目录
背景
Flutter拆包应用具体原理与实现
实际业务场景应用
遇到的问题与注意事项
成果
后续规划
一、背景
由于Flutter的跨平台应用特性,可以提高人效之余,还能保证在iOS和Android平台实现方案的统一性,避免了后续因需求业务扩展由于实现方案不同带来的限制。目前京东到家19个活动落地页已经全部替换为Flutter实现,与此同时也带来了包体积的不断膨胀,其中iOS和Android双端Flutter业务模块包占比都高达20%。苹果官方尽管已经将包体积放宽至200M,考虑到用户在更新和下载的时候的多种场景,我们针对于包的大小限制仍旧以100M为衡量标准,所以Flutter业务模块的瘦身对于到家app来说仍旧是至关重要的。因此我们进行了Flutter包动态下发探索。
二、Flutter拆包应用具体原理与实现
2.1 iOS侧实现方案
2.1.1 iOS端产物
iOS端接入到原生App中主要的产物有App.framework,Flutter.framework,hybird_router.framework,业务Plugin对应的framework,其中App.framework在整体接入产物中占比高达69.7%。App.framework才是实际Dart端代码的业务实现,而它则由kDartVmSnapshotData(vm数据段)、kDartVmSnapshotInstructions(vm指令段)、kDartIsolateSnapshotData(isolate数据段)、kDartIsolateSnapshotInstructions(isolate指令段)四部分组成。
2.1.2编译原理
App.framework是作为动态库接入到iOS工程中的。动态库需要在装入/启动其运行时同时装入函数映像并进行动态链接。也就是程序在使用过程中,才会加载到内存当中,这样对于我们去做一些后置性操作提供一些可行性。数据段包含了经过初始化的全局变量和静态变量,以及他们的值。代码段包含程序的指令,它在程序的执行过程中一般不会改变。由于iOS系统的限制,Flutter引擎不能在运行时将内存页标记为可执行,所以vm指令段和isolate指令段必须预置在包中,不可被分割。通过阅读Flutter引擎源码可以发现,Flutter官方是有提供接口,支持从外部读入vm数据段和isolate数据段。那么,vm数据段和isolate数据段是可以拆分出来,在初始化引擎之前再读取的。而资源包也是可以在程序运行过程中进行读取的。因此,我们打算拆分出数据段和资源文件进行动态下发。
2.1.3编译产生的生成过程及修改操作
如果希望拆分出数据段和资源包的话,那我们首先需要了解Flutter在生成编译产物的时候都做了些什么?
上图就是Flutter打包iOS平台的命令调用过程。经过层层递进查询发现,最终执行的是flutter_tools.snapshot这个二进制执行文件。这个文件所对应的源码是在Flutter Engine中的gen_snapshot.cc。通过阅读对应的源码gen_snapshot.cc文件可见,Dart_CreateSnapshot()方法执行写入操作。因为要将数据段分离的话,就可以在这块进行处理。
上图为gen_snapshot.cc类中生成App端的snapshot函数,实际分离数据段和代码段的逻辑可以据此为切入口进行修改。追踪到最后调用的是ImageWriter中的写入函数,具体改动如下:
void AssemblyImageWriter::WriteText(WriteStream* clustered_stream, bool vm) {
//省略部分官方未改动代码
#if defined(TARGET_OS_MACOS_IOS)
WriteTextToLocalFile(clustered_stream, vm);
#else
//省略原有写入数据段代码
#endif
#endif // !defined(DART_PRECOMPILED_RUNTIME)
}
void AssemblyImageWriter::WriteTextToLocalFile(WriteStream* clustered_stream, bool vm){
#if defined(TARGET_OS_MACOS_IOS)
auto OpenFile = [](const char* filename){
Syslog::Print("open file : %s\n", filename);
bin::File* file = bin::File::Open(NULL, filename, bin::File::kWriteTruncate);
if (file == NULL) {
Syslog::PrintErr("Error: Unable to write file: %s\n", filename);
Dart_ExitScope();
Dart_ShutdownIsolate(); exit(255);
}
return file;
};
auto StreamingWriteCallback = [](void* callback_data, const uint8_t* buffer,intptr_t size) {
bin::File* file = reinterpret_cast < bin::File* >(callback_data);
if (!file->WriteFully(buffer, size)) {
Syslog::PrintErr("Error: Unable to write snapshot file\n");
Dart_ExitScope();
Dart_ShutdownIsolate(); exit(255);
}
};
#if defined(TARGET_ARCH_ARM64)
printf("this is arm64\n");
bin::File *file = OpenFile(vm ? "./SnapshotData/arm64/VmSnapshotData.S" : "./SnapshotData/arm64/IsolateSnapshotData.S");
#else//#if defined(TARGET_ARCH_ARM)
printf("this is armv7\n");
bin::File *file = OpenFile(vm ? "./SnapshotData/armv7/VmSnapshotData.S" : "./SnapshotData/armv7/IsolateSnapshotData.S");
#endif //end of TARGET_ARCH_ARM64
// 省略写入文件操作
}
至于资源文件的话,我们直接选择在产物生成之后,再移动资源文件,简单而高效。
2.1.4编译产物的加载流程及修改操作
当顺利分离出产物后,我们还需要考虑怎么去加载这些产物?下图为Flutter首次创建控制器时的时序图。
上图仅标识出关键点。在原生工程中,首先需要初始化Flutter引擎。在Flutter引擎初始化方法中,会在初始化DartProject的一个实例作为属性。在其中的初始化过程中,会执行函数DefaultSettingsForProcess进行初始化设置。Flutter引擎还会创建Shell,从而去创建Dart VM,此时会从settings中获取vm数据段的路径和isolate数据段的路径进行加载。我们只需要在配置路径的时候重新设置这两个路径,就可以实现从外部加载的需求。可以看到FlutterDartProject的角色主要用来设置Flutter相关信息配置,加载资源路径和数据段都可以借由它来实现。
static flutter::Settings DefaultSettingsForProcess(NSBundle* bundle = nil) {
auto command_line = flutter::CommandLineFromNSProcessInfo();
// Precedence:
// 1. Settings from the specified NSBundle.
// 2. Settings passed explicitly via command-line arguments.
// 3. Settings from the NSBundle with the default bundle ID.
// 4. Settings from the main NSBundle and default values.
//省略部分代码
auto settings = flutter::SettingsFromCommandLine(command_line);
//重设设置配置
resetSettings(settings);
// ignore part code
return settings;
}
void resetSettings(flutter::Settings &settings){
#if (FLUTTER_RUNTIME_MODE != FLUTTER_RUNTIME_MODE_DEBUG)//debug不做任何处理,只针对profile和release
...
//核心设置路径代码
NSString *vmDataPath = [[NSBundle mainBundle] pathForResource:@"VMData" ofType:@"dat"];
if (vmDataPath != nil)
settings.ios_vm_snapshot_data_path = vmDataPath.UTF8String;
}
NSString *isolateDataPath = [[NSBundle mainBundle] pathForResource:@"IsolateData" ofType:@"dat"];
if (vmDataPath != nil)
{
settings.ios_isolate_snapshot_data_path = isolateDataPath.UTF8String;
}
...
#endif
}
2.2 Android侧实现方案
通过下图分析Flutter产物,可得出结论Android端可动态下发libapp.so、libFlutter.so、Flutter_assets这三部分。
到家每个的Flutter模块都是一个单独工程,Android端Flutter打完包后会先把产物(Flutter.aar)上传到maven仓库,主工程会gradle依赖Flutter产物。考虑到既要在开发期间方便调试又要在上线时进行产物剥离,我们选择在Apk编译期间控制Flutter是否拆包,主要思路是Hook Android打包生命周期,自定义gradle task 来剥离产物。接下来介绍下Android平台Flutter从拆包到加载的原理与具体实现。
2.2.1 Flutter产物剥离
由下图可以看到libapp.so和libFlutter.so最终以so的形式存在于Apk的lib/armeabi目录下,Flutter_assets最终存在于assets目录下。我们通过分析android打包脚本确定了so的目录生成是在gradle命令transformNativeLibsWithMergeJniLibsFor_后,assets的目录生成是在gradle命令merge_Assets后。
于是我们分别Hook上面两个打包命令,把这三部分产物copy到一个临时文件夹,并将原有目录下产物删除。
Flutter_assets路径位于 app/build/intermediates/merged_assets/devForTestRelease/mergeDevForTestReleaseAssets/out/ 下
libFlutter.so和libapp.so 路径位于app/build/intermediates/transforms/mergeJniLibs/devForTest/release/0/lib/armeabi/ 下
其中Hook Android打包命令过程如下:
tasks.whenTaskAdded { task ->
if(project.IS_Flutter_DOWNLOAD){
if (task.name.startsWith('merge')&&task.name.endsWith('Assets')) {
task.finalizedBy('FlutterStripAssets')
}else if(task.name.startsWith('transformNativeLibsWithMergeJniLibsFor')){
task.finalizedBy('FlutterStripSo')
}
}
}复制
2.2.2 签名压缩+产物上传
在打包结束之前需要对产物进行签名压缩并最终生成一个patch.zip,然后自动上传到到家配置平台,上传成功后服务端将拆分包解压成3份并生成3个url 以json形式返回客户端,客户端把数据写入在asset目录下,方便启动时读取url下载。
以下是自定义的gradle task的相互依赖关系:
def FlutterUploadTask=tasks.findByName('uploadFlutterToJdCloud')
def FlutterSignAndZipTask=tasks.findByName('FlutterSignAndZip')
def FlutterStripSoTask=tasks.findByName('FlutterStripSo')
FlutterStripSoTask.finalizedBy FlutterSignAndZipTask
FlutterSignAndZipTask.finalizedBy FlutterUploadTask复制
2.2.3 Flutter产物动态加载
目前业界存在两种方案动态加载Flutter产物:1.修改so加载路径并重新编译Flutter引擎 2.重写Flutter加载类,并反射Flutter初始化变量
考虑Flutter升级带来的后期维护成本并结合到家现有的Flutter热更新方案,我们选择了方案1去编译Flutter引擎,下面简单介绍加载so和资源的节点。
1.通过Flutter引擎源码可以得知FlutterLoader中startInitialization方法就是加载libflutter.so的地方,这里Flutter采用System.loadLibrary加载的libflutter.so,无法修改so路径,
所以我们反射ClassLoader的pathList和nativeLibraryDirectories,把自定义的libflutter.so路径添加进去,需要注意下不同版本ClassLoader源码不太一样,需要分多个版本处理反射。
public void startInitialization(@NonNull Context applicationContext, @NonNull Settings settings) {
//省略...
initConfig(applicationContext);
initResources(applicationContext);
System.loadLibrary("flutter");
VsyncWaiter.getInstance((WindowManager) applicationContext.getSystemService(Context.WINDOW_SERVICE)).init();
FlutterJNI.nativeRecordStartTimestamp(initTimeMillis);
}
2.通过分析发现FlutterLoader中的ensureInitializationComplete方法就是加载libapp.so的地方,其中shellArgs存放的是Flutter的libapp.so的路径,我们在原有路径之前添加了自定义的路径,这样就可以加载到下发的libapp.so
public void ensureInitializationComplete(@NonNull Context applicationContext, @Nullable String[] args) {
List<String> shellArgs = new ArrayList<>();
//省略...
if (customLibraryPath != null) {
shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + customLibraryPath);
}
shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + aotSharedLibraryName);
FlutterJNI.nativeInit(applicationContext, shellArgs.toArray(new String[0]),kernelPath, appStoragePath, engineCachesPath);
}
3.通过分析发现Flutter资源的加载是在platform_view_android_jni.cc的RunBundleAndSnapshotFromLibrary方法中,我们在方法队列头部新增一个DirectoryAssetBundle去加载自定义的Flutter资源路径
static void RunBundleAndSnapshotFromLibrary(JNIEnv* env,jobject jcaller,...,jstring jCustomAssetPath,jobject jAssetManager) {
auto asset_manager = std::make_shared<flutter::AssetManager>();
asset_manager->PushBack(std::make_unique<flutter::APKAssetProvider>(env, jAssetManager, fml::jni::JavaStringToString(env, jBundlePath)) );
asset_manager>PushFront(std::make_unique<DirectoryAssetBundle(fml::Duplicate(fml::OpenDirectory((fml::jni::JavaStringToString(env,jCustomAssetPath)).c_str(), false,
fml::FilePermission::kRead).get())));
std::unique_ptr<IsolateConfiguration> isolate_configuration;
//省略...
}
三、实际业务场景应用
3.1 App启动产物下载
App在启动的时候,根据远程配置匹配本地Flutter产物版本判断是否需要下载,Android端对于产物细分为3个下载链接,这样既保证了下载速度还提高了成功率,详细流程见下图。
3.2 App加载引擎失败兜底方案
仅仅做完针对于Flutter包部分产物拆分和加载并不能算是完成了动态化,还需要考虑异常场景的处理,如果下载失败或者加载失败如何处理?
由于Flutter产物下载需要一定时间,为了不阻塞用户浏览,到家路由会在跳转Flutter页面之前进行拦截判断Flutter产物是否加载完毕,如果没有加载完毕,就跳转到对应降级的Web页面,同时根据配置重新拉取下载产物。如果加载完毕,就直接跳转Flutter页面。
四、遇到的问题与注意事项
4.1 iOS端:
需要关注实际业务侧使用的Flutter引擎版本,因为下载源码的时候默认是最新版本,否则最终编译的Flutter引擎版本可能有不兼容问题。
在编译Flutter引擎的时候,报错【This build requires the MacOSX 10.15 SDK, but it was not found on your system.】经过排查日志,在find_sdk.py中发现是Xcode版本问题。猜测Flutter编译1.17.1版本的时候,使用的对应的是Xcode11版本进行处理的,导致升级到高版本之后没有找到对应的MacOSX SDK版本。
4.2 Android端:
在Flutter产物下载完毕去初始化Flutter引擎的时候会报java.lang.UnsatisfiedLinkError,提示libflutter.so找不到。
分析so加载发现虽然此时libapp.so下载完毕,但是so的路径在Application初始化的时候已经全部添加到了nativeLibraryDirectories,导致加载的时候找不到。所以需要在Application初始化时先预埋一个空的so路径。
五、 成果
在采用Flutter产物动态下发方案后,iOS端由于系统受限原因,目前现有业务包拆分出产物大小为19.6M,Flutter业务代码包总体为56.8M,只能达到23.9%产物下发。Android端Flutter 产物可以做到90%以上采用动态下发,能使apk大小减小9M左右。
六、 后续规划
iOS端拆分出数据段的大小和业务实际写法实现息息相关。就像原生需要做代码增量记录,Flutter业务在到家业务中也占有重要的地位,所以也可以进行版本增量记录,以及研究代码写法对于最终包的大小影响。
Android目前Flutter下发产物拆分成了3个包下载,但每个下载包还是偏大,因此我们后续规划更小粒度的包拆分,并结合断点续传,当下载中断时下次接着下载,提高下载速度和成功率。