Flutter整合舊專案並重構帖子詳情頁

沉默王貳發表於2021-04-01

最近一直在做公司新專案的Flutter工作,主要負責部分Flutter頁面的編寫以及與原生Android的橋接。主要的整合工作由於人員緊張,交給平臺組同學來做 。 公司平臺組提供了一整套的整合工具鏈, 開發工具, MVVM結構等一系列輪子,開箱即用。時間長了, 只停留在使用層面,很少深究,還是需要自己多看看。

這次為舊專案整合Flutter, 並使用Flutter重寫帖子詳情頁。 來體會官方提供的, 混合模式的搭建以及開發。

本次需要重寫的舊原生頁面為:

device-2021-03-31-171416.png

重寫之後的Flutter頁面為:

詳情頁.png

專案地址:github.com/stevenwsg/X…

好了, 話不多說了, 讓我們開始吧。

1、舊專案整合Flutter

1.1 Flutter混合開發模式

Flutter混合開發模式一般有兩種方式:

1、將原生專案作為Flutter專案的子專案, Flutter預設戶建立Android和iOS的工程目錄, 可以在該目錄下進行原生客戶端開發;

Flutter Application 專案結構

2、建立Flutter Module 作為依賴項,新增到現有的原生專案中。

第二種方式相對第一種方式更解耦, 尤其是針對現有專案改造成本更小。

1.2 Flutter Module的建立方式

使用 As 建立 Flutter Module

在 As 中選擇 File->New->New Flutter Project,選擇 Flutter Module 建立 Flutter Module 子專案,如下:

建立Flutter Module

1.3 新增Flutter的兩種方式

將Flutter新增到原生工程中, 有兩種方式

  1. 以aar的方式整合到現有Android專案中
  2. 以 Flutet module 的方式整合到現有 Android 專案中

在日常的開發過程中, 都是以第二種方式, 將Flutter Module整合到現有Android專案中,進行混合編譯,之後便可以使用Flutter 的熱更新。

在Jenkins自動化打包時,採用第一種方式, 先將Flutter工程打成aar產物, 結合生成 的aar產物進行編譯Android apk檔案。

以 Flutet module 的方式整合到現有 Android 專案中:

在 setting.gradle 檔案中配置 flutter module 如下:

include ':app', ':easeui'


// 以下是新增
setBinding(new Binding([gradle: this]))
evaluate(new File(
        settingsDir,
        '../flutter_bbs/.android/include_flutter.groovy'
))
複製程式碼

然後在 build.gradle 檔案中新增 flutter module 的依賴,如下:

dependencies {
  implementation project(':flutter')
}
複製程式碼

build完成後, 專案已經變成了原生專案和Flutter專案的混合編譯, 此時的專案結構已經變為混合編譯的專案結構: 混合編譯的專案結構

1.4 新增單個頁面

此時實現原生介面到Flutter介面的跳轉

修改Flutter入口檔案

import 'package:flutter/material.dart';
import 'package:flutter_bbs/post_deatil/view/post_deatil_page.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: PostDetailPage(),
    );
  }
}

複製程式碼

此時展示Flutter版的社群詳情頁

import 'package:flutter/material.dart';

class PostDetailPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return PostDetailState();
  }
}

class PostDetailState extends State {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("帖子詳情"),
      ),
      body: Center(
        child: Text(
          "帖子詳情",
          style: TextStyle(fontSize: 20, color: Colors.blueAccent),
        ),
      ),
    );
  }
}

複製程式碼

在原生工程中建立一個 Activity 繼承 FlutterActivity 並在 AndroidManifest.xml 檔案中宣告:

class MomentDetailActivity : FlutterActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}
複製程式碼
        <activity
            android:name=".flutter.MomentDetailActivity"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
        </activity>
複製程式碼

如何啟動這個Activity那?

startActivity(new Intent(getActivity(), MomentDetailActivity.class));
複製程式碼

