Android 插件化原理浅析

插件化是用于动态加载并运行代码的技术,通常可用于精简APK体积、动态更新功能、进行热修复等
本文源码基于
API 33
,目录如下
- 插件化的目标
- 插件化的理论基础
- 组件检查
- 类检索
- 资源检索
- 插件化的手段
- 注册组件
- 加载类
- 加载资源
- 其它组件的插件化
- Service
- BroadcastReceiver
- ContentProvider
- 参考资料
插件化的目标
插件化技术为APP运行时提供了灵活的加载和运行机制,可以在运行时动态执行通过网络下发的代码,使原生具备了近似于H5的灵活性。插件化的动态技术,通常服务于以下目标:
- 缩减APK体积:通过将二级模块做成在线下载的插件,可以有效缩减应用APK体积,按需加载
- 破除65535方法数限制:继
MultiDex
以外的另一种方案 - 动态修复:发现线上问题时通过更新补丁进行修复,控制风险,防止问题进一步扩大
- 在线升级:在线插件可以实时展示最新版本,升级效率高于APK自身版本升级
- 提升编译效率:宿主和插件可以并行编译
插件化的理论基础
通常我们讲插件化,讨论的是Activity的插件化实现。
Android组件管理机制
在用Context.startActivity(Intent)
拉起新页面时,如果目标Activity没有在AndroidManifest.xml
文件中注册,会抛出如下异常:
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
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_RESOLVED
、START_CLASS_NOT_FOUND
两个结果码都会导致无法创建Activity对象。后者属于类加载机制的原因,此处主要分析前者。
所检查的结果码来自于Instrumentation.java
的execStartActivity()
,其内部调用AMS来启动Activity。
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
文件,它先被加载到内存中,然后在内存中经历函数调用,最后从内存中卸载,这是一个类完整的生命周期过程,包含以下阶段:
- 加载
- 链接(包含验证、准备、解析三个阶段)
- 初始化
- 使用
- 卸载
本章节重点分析其加载原理。
Java中的类加载
负责进行加载类的工具称为类加载器
,Java中的类加载器分为系统类加载器和自定义类加载器两种。
系统类加载器
- 引导类加载器(
Bootstrap ClassLoader
):用c/c++语言实现,用于加载JDK中的核心类,主要加载$JAVA_HOME/jre/lib
目录下的类,例如rt.jar
、resources.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
)加载器,代码如下。
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代码中无法获得它的引用。
AppClassLoader@vi9g71ng
ExtClassLoader@jb0b8ang
注意在上文中我用了“委托”
而不是“继承”
,这是因为加载器内部采用的是委托机制,委托顺序如下图:
- 左侧是JDK中实现的类加载器,通过
parent
属性形成父子关系。应用中自定义的类加载器的parent
都是AppClassLoader - 右侧是JDK中的类加载器实现类。通过类继承的机制形成体系。未来我们就可以通过继承相关的类实现自定义类加载器
自定义类加载器
在一些特殊需求场景下需要自定义类加载器,例如从网络上获取到一个加密后的.class
文件,此时通过自定义类加载器,将字节流解密后,再创建运行时的类对象。
双亲委托模式
是Java类加载机制最核心的知识,描述了在加载器层级结构中如何正确地找到目标类加载器的过程。文字描述如下:
- JVM产生加载某个类的需求
- 首先检索本级缓存,判断这个类是否已经加载过,如果加载过的话则直接返回加载过的对象
- 如果没有加载过,则看当前的类加载器是否存在parent(双亲),如果存在,则交由双亲检索
- 不断向上追查祖先,直到最古老的祖先,然后由该祖先进行查找,同样会查找当前该层级的缓存
- 如果祖先查找不到,则交回儿子加载器查找
- 直到返回当前加载器,触发其
findClass
进行查找
其实这个过程与Android UI中的事件分发处理机制
相似度达到99%,都是将事件(加载、触摸)层层向内/向上传递,一直传递到尽头后尝试进行消费,如果消费成功则终止,消费不成功则反向传回,逐级消费。
一图胜千言。

双亲委派模式的优点
- 效率:加载过一次的类会进行缓存,提升下次使用的速度
- 安全:所有加载任务都会层层传递给系统的类加载器,Java、Android的核心类都是由系统加载的,防止攻击者篡改这些核心类
- 解耦:不同层级的加载器负责不同范围的类的加载,职责清晰,易于理解和维护
判断两个类相同
需要同时满足:
- 类名相同
- 完整包名相同
- 加载器是同一个
Android中的类加载
有了上文的基础,直接用结构图表示,其中BootstrapClassLoader
和PathClassLoader
是必不可少的,其它类加载器由使用者自行定义。
资源加载机制
在讨论资源时,通常指的是assets
目录、res
目录下的drawable
、layout
、color
、string
等。
例:设置ImageView的资源
以设置ImageView的src
为例,有两种方法,分别是在布局文件中直接指明android:src="@drawable/some_image.png"
,和在Java代码中调用ImageView.setImageResource()
。
在布局文件中声明
其函数调用链为:
- ImageView:
constructor
- TypedArray:
getDrawable
- Resources:
getDrawable
- ResourcesImpl:
loadDrawableForCookie
通过setImageResource()设置
- ImageView:
setImageResource
->resolveUri
- Context:
getDrawable
- Resources:
getDrawable
- ResourcesImpl:
loadDrawableForCookie
上述两个方式最终都会调用到ResourceImpl.loadDrawableForCookie
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中,初始化时分别执行以下任务:
- 加载系统的framework-res.apk,导入系统资源,如以android.开头的color等
- 传入apk文件时,查找其内部的resources.arsc进行加载
- 将解析到的资源添加到mResources进行维护,它是位于Native层的资源表
这样,通过Context.getResources
就可以拿到上文中在Native层维护的mResources资源表,进而通过Key查找相应资源。
在以上理论基础上,进行插件化的实践。
插件化的实践
在应用插件化思想的过程中,需要解决以下三个难点:
- 类文件注入,把插件dex插入到ClassLoader类加载器中
- Activity组件的注册,从而将目标页面的生命周期与系统绑定
- 资源注入,让宿主、插件的资源实现互相访问
加载类
自定义PluginClassLoader
新建一个PluginClassLoader
,用来加载插件中的类。
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)
函数就可以加载插件中的类。
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对象
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
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应当是它的子类
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
class SamplePluginActivity : Activity() {}
自动修改后,SamplePluginActivity.kt
class SamplePluginActivity : PluginBaseActivity() {}
加载资源
可访问插件资源的Resources对象
通过上文分析,我们知道APP运行时,通过Context.getResources
获取到一个全局的Resources
对象,其内部封装了ResourcesImpl
进行资源查找,ResourcesImpl
则与AssetsManager
绑定,后者会关联到文件系统中具体的APK。
因此,资源注入的思路是,对插件APK构建其Resources
对象,然后就可以实现在PluginBaseActivity
中提供借助这个Resources
对象查找插件中的资源。
PluginLoader.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,用于从插件/宿主中获取资源
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
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的唯一标志,转发机制就特别适用
参考资料
转载自:https://juejin.cn/post/7357301805569441803