Back

浅谈 Android 插件化原理

Android 系统经历了这么多年的发展,一代代人不断摸索,实际上对于插件化领域,已经有很完善的解决方案了,本文抛砖引玉,带大家认识一下 Android 插件化。

🔌 认识插件化

想必大家都知道,在 Android 系统中,应用是以 Apk 的形式存在的,应用都需要安装才能使用。但实际上 Android 系统安装应用的方式相当简单,其实就是把应用 Apk 拷贝到系统不同的目录下、然后把 so 解压出来而已。

常见的应用安装目录有:

  • /system/app:系统应用
  • /system/priv-app:系统应用
  • /data/app:用户应用

那可能大家会想问,既然安装这个过程如此简单,Android 是怎么运行应用中的代码的呢,我们先看 Apk 的构成,一个常见的 Apk 会包含如下几个部分:

  • classes.dexJava 代码字节码
  • res:资源目录
  • libso 目录
  • assets:静态资产目录
  • AndroidManifest.xml:清单文件

其实 Android 系统在打开应用之后,也只是开辟进程,然后使用 ClassLoader 加载 classes.dex 至进程中,执行对应的组件而已。

那大家可能会想一个问题,既然 Android 本身也是使用类似反射的形式加载代码执行,凭什么我们不能执行一个 Apk 中的代码呢?

这其实就是插件化的目的,让 Apk 中的代码(主要是指 Android 组件)能够免安装运行,这样能够带来很多收益,最显而易见的优势其实就是通过网络热更新、热修复,想象一下,你的应用拥有 Native 应用一般极高的性能,又能获取诸如 Web 应用一样的收益。

嗯,理想很美好不是嘛?

🎯 难点在哪

大家其实都知道,Android 应用本身是基于魔改的 Java 虚拟机的,动态加载代码简直不要太简单,只需要使用 DexClassLoader 加载 Apk,然后反射里面的代码就可以了。

但是光能反射代码是没有意义的,插件化真正的魅力在于,可以动态加载执行 Android 组件(即 ActivityServiceBroadcastReceiverContentProviderFragment)等。

仔细想一下,其实要做到这一点是有难度的,最主要的阻碍是,四大组件是在系统里面中注册的,具体来说是在 Android 系统的 ActivityManagerService (AMS)PackageManagerService (PMS) 中注册的,而四大组件的解析和启动都需要依赖 AMSPMS,如何欺骗系统,让他承认一个未安装的 Apk 中的组件,就是插件化的最大难点。

另外,资源(特指 R 中引用的资源,如 layoutvalues 等)也是一大问题,想象一下你在宿主进程中使用反射加载了一个插件 Apk,代码中的 R 对应的 id 却无法引用到正确的资源,会产生什么后果。

