热修复原理学习(5)Dalvik下完整dex方案探索与初始化时机选择

1.Dalvik下完整dex方案的新探索

1.1 冷启动类加载修复

对于Android下的冷启动类加载修复,最早的实现方案是QQ空间提出的dex插入方案。该方案的主要思想,就是把dex插入到ClassLoader索引路径的最前面。这样在加载一个类的时候,就会优先查找补丁中的类。后来微信的Tinker和手Q的QFix方案都基于该方案做了改进,而这类插入dex的方案,都会遇到一个严重的问题,那就是如果解决DVM下类的pre-verify问题。

如果一个类中直接引用到所有非系统类和该类在同一个dex中的话,这个类就会被打上 CLASS_ISPREVERIFIED标识,具体判定代码可见虚拟机中的 verifyAndOptiomizeClass()函数。

我们先来看看腾讯的三大修复方案是如何解决这个问题的:

  • QQ空间
    在每个类中插入一个来自其它dex的hack.class,此让所有类都无法满足pre-verified条件
  • Tinker
    合成全量dex文件,这样所有类在全量dex中解决,从而消除类重复带来的冲突
  • QFix
    获取虚拟机的某些底层函数,提前解析所有补丁类。以此绕过 pre-verify检查

以上三种方法里,QQ空间方案会侵入到打包流程,并且为hack添加了一些臃肿的代码。
QFix需要获取底层虚拟机的函数,不够稳定可靠。并且和空间方案一样,有个大问题是无法新增public函数,后文会详细讲解。

现在看来比较好的方式,就是像Tinker那样全量合成完整的新dex。Tinker的合成方案是从dex的方法和指令维度进行全量合成,虽然可以大大节省空间,但是由于对dex内容的比较粒度过细 ,实现较为复杂,因此性能消耗比较严重。
实际上,dex的大小占整个APK的比例是比较低的,而占空间大的主要还是APK的资源文件。因此,Tinker方案的时空代价转换的性价比不高。

其实,dex比较的最佳粒度,应该是在类的维度,它既不像方法和指令维度那样细微,也不想bsbiff比较那般粗糙。在类的维度,可以达到时间和控件平衡的最佳效果。

1.2 一种新的全量Dex方案

一般来说,合成完整dex,思路就是把原有的dex和补丁包里的dex重新合并成一个。
然而Sophix的思路是反过来的。

可以这样考虑,既然补丁中已经有变动的类了,那么只要在原先基线包的dex中,去掉补丁中也有的类。这样,补丁+去除补丁类的基线包,不就等于新App中的所有类了吗。

参照Android原生multi-dex的实现再来看这个方案,会有很好的理解。multi-dex是把一个APK里用到的所有类拆分到 classes.dexclasses2.dexclasses3.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具备很多个属性。如下所示:
热修复原理学习(5)Dalvik下完整dex方案探索与初始化时机选择_第1张图片

数据名称 解释
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的类名包含在补丁中,就把它移除,实现下图所示的效果:
热修复原理学习(5)Dalvik下完整dex方案探索与初始化时机选择_第2张图片
接下来,只要修改 pHeader->classDefsSize,把dex中类的数目改为去除补丁中的类之后的数目即可。

我们只是去除了类的定义,而对于类的方法实体以及其他dex信息不做移除,虽然这样会把这个被移除类的无用信息残留在dex文件中,但这些信息并不占用太多空间。移除类操作的方法对dex的处理速度提升帮助是很大的。

1.3 对于Application的处理

由此,我们实现了完整的dex合成。但仍然有个问题,这个问题所有完整dex替换方案都会遇到,那就是对Application的处理。

总所周知,Application是整个App的入口,因此,在进入到替换的完整dex之前,一定会通过Application的代码,然而Application必然是加载在原来的dex里面的。只有在补丁加载后使用的类,会在新的完整dex里面找到。

因此,在加载补丁后,如果Application类使用其他新dex里的类,由于在不同的一个dex里,如果Application被打上了pre-verified标识,这时就会抛出异常。

对此,我们解决办法很简单,既然被打上了pre-verified标识,那么,清除它就是了。