實現效果為:

帖子詳情.png

2、重寫帖子詳情頁

此次使用Flutter重寫的頁面為帖子詳情頁 device-2021-03-31-171416.png

可以看出, 整個頁面可以用一個ListView搞定, ListView包含多種型別。帖子詳情, 分割線, 評論, 評論空態等

2.1 整合Bmob Flutter 倉庫

由於原專案使用的是Bmob雲提供資料服務, 所以在Flutter專案中也需要整合Bmob倉庫,實現資料訪問, 接入地址

在Flutter工程的pubspec.yaml檔案中增加依賴

dependencies:
  data_plugin: ^0.0.16
複製程式碼

在終端輸入以下命令進行安裝:

 flutter packages get
複製程式碼

在runApp中進行一下初始化操作:

/**
 * 非加密方式初始化
 */
Bmob.init("https://api2.bmob.cn", "appId", "apiKey");
複製程式碼

2.2 原生工程頁面向Flutter頁面傳遞帖子Id

原生工程中,將跳轉Flutter頁面的方式改為:

            val intent = Intent(context, MomentDetailActivity::class.java)
            intent.action = Intent.ACTION_RUN
            intent.putExtra(
                "route",
                "moment?noteId = ${note.objectId}"
            )
            context?.startActivity(intent)
複製程式碼

Flutter工程中, 接收傳遞過來的引數:

    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        routes: {
          Routes.MOMENT: (BuildContext context) => PostDetailPage(null),
        },
        onGenerateRoute: (settings) {
          Uri uri = Uri.parse(settings.name);
          Map<String, String> params = uri.queryParameters;
          return MaterialPageRoute(
              builder: (context) => PostDetailPage(params));
        });
複製程式碼

此時Flutter的帖子詳情頁可以拿到了帖子的Id

螢幕快照 2021-03-30 下午2.37.11.png

2.3 Flutter 根據帖子Id獲取帖子資訊

2.3.1 資料拉取

新建網路資訊類

import 'package:data_plugin/bmob/bmob_query.dart';
import 'package:data_plugin/utils/dialog_util.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bbs/post_deatil/model/bean/note.dart';

class NetWorkRepo {
  static Note getNoteInfo(BuildContext context, String noteId) {
    BmobQuery<Note> query = BmobQuery();
    query
        .queryObject(noteId)
        .then((value) => {showSuccess(context, value.toString())});
  }
}
複製程式碼

在PostDetailPage 初始化時進行拉取

class PostDetailState extends State<PostDetailPage> {
  String _noteId;

  @override
  void initState() {
    super.initState();
    _noteId = widget._map["noteId"] as String;
    _initData();
  }

  void _initData() { // 拉取帖子資訊
    NetWorkRepo.getNoteInfo(context, _noteId);
  }
  
  ...
  }
複製程式碼

拉取結果為

資料拉取.png

2.3.2 Json解析

對Json資料 進行反序列化為bean實體。

這裡使用Json2Dart外掛(個人認為json_serializable庫比較難使用, 坑也比較多)

image

image

生成的程式碼為:


import 'package:data_plugin/bmob/table/bmob_object.dart';

class Note extends BmobObject {
  String content;
  String createdAt;
  String objectId;
  int replaycount;
  String title;
  int top;
  String typeid;
  String updatedAt;
  String userid;
  int zancount;

  Note.fromJsonMap(Map<String, dynamic> map)
      : content = map["content"],
        createdAt = map["createdAt"],
        objectId = map["objectId"],
        replaycount = map["replaycount"],
        title = map["title"],
        top = map["top"],
        typeid = map["typeid"],
        updatedAt = map["updatedAt"],
        userid = map["userid"],
        zancount = map["zancount"];

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data['content'] = content;
    data['createdAt'] = createdAt;
    data['objectId'] = objectId;
    data['replaycount'] = replaycount;
    data['title'] = title;
    data['top'] = top;
    data['typeid'] = typeid;
    data['updatedAt'] = updatedAt;
    data['userid'] = userid;
    data['zancount'] = zancount;
    return data;
  }

  @override
  Map getParams() {
    toJson();
  }

  @override
  String toString() {
    return 'Note{content: $content, createdAt: $createdAt, objectId: $objectId, replaycount: $replaycount, title: $title, top: $top, typeid: $typeid, updatedAt: $updatedAt, userid: $userid, zancount: $zancount}';
  }
}


