Flutter 混合开发 - 动态下发 libflutter.so & libapp.so

背景

最近在做包体积优化,在完成代码混淆、压缩,裁剪ndk支持架构,以及资源压缩(如图片转webp、mp3压缩等)后发现安装包的中占比较大的仍是 so 动态库依赖。
image.png具体查看发现 libflutter.so 和 libapp.so 的体积是最大的,这两个动态库都是 flutter 集成进来的。image.png结合项目中 Flutter 的应用,Flutter 页面都是作为二级页面使用,而且页面使用频率很低,所以是不是可以把这两个 so 从 apk 中剔除,在应用启动后再动态下发呢?
如果可以实现,那么包体积又可以缩减 13.8 M,包体积在原基础上立减一半,收益非常可观!开搞!

实战

libflutter.so & libapp.so 如何引入项目的?

项目是以远程依赖方式引入 flutter,即 flutter 开发完成后打包 aar 发布到公司 maven。通过解压已打包的 aar 发现,aar 中仅有 libapp.so,并没有 libflutter.so。而唯一提到 libflutter.so 的只有打包时生成的 pom 文件。
image.png
那么就从宿主项目入手。要远程依赖 flutter,需要指定 repositories{} 。通过配置发现,除了公司 maven 仓库地址,还需要额外配置一个 "https://storage.flutter-io.cn/download.flutter.io",结合打包时生成的 pom 文件,可以猜测 libflutter.so 是在依赖解析过程中引入到项目中的。

allprojects {
    repositories {
        google()
        mavenCentral()

        //flutter 需要的仓库配置:
        maven {
            url '******'  //公司 maven 仓库地址
        }
        maven {
            url 'https://storage.flutter-io.cn/download.flutter.io'
        }
    }
}

如何剔除与上传 libflutter.so & libapp.so

知道了这两个 so 文件如何引入到项目中的,那么接下来就要考虑怎么剔除与上传。剔除的时机有两个时间节点:打包 aar 时,打包 apk 时。结合已了解的 so 文件引入时机,打包 aar 时只能剔除 libapp.so,显然这个时机不合适,那么下面就来看打包 apk 时怎么实现剔除并上传这两个 so 文件。
既然要在打包 apk 时剔除并上传,毫无疑问需要自定义 Gradle Plugin 和 Gradle Task。如何自定义不细讲,网上相关文章太多,自行查看。

这里考虑只在项目中使用,所以直接在项目中新建 buildSrc Module,在里面实现 Gradle Plugin。

自定义 Gradle Plugin

  1. 明确只在打 release 包时才需要剔除(因为谁关心 debug 包包体积呀!)
  2. 确定剔除 Task 执行的时机。剔除要在 merge 所有 so 之后才行,通过查看 task 列表,发现 “mergeReleaseNativeLibs” 就是非常不错的时机。
public class FlutterDynamicPlugin implements Plugin<Project> {
  @Override
  public void apply(Project project) {
    if (project.getPlugins().hasPlugin("com.android.application")) {
      project.afterEvaluate(project1 -> {
        AppExtension appExtension = project.getExtensions().getByType(AppExtension.class);
        appExtension.getApplicationVariants().all(variant -> {
          String variantName = StringUtil.capitalize(variant.getName());

          //只在 release 变体下生效
          if (!variantName.equalsIgnoreCase("release")) return;

          //自定义 Gradle Task
          EngineSoDynamicTask engineSoDynamicTask = project.getTasks().create("flutterSoDynamic" + variantName, EngineSoDynamicTask.class);
          
          //指定自定义 Task 执行时机:mergeReleaseNativeLibs -> flutterSoDynamicRelease
          Task mergeSOTask = project.getTasks().findByName("merge" + variantName + "NativeLibs");
          mergeSOTask.finalizedBy(engineSoDynamicTask);
        });
      });
    }
  }
}

自定义 Gradle Task

  1. 找到 libflutter.so
  2. 上传
  3. 剔除
  4. 记录上传信息(用于运行时下载)

public class EngineSoDynamicTask extends DefaultTask {
    @Input
    public String mergeNativeLibsOutputPath;

    @TaskAction
    public void optimizeEngineSo() {

        //从 app/build/intermediates/merged_native_libs/release/out/lib/arm64-v8a 中找到 libflutter.so
        File soFile = FileUtil.findSpecificFile(mergeNativeLibsOutputPath, "arm64-v8a", "libflutter.so");
        if (soFile == null || !soFile.exists()) return;

        //上传
        String url = HttpUtil.getInstance().upload(soFile);
        if (url != null){
            //记录上传信息
            write2Assets(url);
            //剔除
            soFile.delete();
        }
    }

