携程 Android App 插件化和动态加载实践

阅读数:33177 2015 年 11 月 4 日

编者按:本文为携程无线基础团队投稿,介绍它们已经开源的 Android 动态加载解决方案DynamicAPK,本文作者之一,携程无线研发总监陈浩然将会在ArchSummit 北京 2015 架构师大会上分享架构优化相关内容,欢迎关注。

携程 Android App 的插件化和动态加载框架已上线半年,经历了初期的探索和持续的打磨优化,新框架和工程配置经受住了生产实践的考验。本文将详细介绍 Android 平台插件式开发和动态加载技术的原理和实现细节,回顾携程 Android App 的架构演化过程,期望我们的经验能帮助到更多的 Android 工程师。

需求驱动

2014 年,随着业务发展需要和携程无线部门的拆分,各业务产品模块归属到各业务 BU,原有携程无线 App 开发团队被分为基础框架、酒店、机票、火车票等多个开发团队,从此携程 App 的开发和发布进入了一个全新模式。在这种模式下,开发沟通成本大大提高,之前的协作模式难以为继,需要新的开发模式和技术解决需求问题。

另一方面,从技术上来说,携程早在 2012 年就触到 Android 平台史上最坑天花板(没有之一):65535 方法数问题。旧方案是把所有第三方库放到第二个 dex 中,并且利用 Facebook 当年发现的hack 方法扩大点 LinearAllocHdr 分配空间(5M 提升到 8M),但随着代码的膨胀,旧方案也逐渐捉襟见肘。拆 or 不拆,根本不是可考虑问题,继续拆分 dex 是我们的唯一出路。问题在于:怎么拆才比较聪明?

其次,随着组织架构调整的影响,给我们的 App 质量控制带来极高的挑战,这种紧张和压力让我们的开发团队心力憔悴。此时除了流着口水羡慕前端同事们的在线更新持续发布能力之外,难道就没有办法解决 Native 架构这一根本性缺陷了吗?NO!插件化动态加载带来的额外好处就是客户端的热部署能力。

从以上几点根本性需求可以看出,插件化动态加载架构方案会为我们带来多么巨大的收益,除此之外还有诸多好处:

  • 编译速度提升

    工程被拆分为十来个子工程之后,Android Studio 编译流程繁冗的缺点被迅速放大,在 Win7 机械硬盘开发机上编译时间曾突破 1 小时,令人发指的龟速编译让开发人员叫苦不迭(当然现在换成 Mac+SSD 快太多)。

  • 启动速度提升

    Google 提供的 MultiDex 方案,会在主线程中执行所有 dex 的解压、dexopt、加载操作,这是一个非常漫长的过程,用户会明显的看到长久的黑屏,更容易造成主线程的 ANR,导致首次启动初始化失败。

  • A/B Testing

    可以独立开发 AB 版本的模块,而不是将 AB 版本代码写在同一个模块中。

  • 可选模块按需下载

    ​例如用于调试功能的模块可以在需要时进行下载后进行加载,减少 App Size

列举了这么多痛点,童鞋们早就心潮澎湃按捺不住了吧?言归正传,开始插件化动态加载架构探索之旅。

原理

关于插件化思想,软件业已经有足够多的用户教育。无论是日常使用的浏览器,还是陪伴程序员无数日夜的 Eclipse,甚至连 QQ 背后,都有插件化技术的支持。我们要在 Android 上实现插件化,主要需要考虑 2 个问题:

  • 编译期:资源和代码的编译
  • 运行时:资源和代码的加载

解决了以上 2 个关键问题,之后如何实现插件化的具体接口,就变成个人技术喜好或者具体需求场景差异而已。现在我们就针对以上关键问题逐一破解,其中最麻烦的还是资源的编译和加载问题。

Android 是如何编译的?

首先来回顾下 Android 是如何进行编译的。请看下图:



(点击图片放大)

整个流程庞大而复杂,我们主要关注几个重点环节:aapt、javac、proguard、dex。相关环节涉及到的输入输出都在图上重点标粗。

资源的编译

