关于Android APP Hot Fix-Nuwa实现原理分析

关于Nuwa

Nuwa是由腾讯QQ空间团队实现的为Android APP进行紧急热修复方案的实现,该方案基于Android dex分包方案。

Nuwa的Hot Fix原理介绍

Nuwa通过将有问题的Class打包成Dex文件,APP动态加载对应的Dex文件中的Class来覆盖原来的Class文件,从而实现Hot Fix。

Nuwa实现分析

Nuwa主要包含两个项目:Nuwa(Android Library project)和NuwaGradle(Gradle Plugin),其作用分别如下.

Nuwa: Android Library project。

NuwaGradle: Gradle Plugin project。

Nuwa,作为Android Library project,其主要作用是为APP提供加载Dex文件的功能以及提供Hack.class类。加载Dex文件的功能主要在如下类中实现:


Nuwa.java 提供初始化及加载Patch(dex or apk)文件的方法,初始化方法中将存在asset中的hack.apk copy到app的内部储存文件夹中,并加载hack.apk,为app的所有类(not include Application class)提供Hack类支持(后面叙述Hack类的作用)。

public static void init(Context context) {
    File dexDir = new File(context.getFilesDir(), DEX_DIR);
    dexDir.mkdir();

    String dexPath = null;
    try {
        dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir);
    } catch (IOException e) {
        Log.e(TAG, "copy " + HACK_DEX + " failed");
        e.printStackTrace();
    }

    loadPatch(context, dexPath);
}

public static void loadPatch(Context context, String dexPath) {
    if (context == null) {
        Log.e(TAG, "context is null");
        return;
    }
    if (!new File(dexPath).exists()) {
        Log.e(TAG, dexPath + " is null");
        return;
    }
    File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR);
    dexOptDir.mkdir();
    try {
        DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath());
    } catch (Exception e) {
        Log.e(TAG, "inject " + dexPath + " failed");
        e.printStackTrace();
    }
}

DexUtils.java 主要提供Patch(dex/apk)的加载方法。在Android中,使用BaseDexClassLoader来加载dex/apk文件,而BaseDexClassLoader使用DexPathList类型的成员变量pathList来存储所有的dex信息,ClassLoader findClass 的过程就是遍历DexPathList中的数组成员变量dexElements来查找对应的Class。BaseDexClassLoader有两个继承类:DexClassLoader和PathClassLoader,PathClassLoader主要用于加载已经进行optdex优化后的dex,而DexClassLoader则用于加载未进行optdex优化的dex/apk。Android默认的classloader是PathClassLoader。关于ClassLoader的更多介绍,请参考Java ClassLoader基础

/**
 * 加载dex并插入到PathClassLoader的成员变量pathList中
 */
public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
    DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
    Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
    Object newDexElements = getDexElements(getPathList(dexClassLoader));
    Object allDexElements = combineArray(newDexElements, baseDexElements);
    Object pathList = getPathList(getPathClassLoader());
    // 通过反射来设置PathClassLoader的pathList的dexElements
    ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
}

/**
 * 获取Android默认的PathClassLoader
 */
private static PathClassLoader getPathClassLoader() {
    PathClassLoader pathClassLoader = (PathClassLoader) DexUtils.class.getClassLoader();
    return pathClassLoader;
}

/**
 * 获取DexPathList对象中获取其dexElements成员变量
 */
private static Object getDexElements(Object paramObject)
        throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
    return ReflectionUtils.getField(paramObject, paramObject.getClass(), "dexElements");
}

/**
 * 从BaseDexClassLoader对象中获取其pathList成员变量
 */
private static Object getPathList(Object baseDexClassLoader)
        throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
    return ReflectionUtils.getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}

/**
 * 合并两个dexElements列表
 */
private static Object combineArray(Object firstArray, Object secondArray) {
    Class<?> localClass = firstArray.getClass().getComponentType();
    int firstArrayLength = Array.getLength(firstArray);
    int allLength = firstArrayLength + Array.getLength(secondArray);
    Object result = Array.newInstance(localClass, allLength);
    for (int k = 0; k < allLength; ++k) {
        if (k < firstArrayLength) {
            Array.set(result, k, Array.get(firstArray, k));
        } else {
            Array.set(result, k, Array.get(secondArray, k - firstArrayLength));
        }
    }
    return result;
}