    private void write2Assets(String url) {
        String content = "\"flutterSoUrl\":\"" + url + "\"";
        Write2AssetsUtil.getInstance().writeContent(content);
    }
}

这里以剔除 libflutter.so 为例,由于项目中只支持 arm64-v8a,所以只剔除了该架构下的。

坑点: 记录上传信息是通过向 assets 中插入 json 文件实现的,而上面只指定了自定义 Task 在 mergeReleaseNativeLibs Task 之后执行,这里就会偶现 assets 插入成功了,但打出的 apk 的 asstes 中并没有 json 文件。

原因: mergeReleaseNativeLibs Task 与 mergeReleaseAssets Task 没有指定的先后顺序,这就导致 assets 插入成功了,但被后续的 mergeReleaseAssets Task 覆盖掉了。

解决办法: 指定自定义 Task 、mergeReleaseNativeLibs Task、mergeReleaseAssets Task 三者先后顺序

EngineSoDynamicTask engineSoDynamicTask = project.getTasks().create("flutterSoDynamic" + variantName, EngineSoDynamicTask.class);
Task mergeNativeLibsTask = project.getTasks().findByName("merge" + variantName + "NativeLibs");
Task mergeAssetsTask = project.getTasks().findByName("merge" + variantName + "Assets");                    

// mergeReleaseNativeLibs -> flutterSoDynamicRelease -> mergeReleaseAssets
mergeNativeLibsTask.finalizedBy(engineSoDynamicTask);
mergeAssetsTask.dependsOn(engineSoDynamicTask);

运行时动态加载

libflutter.so & libapp.so 使用时机

要实现动态加载,先明确这两个 so 文件在何时用到,找到这个时间点,只要在其之前下载完成就,理论上就实现了运行时动态加载。
项目中使用的是官方多引擎方案(即 EngineGroup),所以先看它的构造函数中有何逻辑。

public class FlutterEngineGroup {
    
  public FlutterEngineGroup(@NonNull Context context) {
    this(context, null);
  }
    
  public FlutterEngineGroup(@NonNull Context context, @Nullable String[] dartVmArgs) {
    // FlutterInjector.instance() 该方法会创建一个 FlutterInjector 单例,
    //   FlutterInjector 实例创建过程中会创建 FlutterLoader 对象并赋值给 flutterLoader 变量
    FlutterLoader loader = FlutterInjector.instance().flutterLoader();
    if (!loader.initialized()) {
      loader.startInitialization(context.getApplicationContext());
      loader.ensureInitializationComplete(context.getApplicationContext(), dartVmArgs);
    }
  }
}

FlutterEngineGroup 构造函数中直接创建获取 FlutterLoader 对象,然后调用其 startInitialization() 和 ensureInitializationComplete()。限于篇幅,这里直接说结论:

  • startInitialization() 最终会执行 FlutterJNI#loadLibrary(),其内部调用 System.loadLibrary(“flutter”),实现加载 libflutter.so。
  • ensureInitializationComplete() 内部会准备一个 shellArgs 配置,最终调用 FlutterJNI#init() 执行。shellArgs 中有两条是关于 libapp.so 的。
public void ensureInitializationComplete({
	//...
	List<String> shellArgs = new ArrayList<>();
	//...
	shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName);
	shellArgs.add(
            "--"
                + AOT_SHARED_LIBRARY_NAME
                + "="
                + flutterApplicationInfo.nativeLibraryDir
                + File.separator
                + flutterApplicationInfo.aotSharedLibraryName);
	//...
}

通过上面可知,libflutter.so 和 libapp.so 都是在 FlutterEngineGroup 构造时调用的,那么只要在 FlutterEngineGroup 构造之前下载完成即可。

动态加载 libflutter.so

查看 FlutterEngineGroup 构造函数源码可知,libflutter.so 是通过 System.loadLibrary(“flutter”) 来实现加载的。结合 so 加载流程可知,将自定义的 so 文件路径注入到 classLoader#pathList#nativeLibraryDirectories 就可以实现优先加载,就可以实现 so 的动态加载了。这里我们直接复用 Tinker 的 TinkerLoadLibrary#installNativeLibraryPath()

动态加载 libapp.so

查看 FlutterEngineGroup 构造函数源码可知,libapp.so 是添加到一个配置中,然后调用 native 方法执行,所以无法想 libflutter.so 来实现。首先能想到的是能不能 hook 方法来自己实现配置,再次查看 FlutterEngineGroup 代码。
首先拿到 FlutterLoader 对象,那么看下 FlutterLoader 是怎么来的。

FlutterLoader loader = FlutterInjector.instance().flutterLoader();

public final class FlutterInjector {

	public static void setInstance(@NonNull FlutterInjector injector) {
		instance = injector;
	}

