Android插件化框架-Shadow原理解析

作者:dennyz

1、前言

所谓插件化,是实现动态化的一种具体的技术手段。

对于移动端的App而言,无论是Android还是iOS,都存在一个共同的问题,那就是更新的周期较长。

当我们希望快速为App更新功能时,必须经过开发、测试、发布、审核、上线等一系列的流程。之后,还需要用户主动升级app才能够生效。

漫长的周期也使得发布新版本时的风险变得更大。而通过动态化,就可以在一定程度上来解决这个问题。

动态化是一个相对宏大的命题,落实到实现方案,其实有非常多的方法,各自适用的应用场景也各不相同。以下罗列了常见的几种方案:

  • 布局动态化。通过下发配置,再由客户端映射为具体的原生布局,实现动态化。这种方案的性能还不错,但只适合布局动态化,更新业务逻辑则较为困难。
  • H5容器。其实webview就是一个天然可实现动态化的方案。这种方案的稳定性和动态化能力都不错,主要缺陷是性能较差,毕竟js是解释型语言,终究比不过原生。
  • 虚拟运行环境。如Flutter。Flutter所使用的Dart语言既是解释型语言,又是编译型语言,解决了上面提到的性能问题。但这种方案往往需要在apk中依赖一个sdk,增加了包大小。
  • 插件化。插件化通过动态下发部分代码,来实现动态的功能更新。前几年,插件化是较火的方案,近些年受制于系统的限制,变得越来越难以实现。

动态化的范围非常广,本文将聚焦插件化的方案,并以Shadow为例,介绍插件化的原理及Shadow的具体实现。

2、从插件化理论基础说起

插件化的本质,是通过后加载代码的方式来实现的。这个代码,可以是内置于apk里的一个独立产物,但更多的时候,是通过后下发的方式获取的。

动态加载代码这件事听起来很神秘,但其实并没有什么特别的地方。我们所熟知的C++的动态库,就是典型的,可以通过动态加载方式运行起来。

回到插件化上。插件化也是一样的思路。这个方案之所以可以实现,又和Java语言的特性分不开。

我们先回顾一下Java语言的编译流程。Java语言从编写到运行,可以简单分为2步:

扫描二维码关注公众号,回复: 15352898 查看本文章
  1. 通过Java编译器(如javac)将Java源代码编译为.class文件,.class文件中包含了Java的字节码信息。
  2. 通过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个结论:

  1. 在插件中,会优先启动插件apk中的activity。
  2. 如果插件的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

猜你喜欢

转载自blog.csdn.net/maniuT/article/details/131246816