前言
在Android 9.0的jar包中,可以发现很多函数添加了@hide
,这表示google显示了对@hide api的反射调用,上有政策,下有对策,我天朝人民的智慧是无穷尽的,具体的方案可以参考一种绕过Android P对非SDK接口限制的简单方法。按理说只要在Android 9.0的手机反射调用了@hide的api都不会work。但是我在华为的p20上测试,还是可以使用的,说明华为对google的做了一些“定制化”。
划重点
本文将从下面几个方面去讲述插件化的实现原理
- 简单梳理一下Activity的启动流程
- 了解反射和动态代理
- Android类的加载机制,主要了解PathClassLoader和DexClassLoader
- hook的两个方案
- 资源加载
demo
Activity启动流程
启动流程:
- 点击桌面APP图标,Launcher进程采用Binder IPC(IActivityManager)向system_server(AMS)进程发起startActivity请求
- system_server(AMS)进程接受到请求后,检查该进程是否存在,如果不存在则想zygote进程发送创建进程的请求
- zygote进程fork出新的子进程,即APP进程
- APP进程调用ActivityThread中的main函数,然后通过Binder IPC(IActivityManager)向system_server(AMS)进程发起attachApplication请求
- system_server(AMS)进程接受到请求后,先通过Binder IPC(IApplicationThread)向APP发送bindApplication通知,创建Application。发送创建Activity的消息需要分版本
- Android 9.0(28)以下,通过Binder IPC(IActivityManager)向APP进程发送scheduleLaunchActivity请求
- Andriod 9.0(28),通过Binder IPC(IActivityManager)向APP进程发送scheduleTransaction请求
- APP进程接受到请求后
- 小于28的版本,通过mH(handler)想App进程发送scheduleLaunchActivity请求
- 等于28的版本,通过scheduleTransaction,调用ActivityThread的handleLaunchActivity
- 主线程在接收到消息后,开始创建Activity
- 到此,APP便正式启动,开始进入Activity生命周期,执行完onCreate/onStart/onResume方法,UI渲染完成后便可以看到APP主界面了。
反射和动态代理
反射就是动态获取信息和动态代用对象的方法,具体的介绍可以看这篇文章反射、动态代理和注解,这里就不赘述了
classloader
-
PathClassLoader
只能加载已经安装到Android系统中的apk(/data/app目录),是Android默认使用类加载器 -
DexClassLoader
可以加载任意目录下的dex/jar/apk/zip文件,比PathClassLoader要更加灵活,是实现热修复的重点
基于API28的源码
/**
* Provides a simple {@link ClassLoader} implementation that operates on a list
* of files and directories in the local file system, but does not attempt to
* load classes from the network. Android uses this class for its system class
* loader and for its application class loader(s).
*/
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent){
super(dexPath, null, librarySearchPath, parent);
}
}
/**
* A class loader that loads classes from {@code .jar} and {@code .apk} files
* containing a {@code classes.dex} entry. This can be used to execute code not
* installed as part of an application.
*/
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
从注释上可以看出PathClassLoader
是用于系统类和应用程序类的加载,DexClassLoader
可以用来加载任意目录的dex。集体实现还要看BaseDexClassLader
的构造方法
public class BaseDexClassLoader extends ClassLoader {
private final DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
this(dexPath, optimizedDirectory, librarySearchPath, parent, false);
}
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String librarySearchPath, ClassLoader parent, boolean isTrusted) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
...
}
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
...
Class c = pathList.findClass(name, suppressedExceptions);
...
return c;
}
@Override
protected URL findResource(String name) {
return pathList.findResource(name);
}
@Override
public String findLibrary(String name) {
return pathList.findLibrary(name);
}
}
可以看出在构造方法中创建了一个DexPathList对象赋值给了pathList
字段,然后findxxx()
方法都是从DexPathList
中查找。BaseDexClassLoader
的构造函数包含四个参数:
- dexPath:包含类和资源的jar/apk文件列表,由
File.pathSeparator
分割 - optimizedDirectory:由于dex文件被包含在APK或者Jar文件中,因此在装载目标类之前需要先从APK或者Jar文件中解压出dex文件,该参数就是制定解压出的dex文件存放路径。这也是对apk中dex根据平台进行ODEX优化过程,字API26开始无效
- librarySearchPath:指目标类中所使用的c/c++库存放的路径,可以为null
- parent:父
ClassLoader
引用
接下来我们查看DexPathList
的构造方法和findxxx()
方法
final class DexPathList {
private Element[] dexElements;
DexPathList(ClassLoader definingContext, String dexPath,
String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
...
// 加载dexPath路径下的dex和resource
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
suppressedExceptions, definingContext, isTrusted);
...
}
private static Element[] makeDexElements(List files, File optimizedDirectory,
List suppressedExceptions, ClassLoader loader, boolean isTrusted) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
for (File file : files) {
if (file.isDirectory()) {
elements[elementsPos++] = new Element(file);
} else if (file.isFile()) {
String name = file.getName();
DexFile dex = null;
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
if (dex != null) {
elements[elementsPos++] = new Element(dex, null);
}
} catch (IOException suppressed) {
System.logE("Unable to load dex file: " + file, suppressed);
suppressedExceptions.add(suppressed);
}
} else {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
suppressedExceptions.add(suppressed);
}
if (dex == null) {
elements[elementsPos++] = new Element(file);
} else {
elements[elementsPos++] = new Element(dex, file);
}
}
} else {
System.logW("ClassLoader referenced unknown path: " + file);
}
}
return elements;
}
}
构造方法中调用makeDexElements()
方法获取到了Elements[]
数组赋值给了dexElements
变量
public Class> findClass(String name, List suppressed) {
for (Element element : dexElements) {
Class> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
而findClass()
方法则是变量在构造方法初始化好的Element[]
数组中从前往后遍历找到我们需要的类。
这里我们总结一下类加载的过程:
-
PathClassLoader
和DexClassLoader
调用了父类BaseDexClassLoader
的构造方法。 -
BaseDexClassLoader
在构造方法中创建了DexPathList
对象并赋值给pathList
字段,加载类的findxxx()
方法都是调用DexPathList
类的findxxx()
方法来实现类的加载 -
DexPathList
在构造方法中调用makeDexElements()
方法创建了Elements[]
数组赋值给dexElements
字段,findClass()
方法就是从前往后遍历Element[]
数组找到我们要的class
插件化
终于进入到正题了,插件化顾名思义,就是将一个APK拆成多个,在需要的时候进行网络下载,动态加载到内存中。目前有两种比较好的插件化方案:
- Hook Instrumentation方案
- Hook AMS(ActivityManagerService)和ActivityThread中的mH(handler)
选择:这里我们选择第一个方案,第二个方案缺陷比较多,首先是Android 9.0的启动流程做了比较大的改动,hook mH已经失效,而且AMS的hook在Android 8.0和小于8.0也有区别。
选择好了方案我们先梳理一下整个hook的流程:
- 将插件工程打包为APK,然后通过adb push命令发送到宿主手机目录下(模拟下载流程)
- 利用ClassLoader加载插件APK中的类(Android 9.0PathClassLoader也可以加载任意目录下的APK)
- 将APK加载出来的DexPathList中的Emement数据加入到原生的classloader对象中
- hook InstrumentationProxy,在发起execStartActivity时换成占位的Activity,在newActivity的时候换为APk中的Activity
打包apk后面再说,这里先说下面的几步操作
利用ClassLoader加载apk
根据上一章节的叙述,我们知道类加载的时候,首先会到父类的classLoader去寻找,找不到才会到当前的classLoader去加载类,而在这里系统是用PathClassLoader去加载类,这里我们就需要将apk中Element加入到PathClassLoader的DexPathList中的Element数组中,看下面代码
@Throws(Exception::class)
private fun inject(context: Context, origin: ClassLoader, pluginPath: String) {
val optimizeFile = context.getFileStreamPath("plugin") // /data/data/$packageName/files/plugin
if (!optimizeFile.exists()) {
optimizeFile.mkdirs()
}
val pluginClassLoader = DexClassLoader(pluginPath, optimizeFile.absolutePath, null, origin)
val pluginDexPathList = FieldUtil.getField(
Class.forName(CLASS_BASE_DES_CLASSLOADER), pluginClassLoader,
FIELD_PATH_LIST
)
val pluginElements = FieldUtil.getField(
Class.forName(CLASS_DEX_PATH_LIST),
pluginDexPathList,
FIELD_DES_ELEMENTS
) // 拿到插件中的Elements
val originDexPathList = FieldUtil.getField(
Class.forName(CLASS_BASE_DES_CLASSLOADER), origin,
FIELD_PATH_LIST
)
val originElements =
FieldUtil.getField(
Class.forName(CLASS_DEX_PATH_LIST),
originDexPathList,
FIELD_DES_ELEMENTS
)
val array = combineArray(originElements, pluginElements) // 合并数组
FieldUtil.setField(
Class.forName(CLASS_DEX_PATH_LIST),
originDexPathList,
FIELD_DES_ELEMENTS,
array
)// 设置回pathClassLoader
Log.i(TAG, "plugin success to load")
}
fun combineArray(pathElements: Any, dexElements: Any): Any {
val componentType = pathElements.javaClass.componentType
val i = Array.getLength(pathElements)
val j = Array.getLength(dexElements)
val k = i + j
val result = Array.newInstance(componentType, k)
System.arraycopy(dexElements, 0, result, 0, j)
System.arraycopy(pathElements, 0, result, j, i)
return result
}
这样我们就将使用DexClassLoader加载的APK,成功的放到系统PathClassLoader的加载列表中,接下来我们就需要想办法绕过系统检查,启动activity
Hook Instrumentation
我们如果绕过检查呢?通过上面的分析的启动流程会发现,在Instrumentation#execStartActivity中,会有个checkStartActivityResult的方法去检查错误,因此,我们可以复写这个方法,让启动参数能通过系统的检查。首先,我们需要检查启动的Intent能不能匹配到,匹配不到的话,将ClassName修改为我们预先在AndroidManifest中配置的占坑Activity,并且把当前的这个ClassName放到当前的intent的extra中,以便后面做恢复。
public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, Activity target,
Intent intent, int requestCode, Bundle options) {
List resolveInfo = null;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
resolveInfo = mPackageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL);
} else {
resolveInfo = mPackageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
}
//判断启动的插件Activity是否在AndroidManifest.xml中注册过
if (null == resolveInfo || resolveInfo.size() == 0) {
//保存目标插件
intent.putExtra(REQUEST_TARGET_INTENT_NAME, intent.getComponent().getClassName());
//设置为占坑Activity
intent.setClassName(who, PlaceHolderActivity.class.getName());
Log.i("liyachao", PlaceHolderActivity.class.getName());
}
try {
Method execStartActivity = Instrumentation.class.getDeclaredMethod("execStartActivity",
Context.class, IBinder.class, IBinder.class, Activity.class,
Intent.class, int.class, Bundle.class);
return (ActivityResult) execStartActivity.invoke(mInstrumentation, who, contextThread, token, target, intent, requestCode, options);
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
return null;
}
绕过了检查,现在需要解决的问题是还原,我们知道,系统启动Activity最后会调到ActivityThread里,在这里,会通过Instrumentation#newActivity方法去反射构造一个Activity对象,因此我们只需要在这里还原即可。
public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException,
IllegalAccessException, ClassNotFoundException {
String intentName = intent.getStringExtra(REQUEST_TARGET_INTENT_NAME);
if (!TextUtils.isEmpty(intentName)) {
return super.newActivity(cl, intentName, intent);
}
return super.newActivity(cl, className, intent);
}
一切准备就绪,我们最后的问题是,如何替换到系统的Instrumentation。通过查看源代码,找到ActivityThread中的private static volatile ActivityThread sCurrentActivityThread;
,这是一个静态变量,这就方便我们了
@Throws(Exception::class)
@JvmStatic
fun hookActivityThreadInstrumentation(application: Application) {
val activityThreadClazz = Class.forName("android.app.ActivityThread")
val activityThreadField = activityThreadClazz.getDeclaredField("sCurrentActivityThread")
activityThreadField.isAccessible = true
val activityThread = activityThreadField.get(null)
val instrumentationField = activityThreadClazz.getDeclaredField("mInstrumentation")
instrumentationField.isAccessible = true
val instrumentation = instrumentationField.get(activityThread) as Instrumentation
val proxy = InstrumentationProxy(instrumentation, application.packageManager)
instrumentationField.set(activityThread, proxy)
}
这样,我们就能启动一个没有注册在AndroidManifest文件中的Activity了。
资源的插件化方案
资源的插件化方案,目前大概有两种
- 合并资源,这样做的缺点是,可能出现资源冲突,解决方案就是重写aapt,来自定义资源生成规则
- 各个插件构造自己的资源方案
这里我们使用自己构造资源方案,实现起来简单,我们给插件创建一个Resources,然后插件APK中都通过这个Resource去获取资源。这里看下Resources构造方法
/**
* Create a new Resources object on top of an existing set of assets in an
* AssetManager.
*
* @deprecated Resources should not be constructed by apps.
* See {@link android.content.Context#createConfigurationContext(Configuration)}.
*
* @param assets Previously created AssetManager.目前创建的AssetManager,用来加载资源,根据插件APK路径创建AssetManager加载资源
* @param metrics Current display metrics to consider when
* selecting/computing resource values.显示配置,直接使用宿主的Resources的配置即可
* @param config Desired device configuration to consider when。配置项,直接使用宿主的Resources的配置即可
* selecting/computing resource values (optional).
*/
@Deprecated
public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
this(null);
mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
}
接下来看AssetManager如何创建
public final class AssetManager implements AutoCloseable {
public AssetManager() {
synchronized (this) {
if (DEBUG_REFS) {
mNumRefs = 0;
incRefsLocked(this.hashCode());
}
init(false);
if (localLOGV) Log.v(TAG, "New asset manager: " + this);
ensureSystemAssets();
}
}
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {//传入需要加载资源的路径
return addAssetPathInternal(path, false);
}
}
直接通过空参构造方法创建,然后调用addAssetPath()去加载对路径的资源。
接下来我们在Application中创建插件的Resources,之所有在这里创建是为了方便插件APK中获取到这个Resources,因为插件APK中的四大组建实际上是在宿主APK中创建的,那么它们拿到Application实际上也是宿主的,所以只需要通过getApplication().getResources()
就可以非常方便的拿到插件Resource
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
try {
val path = FileUtil.initPath("com.knight.plugin")
val file = File(path)
var pluginPath = ""
file.listFiles().forEach {
if (it.name.endsWith(".apk")) {
pluginPath = it.absolutePath
}
}
pluginResource =
PluginManager.initPlugin(this, pluginPath)
} catch (e: Exception) {
e.printStackTrace()
}
KnightPermission.init(this)
}
override fun getResources(): Resources {
return if (pluginResource == null) super.getResources() else pluginResource!!
}
模拟下载apk
这个过程很简单,在demo中,我将apk放在assets目录下,启动的时候,将apk复制到手机应用的目录下,这样就模拟了下载apk的过程
fun copyData2File(filesDir: File, assets: AssetManager, fileName: String) {
val file = File(filesDir, fileName)
if (file.exists()) {
return
}
var outputStream: OutputStream? = null
var inputStream: InputStream? = null
try {
outputStream = FileOutputStream(file)
inputStream = assets.open(fileName)
val buffer = ByteArray(1024)
var len = inputStream.read(buffer)
while (len != -1) {
outputStream.write(buffer, 0, len)
len = inputStream.read(buffer)
}
Log.i("liyachao", "copy $fileName success")
} catch (e: Exception) {
e.printStackTrace()
} finally {
try {
outputStream?.close()
inputStream?.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
}