总结一下,其实做到插件化的要点就这几个:

  • 反射并执行插件 Apk 中的代码(ClassLoader Injection
  • 让系统能调用插件 Apk 中的组件(Runtime Container
  • 正确识别插件 Apk 中的资源(Resource Injection

当然还有其他一些小问题,但可能不是所有场景下都会遇到,我们后面再单独说。

🎊 解决方案

首先来谈一谈常见插件化框架的整体架构,市面上的插件化框架实际很多,如 TecentShadowDidiVirtualApk360RePlugin,我自己也写了一个简单的插件化框架 Zed。他们各有各的长处,不过大体上差不多,如果要具体学习,我推荐 Shadow,它的优势在于 0 Hook,没有使用私有 API 意味着可以走的很远,不会被 Google 搞。

他们大体原理其实都差不多,运行时会有一个宿主 Apk 在进程中跑,宿舍 Apk 是真正被安装的应用,宿主 Apk 可以加载插件 Apk 中的组件和代码运行,插件 Apk 可以任意热更新。

接下来我们按照上面要点的顺序,大致讲一下每一个方面怎么攻克。

ClassLoader Injection

简单来说,插件化场景下,会存在同一进程中多个 ClassLoader 的场景:

  • 宿主 ClassLoader:宿主是安装应用,运行即自动创建
  • 插件 ClassLoader:使用 new DexClassLoader 创建

我们称这个过程叫做 ClassLoader 注入。完成注入后,所有来自宿主的类使用宿主的 ClassLoader 进行加载,所有来自插件 Apk 的类使用插件 ClassLoader 进行加载,而由于 ClassLoader 的双亲委派机制,实际上系统类会不受 ClassLoader 的类隔离机制所影响,这样宿主 Apk 就可以在宿主进程中使用来自于插件的组件类了。

Runtime Container

上面说到只要做到 ClassLoader 注入后,就可以在宿主进程中使用插件 Apk 中的类,但是我们都知道 Android 组件都是由系统调用启动的,未安装的 Apk 中的组件,是未注册到 AMSPMS 的,就好比你直接使用 startActivity 启动一个插件 Apk 中的组件,系统会告诉你无法找到。

我们的解决方案很简单,即运行时容器技术,简单来说就是在宿主 Apk 中预埋一些空的 Android 组件,以 Activity 为例,我预置一个 ContainerActivity extends Activity 在宿主中,并且在 AndroidManifest.xml 中注册它。

它要做的事情很简单,就是帮助我们作为插件 Activity 的容器,它从 Intent 接受几个参数,分别是插件的不同信息,如:

  • pluginName
  • pluginApkPath
  • pluginActivityName

等,其实最重要的就是 pluginApkPathpluginActivityName,当 ContainerActivity 启动时,我们就加载插件的 ClassLoaderResource,并反射 pluginActivityName 对应的 Activity 类。当完成加载后,ContainerActivity 要做两件事:

  • 转发所有来自系统的生命周期回调至插件 Activity
  • 接受 Activity 方法的系统调用,并转发回系统

我们可以通过复写 ContainerActivity 的生命周期方法来完成第一步,而第二步我们需要定义一个 PluginActivity,然后在编写插件 Apk 中的 Activity 组件时,不再让其继承 android.app.Activity,而是继承自我们的 PluginActivity,后面再通过字节码替换来自动化完成这部操作,后面再说为什么,我们先看伪代码。

public class ContainerActivity extends Activity {
    private PluginActivity pluginActivity;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        String pluginActivityName = getIntent().getString("pluginActivityName", "");
        pluginActivity = PluginLoader.loadActivity(pluginActivityName, this);
        if (pluginActivity == null) {
            super.onCreate(savedInstanceState);
            return;
        }

        pluginActivity.onCreate();
    }

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

    @Override
    protected void onPause() {
        if (pluginActivity == null) {
            super.onPause();
            return;
        }
        pluginActivity.onPause();
    }
    
    // ...
}
public class PluginActivity {
    private ContainerActivity containerActivity;

    public PluginActivity(ContainerActivity containerActivity) {
        this.containerActivity = containerActivity;
    }

    @Override
    public <T extends View> T findViewById(int id) {
        return containerActivity.findViewById(id);
    }

    // ...
}
// 插件 `Apk` 中真正写的组件
public class TestActivity extends PluginActivity {
    // ......
}

Emm,是不是感觉有点看懂了,虽然真正搞的时候还有很多小坑,但大概原理就是这么简单,启动插件组件需要依赖容器,容器负责加载插件组件并且完成双向转发,转发来自系统的生命周期回调至插件组件,同时转发来自插件组件的系统调用至系统。

Resource Injection

最后要说的是资源注入,其实这一点相当重要,Android 应用的开发其实崇尚的是逻辑与资源分离的理念,所有资源(layoutvalues 等)都会被打包到 Apk 中,然后生成一个对应的 R 类,其中包含对所有资源的引用 id

资源的注入并不容易,好在 Android 系统给我们留了一条后路,最重要的是这两个接口:

  • PackageManager#getPackageArchiveInfo:根据 Apk 路径解析一个未安装的 ApkPackageInfo
  • PackageManager#getResourcesForApplication:根据 ApplicationInfo 创建一个 Resources 实例

我们要做的就是在上面 ContainerActivity#onCreate 中加载插件 Apk 的时候,用这两个方法创建出来一份插件资源实例。具体来说就是先用 PackageManager#getPackageArchiveInfo 拿到插件 ApkPackageInfo,有了 PacakgeInfo 之后我们就可以自己组装一份 ApplicationInfo,然后通过 PackageManager#getResourcesForApplication 来创建资源实例,大概代码像这样:

PackageManager packageManager = getPackageManager();
PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(
    pluginApkPath,
    PackageManager.GET_ACTIVITIES
    | PackageManager.GET_META_DATA
    | PackageManager.GET_SERVICES
    | PackageManager.GET_PROVIDERS
    | PackageManager.GET_SIGNATURES
);
packageArchiveInfo.applicationInfo.sourceDir = pluginApkPath;
packageArchiveInfo.applicationInfo.publicSourceDir = pluginApkPath;

Resources injectResources = null;
try {
    injectResources = packageManager.getResourcesForApplication(packageArchiveInfo.applicationInfo);
} catch (PackageManager.NameNotFoundException e) {
    // ...
}

拿到资源实例后,我们需要将宿主的资源和插件资源 Merge 一下,编写一个新的 Resources 类,用这样的方式完成自动代理:

public class PluginResources extends Resources {
    private Resources hostResources;
    private Resources injectResources;

    public PluginResources(Resources hostResources, Resources injectResources) {
        super(injectResources.getAssets(), injectResources.getDisplayMetrics(), injectResources.getConfiguration());
        this.hostResources = hostResources;
        this.injectResources = injectResources;
    }

    @Override
    public String getString(int id, Object... formatArgs) throws NotFoundException {
        try {
            return injectResources.getString(id, formatArgs);
        } catch (NotFoundException e) {
            return hostResources.getString(id, formatArgs);
        }
    }

    // ...
}

然后我们在 ContainerActivity 完成插件组件加载后,创建一份 Merge 资源,再复写 ContainerActivity#getResources,将获取到的资源替换掉:

public class ContainerActivity extends Activity {
    private Resources pluginResources;

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

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

这样就完成了资源的注入。

🧨 黑科技 —— 字节码替换

上面其实说到了,我们被迫改变了插件组件的编写方式:

class TestActivity extends Activity {}
->
class TestActivity extends PluginActivity {}

有没有什么办法能让插件组件的编写与原来没有任何差别呢?

Shadow 的做法是字节码替换插件,我认为这是一个非常棒的想法,简单来说,Android 提供了一些 Gradle 插件开发套件,其中有一项功能叫 Transform Api,它可以介入项目的构建过程,在字节码生成后、dex 文件生成钱,对代码进行某些变换,具体怎么做的不说了,可以自己看文档。

实现的功能嘛,就是用户配置 Gradle 插件后,正常开发,依然编写:

class TestActivity extends Activity {}

然后完成编译后,最后的字节码中,显示的却是:

class TestActivity extends PluginActivity {}

到这里基本的框架就差不多结束了。

✨ 写在最后

插件化是一门很有意思的学问,Emm,怎么说呢,用一句话来形容就是偷天换日灯下黑,在各种坑的限制下不断跟系统博弈寻找出路。随着了解的深入,大家肯定能理解我这句话,本文也只是抛砖引玉,更多的乐趣还是要自己去发掘。

Licensed under CC BY-NC-SA 4.0
©2017-2021 Copyright kindem.xyz / 湘ICP备17018771号-1
Built with Hugo
Theme Stack designed by Jimmy