一、背景
- 背景:介于目前Flutter的学习进度已经告一段落, 但是如果直接使用flutter重写已有的app是不现实的, 因此需要调研Flutter嵌入原生app项目的技术手段
- 技术定位:中级
- 技术应用场景:Android/iOS 已有原生app
- 整体思路:根据官方文档 Add-to-App 提供的方案: 将Flutter打包成library/module, 然后导入到对应项目中, 当做一个三方库来使用
- 其他: 官方已提供了混合开发的demo, 可以参考一下
二、操作步骤
2.1 开发前的准备工作
准备工作
- 熟悉Flutter基本开发
- 熟悉对应原生平台项目开发及对library/module的操作
2.2 进入开发阶段
2.2.1 导入到Android 项目
Flutter引入Android有两种方式: 作为源代码 Gradle 子项目或 AAR 嵌入。
- 注意Flutter目前支持的架构: Flutter 目前仅支持为 x86_64、armeabi-v7a 和 arm64-v8a 构建提前 (AOT) 编译库, 如果Android项目支持别的可能要去掉
- 要注意flutter支持的gradle版本, 比如
2.2.1.1 利用AS创建或导入flutter模块, 直接依赖源代码
直接在AS中选择File > New > New Module, 就能直接创建或者导入Flutter模块, 然后就可以了(不要太简单)!
2.2.1.2 手动编译引入
手动编译引入有两种方式
2.2.1.2.1 通过命令行完成
- 先在命令行创建Flutter模块(注意包名不要和主项目相同)
flutter create -t module --org com.example test_module |
---|
生成的目录结构如下(注意不需要修改.android文件夹内的内容, 这个是每次运行flutter pub get 就会自动生成的, 修改了也没用)
- 在引入之前注意要在工程的 build.gradle 文件中添加配置
android {
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}
- 开始导入 , 在flutter模块的根目录下运行
flutter build aar |
---|
- 命令会在build文件夹里面生成各种环境的包, 并且此时命令行会提示如何继承进原生工程, 按照提示修改对应文件即可
2.2.1.2.2 项目直接依赖模块源代码
- 前面1-2 步骤还是一样要通过命令行创建模块
- 在主项目的settings.gradle 文件中包含模块代码, 然后同步一下
setBinding(new Binding([gradle: this])) // new
evaluate(new File( // new
settingsDir.parentFile, // new
'../xx/test_module/.android/include_flutter.groovy' // new
)) // new
- 最后在build.gradle中导入flutter模块就完成导入了
dependencies {
implementation project(':flutter')
}
2.2.1.3 两种方式优劣对比
- 第一种方式可以更方便运行时修改问题,但是对主项目“污染”会比较高,同时改动会大一些。
- 第二种方式 需要单独调试后,更新 aar 文件再集成到项目中调试,但是这类集成方式更干净,同时 Flutter 相关代码可独立运行测试,且改动较小。
2.2.1.4 测试代码
直接运行可能会报错, 需要修改android/build.gradle
buildscript {
repositories {
// google()
// jcenter()
maven {
url 'https://maven.aliyun.com/repository/google' }
maven {
url 'https://maven.aliyun.com/repository/jcenter' }
maven {
url 'https://maven.aliyun.com/nexus/content/groups/public' }
}
dependencies {
classpath 'com.android.tools.build:gradle:3.4.0'
}
}
修改settings.gradle
repositoriesMode.set(RepositoriesMode. FAIL_ON_PROJECT_REPOS)
改为
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
编译没有报错之后就可以开始写测试代码
1. AndroidManifest.xml 中添加 activity
<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:theme="@style/Theme.AppCompat.DayNight"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
/>
2. MainActivity中添加展示代码
void showView(){
startActivity(FlutterActivity.createDefaultIntent(this));
}
2.2.1.5 Flutter与Android的通信
Flutter与Android原生交互有专门的通信对象(MethodChannel)
- 想要接收和发送消息, 首先要定义消息通道的唯一id, 并且在两边使用相同的id
//Flutter向Native发消息
private static final String CHANNEL_NATIVE = "com.example.flutter/native";
//Native向Flutter发消息
private static final String CHANNEL_FLUTTER = "com.example.flutter/flutter";
- Android中代码
- 接收消息
MethodChannel nativeChannel = new MethodChannel(flutterEngine.getDartExecutor(), CHANNEL_NATIVE);
nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, MethodChannel.Result result) {
switch (call.method){
case "方法名":
result.success("收到来自Flutter的消息");
break;
default :
result.notImplemented();
break;
}
//通过result告诉flutter处理记过
//result.success / result.notImplemented
}
});
- 发送消息
Map<String, Object> result = new HashMap<>();
result.put("message", @"消息内容"); //参数字段需要统一
MethodChannel flutterChannel = new MethodChannel(flutterEngine.getDartExecutor(), CHANNEL_FLUTTER); // 调用Flutter端定义的方法 flutterChannel.invokeMethod("方法名", result);
- Flutter中代码
- 接收消息
Future<dynamic> handler(MethodCall call) async{
switch (call.method){
case '方法名':
onDataChange(call.arguments['message']);
break;
}
}
flutterChannel.setMethodCallHandler(handler);
- 发送消息
Map<String, dynamic> para = {'message':'传递的参数'}; //参数字段需要统一
final String result = await channel.invokeMethod('方法名',para);
print('这是在flutter中打印的'+ result);
2.2.1.6 通信过程出现的问题
//如果展示FlutterActivity和注册监听flutter消息的时候不是使用同一个引擎缓存可能会导致无法接收flutter消息
//如果要正常接收消息的话
1. 在OnCreate中创建和注册引擎
flutterengine = new FlutterEngine(this);
//预热引擎
flutterengine.getDartExecutor().executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault());
//缓存 FlutterActivity 使用的 FlutterEngine
FlutterEngineCache.getInstance().put("my_engine_id" , flutterengine);
2. 展示flutterActivity时使用缓存的引擎来展示 注意id需要相同
startActivity(FlutterActivity.withCachedEngine("my_engine_id").build(this));
2.2.2 导入到iOS项目
2.2.2.1 创建Flutter模块(这里用test_module做示例)
flutter create --template module test_module |
---|
生成的目录结构如下(注意不需要修改.ios文件夹内的内容, 这个是每次运行flutter pub get 就会自动生成的, 修改了也没用)
添加/编写代码到lib文件夹中, 添加需要依赖的插件到pubspec.yaml中, 然后运行 flutter pub get
2.2.2.2 生成Flutter库并引入到项目中
将Flutter module编译成framework, 引入iOS工程, 有三种方式
- 通过CocoaPods脚本自动引入
- 在iOS工程的profile配置文件中添加
flutter_application_path = '../Module/test_module' #注意这里需要使用相对路径
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
- 然后给工程中每一个需要嵌入framework的target的调用install
install_all_flutter_pods(flutter_application_path)
- 最后在项目目录下执行 pod install 即可完成嵌入(注意: 每次修改了yaml文件之后都需要执行 flutter pub get 和 重新pod install)
- 将Flutter Module编译产物通过本地引入工程
- 在flutter项目根目录下运行命令导出为framework
flutter build ios-framework --output=export/ |
---|
- 得到framework之后 , 就跟本地直接引入framework一样 , 通过在Build Settings > Build Phases > Embed Frameworks中引入, 然后在Framework Search Paths添加$(PROJECT_DIR)/export/Release/
ng)
- 由于导出的framework是区分不同环境的, 所以需要配置修改引入路径为配置
在project.pbxproj中把(每一个framework路径都要改)
path = export/Release/xxx.xcframework;
替换为
path = "export/$(CONFIGURATION)/xxx.xcframework
- 然后把 Framework Search Paths 改为(CONFIGURATION)
- 将编译产物通过CocoaPods引入
- Flutter 项目根目录下运行(多了个cocoapods参数)
flutter build ios-framework --cocoapods --output=export/ |
---|
-
得到的文件夹实际上是多了一个cocoapods的配置文件
这里有两种引入方式选择
本地相对路径引入
把这个库封装成一个pod库, 上传到公开的cocoapods索引库或者自己的私有索引库
这里我们直接用本地化的就好了 直接在podfile文件中加入依赖并在根目录下运行 pod install
- 注意生成的文件夹里面的App.framework + FlutterPuginRegistrant.framwrok + shared_preferences.framework 还是与第二种方式相同的, 需要手动嵌入工程中
pod 'Flutter', :podspec => '../export/Debug/Flutter.podspec' |
---|
2.2.2.3 三种方式的优劣对比
- 第一种方式是官方推荐
- 优点是便于操作, 一步到位, 也比较规范
- 缺点是需要每个开发项目的人都配置flutter环境; 引用的framework会分布在不同的文件夹中, 查看比较繁琐
- 第二种方式 需要手动引入, 而且需要修改配置, 十分麻烦, 一般没人用
- 第三种方式 相对第一种稍微复杂一点, 但是引入之后整个模块都是作为单独一个库, 查看不会很繁琐
2.2.2.4 优化流程
除了官方文档提供的方式, 还有另外一种方式可以引入, 并且可以利用脚本简化流程实现一键引入
- 直接在flutter根目录下运行命令 编程出产物, 实际上也是一堆framework
flutter build ios --${packageType} --no-codesign |
---|
- 命令行用pod命令新建一个pod组件
- 把编程产物收集到pod同一个目录下, 并修改podspec文件
- 然后利用cocoapods的本地引入, 把所有的framework封装为一个pod组件引入项目
只需要提前建好pod组件, 修改好podspec文件以及项目podfile文件, 其余交给脚本就可以了
#前提flutter一定要是app项目: pubspec.yaml里 不要加
#module:
# androidPackage: com.example.myflutter
# iosBundleIdentifier: com.example.myFlutter
packageType='debug'
packageFileName='Debug'
if [ -z $out ]; then
out='ios_frameworks'
fi
echo "准备输出所有文件到目录: $out"
echo "清除所有已编译文件"
find . -d -name build | xargs rm -rf
flutter clean
rm -rf $out
rm -rf build
flutter packages get
addFlag(){
cat .ios/Podfile > tmp1.txt
echo "use_frameworks!" >> tmp2.txt
cat tmp1.txt >> tmp2.txt
cat tmp2.txt > .ios/Podfile
rm tmp1.txt tmp2.txt
}
echo "检查 .ios/Podfile文件状态"
a=$(cat .ios/Podfile)
if [[ $a == use* ]]; then
echo '已经添加use_frameworks, 不再添加'
else
echo '未添加use_frameworks,准备添加'
addFlag
echo "添加use_frameworks 完成"
fi
echo "编译flutter"
flutter build ios --${packageType} --no-codesign
echo "编译flutter完成"
mkdir $out
cp -r build/ios/${packageFileName}-iphoneos/*/*.framework $out
cp -r build/ios/${packageFileName}-iphoneos/App.framework $out
# 这里不能使用build里面的flutter.framework , 里面缺少类
cp -r .ios/Flutter/engine/Flutter.xcframework/ios-arm64_x86_64-simulator/Flutter.framework $out
echo "复制framework库到临时文件夹: $out"
libpath='../flutter_lib/flutter_lib/'
rm -rf "$libpath/ios_frameworks"
mkdir $libpath
cp -r $out $libpath
echo "复制库文件到: $libpath"
2.2.2.5 测试代码
引入库之后, 在iOS中导入头文件 #import <Flutter/Flutter.h> 然后编写跳转页面代码即可展示flutter页面
- (void)showFlutterView{
//初始化FlutterViewController
self.flutterViewController = [[FlutterViewController alloc] init];
//为FlutterViewController指定路由以及路由携带的参数
//设置模态跳转满屏显示
self.flutterViewController.modalPresentationStyle = UIModalPresentationFullScreen;
[self presentViewController:self.flutterViewController animated:YES completion:nil];
}
2.2.2.6 Flutter与iOS的通信
Flutter与iOS原生交互也有专门的通信对象(Platform Channel),它有三种类型:
- MethodChannel:用于最常见的方法传递,帮助Flutter和原生平台互相调用方法, 这次就直接用这个, 其他的后面再补充
- BasicMessageChannel:用于数据信息的传递。
- EventChannel:用于事件监听传递等场景。
- 想要接收和发送消息, 首先要定义消息通道的唯一id, 并且在两边使用相同的id
iOS中定义
//Flutter向Native发消息
static NSString *CHANNEL_NATIVE = @"com.example.flutter/native";
//Native向Flutter发消息
static NSString *CHANNEL_FLUTTER = @"com.example.flutter/flutter";
flutter中定义
static const nativeChannel = const MethodChannel('com.example.flutter/native');
static const flutterChannel = const MethodChannel('com.example.flutter/flutter');
- iOS中代码
- 接收消息
//监听flutter的消息 这里需要绑定对应的flutterViewController中的binaryMessenger
FlutterMethodChannel *messageChannel = [FlutterMethodChannel methodChannelWithName:CHANNEL_NATIVE binaryMessenger:self.flutterViewController.binaryMessenger];
__weak typeof(self) weakSelf = self;
//接受Flutter回调
[messageChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult _Nonnull result) {
__strong __typeof(weakSelf) strongSelf = weakSelf;
if ([call.method isEqualToString:@"方法名"]) {
//flutter 传递的参数 字段需要统一
NSString *message = call.arguments[@"message"];
NSLog(@"原生处理数据");
//告诉Flutter我们的处理结果
if (result) {
result(@"xxxxx");
}
}
}];
- 发送消息
//发送消息给flutter页面
FlutterMethodChannel *messageChannel = [FlutterMethodChannel methodChannelWithName:CHANNEL_FLUTTER binaryMessenger:self.flutterViewController.binaryMessenger];
[messageChannel invokeMethod:@"方法名" arguments:@{@"message" : message}]; //传递的参数字段需要统一
- flutter中代码
- 接收消息
Future<dynamic> handler(MethodCall call) async{
switch (call.method){
case '方法名':
onDataChange(call.arguments['message']);
break;
}
}
flutterChannel.setMethodCallHandler(handler);
- 发送消息
Map<String, dynamic> para = {'message':'flutter 给原生的数据'};
final String result = await channel.invokeMethod('方法名',para);
print('原生返回的数据 ' + result);
2.2.4 Debug和热更新
flutter页面要进行热更新需要利用flutter attach , 它可以在任意途径启动(在app启动前启动后都可以)
- 在命令行执行
flutter attach 或者 flutter attach -d deviceId |
---|
- 在Android Studio中直接点击 flutter attach 按钮
-
还有VS Code , 方法也差不多
2.2.5 原生页面嵌入Flutter
前面使用的测试代码都是将flutter作为一整个页面引入, 而不是作为原生页面其中的某个视图, 这一节探索如何将flutter作为一个视图引入到原生页面中
2.2.5.1 Flutter在iOS中引入的方式都是通过FlutterViewController的方式, 如果要作为一个子View使用, 需要通过一些处理
- 首先FlutterViewController的初始化不能直接使用[[FlutterViewController alloc] init], 这种方式创建出来的实例可能会共享内存, 并非不同的实例
- 将Controller的View加载出来
flutterViewController.modalPresentationStyle = UIModalPresentationOverCurrentContext;
//利用presentViewController 展示控制器但是立即dismiss, 只是为了让View加载出来, 这样就可以取出view加载到当前的View上
[self presentViewController:flutterViewController animated:NO completion:^{
[self dismissViewControllerAnimated:NO completion:^{
flutterViewController.view.frame = CGRectMake(50, 50, self.view.frame.size.width * 0.5, self.view.frame.size.height * 0.5);
flutterViewController.view.backgroundColor = [UIColor whiteColor];
[self.view addSubview:flutterViewController.view];
[self addChildViewController:flutterViewController];
[self.view bringSubviewToFront:flutterViewController.view];
}];
}];
- 特别需要注意的是 当前页面销毁的时候, 需要把使用的FlutterView相关资源一并销毁防止内存泄漏
//iOS监听flutter的通道
[evenChannal setStreamHandler:nil];
evenChannal = nil;
//iOS
[messageChannel setMethodCallHandler:nil];
messageChannel = nil;
//使用initWithProject创建出来的FlutterViewController每个实例自带一个engine
//销毁控制器的engine对象
[flutterViewController.engine destroyContext];
2.3 加载顺序、性能和内存
2.3.1 加载步骤
- 构建FlutterEngine , 在.apk/.ipa/.app中加载资源(图片、字体等)
- 加载 Flutter 库 , 引擎的共享库加载一次内存(共享的库, 多个进程也只会加载一次)
- Dart运行时机制管理dart代码的内存和并发性(每个应用程序都会存在一个Dart运行时 , 而且不会关闭)
- 在 Android 上第一次构建 FlutterEngine 和在 iOS 上第一次运行 Dart 入口点时,会完成一次 Dart VM 启动。
- Dart代码的快照会从程序文件加载到内存中, 这里会涉及到dart的JIT特性
- Dart运行时初始化后, 由Flutter引擎对管理dart运行时, 创建和运行Dart Isolate
- 将 UI 附加到 Flutter 引擎, 此时Flutter生成layer树会被转化为OpenGL(或者类似的绘图)指令
2.3.2 占用内存和延迟
Flutter的启动延迟还算是比较低的, 如果可以提前启动FlutterEngine(预热引擎), 还能再优化点
- 在 Android 上预热需要 42 MB 和 1530 毫秒。其中 330 毫秒是主线程上的阻塞调用。
- 在 iOS 上预热需要 22 MB 和 860 毫秒。其中 260 毫秒是主线程上的阻塞调用。
2.4 存在的问题
需要注意的是,与纯 Flutter 应用不同,原生应用混编 Flutter 由于涉及到原生页面与 Flutter 页面之间切换,因此导航栈内可能会出现多个 Flutter 容器的情况,即多个 Flutter 实例。Flutter 实例的初始化成本非常高昂,每启动一个 Flutter 实例,就会创建一套新的渲染机制,即 Flutter Engine,以及底层的 Isolate。而这些实例之间的内存是不互相共享的,会带来较大的系统资源消耗。
为了解决混编工程中 Flutter 多实例的问题,业界有两种解决方案:
- 以今日头条为代表的修改 Flutter Engine 源码,使多 FlutterView 实例对应的多 Flutter Engine 能够在底层共享 Isolate;
- 以闲鱼为代表的共享 FlutterView,即由原生层驱动 Flutter 层渲染内容的方案。
不过,目前这两种解决方案都不够完美。所以,在 Flutter 官方支持多实例单引擎之前,应该尽量使用Flutter去开发一些闭环业务,减少原生页面与Flutter页面之间的交互,尽量避免Flutter页面跳转到原生页面,原生页面又启动一个新的Flutter实例的情况,并且保证应用内不要出现多个 Flutter 容器实例的情况。