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

news2024/11/15 23:58:57

作者: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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Docker Desktop启动失败解决方案(亲侧出坑总结)

现在有些东西网上资料开始变少了。需要自己去总结。有些技术呢又因为分享变得门槛低。今天这个是关于windows下的docker desktop无法启动的问题集锦。卷吧。 背景&#xff1a;应业务需要所以需要在个人电脑上安装docker环境。desktop docker是官方标准的windows下安装工具。 …

6.7面向对象的多态

7. 面向对象特征三&#xff1a;多态性 概念 多态是面向对象程序设计&#xff08;OOP&#xff09;的一个重要特征&#xff0c;指同一个实体同时具有多种形式&#xff0c;即同一个对象&#xff0c;在不同时刻&#xff0c;代表的对象不一样&#xff0c;指的是对象的多种形态。 变…

副业变现:Midjourney绘画赚钱的6种方式

今年被称为AI元年&#xff0c;其中最火的两款AI工具非ChatGpt和Midjourney莫属。究其原因&#xff0c;无非两点&#xff1a;第一&#xff0c;它提高了生产力&#xff0c;之前需要两年完成的工作&#xff0c;使用ChatGpt两天就完成。 第二&#xff0c;它带来了副业收入&#xff…

短视频seo源代码部署步骤

一、 部署短视频SEO矩阵系统源代码&#xff0c;您需要遵循以下步骤&#xff1a; 准备服务器环境 首先&#xff0c;您需要准备一个服务器环境来托管源代码。您可以选择云服务器&#xff08;例如AWS&#xff0c;阿里云等&#xff09;或自己的私人服务器。 安装所需软件 在服务器…

Android系统Binder详解

Android系统启动篇 1&#xff0c;《android系统启动流程简介》 2&#xff0c;《android init进程启动流程》 3&#xff0c;《android zygote进程启动流程》 4&#xff0c;《Android SystemServer进程启动流程》 5&#xff0c;《android launcher启动流程》 6&#xff0c;…

visual studio编译c++问题处理

严重性 代码 说明 项目 文件 行 禁止显示状态 错误 C2760 语法错误: 意外的令牌“标识符”&#xff0c;预期的令牌为“;” 严重性 代码 说明 项目 文件 行 禁止显示状态 错误 C7510 “string_view”: 类型 从属名称的使用必须…

RocketMQ_介绍以及基础入门

目录 一、什么是MQ 1、应用解耦 2、流量削峰 3、数据分发 二、MQ的优缺点 三、各种MQ产品的对比 四、RocketMQ快速入门(单机版本) 一、什么是MQ 在学习RocketMQ之前&#xff0c;我们先来了解什么是MQ&#xff0c;以及为什么要用MQ。MQ的英文全称是&#xff08;Massage Q…

2023首届盘古石杯晋级赛复盘

晋级赛通排61&#xff0c;学生组39&#xff0c;折在大小写格式上的题太多了qaq 容器密码&#xff1a;usy1UN2Mmgram&^d?0E5r9myrk!cmJGr Android程序分析 1.涉案应用刷刷樂的签名序列号是(答案格式&#xff1a;123ca12a)(★☆☆☆☆) 11fcf899 雷电APP跑的时候前面加…

LeetCode 2481. 分割圆的最少切割次数

【LetMeFly】2481.分割圆的最少切割次数 力扣题目链接&#xff1a;https://leetcode.cn/problems/minimum-cuts-to-divide-a-circle/ 圆内一个 有效切割 &#xff0c;符合以下二者之一&#xff1a; 该切割是两个端点在圆上的线段&#xff0c;且该线段经过圆心。该切割是一端…

【Flutter】Flutter 如何使用 flutter_swiper

文章目录 一、前言二、flutter_swiper 的概念三、Flutter 中的 flutter_swiper1. 使用的库2. 方法介绍 四、代码示例1. 简单示例2. 完整示例 五、总结 一、前言 在移动应用开发中&#xff0c;轮播图是一种常见的 UI 元素&#xff0c;它可以用来展示一系列的图片或者内容。在 F…

ACL 2023 | 利用思维链(CoT)推理隐式情感,狂涨50%

