Android Flutter 多实例实践

avatar
开发 @网易智企

引言

Flutter CLI 工具支持将 Flutter Module 打包成 Android AAR 包以供外部依赖使用,即 Flutter AAR。在一个没有使用 Flutter 技术栈的 Android 工程中集成 Flutter AAR 是没有任何问题的,但如果目标工程本身已经使用了 Flutter 框架,在此基础上再接入 Flutter AAR 就会失败,我们称之为 Flutter 多实例问题。本文主要介绍在 Android 平台下 Flutter 多实例问题的一种解决方案。

背景

企业的业务往往是复杂多样的,如果是 ToC 的业务,我们大多时候需要开发一个体验良好的应用 APP;而如果是 ToB 的业务,我们往往需要提供一个易于接入和使用的 SDK。在 ToC 业务上,Flutter 框架提供的跨平台、高效开发与高性能特性,使得移动端应用开发变得更加简单且高效;那在 ToB 业务上,SDK 的开发是否能够享受 Flutter 框架提供的这些红利呢?这一点对于像我们网易云信这样的服务、能力提供商而言尤为重要。网易云信是集网易 21 年 IM 以及音视频技术打造的融合通信云服务专家,稳定易用的通信与视频 PaaS 平台,其服务大多以能力 SDK 的形式对外提供,如果能够提高 SDK 的生产效率和研发效能,好处不言而喻。所以,上面的问题答案当然是肯定的!就像使用 Flutter 开发 APP 一样,我们同样可以使用 Flutter 进行 SDK 开发,从而在 Android / iOS 甚至更多平台中共享一致的业务逻辑实现,减小人力、提高生产效率和研发效能。

在使用 Flutter 进行 SDK 开发时,产物的打包方式主要有以下两种形式:

  • Flutter Package / Flutter Plugin:该打包方式需要以 Dart 源码形式发布到 Pub.dev 或 GitHub,第三方开发者在接入时本质上是以源码的形式依赖,同时接入方本地需要搭建并引入 Flutter 开发环境。此种方式有明显的缺陷:首先,源码发布会将 SDK 内部实现细节完全暴露在外( Flutter 框架并未提供类似 Proguard 的混淆工具),这对企业的非开源项目而言是不可接受的;其次,它变相要求接入方使用 Flutter 技术栈,这对于当前没有在目标项目中使用 Flutter 开发的接入方而言,门槛较高不说,接入体验也不太友好。
  • Android AAR:AAR 是 Android 应用官方的依赖形式,并不存在明显的短板。通过 Flutter 框架提供的 CLI 工具,可以很方便地将 Flutter Module 打包成 AAR 发布出去,不用担心泄漏业务源码,也不损失接入体验。因为打包工具会将 Flutter 层的业务代码编译成 AOT 共享库,而平台层的 Java 业务代码则可以开启混淆避免反编译(为了简便,后面统一使用 Flutter AAR 命名由 Flutter Module 打包而成的 Android AAR 包)。

综上所言,对于企业的一个商业 SDK 项目来说,如果选择使用 Flutter 技术栈进行开发,那么使用 Flutter AAR 形式来发布才是明智之举。但其实这又会引入新的问题。在前文 Flutter 混合开发基础 中我们介绍了,一个 Flutter APP 的包结构,它包含有引擎库 libflutter.so、业务库 libapp.so、 以及flutter_assets 等部分。同理,一个 Flutter Module 打包出来的 AAR 也会包含类似的结构以及产物文件。那在一个 Flutter APP 中,应该以何种姿势接入 Flutter AAR 呢?可以预见的是,它们之间必然存在冲突,文件冲突已经显而易见,类、资源、甚至 Flutter Engine 也可能会冲突,这种常规的 Flutter AAR 包显然是无法集成到 Flutter APP 工程中使用的。有问题就有答案,接下来,我们就一起来分析、探索该问题的解决方案。

Flutter APP 集成 Flutter AAR 问题分析