类的标识位于 ClassObjectaccessFlags成员中,而 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处理方案既没有侵入编译过程,也不需要进行反射替换,所有的兼容操作都在运行期自动做好。接入过程及其顺滑。

1.4 dvmOptReslveClass问题与对策

然而Sophix这种清除标识的方案并非一帆风顺,在开发过程中发现,如果这个入口Application是没有pre-verified,反而有更大的问题。

这个问题是,DVM如果发现某个类没有 pre-verified,就会在初始化这个类的时候做verify操作,将会扫描这个类的所有代码,在扫描过程中对这个类代码使用到的类都要进行 dvmOptResolveClass()操作。

这个 dvmOptResolveClass()正是罪魁祸首,它会在解析的时候对使用到的类进行初始化,而这个逻辑是发生在Application类初始化的时候。此时补丁还没有进行加载,所以就会提前加载到原始dex中的类。接下来当补丁类加载完毕后,当这些已经加载的类用到新dex中的类,并且又是 pre-verified时就会报错。

这里最大的问题是在于我们无法把补丁加载提前到 dvmOptResolveClass之前,因为在一个App的生命周期里,没有可能到达比入口Application初始化更早的时期了。

而这个问题常见于多dex情形,当存在多dex时,无法保证Application用到的类和它处于同个dex中。如果只有一个dex,一般就不会有这个问题。

多dex情况下要想解决这个问题,有两种办法:

  • 让Application用到的所有非系统的类和Application位于同一个dex中,这就可以保证pre-verified标识被打上,避免进入 dvmOptResolveClass,而在补丁加载完之后,我们再清楚pre-verified标识,使得接下来使用其他类也不会报错
  • 把Applicaiton里面除了热修复框架代码以外的其他代码都剥离开,单独提出放到一个其他类里面,这样使得Application不会直接用到过多的非系统类,这样,保证这个单独拿出来的类和Application处于同一个dex的概率还是比较大的。如果想要更保险,Application可以采用反射方式方式访问这个单独得类,这样就彻底把Application和其他类隔绝开了。

第一种方法实现较为简单,因为Android官方multi-dex机制会自动将Application用到的类都打包到主dex中,所以只要把热修复初始化放在 attachBaseContext()的最前面,一般都没有问题。
而第二种方法稍加繁琐,是在代码架构层面进行重新设计,不过可以一劳永逸的解决问题。

2. 入口类与初始化时机选择

2.1 初始化时机

冷启动完整修复方案,本质就是替换掉整个原有的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里面最好。

2.2 防不胜防的细节错误

在进行初始化的时候,经常容易错误地提早引入其他类。

下面这段代码是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();
        }
    }
}

这段简单的代码里面,包含了许多开发者都会出现的错误,这里一一指出每个问题:

  1. CreashReport.initCrashReport(this)在Sophix热修复初始化之前提早引入了,必然是不行的
  2. 虽然初始化确实是在attachBaseContext里面,但是包装了一个SophixWrApper类,这会导致初始化之前提前引入类,因此Sophix的初始化不可以包装在其他类中。
  3. setAppVersion的时候使用了BuildConfig类,这个BuildConfig类是Android编译期间动态生成的,也属于非系统类,如果在这里使用就会有提前引入的问题,这里建议用PackageManager来获取版本号。
  4. LocalStorageUtil直接在声明处赋值了它的示例,这个赋值其实是隐式发生在对象构造函数中的,这个时候甚至是更早与attachBaseContext的,因此也是不行的,需要在初始化之后才能进行赋值
  5. 在回调用中使用了MyLogger,在回调状态的时候引入很可能热修复还未初始化完毕,因此这里需要换位系统类android.utils.log
  6. MultiDex.install(this)调用放在了热修复初始化后,这样做虽然没有引入类的问题,但是可能会导致后面热修复框架初始化的时候找不到其他不在主dex中的热修复框架内部类,因此需要把它提前到热修复初始化之前。而提早引入MultiDex类不会带来问题,因为在热修复初始化之后,再也没有调用到这个MultiDex类的地方。
  7. 最后,经常会有人一楼了 syper.attachBaseContext(base),如果缺少它,后面都无法正常运行。

现在来看一下修改后的代码:

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自己的方法,所以不会新引入任何其他类。

你可能感兴趣的:(Android热修复)