按照如上的原理,那么使用一个Android Library就可以实现Hot Fix功能,那么为啥Nuwa还要提供一个Gradle Plugin来做支持?

原来,apk在安装之时,classes.dex会被虚拟机(dexopt)优化称为odex文件,然后才拿去执行,而虚拟机在启动的时候,包含一项verify的选项,该选项致使虚拟机对class进行校验,如果校验成功,对应的类会被打上CLASS_ISPREVERIFIED的标志,而被打上该标示的class,在查找其所引用的class时,会进行判断该class与被引用的class是否在同一个dex文件中,如果不是,则会抛出”Class resolved by unexpected DEX”的IllegalAccessException。由于Patch中的class用于覆盖classes.dex重的类,所以会导致部分类在classes.dex文件中,部分在patch.dex文件中,故会出现如上错误。


而解决此问题的方法就是防止class被打上CLASS_ISPREVERIFIED的标志,因此需在虚拟机对class进行校验时返回false,虚拟机对class的校验如下:


此代码在DexVerify.cpp中


  1. 验证class->directMethods方法,directMethods包括:static方法、private方法、构造函数
  2. 验证class->virtualMethods方法。

    概括就是如果以上方法中直接引用到的class(第一层级关系,不会进行递归搜索)和class都在同一个dex中的话,那么这个class就不会被打上CLASS_ISPREVERIFIED标志


    NuwaGradle这个Gradle Pligin project就是为了实现这个目的的。

NuwaGradle,使用Groovy实现的Gradle plugin项目,其主要作用有:1. gradle task插入,2. java字节码级别的代码插入, 3. 记录编译后各class文件的hash值, 4. 根据hash变化与否来进行打包patch.jar
查看一个标准Android Project的gradle task:

$ gradle -q tasks --all
...
app:assembleRelease - Assembles all Release builds. [app:compileReleaseSources]
app:dexRelease
app:packageRelease
app:preDexRelease
...
app:compileReleaseSources
app:checkReleaseManifest
app:compileReleaseAidl
app:compileReleaseJavaWithJavac
app:compileReleaseNdk
app:compileReleaseRenderscript
app:generateReleaseAssets
app:generateReleaseBuildConfig
app:generateReleaseResValues
app:generateReleaseResources
app:generateReleaseSources
app:mergeReleaseAssets
app:mergeReleaseResources
app:preBuild
app:preDebugBuild
app:preReleaseBuild
app:prepareComAndroidSupportAppcompatV72220Library - Prepare com.android.support:appcompat-v7:22.2.0
app:prepareComAndroidSupportDesign2220Library - Prepare com.android.support:design:22.2.0
app:prepareComAndroidSupportSupportV42220Library - Prepare com.android.support:support-v4:22.2.0
app:prepareReleaseDependencies
app:processReleaseJavaRes
app:processReleaseManifest
app:processReleaseResources
...

根据输出可知,在构建Android Project的过程中,存在dex** 和preDex** 任务。而NuwaGradle则在对应的preDex** Task前插入修改字节码的任务及在dex**任务前把有与上一版本相比有修改的class文件提取到另外的文件夹并打包成Patch文件用于发布。

