Flutter与原生混合开发

一、背景

  • 背景:介于目前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 嵌入。

  1. 注意Flutter目前支持的架构: Flutter 目前仅支持为 x86_64、armeabi-v7a 和 arm64-v8a 构建提前 (AOT) 编译库, 如果Android项目支持别的可能要去掉
  2. 要注意flutter支持的gradle版本, 比如

2.2.1.1 利用AS创建或导入flutter模块, 直接依赖源代码

直接在AS中选择File > New > New Module, 就能直接创建或者导入Flutter模块, 然后就可以了(不要太简单)!

Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.002.png

2.2.1.2 手动编译引入

手动编译引入有两种方式

2.2.1.2.1 通过命令行完成

  1. 先在命令行创建Flutter模块(注意包名不要和主项目相同)
flutter create -t module --org com.example test_module

生成的目录结构如下(注意不需要修改.android文件夹内的内容, 这个是每次运行flutter pub get 就会自动生成的, 修改了也没用)

Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.003.png
  1. 在引入之前注意要在工程的 build.gradle 文件中添加配置
android {
  compileOptions {
    sourceCompatibility 1.8
    targetCompatibility 1.8
  }
}
  1. 开始导入 , 在flutter模块的根目录下运行
flutter build aar
  1. 命令会在build文件夹里面生成各种环境的包, 并且此时命令行会提示如何继承进原生工程, 按照提示修改对应文件即可
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.004.png
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.005.png

2.2.1.2.2 项目直接依赖模块源代码

  1. 前面1-2 步骤还是一样要通过命令行创建模块
  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
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.006.png
  1. 最后在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 

   
2. MainActivity中添加展示代码
void showView(){
    startActivity(FlutterActivity.createDefaultIntent(this));
}

2.2.1.5 Flutter与Android的通信

Flutter与Android原生交互有专门的通信对象(MethodChannel

  1. 想要接收和发送消息, 首先要定义消息通道的唯一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";
  1. Android中代码
  2. 接收消息
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
       } 
});
  1. 发送消息
Map result = new HashMap<>(); 
result.put("message", @"消息内容"); //参数字段需要统一
MethodChannel flutterChannel = new MethodChannel(flutterEngine.getDartExecutor(), CHANNEL_FLUTTER); // 调用Flutter端定义的方法 flutterChannel.invokeMethod("方法名", result);
  1. Flutter中代码
  2. 接收消息
Future handler(MethodCall call) async{
  switch (call.method){
    case '方法名':
      onDataChange(call.arguments['message']);
      break;
  }
}

flutterChannel.setMethodCallHandler(handler);
  1. 发送消息
 Map 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 就会自动生成的, 修改了也没用)

Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.007.png

添加/编写代码到lib文件夹中, 添加需要依赖的插件到pubspec.yaml中, 然后运行 flutter pub get

2.2.2.2 生成Flutter库并引入到项目中

将Flutter module编译成framework, 引入iOS工程, 有三种方式

  1. 通过CocoaPods脚本自动引入
  2. 在iOS工程的profile配置文件中添加
flutter_application_path = '../Module/test_module' #注意这里需要使用相对路径
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.008.png
  1. 然后给工程中每一个需要嵌入framework的target的调用install

install_all_flutter_pods(flutter_application_path)

  1. 最后在项目目录下执行 pod install 即可完成嵌入(注意: 每次修改了yaml文件之后都需要执行 flutter pub get 和 重新pod install)
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.009.png
  1. 将Flutter Module编译产物通过本地引入工程
  2. 在flutter项目根目录下运行命令导出为framework
flutter build ios-framework --output=export/
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.010.png
  1. 得到framework之后 , 就跟本地直接引入framework一样 , 通过在Build Settings > Build Phases > Embed Frameworks中引入, 然后在Framework Search Paths添加$(PROJECT_DIR)/export/Release/
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.011.png

ng)

  1. 由于导出的framework是区分不同环境的, 所以需要配置修改引入路径为配置
在project.pbxproj中把(每一个framework路径都要改)
path = export/Release/xxx.xcframework;
替换为
path = "export/$(CONFIGURATION)/xxx.xcframework
  1. 然后把 Framework Search Paths 改为(CONFIGURATION)
  2. 将编译产物通过CocoaPods引入
  3. Flutter 项目根目录下运行(多了个cocoapods参数)
flutter build ios-framework --cocoapods --output=export/
  1. 得到的文件夹实际上是多了一个cocoapods的配置文件


    Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.012.png
  2. 这里有两种引入方式选择

  3. 本地相对路径引入

  4. 把这个库封装成一个pod库, 上传到公开的cocoapods索引库或者自己的私有索引库

