对于Android下的冷启动类加载修复,最早的实现方案是QQ空间提出的dex插入方案。该方案的主要思想,就是把dex插入到ClassLoader索引路径的最前面。这样在加载一个类的时候,就会优先查找补丁中的类。后来微信的Tinker和手Q的QFix方案都基于该方案做了改进,而这类插入dex的方案,都会遇到一个严重的问题,那就是如果解决DVM下类的pre-verify问题。
如果一个类中直接引用到所有非系统类和该类在同一个dex中的话,这个类就会被打上 CLASS_ISPREVERIFIED
标识,具体判定代码可见虚拟机中的 verifyAndOptiomizeClass()
函数。
我们先来看看腾讯的三大修复方案是如何解决这个问题的:
以上三种方法里,QQ空间方案会侵入到打包流程,并且为hack添加了一些臃肿的代码。
QFix需要获取底层虚拟机的函数,不够稳定可靠。并且和空间方案一样,有个大问题是无法新增public函数,后文会详细讲解。
现在看来比较好的方式,就是像Tinker那样全量合成完整的新dex。Tinker的合成方案是从dex的方法和指令维度进行全量合成,虽然可以大大节省空间,但是由于对dex内容的比较粒度过细 ,实现较为复杂,因此性能消耗比较严重。
实际上,dex的大小占整个APK的比例是比较低的,而占空间大的主要还是APK的资源文件。因此,Tinker方案的时空代价转换的性价比不高。
其实,dex比较的最佳粒度,应该是在类的维度,它既不像方法和指令维度那样细微,也不想bsbiff比较那般粗糙。在类的维度,可以达到时间和控件平衡的最佳效果。
一般来说,合成完整dex,思路就是把原有的dex和补丁包里的dex重新合并成一个。
然而Sophix的思路是反过来的。
可以这样考虑,既然补丁中已经有变动的类了,那么只要在原先基线包的dex中,去掉补丁中也有的类。这样,补丁+去除补丁类的基线包,不就等于新App中的所有类了吗。
参照Android原生multi-dex
的实现再来看这个方案,会有很好的理解。multi-dex是把一个APK里用到的所有类拆分到 classes.dex
、classes2.dex
、classes3.dex
等之中,而每个dex都只包含了部分的类定义,但单个dex也是可以加载的,因为只要把所有的dex都加载进去,本dex中不存在的类就可以在运行期间在其他的dex中找到。
同理,基线包在dex在去掉了补丁中的类后,原有需要发生变更的类就被消除了,基线包dex里就包含不变的类了。而这些不变的类在用到补丁中的新类时会自动地找到补丁dex,补丁包中的新类在需要用到不变的类时也会只找到基线包dex的类。
这样基线包里面不使用补丁类的类仍旧可以按照原来的逻辑做odex,最大程度地保证了dexopt的效果。
这么一来,我们就不需要像传统合成思路那样去判断类的增加和修改情况,也不需要处理合成时方法数超了的情况,对于dex的结构也不用进行破坏性重构。
现在,合成完整dex的问题就简化为如果在基线包dex里面去掉补丁包中包含的所有类,接下来我们看一下dex中去除指定类的具体实现。
首先,来看下dex文件中header的结构:
struct DexHeader {
u1 magic[8]; /* 版本标识 */
u4 checksum; /* adler32 检验 */
u1 signature[kSHA1DigestLen]; /* SHA-1 哈希值 */
u4 fileSize; /* 整个文件大小 */
u4 headerSize; /* DexHeader 大小 */
u4 endianTag; /* 字节序标记 */
u4 linkSize; /* 链接段大小 */
u4 linkOff; /* 链接段偏移 */
u4 mapOff; /* DexMapList 的文件偏移 */
u4 stringIdsSize; /* DexStringId 的个数 */
u4 stringIdsOff; /* DexStringId 的文件偏移 */
u4 typeIdsSize; /* DexTypeId 的个数 */
u4 typeIdsOff; /* DexTypeId 的文件偏移 */
u4 protoIdsSize; /* DexProtoId 的个数 */
u4 protoIdsOff; /* DexProtoId 的文件偏移 */
u4 fieldIdsSize; /* DexFieldId 的个数 */
u4 fieldIdsOff; /* DexFieldId 的文件偏移 */
u4 methodIdsSize; /* DexMethodId 的个数 */
u4 methodIdsOff; /* DexMethodId 的文件偏移 */
u4 classDefsSize; /* DexClassDef 的个数 */
u4 classDefsOff; /* DexClassDef 的文件偏移 */
u4 dataSize; /* 数据段的大小 */
u4 dataOff; /* 数据段的文件偏移 */
};
DexHeader 是dex文件的头部,用来描述整个dex文件中每个属性在dex结构中的具体位置,从这些描述信息,我们能看出dex具备很多个属性。如下所示:
数据名称 | 解释 |
---|---|
header | dex文件头部,记录整个dex文件的相关属性 |
string_ids | 字符串数据索引,记录了每个字符串在数据区的偏移量 |
type_ids | 类似数据索引,记录了每个类型的字符串索引 |
proto_ids | 原型数据索引,记录了方法声明的字符串,返回类型字符串,参数列表 |
field_ids | 字段数据索引,记录了所属类,类型以及方法名 |
method_ids | 类方法索引,记录方法所属类名,方法声明以及方法名等信息 |
class_defs | 类定义数据索引,记录指定类各类信息,包括接口,超类,类数据偏移量 |
data | 数据区,保存了各个类的真实数据 |
link_data | 静态链接文件中使用的数据。 |
这里我们打算去除dex中的类,因此我们最关心的自然是这里的 class_defs属性。
需要注意的是,并不是要把某个类的所有信息都从dex移除,因为如果这么做,可能会导致dex的各个部分都发生变化,从而需要大量调整offset,这样就会变得费时费力了,我们要做的,仅仅是使得在解析这个dex的时候找不到这个类的定义就可以了。
因此,只需要移除定义的入口,对于类的具体内容不进行删除,这样可以最大限度减少offset
的修改。
我们来看虚拟机在dexopt的时候是如果找到某个dex中所有的类定义的,它是在 verifyAndOptimizeClasses()
中,注意,和之前的 verifyAndOptimizeClass()
是两个方法,它也是在安装Apk时调用的:
// dalvik/vm/analysis/DexPrepare.cpp
static void verifyAndOptimizeClasses(DexFile* pDexFile, bool doVerify,
bool doOpt)
{
u4 count = pDexFile->pHeader->classDefsSize;
u4 idx;
for (idx = 0; idx < count; idx++) {
const DexClassDef* pClassDef;
const char* classDescriptor;
ClassObject* clazz;
pClassDef = dexGetClassDef(pDexFile, idx); // 1
classDescriptor = dexStringByTypeIdx(pDexFile, pClassDef->classIdx);
clazz = dvmLookupClass(classDescriptor, NULL, false);
if (clazz != NULL) {
verifyAndOptimizeClass(pDexFile, clazz, pClassDef, doVerify, doOpt); // 2
} else {
ALOGV("DexOpt: not optimizing unavailable class '%s'",
classDescriptor);
}
}
}
注释1:返回了 pDexFile下第idx个类的定义。
注释2:调用我们熟知的 verifyAndOptimizeClass
来对这个类进行类校验和类优化。
我们来看下注释1的 dexGetClassDef()
方法:
// dalvik/libdex/DexFile.h
DEX_INLINE const DexClassDef* dexGetClassDef(const DexFile* pDexFile, u4 idx) {
assert(idx < pDexFile->pHeader->classDefsSize);
return &pDexFile->pClassDefs[idx]; //返回 pClassDefs的第idx元素
}
而这里的 pClassDefs是这么来的呢?下面是dex的赋值:
// dalvik/libdex/DexFile.cpp
void dexFileSetupBasicPointers(DexFile* pDexFile, const u1* data) {
DexHeader *pHeader = (DexHeader*) data;
...
pDexFile->pClassDefs = (const DexClassDef*) (data + pHeader->classDefsOff);
...
}
由此可以看出,一个类的所DexClassDef,也就是类定义,是从 pHeader->classDefsOff
偏移处开始的,依次呈线性排列的,一个dex里面一共有 pHeader->classDefsSize
个类定义。
因此,我们就可以直接找到pHead->classDefsOff
偏移处,遍历所有的DexClassDef
,如果发现这个 DexClassDef的类名包含在补丁中,就把它移除,实现下图所示的效果:
接下来,只要修改 pHeader->classDefsSize
,把dex中类的数目改为去除补丁中的类之后的数目即可。
我们只是去除了类的定义,而对于类的方法实体以及其他dex信息不做移除,虽然这样会把这个被移除类的无用信息残留在dex文件中,但这些信息并不占用太多空间。移除类操作的方法对dex的处理速度提升帮助是很大的。
由此,我们实现了完整的dex合成。但仍然有个问题,这个问题所有完整dex替换方案都会遇到,那就是对Application的处理。
总所周知,Application是整个App的入口,因此,在进入到替换的完整dex之前,一定会通过Application的代码,然而Application必然是加载在原来的dex里面的。只有在补丁加载后使用的类,会在新的完整dex里面找到。
因此,在加载补丁后,如果Application类使用其他新dex里的类,由于在不同的一个dex里,如果Application被打上了pre-verified标识,这时就会抛出异常。
对此,我们解决办法很简单,既然被打上了pre-verified标识,那么,清除它就是了。
类的标识位于 ClassObject
的 accessFlags
成员中,而 pre-verifiyed标识的定义是 CLASS_ISPREVERIFIED = (1 << 16)
,因此,我们只需要在JNI层清除掉它即可:
classObj->accessFlags &= ~CLASS_ISPREVERIFIED;
这样,在 dvmResolveClass()
中找到新的dex里的类后,由于 CLASS_ISPREVERIFIED
标识被清空,就不会判断所在dex是否相同,从而成功避免抛出异常。
接下来,我们来比对目前市场上其他完整dex方案是怎么做的。
(1)Tinker
Tinker的方案是在 AndroidManifest.xml
声明中就要求开发者将自己的Application直接替换成 TinkerApplication
。而对于真正App的Application,要在初始化TinkerApplication时作为参数传入。这样 TinkerApplication
会接管这个传入的Application,在生命周期回调时通过反射的方式调用实际 Application的相关回调逻辑。这么做确实很好地将入口Application和用户代码隔离开,不过需要改造原有的Application,如果对Application有更多扩展,接入成本也是比较高的。
(2)Amigo
Amigo的方案是在编译过程中,用Amigo自定义的gradle插件将App的Application替换成了 Amigo自己的另一个Application,并且将原来的Application的name保存了起来,该修复的问题都修复完后再调用之前保存的Application的 attach(context)
。将它回调到loadedApk()
中,最后调用它的onCreate()
,执行原有Application的逻辑,这种方式只是在代码层面开发者无感知,但其实在编译期间偷偷帮用户做了替换,有点掩耳盗铃的意思,并且这种对系统做反射替换本身也是由一定的风险的。
相比之下,Sophix的Application处理方案既没有侵入编译过程,也不需要进行反射替换,所有的兼容操作都在运行期自动做好。接入过程及其顺滑。
然而Sophix这种清除标识的方案并非一帆风顺,在开发过程中发现,如果这个入口Application是没有pre-verified,反而有更大的问题。
这个问题是,DVM如果发现某个类没有 pre-verified,就会在初始化这个类的时候做verify操作,将会扫描这个类的所有代码,在扫描过程中对这个类代码使用到的类都要进行 dvmOptResolveClass()
操作。
这个 dvmOptResolveClass()
正是罪魁祸首,它会在解析的时候对使用到的类进行初始化,而这个逻辑是发生在Application类初始化的时候。此时补丁还没有进行加载,所以就会提前加载到原始dex中的类。接下来当补丁类加载完毕后,当这些已经加载的类用到新dex中的类,并且又是 pre-verified时就会报错。
这里最大的问题是在于我们无法把补丁加载提前到 dvmOptResolveClass之前,因为在一个App的生命周期里,没有可能到达比入口Application初始化更早的时期了。
而这个问题常见于多dex情形,当存在多dex时,无法保证Application用到的类和它处于同个dex中。如果只有一个dex,一般就不会有这个问题。
多dex情况下要想解决这个问题,有两种办法:
dvmOptResolveClass
,而在补丁加载完之后,我们再清楚pre-verified标识,使得接下来使用其他类也不会报错第一种方法实现较为简单,因为Android官方multi-dex机制会自动将Application用到的类都打包到主dex中,所以只要把热修复初始化放在 attachBaseContext()
的最前面,一般都没有问题。
而第二种方法稍加繁琐,是在代码架构层面进行重新设计,不过可以一劳永逸的解决问题。
冷启动完整修复方案,本质就是替换掉整个原有的dex文件。然而“完整替换”只是一种理想化的设想,实际上无法做到“完整的”。原因是热修复的初始化本身也是一段代码。必须调用到这段代码,热修复才能执行完成,因此调用到热修复的类,肯定是使用者自己的类,这个类是无法被热修复影响到的,并且它只存在于原始安装包的 classes.dex
中。如果要使热修复类之前使用的其他类最少,只能放在Application类入口中。
那么,放在Activity类里面是不是也可以呢?当然,如果你的App里面没有Application,放到Activity里面似乎也没有太大的问题,并且简单测试好像也能正常工作。
但是,如果你的AndroidManifest中注册了ContentProvider,事情就没有那么顺利了。ContentProvider的onCreate方法优先调用于Activity的onCreate方法。这就使得我们可能还没有完成热修复替换,就先执行到了 ContentProvider中的业务逻辑代码,导致某些类被提前引入。提前引入其他类的危害我们在之前的章节已经说明,这不仅会导致这些类无法修复,更可能引起 pre-verify异常,因此,只有把初始化放在Application类中,才能保证不会错误地提早引入类。
如果放在Application中,又有两种选择:放在onCreate()
中或者放在 attachBaseContext()
中。
放在 attachBaseContext()
中自然是没有问题的,因为他是Application中最早被执行的代码,但需要注意的是,在attachBaseContext里面有很多限制,此时App申请的权限还没有授予完成,所以会遇到无法访问网络之类的问题,因此在attachBaseContext里面可以执行初始化,但是不可以进行网络请求下载新补丁。
那放在Application的onCreate中可以吗?简单测试似乎没有什么问题。然而,它和之前的Activity的onCreate方法一样,执行时间会晚于ContentProvider的onCreate方法。
当然,如果你的AndroidManifest里面没有注册过ContentProvider,并且能够保证引入的第三方库的AndoridManifest里面也没有注册,放在onCreate里面就没有什么问题。不过保险起见,为了避免以后某天项目在无意中引入,还是放在attachBaseContext里面最好。
在进行初始化的时候,经常容易错误地提早引入其他类。
下面这段代码是Sophix的热修复初始化代码,SophixManager
需要在设置各种属性后调用 initialize()
方法进行初始化,就以这段代码为例:
public class SampleApplication extends Application {
LocalStorageUtil localStorageUtil = new LocalStorageUtil();
@Override
protected void attachBaseContext(Context base) {
CrashReport.initCrashReport(this);
SophixWrApper.init(this);
MultiDex.install(this);
localStorageUtil.init(this);
}
@Override
public void onCreate() {
super.onCreate();
SophixWrApper.query();
}
public LocalStorageUtil getLocalStorageUtil() {
return localStorageUtil;
}
static private class SophixWrApper {
static void init(Application context) {
final SophixManager instance = SophixManager.getInstance();
instance.setContext(context)
.setAppVersion(BuildConfig.VERSION_NAME)
.setPatchLoadStatusStub(new PatchLoadStatusListener() {
@Override
public void onLoad(final int mode,
final int code,
final String info,
final int handlePatchVersion) {
if (code == PatchStatus.CODE_LOAD_SUCCESS) {
MyLogger.d("", "Sophix load patch success");
} else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {
MyLogger.d("", "Sophix preload patch success");
}
}
});
instance.initialze();
}
static void query() {
SophixManager.getInstance().queryAndLoadNewPatch();
}
}
}
这段简单的代码里面,包含了许多开发者都会出现的错误,这里一一指出每个问题:
CreashReport.initCrashReport(this)
在Sophix热修复初始化之前提早引入了,必然是不行的setAppVersion
的时候使用了BuildConfig类,这个BuildConfig类是Android编译期间动态生成的,也属于非系统类,如果在这里使用就会有提前引入的问题,这里建议用PackageManager来获取版本号。现在来看一下修改后的代码:
public class SampleApplication extends Application {
LocalStorageUtil localStorageUtil;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
initSophix(this);
CrashReport.initCrashReport(this);
initlocalStorageUtil();
}
@Override
public void onCreate() {
super.onCreate();
SophixManager.getInstance().queryAndLoadNewPatch();
}
private void initlocalStorageUtil(){
localStorageUtil = new LocalStorageUtil();
localStorageUtil.init(this);
}
public LocalStorageUtil getLocalStorageUtil() {
return localStorageUtil;
}
private void initSophix(Application context) {
String AppVersion = "0";
try {
AppVersion = this.getPackageManager()
.getPackageInfo(this.getPackageName(),0)
.versionName;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
final SophixManager instance = SophixManager.getInstance();
instance.setContext(context)
.setAppVersion(AppVersion)
.setAppKey(null)
.setEnableDebug(false)
.setEnableFullLog()
.setPatchLoadStatusStub(new PatchLoadStatusListener() {
@Override
public void onLoad(final int mode,
final int code,
final String info,
final int handlePatchVersion) {
if (code == PatchStatus.CODE_LOAD_SUCCESS) {
Log.d("", "Sophix load patch success");
} else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {
Log.d("", "Sophix preload patch success");
}
}
});
instance.initialze();
}
}
这样就万无一失了。这里初始化放到独立的initSophix是没有关系的,因为是入口Application自己的方法,所以不会新引入任何其他类。