前言
为什么会有Flutter混编方案?其实这是一个很现实的问题。比如我们想要新写一个App,直接选用Flutter作为移动端开发的跨平台方案是非常好的一个选择。但是现实中是我们的App可能是已经开发了很多年的一个巨型工程,完全放弃原有的代码而使用Flutter重写App是不现实的。
开发过程中,我们最想要的是原生代码开发和Flutter共存:既不影响项目工程的原生开发,又能使用Flutter去统一iOS/Android技术栈。
混编方案
一般来说混编方案有以下两种:
- 统一管理方案:将iOS工程和Android工程作为Flutter工程的子工程,由Flutter统一管理。
- 三端分离方案:iOS工程、Android工程、Flutter工程是三个单独的项目工程,将Flutter工程的编译产物作为iOS工程和Android工程的依赖模块,原有工程的管理模式不变,对原生工程没有侵入性,无需额外配置工作。
1. 统一管理方案
统一管理方案是只有一个项目工程,这样的好处是代码集中,可以很方便的进行项目开发,每个开发同学都可以进行iOS、Android和Flutter的开发。当然缺点也非常明显:
- 对原有项目的侵入性太大,项目对外部环境的依赖程度增加。
- 每个人本地都要装有自己端的开发环境(iOS/Android)和Flutter的开发环境,并且Flutter SDK版本要保持一致。
- 耦合度会越来越高。当项目越来越复杂后,整个项目的代码耦合度会越来越高,相关工具链耗时也会越来越长,导致开发效率降低。
2. 三端分离方案
三端分离方案是iOS、Android和Flutter分别作为三个独立项目存在,在远端各自有各自的代码仓库。这种方案需要单独创建Flutter项目,然后通过iOS(CocoaPods)和安卓的依赖管理工具将Flutter项目build出来的framework、资源包等放入Native工程以供使用。这种方式可以将iOS、Android和Flutter项目放在一个目录下面作为一个项目来管理,也可以不在同一目录下,关键是设置Flutter模块依赖时相对路径一定要设置正确,如下:
some/path/
demo_android/
demo_ios/
demo_flutter/
以iOS端为例。
2.1 创建Flutter工程
要将Flutter集成到现有项目中,首先创建Flutter模块,命令行运行:
cd yourproject/path/
flutter create --template module my_flutter
执行完后,在yourproject/path/my_flutter
中生成了Flutter模块项目。在该目录中,你可以运行一些flutter命令,比如flutter run --debug
、flutter build ios
。
注意:
path可以自己定,但是一定要和后边Podfile文件的路径一致。
my_flutter
目录结构如下:
.
├── .android // Android部分工程文件
├── .gitignore // 忽略项配置文件
├── .ios // iOS部分工程文件
│ └── Runner.xcworkspace // iOS工程工作区
├── lib // 项目Dart源文件
│ └── main.dart // Flutter项目代码入口文件,类似iOS的main.m,RN的index.js文件
├── pubspec.yaml // 项目依赖配置文件,类似于iOS的Podfile,RN的package.json文件
└── test // 项目测试文件
- 在lib目录下放置自己的Dart代码
- Flutter依赖项添加到
my_flutter/pubspec.yaml
,包括Flutter软件包和插件。 -
.ios
包含一个Xcode工作区,自己的iOS代码不要添加到这里,这里的更改不会显示到已有的iOS项目中,并且可能会被Flutter覆盖。 -
.ios/
和.android/
目录是自动生成的,不要对其进行源码控制。 - 首次拉取到集成Flutter模块的项目代码后,构建项目之前,要现在
my_flutter
目录运行flutter pub get
以生成对应的.ios/
和.android/
目录。
使用Android Studio运行Flutter工程无误后,就可以将Flutter推到远端仓库,为后边的混编做好准备工作。
2.2 将Flutter工程集成到已有应用程序中
官方推荐使用CocoaPods依赖管理工具来安装Flutter SDK,这种方式要求当前项目的每个开发人员本地都必须安装Flutter SDK版本。
如果你的项目还没有使用CocoaPods,可以参考CocoaPods官网或者CocoaPods入门来给项目添加CocoaPods依赖管理工具。
① Podfile文件
我们要通过CocoaPods
管理Flutter SDK,需要再Podfile
文件中增加以下内容:
flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
// 对于每个需要集成Flutter的Podfile target,添加如下内容
install_all_flutter_pods(flutter_application_path)
这里需要注意的是,如果你的Flutter模块目录结构与官方文档推荐的不一致,需要自己调整相对路径,以保证安装正确。Podfile
详情案例如下:
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'
inhibit_all_warnings!
# path修改为调整后的相对路径
flutter_application_path = './Demo/Vendors/demo_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
target 'MyApp' do
install_all_flutter_pods(flutter_application_path)
end
② 运行pod install
pod install
主要做了以下事情:
- 解析
Generated.xcconfig
文件,获取 Flutter工程配置信息,文件在my_flutter/.ios/Flutter/
目录下,文件中包含了Flutter SDK路径、Flutter工程路径、Flutter工程入口、编译目录等。 - 将Flutter SDK中的
Flutter.framework
通过pod
添加到Native工程。 - 使用
post_install
这个pod hooks
来关闭Native工程的bitcode
,并将Generated.xcconfig
文件加入Native工程。
现在虽然进行了三端分离,但是项目之间是直接依赖的,仍然存在一些问题:
- 对原有项目仍然有侵入性,需要在项目中配置Podfile文件和执行flutter命令。
- 每个人本地仍然需要自己端的开发环境(iOS/Android)和Flutter的开发环境,并且Flutter SDK版本要保持一致。
2.3 构建Flutter模块
三端分离方案的关键是抽离Flutter工程,将Flutter项目的构建产物按照某种规则提供给原生工程使用,比如Android使用aar,iOS使用CocoaPods。
这种方案是将Flutter项目的构建产物作为原生工程的子模块,原有工程不需要本地安装Flutter开发环境,只需要关注原生开发即可。当我们的Flutter项目有了新功能或改动后,将其构建产物通过拖入或改造为依赖库的方式提供给原生项目使用。下面我们来一步步实现三端分离方案。
原生工程对Flutter的依赖主要分为两部分:
- Flutter库和引擎,也就是Flutter的framework库和引擎库。
- Flutter工程,也就是我们自己实现的Flutter模块功能,主要包含Flutter模块lib目录下的Dart代码和各种资源。
① 构建
iOS集成Flutter模块要稍微比Android麻烦一点。iOS项目工程对Flutter的依赖分别是:
- Flutter库和引擎,即Flutter.framework;
- Flutter项目产物,即App.framework。
iOS项目的Flutter模块依赖,实际上就是这两个产物,可以直接拖入项目工程,或者封装成一个CocoaPods私有库供原生项目引用。
如何构建Flutter产物呢,Flutter项目根目录下执行build命令:
flutter build ios --debug
这条命令执行完后,会生成上面说的构建产物:Flutter.framework
和App.framework
。如果想要release的产物,把--debug
换成--release
即可。
② 依赖使用
如果想和Android一样搞成依赖库,需要单独将构建产物封装成CocoaPods私有库,通过pod的方式给项目引入使用。如何构建私有库,这里就不介绍了,如果有兴趣可以参考CocoaPods入门。
如果不想这么麻烦,也可以直接将产物拖进项目的某个目录下,直接引入使用。
原生iOS项目中,导入头文件#import <Flutter/Flutter.h>
,直接使用FlutterViewController创建视图控制器即可,代码如下:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
FlutterViewController *vc = [[FlutterViewController alloc]init];
self.window.rootViewController = vc;
[self.window makeKeyAndVisible];
return YES;
}
2.4. iOS端集成方案
① 开发模式
开发模式最重要要求就是便于调试,这时可以采用上面1.2.2
方案,仍然是三端分离,但是不使用Flutter构建产物。
首先在iOS指定目录下clone Flutter项目代码。之前的iOS项目也集成过React Native模块,为了便于统一管理,我们将Flutter模块放在React Native模块同一目录下。切到指定目录下执行clone命令:
git clone flutter项目地址
然后进入到Flutter模块根目录下,执行:
flutter pub get
这是管理Flutter packages的命令,会将项目依赖的Flutter package拉取到本地供项目使用。
此时Flutter模块的准备工作已经完成,下边需要将Flutter模块配置给iOS项目工程使用。Podfile文件中增加以下内容:
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'
inhibit_all_warnings!
# flutter模块路径配置,路径为你缩放至目录的相对路径
flutter_application_path = './Demo/Vendors/demo_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
target 'Demo' do
# iOS依赖库
pod 'AFNetworking', '3.2.1'
# flutter
install_all_flutter_pods(flutter_application_path)
end
修改完毕后,执行:
pod update
执行完毕后,Flutter.framework
会通过Pod添加到iOS项目中,还有一些工程配置也会在这个命令中得以完成。
此时配置完毕,iOS项目添加如2.3所示native代码,运行项目,即可看到Flutter页面。
② 发布模式
开发模式主要是为了调试,所以开发同学本地必须有Flutter开发环境,但是当开发测试完需要打Release包发布或者代码提交的服务端使用打包服务机打包的时候,Flutter工程的代码是什么我们已经不关心了,只需要提供Flutter工程的编译产物给原生工程依赖使用即可。
所以此时进入到项目中的Flutter工程目录下执行flutter build ios --debug
或flutter build ios --release
构建编译产物,待编译完成后有两种方式提供给原生工程使用:
- 直接拖入原生工程,做相应工程配置后即可依赖使用。
- 将构建产物使用CocoaPods制作成私有库供原生项目依赖使用,这也是目前比较推崇的方式。
具体关系图:
目前iOS端是直接将Flutter编译产物直接拖入项目使用,后续会将Flutter编译产物构建成上图中私有库的形式,使用Cocoapods做Flutter模块的依赖管理。
3. Native与Flutter通信
就像Native与H5交互相仿,Native与Flutter通信也是通过一个中间通信工具对象(Platform Channel)来完成的,有三种类型:
- MethodChannel,最常用的传递对象,现在项目中使用的通信方式也是基于MethodChannel完成的。
- BasicMessageChannel,用户数据信息的传递。
- EventChannel,用于时间监听传递等场景。
3.1 Native模块
下面我们来看下iOS端代码实现,首先定义MethodChannel的name,初始化过程中会使用这些name创建通信对象。
static NSString *const kChannelFlutterToNative = @"com.demo.flutter/native";
static NSString *const kChannelNativeToFlutter = @"com.demo.flutter/flutter";
① Native模块传递信息给Flutter模块
// native to flutter
FlutterMethodChannel *flutterChannel = [FlutterMethodChannel methodChannelWithName:kChannelNativeToFlutter binaryMessenger:flutterVC.binaryMessenger];
NSString *serviceToken = @"token";
if (serviceToken.length > 0) {
[flutterChannel invokeMethod:@"onActivetyResult" arguments:@{@"cookie" : serviceToken}
];
}
② Native模块接收Flutter模块传递信息
// flutter to native
FlutterMethodChannel *nativeChannel = [FlutterMethodChannel methodChannelWithName:kChannelFlutterToNative binaryMessenger:flutterVC.binaryMessenger];
[nativeChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult _Nonnull result) {
if ([call.method isEqualToString:@"openWebViewPage"]) {
//打开webView
NSString *url = [call.arguments[@"message"] description];
self.loadWebView(url);
} else if ([call.method isEqualToString:@"handleTrackingCrash"]) {
//触发native埋点
[self trackingFlutterErrorWithData:call.arguments];
}
}];
3.2 Flutter模块
Flutter工程代码实现,首先也要初始化MethodChannel对象,初始化用的字符串要与上面iOS Native工程使用的保持一致。
static final flutterToNativeChannel = const MethodChannel('com.demo.flutter/native');
static final nativeToFlutterChannel = const MethodChannel('com.demo.flutter/flutter');
① Flutter模块接收Native模块传递信息
Future<dynamic> handle(MethodCall call) async {
switch(call.method) {
case 'onActivetyResult':
onDataChange(call.arguments);
break;
}
}
ConstantsUtil.nativeToFlutterChannel.setMethodCallHandler(handle);
② Flutter传递信息给Native模块
Widget renderBottomRow(i) {
return GestureDetector(
onTap: () => openWebView(ConstantsUtil.flutterToNativeChannel, listData[i]['url']),
child: Container(
...
),
);
}
Future<Null> openWebView(MethodChannel channel, String url) async {
Map<String,String> param = {'message':url};
await channel.invokeMethod('openWebViewPage', param);
}
4. 调试
作为软件开发,调试过程必不可少,那么在混编模式下有什么调试方案和技巧呢?
在2.4所提到的开发模式下,我们本地既有Native工程代码,又有Flutter工程代码,想要同时调试Native代码和Flutter代码,一般有两种方案:
4.1 iOS和Flutter同时调试,不支持断点方案
① Xcode打开iOS项目,运行项目并打开Flutter项目页面。
② 终端命令行输入:flutter devices
,打印出已连接到计算机的设备。
③ Android Studio打开嵌在iOS项目中的Flutter项目,控制台选择Terminal
选项卡,输入:
flutter attach -d 894DADC8-A12B-47FC-B8A7-EE29F0D2B086
flutter attach
的作用是将当前Flutter项目连接到某个正在运行的应用程序上。回车后,控制台会输出:
Syncing files to device iPhone 11...
6,247ms (!)
这表示连接成功,具体Android Studio项目详情截图如下:
Flutter项目代码修改后:
- 按
r
是热加载,局部刷新,刷新所有改动的Flutter代码文件,此时就可以看到代码改动后的结果; - 按
R
是热重启,全部刷新,刷新所有的Flutter文件。如过Hot reload
刷新无效,可以尝试使用Hot restart
。 - 按
d
和q
都是终止连接,结束调试。
Hot reload
和Hot restart
区别:
-
Hot reload
,将所有代码更改加载到VM中,并重新构建Widget
树,但是不会重新运行main()
或initState()
。 -
Hot restart
,同样将所有代码更改加载到VM中,然后重新启动Flutter应用,从而丢失应用状态。
4.2 iOS和Flutter同时调试,支持断点方案
① Android Studio打开嵌在iOS项目中的Flutter项目,工具栏点击Flutter Attach
。
此时控制台Debug
选项卡log输出:
Waiting for a connection from Flutter on iPhone 11...
② Xcode打开iOS项目,运行项目并打开Flutter项目页面。控制台Debug
选项卡log如下输出代表连接完成,可以进行断点调试。
Debug service listening on ws://127.0.0.1:54615/cDjoWoEjEok=/ws
Syncing files to device iPhone 11...
同样在控制台上边也可以通过点击Hot reload
和Hot restart
按钮来实现代码修改的更新操作。
5. 遇到的问题
5.1 找不到GeneratedPluginRegistrant
文件
如果原生代码中使用了GeneratedPluginRegistrant
类,还要从Flutter项目中将这个类文件拿出来和Flutter项目产物放在一块提供给原生项目使用,不然会报错找不到这个类。
5.2 启动崩溃,Library not loaded: @rpath/Flutter.framework/Flutter
项目启动崩溃,控制台log日志如下:
dyld: Library not loaded: @rpath/Flutter.framework/Flutter
Referenced from: /Users/zzz/Library/Developer/CoreSimulator/Devices/F5A071EC-2F1A-47E8-9C71-8E1269E01568/data/Containers/Bundle/Application/72BC9387-1FFB-467F-97FE-21767A5861B0/Demo.app/Demo
Reason: image not found
根据提示Flutter.framework没有被加载,我们点击Xocde的TARGETS
-> General
,找到 Frameworks,Libraries,and Embedded Content
选项的Flutter.framework
,将Embed
值由Do Not Embed
改为Embed Without Signing
,重新运行项目即可。
另外App.framework
也需要将Embed
值由Do Not Embed
改为Embed Without Signing
,不然项目运行后进入flutter页面是看到你写的功能页面的。
5.3 项目嵌入Flutter编译产物后,使用模拟器运行项目报错
Building for iOS Simulator, but the linked and embedded framework 'App.framework' was built for iOS.
这是因为Xcode 11.4更改了框架的链接和嵌入方式,导致了在iOS设备和模拟器之间切换的问题。想要避免这个启动错误最简单的操作就是更改Workspace Settings
。点击Xocde菜单栏File --> Workspace Settings...
,在弹出对话框中将Build System
值改为Legacy Build System
即可。重新运行项目,即可正常在模拟器启动应用。
5.4 Android Studio运行Flutter项目,提示Waiting for another flutter command to release the startup lock...
项目异常关闭或者使用任务管理器强制关闭后一般会出现这个问题,原因是在Flutter编译运行过程中会创建一个文件锁lockfile
,而异常关闭或者强制关闭或导致这个锁没有释放而一直存在,导致启动过程中出现waiting问题。
解决方案也很简单,找到这个文件删除即可。
rm ./flutter/bin/cache/lockfile
5.5 应用程序提交App Store时报错
Unsupported Architecture. Your executable contains unsupported architecture '[x86_64, i386]
意思比较明白,打包的应用程序包含了不被支持的模拟器架构(x86_64和 i386)。解决方案当然就是删掉这两个模拟器架构。
选择Xcode的Targets --> Build Phases
,点击+
号按钮选择New Run Script Phase
,在输入框中填入以下代码:
APP_PATH="${TARGET_BUILD_DIR}/${WRAPPER_NAME}"
# This script loops through the frameworks embedded in the application and
# removes unused architectures.
find "$APP_PATH" -name '*.framework' -type d | while read -r FRAMEWORK
do
FRAMEWORK_EXECUTABLE_NAME=$(defaults read "$FRAMEWORK/Info.plist" CFBundleExecutable)
FRAMEWORK_EXECUTABLE_PATH="$FRAMEWORK/$FRAMEWORK_EXECUTABLE_NAME"
echo "Executable is $FRAMEWORK_EXECUTABLE_PATH"
EXTRACTED_ARCHS=()
for ARCH in $ARCHS
do
echo "Extracting $ARCH from $FRAMEWORK_EXECUTABLE_NAME"
lipo -extract "$ARCH" "$FRAMEWORK_EXECUTABLE_PATH" -o "$FRAMEWORK_EXECUTABLE_PATH-$ARCH"
EXTRACTED_ARCHS+=("$FRAMEWORK_EXECUTABLE_PATH-$ARCH")
done
echo "Merging extracted architectures: ${ARCHS}"
lipo -o "$FRAMEWORK_EXECUTABLE_PATH-merged" -create "${EXTRACTED_ARCHS[@]}"
rm "${EXTRACTED_ARCHS[@]}"
echo "Replacing original executable with thinned version"
rm "$FRAMEWORK_EXECUTABLE_PATH"
mv "$FRAMEWORK_EXECUTABLE_PATH-merged" "$FRAMEWORK_EXECUTABLE_PATH"
done
这段脚本将会在打包过程中执行,删除掉Archive
包中不被支持的模拟器架构(x86_64和 i386)。
5.6 配置Flutter环境变量后执行Flutter命令仍然报zsh: command not found: flutter
问题
配置Flutter环境变量后执行Flutter命令正常,退出终端工具ZSH
,再次打开终端工具ZSH
执行Flutter命令,会提示zsh: command not found: flutter
问题。
原因是如果你使用的是ZSH
,终端启动时 ~/.bash_profile
将不会被加载,解决办法就是修改 ~/.zshrc
,在其中添加:source ~/.bash_profile
。
6. 异常监控
要想知道Flutter模块在原生应用中是否正常使用,异常监控绝对少不了。下面我们来看下Flutter异常如何收集和上报。
trackError
方法是回调Native异常上报代码,在Native中上传。目前iOS端使用的是埋点上传的方式记录当前Flutter模块的异常,后边还会做进一步优化,上传到统一异常收集平台展示。
Future<Null> trackError (MethodChannel channel, String exception, String stack) async {
Map<String,String> param = {'exception' : exception,
'stack' : stack};
await channel.invokeMethod('handleTrackingCrash', param);
}
6.1 Dart
异常
对于Dart
异常,我们可以使用全局onError
函数去捕获:
runZoned(() {
runApp(MyApp());
if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
}
}, onError: (error, stackTrace) {
// This is a pure Dart error
trackError(ConstantsUtil.flutterToNativeChannel, error.toString(), stackTrace.toString());
});
这里只要Dart
代码有Error就会触发onError
回调方法。
6.2 Flutter异常
除了Dart异常外,Flutter也能抛出其他异常,比如调用原生代码发生的平台异常,这种类型的异常也同样是需要上报的。
为了捕获 Flutter 异常,需要重写FlutterError.onError属性。在开发环境下,可以将异常格式化输出到控制台。在生产环境下,可以把异常信息传递Native模块做异常上报。
FlutterError.onError = (FlutterErrorDetails errorDetails) {
trackError(ConstantsUtil.flutterToNativeChannel, errorDetails.exception.toString(), errorDetails.stack.toString());
};
如果想要了对异常上报做进一步了解,请点击实用教程查看。