Android 插件化原理浅析

插件化是用于动态加载并运行代码的技术,通常可用于精简APK体积、动态更新功能、进行热修复等

本文源码基于API 33,目录如下

  • 插件化的目标
  • 插件化的理论基础
    • 组件检查
    • 类检索
    • 资源检索
  • 插件化的手段
    • 注册组件
    • 加载类
    • 加载资源
  • 其它组件的插件化
    • Service
    • BroadcastReceiver
    • ContentProvider
  • 参考资料

插件化的目标

插件化技术为APP运行时提供了灵活的加载和运行机制,可以在运行时动态执行通过网络下发的代码,使原生具备了近似于H5的灵活性。插件化的动态技术,通常服务于以下目标:

  • 缩减APK体积:通过将二级模块做成在线下载的插件,可以有效缩减应用APK体积,按需加载
  • 破除65535方法数限制 :继MultiDex以外的另一种方案
  • 动态修复:发现线上问题时通过更新补丁进行修复,控制风险,防止问题进一步扩大
  • 在线升级:在线插件可以实时展示最新版本,升级效率高于APK自身版本升级
  • 提升编译效率:宿主和插件可以并行编译

插件化的理论基础

通常我们讲插件化,讨论的是Activity的插件化实现。

Android组件管理机制

在用Context.startActivity(Intent)拉起新页面时,如果目标Activity没有在AndroidManifest.xml文件中注册,会抛出如下异常:

bash 复制代码
android.content.ActivityNotFoundException: Unable to find explicit activity class {pro.lilei.plugin/pro.lilei.plugin.PluginActivity}; have you declared this activity in your AndroidManifest.xml?

API 33的源码里,这段检查代码位于Instrumentation.java

android.app.Instrumentation.java

java 复制代码
public static void checkStartActivityResult(int res, Object intent) {
    if (!ActivityManager.isStartResultFatalError(res)) {
        return;
    }
    switch (res) {
        case ActivityManager.START_INTENT_NOT_RESOLVED:
        case ActivityManager.START_CLASS_NOT_FOUND:
            if (intent instanceof Intent && ((Intent)intent).getComponent() != null)
                throw new ActivityNotFoundException(
                        "Unable to find explicit activity class "
                        + ((Intent)intent).getComponent().toShortString()
                        + "; have you declared this activity in your AndroidManifest.xml"
                        + ", or does your intent not match its declared <intent-filter>?");
            throw new ActivityNotFoundException(
                    "No Activity found to handle " + intent);
        case ...
    }
}

由上述代码可知,在检验结果码的过程中,START_INTENT_NOT_RESOLVEDSTART_CLASS_NOT_FOUND两个结果码都会导致无法创建Activity对象。后者属于类加载机制的原因,此处主要分析前者。

所检查的结果码来自于Instrumentation.javaexecStartActivity(),其内部调用AMS来启动Activity。

java 复制代码
public ActivityResult execStartActivity(...) {
    ...
    intent.migrateExtraStreamToClipData(who);
    intent.prepareToLeaveProcess(who);
    int result = ActivityTaskManager.getService().startActivity(whoThread,
            who.getOpPackageName(), who.getAttributionTag(), intent,
            intent.resolveTypeIfNeeded(who.getContentResolver()), token,
            target != null ? target.mEmbeddedID : null, requestCode, 0, null, options);
    checkStartActivityResult(result, intent);
    ...
}

调用由AMS转发给ActivityTaskManagerService,调用链如下

  • ActivityTaskManagerServicer : startActivity -> startActivityAsUser -> startActivityAsUser
  • ActivityStarter : execute -> executeRequest

在这个过程中,系统会在AndroidManifest.xml文件中注册的组件中,查找可以响应当前Intent的那个,查找的规则既包括类名精准匹配,也包括<intent-filter>标签的匹配。对于插件而言,其Activity显然是没有经过注册的,因此在这一步会出错,返回START_INTENT_NOT_RESOLVED解析失败。

类加载机制

从我们写下的.java类文件代码,到最终运行在虚拟机中的程序,这中间经历了多个环节。我们知道,Java是一门运行在虚拟机中的语言,首先,.java文件会被javac命令编译成.class文件,它先被加载到内存中,然后在内存中经历函数调用,最后从内存中卸载,这是一个类完整的生命周期过程,包含以下阶段:

  1. 加载
  2. 链接(包含验证、准备、解析三个阶段)
  3. 初始化
  4. 使用
  5. 卸载