Android 的资源编译依赖一个强大的命令行工具:aapt,它位于<SDK>/build-tools/<buildToolsVersion>/aapt,有着众多的 命令行参数,其中有几个值得我们特别关注:

  • -I add an existing package to base include set

    这个参数可以在依赖路径中追加一个已经存在的 package。在 Android 中,资源的编译也需要依赖,最常用的依赖就是 SDK 自带的 android.jar 本身。打开 android.jar 可以看到,其实不是一个普通的 jar 包,其中不但包含了已有 SDK 类库 class,还包含了 SDK 自带的已编译资源以及资源索引表 resources.arsc 文件。在日常的开发中,我们也经常通过@android:color/opaque_red形式来引用 SDK 自带资源。这一切都来自于编译过程中 aapt 对 android.jar 的依赖引用。同理,我们也可以使用这个参数引用一个已存在的 apk 包作为依赖资源参与编译。

  • -G A file to output proguard options into.

    资源编译中,对组件的类名、方法引用会导致运行期反射调用,所以这一类符号量是不能在代码混淆阶段被混淆或者被裁减掉的,否则等到运行时会找不到布局文件中引用到的类和方法。-G 方法会导出在资源编译过程中发现的必须 keep 的类和接口,它将作为追加配置文件参与到后期的混淆阶段中。

  • -J specify where to output R.java resource constant definitions

在 Android 中,所有资源会在 Java 源码层面生成对应的常量 ID,这些 ID 会记录到 R.java 文件中,参与到之后的代码编译阶段中。在 R.java 文件中,Android 资源在编译过程中会生成所有资源的 ID,作为常量统一存放在 R 类中供其他代码引用。在 R 类中生成的每一个 int 型四字节资源 ID,实际上都由三个字段组成。第一字节代表了 Package,第二字节为分类,三四字节为类内 ID。例如:

复制代码
//android.jar 中的资源,其 PackageID 为 0x01
public static final int cancel = 0x01040000;
// 用户 app 中的资源,PackageID 总是 0x7F
public static final int zip_code = 0x7f090f2e;

我们修改 aapt 后,是可以给每个子 apk 中的资源分配不同头字节 PackageID,这样就不会再互相冲突。

代码的编译

大家对 Java 代码的编译应该相当熟悉,只需要注意以下几个问题即可:

  • classpath

    Java 源码编译中需要找齐所有依赖项,classpath 就是用来指定去哪些目录、文件、jar 包中寻找依赖。

  • 混淆。

    为了安全需要,绝大部分 Android 工程都会被混淆。混淆的原理和配置可参考Proguard 手册

有了以上背景知识,我们就可以思考并设计插件化动态加载框架的基本原理和主要流程了。

实现

实现分为两类:1. 针对插件子工程做的编译流程改造,2. 运行时动态加载改造(宿主程序动态加载插件,有两个壁垒需要突破:资源如何访问,代码如何访问)。

插件资源编译

,针对插件的资源编译,我们需要考虑到以下几点:

  • 使用-I参数对宿主的 apk 进行引用。

    据此,插件的资源、xml 布局中就可以使用宿主的资源和控件、布局类了。

  • 为 aapt 增加--apk-module参数。

    如前所述,资源 ID 其实有一个 PackageID 的内部字段。我们为每个插件工程指定独特的 PackageID 字段,这样根据资源 ID 就很容易判明,此资源需要从哪个插件 apk 中去查找并加载了。在后文的资源加载部分会有进一步阐述。

  • 为 aapt 增加--public-R-path参数。

    按照对 android.jar 包中资源使用的常规手段,引用系统资源可使用它的 R 类的全限定名android.R来引用具体 ID,以便和当前项目中的 R 类区分。插件对于宿主的资源引用,当然也可以使用base.package.name.R来完成。但由于历史原因,各子 BU 的“插件”代码是从主 app 中解耦独立出去的,资源引用还是直接使用当前工程的 R。如果改为标准模式,则当前大量遗留代码中R都需要酌情改为base.R,工程量大并且容易出错,未来对 bu 开发人员的使用也有点不够“透明”。因此我们在设计上做了让步,额外增加--public-R-path参数,为 aapt 指明了base.R的位置,让它在编译期间把 base 的资源 ID 定义在插件的 R 类中完整复制一份,这样插件工程即可和之前一样,完全不用在乎资源来自于宿主或者自身,直接使用即可。当然这样做带来的副作用就是宿主和插件的资源不应有重名,这点我们通过开发规范来约束,相对比较容易理解一些。

插件代码编译

针对插件的代码编译,需要考虑以下几点:

  • classpath

    对于插件的编译来说,除了对 android.jar 以及自己需要的第三方库进行依赖之外,还需要依赖宿主导出的 base.jar 类库。同时对宿主的混淆也提出了要求:宿主的所有 public/protected 都可能被插件依赖,所以这些接口都不允许被混淆。

  • 混淆。

    插件工程在混淆的时候,当然也要把宿主的混淆后 jar 包作为参考库导入。

自此,编译期所有重要步骤的技术方案都已经确定,剩下的工作就只是把插件 apk 导入到先一步生成好的 base.apk 中并重新进行签名对齐而已。

万事俱备,只欠表演。接下来我们看看在运行时插件们是如何登台亮相的。

运行时资源的加载

平常我们使用资源,都是通过 AssetManager 类和 Resources 类来访问的。获取它们的方法位于 Context 类中。

Context.java

复制代码
/** Return an AssetManager instance for your application's package. */
public abstract AssetManager getAssets();
/** Return a Resources instance for your application's package. */
public abstract Resources getResources();

它们是两个抽象方法,具体的实现在 ContextImpl 类中。ContextImpl 类中初始化 Resources 对象后,后续 Context 各子类包括 Activity、Service 等组件就都可以通过这两个方法读取资源了。

ContextImpl.java

复制代码
private final Resources mResources;
@Override
public AssetManager getAssets() {
return getResources().getAssets();
}
@Override
public Resources getResources() {
return mResources;
}

既然我们已经知道一个资源 ID 应该从哪个 apk 去读取(前面在编译期我们已经在资源 ID 第一个字节标记了资源所属的 package),那么只要我们重写这两个抽象方法,即可指导应用程序去正确的地方读取资源。

至于读取资源,AssetManager 有一个隐藏方法 addAssetPath,可以为 AssetManager 添加资源路径。

复制代码
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
synchronized (this) {
int res = addAssetPathNative(path);
makeStringBlocks(mStringBlocks);
return res;
}
}

我们只需反射调用这个方法,然后把插件 apk 的位置告诉 AssetManager 类,它就会根据 apk 内的 resources.arsc 和已编译资源完成资源加载的任务了。

以上我们已经可以做到加载插件资源了,但使用了一大堆定制类实现。要做到“无缝”体验,还需要一步:使用 Instrumentation 来接管所有 Activity、Service 等组件的创建(当然也就包含了它们使用到的 Resources 类)。

话说 Activity、Service 等系统组件,都会经由 android.app.ActivityThread 类在主线程中执行。ActivityThread 类有一个成员叫 mInstrumentation,它会负责创建 Activity 等操作,这正是注入我们的修改资源类的最佳时机。通过篡改 mInstrumentation 为我们自己的 InstrumentationHook,每次创建 Activity 的时候顺手把它的 mResources 类偷天换日为我们的 DelegateResources,以后创建的每个 Activity 都拥有一个懂得插件、懂得委托的资源加载类啦!

当然,上述替换都会针对 Application 的 Context 来操作。

运行时类的加载

类的加载相对比较简单。与 Java 程序的运行时 classpath 概念类似,Android 的系统默认类加载器 PathClassLoader 也有一个成员 pathList,顾名思义它从本质来说是一个 List,运行时会从其间的每一个 dex 路径中查找需要加载的类。既然是个 List,一定就会想到,给它追加一堆 dex 路径不就得了?实际上,Google 官方推出的 MultiDex 库就是用以上原理实现的。下面代码片段展示了修改 pathList 路径的细节:

MultiDex.java

