微店 Android 插件化实践

随着微店业务的发展,App 不可避免地也遇到了 65535 的大坑。除此之外,业务模块增多、代码量增大所带来的问题也逐渐显现出来。模块耦合度高、协作开发困难、编译时间过长等问题严重影响了开发进程。在预研了多种方案以后,插件化似乎是解决这些问题比较好的一个方向。虽然业界已经有很多优秀的开源插件化框架,但预研后发现在使用上对我们会有一定的局限。要么追求低侵入性而 Hook 大量系统底层代码稳定性不敢保证,要么有很高的侵入性不满足微店定制化的需求。技术要很好地服务业务,我们想在稳定性和低侵入性上寻找一个平衡……

图 1 微店插件化改造流程

微店从 2016 年 4 月份开始进行插件化改造,到年底基本完成(可见图 1 路线)。现在一共有 29 个模块以插件化的方式运行,其中既有如商品、订单等的业务模块,也有像 Network、Cache 等的基础模块,目前我们的插件化框架已经很好地支持了微店多 Feature 快速并行迭代开发。完成一个插件化框架的 Demo 并不是多难的事儿,然而要开发一款完善的插件化框架却非易事, 本篇将我们插件化改造过程中所涉及到的一些技术点以及思考与大家分享一下。

插件化技术原理

插件化技术听起来高深莫测,实际要解决的就是三个问题:

  1. 代码加载;
  2. 资源加载;
  3. 组件的生命周期。

代码加载

我们知道 Android 和 Java 一样都是通过 ClassLoader 来完成类加载,对于动态加载在实现方式上有两种机制可供选择,分别为单 ClassLoader 机制和多 ClassLoader 机制:

  • 单 ClassLoader 机制:类似于 Google MulDex 机制,运行时把所有的类合并在一块,插件和宿主程序的类全部都通过宿主的 ClassLoader 加载,虽然代码简单,但是鲁棒性很差;

  • 多 ClassLoader 机制:每个插件都有一个自己的 ClassLoader,类的隔离性会很好。另外多 ClassLoader 还有一个优点,为插件的热部署提供了可能。如果插件需要升级,直接新建一个 ClassLoader 加载新的插件,然后替换掉原来的即可。

我们的框架在类加载时采用的是多 ClassLoader 机制,框架会创建两种 ClassLoader。第一种是 BundleClassLoader,每个 Bundle 安装时会分配一个 BundleClassLoader,负责该 Bundle 的类加载;第二种是 DispatchClassLoader,它本身并不负责真正类的加载,只是类加载的一个分发器,DispatchClassLoader 持有宿主及所有 Bundle 的 ClassLoader。关系如图 2 所示。

图2 插件化框架 ClassLoader 关系

如何 Hook 系统的 ClassLoader

应用类通过 PathClassLoader 来加载,PathClassLoader 存在于 LoadedApk 中,那么,如何才能替换 LoadedApkPathClassLoader 为我们的 DispatchClassLoader 呢?大家首先想到的是反射,但可惜 LoadedApk 对象是 @Hide 的,要替换首先需要 Hook 拿到 LoadedApk 对象,然后再通过反射替换 PathClassLoader。要反射两次特别是 LoadedApk 对象的获取我们认为风险很高,那还有没有其他方案可以注入 DispatchClassLoader?我们知道 Java 类加载时基于双亲委派机制,加载应用类的 PathClassLoader 其 Parent 为 BootClassLoader,能否在调用链上插入 DispatchClassLoader 呢?

图3 ClassLoader 委派关系

从图 3 大家可以看到,我们通过修改类的父子关系成功地把 DispatchClassLoader 插入到类的加载链中。修改类的父子关系直接通过反射修改 ClassLoaderparent 字段即可,虽然也是反射的私有属性,但相对于 Hook LoadedApk 这个私有对象的私有方法,风险要相对小很多。

类加载优化

