插件化几乎是现在 Android 开发工程师的必备技能之一了。在前几年,Android 的安全机制不是很完善的时候,插件化的确大放异彩了一段时间,但是随着 Android 变得越来越安全,插件化的风头也有所收敛。从最初只支持 Activity 的动态加载发展到可以完全模拟 app 运行时的沙箱系统,插件化的技术也一步步趋于成熟。
一、发展历史
插件化技术最初源于免安装运行apk的想法,这个免安装的 apk 可以理解为插件。支持插件化的 app 可以在运行时加载和运行插件,这样便可以将 app 中一些不常用的功能模块做成插件,一方面减小了安装包的大小,另一方面可以实现 app 功能的动态扩展。想要实现插件化,主要是解决下面三个问题:
- 插件中代码的加载和与主工程的互相调用
- 插件中资源的加载和与主工程的互相访问
- 四大组件生命周期的管理
下面是比较出名的几个开源的插件化框架,按照出现的时间排序。研究它们的实现原理,可以大致看出插件化技术的发展,根据实现原理我把这几个框架划分成了三代。
第一代:dynamic-load-apk最早使用 ProxyActivity 这种静态代理技术,由 ProxyActivity 去控制插件中 PluginActivity 的生命周期。该种方式缺点明显,插件中的 activity 必须继承 PluginActivity,开发时要小心处理 context。而 DroidPlugin 通过 Hook 系统服务的方式启动插件中的 Activity,使得开发插件的过程和开发普通的 app 没有什么区别,但是由于 hook 过多系统服务,异常复杂且不够稳定。
第二代:为了同时达到插件开发的低侵入性(像开发普通 app 一样开发插件)和框架的稳定性,在实现原理上都是趋近于选择尽量少的 hook,并通过在 manifest 中预埋一些组件实现对四大组件的插件化。另外各个框架根据其设计思想都做了不同程度的扩展,其中 Small 更是做成了一个跨平台,组件化的开发框架。
第三代:VirtualApp 比较厉害,能够完全模拟 app 的运行环境,能够实现 app 的免安装运行和双开技术。Atlas 是阿里开源出来的一个结合组件化和热修复技术的一个 app 基础框架,其广泛的应用与阿里系的各个 app,其号称是一个容器化框架。
下面详细介绍插件化框架的原理,分别对应着实现插件化的三个核心问题。
二、基本原理
在探索基本原理之前,我们先看一张 Android Apk 打包的流程图:
基本分为7个阶段:
打包资源文件,生成R.java文件
打包资源的工具是aapt,在这个过程中,项目中的AndroidManifest.xml文件和布局文件XML都会编译,然后生成相应的R.java,另外AndroidManifest.xml会被aapt编译成二进制。存放在APP的res目录下的资源,该类资源在APP打包前大多会被编译,变成二进制文件,并会为每个该类文件赋予一个resource id。对于该类资源的访问,应用层代码则是通过resource id进行访问的。Android应用在编译过程中aapt工具会对资源文件进行编译,并生成一个resource.arsc文件,resource.arsc文件相当于一个文件索引表,记录了很多跟资源相关的信息。
处理aidl文件,生成相应的Java文件
aidl工具解析接口定义文件然后生成相应的Java代码接口供程序调用。如果在项目没有使用到aidl文件,则可以跳过这一步。
编译项目源代码,生成class文件
项目中所有的Java代码,包括R.java和.aidl文件,都会变Java编译器(javac)编译成.class文件,生成的class文件位于工程中的bin/classes目录下。
转换所有的class文件,生成classes.dex文件
dex工具生成可供Android系统Dalvik虚拟机执行的classes.dex文件,任何第三方的libraries和.class文件都会被转换成.dex文件。dx工具的主要工作是将Java字节码转成成Dalvik字节码、压缩常量池、消除冗余信息等。
打包生成APK文件
所有没有编译的资源,如images、assets目录下资源(该类文件是一些原始文件,APP打包时并不会对其进行编译,而是直接打包到APP中,对于这一类资源文件的访问,应用层代码需要通过文件名对其进行访问);编译过的资源和.dex文件都会被apkbuilder工具打包到最终的.apk文件中。
对APK文件进行签名
一旦APK文件生成,它必须被签名才能被安装在设备上。在开发过程中,主要用到的就是两种签名的keystore。一种是用于调试的debug.keystore,它主要用于调试,在Eclipse或者Android Studio中直接run以后跑在手机上的就是使用的debug.keystore。另一种就是用于发布正式版本的keystore。
对签名后的APK文件进行对齐处理
如果你发布的apk是正式版的话,就必须对APK进行对齐处理,用到的工具是 zipalign。对齐的主要过程是将APK包中所有的资源文件距离文件起始偏移为4字节整数倍,这样通过内存映射访问apk文件时的速度会更快。对齐的作用就是减少运行时内存的使用。
那么,其实插件化的突破口,就在生成的 .dex 文件和 Compiled Resources 文件里。
1. 类加载
我们都知道,Java 中类的加载使用的是 ClassLoader,它负责将 *.class 加载为内存中的 Class 对象。它的加载机制为『双亲委派』,即能交给父类加载器去加载的,绝不自行加载。这一点我们可以从它的源码中看出:
public abstract class ClassLoader {
...
// 父 class loader 的委托
// 注意:VM 将该变量的偏移量硬编码了,所以新的成员变量必须要声明在这个变量之后
private final ClassLoader parent;
...
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 第一步,先检查是否已经被加载了
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 这里使用了『双亲委托』机制,递归调用父类的 loadClass 方法
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
// 如果还没找到,就调用 findClass 方法来找到类
c = findClass(name);
}
}
return c;
}
双亲委派机制有两个作用:
- 防止重复加载同一个 class 。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
- 保证核心 class 不能被篡改。通过委托方式,不会去篡改核心 class,即使篡改也不会去加载,即使加载了也不会是同一个 class 对象了。不同的加载器加载同一个 class 也不是同一个 Class 对象。这样保证了 Class 执行安全。
在 Android 中 ClassLoader 有多个派生类,如下图所示:
其中最常用也最重要的两个类,就是 DexClassLoader 和 PathClassLoader 了。它们都继承自 BaseDexClassLoader。我们先来看看 DexClassLoader 的代码:
DexClassLoader:
public class DexClassLoader extends BaseDexClassLoader {
// 四个参数分别是:
// dexPath:包含 dex 文件的 jar 包或 apk 文件的路径
// optimizedDirectory:dex 文件释放目录/缓存目录,必须为应用的 private 目录,不能为空
// librarySearchPath:native 库的路径,可以为空
// parent: 父类加载器(用于双亲委派)
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
DexClassLoader 的主要功能如下:
- 用于加载包含 *.dex 文件的 jar 包或 apk 文件
- 要求一个应用私有可写的目录去缓存编译的 class 文件
- 不允许加载外部存储空间的文件,以防注入攻击
从 API 26 开始,optimizedDirectory
将不再起作用,这个字段将会被废除掉。
PathClassLoader:
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}
// 三个参数分别是:
// dexPath:包含 dex 文件的 jar 包或 apk 文件的路径
// librarySearchPath:native 库的路径,可以为空
// parent: 父类加载器(用于双亲委派)
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
}
}
无论哪种动态加载,加载的可执行文件一定要存放在内部存储。DexClassLoader 可以指定自己的optimizedDirectory
,所以它可以加载外部的 dex ,最终这个 dex 会被复制到内部路径的optimizedDirectory
中;而 PathClassLoader 没有optimizedDirectory
,所以它只能加载内部的 dex,这些大都是存在系统中已经安装过的 apk 里面的。PathClassLoader 在 ART 虚拟机上可以加载未安装的 apk 的 dex ,在 Dalvik 则不行。
2. 资源加载
Android 中的资源分为两类:
第一类是 res 目录下存放的可编辑的资源文件,这类文件在编译时系统会自动在R文件中生成资源文件的16进制值。例如:
public final class R {
public static final class anim {
public static final int abc_fade_in=0x7f050000;
public static final int abc_fade_in=0x7f050000;
...
}
}
平时开发时,访问这类资源比较简单,只要用Context.getResource()
方法即可。
第二类是 assets 目录下存放的资源文件。APK 打包时并不会处理这些资源,而是直接将其打包,所以我们不能直接访问,只能用AssetManager.open()
方法来实现对 assets 目录下资源文件的访问。如下:
Resource resource = getResource();
AssetsManager am = getResource().getAssets();
InputStream is = getResource().getAssets().open("filename");
通过对这两类资源的分析,我们可以初步做出一个结论:我们能使用的 Resources 类是一个很重要的类,通过此类提供的相关 API ,我们能操作资源的加载。
谈及资源插件化,我们不得不对 AssetsManager 的 API 多说一些。
AssetsManager 中有一个addAssetsPath(String Path)
方法,App 启动的时候就会将当前的 apk 路径传进去,接下来 AssetsManager 和 Resources 就能访问当前 apk 的所有资源了。
AssetsManager 的addAssetsPath
方法不对外,但是我们可以通过反射的方式,把插件 apk 的路径传到这个方法,这样就把插件的资源添加到一个资源池中了。App 有几个插件,我们就调用几次addAssetsPath
方法,把插件的资源都塞到池子里。
举个栗子,比如在插件的 apk 里存在一个名为plugin_s_1
的字符串,我们看看如何在宿主中访问它。
先理顺一下思路:
- 先要创建 AssetManager 对象,读取插件中的资源
- 用 DexClassLoader 加载插件,并生成该插件对应的 ClassLoader
- 反射获取插件中的类,并将其实例化,就可以让插件类读取插件资源了
写成代码可以如下:
public class MainActivity extends Activity {
private AssetManager mAssetManager;
private Resources mResources;
private Resources.Theme mTheme;
private String dexPath = null; //apk文件地址
private File fileRelease = null; //释放目录
protected DexClassLoader classLoader = null;
private String pluginName = "plugin1.apk";
TextView mTextView;
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(newBase);
// 在attachBaseContext时就拷贝插件文件,以便后面进行操作
extractAssets(newBase, pluginName);
}
@SuppressLint("NewApi")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
File extractFile = this.getFileStreamPath(pluginName);
dexPath = extractFile.getPath();
fileRelease = getDir("dex", 0);
// 新建 DexClassLoader
classLoader = new DexClassLoader(dexPath, fileRelease.getAbsolutePath(), null, getClassLoader());
mTextView = findViewById(R.id.tv);
// 对资源文件的调用
findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
loadResources();
try {
Class mLoadClassDynamic = classLoader.loadClass("com.plugin1.Dynamic");
Object dynamicObject = mLoadClassDynamic.newInstance();
IDynamic dynamic = (IDynamic) dynamicObject;
String content = dynamic.getStringForResId(MainActivity.this);
mTextView.setText(content);
Toast.makeText(getApplicationContext(), content + "", Toast.LENGTH_LONG).show();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
protected void loadResources() {
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, dexPath);
mAssetManager = assetManager;
} catch (Exception e) {
e.printStackTrace();
}
Resources superRes = super.getResources();
superRes.getDisplayMetrics();
superRes.getConfiguration();
mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
mTheme = mResources.newTheme();
mTheme.setTo(super.getTheme());
}
@Override
public AssetManager getAssets() {
return mAssetManager == null ? super.getAssets() : mAssetManager;
}
@Override
public Resources getResources() {
return mResources == null ? super.getResources() : mResources;
}
@Override
public Resources.Theme getTheme() {
return mTheme == null ? super.getTheme() : mTheme;
}
/**
* 把Assets里面得文件复制到 /data/data/files 目录下
*
* @param context
* @param sourceName
*/
public static void extractAssets(Context context, String sourceName) {
AssetManager am = context.getAssets();
InputStream is = null;
FileOutputStream fos = null;
try {
is = am.open(sourceName);
File extractFile = context.getFileStreamPath(sourceName);
fos = new FileOutputStream(extractFile);
byte[] buffer = new byte[1024];
int count = 0;
while ((count = is.read(buffer)) > 0) {
fos.write(buffer, 0, count);
}
fos.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
closeSilently(is);
closeSilently(fos);
}
}
private static void closeSilently(Closeable closeable) {
if (closeable == null) {
return;
}
try {
closeable.close();
} catch (Throwable e) {
e.printStackTrace();
}
}
}
基于这个思路,我们可以尝试使用插件资源替换当前显示的内容,实现换肤效果,核心思想是一样的,这里就不过多赘述了。
三、组件生命周期的管理
Android 中的四大组件的生命周期是由系统管理的,我们可以不去理会,但是在插件中,我们仅仅是构造出一个实例是没用的,我们还需要接管它的生命周期。其中 Activity 的生命周期最为复杂,使用的也最多,我们来以此为例讲一下插件化如何管理组件的生命周期。
插件化管理组件生命周期大致分为下面两种方式:
ProxyActivity
通过代理方式来管理插件中组件的生命周期。这种方式最早由 dynamic-load-apk 提出,它的思路是在主工程中做一个 ProxyActivity,启动插件中的 Activity 时会先启动 ProxyActivity,并在其中创建 Activity,并同步生命周期,如下图:
具体过程如下:
- 首先需要通过统一的入口(如上图中的 PluginManager)启动插件 Activity,其内部会将启动的插件Activity信息保存下来,并将 intent 替换为启动 ProxyActivity 的intent。
- ProxyActivity 根据插件的信息拿到该插件的 ClassLoader 和 Resource,通过反射创建 PluginActivity 并调用其
onCreate()
方法。 - PluginActivty 调用的
setContentView()
被重写了,会去调用 ProxyActivty 的setContentView()
。由于 ProxyActivity 重写了getResource()
返回的是插件的 Resource,所以setContentView()
能够访问到插件中的资源。同样findViewById()
也是调用ProxyActivity 的。 - 在 ProxyActivity 中执行生命周期方法时,会调用 PluginActivity 相应的生命周期方法。
在实现这种方式时,ProxyActivity 中需要重写getResouces()
、getAssets()
、getClassLoader()
方法返回插件的相应对象。生命周期函数以及和用户交互相关函数,如onResume()
、onStop()
、onBackPressed()
等需要转发给插件 Activity。同时,PluginActivity 中所有与 Context 的相关的方法,如setContentView()
、getLayoutInflater()
、getSystemService()
等都需要调用 ProxyActivity 中的相应方法。
缺点:这种方式,插件中的所有 Activity 必须继承自 PluginActivity,开发的侵入性强。如果某个 Activity 还需要特别的启动模式,那还需要自己管理 Activity 栈,实现起来很复杂。插件中如果要用到 Context,必须由宿主来实现。
在 dynamic-load-apk 之后,很少有插件化方案会这么做了。
Hook Activity
我们先看看系统是如何启动 Activity 的:
简单描述一下步骤。
- 某处调用
startActivity()
方法,实际会调用Instrumentation.execStartActivity()
方法。 - 通过跨进程的 binder 调用,进入到 ActivityManagerService 中,其内部会处理 Activity 栈。之后又通过跨进程调用进入到 Activity2所在的进程中。
- ApplicationThread 是一个 binder 对象,其运行在 binder 线程池中,内部包含一个 H 类,该类继承于类 Handler。ApplicationThread 将启动 Activity2 的信息通过 H 对象发送给主线程。
- 主线程拿到 Activity2 的信息后,执行
Instrumentation.newActivity()
方法,其内通过 ClassLoader 反射创建 Activity2 实例。
如果是这种方案,我们需要关心两个问题:
- 插件中的 Activity没有在 AndroidManifest 中注册,如何绕过检测;
- 如何构造Activity实例,同步生命周期。
解决方案有很多种,我们以 VirtualApk 为例,思路如下:
- 先在 Manifest 中预埋 StubActivity,启动时 hook 上图第1步,将 Intent 的目标 Activity 替换成 StubActivity。
- hook 第10步,通过插件的 ClassLoader 反射创建插件 Activity
- 之后 Activity 的所有生命周期回调都会通知给插件 Activity
VirtualAPK 在初始化时会调用hookInstrumentationAndHandler()
,该方法hook了系统的 Instrumentaiton 类,由上文可知该类和Activity的启动息息相关:
protected void hookInstrumentationAndHandler() {
try {
ActivityThread activityThread = ActivityThread.currentActivityThread();
Instrumentation baseInstrumentation = activityThread.getInstrumentation();
final VAInstrumentation instrumentation = createInstrumentation(baseInstrumentation);
Reflector.with(activityThread).field("mInstrumentation").set(instrumentation);
Handler mainHandler = Reflector.with(activityThread).method("getHandler").call();
Reflector.with(mainHandler).field("mCallback").set(instrumentation);
this.mInstrumentation = instrumentation;
Log.d(TAG, "hookInstrumentationAndHandler succeed : " + mInstrumentation);
} catch (Exception e) {
Log.w(TAG, e);
}
}
该段代码将主线程中的mInstrumentation
对象替换成了自定义的 VAInstrumentation 类。在启动和创建插件 Activity 时,VAInstrumentation 都会偷偷做一些手脚,我们简单看看:
@Override
public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, Activity target, Intent intent, int requestCode) {
injectIntent(intent);
return mBase.execStartActivity(who, contextThread, token, target, intent, requestCode);
}
...
@Override
public void callActivityOnCreate(Activity activity, Bundle icicle, PersistableBundle persistentState) {
injectActivity(activity);
mBase.callActivityOnCreate(activity, icicle, persistentState);
}
偷梁换柱的工作如下:
protected void injectIntent(Intent intent) {
// 将 Intent 转换为显式 Intent
mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
// 如果 Component 为 null,说明是隐式 Intent
if (intent.getComponent() != null) {
Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(), intent.getComponent().getClassName()));
// resolve intent with Stub Activity if needed
this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
}
}
...
protected void injectActivity(Activity activity) {
final Intent intent = activity.getIntent();
if (PluginUtil.isIntentFromPlugin(intent)) {
Context base = activity.getBaseContext();
try {
LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
Reflector.with(base).field("mResources").set(plugin.getResources());
Reflector reflector = Reflector.with(activity);
reflector.field("mBase").set(plugin.createPluginContext(activity.getBaseContext()));
reflector.field("mApplication").set(plugin.getApplication());
// set screenOrientation
ActivityInfo activityInfo = plugin.getActivityInfo(PluginUtil.getComponent(intent));
if (activityInfo.screenOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
activity.setRequestedOrientation(activityInfo.screenOrientation);
}
// for native activity
ComponentName component = PluginUtil.getComponent(intent);
Intent wrapperIntent = new Intent(intent);
wrapperIntent.setClassName(component.getPackageName(), component.getClassName());
activity.setIntent(wrapperIntent);
} catch (Exception e) {
Log.w(TAG, e);
}
}
}
用这种方式欺骗了系统,让系统误以为这是个正常的 Activity,在 Activity 栈中也会出现这个插件 Activity。同时,为了让这个插件 Activity 能够正常运行,还需要做一些替换工作,如 Resource、Context、Application 等。之后,生命周期事件会由 AMS 传递给插件 Activity 的实例。
四、比较知名的插件化库
现在已经有很多成熟的插件化库了,简单介绍几个。
1. DynamicLoadApk
这是一个非常著名的动态加载库。作者是比较有名的 Android 专家任玉刚。它的设计思路如下图:
它总体上分为四个模块:
- DLPluginManager。插件管理模块,负责插件的加载、管理以及启动插件组件。
- Proxy。代理组件模块,目前包括 DLProxyActivity(代理 Activity)、DLProxyFragmentActivity(代理 FragmentActivity)、DLProxyService(代理 Service)。
- ProxyImpl。代理组件公用逻辑模块,与 Proxy 不同的是,这部分并不是一个组件,而是负责构建、加载插件组件的管理器。这些 ProxyImpl 通过反射得到插件组件,然后将插件与 Proxy 组件建立关联,最后调用插件组件的 onCreate 函数进行启动。
- Base Plugin。插件组件的基类模块,目前包括 DLBasePluginActivity(插件 Activity 的基类)、DLBasePluginFragmentActivity(插件 FragmentActivity 的基类)、DLBasePluginService(插件 Service 的基类)。
它的调用流程如下:
上面是调用插件 Activity 的流程图,其他组件调用流程类似。
- 首先通过 DLPluginManager 的
loadApk()
方法加载插件,这步每个插件只需调用一次。 - 通过 DLPluginManager 的
startPluginActivity()
方法启动代理 Activity。 - 代理 Activity 启动过程中构建、启动插件 Activity。
但是由于 Android P 之后,系统禁止调用私有 API(经过 @hide 修饰的方法),这个库也就渐渐的失去了它的能力。
2. Small
它采取的方式与 Virtual 类似,也是先在 AndroidManifest 中埋下一个特殊命名的 Activity 来欺骗 Instrumentation,用以获得生命周期。然后通过替换 Instrumentation 的方式来创建新 Activity 的实例。
3. rePlugin
这是 360 公司研发的插件化系统。它选择了 hook 系统的 ClassLoader,当启动 Activity 时,如果判断要启动的 Activity 是插件 Activity,会通过插件中的 ClassLoader 来创建相应的对象。同时,它为了保证系统的稳定性,选择了少 hook,并没有 hook startActivity()
方法,而是重写了startActivity()
。所以,插件 Activity 也必须要继承自一个类似 PluginActivity 的基类。