这里我们直接用本地化的就好了 直接在podfile文件中加入依赖并在根目录下运行 pod install

  1. 注意生成的文件夹里面的App.framework + FlutterPuginRegistrant.framwrok + shared_preferences.framework 还是与第二种方式相同的, 需要手动嵌入工程中
pod 'Flutter', :podspec => '../export/Debug/Flutter.podspec'
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.013.png

2.2.2.3 三种方式的优劣对比

  • 第一种方式是官方推荐
  • 优点是便于操作, 一步到位, 也比较规范
  • 缺点是需要每个开发项目的人都配置flutter环境; 引用的framework会分布在不同的文件夹中, 查看比较繁琐
  • 第二种方式 需要手动引入, 而且需要修改配置, 十分麻烦, 一般没人用
  • 第三种方式 相对第一种稍微复杂一点, 但是引入之后整个模块都是作为单独一个库, 查看不会很繁琐

2.2.2.4 优化流程

除了官方文档提供的方式, 还有另外一种方式可以引入, 并且可以利用脚本简化流程实现一键引入

  1. 直接在flutter根目录下运行命令 编程出产物, 实际上也是一堆framework
flutter build ios --${packageType} --no-codesign
  1. 命令行用pod命令新建一个pod组件
  2. 把编程产物收集到pod同一个目录下, 并修改podspec文件
  3. 然后利用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页面

- (void)showFlutterView{
  //初始化FlutterViewController
  self.flutterViewController = [[FlutterViewController alloc] init];
  //为FlutterViewController指定路由以及路由携带的参数
  //设置模态跳转满屏显示
  self.flutterViewController.modalPresentationStyle = UIModalPresentationFullScreen;
  [self presentViewController:self.flutterViewController animated:YES completion:nil];
}
Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.014.png

2.2.2.6 Flutter与iOS的通信

FlutteriOS原生交互也有专门的通信对象(Platform Channel),它有三种类型:

  • MethodChannel:用于最常见的方法传递,帮助Flutter和原生平台互相调用方法, 这次就直接用这个, 其他的后面再补充
  • BasicMessageChannel:用于数据信息的传递。
  • EventChannel:用于事件监听传递等场景
  1. 想要接收和发送消息, 首先要定义消息通道的唯一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');
  1. iOS中代码
  2. 接收消息
  //监听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");
      }
    }
  }];
  1. 发送消息
//发送消息给flutter页面
  FlutterMethodChannel *messageChannel = [FlutterMethodChannel methodChannelWithName:CHANNEL_FLUTTER binaryMessenger:self.flutterViewController.binaryMessenger];
  [messageChannel invokeMethod:@"方法名" arguments:@{@"message" : message}]; //传递的参数字段需要统一
  1. flutter中代码
  2. 接收消息
Future handler(MethodCall call) async{
  switch (call.method){
    case '方法名':
      onDataChange(call.arguments['message']);
      break;
  }
}

flutterChannel.setMethodCallHandler(handler);
  1. 发送消息
Map 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 , 方法也差不多


    Aspose.Words.47babac3-0166-4cc3-9d57-f88474c3fb9b.015.png

2.2.5 原生页面嵌入Flutter

前面使用的测试代码都是将flutter作为一整个页面引入, 而不是作为原生页面其中的某个视图, 这一节探索如何将flutter作为一个视图引入到原生页面中

2.2.5.1 Flutter在iOS中引入的方式都是通过FlutterViewController的方式, 如果要作为一个子View使用, 需要通过一些处理

  1. 首先FlutterViewController的初始化不能直接使用[[FlutterViewController alloc] init], 这种方式创建出来的实例可能会共享内存, 并非不同的实例
  2. 将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];
    }];
  }];
  1. 特别需要注意的是 当前页面销毁的时候, 需要把使用的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 加载步骤

  1. 构建FlutterEngine , 在.apk/.ipa/.app中加载资源(图片、字体等)
  2. 加载 Flutter 库 , 引擎的共享库加载一次内存(共享的库, 多个进程也只会加载一次)
  3. Dart运行时机制管理dart代码的内存和并发性(每个应用程序都会存在一个Dart运行时 , 而且不会关闭)
  4. 在 Android 上第一次构建 FlutterEngine 和在 iOS 上第一次运行 Dart 入口点时,会完成一次 Dart VM 启动。
  5. Dart代码的快照会从程序文件加载到内存中, 这里会涉及到dart的JIT特性
  6. Dart运行时初始化后, 由Flutter引擎对管理dart运行时, 创建和运行Dart Isolate
  7. 将 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 容器实例的情况。

你可能感兴趣的:(Flutter与原生混合开发)