不管是 DispatchClassLoaderBundleClassLoader,对于依赖 Bundle 类的查找都是通过遍历来实现的。由于我们把 Network、Cache 等基础组件也进行了插件化,所以 Bundle 依赖会比较多,这个遍历过程会有一定的性能损耗。我们想加载类时能否根据 ClassName 快速定位到该类属于哪一个 Bundle?最终,我们采用的方案是:在编译阶段会收集 Bundle 所包含的 PackageName 信息,在插件安装阶段构造一个 PackageName 与 Bundle 的对应表,这样加载 Class 时,根据包名可快速定位该 Class 属于哪一个 Bundle。当然,由于混淆的原因,不同插件的包名可能重复,对此,我们通过规范来进行保证。

资源加载

资源加载方案可选择的余地不多,都是用 AssetManager@hide 方法 addAssetPath,直接构造插件 Apk 的 AssetManagerResouce 对象。需要注意的是,我们采用的是资源合并的方案,通过 addAssetsPath 方法添加资源时,需要同时添加插件程序的资源文件和宿主程序的资源,及其依赖的资源。这样可以将 Resource 合并到一个 Context 中,解决资源访问时需要切换上下文的问题。另外,若不进行资源合并,插件也无法引入宿主的资源。

资源 ID 冲突问题

由于我们在构造 AssetManager 时,会把宿主、插件及依赖插件的资源合并在一起,那么宿主资源 ID 与插件资源 ID,或插件资源 ID 之间都有可能重复。我们知道资源 ID 是在编译时生成的,其生成的规则是 0xPPTTNNNN,要解决冲突就需要对资源进行分段,资源分段常用的有两种方式,分别为固定 PP 段与固定 TT 段。当时采用哪种资源分段方案对于我们来说是一个比较纠结的选择,固定 PP 段需要修改 AAPT,代价比较大,固定 TT 段相对来说则较为简单。初始我们采用的是固定 TT 段,但后来随着插件的增多,TT 段明显不够用,后来还是采用修改 AAPT 固定 PP 段。大家要上插件化,如果可预见后续插件比较多,建议直接采用固定 PP 段方案。

除了 ID 冲突以外,资源名也有可能重复,对于资源名重复的问题我们通过规范来约束,所有的插件都分配有固定的资源前缀。

如何 Hook 资源加载过程

Android 通过 Resources 对象完成资源加载,要 Hook 资源加载过程,首先想到的是能否替换系统的 Resources 对象为我们自定义的 Resources 对象。

调研发现要替换 Resouce 对象,至少要替换两个系统对象 LoadedApkContextImplmResources 属性,并且 LoadedApkContextImpl 都是私有对象,基于兼容性的考虑我们放弃了这种方案,而采用直接复写 ActivityApplication 的获取资源的相关方法来完成 Bundle 资源的加载。由于该方案对 ApplicationActivity 都有侵入,所以会带来一定的接入成本。为此,我们在编译过程中用代码注入的方式完成资源加载的 Hook,资源的加载操作对插件开发来说是完全透明的。

注:资源 Hook 涉及到复写的方法有如下几个:

Override
public Resources getResources() {
}

Override
public AssetManager getAssets() {
}

Override
public Resources.Theme getTheme() {
}

@Override
public Object getSystemService(String name) {
   if (Context.LAYOUT_INFLATER_SERVICE.equals(name)) {
      // 自定义 LayoutInflater
   }
   return super.getSystemService(name);
}

组件生命周期

对于 Android 来说,并不是类加载进来就可以使用了,很多组件都是有生命的。因此对于这些有血有肉的类,必须给它们注入生命力,也就是所谓的组件生命周期管理。很多插件化框架,比如 DroidPlugin 通过大量 Hook AMS、PMS 等来实现组件的生命周期,从而实现无侵入性。但技术肯定是服务于业务,四大组件真的都需要做插件化吗?在无侵入性和兼容性上该如何抉择?对于这个问题我们给出的答案是稳定压倒一切。综合当前的业务形态,我们插件化框架定位只实现 ActivityBroadCastReceiver 插件化,牺牲部分功能以求稳定性可控。BroadCastReceiver 插件化只是把静态广播转为动态广播,下面重点分解一下 Activity 插件化。

