作者:dennyz
1、前言
所谓插件化,是实现动态化的一种具体的技术手段。
对于移动端的App而言,无论是Android还是iOS,都存在一个共同的问题,那就是更新的周期较长。
当我们希望快速为App更新功能时,必须经过开发、测试、发布、审核、上线等一系列的流程。之后,还需要用户主动升级app才能够生效。
漫长的周期也使得发布新版本时的风险变得更大。而通过动态化,就可以在一定程度上来解决这个问题。
动态化是一个相对宏大的命题,落实到实现方案,其实有非常多的方法,各自适用的应用场景也各不相同。以下罗列了常见的几种方案:
- 布局动态化。通过下发配置,再由客户端映射为具体的原生布局,实现动态化。这种方案的性能还不错,但只适合布局动态化,更新业务逻辑则较为困难。
- H5容器。其实webview就是一个天然可实现动态化的方案。这种方案的稳定性和动态化能力都不错,主要缺陷是性能较差,毕竟js是解释型语言,终究比不过原生。
- 虚拟运行环境。如Flutter。Flutter所使用的Dart语言既是解释型语言,又是编译型语言,解决了上面提到的性能问题。但这种方案往往需要在apk中依赖一个sdk,增加了包大小。
- 插件化。插件化通过动态下发部分代码,来实现动态的功能更新。前几年,插件化是较火的方案,近些年受制于系统的限制,变得越来越难以实现。
动态化的范围非常广,本文将聚焦插件化的方案,并以Shadow为例,介绍插件化的原理及Shadow的具体实现。
2、从插件化理论基础说起
插件化的本质,是通过后加载代码的方式来实现的。这个代码,可以是内置于apk里的一个独立产物,但更多的时候,是通过后下发的方式获取的。
动态加载代码这件事听起来很神秘,但其实并没有什么特别的地方。我们所熟知的C++的动态库,就是典型的,可以通过动态加载方式运行起来。
回到插件化上。插件化也是一样的思路。这个方案之所以可以实现,又和Java语言的特性分不开。
我们先回顾一下Java语言的编译流程。Java语言从编写到运行,可以简单分为2步:
- 通过Java编译器(如javac)将Java源代码编译为.class文件,.class文件中包含了Java的字节码信息。
- 通过Java虚拟机(在Android上,主要指Art虚拟机与Dalvik虚拟机),将字节码再转换为对应的机器码进行执行。目前大部分的java虚拟机,都同时支持解释器和编译器。解释器使得程序可以快速启动,而编译器则负责把热点代码编译为机器码,提高程序的运行效率。
Java语言的这个特性,决定了它是一门完全的动态链接的语言。
所谓链接,指的是程序在编译和装载中间的一个阶段。链接可以分为静态链接和动态链接两种。
- 动态链接,将对符号的重定位推迟到程序运行时才进行。以Java为例,类A依赖了类B的某个方法,在class文件中保留的其实是类B的名称和方法签名,直至真正需要调用这个方法的时候,才会去查找类B。
- 静态链接则与之相对,在装载之前就会完成所有符号的引用。静态链接的优点是程序发布时无需带库可独立运行,而缺点是浪费内存,且修改任意一处需要编译所有地方。
除了少部分优化为Native的类,Java的类都是在运行时动态加载的,这其中也包含了我们所熟知的Activity(但在非Debug模式下,Activity可能被优化为native)。
事实上,系统也是这么做的。只需要定义好基类Activity的接口,就可以New一个App中指定的Activity,向上转型为基类Activity来使用。
这里的向上转型,指的是对于系统而言,只关心new了一个activity的对象,并且只关心这个对象上属于activity的那些方法。至于这些方法是否被子类重写,系统是不关心的。
我在这里举例了activity的例子,是因为activity是我们最常用的四大组件之一。但对于其他的组件,也是类似的方式。
那么到这里,插件的基本原理也就比较清晰了。
所有的插件无外乎就是通过一个新的ClassLoader,去加载后下发的插件中的代码进行使用,从而实现动态化。通过classLoader加载代码,只需要一行代码而已,这并没有什么技术上的难度。
插件化框架首先要解决的问题,并不是如何动态加载Activity,而是加载后的Activity没有在AndroidManifest中注册,该如何绕过系统限制启动的问题。当然,也包括其他的细节。
在本文的下半部分,我会为大家介绍一下,插件化技术在面对原生系统限制时遇到的一些问题,以及Shadow在这些问题上,是如何思考和抉择的。
3、概念名词介绍
下文会以Shadow的官方demo为例,来介绍Shadow在插件化方面的设计。在介绍之前,我们需要先简单统一一下各个名词的概念,避免歧义。
名词 | 概念 |
---|---|
主进程和插件进程 | 多进程并不是插件化必须的实现方案,但大部分情况下,我们会用多进程的方式来实现。我们用主进程表示app启动时的默认的进程,用插件进程表示加载并运行插件代码的那个进程。一般来说,当你的插件有许多activity流转时,这些activity就是在插件进程中被创建和展示的。 |
宿主工程和插件工程 | 在官方的demo中,宿主工程和插件工程是在一起的。但是这几个module事实上并无依赖关系,是各自独立编译的。宿主工程指编译可独立运行的apk的工程。而插件工程则指编译插件apk的工程,包含了pluginManager.apk和plugin.zip两个部分。需要注意,宿主工程中的代码并非只在主进程中运行。同样,插件工程中的代码也并非只运行在插件进程。 |
4、Shadow的工程结构
从官方的Github上下载最新节点,可以看到Shadow的工程目录如上图所示。
其中,sample-host就是上文所指的宿主工程,sample-manager和sample-plugin下的所有module,统称为插件工程。
插件工程的编译产物有2个,分别是pluginmanager.apk和plugin.zip,而plugin.zip中又包含了4个apk。他们的关系如下图所示:
module名称 | module编译产物 | 最终产物形式 | 是否动态加载 | 代码运行所在进程 | 主要职责 |
---|---|---|---|---|---|
sample-host | 可独立运行的apk | 可独立运行的apk | 否 | 主进程和插件进程均有 | 是对外发布的app |
sample-manager | pluginmanager.apk | pluginmanager.apk | 是 | 主进程 | 安装、管理及加载插件 |
sample-plugin/sample-app | app-plugin.apk | plugin.zip | 是 | 插件进程 | 业务逻辑 |
sample-plugin/sample-base | base-plugin.apk | plugin.zip | 是 | 插件进程 | 业务逻辑,被app以compileOnly的方式依赖 |
sample-plugin/sample-loader | loader.apk | plugin.zip | 是 | 插件进程 | 插件的加载 |
sample-plugin/sample-runtime | runtime.apk | plugin.zip | 是 | 插件进程 | 插件运行时的代理组件,如container activity(见下文) |
我们可以看到,上述的各个module都会编译出各自的独立apk,这也就是说,他们是相对独立的。通过运行时加载代码、动态链接的方式,最终形成一个完成的app。
5、Hack Activity的方案
上文已经提到,对于插件化而言,主要的挑战并不是如何动态加载代码,而是插件的activity并没有真正在Manifest中注册,如何绕过系统限制的问题。
那么我们不妨先思考一下,如果我们自己实现一个插件化的框架,怎么解决这个问题。
比较直接的思路,是理解系统检查Manifest的原理,想办法Hack掉其中的关节步骤,从而绕过检查。
显然,这种方式对系统的运行环境有一定的要求。当系统源码发生改变,或者国内厂商魔改了源码之后,都会存在一定兼容性的问题,需要不断适配。
在这个问题上,不管是360的Replugin还是tencent的Shadow,都采用了类似的方案。那就是设法启动一个真实存在的activity,也就是真实在系统的Manifest中注册过的Activity。
我们把在插件中,业务方想要启动的activity称之为PluginActivity。而真实注册在系统中的,没有具体业务逻辑的代理Activity,称之为ContainerActivity,也是一个几乎为空壳的壳Activity。
上述两个插件化的方案,都是在我们尝试通过Context#startActivity时候,通过一些方式修改intent。将原本尝试启动PluginActivity的intent,偷梁换柱为启动ContainerActivity的activity。
因为ContainerActivity是真实注册过的,那么权限检查这块就不存在问题。
再接下来的步骤,两个插件的实现思路就不同了:
5.1 Replugin的思路:
Hack宿主的ClassLoader,使得系统收到加载ContainerActivity的请求时,返回的是PluginActivity类。
由于PluginActivity本质上也是一个继承了android.app.Activity的类,通过向上转型为activity去使用,理论上不会存在什么问题。
Replugin的这个方案的问题之一,是需要在宿主apk中,为每一个插件的业务Activity注册一个对应的坑位Activity、。关于这点,我们先看下ClassLoader load方法的签名:
public abstract class ClassLoader {
public Class<?> loadClass(String name) throws ClassNotFoundException {
......
}
}
可以看到,ClassLoader在loadClass的时候,收到的参数只有一个类名。这就导致,对于每个业务插件中的Activity,都需要一个ContainerActivity与之对应。在宿主apk中,我们需要注册大量的坑位Activity。
另外,Replugin hack了加载class的过程,后面也不得不继续用Hack手段解决系统看到了未安装的Activity的问题。比如系统为插件Activity初始化的Context是以宿主的apk初始化的,插件框架就不得不再去Hack修复。
5.2 Shadow的思路
Shadow则使用了另一种思路。既然对系统而言,ContainerActivity是一个真实注册过的存在的activity,那么就让这个activity启动起来。
同时,让ContainerActivity持有PluginActivity的实例。ContainerActivity将自己的各类方法,依次转发给PluginActivity去实现,如onCreate等生命周期的方法。
Shadow在这里所采用的方案,本质上是一种代理的思路。在这种思路中,事实上,PluginActivity并不需要真正继承Activity,它只需要继承一个与Activity有着类似的方法的接口就可以了。
Shadow的这个思路,一个ContainerActivity可以对应多个PluginActivity,我们只需要在宿主中注册有限个必须的activity即可。
并且,后续插件如果想要新增一个activity,也不是必须要修改宿主工程。只要业务上允许,完全可以复用已有的ContainerActivity。
5.2.1 偷梁换柱,替换intent
上文已经提到,Shadow在运行的时候,从系统角度看不到PluginActivity的存在。因此,PluginActivity是否继承了android.app.Activity就显得无关紧要。
事实上,Shadow也是这么干的。具体的技术实现,则是使用AOP的思路,使用官方的在构建过程中的Transform API来完成的。类似的技术,其实已经有很多开源框架用到了。
通过AOP,我们的业务PluginActivity最终会被替换为继承com.tencent.shadow.core.runtime.ShadowActivity。而ShadowActivity,又继承自ShadowContext。
在ShadowContext中,我们可以找到这样一段代码:
public class ShadowContext extends SubDirContextThemeWrapper {
@Override
public void startActivity(Intent intent, Bundle options) {
final Intent pluginIntent = new Intent(intent);
pluginIntent.setExtrasClassLoader(mPluginClassLoader);
final boolean success = mPluginComponentLauncher.startActivity(this, pluginIntent, options);
if (!success) {
super.startActivity(intent, options);
}
}
}
其中,第6行:
mPluginComponentLauncher.startActivity(this, pluginIntent, options)
就是将试图启动PluginActivity修改为启动ContainerActivity的具体实现了。Shadow会解析插件apk中的Manifest,所有在插件apk中注册过的Activity,都会被优先启动。
如果启动失败,还会尝试使用super.startActivity,这个时候,就是启动宿主工程中的activity了。
分析这段代码,可以得出2个结论:
- 在插件中,会优先启动插件apk中的activity。
- 如果插件的activity没有在插件的Manifest中注册,那么还会尝试启动宿主apk中的Activity。
5.2.2 runtime与classLoader
到了上面一步,一个插件的页面已经可以被启动了。但是距离真正可以使用,或者说让业务方无感知地使用,还有不少问题要解决。
在介绍Shadow的工程结构的时候,我们有提到sample-plugin/sample-runtime这样的一个module。而这个module就是存放上文提到的ContainerActivity的地方。
这是什么意思呢,就是说,我们所说的ContainerActivity,也就是壳Activity,确实是在宿主apk中真实注册了的。但是它的代码,却是在一个后加载的插件中。不是打包在宿主apk中的,而是动态的。
Shadow这样的设计,是的宿主apk更加轻量化,动态化的程度也更高。但是却面临这一个问题:系统怎么能找到这个后下载的ContainerActivity?
5.2.2.1 什么是ClassLoader
为了回答上面的这个问题,我们需要先复习一下ClassLoader相关的概念。
ClassLoader,顾名思义,是用来加载Java类的。有时候我们会遇到一些ClassNotFoundException,“罪魁祸首”就是因为这个ClassLoader。
Android系统上有三个常见的ClassLoader,分别是BootClassLoader、PathClassLoader和DexClassLoader。他们的区别和联系是:
- PathClassLoader和DexClassLoader都继承自BaseDexClassLoader
- BootClassLoader用于加载Android Framework层的class文件,比如 Activity、Fragment。8.0以前,PathClassLoader 只能加载我们安装过的 apk,DexClassLoader 可以加载sd卡上的apk。8.0以后,这两者没有什么区别
这里要注意的是,由不同的ClassLoader加载的类,其实是不同的类。
在插件化的工程中,我们经常可以遇到明明是同一个类,但是一个变量一会儿是A一会儿是B的场景。遇到这种case,往往就是因为ClassLoader不同导致的。
当一个类尝试调用另一个类中的方法的时候,就会向加载了自己的那个ClassLoader去查找另一个类。如果没有加载过,就会首先通过这个ClassLoader进行加载,之后再继续运行。
而ClassLoader加载类的时候,又遵循了双亲委派的模式,即优先委派给父加载器进行加载。如果父加载器已经加载过,就不需要再加载了。这里的双亲二字有一定的迷惑性,其实ClassLoader没有双亲,只有单亲。
在Android中,通常app应用的类都是由PathClassLoader加载的,而PathClassLoader的父加载器则是BootClassLoader。
为什么Java要设计这样的双亲委派模式呢?大部分场景下,这样的设计都能符合实际的业务场景。例如,不同的业务都需要用到String对象,那么双亲委派模式就可以保证这个对象都是通过同一个ClassLoader加载出来的。
5.2.2.2 Hack ClassLoader
先说结论:Shadow框架通过反射修改了PathClassLoader的父加载器。
原本的ClassLoader结构为BootClassLoader <- PathClassLoader,插入后的结构变为BootClassLoader <- RuntimeClassLoader <- PathClassLoader。
这个新插入的RuntimeClassloader,就是用来加载插件的Runtime的。ContainerActivity即由这个ClassLoader加载。
这个结构的修改,可以使得系统在向PathClassLoader查找ContainerActivity时能够正确找到实现,因为双亲委派模式的设计,会让PathClassLoader会将加载ContainerActivity的请求委托给RuntimeClassLoader。
我们看一下Shadow中的源代码:
public class DynamicRuntime {
private static void hackParentToRuntime(InstalledApk installedRuntimeApk, ClassLoader contextClassLoader) throws Exception {
RuntimeClassLoader runtimeClassLoader = new RuntimeClassLoader(installedRuntimeApk.apkFilePath, installedRuntimeApk.oDexPath,
installedRuntimeApk.libraryPath, contextClassLoader.getParent());
hackParentClassLoader(contextClassLoader, runtimeClassLoader);
}
/**
* 修改ClassLoader的parent
*
* @param classLoader 需要修改的ClassLoader
* @param newParentClassLoader classLoader的新的parent
* @throws Exception 失败时抛出
*/
static void hackParentClassLoader(ClassLoader classLoader,
ClassLoader newParentClassLoader) throws Exception {
Field field = getParentField();
if (field == null) {
throw new RuntimeException("在ClassLoader.class中没找到类型为ClassLoader的parent域");
}
field.setAccessible(true);
field.set(classLoader, newParentClassLoader);
}
}
这个hackParentClassLoader的方法,就是替换classLoader的parent对象的方法了。
Shadow的这个设计,也是我觉得很有意思的一个设计。
6 插件Resource
至此,我们已经顺利启动了一个Activity,还想办法把ContainerActivity也做成了动态化的一部份。唯一的小缺憾,可能是ContainerActivity需要在宿主中注册,这个目前没有什么好的技术手段可以去规避了。
6.1 资源 ID 冲突问题
那么下一个问题,就是插件中一定也会有对资源的访问。通常情况下,资源访问会是类似下面的这样的形式:
textView.setText(R.string.main_activity_info);
我们对资源的访问通过一个int值,而这个值是在apk的打包期间,由脚本生成的。这个值与具体的资源之间存在一一对应的关系。
由于插件和宿主工程是独立编译的,如果不修改分区,两者的资源可能存在冲突,这个时候就不知道应该去哪里加载资源了。
为了解决这个问题,Shadow修改了插件资源的id的分区。修改资源id并不复杂,只需要一行代码就可以解决:
additionalParameters "--package-id", "0x7E", "--allow-reserved-package-id"
反编译打包完成的apk,也很容易就可以发现,同一个资源的分区是不同的。宿主工程的是7f开头,而插件则是7e。
- 宿主工程:
- 插件工程:
6.2 如何访问插件资源
解决了 id 冲突的问题,还有一个问题需要考虑,那就是对系统而言,是看不到插件的存在的。那么,如何让业务方可以获取插件的资源呢?
其实,Android中对资源是有着和共享库类似的加载机制的。我们可以通过ApplicationInfo中的一个sharedLibraryFiles变量,拓展对资源的访问。尽管这个名字听起来很像是共享动态库相关的目录,但实际上它确实是资源共享库。
我们只需要把插件的路径添加到这个 sharedLibraryFiles 中,就可以了。我们看下核心代码的实现:
object CreateResourceBloc {
private fun fillApplicationInfoForNewerApi(
applicationInfo: ApplicationInfo,
hostApplicationInfo: ApplicationInfo,
pluginApkPath: String
) {
applicationInfo.publicSourceDir = hostApplicationInfo.publicSourceDir
applicationInfo.sourceDir = hostApplicationInfo.sourceDir
// hostSharedLibraryFiles中可能有webview通过私有api注入的webview.apk
val hostSharedLibraryFiles = hostApplicationInfo.sharedLibraryFiles
val otherApksAddToResources =
if (hostSharedLibraryFiles == null)
arrayOf(pluginApkPath)
else
arrayOf(
*hostSharedLibraryFiles,
pluginApkPath
)
applicationInfo.sharedLibraryFiles = otherApksAddToResources
}
}
上述代码的核心在第 18 行。我们可以看到,Shadow 把 pluginApkPath 添加到了applicationInfo中。后面还会用这个 applicationInfo 来构造 resource 对象。这样,就使得插件进程可以访问插件的资源。
上面这个方法的名称叫做fillApplicationInfoForNewerApi。自然还有一个方法叫做fillApplicationInfoForLowerApi。
object CreateResourceBloc {
/**
* 在API 25及以下代替设置sharedLibraryFiles后通过getResourcesForApplication创建资源的方案。
* 因调用addAssetPath方法也无法满足CreateResourceTest涉及的场景。
*/
private fun fillApplicationInfoForLowerApi(
applicationInfo: ApplicationInfo,
hostApplicationInfo: ApplicationInfo,
pluginApkPath: String
) {
applicationInfo.publicSourceDir = pluginApkPath
applicationInfo.sourceDir = pluginApkPath
applicationInfo.sharedLibraryFiles = hostApplicationInfo.sharedLibraryFiles
}
}
作者在注释中已经说明了为什么低版本和高版本采用不同的逻辑。在这些低版本中,会构造一个新的MixResources。这个方案依赖的是Resources的一个已经被废弃的构造器。
这个方案和高版本不同的地方在于,高版本是把插件目录添加到 sharedLibraryFiles 中。而低版本,则是构造一个只能加载插件目录的Resource对象。在需要加载资源时,优先交给pluginResource 加载,加载失败的时候再交给 hostResource 加载。下面的代码中的 tryMainThenShared,就是上述逻辑的体现。
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
@TargetApi(CreateResourceBloc.MAX_API_FOR_MIX_RESOURCES)
private class MixResources(
private val mainResources: Resources,
private val sharedResources: Resources
) : Resources(mainResources.assets, mainResources.displayMetrics, mainResources.configuration) {
private fun <R> tryMainThenShared(function: (res: Resources) -> R) = try {
function(mainResources)
} catch (e: NotFoundException) {
function(sharedResources)
}
override fun getText(id: Int) = tryMainThenShared { it.getText(id) }
}
完整创建一个可以加载插件resource的代码如下:
object CreateResourceBloc {
fun create(archiveFilePath: String, hostAppContext: Context): Resources {
triggerWebViewHookResources(hostAppContext)
val packageManager = hostAppContext.packageManager
val applicationInfo = ApplicationInfo()
val hostApplicationInfo = hostAppContext.applicationInfo
applicationInfo.packageName = hostApplicationInfo.packageName
applicationInfo.uid = hostApplicationInfo.uid
if (Build.VERSION.SDK_INT > MAX_API_FOR_MIX_RESOURCES) {
fillApplicationInfoForNewerApi(applicationInfo, hostApplicationInfo, archiveFilePath)
} else {
fillApplicationInfoForLowerApi(applicationInfo, hostApplicationInfo, archiveFilePath)
}
try {
val pluginResource = packageManager.getResourcesForApplication(applicationInfo)
return if (Build.VERSION.SDK_INT > MAX_API_FOR_MIX_RESOURCES) {
pluginResource
} else {
val hostResources = hostAppContext.resources
MixResources(pluginResource, hostResources)
}
} catch (e: PackageManager.NameNotFoundException) {
throw RuntimeException(e)
}
}
}
6.3 未能处理的case
6.3.1 Case 1
我们对插件资源的访问,依赖于一个被处理过的resource对象。上文提到,Shadow已经通过trasform替换了PluginActivity所继承的父类为ShadowActivity,因为,我们在访问资源的时候,自然而然拿到的是一个PluginResource对象,这没有什么问题。
但是,在一些特殊的情况下,还是会存在问题。
例如,如果资源的加载是系统完成的。应用把资源id交给系统,然后系统直接向宿主apk索取资源。在这个实现的路径上,Shadow的代码没有任何办法可以hook这个调用,自然也无法访问到插件中的资源。
Activity的入场动画就是这个case,我们看下Shadow的实现:
public class ShadowActivity extends PluginActivity {
@Override
public void overridePendingTransition(int enterAnim, int exitAnim) {
//如果使用的资源不是系统资源,我们无法支持这个特性。
if ((enterAnim & 0xFF000000) != 0x01000000) {
enterAnim = 0;
}
if ((exitAnim & 0xFF000000) != 0x01000000) {
exitAnim = 0;
}
hostActivityDelegator.overridePendingTransition(enterAnim, exitAnim);
}
}
shadow直接屏蔽了除了系统资源外的其他资源。
6.3.2 case 2
在 xml 中使用自定义 Drawable 的死后,xml 形式的 Drawable 是通过 Resource 中的 DrawableInflater 来解析和加载的。
但是插件中的DrawableInflater 使用的 ClassLoader 是宿主的 ClassLoader。当自定义的 Drawable中使用到了 R 文件后,加载的 R 文件也是宿主的,这就会导致找不到资源而崩溃。
这里崩溃一方面是代码混淆导致的 R 文件加载不到,另一方面是在高版本上,使用sharedLibraryFiles也会出现无法加载插件资源的情况。经过测试,使用 MixResource 就不存在这个问题。后者估计是系统的 bug。
7、Shadow中的Trasform:PackageManager
上文已经提到,Shadow中的Activity,其实并不继承android.app.Activity,是继承com.tencent.shadow.core.runtime.ShadowActivity。而这一过程的实现,是通过Transform的API完成的。
通过AOP的思想去实现一些设计的好处是对用户无感知,但是坏事也是太无感知了。如果不熟悉设计,出现问题后就很难追查。
而Shadow中AOP的运用不只有这一处。还有对PackageManager的Hack。
在Android开发中免不了使用PackageManager获取当前应用的一些信息。而插件本身,对系统而言是看不到的。因此,框架需要处理这方面的问题。
一个直接的思路是直接覆写Context的getPackageManager方法,返回一个PackageManager的子类(ShadowPackageManager)。但是这种做法,在各个OEM上都会出现一些问题,原因是OEM可能会向PackageManager中增加各类Hide的方法,这些方法不需要覆写就可以编译通过,但是运行时就会出现AbstractMethodError的错误。
因此,Shadow在这个问题上的处理方案是,通过Transform的API,修改了业务中访问PackageManger的地方。这些访问的地方都是在业务的代码中,是完全可以修改的。
具体的代码可以参考PackageManagerTransform。代码较多,这里就不贴细节了。
这段代码的作用,是将插件中对系统的PackageManger的访问,修改为对PackageManagerInvokeRedirect的访问。类似于这样的逻辑:
public void test() {
PackageManager pm = context.getPackageManager();
ApplicationInfo info = staticMethod.getApplicationInfo(pm, "packageName", GET_META_DATA);
}
private static ApplicationInfo staticMethod(PackageManager pm, String packageName, int flags) {
...
...
}
但并非所有对PackageManager的方法的访问都被修改了。具体Hack的接口,可以参考PackageManagerInvokeRedirect的实现。
public class PackageManagerInvokeRedirect {
public static PluginPackageManager getPluginPackageManager(ClassLoader classLoaderOfInvokeCode) {
return PluginPartInfoManager.getPluginInfo(classLoaderOfInvokeCode).packageManager;
}
public static ApplicationInfo getApplicationInfo(ClassLoader classLoaderOfInvokeCode, String packageName, int flags) throws PackageManager.NameNotFoundException {
return getPluginPackageManager(classLoaderOfInvokeCode).getApplicationInfo(packageName, flags);
}
public static ActivityInfo getActivityInfo(ClassLoader classLoaderOfInvokeCode, ComponentName component, int flags) throws PackageManager.NameNotFoundException {
return getPluginPackageManager(classLoaderOfInvokeCode).getActivityInfo(component, flags);
}
public static ServiceInfo getServiceInfo(ClassLoader classLoaderOfInvokeCode, ComponentName component, int flags) throws PackageManager.NameNotFoundException {
return getPluginPackageManager(classLoaderOfInvokeCode).getServiceInfo(component, flags);
}
public static ProviderInfo getProviderInfo(ClassLoader classLoaderOfInvokeCode, ComponentName component, int flags) throws PackageManager.NameNotFoundException {
return getPluginPackageManager(classLoaderOfInvokeCode).getProviderInfo(component, flags);
}
public static PackageInfo getPackageInfo(ClassLoader classLoaderOfInvokeCode, String packageName, int flags) throws PackageManager.NameNotFoundException {
return getPluginPackageManager(classLoaderOfInvokeCode).getPackageInfo(packageName, flags);
}
@TargetApi(Build.VERSION_CODES.O)
public static PackageInfo getPackageInfo(ClassLoader classLoaderOfInvokeCode, VersionedPackage versionedPackage,
int flags) throws PackageManager.NameNotFoundException {
return getPluginPackageManager(classLoaderOfInvokeCode).getPackageInfo(versionedPackage.getPackageName(), flags);
}
public static ProviderInfo resolveContentProvider(ClassLoader classLoaderOfInvokeCode, String name, int flags) {
return getPluginPackageManager(classLoaderOfInvokeCode).resolveContentProvider(name, flags);
}
public static List<ProviderInfo> queryContentProviders(ClassLoader classLoaderOfInvokeCode, String processName, int uid, int flags) {
return getPluginPackageManager(classLoaderOfInvokeCode).queryContentProviders(processName, uid, flags);
}
public static ResolveInfo resolveActivity(ClassLoader classLoaderOfInvokeCode, Intent intent, int flags) {
return getPluginPackageManager(classLoaderOfInvokeCode).resolveActivity(intent, flags);
}
public static ResolveInfo resolveService(ClassLoader classLoaderOfInvokeCode, Intent intent, int flags) {
return getPluginPackageManager(classLoaderOfInvokeCode).resolveService(intent, flags);
}
}
8、PluginManager与Plugin
在Shadow,插件编译后的产物有2个,分别是pluginmanager.apk与plugin.zip,这两个都是动态加载的插件代码部分。
8.1 PluginManager
Shadow将PluginManager部分独立出来,用于负责插件的安装、加载等流程。
PluginManager是在主进程被加载的,与业务的交互,也只有一个接口:
public interface PluginManager {
/**
* @param context context
* @param fromId 标识本次请求的来源位置,用于区分入口
* @param bundle 参数列表
* @param callback 用于从PluginManager实现中返回View
*/
void enter(Context context, long fromId, Bundle bundle, EnterCallback callback);
}
Shadow有一个该接口的实现类,DynamicPluginManager。DynamicPluginManager的Enter方法再次调用了updateManagerImpl方法,而这个方法创建的implLoader,才是我们在pluginmanager.apk中实现的具体的加载类。demo中为SamplePluginManager。
public final class DynamicPluginManager implements PluginManager {
@Override
public void enter(Context context, long fromId, Bundle bundle, EnterCallback callback) {
if (mLogger.isInfoEnabled()) {
mLogger.info("enter fromId:" + fromId + " callback:" + callback);
}
updateManagerImpl(context);
mManagerImpl.enter(context, fromId, bundle, callback);
mUpdater.update();
}
private void updateManagerImpl(Context context) {
File latestManagerImplApk = mUpdater.getLatest();
String md5 = md5File(latestManagerImplApk);
if (mLogger.isInfoEnabled()) {
mLogger.info("TextUtils.equals(mCurrentImplMd5, md5) : " + (TextUtils.equals(mCurrentImplMd5, md5)));
}
if (!TextUtils.equals(mCurrentImplMd5, md5)) {
ManagerImplLoader implLoader = new ManagerImplLoader(context, latestManagerImplApk);
PluginManagerImpl newImpl = implLoader.load();
Bundle state;
if (mManagerImpl != null) {
state = new Bundle();
mManagerImpl.onSaveInstanceState(state);
mManagerImpl.onDestroy();
} else {
state = null;
}
newImpl.onCreate(state);
mManagerImpl = newImpl;
mCurrentImplMd5 = md5;
}
}
}
可以看到,updateManagerImpl会在每次进入前判断上次加载的pluginmanager.apk的MD5是否发生变化。当MD5不一致的时候,就会重新load一个新的实现了PluginManager的实例。
主进程与插件的交互,都从这个enter接口开始。他们之间的依赖,也只有这个enter接口。
demo中的SamplePluginManager,才是真正负责插件的安装与加载的地方,主要包括了
- 解压plugin.zip
- 保存插件信息(存储数据库中)
- 通过startService启动插件进程,建立通讯(通过shadow重写的binder,而不是AIDL)
- 在插件进程中加载必须的代码,包括:runtime、loader、base、app这4个apk
- 手动调用application的onBaseContextAttached和onCreate方法(是在插件的manifest中注册的application,而不是宿主工程中的application。宿主工程的application的生命周期的执行,是由系统回调的,并且它的classLoader也是PathClassLoader)。
下图简单地说明了插件启动的整个过程。
8.2 Plugin
Plugin的产物是一个zip包,除了4个apk外,还有一个叫config.json的json文件。
8.2.1 ConfigJson
ConfigJson是在打包过程中由脚本自动自动生成的。该文件中包含了插件的版本信息和插件apk的描述。
跟版本有关的信息如下:
- version:标识插件的版本信息
- compact_version:插件向下兼容的版本。在当前其实并没有使用的地方。
- UUID:可以理解为是插件的id。UUID相同的同一组插件才可以在一起工作。当插件包的内容发生变化后,UUID也会相应改变。
- UUID_NickName:对实际业务并没有什么作用,但是可以方便我们管理插件。可以理解为是插件的一个通俗易懂的名字。
一份完整的configjson的格式如下所示。
{
"pluginLoader":{
"apkName":"sample-loader-release.apk",
"hash":"B26313DE458E7571F214CBD27F2E4DC1"
},
"plugins":[
{
"partKey":"sample-plugin-app",
"apkName":"sample-app-plugin-releaseTest.apk",
"dependsOn":[
"sample-base"
],
"businessName":"sample-plugin-app",
"hash":"AF32CEA73F41A93E05DBA8B8C46F23AB"
},
{
"partKey":"sample-base",
"apkName":"sample-base-plugin-release.apk",
"businessName":"sample-plugin-app",
"hostWhiteList":[
"com.xxx.a.b.c"
],
"hash":"193A7AA41BFC1FCCDC8F8C316A95EB0E"
}
],
"runtime":{
"apkName":"sample-runtime-release.apk",
"hash":"1A1B36A5197D72E5AD128F07C4F4C302"
},
"UUID":"6EC46EC1-2358-4CF8-9B08-6BF5F0FB183D",
"version":1,
"UUID_NickName":"1.0.6"
}
8.2.2 businessName
businessName是比较容易理解的一个属性。它指的是该插件的业务名,可以为空。
当businessName为空的时候,插件与宿主就会使用同样的data目录。此时,可以认为插件与宿主其实是同一个业务。
当businessName不为空的时候,宿主的data目录中就会有一个以businessName为名称的子目录。此时,插件与宿主的数据(如SharedPreference、MMKV等)就是隔离的。此时,尽管MMKV本身拥有支持多进程的能力,但是因为文件隔离,导致插件也会无法访问宿主的MMKV中的数据。
8.2.3 dependsOn与hostWhiteList
这两个属性与classLoader的双亲委派模式是相关的。其中,dependsOn的优先级要比hostWhiteList高。
我们先看一下PluginClassLoader的实现。
class PluginClassLoader(
dexPath: String,
optimizedDirectory: File?,
librarySearchPath: String?,
parent: ClassLoader,
private val specialClassLoader: ClassLoader?, hostWhiteList: Array<String>?
) : BaseDexClassLoader(dexPath, optimizedDirectory, librarySearchPath, parent) {
@Throws(ClassNotFoundException::class)
override fun loadClass(className: String, resolve: Boolean): Class<*> {
var clazz: Class<*>? = findLoadedClass(className)
if (clazz == null) {
//specialClassLoader 为null 表示该classLoader依赖了其他的插件classLoader,需要遵循双亲委派
if (specialClassLoader == null) {
return super.loadClass(className, resolve)
}
//插件依赖跟loader一起打包的runtime类,如ShadowActivity,从loader的ClassLoader加载
if (className.subStringBeforeDot() == "com.tencent.shadow.core.runtime") {
return loaderClassLoader.loadClass(className)
}
//包名在白名单中的类按双亲委派逻辑,从宿主中加载
if (className.inPackage(allHostWhiteTrie)) {
return super.loadClass(className, resolve)
}
var suppressed: ClassNotFoundException? = null
try {
//正常的ClassLoader这里是parent.loadClass,插件用specialClassLoader以跳过parent
clazz = specialClassLoader.loadClass(className)!!
} catch (e: ClassNotFoundException) {
suppressed = e
}
if (clazz == null) {
try {
clazz = findClass(className)!!
} catch (e: ClassNotFoundException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
e.addSuppressed(suppressed)
}
throw e
}
}
}
return clazz
}
}
第15行的specialClassLoader == null,对应的就是dependsOn不为空的时候。此时,以为着该插件的apk依赖了其他插件。此时,它的ClassLoader需要遵循标准的双亲委派模式。这个时候,它的hostWhiteList的声明是无效的,需要定义在它所依赖的业务中才可以。
而第25行的className.inPackage(allHostWhiteTrie)对应的就是hostWhiteList属性了。有一些类,例如说,retrofit,可以考虑从宿主apk中加载代码。这样可以减少插件包的大小。
那么在这种情况下,可以设置hostWihiteList属性,允许插件访问宿主中的类。
在release打包的时候,记得考虑混淆的影响。因为插件和宿主是独立编译的,混淆之后两边的类名会不一样,hostWhiteList属性就可能失效。
Shadow这样的设计,保证了大部分代码都是通过插件的ClassLoader加载的,又允许插件访问宿主的部分代码。
9、总结
尽管随着时代的发展,插件化已经没有过去几年那么火爆了。现存的还在维护的插件化框架也没有以前那么多了。
但是研究插件化仍然是一件十分有意思的事。大部分的开源三方库都是在系统的允许规则内去帮我们去做一些事,如网络请求、图片加载。而插件化反其道而行之,想办法绕过系统的限制,去做原生开发不让我们做的事。这其中也涉及到了java和android的各方面的知识点。
Android 学习笔录
Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap