微店 Android 插件化实践

来源:互联网 发布:大漠驼铃 php 编辑:程序博客网 时间:2024/05/17 03:19

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

这里写图片描述

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

技术原理

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

代码加载
资源加载
组件的生命周期
代码加载

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

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

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

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

如何Hook系统的ClassLoader?

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

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

类加载优化

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

资源加载

资源加载方案可选择的余地不多,都是用AssetManager的@hide方法addAssetPath,直接构造插件apk的AssetManager和Resouce对象。需要注意的是我们采用的是资源合并的方案, 通过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对象,至少要替换两个系统对象LoadedApk、ContextImpl的mResources属性,并且LoadedApk及ContextImpl都是私有对象,基于兼容性的考虑我们放弃了这种方案,而采用直接复写Activity及Application的获取资源的相关方法来完成Bundle资源的加载。由于该方案对Application及Activity都有侵入, 所以会带来一定的接入成本。为了减少接入成本,我们在编译过程中用代码注入的方式完成资源加载的Hook,资源的加载操作对插件开发来说是完全透明的。

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

Override
public Resources getResources()

Override
public AssetManager getAssets()

Override
public Resources.Theme getTheme()

Override
public Object getSystemService(String name)

组件生命周期

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

Activity插件化

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

一种是静态代理,写一个PluginActivity继承自Activity基类,把Activity基类里面涉及生命周期的方法全都重写一遍。
另一种方式是动态替换,宿主中预注册桩StubActivity,通过在系统不同层次Hook从而实现StubActivity和RealActivity之间的转换,以达到偷梁换柱的目的。
由于第一种方案对插件开发侵入性太大,我们采用的是第二种方案。采用第二种方案,那么我们需对下图中①和②两个点进行Hook。

这里写图片描述

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

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

这里写图片描述

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

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

这里写图片描述

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

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

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

备注:1. 插件更新支持增量更新 2. 对于插件更新检查有两个触发时机:一个是进程初始化时(Pull),另一个是主动Push触发(Push)
HotPatch: 插件化以后,App分为宿主和插件,宿主为源码依赖,插件为二进制依赖。对于宿主和插件我们采用不同的HotPatch方案 :
插件:因为插件支持动态部署,若插件需要补丁,我们直接升级插件即可,况且插件支持增是升级,补丁包的大小也可以得到有效控制。
宿主:宿主不支持动态部署,只能走传统的HotPatch方案,经过多种方案的对比,我们采用的是类似于Tinker方案,具体原因大家可以参考微信热补丁演进之路
但我们并不是直接使用的Tinker,而是在实现思路上与Tinker一致,采用全Dex替换的方式来规避其他方案的问题。由于我们不仅业务组件实现了插件化,而且大部分基础组件(network、cache)等也实现了插件化,所以宿主并不是很大(<2.5M),况且宿主里的代码都比较稳定。

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

代码管理

我们定位每个插件都是可以独立迭代App,插件化以后,我们整个的工程组织方式为如下形式:
这里写图片描述
图中每个工程都对应一个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插件化在国内发展有几年,各种方案百花其放,但真的要在业务快速迭代的过程中完成插件化改造工作,其中酸爽也只有亲历者才能体会到。近年来随着RN、Weex及微信小程序的兴起,很多以前需要插件化才能解决的问题,现在或许有了更好的解决方向……

结束语:技术服务于业务,稳定压倒一切

原创粉丝点击