Activity 插件化

Activity 插件化实现大致有以下两种方式:

  • 一种是静态代理,写一个 PluginActivity 继承自 Activity 基类,把 Activity 基类里涉及生命周期的方法全都重写一遍;
  • 另一种方式是动态替换,宿主中预注册桩 StubActivity,通过在系统不同层次 Hook,从而实现 StubActivityRealActivity 之间的转换,以达到偷梁换柱的目的。

由于第一种方案对插件开发侵入性太大,我们采用的是第二种方案。既然如此,我们就需要对图 4 中①和②两个点进行 Hook。

图4 Hook 点选取

  • 对于①Hook:业内一般的做法是 Hook ActivityThread 类有成员变 mInstrumentation,它会负责创建 Activity 等操作,可以通过篡改 mInstrumentation 为我们自己的 InstrumentationHook,在其 execStartActivity() 方法中完成 RealActivity->StubActivity 的转化。

  • 对于②Hook:不同的框架选择在系统不同的层次上进行 Hook,来完成 StubActivity->RealActivity 的还原。

图5 现有插件化框架 Hook 策略

从图 5 可以看出第二种方案不管在哪一点上的 Hook 都会涉及到系统私有对象的操作,从而引入不可控风险。而我们的原则是尽量少地 Hook,若是以牺牲低侵入性为代价,有没有一种更安全的方案呢?并且由于只对 Activity 进行插件化,所有启动 Activity 的地方都是通过 ContextstartActivity 方法调起,我们只要复写 ApplicationActivitystartActivity() 方法,在 startActivity() 方法调用时完成 RealActivity->StubActivity,在类加载时实现 StubActivity->RealActivity 就可以了。同样,复写方法所引入的侵入性完全可以在编译期通过代码注入的方式解决掉。

注:实际上,虽然 startActivity 有很多重写方法,但我们只需复写以下两个就可以了:

@Override
public void startActivityForResult(Intent intent, int requestCode) {
}

@Override
public void startActivityForResult(Intent intent, int requestCode, Bundle options) {
}

另外,对于 ActivityLanchMode,我们是通过在宿主中每种 LaunchMode 都预注册了多个(8 个)StubActivity 来实现。值得注意的一点是,如果插件 Activity 为透明主题,由于系统限制不能动态设置透明主题,所以对于每种 LaunchMode 类型我们都增加了一个默认是透明主题的 StubActivity

为了尽可能地保证稳定性,我们插件 Activity 支持两种运行模式,一种是预注册模式,一种是免注册模式。对于静态插件(随 App 打包)我们默认运行在预注册模式下,对于动态插件(服务器下发)才运行在免注册模式下。值得说明的是,静态插件与宿主 AndroidManifest 合并是在编译期自动完成的。

插件间依赖