©PaperWeekly 原创 作者 | 费豪 单位 | 新加坡国立大学 题目&#xff1a; Reasoning Implicit Sentiment with Chain-of-Thought Prompting 作者&#xff1a; 费豪&#xff0c;李波波&#xff0c;刘乾&#xff0c;邴立东⁴&#xff0c;李霏&#xff0c;Chua Tat-Seng 新加…

聚观早报|青年失业率处在高位;滴滴租车在全国300个城市上线服务

今日要闻&#xff1a;青年失业率处在高位&#xff1b;滴滴租车在全国300个城市上线服务&#xff1b;特斯拉提供三个月免费充电服务&#xff1b;苹果新专利Apple Watch&#xff1b;甲骨文宣布裁员数百人 青年失业率处在高位 6 月 15 日&#xff0c;国新办举行 5 月份国民经济运…

signoz调研部署及log收集体验

这里是weihubeats,觉得文章不错可以关注公众号小奏技术&#xff0c;文章首发。拒绝营销号&#xff0c;拒绝标题党 背景 最近一直在调研监控、log收集&#xff0c;tracing相关的开源项目&#xff0c;以便使用。 前段时间一直调研使用的是skywalking。后来发现在log收集存储这一…

Ubuntu18编译内核源码,并调整版本号

​ 目标 下载ubuntu18 4.15.0-20-generic内核源码&#xff0c;默认情况下编译的内核版本会是4.15.17&#xff0c;我们需要调整版本号跟系统默认的一致&#xff0c;即4.15.0-20-generic 1 下载内核源码 sudo apt install linux-source-4.15.0 默认情况下&#xff0c;上面的…

苹果iOS 17新功能:iPhone激活Apple Watch铃声反向查找手表

苹果 Apple Watch 此前一直有查找 iPhone 的功能&#xff0c;用户可以点击表盘的电话图标&#xff08;或者长按&#xff09;来激活 iPhone 的铃声&#xff0c;从而找到附近的 iPhone 手机。 在最新的 iOS 17 测试版本中&#xff0c;苹果为 iPhone 也添加了这一功能的反向版本&a…

STM32速成笔记—串口通信

文章目录 一、什么是串口通讯二、串口通讯有什么用三、STM32的串口通信四、串口通信相关概念4.1 波特率4.2 全双工和半双工4.3 同步通信和异步通信 五、硬件连接六、串口通讯程序配置6.1 使能串口时钟和GPIO时钟6.2 初始化GPIO6.3 初始化串口参数6.4 使能串口6.5 串口接收中断6…

3ds MAX 多维材质

有的时候&#xff0c;我们想在一个图形中添加两个材质 比如我们绘制了一个球体&#xff0c;想要表示这是足球&#xff0c;但是没有黑白方块的贴图 除了绘制一个贴图外&#xff0c;我们可以用多维材质直接实现。 这里给茶壶对象的盖子和壶身设置两种贴图&#xff1a; 首先打…

MVI设计模式

一.各种框架对比 https://blog.csdn.net/qq_36390114/article/details/126160017 1. MVC&#xff08;Model-View-Controller&#xff09; 模型-视图-控制器 MVC的目的就是为了M和V代码分离&#xff0c;降低耦合性。 Model&#xff1a;数据来源&#xff0c;网络请求数据和数据…

DJ8-1 shell 的启动和终止、重定向、管道

目录 8.1 shell 的启动和终止 8.2 输入输出重定向 8.2.0 标准输入输出 8.2.1 输出重定向 > 8.2.2 输入重定向 < 8.2.3 常见输入输出重定向形式 8.2.4 标准错误输出重定向 8.3 管道 Linux 系统中的 shell 具有两大功能&#xff1a; 是一个命令解释器&…

express框架学习笔记

express简介 express是一个基于Node.js平台的极简的、灵活的WEB应用开发框架。express是一个封装好的工具包&#xff0c;封装了很多功能&#xff0c;便于我们开发WEB应用&#xff08;HTTP服务&#xff09; express使用 新建express文件夹新建文件test01.js&#xff0c;代码如…