本章节重点分析其加载原理。

Java中的类加载

负责进行加载类的工具称为类加载器,Java中的类加载器分为系统类加载器和自定义类加载器两种。

系统类加载器

  • 引导类加载器Bootstrap ClassLoader):用c/c++语言实现,用于加载JDK中的核心类,主要加载$JAVA_HOME/jre/lib目录下的类,例如rt.jarresources.jar等jar包。
  • 扩展类加载器Extensions ClassLoader):用Java语言实现的ExtClassLoader,其作用是加载Java的扩展类,位于$JAVA_HOME/jre/lib/ext目录下
  • 应用程序类加载器Application ClassLoader):用Java语言实现的AppClassLoader,是距离开发者最近的类加载器,可以通过ClassLoader.getSystemClassLoader方法获取到它,用于加载应用程序classpath目录和系统环境变量java.class.path指定目录下的类

可以在代码中通过class.getClassLoader()获取到当前的类加载器,类加载器之间存在委托关系,因此可以遍历取出其双亲(Parent)加载器,代码如下。

java 复制代码
public class ClassLoaderDemo {
    public static void main(String[] args) {
        ClassLoader cl = ClassLoaderDemo.class.getClassLoader();
        while (cl != null) {
            System.out.println("ClassLoader: " + cl);
            cl = cl.getParent();
        }
    }
}

控制台输出结果如下,可见当前生效的类加载器及其委托关系。由于ExtClassLoader的双亲加载器是BootstrapClassLoader,其由c/c++实现,故在Java代码中无法获得它的引用。

bash 复制代码
AppClassLoader@vi9g71ng
ExtClassLoader@jb0b8ang

注意在上文中我用了"委托"而不是"继承",这是因为加载器内部采用的是委托机制,委托顺序如下图:

  • 左侧是JDK中实现的类加载器,通过parent属性形成父子关系。应用中自定义的类加载器的parent都是AppClassLoader
  • 右侧是JDK中的类加载器实现类。通过类继承的机制形成体系。未来我们就可以通过继承相关的类实现自定义类加载器

自定义类加载器

在一些特殊需求场景下需要自定义类加载器,例如从网络上获取到一个加密后的.class文件,此时通过自定义类加载器,将字节流解密后,再创建运行时的类对象。

双亲委托模式

是Java类加载机制最核心的知识,描述了在加载器层级结构中如何正确地找到目标类加载器的过程。文字描述如下:

  1. JVM产生加载某个类的需求
  2. 首先检索本级缓存,判断这个类是否已经加载过,如果加载过的话则直接返回加载过的对象
  3. 如果没有加载过,则看当前的类加载器是否存在parent(双亲),如果存在,则交由双亲检索
  4. 不断向上追查祖先,直到最古老的祖先,然后由该祖先进行查找,同样会查找当前该层级的缓存
  5. 如果祖先查找不到,则交回儿子加载器查找
  6. 直到返回当前加载器,触发其findClass进行查找

其实这个过程与Android UI中的事件分发处理机制相似度达到99%,都是将事件(加载、触摸)层层向内/向上传递,一直传递到尽头后尝试进行消费,如果消费成功则终止,消费不成功则反向传回,逐级消费。

一图胜千言。

双亲委派模式的优点

  • 效率:加载过一次的类会进行缓存,提升下次使用的速度
  • 安全:所有加载任务都会层层传递给系统的类加载器,Java、Android的核心类都是由系统加载的,防止攻击者篡改这些核心类
  • 解耦:不同层级的加载器负责不同范围的类的加载,职责清晰,易于理解和维护

判断两个类相同

需要同时满足:

  • 类名相同
  • 完整包名相同
  • 加载器是同一个

Android中的类加载

有了上文的基础,直接用结构图表示,其中BootstrapClassLoaderPathClassLoader是必不可少的,其它类加载器由使用者自行定义。

资源加载机制

在讨论资源时,通常指的是assets目录、res目录下的drawablelayoutcolorstring等。

例:设置ImageView的资源

以设置ImageView的src为例,有两种方法,分别是在布局文件中直接指明android:src="@drawable/some_image.png",和在Java代码中调用ImageView.setImageResource()

在布局文件中声明

其函数调用链为:

  1. ImageView : constructor
  2. TypedArray : getDrawable
  3. Resources : getDrawable
  4. ResourcesImpl : loadDrawableForCookie