我们拆分插件时,首先明确的是每个插件的业务边界,有了边界才有所谓的内聚性,才能区分外部使用者和内部实现者。基于这样拆分,我们可以看出每个插件既可以依赖于其他插件,也可能被其他插件依赖。为了简化业务插件与基础插件之间的依赖关系,我们规定基础插件不能依赖业务插件,业务插件可以依赖基础插件,业务插件与业务插件之间、基础插件与基础插件之间可以互相依赖。总结来看,插件之间的依赖主要有两种形式:

  1. 页面跳转(比如商品 Bundle 跳转到店铺 Bundle 某一页面):Android 可以用 Intent 解耦页面跳转,但考虑到多端统一,我们采用的是类似于总线机制,所有跳转都通过 Page Bus 处理,每个页面都对应一个别名,跳转时根据别名来进行。

  2. 功能调用(商品 Bundle 用到店铺 Bundle 信息):我们把每个插件抽象为一个服务提供者,插件对外暴露的服务称之为本地 Service,它以 Interface 的形式定义,服务提供者保证版本之间的兼容。本地 Service 在插件的 AndroidManifest 中声明,插件安装时向框架注册本地 Service,其他插件使用时直接根据服务别名查询服务。我们会把本地 Service 的查询过程直接绑定到 Context 的 getSystemService() 方法上,整个使用过程就和调用 Android 系统服务一样。此外,除了服务以外,插件还有可能对外暴露一些 Class,为了增加内聚性,我们通过@annotation 的方式声明对外暴露的 Class,在编译阶段 Export 供其他插件依赖,未被注解的类就算是 public,对其他插件也是不可见的。

插件的依赖关系定义在每个插件的 AndroidManifest 文件中。

举个例子,下面是 Shop-Management 模块在 AndroidManifest 中的声明:

<!-- 以下定义的为 Shop-Management 依赖的 Bundle-->
<dependent-bundle android:name="com.koudai.weishop.lib.network" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.location" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.image" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.boostbus" android:versionName="7.7.0.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.base" android:versionName="7.7.5.0"/>
<dependent-bundle android:name="com.koudai.weishop.lib.account" android:versionName="7.7.0.0"/>

<!-- 以下定义的为 Shop-Management 对外暴露的服务-->
<local-service android:name="ShopManagerService" android:value="com.koudai.weishop.shop.management.impl.ShopManager"/>

其中,versionName 为声明的依赖插件的最小版本号,插件安装阶段会校验依赖条件是否满足,若不满足会进行相应处理(Debug 模式抛 RuntimException,Release 模式输出 error log 并上报监控后台)。

动态部署及 HotPatch

插件化以后,动态部署和 HotPatch 也是需要说明的两个点:

动态部署

我们框架支持 ActivityBroadcastReceiver 的免注册,若插件没有新增其他类型(Service、Provider)的组件,则该插件支持动态部署。由于我们采用多 ClassLoader 机制,理论上是支持热更新的,但考虑到插件有对外导出 Class,为了减少风险,我们对于动态插件生效时间延迟到应用切换至后台以后,当用户切换到后台时直接 Kill 进程。

注:

  1. 插件更新支持增量更新;
  2. 对于插件更新检查有两个触发时机:一个是进程初始化时(Pull),另一个是主动 Push 触发(Push)。

HotPatch

插件化后,App 分为宿主和插件,宿主为源码依赖,插件为二进制依赖。对于宿主和插件,我们采用不同的 HotPatch 方案:

  • 插件——因为插件支持动态部署,若插件需要补丁,我们直接升级插件即可。况且插件支持增
    是升级,补丁包的大小也可以得到有效控制;
  • 宿主——宿主不支持动态部署,只能走传统的 HotPatch 方案,经过多种方案的对比,我们采
    用的是类似于 Tinker 方案,具体原因大家可以参考《微信热补丁演进之路》。

但我们并不是直接使用的 Tinker,而是在实现思路上与 Tinker 一致,采用全 Dex 替换的方式来规避其他方案的问题。由于我们不仅业务组件实现了插件化,而且大部分基础组件(Network、Cache 等)也实现了插件化,所以宿主并不是很大(<2.5M),况且宿主里的代码都比较稳定。

微信的 Tinker 方案在补丁包的大小上的确有很大的优势,我们敬佩其技术探究的精神,但对于其稳定性持有怀疑态度,基于宿主包可控的前提下,我们选择牺牲流量来保证稳定性。

代码管理

我们定位每个插件都是可以独立迭代 App,插件化以后,整个的工程组织方式为如图 6 的形式。

图6 微店工程组织方式