上面说到 Flutter APP 无法集成常规打包出来的 Flutter AAR,因为存在一系列的冲突,但具体会出现什么样的错误,还是需要我们真正动手去集成才能知道。这个环节感兴趣的小伙伴可以亲自动手尝试,不再赘述,下面直接给出结论说明两者共存存在哪些问题:

  • 构建失败,其实就是因为文件、类冲突导致编译失败。主要冲突有:
    • Flutter 版本依赖冲突:Flutter APP 宿主工程与 Flutter AAR 使用的 Flutter 版本不一致导致,包括 Flutter Embedding Jar 与 Flutter SO Jar,前者包含平台层 Java 代码,后者包含 libflutter.so 引擎库文件。通过 Gradle 我们可以解决这个依赖的版本冲突,例如强制使用其中某个版本,但这样做极有可能会出现运行时错误。
    • Flutter Plugin 平台代码 / 资源冲突:Flutter APP 和 Flutter AAR 引用了相同的 Plugin 但版本不一致导致。插件中会包含平台层的代码,版本不一致同样可能会导致编译失败或者运行时错误。
    • GeneratedPluginRegistrant.java 文件冲突:该文件为 Flutter 工具生成的插件自动注册类,用于 Flutter Engine 启动时自动加载所需插件。Flutter APP 与 Flutter AAR 均有对应的类文件,负责加载各自依赖的插件,两者缺一不可。
    • libapp.so 冲突:这是 Dart 代码经过 AOT 生成的动态库,Flutter APP 和 Flutter AAR 都会生成与其对应的 so 库,我们不能单纯的只使用它们其中之一,因为它们本身包含的 AOT 代码是从不同的源码编译过来的。
  • 运行时错误
    • 同一个 Flutter Engine 不支持加载多个 AOT 库:Flutter Engine 在初始化时会动态链接 libapp.so 这个 AOT 库,解析其中的数据段,并执行代码段中的机器指令。但在我们的场景中,运行时其实是包含有两个 AOT 库的,它们都需要加载到 Flutter Engine 中来,使用同一个Engine 是无法满足需求的,因为在 Flutter 的实现中,一个 Engine 只能对应一个 AOT 库。
    • 图片资源、字体库无法正常显示:此类资源会被打包至 flutter_assets 中,并且会生成对应的 Manifest 资源描述清单文件。但 Flutter APP 生成的资源清单文件会覆盖 Flutter AAR 中的资源清单文件,这样导致 Flutter Engine 在加载资源时,无法从清单文件中查询到对应的资源,因此加载失败。

以上就是我们在 Flutter APP 中接入 Flutter AAR 遇到的问题。针对这些问题,我们首先想到的是,Flutter Team 或者开源社区是不是已经有此类问题的解决方案了?但在经过调研后发现目前并没有。Flutter 框架是支持多个 Engine 的,包括 Flutter 2.0 新支持的 Engine Group 仅支持加载和运行同一个 AOT 库下的代码,明显不能满足我们的需求。我们还给官方提了对应 Issue(github.com/flutter/flu…) 进行讨论,但是暂时还没有得到满意的解决方案,为此我们不得已走上了自己探索解决方案的自强之路。

解决方案探索

通过上面的分析,我们已经了解了接入过程中出现的具体错误以及出错原因。在真正着手探索解决方案前,还应设立目标解决方案应该满足的一些原则:

  • 首先方案应该朝着最小引擎改动、甚至无改动的方向努力。因为 Flutter 框架一直在不断迭代演进,如果我们修改了引擎这块的逻辑,除非这些改动能通过 PR 进入主干分支,否则引擎一旦更新,我们的方案就得重新适配,后期维护工作大。
  • 其次方案应该尽量不依赖宿主工程做额外的改造或支持。首先 Flutter APP 接入 Flutter AAR 就跟普通 Android APP 接入 Android AAR 一样简单,不应引入额外的插件或是 Gradle 脚本;其次 Flutter AAR 和 Flutter APP 的 Flutter 运行时环境应该尽量隔离。

明确目标之后,我们再来看看入手点在哪里。由于需要尽量避免引擎改动,那应该是自上而下,首先从应用层切入,看能否找到对策。这就需要我们深入源码,从上到下了解 Flutter 框架的初始化、运行机制。这里不做单独讲解,在具体问题分析解决上再说明。现在我们再回过头来看最初遇到的一系列问题,并尝试运用所掌握的 Android 、Flutter 框架知识来解决。

Class 冲突解决

Class 冲突是因为 Flutter AAR 与 Flutter APP 都有自己的 Plugins 依赖、以及可能会依赖不同版本的 Flutter Embedding Jar,这些依赖库里都包含有平台代码,这会导致编译期类重复而失败。那如何解决这个问题呢? 最简单也是最暴力的方法就是对 Flutter AAR 依赖的所有 Plugin 以及 Embedding Jar 源码进行重命名(修改类名或者包名),虽然能解决问题,但工作量巨大、修改面广、不灵活,一旦 Plugin 或 Flutter 版本更新都需要重新修改。

那有没有更好的办法呢?答案是自定义ClassLoader。具体的,在构建 Flutter AAR 时,在源代码编译成 .class 阶段完成之后,将所有的插件、Flutter Embedding Jar 对应的 .class 文件搜集起来,打包成一个 DEX 文件放入 Flutter AAR 的 assets 中。在运行时,需要将 assets 下的 DEX 文件拷贝到应用的 data 私有目录下,再通过 DexClassLoader 去动态加载这个 DEX。这里需要注意的是 DEX 文件是版本号的概念的,它跟 Flutter AAR 的版本号是绑定的,意味着每次加载这个 DEX 时,我们首先需要检查当前私有目录下的文件版本是否与 Flutter AAR 版本一致,一致则直接加载即可,不一致需要删除原 DEX 文件并重新拷贝后再加载。关键代码如下:

image.png

image.png

针对 DEX 文件的加载一般而言我们只需要使用 DexClassLoader 这个系统类就行了,但这里我们需要继承 BaseDexClassLoader,并重写 findClass 方法。

