一、前言
Deferred Components,也就是延迟组件,是官方实现的 Flutter 代码动态下发的方案。本文简单介绍官方方案的实现细节,探索在国内环境下使用 Deferred Components。提供一些实现思路。
Deferred Components 是 Flutter2.2 推出的功能,依赖于 Dart2.13 新增的对 Split AOT 编译支持。将可以在运行时每一个可单独下载的 Dart 库、assets 资源包称之为延迟加载组件,即 Deferred Components。Flutter 代码编译后,所有的业务逻辑都会打包在 libapp.so 一个文件里。但如果使用了延迟加载,便可以分拆为多个 so 文件,甚至一个 Dart 文件也可以编译成一个单独的 so 文件。
这样带来的好处是显而易见的,可以将一些不常用功能放到单独的 so 文件中,当用户使用时再去下载,可以大大降低安装包的大小,提高应用的下载转换率。截止目前来讲,官方的实现方案必须依赖 Google Play,虽然也针对中国的开发者给出了不依赖 Google Play 的自定义方案,但是并没有给出实现细节,本文为博主自己通过网上搜索相关内容,学习实践后得出的一些经验和成果,有不足之处,欢迎大家一起讨论学习。
二、官方实现方式
2.1.1. 引入 play core 依赖,将 Play Core 添加到 Android 应用程序的 build.gradle 依赖项中。 在 android/app/build.gradle 中添加以下内容
dependencies {
implementation "com.google.android.play:core:1.8.0"
}
2.1.2. 如果使用 Google Play 商店作为动态功能的分发模型, 应用程序必须支持 SplitCompat 并手动提供 PlayStoreDeferredComponentManager 的实例。 这两个任务都可以通过设置 android/app/src/main/AndroidManifest.xml 中的 android:name 为 io.flatter.app.flatterPlayStoreSplitApplication 应用属性来完成, 如果你使用了 FlutterPlayStoreSplitApplication(建议使用这个方式,感受下官方的方式就行,主要是后面的自定义实现) ,可以跳过步骤 2.1.3 :
<manifest ...
<application
android:name= "io.flutter.app.FlutterPlayStoreSplitApplication"
...
</application>
</manifest>
2.1.3. 修改 Application 类的 onCreate 方法和 attachBaseContext 方法。
import io.flutter.embedding.engine.dynamicfeatures.PlayStoreDeferredComponentManager;
import io.flutter.FlutterInjector;
@Override
protected void onCreate(){
super.onCreate()
// 负责deferred components的下载与安装
PlayStoreDeferredComponentManager deferredComponentManager = new
PlayStoreDeferredComponentManager(this, null);
FlutterInjector.setInstance(new FlutterInjector.Builder()
.setDeferredComponentManager(deferredComponentManager).build());
}
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
// Emulates installation of future on demand modules using SplitCompat.
SplitCompat.install(this);
}
2.1.4. 修改 pubspec.yaml 文件,flutter 工具会在 pubspec.yaml 中查找 deferred-components , 来确定是否应将应用程序构建为延迟加载。除非你已经知道所需的组件和每个组件中的 Dart 延迟库,否则可以暂时将其留空。当 gen_snapshot 生成加载单元后,你可以在后面的 步骤 中完善这部分内容。
flutter:
deferred-components:
2.1.5. 在 flutter 工程里新增 box.dart 和 some_widgets.dart 两个文件,DeferredBox 就是要延迟加载的控件,本例中 box.dart 被称为一个加载单元,即 loading_unit,每一个 loading_unit 对应唯一的 id,一个 deferred component 可以包含多个加载单元。记得这个概念,后续会用到。
// box.dart
import 'package:flutter/widgets.dart';
/// A simple blue 30x30 box.
class DeferredBox extends StatelessWidget {
DeferredBox() {}
@override
Widget build(BuildContext context) {
return Container(
height: 30,
width: 30,
color: Colors.blue,
);
}
}
在应用中使用 deferred 关键字导入新的 Dart 库,并调用 loadLibrary 。下面的示例使用 FutureBuilder 等待 loadLibrary 的 Future 对象(在 initState 中创建)完成, 并将 CircularProgressIndicator 做为占位。 当 Future 完成时,会返回 DeferredBox 。 SomeWidget 便可在应用程序中正常使用,在成功加载之前不会尝试访问延迟的 Dart 代码。
import 'box.dart' deferred as box;
class SomeWidget extends StatefulWidget {
@override
_SomeWidgetState createState() => _SomeWidgetState();
}
class _SomeWidgetState extends State<SomeWidget> {
Future<void> _libraryFuture;
@override
void initState() {
//只有调用了loadLibrary方法,才会去真正下载并安装deferred components.
_libraryFuture = box.loadLibrary();
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _libraryFuture,
builder: (BuildContext context, AsyncSnapshot<void> snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return box.DeferredBox();
}
return CircularProgressIndicator();
},
);
}
}
2.1.6. 然后在 main.dart 里面新增一个跳转到 SomeWidget 页面的按钮。
Navigator.push(context, MaterialPageRoute(
builder: (context) {
return const SomeWidget();
},
));
2.1.7.terminal 里运行 flutter build appbundle 命令(注意:执行flutter build appbundle 前应先在项目的pubspec.yaml文件中deferred-components 下添加组件的名称 “- name: xxxxx(组件名称)”,否则执行flutter build appbundle不会走验证延迟加载组件的流程),如下:
deferred-components:
- name: ComponentName
libraries:
- package:XXX/xxxxx/box.dart
如果有多个,如下继续添加
- name: ComponentName2
libraries:
- package:XXX/xxxxx/xxx.dart
此时,gen_snapshot 不会立即去编译 app,而是先运行一个验证程序,目的是验证此工程是否符合动态下发 dart 代码的格式,第一次构建时肯定不会成功,你只需要按照编译提示去修改即可。当全部修改完毕后,会得到最终的.aab 类型的安装包。
最终根据提示修改后,AndroidManifest.xml文件中会有如下一行,value="3:PublishPage,2:DetailPage",3代表加载单元id,PublishPage为延迟组件名称,多个延迟组件以“,”分隔。
<meta-data android:name="io.flutter.embedding.engine.deferredcomponents.DeferredComponentManager.loadingUnitMapping" android:value="3:PublishPage,2:DetailPage"/>
同时会在根目录中生成一个deferred_components_loading_units.yaml文件,该文件中的内容为各个加载单元及其对应的文件路径,可以将这些路径copy到pubspec.yaml文件中deferred-components 下(libraries文件可能会有多个,不管有多少,都将其复制过来)。
deferred_components_loading_units.yaml文件如下:
loading-units:
- id: 2
libraries:
- package:xinfa/resource/material/materialDetail.dart
- package:xinfa/resource/material/chosDevices.dart
- id: 3
libraries:
- package:xinfa/resource/publish/publishPage.dart
- package:xinfa/resource/publish/popWeekDay.dart
pubspec.yaml文件如下:
deferred-components:
- name: PublishPage
libraries:
- package:xinfa/resource/publish/publishPage.dart
- package:xinfa/resource/publish/popWeekDay.dart
- name: DetailPage
libraries:
- package:xinfa/resource/material/materialDetail.dart
- package:xinfa/resource/material/chosDevices.dart
将在 pubspec.yaml 中定义的所有延迟组件手动添加到 android/settings.gradle 文件中的 includes 部分。 例如,如果 pubspec 中定义了三个名为 PublishPage 、 DetailPage 的延迟组件, 请确保 android/settings.gradle 中包含以下内容:
include ':app', ':PublishPage', ':DetailPage'
以上便是官方实现方案的基本步骤,更多细节可以参考官方文档
https://docs.flutter.dev/perf/deferred-components
2.2 本地验证
在将生成的 aab 安装包上传到 Google Play 上之前,最好先本地验证一下。
首先你需要下载 bundletool(下载地址: https://github.com/google/bundletool/releases ),然后依次运行下列命令就可以将 aab 安装包装在手机上进行最终的验证了。
<your_app_project_dir> 是应用程序对应项目的目录位置, <your_temp_dir> 用于存储 bundletool 输出的所有临时目录。这会将你的 .aab 文件解压为 .apks 文件并将其安装到设备上。所有 Android 可用的动态特性都在本地加载到设备上,并模拟延迟组件的安装。 再次运行 build-apks 之前,请删除已存在的 app.apks 文件:
java -jar bundletool.jar build-apks --bundle=<your_app_project_dir>/build/app/outputs/bundle/release/app-release.aab --output=<your_temp_dir>/app.apks --local-testing
java -jar bundletool.jar install-apks --apks=<your_temp_dir>/app.apks
2.3 loadLibrary () 方法调用的生命周期
图 1 官方实现方案介绍图
(来源:https://github.com/flutter/flutter/wiki/Deferred-Components)
从官方的实现方案中可以知道,只有调用了 loadLibrary 方法后,才会去真正执行 deferred components 的下载与安装工作,现在着重看下此方法的生命周期。
调用完 loadLibrary 方法后,dart 会在内部查询此加载单元的 id,并将其一直向下传递,当到达 jni 层时,jni 负责将此加载单元对应的 deferred component 的名字以及此加载单元 id 一块传递给
PlayStoreDynamicFeatureManager,此类负责从 Google Play Store 服务器下载对应的 Deferred Components 并负责安装。安装完成后会逐层通知,最终告诉 dart 层,在下一帧渲染时展示动态下发的控件。
三、自定义实现
3.1 思路
梳理了 loadLibrary 方法调用的生命周期后,只需要自己实现一个类来代替
PlayStoreDynamicFeatureManager 的功能即可。在官方方案中具体负责完成 PlayStoreDynamicFeatureManager 功能的实体类是 io.flutter.embedding.engine.deferredcomponents.PlayStoreDeferredComponentManager,其继承自 DeferredComponentManager,分析源码得知,它最重要的两个方法是 installDeferredComponent 和 loadDartLibrary。
- installDeferredComponent:这个方法主要负责 component 的下载与安装,下载安装完成后会调用 loadLibrary 方法,如果是 asset-only component,那么也需要调用 DeferredComponentChannel.completeInstallSuccess 或者 DeferredComponentChannel.completeInstallError 方法。
- loadDartLibrary:主要是负责找到 so 文件的位置,并调用 FlutterJNI dlopen 命令打开 so 文件,你可以直接传入 apk 的位置,flutterJNI 会直接去 apk 里加载 so,避免处理解压 apk 的逻辑。
那基本思路就有了,自己实现一个实体类,继承 DeferredComponentManager,实现这两个方法即可。
3.2 代码实现
本例只是最小 demo 实现,cpu 架构采用 arm64(实际应用中应根据具体设备的cpu架构来实现),且暂不考虑 asset-only 类型的 component。
3.2.1. 新增 Test 类,继承 DeferredComponentManager。
3.2.2. 实现 installDeferredComponent 方法,将 so 文件放到外部 SdCard 存储里,代码负责将其拷贝到应用的私有存储中,以此来模拟网络下载过程。
并且实现 loadDartLibrary 方法,可以直接拷贝
PlayStoreDeferredComponentManager 类中的此方法,注释已加,其主要作用就是在内部私有存储中找到 so 文件,并调用 FlutterJNI dlopen 命令打开 so 文件。
package io.flutter.plugins;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import io.flutter.embedding.engine.FlutterJNI;
import io.flutter.embedding.engine.deferredcomponents.DeferredComponentManager;
import io.flutter.embedding.engine.systemchannels.DeferredComponentChannel;
public class Test implements DeferredComponentManager {
FlutterJNI flutterJNI;
Context context;
DeferredComponentChannel channel;
public Test(Context context) {
this.context = context;
}
@Override
public void setJNI(FlutterJNI flutterJNI) {
this.flutterJNI = flutterJNI;
}
@Override
public void setDeferredComponentChannel(DeferredComponentChannel channel) {
this.channel = channel;
}
@Override
//这个方法主要负责component的下载与安装,下载安装完成后会调用loadLibrary方法,如果是asset-only component,那么也需要调用DeferredComponentChannel.completeInstallSuccess或者DeferredComponentChannel.completeInstallError方法。
public void installDeferredComponent(int loadingUnitId, String componentName) {
Toast.makeText(context, "so文件开始获取",Toast.LENGTH_LONG).show();
Log.e("131", "so文件开始获取,loadingUnitId=" + loadingUnitId );
Log.e("132", "so文件开始获取,componentName=" + componentName );
String resolvedComponentName = "PublishPage";
if (resolvedComponentName == null) {
// Log.e(TestAG, "Deferred component name was null and could not be resolved from loading unit id.");
Toast.makeText(context, "componentName= null", Toast.LENGTH_LONG).show();
return;
} // Handle a loading unit that is included in the base module that does not need download.
if (resolvedComponentName.equals("") && loadingUnitId > 0) { // No need to load assets as base assets are already loaded.
Toast.makeText(context, "No need to load assets as base assets are already loaded.", Toast.LENGTH_LONG).show();
loadDartLibrary(loadingUnitId, resolvedComponentName);
return;
}
Toast.makeText(context, "耗时操作模拟网络请求去下载android module", Toast.LENGTH_SHORT).show();
// 耗时操作,模拟网络请求去下载android module
new Thread(() -> {
String sourcePath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/Android/data/com.example.xinfa/files/libapp.so-" + loadingUnitId + ".part.so";
Log.e("121", "sourcePath----------:" + sourcePath);
String toPath = context.getFilesDir().getAbsolutePath() + "/libapp.so-" + loadingUnitId + ".part.so";
Log.e("122", "toPath----------:" + toPath);
boolean result = false;
try{
result = movSoToPrivateDir(sourcePath, toPath);
}catch (IOException e) {
throw new RuntimeException(e);
}
if (result) { //模拟网络下载,添加2秒网络延迟
Log.e("111", "移动原文件success");
// Toast.makeText(context, "移动原文件success", Toast.LENGTH_SHORT).show();
new Handler(Looper.getMainLooper()).postDelayed(() -> {
loadAssets(loadingUnitId, resolvedComponentName);
loadDartLibrary(loadingUnitId, resolvedComponentName);
if (channel != null) {
channel.completeInstallSuccess(resolvedComponentName);
}
}, 2000);
} else {
Log.e("110", "移动原文件失败");
// Toast.makeText(context, "移动原文件-------------失败", Toast.LENGTH_SHORT).show();
new Handler(Looper.getMainLooper()).post(() -> {
Toast.makeText(context, "未在sd卡中找到so文件", Toast.LENGTH_LONG).show();
if (channel != null) {
channel.completeInstallError(resolvedComponentName, "未在sd卡中找到so文件");
}
if (flutterJNI != null) {
flutterJNI.deferredComponentInstallFailure(loadingUnitId, "未在sd卡中找到so文件", true);
}
});
}
}).start();
}
public boolean movSoToPrivateDir(String oldFilePath, String newFilePath) throws IOException {
Log.e("101","path---------:" + oldFilePath);
Log.e("102","path---------:" + newFilePath);
//如果原文件不存在
if (fileExists(oldFilePath) == false) {
Log.e("103", "没有找到原文件路径");
return false;
}
//获得原文件流
FileInputStream inputStream = new FileInputStream(new File(oldFilePath));
byte[] data = new byte[1024];
//输出流
FileOutputStream outputStream =new FileOutputStream(new File(newFilePath));
//开始处理流
while (inputStream.read(data) != -1) {
outputStream.write(data);
}
inputStream.close();
outputStream.close();
Log.e("105","转移文件完成");
return true;
}
public static boolean fileExists(String filePath) {
File file = new File(filePath);
return file.exists();
}
@Override
public String getDeferredComponentInstallState(int loadingUnitId, String componentName) {
return null;
}
@Override
public void loadAssets(int loadingUnitId, String componentName) {
}
@Override
public boolean uninstallDeferredComponent(int loadingUnitId, String componentName) {
return false;
}
@Override
public void destroy() {
}
@Override
public void loadDartLibrary(int loadingUnitId, String componentName) {
if (loadingUnitId < 0) {
return;
}
//拿到so的文件名字(此处demo写死了字符串+拼接loadingUnitId,实际上应可以灵活处理)
String aotSharedLibraryName = "libapp.so-" + loadingUnitId + ".part.so";
//拿到支持的abi格式--arm64_v8a // Possible values: armeabi, armeabi-v7a, arm64-v8a, x86, x86_64, mips, mips64
String abi = Build.SUPPORTED_ABIS[0];
Log.e("118", "abi----" + abi);
Toast.makeText(context, "abi-"+abi, Toast.LENGTH_LONG).show();
String pathAbi = abi.replace("-", "_"); // abis are represented with underscores in paths.
// TODO(garyq): Optimize this apk/file discovery process to use less i/o and be more // performant and robust.
// Search directly in APKs first
List<String> apkPaths = new ArrayList<>(); // If not found in APKs, we check in extracted native libs for the lib directly.
List<String> soPaths = new ArrayList<>();
Queue<File> searchFiles = new LinkedList<>(); // Downloaded modules are stored here--下载的 modules 存储位置
searchFiles.add(context.getFilesDir());
Log.e("138", "getFilesDir path---" + context.getFilesDir().getAbsolutePath());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
//第一次通过appbundle形式安装的split apks位置
// The initial installed apks are provided by `sourceDirs` in ApplicationInfo.
// The jniLibs we want are in the splits not the baseDir. These
// APKs are only searched as a fallback, as base libs generally do not need
// to be fully path referenced.
for (String path : context.getApplicationInfo().splitSourceDirs) {
Log.e("138", "splitSourceDirs---" + path);
searchFiles.add(new File(path));
}
}
//查找apk和so文件
while (!searchFiles.isEmpty()) {
File file = searchFiles.remove();
if (file != null && file.isDirectory() && file.listFiles() != null) {
for (File f : file.listFiles()) {
Log.e("139", "file.listFiles 的 file---" + f.getAbsolutePath());
searchFiles.add(f);
}
continue;
}
String name = file.getName(); // Special case for "split_config" since android base module non-master apks are
// initially installed with the "split_config" prefix/name.
if (name.endsWith(".apk") && (name.startsWith(componentName) || name.startsWith("split_config")) && name.contains(pathAbi)) {
// Log.e("136", "apkPaths ---" + file.getAbsolutePath());
apkPaths.add(file.getAbsolutePath());
continue;
}
if (name.equals(aotSharedLibraryName)) {
// Log.e("135", "soPaths ---" + file.getAbsolutePath());
soPaths.add(file.getAbsolutePath());
}
}
List<String> searchPaths = new ArrayList<>(); // Add the bare filename as the first search path. In some devices, the so
// file can be dlopen-ed with just the file name.
searchPaths.add(aotSharedLibraryName);
for (String path : apkPaths) {
Log.e("109","apk文件的路径:" + path);
searchPaths.add(path + "!lib/" + abi + "/" + aotSharedLibraryName);
}
for (String path : soPaths) {
Log.e("108","so文件的路径:" + path);
searchPaths.add(path);
}//打开so文件
flutterJNI.loadDartDeferredLibrary(loadingUnitId, searchPaths.toArray(new String[searchPaths.size()]));
}
}
3.2.4. 修改 Application 的代码并删除
com.google.android.play:core 的依赖。
override fun onCreate() {
super.onCreate()
val deferredComponentManager = Test(this, null)
val injector = FlutterInjector.Builder().setDeferredComponentManager(deferredComponentManager).build()
FlutterInjector.setInstance(injector)
至此,核心代码全部实现完毕。
3.3 本地验证
- 运行 flutter build appbundle --release --target-platform android-arm64 命令生成 app-release.aab 文件。
- . 运行下列命令将 app-release.aab 解析出本地可以安装的 apks 文件:java -jar bundletool.jar build-apks --bundle=app-release.aab --output=app.apks --local-testing
- 解压上一步生成的 app.apks 文件,在加压后的 app 文件夹下找到 splits/ComponentName-arm64_v8a_2.apk,继续解压此 apk 文件,在生成的 ComponentName-arm64_v8a_2 文件夹里找到 lib/arm64-v8a/libapp.so-2.part.so 文件。
- 执行 java -jar bundletool.jar install-apks --apks=app.apks 命令安装 app.apks,此时打开安装后的 app,跳转到 延迟组件 页面,此时页面不会成功加载,并且会提示你 “未在 sd 卡中找到 so 文件”。
- 将第 3 步找到的 lipase.so-2.part.so push 到指定文件夹下,命令如下 adb push libapp.so-2.part.so /storage/emulated/0/Android/data/com.example.deferred_official_demo/files。重启 app 进程,并重新打开 延迟组件的界面即可。
四、 总结
- 执行flutter build appbundle 前应先在项目的pubspec.yaml文件中deferred-components 下添加组件的名称 “- name: xxxxx”,否则执行flutter build appbundle不会走验证延迟加载组件的流程,如下:
自定义下载分发流程的几个注意点
重点:自定义实现文件服务器管理Dart共享库和资产文件,主要是管理.so文件,同时可以维护组件的名称、加载单元的id和.so文件之间的关系。客户端可以根据id下载对应的.so文件实现延迟加载。这一套流程,可以根据具体业务需求去完善,暂时先整理整体的流程。
1.注意:需要适配不同cpu架构的安卓设备。在服务器端应同时存储管理多个不同cpu架构的.so文件,客户端在下载时根据自身cpu架构类型下载对应的.so文件,如果没有对应上,延迟加载组件加载会失败。
2.基于第一点,so文件的名称格式需要定一个规范,例如:libapp-armeabi-v7a.so-2.part.so,其中armeabi-v7a为cpu架构,2为loadingUnitId,这样客户端可以根据自身cpu架构和loadingUnitId 下载对应的so文件。
Tip:延迟加载单元中文件的 setState 方法体内不能为空,必须有内容,可以加print(“…”);否则会闪退(不确定,但是遇到了)
参考资料:1.https://docs.flutter.dev/perf/deferred-components
2.https://www.sohu.com/a/474651066_121124376