在此之中每个工程都对应一个 Git 库,主库包含多个子库,对于这种工程结构,我们很自然地想到用 SubModule 来管理微店工程。然而事与愿违,使用一段 SubModule 后发现有两个问题严重影响开发效率:

  • 开发某个插件时,对于其他插件应该都是二进制依赖,不再需要其他插件的源码,但 SubModule 会把所有子工程的源码都 Checkout 出来。考虑到 Gradle 的生命周期,这样严重影响了编译速度;另外,主工程包含所有子工程的源码也增加误操作的风险(全量编译、引用本地包而非 Release 包);

  • 代码提交复杂且经常出现冲突:我们知道每次 Git 提交都会产生一个 Sha 值,主工程管理所有子工程的 Sha 值,每次子工程变动,除了提交子工程以外,还需要同步更新主工程的 Sha 值。这样每次子工程的变动都涉及到两次 Commit,更严重的是,如果两个人同时改动同一个子工程,但忘记了同步提交主工程的 Sha 值,则会产生冲突,而且这种情况下无法更新、无法回滚、无法合并,崩溃……

针对使用 Submodule 过程中遇到的问题,我们引入了 Repo 来管理工程代码。Repo 不像 Submodule 那样,通过建立一种主从关系,用主 Module 管理子 Module。在 Repo 里,所有 Module 都是平级关系,每个 Module 的版本管理完全独立于任何其他 Module,不会像 Submodule 那样,提交了子 Module 代码,也会对主 Module 造成影响。

另外,我们在使用过程中,还发现了另外一些好处:

  • 剥离了主 Module 和子 Module 的关系,检出、同步、提交等操作都比 Sumodule 要快好多倍;
  • 模块管理配置由一个陌生的 .gitmodules 变成了所有人都更熟悉的 XML 文件,便于配置管理。

开发调试

插件化以前,我们对所有模块都是源码依赖。插件化以后,运行某一模块时,仅对宿主及当前模块是源码依赖,对于其他模块全部是二进制依赖。集成方式的改变就涉及到如下两个问题:

  • 打包时如何集成插件包?
  • 如何进行断点调试?

插件包集成

我们插件的二进制包是 so 包,其实这些 so 都是正常的 Apk 结构,改为 so 放入 lib 目录只是为了安装时借用系统的能力从 Apk 中解压出来,方便后续安装。我们目前所有的库都是基于 Maven 来管理,插件既然是 so 包,正好借用 Maven 管理能力同时,基于开源的 Gradle 插件 android-native-dependencies 实现了插件的集成。

断点调试

开发插件时,对于其他插件的二进制包都是依赖的已发布版,所有已发布的插件都是混淆包。若开发过程中涉及到其他插件的断点调试,则会出现无法对应源码。

对于这种情况,我们制定了一个策略,在 Debug 模式下,会优先使用本地编译的包。若要调试其他插件,可以把插件源码检出来编译本地包(得益于 Repo 检出过程非常方便),打包过程若检索到有本地包,会替换掉从 Maven 远程仓库下载的包,当然,这个替换过程是通过编译脚本自动完成的。

总结

虽然 Android 插件化在国内发展有几年,各种方案百花齐放,但真的要在业务快速迭代的过程中完成插件化改造工作,其中酸爽也只有亲历者才能体会到。近年来随着 React Native、Weex 及微信小程序的兴起,很多以前需要插件化才能解决的问题,现在或许有了更好的解决方向。但,技术服务于业务,稳定压倒一切,与大家共勉。

作者: 彭昌虎,先后在华为、腾讯从事Android开发工作,2011年加入微店,负责口袋购物、微店等多款产品的架构设计,2016年主导微店App完成插件化改造工作。
责编: 唐小引(@唐门教主),欢迎技术投稿、约稿、给文章纠错,请发送邮件至[email protected]
版权声明: 本文为 CSDN 原创文章,未经允许,请勿转载。

你可能感兴趣的:(android,框架,插件化,微店)