默认类的加载基于双亲委派模型,一般都是先请求父加载器加载,如果父加载器加载失败子加载器才有机会加载。但在这里,我们 findClass 的逻辑需要反其道而行之。Flutter AAR 需要加载的类应该优先使用子加载器从 DEX 文件中加载,加载失败后才能通过父加载器加载。代码如下:

image.png

库文件冲突解决

libflutter.so 是Flutter Engine 动态库文件,在运行时会被 Flutter Embedder Jar 加载进来。这个库文件冲突,我们不能单纯使用宿主中同名的库文件,因为两者的 Engine 版本可能不一致以及不违背运行时 Flutter 版本隔离的目标。

这里解决冲突最简单的方法就是重命名。通过阅读代码,我们发现 Android 以 so 库的路径为 key 保存所有已经加载的动态库,即便是完全相同的 so 库,只要文件路径不一致,就可以同时 load 进来。因此,这里通过重命名能解决文件冲突的问题,也不会影响到 so 的加载。

libapp.so 冲突也是类似的,我们同样需要对 Flutter AAR 中的 libapp.so 重命名。此外,我们还需要特殊处理这两个 so 的加载流程。因为 Flutter 运行时硬编码了动态库的名称,如果不修改加载流程,在查考库时就会找到 Flutter APP 生成的库文件,而不是我们 Flutter AAR 的库文件。

Flutter Engine 的初始化是在 FlutterLoader 这个类中,在这里会加载 libflutter.so 并配置一系列的参数初始化 Native Engine。我们需要做的就是替换 libflutter.so 的加载逻辑,转而去加载重命名后的 Engine 库文件。对于 libapp.so ,它并不是在 Java 层加载的,而是由 Native Engine 通过 dlopen 链接的。通过查阅 Engine 的代码我们发现通过 --aot-shared-library-name 选项可以设置要加载的目标 libapp.so 路径。关键代码如下:

image.png

Flutter 资源冲突解决

Flutter 相关资源是打包放到 assets 目录下的,且通过对应的 Manifest 文件来声明,分别是:**FontManifest.json 与 AssetsManifest.json **文件。这两个文件分别列出了 Flutter 依赖的所有字体资源与路径映射关系、图片资源与路径映射关系。

Flutter-Engine 在运行时通过这两个文件来解析图片与字体资源,Flutter AAR 中虽然也包含了这两个文件,但会被 Flutter APP 宿主中的同名文件覆盖,导致字体或资源无法加载。所以,这里有两个简单方案:

  • 支持编译期合并对应的资源清单 json 文件;这需要开发 Plugin 插件供宿主使用,实现复杂而且接入不友好;
  • Flutter AAR 中抽离出一个独立的资源包 Package 供 Flutter APP 依赖,资源包中仅包含 Flutter AAR 引用的所有图片、字体资源(不包含任何业务逻辑,因此可以放心的发布到pub平台),宿主在 Flutter 层依赖这个 Package,这样宿主在构建时 Flutter 工具会合并所有的的资源,并生成完整的资源清单文件。

至此,我们解决了 Flutter AAR 与 Flutter APP 的共存问题。当然整个方案落地下来,其中还会碰到其他一些问题,比如:生成的 DEX 文件需要访问宿主中的其他类的时候,在混淆启用的情况下,应该如何保证 DEX 访问主ClassLoader中的类、方法没有问题;再如:Flutter AAR 的 DEX 中如果包含有 Android 组件怎么办?Android 四大组件都是需要由应用的主ClassLoader进行加载的,如果主 DEX 中没有包含这些类,那么肯定启动失败;等等诸如此类问题,这里不再一一列举。

总结

下图所示为 Flutter 多实例运行时的架构图。类似于多 Flutter Engine,以上方案实现的多 Flutter 实例,也是通过创建多个 Native 的 AndroidShellHolder 来实现的。不同的是,在多 Engine 下不同的 ShellHolder 绑定相同的 libapp.so,而多实例下绑定的是不同的 libapp.so ,因此该方案能在运行时隔离 Flutter APP 与 Flutter AAR 的 Flutter 运行时环境。

image.png

该方案的主要优势表现在:

  • 无 Engine 定制,可维护性较高
  • Flutter APP 与 Flutter AAR 的 Flutter 版本、运行时环境相互独立

有得必有失,相对地,在其他方面,该方案有所不足:

  • 使用了独立的 Flutter Engine 库文件,因此会导致包体积增加
  • 会加载两个不同的 Flutter Engine ,内存会有所增加

综上,在 SDK 开发中采用 Flutter 技术,同样能够发挥 Flutter 在 APP 开发中的优势,前提是我们能够解决好 Flutter 多实例的问题。本文主要讲解了 Android Flutter 多实例的一种实现思路,希望能够对大家有所帮助。

作者简介

李成达,网易云信资深移动端开发工程师,热衷于研究跨平台开发技术以及工程提效,目前主要负责视频会议组件化 SDK 的相关研发工作。

更多技术干货,欢迎关注【网易智企技术+】微信公众号