複製程式碼

此時已將Json資料轉換為了Bean實體:

bean實體.png

2.3.3 UI展示

接下來將帖子實體展示在UI上

修改post_deatil_page.dart, 整個頁面只顯示一個ListView, Item根據資料型別決定

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("帖子詳情"),
        ),
        body: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            return _buildListViewCell(
                items[index]); //根據資料去構造不同的widget填充到ListView中
          },
        ));
  }

  Widget _buildListViewCell(Object object) {
    if (object is Note) {  // 如果資料型別是帖子型別
      return MomentDetailWidget(object); // 返回帖子詳細資訊Widget
    }
  }
複製程式碼

帖子的詳細資訊MomentDetailWidget

import 'package:flutter/material.dart';
import 'package:flutter_bbs/post_deatil/model/bean/note.dart';

class MomentDetailWidget extends StatelessWidget {
  final Note note;

  MomentDetailWidget(this.note);

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.only(left: 20, top: 10, bottom: 10, right: 20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          _buildHeaderWidget(),
          _buildContentWidget(),
          _buildIconWidget(),
          _buildReplayWidget(),
        ],
      ),
    );
  }

  Widget _buildHeaderWidget() {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Container(
          margin: EdgeInsets.only(right: 10),
          child: ClipOval(
            child: Image.asset(
              "images/logo.webp",
              width: 80,
              height: 80,
            ),
          ),
        ),
        Expanded(
            child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              note.title ?? "",
              style: TextStyle(color: Colors.black54, fontSize: 20),
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
            ),
            Container(
              margin: EdgeInsets.only(top: 20),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    note.typeid ?? "",
                    style: TextStyle(color: Colors.black45, fontSize: 16),
                  ),
                  Expanded(child: Container()),
                  Text(
                    note.updatedAt?.substring(0, 10) ?? "",
                    style: TextStyle(color: Colors.black45, fontSize: 16),
                  )
                ],
              ),
            ),
          ],
        )),
      ],
    );
  }

  Widget _buildContentWidget() {
    return Container(
      margin: EdgeInsets.only(top: 10),
      child: Expanded(
        child: Text(
          note.content ?? "",
          style: TextStyle(color: Colors.black54, fontSize: 16),
        ),
      ),
    );
  }

  Widget _buildIconWidget() {
    return Container(
      margin: EdgeInsets.only(top: 20),
      child: Flex(
        direction: Axis.horizontal,
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Row(
            children: [
              Image.asset(
                "images/zan.webp",
                width: 20,
                height: 20,
              ),
              Container(
                margin: EdgeInsets.only(left: 5),
                child: Text(
                  note.zancount?.toString() ?? "",
                  style: TextStyle(fontSize: 14),
                ),
              )
            ],
          ),
          Row(
            children: [
              Image.asset(
                "images/replay.webp",
                width: 20,
                height: 20,
              ),
              Container(
                margin: EdgeInsets.only(left: 5),
                child: Text(
                  note.replaycount?.toString() ?? "",
                  style: TextStyle(fontSize: 14),
                ),
              )
            ],
          )
        ],
      ),
    );
  }

  Widget _buildReplayWidget() {
    return Container(
      margin: EdgeInsets.only(
        top: 20,
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
              child: TextField(
            decoration: InputDecoration(
              hintText: ' 開始你的評論吧',
              hintStyle: TextStyle(fontFamily: 'MaterialIcons', fontSize: 16),
              contentPadding: EdgeInsets.only(top: 8, bottom: 8),
              border: OutlineInputBorder(
                borderSide: BorderSide.none,
                borderRadius: BorderRadius.all(Radius.circular(5)),
              ),
              filled: true,
            ),
          )),
          Container(
            margin: EdgeInsets.only(left: 20),
            child: OutlinedButton(
              onPressed: () {},
              child: Text("評論"),
            ),
          )
        ],
      ),
    );
  }
}

