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步:

  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-managerpluginmanager.apkpluginmanager.apk主进程安装、管理及加载插件
sample-plugin/sample-appapp-plugin.apkplugin.zip插件进程业务逻辑
sample-plugin/sample-basebase-plugin.apkplugin.zip插件进程业务逻辑,被app以compileOnly的方式依赖
sample-plugin/sample-loaderloader.apkplugin.zip插件进程插件的加载
sample-plugin/sample-runtimeruntime.apkplugin.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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/29300.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【redis】redis的5种数据结构及其底层实现原理

文章目录 redis中的数据结构redis数据结构底层实现stringlisthashsetintset字典 zset跳表插入删除过程 redis中的数据结构 Redis支持五种数据类型&#xff1a;string&#xff08;字符串&#xff09;&#xff0c;hash&#xff08;哈希&#xff09;&#xff0c;list&#xff08;…

Python如何制作图标点选验证码

本文讲解如何使用python中的opencv库来制作图标点选验证码 图标点选验证码制作起来非常简单,你只需要准备两部分数据集,数据集数量都不用很多,背景图我选择了20个左右,大小为(300, 500)左右,图标我抓取了100多个,图标大小为(40,40)左右,图标由不同大小的透明度构成…

C++:IO流

目录 一. C语言的输入输出方式 二. C的输入输出 2.1 C标准IO流 2.2 文件IO流 2.3 字符串IO流 一. C语言的输入输出方式 一般C语言通过scanf和printf来实现输入输出&#xff0c;scanf和printf都需要显示地指定数据类型。同时&#xff0c;C语言还支持fscanf/fprintf以及ssc…

【大数据】可视化仪表板 - Superset的安装和使用

写在前面&#xff1a;博主是一只经过实战开发历练后投身培训事业的“小山猪”&#xff0c;昵称取自动画片《狮子王》中的“彭彭”&#xff0c;总是以乐观、积极的心态对待周边的事物。本人的技术路线从Java全栈工程师一路奔向大数据开发、数据挖掘领域&#xff0c;如今终有小成…

MYSQL中 find_in_set() 函数用法详解

MYSQL中 find_in_set() 函数用法详解 官方涵义&#xff08;MySQL手册中语法说明&#xff09; FIND_IN_SET(str,strlist) &#xff1a; str 要查询的字符串&#xff0c;strlist 需查询的字段&#xff0c;参数以”,”分隔&#xff0c;形式如 (1,2,6,8,10,22)&#xff1b;该函数的…

接口如何运用pytest+HttpRunner展开测试?

目录 前言&#xff1a; 一、 什么是接口测试 二、 引入自动化背景 三、 自动化技术选型 四、 自动化测试用例 五、自动化成果 前言&#xff1a; pytest和HttpRunner都是Python编程语言中常用的接口测试框架。 pytest是一种成熟的、灵活的、社区支持良好的测试框架&…

vr沉浸式仿真实训展厅中控系统提高课堂纪律

为解决实训教学过程中“看不到、进不去、成本高、危险大”的问题&#xff0c;VR智能中控系统为职业教育及高等教育老师提供一个数字化、沉浸式、集中管控的实训教学工具。 VR智能中控系统通过对VR教学课堂的实时监控、数据的收集和分析&#xff0c;为气象学院的教学提供更多帮助…

2023年05月份青少年软件编程Scratch试卷三级真题

2023-05 Scratch三级真题 分数&#xff1a;100 题数&#xff1a;38 测试时长&#xff1a;60min 一、单选题(共25题&#xff0c;共50分) 1. 关于变量&#xff0c;下列描述错误的是&#xff1f;&#xff08;A&#xff09;&#xff08;2分&#xff09; &#xff08;变量那一栏…

【深度学习】基于Qt的人脸识别系统,门禁人脸识别系统,Python人脸识别流程,树莓派

文章目录 人脸识别过程人脸检测人脸对齐人脸特征提取特征距离比对人脸识别系统 人脸识别过程 在深度学习领域做人脸识别的识别准确率已经高到超出人类识别&#xff0c;但综合考虑模型复杂度&#xff08;推理速度&#xff09;和模型的识别效果&#xff0c;这个地方还是有做一些…

基于Java物流管理系统设计实现(源码+lw+部署文档+讲解等)

