插件化技术最初源于免安装运行 apk 的想法,这个免安装的 apk 可以理解为插件。支持插件化的 app 可以在运行时加载和运行插件,这样便可以将 app 中一些不常用的功能模块做成插件,一方面减小了安装包的大小,另一方面可以实现 app 功能的动态扩展。想要实现插件化,主要是解决下面三个问题:
1. 插件化发展
第一代:dynamic-load-apk 最早使用 ProxyActivity 这种静态代理技术,由 ProxyActivity 去控制插件中 PluginActivity 的生命周期。该种方式缺点明显,插件中的 activity 必须继承 PluginActivity,开发时要小心处理 context。而 DroidPlugin 通过 Hook 系统服务的方式启动插件中的 Activity,使得开发插件的过程和开发普通的 app 没有什么区别,但是由于 hook 过多系统服务,异常复杂且不够稳定。
第二代:为了同时达到插件开发的低侵入性 (像开发普通 app 一样开发插件) 和框架的稳定性,在实现原理上都是趋近于选择尽量少的 hook,并通过在 manifest 中预埋一些组件实现对四大组件的插件化。另外各个框架根据其设计思想都做了不同程度的扩展,其中 Small 更是做成了一个跨平台,组件化的开发框架。
第三代:VirtualApp 比较厉害,能够完全模拟 app 的运行环境,能够实现 app 的免安装运行和双开技术。Atlas 是阿里今年开源出来的一个结合组件化和热修复技术的一个 app 基础框架,其广泛的应用与阿里系的各个 app,其号称是一个容器化框架。
2. 基本原理
外部 apk 中类的加载:
Android 中常用的有两种类加载器,DexClassLoader 和 PathClassLoader,它们都继承于BaseDexClassLoader。
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
}
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String libraryPath,
ClassLoader parent) {
super(dexPath, null, libraryPath, parent);
}
}
区别在于调用父类构造器时,DexClassLoader 多传了一个 optimizedDirectory 参数,这个目录必须是内部存储路径,用来缓存系统创建的 Dex 文件。而 PathClassLoader 该参数为 null,只能加载内部存储目录的 Dex 文件。所以我们可以用DexClassLoader 去加载外部的 apk,用法如下:
/**
* 第一个参数为 apk 的文件目录
* 第二个参数为内部存储目录
* 第三个为库文件的存储目录
* 第四个参数为父加载器
*/
new DexClassLoader(apk.getAbsolutePath(), dexOutputPath, libsDir.getAbsolutePath(), parent)
双亲委托机制:
ClassLoader 调用 loadClass 方法加载类:
protected Class> loadClass(String className, boolean resolve) throws ClassNotFoundException {
//首先从已经加载的类中查找
Class> clazz = findLoadedClass(className);
if (clazz == null) {
ClassNotFoundException suppressed = null;
try {
//如果没有加载过,先调用父加载器的loadClass
clazz = parent.loadClass(className, false);
} catch (ClassNotFoundException e) {
suppressed = e;
}
if (clazz == null) {
try {
//父加载器都没有加载,则尝试加载
clazz = findClass(className);
} catch (ClassNotFoundException e) {
e.addSuppressed(suppressed);
throw e;
}
}
}
return clazz;
}
可以看出 ClassLoader 加载类时,先查看自身是否已经加载过该类,如果没有加载过会首先让父加载器去加载,如果父加载器无法加载该类时才会调用自身的 findClass 方法加载,该机制很大程度上避免了类的重复加载。
DexClassLoader 的 DexPathList:
DexClassLoader 重载了 findClass 方法,在加载类时会调用其内部的 DexPathList 去加载。DexPathList 是在构造DexClassLoader 时生成的,其内部包含了 DexFile。DexPathList 的 loadClass 会去遍历 DexFile 直到找到需要加载的类:
public Class findClass(String name, List suppressed) {
//循环dexElements,调用DexFile.loadClassBinaryName加载class
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
有一种热修复技术正是利用了 DexClassLoader 的加载机制,将需要替换的类添加到 dexElements 的前面,这样系统会使用先找到的修复过的类。
通过给插件 apk 生成相应的 DexClassLoader 便可以访问其中的类,这边又有两种处理方式,有单 DexClassLoader 和多DexClassLoader 两种结构。
多 DexClassLoader:
对于每个插件都会生成一个 DexClassLoader,当加载该插件中的类时需要通过对应 DexClassLoader 加载。这样不同插件的类是隔离的,当不同插件引用了同一个类库的不同版本时,不会出问题。RePlugin 采用的是该方案。
单 DexClassLoader:
将插件的 DexClassLoader 中的 pathList 合并到主工程的 DexClassLoader 中。这样做的好处时,可以在不同的插件以及主工程间直接互相调用类和方法,并且可以将不同插件的公共模块抽出来放在一个 common 插件中直接供其他插件使用。Small 采用的是这种方式。
互相调用
插件调用主工程:
主工程调用插件:
Android 系统通过 Resource 对象加载资源,下面代码展示了该对象的生成过程:
//创建AssetManager对象
AssetManager assets = new AssetManager();
//将apk路径添加到AssetManager中
if (assets.addAssetPath(resDir) == 0){
return null;
}
//创建Resource对象
r = new Resources(assets, metrics, getConfiguration(), compInfo);
因此,只要将插件 apk 的路径加入到 AssetManager 中,便能够实现对插件资源的访问。具体实现时,由于 AssetManager 并不是一个 public 的类,需要通过反射去创建,并且部分 Rom 对创建的 Resource 类进行了修改,所以需要考虑不同 Rom 的兼容性。
资源路径的处理:
和代码加载相似,插件和主工程的资源关系也有两种处理方式。
合并式由于 AssetManager 中加入了所有插件和主工程的路径,因此生成的 Resource 可以同时访问插件和主工程的资源。但是由于主工程和各个插件都是独立编译的,生成的资源 id 会存在相同的情况,在访问时会产生资源冲突。
独立式时,各个插件的资源是互相隔离的,不过如果想要实现资源的共享,必须拿到对应的 Resource 对象。
Context 的处理:
通常我们通过 Context 对象访问资源,光创建出 Resource 对象还不够,因此还需要一些额外的工作。 对资源访问的不同实现方式也需要不同的额外工作。以 VirtualAPK 的处理方式为例:
第一步:创建 Resource
if (Constants.COMBINE_RESOURCES) {
//插件和主工程资源合并时需要hook住主工程的资源
Resources resources = ResourcesManager.createResources(context, apk.getAbsolutePath());
ResourcesManager.hookResources(context, resources);
return resources;
} else {
//插件资源独立,该resource只能访问插件自己的资源
Resources hostResources = context.getResources();
AssetManager assetManager = createAssetManager(context, apk);
return new Resources(assetManager, hostResources.getDisplayMetrics(), hostResources.getConfiguration());
}
第二步:hook 主工程的 Resource
对于合并式的资源访问方式,需要替换主工程的 Resource,下面是具体替换的代码。
public static void hookResources(Context base, Resources resources) {
try {
ReflectUtil.setField(base.getClass(), base, "mResources", resources);
Object loadedApk = ReflectUtil.getPackageInfo(base);
ReflectUtil.setField(loadedApk.getClass(), loadedApk, "mResources", resources);
Object activityThread = ReflectUtil.getActivityThread(base);
Object resManager = ReflectUtil.getField(activityThread.getClass(), activityThread, "mResourcesManager");
if (Build.VERSION.SDK_INT < 24) {
Map
注意下上述代码 hook 了几个地方,包括以下几个 hook 点:
第三步:关联 resource 和 Activity
Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
activity.setIntent(intent);
//设置Activity的mResources属性,Activity中访问资源时都通过mResources
ReflectUtil.setField(ContextThemeWrapper.class, activity, "mResources", plugin.getResources());
上述代码是在 Activity 创建时被调用的 (后面会介绍如何 hook Activity 的创建过程),在 activity 被构造出来后,需要替换其中的mResources 为插件的 Resource。由于独立式时主工程的 Resource 不能访问插件的资源,所以如果不做替换,会产生资源访问错误。
做完以上工作后,则可以在插件的 Activity 中放心的使用 setContentView,inflater 等方法加载布局了。
资源冲突
合并式的资源处理方式,会引入资源冲突,原因在于不同插件中的资源 id 可能相同,所以解决方法就是使得不同的插件资源拥有不同的资源 id。资源 id 是由 8 位 16 进制数表示,表示为 0xPPTTNNNN。PP 段用来区分包空间,默认只区分了应用资源和系统资源,TT 段为资源类型,NNNN 段在同一个 APK 中从 0000 递增。
所以思路是修改资源 id 的 PP 段,对于不同的插件使用不同的 PP 段,从而区分不同插件的资源。具体实现方式有两种
具体实现可以分别参考 Atlas 框架和 Small 框架。推荐第二种方式,不用入侵原有的编译流程。
3. 四大组件支持
Android 开发中有一些特殊的类,是由系统创建的,并且由系统管理生命周期。如常用的四大组件,Activity,Service,BroadcastReceiver 和 ContentProvider。 仅仅构造出这些类的实例是没用的,还需要管理组件的生命周期。其中以 Activity 最为复杂,不同框架采用的方法也不尽相同。下面以 Activity 为例详细介绍插件化如何支持组件生命周期的管理,大致分为两种方式:
ProxyActivity 代理的方式最早是由 dynamic-load-apk 提出的,其思想很简单,在主工程中放一个 ProxyActivy,启动插件中的Activity 时会先启动 ProxyActivity,在 ProxyActivity 中创建插件 Activity,并同步生命周期。
代理方式的关键总结起来有下面两点:
该方式虽然能够很好的实现启动插件 Activity 的目的,但是由于开发式侵入性很强,dynamic-load-apk 之后的插件化方案很少继续使用该方式,而是通过 hook 系统启动 Activity 的过程,让启动插件中的 Activity 像启动主工程的 Activity 一样简单。
下面介绍如何通过 hook 的方式启动插件中的 Activity,需要解决以下两个问题:
解决方法有很多种,以 VirtualAPK 为例,核心思路如下:
下面具体分析整个过程涉及到的代码:
替换系统 Instrumentation
VirtualAPK 在初始化时会调用 hookInstrumentationAndHandler,该方法 hook 了系统的 Instrumentaiton 类,由上文可知该类和Activity 的启动息息相关。
private void hookInstrumentationAndHandler() {
try {
//获取Instrumentation对象
Instrumentation baseInstrumentation = ReflectUtil.getInstrumentation(this.mContext);
//构造自定义的VAInstrumentation
final VAInstrumentation instrumentation = new VAInstrumentation(this, baseInstrumentation);
//设置ActivityThread的mInstrumentation和mCallBack
Object activityThread = ReflectUtil.getActivityThread(this.mContext);
ReflectUtil.setInstrumentation(activityThread, instrumentation);
ReflectUtil.setHandlerCallback(this.mContext, instrumentation);
this.mInstrumentation = instrumentation;
} catch (Exception e) {
e.printStackTrace();
}
}
该段代码将主线程中的 Instrumentation 对象替换成了自定义的 VAInstrumentation 类。在启动和创建插件 activity 时,该类都会偷偷做一些手脚。
hook activity 启动过程
VAInstrumentation 类重写了 execStartActivity 方法:
public ActivityResult execStartActivity(
Intent intent) {
//转换隐式intent
mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
if (intent.getComponent() != null) {
//替换intent中启动Activity为StubActivity
this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
}
//调用父类启动Activity的方法
public void markIntentIfNeeded(Intent intent) {
if (intent.getComponent() == null) {
return;
}
String targetPackageName = intent.getComponent().getPackageName();
String targetClassName = intent.getComponent().getClassName(); // search map and return specific launchmode stub activity
if (!targetPackageName.equals(mContext.getPackageName()) && mPluginManager.getLoadedPlugin(targetPackageName) != null) {
intent.putExtra(Constants.KEY_IS_PLUGIN, true);
intent.putExtra(Constants.KEY_TARGET_PACKAGE, targetPackageName);
intent.putExtra(Constants.KEY_TARGET_ACTIVITY, targetClassName);
dispatchStubActivity(intent);
}
}
execStartActivity 中会先去处理隐式 intent,如果该隐式 intent 匹配到了插件中的 Activity,将其转换成显式。之后通过markIntentIfNeeded 将待启动的的插件 Activity 替换成了预先在 AndroidManifest 中占坑的 StubActivity,并将插件 Activity 的信息保存到该 intent 中。其中有个 dispatchStubActivity 函数,会根据 Activity 的 launchMode 选择具体启动哪个 StubActivity。VirtualAPK 为了支持 Activity 的 launchMode 在主工程的 AndroidManifest 中对于每种启动模式的 Activity 都预埋了多个坑位。
hook Activity 的创建过程
上一步欺骗了系统,让系统以为自己启动的是一个正常的 Activity。当构建 Activity 时,再将插件的 Activity 换回来,此时调用的是 VAInstrumentation 类的 newActivity 方法。
@Override
public Activity newActivity(ClassLoader cl, String className, Intent intent){
try {
cl.loadClass(className);
} catch (ClassNotFoundException e) {
//通过LoadedPlugin可以获取插件的ClassLoader和Resource
LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
//获取插件的主Activity
String targetClassName = PluginUtil.getTargetActivity(intent);
if (targetClassName != null) {
//传入插件的ClassLoader构造插件Activity
Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
activity.setIntent(intent);
//设置插件的Resource,从而可以支持插件中资源的访问
try {
ReflectUtil.setField(ContextThemeWrapper.class, activity, "mResources", plugin.getResources());
} catch (Exception ignored) {
// ignored.
}
return activity;
}
} return mBase.newActivity(cl, className, intent);
}
由于 AndroidManifest 中预埋的 StubActivity 并没有具体的实现类,所以此时会发生 ClassNotFoundException。之后在处理异常时取出插件 Activity 的信息,通过插件的 ClassLoader 反射构造插件的 Activity。
一些额外操作
插件 Activity 构造出来后,为了能够保证其正常运行还要做些额外的工作,VAInstrumentation 类做了一些处理:
@Override
public void callActivityOnCreate(Activity activity, Bundle icicle) {
final Intent intent = activity.getIntent();
if (PluginUtil.isIntentFromPlugin(intent)) {
Context base = activity.getBaseContext();
try {
LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
ReflectUtil.setField(base.getClass(), base, "mResources", plugin.getResources());
ReflectUtil.setField(ContextWrapper.class, activity, "mBase", plugin.getPluginContext());
ReflectUtil.setField(Activity.class, activity, "mApplication", plugin.getApplication());
ReflectUtil.setFieldNoException(ContextThemeWrapper.class, activity, "mBase", plugin.getPluginContext());
// set screenOrientation
ActivityInfo activityInfo = plugin.getActivityInfo(PluginUtil.getComponent(intent));
if (activityInfo.screenOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
activity.setRequestedOrientation(activityInfo.screenOrientation);
}
} catch (Exception e) {
e.printStackTrace();
}
}
mBase.callActivityOnCreate(activity, icicle);
}
这段代码主要是将 Activity 中的 Resource,Context 等对象替换成了插件的相应对象,保证插件 Activity 在调用涉及到 Context的方法时能够正确运行。
经过上述步骤后,便实现了插件 Activity 的启动,并且该插件 Activity 中并不需要什么额外的处理,和常规的 Activity 一样。那问题来了,之后的 onResume,onStop 等生命周期怎么办呢?答案是所有和 Activity 相关的生命周期函数,系统都会调用插件中的 Activity。原因在于 AMS 在处理 Activity 时,通过一个 token 表示具体 Activity 对象,而这个 token 正是和启动 Activity 时创建的对象对应的,而这个 Activity 被我们替换成了插件中的 Activity,所以之后 AMS 的所有调用都会传给插件中的 Activity。
小结
VirtualAPK 通过替换了系统的 Instrumentation,hook 了 Activity 的启动和创建,省去了手动管理插件 Activity 生命周期的繁琐,让插件 Activity 像正常的 Activity 一样被系统管理,并且插件 Activity 在开发时和常规一样,即能独立运行又能作为插件被主工程调用。
其他插件框架在处理 Activity 时思想大都差不多,无非是这两种方式之一或者两者的结合。在 hook 时,不同的框架可能会选择不同的 hook 点。如 360 的 RePlugin 框架选择 hook 了系统的 ClassLoader,在判断出待启动的 Activity 是插件中的时,会调用插件的 ClassLoader 构造相应对象。另外 RePlugin 为了系统稳定性,选择了尽量少的 hook,因此它并没有选择 hook 系统的startActivity 方法来替换 intent,而是通过重写 Activity 的 startActivity,因此其插件 Activity 是需要继承一个类似 PluginActivity的基类的。不过 RePlugin 提供了一个 Gradle 插件将插件中的 Activity 的基类换成了 PluginActivity,用户在开发插件 Activity 时也是没有感知的。
四大组件中 Activity 的支持是最复杂的,其他组件的实现原理要简单很多,简要概括如下: