Toc
  1. 一、发展历史
  2. 二、基本原理
    1. 1. 类加载
    2. 2. 资源加载
  3. 三、组件生命周期的管理
    1. ProxyActivity
    2. Hook Activity
  4. 四、比较知名的插件化库
    1. 1. DynamicLoadApk
    2. 2. Small
    3. 3. rePlugin
Toc
0 results found
关于插件化

插件化几乎是现在 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 个阶段:

  1. 打包资源文件,生成 R.java 文件

    打包资源的工具是 aapt,在这个过程中,项目中的 AndroidManifest.xml 文件和布局文件 XML 都会编译,然后生成相应的 R.java,另外 AndroidManifest.xml 会被 aapt 编译成二进制。存放在 APP 的 res 目录下的资源,该类资源在 APP 打包前大多会被编译,变成二进制文件,并会为每个该类文件赋予一个 resource id。对于该类资源的访问,应用层代码则是通过 resource id 进行访问的。Android 应用在编译过程中 aapt 工具会对资源文件进行编译,并生成一个 resource.arsc 文件,resource.arsc 文件相当于一个文件索引表,记录了很多跟资源相关的信息。

  2. 处理 aidl 文件,生成相应的 Java 文件

    aidl 工具解析接口定义文件然后生成相应的 Java 代码接口供程序调用。如果在项目没有使用到 aidl 文件,则可以跳过这一步。

  3. 编译项目源代码,生成 class 文件

    项目中所有的 Java 代码,包括 R.java 和.aidl 文件,都会变 Java 编译器(javac)编译成.class 文件,生成的 class 文件位于工程中的 bin/classes 目录下。

  4. 转换所有的 class 文件,生成 classes.dex 文件

    dex 工具生成可供 Android 系统 Dalvik 虚拟机执行的 classes.dex 文件,任何第三方的 libraries 和.class 文件都会被转换成.dex 文件。dx 工具的主要工作是将 Java 字节码转成成 Dalvik 字节码、压缩常量池、消除冗余信息等。

  5. 打包生成 APK 文件

    所有没有编译的资源,如 images、assets 目录下资源(该类文件是一些原始文件,APP 打包时并不会对其进行编译,而是直接打包到 APP 中,对于这一类资源文件的访问,应用层代码需要通过文件名对其进行访问);编译过的资源和.dex 文件都会被 apkbuilder 工具打包到最终的.apk 文件中。

  6. 对 APK 文件进行签名

    一旦 APK 文件生成,它必须被签名才能被安装在设备上。在开发过程中,主要用到的就是两种签名的 keystore。一种是用于调试的 debug.keystore,它主要用于调试,在 Eclipse 或者 Android Studio 中直接 run 以后跑在手机上的就是使用的 debug.keystore。另一种就是用于发布正式版本的 keystore。

  7. 对签名后的 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;
}

双亲委派机制有两个作用:

  1. 防止重复加载同一个 class 。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
  2. 保证核心 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 的字符串,我们看看如何在宿主中访问它。

先理顺一下思路:

  1. 先要创建 AssetManager 对象,读取插件中的资源
  2. 用 DexClassLoader 加载插件,并生成该插件对应的 ClassLoader
  3. 反射获取插件中的类,并将其实例化,就可以让插件类读取插件资源了

写成代码可以如下:

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,并同步生命周期,如下图:

具体过程如下:

  1. 首先需要通过 统一的入口(如上图中的 PluginManager)启动插件 Activity,其内部会将启动的插件 Activity 信息保存下来,并将 intent 替换为启动 ProxyActivity 的 intent。
  2. ProxyActivity 根据插件的信息拿到该插件的 ClassLoader 和 Resource,通过反射创建 PluginActivity 并调用其 onCreate() 方法。
  3. PluginActivty 调用的 setContentView() 被重写了,会去调用 ProxyActivty 的 setContentView()。由于 ProxyActivity 重写了getResource() 返回的是插件的 Resource,所以 setContentView() 能够访问到插件中的资源。同样 findViewById() 也是调用 ProxyActivity 的。
  4. 在 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 的:

简单描述一下步骤。

  1. 某处调用 startActivity() 方法,实际会调用 Instrumentation.execStartActivity() 方法。
  2. 通过跨进程的 binder 调用,进入到 ActivityManagerService 中,其内部会处理 Activity 栈。之后又通过跨进程调用进入到 Activity2 所在的进程中。
  3. ApplicationThread 是一个 binder 对象,其运行在 binder 线程池中,内部包含一个 H 类,该类继承于类 Handler。ApplicationThread 将启动 Activity2 的信息通过 H 对象发送给主线程。
  4. 主线程拿到 Activity2 的信息后,执行 Instrumentation.newActivity() 方法,其内通过 ClassLoader 反射创建 Activity2 实例。

如果是这种方案,我们需要关心两个问题:

  • 插件中的 Activity 没有在 AndroidManifest 中注册,如何绕过检测;
  • 如何构造 Activity 实例,同步生命周期。

解决方案有很多种,我们以 VirtualApk 为例,思路如下:

  1. 先在 Manifest 中 预埋 StubActivity,启动时 hook 上图第 1 步,将 Intent 的目标 Activity 替换成 StubActivity。
  2. hook 第 10 步,通过插件的 ClassLoader 反射创建插件 Activity
  3. 之后 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 专家任玉刚。它的设计思路如下图:

它总体上分为四个模块:

  1. DLPluginManager。插件管理模块,负责插件的加载、管理以及启动插件组件。
  2. Proxy。代理组件模块,目前包括 DLProxyActivity(代理 Activity)、DLProxyFragmentActivity(代理 FragmentActivity)、DLProxyService(代理 Service)。
  3. ProxyImpl。代理组件公用逻辑模块,与 Proxy 不同的是,这部分并不是一个组件,而是负责构建、加载插件组件的管理器。这些 ProxyImpl 通过反射得到插件组件,然后将插件与 Proxy 组件建立关联,最后调用插件组件的 onCreate 函数进行启动。
  4. Base Plugin。插件组件的基类模块,目前包括 DLBasePluginActivity(插件 Activity 的基类)、DLBasePluginFragmentActivity(插件 FragmentActivity 的基类)、DLBasePluginService(插件 Service 的基类)。

它的调用流程如下:

上面是调用插件 Activity 的流程图,其他组件调用流程类似。

  1. 首先通过 DLPluginManager 的 loadApk() 方法加载插件,这步每个插件只需调用一次。
  2. 通过 DLPluginManager 的 startPluginActivity() 方法启动代理 Activity。
  3. 代理 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 的基类。

打赏
支付宝
微信
本文作者:CodingRabbit
版权声明:本文首发于CodingRabbit的博客,转载请注明出处!