插件化,一个陌生有熟悉的名词,从我们学习Android伊始,总能隐约听到关于它的消息,从360的RePlugin,到DiDi的VirtualAPK更新换代,再到Tencent Shadow横空出世,可以说插件化已经从一个剑走偏锋的黑科技,蜕变成了独步天下的高级技能,其中蕴含的各种思想和变化,也成了考核高级开发工程师无法避开的++障碍++,今天我们就来简单的说一下这些『高深』的技术。
1. 什么是插件化
插件化,通俗来说就是一种动态加载技术。我们可以通过一个已安装的Apk来加载本地的apk文件,通过这种动态的加载技术来实现应用功能的拓展、动态更新、灰度发布、A/B Test等功能,本质上宿主以及所有的插件都是Apk,只不过宿主可以把其他的插件Apk加载并且运行起来。
2. 插件化的原理
这里我们不去把四大组件的插件化都介绍一遍,而是把Activity的原理讲明白,其实原理都是大同小异的,大家能理解一个其他的也不在话下。
2.1 Apk是什么?
Apk作为Android系统中的安装文件,其本质就是一个压缩包,你可以直接把它当做一个zip包,里面有各种二进制和资源文件,只是Android系统通过有规律的加载并运行这些文件,并把这些在手机屏幕上显示出来。
在继续往下看之前,我们要抛开原先的一些『概念』,插件化里面的Activity不是一个界面,而是一个class文件,Service也不是服务,也是一个Class文件,Receiver、Provider以及各种资源都是这样,只有以文件的角度去看待,才能以系统的角度去思考。简单的说Apk里的文件分为两类 Class文件 和 资源文件。加载这两种文件也需要不同的方式,Class文件用ClassLoader加载,资源文件用AssetManager加载。
2.2 Activity启动
我们明明说的是插件化,为什么先扯到了Activity的启动上了呢?其实这里才是插件化里的最核心的地方,因为所有的插件化框架都是依托源码才做出来的,我们要做的相当于一个『小系统』,如果我们都不能明白源码时如何实现的,我们又怎么能在其基础上构建我们的框架呢?所以我们在继续往下之前,必须要把启动的过程都缕清楚,这样在面对众多的插件化框架时才能有条不紊发去分析。
2.2.1 入门
Activity的启动流程可以简单的理解为一个进程间的通信过程,只不过一端是我们的App,另一端是Android系统。试想我们现有的逻辑,如果想要打开一个界面,就是调用startActivity()方法,我们就要把这个Class交给系统,系统验证通过,实例化这个界面并且交还给我们,我们才可以使用。这就是一个进程间通讯的过程,由于Android IPC使用Binder作为进程间通讯的主要手段,我们甚至可以直接把它看作是一个C/S的模型,我们的App就是客户端而系统是服务端,我们所做的也只是一个请求,而系统也为我们做了大部分的事情,比如Class的加载、实例化、Activity生命周期的控制、权限的管理等等。
2.2.1 进阶
上述的流程只是让大家有一个大致的印象,想要了解更多就的去分析源码了。
上图是基于Android 7.1 Activity启动流程的整理,要用文字的方式去讲清楚这么一件事,其实并不容易,何况是这么一件挺麻烦的事,这幅图也只是一个参考,方便我们的讲解,请大家一定要对照图和下面的方法引用图去看一下源码,只有先把源码看明白,才能对插件化有一个自己的认识。
对于Activity的启动来说,其实每个版本的差别并不大,但是低版本的源码封装的没那么复杂,更便于我们阅读。
2.2.3 思考
在看完源码之后,就要开始真正的思考了,如果让你去实现一个插件化的框架你要从哪里开始呢?
自然是先打一个插件Apk的包,试着去打开并加载。那么,用什么去打开?用什么去加载呢?
ClassLoader
其实在Android中,Activity也是通过ClassLoader来实例化的,只不过和我们认为的Java中不太一样。Android里并没有完全使用Java的加载模型但是借鉴了相似的思路。在Android中ClassLoader分为3种,每一种ClassLoader分别加载不同的文件。
- BootClassLoader:为系统预加载使用
- PathClassLoader:给程序、系统程序、应用程序 加载class
- DexClassLoader:加载apk、zip 文件
一般而言,Boot是系统用的,Path是App用的,Dex是用户用的。有了DexClassLoader,我们就可以把Apk加载到内存中使用了。但是这种方法过于简单粗暴,并且在实例化之后丢失了Context的环境,而丢失上下文的后果就是,我们所熟知的大部分方法都无法使用了,比如:findViewById()、startActivity()、startService()等等,要解决这个问题,我们就得从ClassLoader的底层加载去入手了。
我们看Activity启动流程图中的方法9和10,可以看出在performLaunchActivity中获取ClassLoader,Class的其实是在Instrumentation中通过newInstance()创建的,这里我们具体了解一下这一段的调用流程。
我们从ActivityThread.performLaunchActivity() 方法出发,找到ClassLoader,逐步向上找去,发现其实这个ClassLoader其实就是PathClassLoader。
在PathClassLoader中,真正的加载其实都是通过BaseDexClassLoader来进行的,而BaseDexClassLoader中有一个pathList字段,这个变量相等于一个Dex数组,各种Dex的信息都在里面,而DexClassLoader的父类也是BaseDexClassLoader,这里就是一个完美的Hook点,既然可以获取相同的Dex数据,那也能把类似的数据拼接到一起。
分析PathClassLoader的加载流程,我们通过Hook,从而把插件的Class直接『挂载』到上面,从而让插件里的Class无缝接入到主App中。
资源文件的加载
AssetManager本就是个十分强大的资源管理器,只是有些功能没有对我们开放,我们需要做的只是通过一些方法(反射)把Apk里的资源加进去就好了。
在AssetManager的源码中,mStringBlocks就是用来保存资源文件的变量,我们通过addAssetPath()方法把插件的路径加载进来,之后反射调用ensureStringBlocks()确保文件都加载进来,最后构造一个Resources在工程中使用即可。
// 执行此 public final int addAssetPath(String path) 方法,能把插件的路径添加进去
Method method = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
method.setAccessible(true);
method.invoke(assetManager, file.getAbsolutePath());
// 实例化 ensureStringBlocks()
Method ensureStringBlocksMethod = assetManager.getClass().getDeclaredMethod("ensureStringBlocks");
ensureStringBlocksMethod.setAccessible(true);
// 执行了ensureStringBlocks 初始化 string.xml color.xml anim.xml 等文件
ensureStringBlocksMethod.invoke(assetManager);
// 加载插件资源
Resources r = getResources(); // 拿到宿主的配置信息
resources = new Resources(assetManager, r.getDisplayMetrics(), r.getConfiguration());
启动
现在Class和Recourse都已经准备好了,可以试着去启动了。
但是当你启动了之后你会遇到一个不可回避的问题,这就是我们可能时常忘记的Manifest声明错误。原因很显然,我们加载的是插件中的Activity,这个类甚至都不在系统里,更不要说在Manifest里面声明了。
如果没有源码的话,我们的插件化之路可能已经要以失败告终了,好在Android是开源的,我们可以去研究为什么会报这个错误,或许可以解决这个限制。
你可以直接使用打印的错误在源码里面搜索,最后会在Instrumentation的 checkStartActivityResult方法中找到这段话,如果我们向上寻找的话就会发现,这个方法的调用者就是execStartActivity()。
也许你会想到一个方案,就是事先在Manifest中声明好呀,这样不就很容易的解决了?我想在插件化发展中一定有这样的过程,这是一个简单且行之有效的方法,但是我们所熟知的框架里面却没有一个这样实现的,因为这是不实用的。试想,如果要用个插件化,这个App必然是有众多逻辑与界面,有多个业务的航母级应用,开发人员几十上百个,如果要这样去开发就违背了我们插件化的初衷,在开发难度上也并没有降低。
于是,大家就开始思考解决方案,最先出现的就是——占位式插件化。
预先在Manifest中声明一个ProxyActivity,所有的界面都以这个Activity作为跳板启动(把目标Activity的class路径放在extra里面),在打开ProxyActivity之后再通过ClassLoader加载并实例化目标Activity,这个Activity就启动成功了。
至此,我们的插件化框架就完成了,这个思路可以让这个『原始』的框架在Android9.0上运行,这是其他很多利用反射框架不可企及的,但是慢慢的你也会发现它的很多缺点,就是上文说提及的『侵入性』太强。因为这个Activity是完全不受系统管理的(这个Activity是我们自己实例化的),我们需要在ProxyActivity中接管Activity的生命周期,我们无法去管理Activity的启动栈了,我们甚至无法使用Context了。
显然,开发者和使用者对这样的实现方式并不满意,随后便有了更加便于开发的Hook式。
我们知道ActivityNotFoundException这个错误是在哪里抛出来的,也知道原本的代码,那么我们为什么不去绕过它?或者让系统不去执行这个方法呢?
Hook式的插件化实现起来稍显复杂,说复杂也只是因为要看的源码有点多。
这里我们不去深究代码实现,大家讲起来都差不多,我们的目标是把思路搞明白,如果想深入研究的话可以去看下深入理解Android插件化技术
可以看到,Hook的方式也是需要ProxyActivity的,只不过使用的地方不一样,我们原来是直接启动ProxyActivity,但是现在这部分工作被Hook做了,既然有替换的过程,必然也有还原的地方,这个点就是在newActivity的Handler中。通过这个『神不知鬼不觉�』的过程,我们用另一种方式实现了插件化。
但是这样的方式也是有不足的地方的,Hook本就是『不安全』的,源码中更改了一个字段,删除了某个方法,都会造成不可预料的后果,事实也确实是这样,每个版本的启动过程都会有更改,而我们只能提前去适配新版本,避免出现问题。
3. 总结
这里只是说明了插件化的基本原理,其实完整的插件化框架还有很多东西,四大组件、Activity栈、插件中的组件相互启动、各个版本适配……如果你有兴趣不如去自己试一试,相信对你的成长有很大帮助。
一个好的插件化框架是需求足够且充足的前置知识,比如ClassLoader的加载,Hook、动态代理、Activity的启动流程等等。如果大家想学习FrameWork这是一个很好的切入点,因为大多数Hook的代码,都得你去阅读源码之后才能下手,而这无论是对于四大组件的启动流程,还是个版本之间的差异,你都需要把这些做到了如指掌。
原始的插件化还是需要借助各种反射的逻辑,寻找我们可能去着手的Hook点去做,但是随着Google从9.0开始对于『危险代码』的紧缩和Android版本之间的兼容性问题,Hook的方式也慢慢显露出各种问题,而腾讯基于无反射的实现,相信是未来插件化的发展方向。
源码地址:https://github.com/devilsen/PluginTest