...
if (preDexTask) {
    // 定义在preDex** 任务之前插入的任务名为nuwaJarBeforePreDex**
    def nuwaJarBeforePreDex = "nuwaJarBeforePreDex${variant.name.capitalize()}"
    // 定义任务nuwaJarBeforePreDex** 所执行的动作
    project.task(nuwaJarBeforePreDex) << {
        Set<File> inputFiles = preDexTask.inputs.files.files
        inputFiles.each { inputFile ->
            def path = inputFile.absolutePath
            if (NuwaProcessor.shouldProcessPreDexJar(path)) {
                // 对Jar文件中的class进行代码插入
                NuwaProcessor.processJar(hashFile, inputFile, patchDir, hashMap, includePackage, excludeClass)
            }
        }
    }
    // 修改preDex** 与nuwaJarBeforePreDex** 任务的依赖顺序
    def nuwaJarBeforePreDexTask = project.tasks[nuwaJarBeforePreDex]
    nuwaJarBeforePreDexTask.dependsOn preDexTask.taskDependencies.getDependencies(preDexTask)
    preDexTask.dependsOn nuwaJarBeforePreDexTask

    // nuwaJarBeforePreDex** 之前执行一些初始化动作
    nuwaJarBeforePreDexTask.doFirst(nuwaPrepareClosure)

    // 定义在dex** 任务之前插入的任务名为nuwaClassBeforeDex**
    def nuwaClassBeforeDex = "nuwaClassBeforeDex${variant.name.capitalize()}"
    // 定义任务nuwaClassBeforeDex** 所执行的动作
    project.task(nuwaClassBeforeDex) << {
        Set<File> inputFiles = dexTask.inputs.files.files
            inputFiles.each { inputFile ->
                def path = inputFile.absolutePath
                // 对非R及BuildConfig类进行代码插入
                if (path.endsWith(".class") && !path.contains("/R\$") && !path.endsWith("/R.class") && !path.endsWith("/BuildConfig.class")) {
                    if (NuwaSetUtils.isIncluded(path, includePackage)) {
                            if (!NuwaSetUtils.isExcluded(path, excludeClass)) {
                                    def bytes = NuwaProcessor.processClass(inputFile)
                                      path = path.split("${dirName}/")[1]
                                       def hash = DigestUtils.shaHex(bytes)
                                       hashFile.append(NuwaMapUtils.format(path, hash))
                                    // 判断此class的hash知否有变化,有的话则将该class拷贝至patch的文件夹
                                    if (NuwaMapUtils.notSame(hashMap, path, hash)) {                                                
                                        NuwaFileUtils.copyBytesToFile(inputFile.bytes, NuwaFileUtils.touchFile(patchDir, path))
                                       }
                              }
                    }
                }
            }
        }
        // 修改dex** 与nuwaClassBeforeDex** 任务的依赖顺序
        def nuwaClassBeforeDexTask = project.tasks[nuwaClassBeforeDex]
        nuwaClassBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask)
        dexTask.dependsOn nuwaClassBeforeDexTask

        // 将保存class hash的map文件copy到其他目录
        nuwaClassBeforeDexTask.doLast(copyMappingClosure)

        nuwaPatchTask.dependsOn nuwaClassBeforeDexTask
        beforeDexTasks.add(nuwaClassBeforeDexTask)
} else {
    ...
}

NuwaGradle使用的是javaassist库来进行字节码插入的, 对Jar文件的代码注入操作:

public static processJar(File hashFile, File jarFile, File patchDir, Map map, HashSet<String> includePackage, HashSet<String> excludeClass) {
    if (jarFile) {
        // 创建tmp 的jar文件
        def optJar = new File(jarFile.getParent(), jarFile.name + ".opt")

        def file = new JarFile(jarFile);
        Enumeration enumeration = file.entries();
        JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar));

        while (enumeration.hasMoreElements()) {
            // 读取jar文件中的每个类
            JarEntry jarEntry = (JarEntry) enumeration.nextElement();
            String entryName = jarEntry.getName();
            ZipEntry zipEntry = new ZipEntry(entryName);

            InputStream inputStream = file.getInputStream(jarEntry);
               jarOutputStream.putNextEntry(zipEntry);
            // 判断是否需要注入代码
               if (shouldProcessClassInJar(entryName, includePackage, excludeClass)) {
                   // 代码注入
                def bytes = referHackWhenInit(inputStream);
                // 写入到tmp jar文件中
                   jarOutputStream.write(bytes);

                   def hash = DigestUtils.shaHex(bytes)
                hashFile.append(NuwaMapUtils.format(entryName, hash))
                // 判断是否需要打包进patch
                if (NuwaMapUtils.notSame(map, entryName, hash)) {
                    NuwaFileUtils.copyBytesToFile(bytes, NuwaFileUtils.touchFile(patchDir, entryName))
                }
            } else {
                jarOutputStream.write(IOUtils.toByteArray(inputStream));
            }
            jarOutputStream.closeEntry();
        }
        jarOutputStream.close();
        file.close();

        if (jarFile.exists()) {
            jarFile.delete()
        }
        // 覆盖原jar文件
        optJar.renameTo(jarFile)
    }
}

