前言
为什么会有Flutter混编方案?其实这是一个很现实的问题。比如我们想要新写一个App,直接选用Flutter作为移动端开发的跨平台方案是非常好的一个选择。但是现实中是我们的App可能是已经开发了很多年的一个巨型工程,完全放弃原有的代码而使用Flutter重写App是不现实的。
开发过程中,我们最想要的是原生代码开发和Flutter共存:既不影响项目工程的原生开发,又能使用Flutter去统一iOS/Android技术栈。
混编方案
一般来说混编方案有以下两种:
flutter_01.png
- 统一管理方案:将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_02.png
这条命令执行完后,会生成上面说的构建产物:Flutter.framework和App.framework。如果想要release的产物,把--debug换成--release即可。
flutter_03.png
② 依赖使用
如果想和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_04.png
然后进入到Flutter模块根目录下,执行:
flutter pub get
这是管理Flutter packages的命令,会将项目依赖的Flutter package拉取到本地供项目使用。
flutter_05.png
此时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_06.png
执行完毕后,Flutter.framework会通过Pod添加到iOS项目中,还有一些工程配置也会在这个命令中得以完成。
此时配置完毕,iOS项目添加如2.3所示native代码,运行项目,即可看到Flutter页面。
② 发布模式
开发模式主要是为了调试,所以开发同学本地必须有Flutter开发环境,但是当开发测试完需要打Release包发布或者代码提交的服务端使用打包服务机打包的时候,Flutter工程的代码是什么我们已经不关心了,只需要提供Flutter工程的编译产物给原生工程依赖使用即可。
所以此时进入到项目中的Flutter工程目录下执行flutter build ios --debug或flutter build ios --release构建编译产物,待编译完成后有两种方式提供给原生工程使用:
- 直接拖入原生工程,做相应工程配置后即可依赖使用。
flutter_07.png
- 将构建产物使用CocoaPods制作成私有库供原生项目依赖使用,这也是目前比较推崇的方式。
具体关系图:
flutter_08.png
目前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,打印出已连接到计算机的设备。
flutter_09.png
③ 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_10.png
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。
flutter_11.png
此时控制台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...
flutter_12.png
同样在控制台上边也可以通过点击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.
flutter_13.png
这是因为Xcode 11.4更改了框架的链接和嵌入方式,导致了在iOS设备和模拟器之间切换的问题。想要避免这个启动错误最简单的操作就是更改Workspace Settings。点击Xocde菜单栏File --> Workspace Settings...,在弹出对话框中将Build System值改为Legacy Build System即可。重新运行项目,即可正常在模拟器启动应用。
flutter_14.png
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());
};
如果想要了对异常上报做进一步了解,请点击实用教程查看。













网友评论