一次编写,到处运行终是梦;
一次运行,各种填坑才是真。
但是 一个 React Native 倒下来,还会有千千万万个新方案站起来,Google 推出的 Flutter 有接替 React Native 梦想的脚步,而在国内淘宝闲鱼和头条团队也在积极响应着 Flutter 的方案,以下是头条工程师张星宇同学的分享,关于头条 Flutter 混合工程实践。
从 App Store 下载或更新头条(6.9.2 或以上版本),找到 懂车帝 -> 热门车型,点击打开后即可体验 Flutter 的页面效果。
由于前期业务改造顺利,线上 Crash 少,性能良好,目前我们正在进行小视频模块的 Flutter 重构,即将上线。
本文主要介绍头条 iOS 端在接入 Flutter 的过程中,选择的技术方案,遇到的问题和未来的计划。虽然是以 iOS 为例,但很多内容都是双端通用的。
为了方便对 Flutter 零基础读者阅读本文,首先介绍一些 Flutter 的基础知识。
当我们写完 Dart 代码后,需要编译后才能给客户端使用。Flutter 的产物分为两种模式,一个是 Debug 模式,采用 JIT(Just In Time)的方式,类似于解释型语言,好处是可以支持热更新,方便调试。另一种是 AOT(Ahead Of Time)模式,类似于编译型语言,好处是性能比较好。
因此在开发中,我们一般使用 Debug 模式的产物来提升调试效率,正式上线后再换成 Release 模式获得极致的性能。不管是哪种模式,在 iOS 下的产物都是三个(安卓类似):
App.framework:和 flutter 的业务逻辑相关,在 Debug 模式下就是一个很小的空壳,在 Release 模式下包含全部业务逻辑
flutter_assets:也和 flutter 的业务逻辑相关,在 Debug 模式下包含全部业务逻辑,在 Release 模式下很小。
Flutter.framework:实现 Flutter 框架自己的逻辑
目前主流的方案中主要有两种,一种在 Google 的官方文档里,它最大的问题在于,需要创建一步 BUILD PHASE 调用 shell 脚本去编译 Flutter。编译 Flutter 的过程需要本机有 Flutter 环境才行。
这显然是不能接受的。其实我们先不考虑 Flutter 这件事,不管是搭建什么类型的混合工程,都一定要保证以下两点:
对原生工程无侵入:原生工程可以增加组件的依赖,但不能修改主工程的配置,更不能让原生工程对环境产生新的依赖。
方便调试:不管是原生工程开发,调试新接入的模块(比如 Flutter),还是模块开发调试原生工程,都应该支持断点调试。
很明显,官网的方案不满足第一点要求,我们不能让每个参与原生工程开发的同学,本机都安装 Flutter 环境。
从 Flutter 的官网教程 中可以看到,它支持 flutter run
命令或者从 IDE 启动 iOS 工程。其实只要观察一下 ios 目录下的 Runner.xcworkspace
工程就会发现,Flutter 项目运行的就是这个工程。当然如果直接把我们的 iOS 工程拷贝过来是无法运行的。
至少在目前最新的 dev 分支(Tag:v0.10.0)上是不行的,因为 flutter 的代码中写死了工程的名字就是 Runner,可以验证一下:
这也就催生了目前主流的,也是闲鱼最先介绍的方案,魔改 Flutter 源码,让它能运行自己的工程。然而个人觉得这种方式还是太 trick,主要有两个问题:
后续如果 Flutter 升级了,还得把修改的部分应用到新的分支上去,并且修复冲突。
如果公司或者业内有别的团队要用,接入成本比较高。
观察一下 Runner 的工程结构:
再看一眼 Flutter 编译脚本就会发现,这一步并不是必须放在主工程的 BUILD 阶段进行。只要拥有 Flutter 的编译产物,项目就可以接入 Flutter 的功能了。因此头条选择的方案是:
利用 flutter build ios
和 flutter build ios --debug
命令,先后构建两种模式下的产物并上传到 CDN
构建一个 Pod,并且让 Pod 依赖上述产物
在第一步中,运行 flutter build ios
实际上会依赖 Runner 工程,相当于是我们借助这个空壳去得到产物。不过要想编译通过,还需要做如下两个微小的改动:
修改项目的 Bundle ID,使用自己的证书。否则无法通过签名
打开 Xcode -> File -> Workspace -> Setting,把 BUILD_SYSTEM 切换为 Legacy,否则会产生编译错误
第二步中,重点在于如何让 Pod 动态的下载资源,因为需要指定使用哪个版本,哪种模式的 Flutter 产物。我们目前的策略是在打包机器上使用 Release 模式产物,在开发机器上使用 Debug 模式产物。
至于动态指定版本号,可以这样写:
s.prepare_command = "bash ./BDFlutterDownload.sh"
s.vendored_frameworks = 'Framework/*.framework'
s.resources = 'Framework/flutter_assets'
通过 prepare_command
语法,可以在 Pod Install 的过程中执行 shell 脚本,把产物下载到 Framework
文件夹中,只要把版本号写到脚本中,并且及时更新就可以了。
上述方案解决了 Flutter 工程接入的问题,因为是编译后的资源接入,我们还需要保证 Flutter 开发的同学可以正常调试。这里以最新的 dev 分支举例(Tag:v0.10.0)。
如果是 Flutter 开发的同学,不关心 iOS 开发的细节,可以找到打包后的 app 文件,然后使用 flutter run --use-application-binary
指定被调试应用的路径:
如果是 iOS 开发的同学,使用 Xcode 运行项目即可。这两种方式下,都可以看到本地调试的端口(图中的 57674)。
如果是使用编译好的 app 来运行,可以直接使用 r/R 来实现热刷新/热重启。如果使用 Xcode 运行项目,需要先使用命令:flutter attach --debug-port=57674
。
如果需要在 iOS 侧打断点,必须用 Xcode 运行项目。如果需要在 Dart 侧打断点,可以利用 IDE 的 Debug 功能。这里以 VSCode 为例,首先安装 Dart 插件。
按下 Command + Shift + P 进入命令模式,输入 Attach to Flutter Process:
回车,输入调试端口,比如上文的 57674:
此时会进入调试状态,断点可以生效了。
相信稍微复杂一点的业务,都经历过或正在经历组件化拆分的痛苦阶段。因此我们在 Flutter 进行之初,就采用组件化的方式进行开发。
使用官方的脚手架创建 Plugin 后,默认会创建 Plugin 的 Native 部分代码。如果把 Native 部分的逻辑写在这里,最终也会编译到 Flutter 的产物中,但因为集成到主项目中的部分是二进制文件,所以无法调试。
至于 Native 部分的逻辑是应该写在 Dart Plugin 里面,对 Native 部分透明,还是应该由 Native 的 Pod 自行提供,我觉得是一个见仁见智的问题,都有各自的道理。考虑到头条混合工程的搭建形式,iOS 端最终选择了由各个业务 Pod 来提供插件逻辑,并且向 Flutter 的主 Pod 注册。
注册的方式比较简单,写在 load 方法中即可,这里只会简单的做一下存储,所以不用担心性能问题。以图片库为例,需要让它依赖 Flutter 主 Pod,然后稍加改造:
#import "FlutterWebImagePlugin.h"
#import <BDFlutter/FlutterManager.h>
@implementation FlutterWebImagePlugin
+ (void)load {
[FlutterManager registPlugin:self withMethodChannel:@"tt_image"];
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if ([@"fetchImage" isEqualToString:call.method]) {
result(imageData)
}
}
@end
注册方法的实现很简单,只是把类和 channel 名称做一下存储:
static NSMutableDictionary *pluginInfos = nil;
+ (void)registPlugin:(Class<FlutterPlugin>)plugin withMethodChannel:(NSString *)channel {
if (!channel) {
return;
}
if (!pluginInfos) {
pluginInfos = [NSMutableDictionary dictionary];
}
[pluginInfos setObject:plugin forKey:channel];
}
然后在适当的时机,比如 FlutterViewController 初始化或者预加载时,注册插件即可:
[pluginInfos enumerateKeysAndObjectsUsingBlock:^(NSString *channel, Class pluginClass, BOOL *stop) {
id<FlutterPlugin> plugin = [[pluginClass alloc] init];
[_plugins addObject:plugin];
id<FlutterPluginRegistrar> registrar = [_flutterViewController registrarForPlugin:channel];
FlutterMethodChannel* methodChannel = [FlutterMethodChannel
methodChannelWithName:channel
binaryMessenger:[registrar messenger]];
[registrar addMethodCallDelegate:plugin channel:methodChannel];
}];
一个需要注意的点是,创建 Dart 组件时,记得在 pubspec.yaml
中指定依赖的 Dart SDK 版本号:
environment:
sdk: ">=1.19.0 <3.0.0"
如果不写的话,默认依赖 2.0.0 以下的版本,会导致最新的 Flutter 代码无法编译。而 Flutter 代码还在不断的优化,降低包大小。相比于两个多月前的版本, 目前 v0.10.0 已经降低了 2.3 Mb 的包大小。
因为目前只是初版上线,验证了一下技术可行性,还有很多不成熟的地方,未来计划的改进有:
把插件注册、导航栈管理、通用库 Plugin 等通用的逻辑单独抽出来,作为公司通用的 Flutter 基础组件,这个组件不依赖任何业务库
因为每个应用都有自己的 Flutter 产物,所以每个业务都有自己的 Flutter 业务组件,它可以依赖业务库
把上述组件化的模式抽象出来,结合公司的组件化平台,做到更加自动化,更容易复用
本文来自头条开发工程师张星宇的专栏《从 iOS 到全栈》专栏,另外作者还著有《Mac 高效开发指南》,双十一当天半价哦。
相关阅读:
推荐阅读
关注回复“iOS开发”、“Android开发”、“机器学习”、“区块链”有惊喜。
小专栏:专业技术写作社区