通过setImageResource()设置

  1. ImageView : setImageResource -> resolveUri
  2. Context : getDrawable
  3. Resources : getDrawable
  4. ResourcesImpl : loadDrawableForCookie

上述两个方式最终都会调用到ResourceImpl.loadDrawableForCookie

java 复制代码
private Drawable loadDrawableForCookie(...) {
    ...
    if (file.endsWith(".xml")) {
        final String typeName = getResourceTypeName(id);
        if (typeName != null && typeName.equals("color")) {
            dr = loadColorOrXmlDrawable(wrapper, value, id, density, file);
        } else {
            dr = loadXmlDrawable(wrapper, value, id, density, file);
        }
    } else {
        // 通过AssetsManager加载系统资源流文件
        final InputStream is = mAssets.openNonAsset(
                value.assetCookie, file, AssetManager.ACCESS_STREAMING);
        final AssetInputStream ais = (AssetInputStream) is;
        dr = decodeImageDrawable(ais, wrapper, value);
    }
    ...
}

通过上述分析可知,获取资源的方法调用链是:

  • Context->Resources->AssetManager->NativeAssetManager

这个流程中的关键概念阐释如下:

  • Resources: 由Context.getResources返回,通过它可以获取APP各项资源,是供我们直接调用的接口。当APP启动时随着Context实例而创建
  • ResourcesImpl: 由ResourcesManager创建,它是Resources的具体实现,与Resources绑定,持有一个AssetsManager对象
  • AssetsManager: 关键类,它在初始化时读取了当前APP的资源表resource.arsc文件,并调用addAssetPath(String path)将资源文件通过路径进行加载。在APP进程初始化时就伴随着AssetsManager的创建

AssetsManager读取资源顺序

AssetManager的初始化代码位于Native层的AssetManager.cpp中,初始化时分别执行以下任务:

  1. 加载系统的framework-res.apk,导入系统资源,如以android.开头的color等
  2. 传入apk文件时,查找其内部的resources.arsc进行加载
  3. 将解析到的资源添加到mResources进行维护,它是位于Native层的资源表

这样,通过Context.getResources就可以拿到上文中在Native层维护的mResources资源表,进而通过Key查找相应资源。

在以上理论基础上,进行插件化的实践。

插件化的实践

在应用插件化思想的过程中,需要解决以下三个难点:

  • 类文件注入,把插件dex插入到ClassLoader类加载器中
  • Activity组件的注册,从而将目标页面的生命周期与系统绑定
  • 资源注入,让宿主、插件的资源实现互相访问

加载类

自定义PluginClassLoader

新建一个PluginClassLoader,用来加载插件中的类。

java 复制代码
public class PluginClassLoader extends BaseDexClassLoader {
    public DexClassLoader(
        String dexPath,
        String optimizedDirectory,
        String librarySearchPath,
        ClassLoader parent) {
            // ...
        }
}

构造函数中的4个参数说明如下:

  • dexPath: 需要加载的dex/jar/apk路径,对于assets目录下的插件包,需要将其复制到应用目录下
  • optimizedDirectory: ART虚拟机对dex优化后生成odex的存放位置
  • librarySearchPath: so文件位置
  • parent: 双亲类加载器

将PluginClassLoader实例化后,调用其loadClass(clazzName)函数就可以加载插件中的类。

kotlin 复制代码
private fun extractPlugin() {
    val inputStream = assetts.open("plugin.apk")
    File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())
}

private fun init() {
    extractPlugin()
    pluginPath = File(filesDir.absolutePath, "pluginapk").absolutePath
    nativeLibDir = File(filesDir, "pluginlib").absolutePath
    dexOutPath = File(filesDir, "dexout").absolutePath
    pluginClassLoader = PluginClassLoader(pluginPath, nativeLibDir, dexOutPath, this::class.java.classloader)
}

通过反射生成Activity对象

kotlin 复制代码
val loadClass = pluginClassLoader.loadClass(activityName)
loadClass.getMethod("test", null).invoke(loadClass)

注册组件

通过上一章节自定义的PluginClassLoader,我们可以创建插件APK中的Activity对象并调用其方法,然而,这只是把它当成普通Java对象来使用,对于Android系统而言,我们当然希望AMS能够识别我们所创建的Activity实例,自动调用其生命周期相应方法。因此,需要将上一步创建的Activity与AMS的生命周期逻辑绑定。