对class文件的代码注入操作:

public static byte[] processClass(File file) {
    def optClass = new File(file.getParent(), file.name + ".opt")

    FileInputStream inputStream = new FileInputStream(file);
    FileOutputStream outputStream = new FileOutputStream(optClass)
    // 代码注入
    def bytes = referHackWhenInit(inputStream);
    outputStream.write(bytes)
    inputStream.close()
    outputStream.close()
    if (file.exists()) {
        file.delete()
    }
    optClass.renameTo(file)
    return bytes
}

private static byte[] referHackWhenInit(InputStream inputStream) {
    ClassReader cr = new ClassReader(inputStream);
    ClassWriter cw = new ClassWriter(cr, 0);
    ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc,
                                         String signature, String[] exceptions) {

            MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
            mv = new MethodVisitor(Opcodes.ASM4, mv) {
                @Override
                void visitInsn(int opcode) {
                    // Java编译器在为它编译的每个类都至少生成一个实例化方法,即<init>方法。
                    // 在类的实例化方法<init>中,给其添加一个Hack类型的常量
                    if ("<init>".equals(name) && opcode == Opcodes.RETURN) {
                        super.visitLdcInsn(Type.getType("Lcn/jiajixin/nuwa/Hack;"));
                    }
                   super.visitInsn(opcode);
                }
            }
            return mv;
        }

    };
    cr.accept(cv, 0);
    return cw.toByteArray();
}

如此则可避免所有的类被打上CLASS_ISPREVERIFIED标志,即class调用不同dex文件中的class也不会出错(PS:Application不应该被注入Hack类型常量,因为Hack.apk是在Application的attachBaseContext方法中加载,而在构造方法就引用了Hack类,因此会抛出异常)。

总结

遇到的坑

  1. 使用了Nuwa之后,执行gradle clean && gradle build –info 命令会出现一下错误:

    :app:nuwaJarBeforeDex***Release (Thread[Daemon worker,5,main]) completed. Took 0.011 secs.


    FAILURE: Build failed with an exception.


    * What went wrong:

    Execution failed for task ‘:app:nuwaJarBeforeDex***Release’.

    > $(ProjectPath)/app/build/intermediates/classes-proguard/***/release/classes.jar (No such file or directory)


    出错原因:在VIPME项目中,针对debug和release的构建使用了不同的规则,为方便开发人员调试,在debug的构建规则中未打开代码混淆,而在release的规则重则打开了代码混淆,而使用gradle build进行构建的时候,是同时构建debug和release的版本。

    通过查看gradle build | grep “:*:*“的输出:

    ...
    :facebook:compileReleaseJavaWithJavac
    :facebook:extractReleaseAnnotations
    :facebook:mergeReleaseProguardFiles UP-TO-DATE
    :facebook:packageReleaseJar
    :facebook:compileReleaseNdk UP-TO-DATE
    :facebook:packageReleaseJniLibs UP-TO-DATE
    :facebook:packageReleaseLocalJar UP-TO-DATE
    :facebook:packageReleaseRenderscript UP-TO-DATE
    :facebook:packageReleaseResources
    :facebook:bundleRelease
    :app:prepareCnJiajixinNuwaNuwa100Library
    :app:prepareComAndroidSupportAppcompatV72220Library
    :app:prepareComAndroidSupportDesign2220Library
    :app:prepareComAndroidSupportRecyclerviewV72220Library
    :app:prepareComAndroidSupportSupportV42221Library
    :app:prepareComFacebookAndroidFacebook451Library
    :app:prepareComGoogleAndroidGmsPlayServicesAnalytics780Library
    :app:prepareComGoogleAndroidGmsPlayServicesBase780Library
    :app:prepareComGoogleAndroidGmsPlayServicesGcm780Library
    :app:prepareComTwitterSdkAndroidTweetComposer100Library
    :app:prepareComTwitterSdkAndroidTwitterCore160Library
    :app:prepareIoFabricSdkAndroidFabric136Library
    :app:prepareVipmeAndroidViewPagerIndicatorUnspecifiedLibrary
    :app:prepareOfficialDebugDependencies
    :app:compileOfficialDebugAidl
    :app:compileOfficialDebugRenderscript
    :app:generateOfficialDebugBuildConfig
    :app:generateOfficialDebugAssets UP-TO-DATE
    :app:mergeOfficialDebugAssets
    :app:generateOfficialDebugResValues
    :app:processOfficialDebugGoogleServices
    ...
    :app:preDexOfficialDebug
    :app:nuwaClassBeforeDexOfficialDebug
    :app:dexOfficialDebug
    :app:validateDebugSigning
    :app:packageOfficialDebug
    :app:assembleOfficialDebug
    :app:assembleDebug
    :app:checkOfficialReleaseManifest
    :app:prepareOfficialReleaseDependencies
    :app:compileOfficialReleaseAidl
    :app:compileOfficialReleaseRenderscript
    :app:generateOfficialReleaseBuildConfig
    :app:generateOfficialReleaseAssets UP-TO-DATE
    :app:mergeOfficialReleaseAssets
    :app:generateOfficialReleaseResValues
    :app:processOfficialReleaseGoogleServices
    :app:generateOfficialReleaseResources
    ...
    