複製程式碼

實現結果為:

詳情.png

2.4 Flutter根據帖子Id獲取評論資訊

2.4.1 資料獲取

  // 根據帖子Id拉取評論資訊
  static List<Comment> getCommentInfo(BuildContext context, String noteId) {
    BmobQuery<Comment> query = BmobQuery();
    query.addWhereEqualTo("noteid", noteId);
    query.queryObjects().then((value) {
      List<Comment> list = List();
      value.forEach((element) {
        list.add(Comment.fromJsonMap(element));
      });
      print(list.toString());
    }).catchError((e) {
      showError(context, BmobError.convert(e).error);
    });
  }
複製程式碼

請求結果為: comment請求結果.png

2.4.2 Json解析

import 'package:data_plugin/bmob/table/bmob_object.dart';

class Comment extends BmobObject {
  String content;
  String createdAt;
  String noteid;
  String objectId;
  String updatedAt;
  String userid;
  String username;

  Comment.fromJsonMap(Map<String, dynamic> map)
      : content = map["content"],
        createdAt = map["createdAt"],
        noteid = map["noteid"],
        objectId = map["objectId"],
        updatedAt = map["updatedAt"],
        userid = map["userid"],
        username = map["username"];

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> data = new Map<String, dynamic>();
    data['content'] = content;
    data['createdAt'] = createdAt;
    data['noteid'] = noteid;
    data['objectId'] = objectId;
    data['updatedAt'] = updatedAt;
    data['userid'] = userid;
    data['username'] = username;
    return data;
  }

  @override
  Map getParams() {
    toJson();
  }

  @override
  String toString() {
    return 'Comment{content: $content, createdAt: $createdAt, noteid: $noteid, objectId: $objectId, updatedAt: $updatedAt, userid: $userid, username: $username}';
  }
}

複製程式碼

解析結果為: 螢幕快照 2021-03-31 下午8.57.15.png

2.4.3 UI展示

詳情頁的ListView改造為多型別ListView, 可以展示帖子, 分割線, 評論等內容

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          leading: BackButton(onPressed: () {}),
          title: Text("帖子詳情"),
          centerTitle: true,
        ),
        body: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) {
            return _buildListViewCell(
                items[index]); //根據資料去構造不同的widget填充到ListView中
          },
        ));
  }

  Widget _buildListViewCell(Object object) {
    if (object is Note) { 
      return MomentDetailWidget(object); // 帖子資訊
    } else if (object is Comment) {
      return CommentDetailWidget(object); // 評論資訊
    } else if (object is DividerBean) {
      return DividerWidget(); // 分割線資訊
    } else if (object is CommentEmptyBean) {
      return CommentEmptyWidget(); // 評論為空時的UI
    } else if (object is CommentTitleBean) {
      return CommentTitleWidget(object.commentNum); //  評論數量
    } else {
      return Container(); // 不識別的資料型別, 返回空Container
    }
  }
複製程式碼

評論Widget

import 'package:flutter/material.dart';
import 'package:flutter_bbs/post_deatil/model/bean/comment.dart';

class CommentDetailWidget extends StatelessWidget {
  final Comment comment;

  CommentDetailWidget(this.comment);

