浅谈 Android 插件化原理
好文推荐:
作者:kindem
转载地址:https://www.kindem.xyz/post/key/29
🔌 认识插件化
想必大家都知道,在 Android
系统中,应用是以 Apk
的形式存在的,应用都需要安装才能使用。但实际上 Android
系统安装应用的方式相当简单,其实就是把应用 Apk
拷贝到系统不同的目录下、然后把 so
解压出来而已。
常见的应用安装目录有:
-
/system/app
:系统应用 -
/system/priv-app
:系统应用 -
/data/app
:用户应用
那可能大家会想问,既然安装这个过程如此简单,Android
是怎么运行应用中的代码的呢,我们先看 Apk
的构成,一个常见的 Apk
会包含如下几个部分:
-
classes.dex
:Java
代码字节码 -
res
:资源目录 -
lib
:so
目录 -
assets
:静态资产目录 -
AndroidManifest.xml
:清单文件
其实 Android
系统在打开应用之后,也只是开辟进程,然后使用 ClassLoader
加载 classes.dex
至进程中,执行对应的组件而已。
那大家可能会想一个问题,既然 Android
本身也是使用类似反射的形式加载代码执行,凭什么我们不能执行一个 Apk
中的代码呢?
这其实就是插件化的目的,让 Apk
中的代码(主要是指 Android
组件)能够免安装运行,这样能够带来很多收益,最显而易见的优势其实就是通过网络热更新、热修复,想象一下,你的应用拥有 Native
应用一般极高的性能,又能获取诸如 Web
应用一样的收益。
嗯,理想很美好不是嘛?
🎯 难点在哪
大家其实都知道,Android
应用本身是基于魔改的 Java
虚拟机的,动态加载代码简直不要太简单,只需要使用 DexClassLoader
加载 Apk
,然后反射里面的代码就可以了。
但是光能反射代码是没有意义的,插件化真正的魅力在于,可以动态加载执行 Android
组件(即 Activity
、Service
、BroadcastReceiver
、ContentProvider
、Fragment
)等。
仔细想一下,其实要做到这一点是有难度的,最主要的阻碍是,四大组件是在系统里面中注册的,具体来说是在 Android
系统的 ActivityManagerService (AMS)
和 PackageManagerService (PMS)
中注册的,而四大组件的解析和启动都需要依赖 AMS
和 PMS
,如何欺骗系统,让他承认一个未安装的 Apk
中的组件,就是插件化的最大难点。
另外,资源(特指 R
中引用的资源,如 layout
、values
等)也是一大问题,想象一下你在宿主进程中使用反射加载了一个插件 Apk
,代码中的 R
对应的 id
却无法引用到正确的资源,会产生什么后果。
总结一下,其实做到插件化的要点就这几个:
- 反射并执行插件
Apk
中的代码(ClassLoader Injection
) - 让系统能调用插件
Apk
中的组件(Runtime Container
) - 正确识别插件
Apk
中的资源(Resource Injection
)
当然还有其他一些小问题,但可能不是所有场景下都会遇到,我们后面再单独说。
🎊 解决方案
首先来谈一谈常见插件化框架的整体架构,市面上的插件化框架实际很多,如 Tecent
的 Shadow、Didi
的 VirtualApk、360
的 RePlugin,我自己也写了一个简单的插件化框架 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
中的组件,是未注册到 AMS
和 PMS
的,就好比你直接使用 startActivity
启动一个插件 Apk
中的组件,系统会告诉你无法找到。
我们的解决方案很简单,即运行时容器技术,简单来说就是在宿主 Apk
中预埋一些空的 Android
组件,以 Activity
为例,我预置一个 ContainerActivity extends Activity
在宿主中,并且在 AndroidManifest.xml
中注册它。
它要做的事情很简单,就是帮助我们作为插件 Activity
的容器,它从 Intent
接受几个参数,分别是插件的不同信息,如:
pluginName
pluginApkPath
pluginActivityName
等,其实最重要的就是 pluginApkPath
和 pluginActivityName
,当 ContainerActivity
启动时,我们就加载插件的 ClassLoader
、Resource
,并反射 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
应用的开发其实崇尚的是逻辑与资源分离的理念,所有资源(layout
、values
等)都会被打包到 Apk
中,然后生成一个对应的 R
类,其中包含对所有资源的引用 id
。
资源的注入并不容易,好在 Android
系统给我们留了一条后路,最重要的是这两个接口:
-
PackageManager#getPackageArchiveInfo
:根据Apk
路径解析一个未安装的Apk
的PackageInfo
-
PackageManager#getResourcesForApplication
:根据ApplicationInfo
创建一个Resources
实例
我们要做的就是在上面 ContainerActivity#onCreate
中加载插件 Apk
的时候,用这两个方法创建出来一份插件资源实例。具体来说就是先用 PackageManager#getPackageArchiveInfo
拿到插件 Apk
的 PackageInfo
,有了 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,怎么说呢,用一句话来形容就是偷天换日灯下黑,在各种坑的限制下不断跟系统博弈寻找出路。随着了解的深入,大家肯定能理解我这句话,本文也只是抛砖引玉,更多的乐趣还是要自己去发掘。
如果还想了解更多Android 相关的更多知识点,可以点进我的【GitHub项目中:https://github.com/733gh/GH-Android-Review-master】自行查看,里面记录了许多的Android 知识点。
持续更新--请Android的小伙伴关注! 喜欢的话给一个赞Star吧!