博主介绍&#xff1a; ✌全网粉丝30W,csdn特邀作者、博客专家、CSDN新星计划导师、java领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战 ✌ &#x1f345; 文末获取源码联系 &#x1f345; &#x1f447;&#x1f3fb; 精…

STM32单片机(四)第一节:OLED调试工具

❤️ 专栏简介&#xff1a;本专栏记录了从零学习单片机的过程&#xff0c;其中包括51单片机和STM32单片机两部分&#xff1b;建议先学习51单片机&#xff0c;其是STM32等高级单片机的基础&#xff1b;这样再学习STM32时才能融会贯通。 ☀️ 专栏适用人群 &#xff1a;适用于想要…

从业务出发,K8S环境自建和非自建整体架构设计比较

新钛云服已累计为您分享751篇技术干货 随着数字化转型的大潮到来&#xff0c;越来越多的企业开始上云&#xff0c;同时也纷纷加入到微服务和K8S队伍中。但在K8S整体环境究竟应该用自建的还是非自建&#xff1f;以及他们需要用到的服务&#xff0c;究竟应该自建还是直接用PAAS服…

C++【STL】之list的使用

文章目录&#xff1a; list介绍list使用1. 默认成员函数1.1 构造函数1.2 拷贝构造1.3 赋值重载1.4 析构函数 2. 迭代器3. 容量操作4. 数据访问5. 数据修改5.1 插入删除5.2 交换调整清理 6. 其他操作6.1 链表拼接6.2 链表移除6.3 排序6.4 链表逆置 list介绍 list是可以在常数范围…

java 版本企业电子招投标采购系统源码之登录页面

​ 信息数智化招采系统 服务框架&#xff1a;Spring Cloud、Spring Boot2、Mybatis、OAuth2、Security 前端架构&#xff1a;VUE、Uniapp、Layui、Bootstrap、H5、CSS3 涉及技术&#xff1a;Eureka、Config、Zuul、OAuth2、Security、OSS、Turbine、Zipkin、Feign、Monitor、…

轻量服务器架设网站打开速度慢,如何加速?

轻量服务器非常适合流量适中的小、中型网站&#xff0c;虽作为轻量级主机包&#xff0c;但它一般与云服务器使用同样的 CPU、内存、硬盘等底层资源。只是&#xff0c;轻量服务器的资源(可用的存储空间、RAM 和 CPU等硬件/内存容量)更低&#xff0c;虽然这些对于较中、小的网站来…

性能优化-内存优化

8-《内存优化》 一.基础知识1.Java的内存分配区域2.Java的引用类型3.Java的垃圾回收机制&#xff1a;三个问题4.Android的内存管理机制 二. Android的内存泄漏、内存溢出、内存抖动概念0.内存泄露1.内存溢出![在这里插入图片描述](https://img-blog.csdnimg.cn/8b73ef844f26470…

png转jpg,直接改后缀?

通过把.png改为.jpg可以改变图片的格式么&#xff1f; 将PNG文件扩展名改为JPEG的扩展名&#xff08;.jpg或.jpeg&#xff09;不会更改图像的格式。它只是更改了文件扩展名&#xff0c;这可能导致一些图像查看器和编辑器无法正确识别和处理该文件。 PNG和JPEG是两种不同的图像文…

Python自动人工智能训练数据增强工具 | DALI介绍(含代码)

Python自动人工智能训练数据增强工具 | DALI介绍(含代码) 文章目录 Python自动人工智能训练数据增强工具 | DALI介绍(含代码)自动数据增强方法DALI 和条件执行使用 DALI 自动增强使用 DALI 的自动增强性能尝试使用 DALI 进行自动增强 深度学习模型需要数百 GB 的数据才能很好地…

练习:逻辑回归

练习2&#xff1a;逻辑回归 介绍 在本练习中&#xff0c;您将实现逻辑回归并将其应用于两个不同的数据集。还将通过将正则化加入训练算法&#xff0c;来提高算法的鲁棒性&#xff0c;并用更复杂的情形来测试模型算法。 在开始练习前&#xff0c;需要下载如下的文件进行数据上…

研一,有点迷茫。

作者&#xff1a;阿秀 校招八股文学习网站&#xff1a;https://interviewguide.cn 这是阿秀的第「277」篇原创 小伙伴们大家好&#xff0c;我是阿秀。 最近回答了不少大一大二研一在读的学习圈中学弟学妹的咨询问题&#xff0c;基本都是计算机学习、进度、疑惑等等相关的问题&a…