要实现上述目的,通常有两种方法:

  • Manifest中预先插桩
    • 优点:实现简单,对Android各个版本兼容性好
    • 缺点:代码侵入量大,需要借助中间类来中转双向(宿主-插件之间)方法调用;插桩Activity数量不好控制
  • Hook系统启动Activity时的检查过程
    • 优点:无缝接入,不需要中转Activity
    • 缺点:Android不同版本的检查逻辑不同,有兼容性风险

这里我们选用插桩的方式进行实现,首先在宿主中声明一个StubActivity,它主要有2个职责:

  • AndroidManifest.xml文件中注册自身,从而可以被AMS生命周期识别并调用到
  • onCreate时从Intent中接收传递来的参数对象PluginInfo(name, apkPath, activityName),以便通过PluginClassLoader进行自定义创建

相对应的,如果要把插件Activity的各项系统调用转发回系统,也需要借助一个插件Activity的基类PluginBaseActivity,它的职责是:

  • 将插件Activity的各个系统调用转发给StubActivity,使诸如setContentView等系统接口调用对插桩类生效。因为在系统眼中此时前台的应当是插桩类StubActivity

StubActivity.java

java 复制代码
public class StubActivity extends AppCompatActivity {
    private PluginBaseActivity mPluginBaseActivity;
    
    @Override
    protected void onCreate(final Bundle savedInstanceState) {
        String pluginActivityName = getIntent().getString("activityName", "");
        mPluginBaseActivity = PluginLoader.loadActivity(pluginActivityName, this); // 加载插件APK并通过自定义ClassLoader创建插件Activity实例
        if (mPluginBaseActivity == null) {
            super.onCreate(savedInstanceState);
            return;
        }
        mPluginBaseActivity.onCreate(); // 其它生命周期调用同理
    }
}

@Override
protected void onResume() {
    if (mPluginBaseActivity == null) {
        super.onResume();
        return;
    }
    mPluginBaseActivity.onResume();
}

PluginBaseActivity.java,插件Activity应当是它的子类

java 复制代码
public class PluginBaseActivity {
    private StubActivity mStubActivity;

    public StubActivity(StubActivity stubActivity) {
        mStubActivity = stubActivity;
    }

    @Override
    public <T extends View> T findViewById(int id) {
        return mStubActivity.findViewById(id);
    }
    // ... 其它各项系统调用同理
}

字节码替换工具:Shadow

Shadow是腾讯开源的一款插件化框架,其实现思路就是StubActivity插桩,它提供了字节码替换功能,可以在编译阶段自动将插件中的Activity继承关系改为PluginBaseActivity。其实现原理是借助Gradle的Transform Api,在字节码生成后、dex文件创建前,对生成的字节码进行修改。这样一来,插件开发者只需要按照传统方式开发Activity代码,无须手动继承PluginBaseActivity

自动修改前,SamplePluginActivity.kt

kotlin 复制代码
class SamplePluginActivity : Activity() {}

自动修改后,SamplePluginActivity.kt

kotlin 复制代码
class SamplePluginActivity : PluginBaseActivity() {}

加载资源

可访问插件资源的Resources对象

通过上文分析,我们知道APP运行时,通过Context.getResources获取到一个全局的Resources对象,其内部封装了ResourcesImpl进行资源查找,ResourcesImpl则与AssetsManager绑定,后者会关联到文件系统中具体的APK。

因此,资源注入的思路是,对插件APK构建其Resources对象,然后就可以实现在PluginBaseActivity中提供借助这个Resources对象查找插件中的资源。

PluginLoader.java

java 复制代码
public Resources getPluginResources() {
    PackageManager packageManager = applicationContext.getPackageManager();
    // 1. 通过APK路径,获取其PackageInfo
    PackageInfo pi = packageManager.getPackageArchiveInfo(
        pluginApkPath,
        PackageManager.GET_ACTIVITIES
        |PackageManager.GET_META_DATA
        |PackageManager.GET_SERVICES
        |PackageManager.GET_PROVIDERS
        |PackageManager.GET_SIGNATURE
    );
    pi.applicationInfo.sourceDir = pluginApkPath;
    pi.applicationInfo.publicSourceDir = pluginApkPath;
    
    // 2.创建插件Resources对象
    Resources pluginResources = packageManager.getResourcesForApplication(pi.applicationInfo);
}

