最近一直在做公司新项目的Flutter工作,主要负责部分Flutter页面的编写以及与原生Android的桥接。主要的集成工作由于人员紧张,交给平台组同学来做 。 公司平台组提供了一整套的集成工具链, 开发工具, MVVM结构等一系列轮子,开箱即用。时间长了, 只停留在使用层面,很少深究,还是需要自己多看看。
这次为旧项目集成Flutter, 并使用Flutter重写帖子详情页。 来体会官方提供的, 混合模式的搭建以及开发。
本次需要重写的旧原生页面为:
重写之后的Flutter页面为:
项目地址:https://github.com/stevenwsg/XSYBBS
好了, 话不多说了, 让我们开始吧。
Flutter混合开发模式一般有两种方式:
1、将原生项目作为Flutter项目的子项目, Flutter默认户创建Android和iOS的工程目录, 可以在该目录下进行原生客户端开发;
2、创建Flutter Module 作为依赖项,添加到现有的原生项目中。
第二种方式相对第一种方式更解耦, 尤其是针对现有项目改造成本更小。
使用 As 创建 Flutter Module
在 As 中选择 File->New->New Flutter Project,选择 Flutter Module 创建 Flutter Module 子项目,如下:
将Flutter添加到原生工程中, 有两种方式
在日常的开发过程中, 都是以第二种方式, 将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项目的混合编译, 此时的项目结构已经变为混合编译的项目结构:
此时实现原生界面到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));
实现效果为:
此次使用Flutter重写的页面为帖子详情页
可以看出, 整个页面可以用一个ListView搞定, ListView包含多种类型。帖子详情, 分割线, 评论, 评论空态等
由于原项目使用的是Bmob云提供数据服务, 所以在Flutter项目中也需要集成Bmob仓库,实现数据访问, 接入地址
在Flutter工程的pubspec.yaml文件中增加依赖
dependencies: data_plugin: ^0.0.16
在终端输入以下命令进行安装:
flutter packages get
在runApp中进行一下初始化操作:
/** * 非加密方式初始化 */ Bmob.init("https://api2.bmob.cn", "appId", "apiKey");
原生工程中,将跳转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
新建网络信息类
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); } ... }
拉取结果为
对Json数据 进行反序列化为bean实体。
这里使用Json2Dart插件(个人认为json_serializable库比较难使用, 坑也比较多)
生成的代码为:
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实体:
接下来将帖子实体展示在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("评论"), ), ) ], ), ); } }
实现结果为:
// 根据帖子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); }); }
请求结果为:
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}'; } }
解析结果为:
详情页的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, )), ], )); } }
此时呈现的效果为:
由于发表评论的数据结构需要填充自己的userId和userName, 所以先实现Flutter从原生获取用户自己的uid和userName信息。
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 -> { } } } } }
发起调用后, 显示结果 为
// 发表评论 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); }); }
评论发表成功后, 将评论插到评论列表最后一项
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 对页面进行重写。
项目地址为: github.com/stevenwsg/XSYBBS