复制代码
private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
File optimizedDirectory)
throws IllegalArgumentException, IllegalAccessException,
NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
/* The patched class loader is expected to be a descendant of
* dalvik.system.BaseDexClassLoader. We modify its
* dalvik.system.DexPathList pathList field to append additional DEX
* file entries.
*/
Field pathListField = findField(loader, "pathList");
Object dexPathList = pathListField.get(loader);
expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
}

当然,针对不同 Android 版本,类加载方式略有不同,可以参考MultiDex 源码做具体的区别处理。

至此,之前提出的四个根本性问题,都已经有了具体的解决方案。剩下的就是编码!

编码主要分为三部分:

  • 对 aapt 工具的修改。
  • gradle 打包脚本的实现。
  • 运行时加载代码的实现。

具体实现可以参考我们在 GitHub 上的开源项目DynamicAPK

收益与代价

任何事物都有其两面性,尤其像动态加载这种使用了非官方 Hack 技术的方案,更需要在规划阶段把收益和代价考虑清楚,方便完成后进行复盘。

收益

  • 插件化架构适应现有组织架构和开发节奏需求,各 BU 不但从代码层面,更从项目控制层面做到了高内聚低耦合,极大降低了沟通成本,提高了工作效率。
  • 拆分成多个小的插件后,dex 从此告别方法数天花板。
  • HotFix 为 app 质量做好最后一层保障方案,再也没有无法挽回的损失了,而且现在 HotFix 的级别粒度可控,即可以是传统 class 级别(直接使用 pathClassLoader 实现),也可以是带资源的 apk 级别。
  • ABTesting 脱离古老丑陋的 if/else 实现,多套方案随心挑选按需加载。
  • 编译速度大大提高,各 BU 只需使用宿主的编译成果更新编译自己子工程部分,分分钟搞定。
  • App 宿主 apk 大大减小,各业务模块按需后台加载或者延迟懒加载,启动速度优化,告别黑屏和启动 ANR。
  • 各 BU 插件 apk 独立,谁胖谁瘦一目了然,app size 控制有的放矢。

以上收益,基本达到甚至超出了项目的预期目标: D

代价

  • 资源别名

    Android 提供了强大的资源别名规则,参考可以获取更多细节描述。但不幸的是,在三星 S6 等部分机型上使用资源别名会出现宿主资源和插件资源 ID 错乱导致资源找不到的问题。无奈只能禁止使用这一技术,所幸放弃这个高级特性不会引起根本性损失。

  • 重名资源

    如前文所述的原因,宿主的资源 ID 会在插件中完整复制一份。失去了包名这一命名空间的保护,重名资源会直接造成冲突。暂时通过命名规范的方式规避,好在良好的命名习惯也是各开发应该做到的,因此解决代价较小。

  • 枚举

    很多控件都会使用枚举来约束属性的取值范围。不幸的是 Android 的枚举居然是用命名来唯一确定 R 中生成的 id 常量,毫无命名空间或者所属控件等顾忌。因为上一点同样的原因,宿主和插件内的同名枚举会造成 id 冲突。暂时同样通过命名规范的方式规避。

  • 外部访问资源能力。

    对于极少数需要从外部访问 apk 资源的场合(例如发送延时通知),此时 App 尚未启动,资源的获取由系统代劳,理所当然无法洞悉内部插件的资源位置和获取方式。对于这种情况实在无能为力,只好特别准许此类资源直接放在宿主 apk 内。

以上代价,或者无伤大雅,或者替代方案成本非常低,都在可接受范围内。

未来优化

还有一些高级特性,因为优先级关系暂未实现,但随着各业务线的开发需求也被提到优化日程上来,如:

  • 插件工程支持 so 库。
  • 插件工程支持 lib 工程依赖、aar 依赖、maven 远程依赖等各种高级依赖特性。
  • IDE 友好,让开发人员可以更方便的生成插件 apk。

开源

经过以上介绍,相信各位对携程 Android 插件化开发和动态加载方案有了初步了解。细节请移步 GitHub 开源项目DynamicAPK。携程无线基础研发团队未来会继续努力,为大家分享更多项目实践经验。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论