随后将pluginResources与宿主的Resources进行合并,从而让插件的代码也可以访问到宿主当中的资源。

PluginResources.java,用于从插件/宿主中获取资源

java 复制代码
public class PluginResources extends Resources {
    private Resources hostResources;
    private Resources pluginResources;

    // 这里传入的是上一步通过插件APK生成的Resources对象
    public PluginResources(Resources hostResources, Resources pluginResources) {
        super(pluginResources.getAssets(), pluginResources.getDisplayMetrics(), pluginResources.getConfiguration());
        this.hostResources = hostResources;
        this.pluginResources = pluginResources;
    }

    // 先从插件中获取,如果获取不到,就从宿主当中找
    @Override
    public String getString(int id, Object... formatArgs) throws NotFoundException {
        try {
            return pluginResources.getString(id, formatArgs);
        } catch (NotFoundException e) {
            return hostResources.getString(id, formatArgs);
        }
    }

    // ...
}

双重查找资源

最后在宿主插桩StubActivity中,使用包装后的PluginResources提供资源查找服务,这样,即使在插件Activity中调用,也可以借助PluginBaseActivity转发给插桩的StubActivity,从而实现资源注入。

StubActivity.java

java 复制代码
public class StubActivity extends Activity {
    private Resources mPluginResources;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ...
        mPluginResources = new PluginResources(super.getResources(), PluginLoader.getResources(pluginApkPath));
        // ...
    }

    @Override
    public Resources getResources() {
        if (mPluginResources == null) {
            return super.getResources();
        }
        return mPluginResources;
    }
}

其它组件的插件化

除了Activity,再简单讲述下其它3个组件的插件化实现思路,只提供概念上的指引,不做过多讲解。

Service插件化

和Activity的相同点是,先在Manifest文件中插桩StubService,插件中的Service就可以借尸还魂。但是,与Activity有所不同,作为桩子的StubActivity可以提供给多个插件Activity使用,多次去start同一个StubActivity,系统中就会存在多个StubActivity实例。而于Service而言,多次去start同一个StubService,系统中不会增多实例,所以因此需要准备多个StubService,以备插件中存在多个Service的情况。

BroadcastReceiver插件化

与前两者有所不同,BroadcastReceiver分为动态广播与静态广播。

动态广播

不需要与AMS交互,使用ClassLoader方案进行加载即可。

静态广播

附加了IntentFilter信息,无法通过插桩的方式进行预站位,实现思路是通过PackageManager取出Manifest文件中声明的静态广播,将其注册为动态广播。

ContentProvider插件化

与BroadcastReceiver处理方法类似,通过PackageParser解析Manifest文件中注册的ContentProvider,然后调用ActivityThread.installContentProviders函数,把它们注册在宿主APP中。

有两点需要注意:

  • 注册时机:APP安装自己的ContentProvider是在进程启动时候进行,比Application的onCreate还要早,所以我们要在Application的attachBaseContext方法中手动执行上述操作
  • 转发机制:让外界App直接调用插件的App,并不是一件特别好的事情,因为插件的稳定性欠佳。最好是由App的ContentProvider作为中转。因为字符串是ContentProvider的唯一标志,转发机制就特别适用

参考资料

相关推荐
码出极致5 小时前
支付平台资金强一致实践:基于 Seata TCC+DB 模式的余额扣减与渠道支付落地案例
后端·面试
walking9575 小时前
JavaScript 神技巧!从 “堆代码” 到 “写优雅代码”,前端人必看
前端·面试
walking9575 小时前
前端 er 收藏!高性价比 JS 工具库,轻量又强大
前端·面试
walking9575 小时前
效率党必藏! JavaScript 自动化脚本,覆盖文件管理、天气查询、通知提醒(含详细 demo)
前端·面试
我现在不喜欢coding6 小时前
为什么runloop中先处理 blocks source0 再处理timer source1?
ios·面试
walking9576 小时前
前端开发中常用的JavaScript方法
前端·面试
大舔牛6 小时前
图片优化全景策略
前端·面试
FogLetter6 小时前
Vite vs Webpack:前端构建工具的双雄对决
前端·面试·vite
wycode7 小时前
# 面试复盘(2)--某硬件大厂前端
前端·面试
绝无仅有7 小时前
Go 语言常用命令使用与总结
后端·面试·github