  @override
  Widget build(BuildContext context) {
    return Container(
        margin: EdgeInsets.only(left: 20, top: 8, bottom: 8, right: 20),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            Text(
              comment.username + " : ",
              style: TextStyle(fontSize: 16, color: Colors.blue),
            ),
            Expanded(
                child: Text(
              comment.content,
              style: TextStyle(fontSize: 16),
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
            )),
          ],
        ));
  }
}

複製程式碼

此時呈現的效果為: 詳情頁.png

2.5 發表評論

由於發表評論的資料結構需要填充自己的userId和userName, 所以先實現Flutter從原生獲取使用者自己的uid和userName資訊。 1617273319196.jpg

2.5.1 使用MethodChannel 從原生獲取使用者的uid

Flutter部分

import 'package:flutter/services.dart';

class MomentBridge {
  static const String BRIDGE_NAME = "flutter.bbs/moment";

  static const String METHOD_GET_USER_INFO = "getUserInfo";
  static const String KEY_USER_ID = "key_user_id";
  static const String KEY_USER_NAME = "key_user_name";

  static const _methodChannel = const MethodChannel(BRIDGE_NAME);

  static Future<Map> getUserInfo() async {
    try {
      Map res = await _methodChannel.invokeMethod(METHOD_GET_USER_INFO);
      print("getUserInfo suc" + res.toString());
      return res;
    } catch (e) {
      print("getUserInfo error" + e.toString());
    }
    return Map();
  }
}

複製程式碼

Android 原生部分:

package com.wsg.xsybbs.flutter

import android.os.Bundle
import cn.bmob.v3.BmobUser
import com.wsg.xsybbs.bean.User
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant

/**
 * Create by wangshengguo on 2021/3/25.
 */
class MomentDetailActivity : FlutterActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
//        flutterEngine.let {
//            GeneratedPluginRegistrant.registerWith(it)
//        }

        // 註冊MethodChannel
        MethodChannel(
            flutterEngine.dartExecutor,
            MomentBridge.BRIDGE_NAME
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                MomentBridge.METHOD_GET_USER_INFO -> {
                    val user = BmobUser.getCurrentUser(User::class.java)

                    val map: HashMap<String, String> = hashMapOf()
                    map[MomentBridge.KEY_USER_ID] = user.objectId
                    map[MomentBridge.KEY_USER_NAME] = user.username
                    result.success(map)
                }
                else -> {

                }
            }
        }
    }
}
複製程式碼

發起呼叫後, 顯示結果 為 1617273648441.jpg

2.5.2 發表評論

  // 發表評論
  static void addComment(BuildContext context, String noteId, String content,
      Function(Comment comment) update) async {
    Comment comment = Comment();
    comment.noteid = noteId;
    comment.content = content;

    Map map = await MomentBridge.getUserInfo();
    comment.userid = map[MomentBridge.KEY_USER_ID];
    comment.username = map[MomentBridge.KEY_USER_NAME];

    comment.save().then((value) {
      Toast.show("評論發表成功", context);
      update(comment);
    }).catchError((e) {
      showError(context, BmobError.convert(e).error);
    });
  }
複製程式碼

2.5.3 重新整理UI

評論發表成功後, 將評論插到評論列表最後一項

      return MomentDetailWidget(object, (String content) {
        NetWorkRepo.addComment(context, _noteId, content, (comment) {
          setState(() {
            if (items[items.length - 1] is CommentEmptyBean) { // 如果評論列表為空, 移除評論為空時的UI。將評論 插入資料集合展示
              items.removeAt(items.length - 1);
              items.add(comment);
            } else { // 直接插入
              items.add(comment);
            }
          });
        });
      });
複製程式碼

後續有空的話,會繼續完善點贊相關的功能, 並使用MVVM 對頁面進行重寫。

3、專案地址

專案地址為: github.com/stevenwsg/X…

4、參考

Flutter系列之混合開發Android篇

Native 跳轉 Flutter 傳遞引數

bmob-flutter-sdk

Dart(*)JSON序列化

toast

相關文章