可知gradle对第三方依赖库项目(如VIPME所依赖的facebook和viewpagerindicator)的处理在是在所有的debugtask之前,而debug 的所有task又在release task之前,在app/build/intermediates/exploded-aar/文件夹中查看到,gradle对第三方依赖库处理的输出文件并没有对debug和release进行分开存放。

同时,在build的过程中,混淆过程输出很多warning信息:

:app:proguardOfficialRelease
...
Warning: com.facebook.AccessTokenManager$1: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.AccessTokenManager$2: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.AccessTokenManager$3: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.AccessTokenManager$4: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.AccessTokenManager$RefreshResult: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.AccessTokenSource: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.AccessTokenTracker: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.AccessTokenTracker$CurrentAccessTokenBroadcastReceiver: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.BuildConfig: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.CallbackManager$Factory: can't find referenced class cn.jiajixin.nuwa.Hack
Warning: com.facebook.FacebookActivity: can't find referenced class cn.jiajixin.nuwa.Hack
...

由以上错误信息可知,release的proguard task失败导致未输出对应的class文件,而使app:nuwaJarBeforeDex task缺失输入文件而失败。但是NuwaGradle 是在preDex* task之前也就是proguard之后才注入Hack代码的,怎么会在proguard之前就已经有Hack了呢?猜测是debug和release都使用了同一份的第三方依赖库build输出的产物,而在debug 的proguard task已经对第三方依赖库的代码进行了注入,所以导致release proguard task运行失败。

于是提取app/build/intermediates/exploded-aar/文件夹中facebook的classes.jar文件重命名为classes.zip并解压出来,提取其中的某个类\
**.class并对其反编译所得:

$javap -v ***.class | grep Hack
  #803 = Utf8               cn/jiajixin/nuwa/Hack
  #804 = Class              #803          //  cn/jiajixin/nuwa/Hack
  6: ldc_w         #804                // class cn/jiajixin/nuwa/Hack
       9: ldc_w         #804                // class cn/jiajixin/nuwa/Hack
      85: ldc_w         #804                // class cn/jiajixin/nuwa/Hack
     552: ldc_w         #804                // class cn/jiajixin/nuwa/Hack

由此证明debug和release使用相同的第三方依赖库产物来进行下一步的动作从而导致出现以上的错误。


解决方法:

  1. 使用gradle assembleRelease 命令只针对release进行构建 (推荐);
  2. 打开debug的混淆(由于debug的task中已对第三方依赖库的产物jar进行了混淆,所以release的时候不会再次混淆)。

(Done)