	public static FlutterInjector instance() {
		accessed = true;
		if (instance == null) {
			instance = new Builder().build();
		}
		return instance;
	}

	public static final class Builder {

		public Builder setFlutterJNIFactory(@NonNull FlutterJNI.Factory factory) {
			this.flutterJniFactory = factory;
			return this;
		}

		private void fillDefaults() {
			if (flutterJniFactory == null) {
				flutterJniFactory = new FlutterJNI.Factory();
			}

			if (executorService == null) {
				executorService = Executors.newCachedThreadPool(new NamedThreadFactory());
			}

			if (flutterLoader == null) {
				flutterLoader = new FlutterLoader(flutterJniFactory.provideFlutterJNI(), executorService);
			}
		}

		public FlutterInjector build() {
			fillDefaults();

			return new FlutterInjector(
				flutterLoader, deferredComponentManager, flutterJniFactory, executorService);
		}
	}
}

通过上面的代码可知,FlutterLoader 时在 FlutterInjector 构造时默认创建。同时值得注意的两点:

  • FlutterInjector 是单例模式,并提供 setInstance() 自行创建。
  • FlutterInjector 通过构造模式构建,并提供自行创建 FlutterJNI.Factory、FlutterLoader 等。

有这两点完全可以 hook FlutterLoader#ensureInitializationComplete()了,但实操下来发现代码量太大,实现难度太高。虽然没法 hook ensureInitializationComplete() 来修改配置,但在实操过程中发现重要信息。
image.png
大致意思是,下面的配置是为上面做兜底。如果我们把 libapp.so 剔除,那么这俩配置都无法生效,那我们可以再加一条来兜底啊,即把下载后 libapp.so 的存储路径配置上去。
结合之前的代码逻辑,shellArgs 最终会在 FlutterJNI#init() 中使用,而 FlutterJNI 又可以在 FlutterInjector 自行创建,那么问题不就简单了:

  • 新建自定义的 FlutterJNI 继承自 FlutterJNI,内部重写 init(),将下载后下载后 libapp.so 的存储路径添加到 shellArgs 中。
  • 在调用 FlutterEngineGroup 构造之前调用 FlutterInjector#setInstance() 将自定义的 FlutterJNI 注入进去。
class CustomFlutterJNI(private val appSOSavePath: String) : FlutterJNI(){
	override fun init(
		context: Context,
		args: Array<out String>,
		bundlePath: String?,
		appStoragePath: String,
		engineCachesPath: String,
		initTimeMillis: Long
	) {
		val hookArgs = args.toMutableList().run {
			add("--aot-shared-library-name=$appSOSavePath")
			toTypedArray()
		}
		super.init(context, hookArgs, bundlePath, appStoragePath, engineCachesPath, initTimeMillis)
	}

	class CustomFactory(private val appSOSavePath: String) : Factory(){
		override fun provideFlutterJNI(): FlutterJNI {
			return CustomFlutterJNI(appSOSavePath)
		}
	}
}
val appSOSavePath = "******"  // libapp.so 下载保存的存储路径
FlutterInjector.setInstance(FlutterInjector.Builder()
	.setFlutterJNIFactory(CustomFlutterJNI.CustomFactory(appSOSavePath))
	.build())
val engineGroup = FlutterEngineGroup(context)

小结

通过如下几步实现了 libflutter.so 和 libapp.so 的剔除、上传、动态加载:

  • 自定义 GradleTask 实现在 merged_native_libs/ 中查找指定 so 文件、上传、记录上传信息(写入 assets 中)、剔除。
  • 自定义 GradlePlugin 指定仅在 release 打包中使用,并指定自定义 GradleTask 执行时机。
  • 读取 asstes 信息并下载,下载完成后通过注入 so 加载目录和 hook FlutterJNI 实现动态加载 so 文件,最后调用 FlutterEngineGroup 实现 Flutter 初始化。

实现后的效果非常显著:
image.png

完整代码(仅供参考)

GitHub - StefanShan/flutterSoDynamic: 从 apk 中剔除 libflutter.so 和 libapp.so,并动态下发加载

优化

上面把所有流程跑通了,但有些地方还需要优化:

  • libflutter.so 是根据 flutter 版本生成的,libapp.so 为业务代码生成,所以需要区分上传,即做版本控制,减少重复上传。
  • 同样在下载时,也要根据版本判断,避免重复下载。
  • 动态加载失败时,需要做兜底处理,例如用 H5 页面来替代。

文章来源(更多文章请点击) 青杉

参考资料

到家Flutter动态化瘦身方案的探索 - 墨天轮
Android 重构之旅:动态下发 SO 库
Android 动态链接库 So 的加载
Android编译期动态添加assets





Hi,我是“青杉”,您可以通过如下方式关注我: