用了一周多,做了一个Android动态加载的小玩具DCommand。支持下载APK,获取其中的资源、执行代码、启动Activity(这个是抄的,非常粗糙)。
最开始只是觉得动态加载逻辑代码很有用,如果MVP模式使用合理的话,对于大部分的逻辑更新、线上bug修复直接使用动态下发APK,更新P端的逻辑即可。后来越来越复杂,最后基本所有方面都可以动态使用,如果再深入开发的话,做个MVP框架也是可以的(当然最好迁移到RxJava上,这种网络文件操作很多的东西,用响应式编程还是很赞的)。
整体结构
理想情况下,结构是分层的:
- Apk管理,ApkConfigManager,负责下载Apk、验证安全并读取其中的配置。维护Apk相关数据库
- classloader管理,ClassManager,负责创建对应某个Apk的DexClassLoader
- resources/asset管理,ResourceManager,负责创建对应某个Apk的ResourceFetcher。维护resource相关数据库
- interface管理,CommandManager,负责加载某个interface在Apk中对应的实现。维护interface相关数据库
- component管理,ComponentManager,负责启动指定类名的Android Component(暂时只有Activity)。维护component相关数据库
- resource获取,ResourceFetcher,负责构建Resources对象,并获取其中的指定资源
- App,动态加载的使用者
流程
以获取interface的实现为例:
1. 调用CommandManager.getImplement
2. CommandManager调用ClassManager.loadClass
3. ClassManager调用ApkConfigManager.getApkInfoAndFileById
4. ApkConfigManager下载Config.json并解析
5. ApkConfigManager使用Apk的id找到url,下载Apk
6. ApkConfigManager校验Apk签名
7. ApkConfigManager通知有新Apk
8. CommandManager得到通知,更新数据库
9. ApkConfigManager通过回调,将Apk文件返回给ClassManager
10. ClassManager使用返回的文件构造ClassFetcher,通过回调返回给CommandManager
11. CommandManager使用数据库中的映射关系找到interface的实现,反射构建实现对象
细节
名词 | 解释 |
Apk | 动态加载的目标,从网络上下载的Apk文件 |
宿主 | 使用动态加载的程序 |
1. Apk配置和更新
使用id唯一表示Apk,不使用版本,使用时间戳来标记Apk新旧。时间戳是配置文件的生成时间点,如果Apk是在配置文件之后下载的,肯定是最新版本。使用Apk版本会要求多次读文件内容,效率有问题。
2. Apk校验
校验分为两部分:Apk签名完整性的校验,Apk签名和宿主签名的校验。
- Apk签名校验:
- Apk签名过程:解释0,解释1。总结起来就是:先对所有文件进行digest放到MANIFEST.MF中;再对MANIFEST.MF的每一行加密,放到CERT.SF中;最后把公钥信息放到CERT.RSA中。
- 校验过程:入口在PackageParser中,基本是签名的逆过程,但是写的实在是乱,没看懂。真正调用签名校验的地方是PackageParser.collectCertificates。
真正使用不需要这么复杂。正常情况下,使用PackageManager.getPackageArchieveInfo并传入GET_SIGNATURES就可以了。当然也可以直接反射。
- 签名交叉校验
其实就是读入宿主和Apk的签名,对比是不是相同的公钥。
//读入证书
X509Certificate cert = (X509Certificate) certFactory
.generateCertificate(new ByteArrayInputStream(signature.toByteArray()));
//公钥字符串
cert.getPublicKey().toString();
公钥本身在公钥字符串中,在字符串modulus=和,publicExponent之间。
3. AndroidManifest的解析
Android中Xml是预处理过的,所以不能随随便便就读出来了。一个还算准确的图,一个通过源码分析的解释。特别推荐一下后一个,他通过看ResType.h这些aapt相关的类,非常准确的还原了二进制的结构。
manifest文件二进制片段示例中对应关系如下(二进制是big-endian的):
/*
<manifest versionCode="1"
0201// type
1000// header size
8800 0000// size
0200 0000// line number
ffff ffff// comment
ffff ffff// ns
0c00 0000// name
1400// start
1400// size
0500// count
0000// id
0000// class
0000// style
0700 0000// ns
0000 0000// name
ffff ffff// raw value
0800// size
00// 00
10// type
0100 0000//data
*/
对于大部分基础信息,PackageManager.getPackageArchieveInfo就足够了。我这里用到的,PackageManager.getPackageArchieveInfo没有的,只有读取meta-data的功能。这个答案里的代码是有问题的,需要修改,具体可见Manifest类。
4. DexClassLoader
构造函数中的optimizedDirectory就是dex文件解压后的位置,第一次还是比较慢的。
5. 泛型
- return泛型时,Java会自动类型匹配。但是用回调代替return后,自动类型匹配不管用。可以用一个比较鬼的办法:
public interface Listener{
<T> T onXXX(T obj);
}
调用时:
Foo ret = listener.onXXX(obj);
此时obj会被匹配成Foo类的对象。
6. 动态Activity
启动动态Activity问题在于系统不识别动态Activity。基本就是用fragment或者proxyActivity来绕过。关于ProxyActivity。
使用proxyActivity分为两部分:
- 宿主提供ProxyActivity,系统实际识别的就是这个Activity。ProxyActivity将所有系统的回调事件路由给DynamicActivity,基本就是所有onXXX函数
- Apk提供DynamicActivity,所有真正的逻辑都在这里。DynamicActivity将所有调用系统的函数都委托给ProxyActivity来做,比如startActivity、getXXX等。
里面有一个坑,在super.onCreate之前,getIntent是返回不了有效数据的。
后来发现,其实还是有很多牛逼的解决方法更优雅的解决这个问题的。总结一下探索新方法的思路:
1. 仔细看一遍Activity的启动流程
2. 寻找里面非native、非IPC的与Activity相关的部分(存在于同一个虚拟机实例中,可以通过反射替换成自己的实现):
- Activity.mInstrumentation
- PackageInfo
- ActivityInfo
7. Resources
- 资源是靠id标记的,id的最高8位是包名,正常生成的Apk,id都是7f打头的
- 资源id的type字段并不是固定的,是在aapt生成时遇到什么新资源就加一生成的
- 资源的维度简直就不能理解,老罗的解读
- 资源读取时,就是顺序的找,找到了就返回了。所以当宿主和Apk的资源同时存在时(不修改aapt一定会有重复id),一定会出bug。所以不能反射修改Activity的mResources,这样会出错。只能显式的分开用这两种资源
- 调用某Apk中的资源代码:
assetManager = AssetManager.class.newInstance();
ReflectUtils.invokeMethod(assetManager, "addAssetPath", new Class[]{String.class}, new Object[]{apkFile.getAbsolutePath()});
mResources = new Resources(assetManager, metrics, configuration);
这个mResources就是可以用的,包括id和真正资源,系统相关代码在ResourceManager#getTopLevelResources里