android热修复的pre-verify问题详解及实践

本篇文章继续上一篇,主要分析一下classloader方案在dalvik虚拟机中的pre-verify问题。关于classloader方案的原理可以参考上一篇文章android热修复相关之Multidex解析进行了解。自从Multidex出现之后,QQ空间的一篇文章引发了classloader热修复方案的浪潮,包括开源的Nuwa,HotFix,Tinker等,很有价值的一篇文章安卓App热补丁动态修复技术介绍。这篇文章比较清晰的阐述了pre-verify问题以及解决方案,但是我看完后还是有些疑惑的,比如为什么当类A直接引用了类B后,就可以不被打上CLASS_VERIFIED标记?这篇文章采用实践加源码的方式,因为篇幅原因,具体的实践操作过程可能不会特别详细,但是力求讲清楚整个pre-verify的出现及解决过程,顺便也可以了解到dalvik虚拟机的dexopt的大致流程。

image.png

盗个QQ空间的原理图,原理很简单,假设classes.dex中的Qzone.class有bug,我们通过动态加载patch.dex,并将patch.dex插入到Elements数组中,保证在classes.dex的前面。这样一来,当出发Qzone.class的加载时,很明显会加载到patch.class中的Qzone.class,而classes.dex中的Qzone.class是永远加载不到的,从而达到热修复的效果。从原理上分析没有任何问题,实践一下看看:
首先,我们新建一个FixTest工程,添加一个名为patch的module,核心代码如下:

 public static void inject(Context context,String dexPath){
    try {
      Class<?> cl = Class.forName("dalvik.system.BaseDexClassLoader");
      Object originPathList=ReflectionUtils.getField(cl,DexUtils.class.getClassLoader(),"pathList");
      Object originElements=ReflectionUtils.getField(originPathList.getClass(),originPathList,"dexElements");
      String dexOpt=context.getDir("odex",0).getAbsolutePath();
      DexClassLoader dexClassLoader=new DexClassLoader(dexPath,dexOpt,dexOpt,DexUtils.class.getClassLoader());
      Object pathList=ReflectionUtils.getField(cl,dexClassLoader,"pathList");
      Object elements=ReflectionUtils.getField(pathList.getClass(),pathList,"dexElements");
      Object combineElements=combineArray(elements,originElements);
      ReflectionUtils.setFeild(originPathList.getClass(),originPathList,"dexElements",combineElements);
      Object object= ReflectionUtils.getField(originPathList.getClass(),originPathList,"dexElements");
      Log.i("ljj", "inject->length: "+Array.getLength(object));
    } catch (ClassNotFoundException e) {
      e.printStackTrace();
    } catch (IllegalAccessException e) {
      e.printStackTrace();
    } catch (NoSuchFieldException e) {
      e.printStackTrace();
    }
  }

代码就是实现了上图中的原理,将dexPath对应的patch包插入到了PathClassLoader的Elements的前面。我们在app的module中,引入patch,进行测试,首先在app的Application中加入

 String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "patch.dex";
 HotFix.inject(this, dexPath);

我们新建一个Test类,在MainActivity中用一个TextView显示showText的结果,直接运行程序,textView显示“I am an error”。

public class Test {
  public String showText(){
    return "I am an error";
  }
}

假设现在发现显示错了,我们要显示的是“I am a patch”,按照上面所说的,我们可以修改Test类,然后打包命名为patch.dex进行下发即可。至于patch.dex的生成,有很多种方式,我们直接修改showText方法后,执行gradle build,然后将/app/build/intermediates/classes/debug/包名/Test.class文件随便拷贝到一个目录,在目录中建立包级文件夹,假设顶层文件夹为dex,里层文件夹为com/ljj/fixtest/Test.class,调用dx命令

dx --dex --output=patch.dex  dex

将生成的patch.dex放到SDcard的根目录。好的,至此一切准备工作都已经完成,我们在android5.0,6.0,7.0上都能正常运行,但在android4.2的手机上当我们重启时,报了这样的异常:

01-02 00:56:37.674 11264-11264/com.ljj.fixtest E/AndroidRuntime: FATAL EXCEPTION: main
                                                                 java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
                                                                     at com.ljj.fixtest.MainActivity$1.onClick(MainActivity.java:20)
                                                                     at android.view.View.performClick(View.java:4299)
                                                                     at android.view.View$PerformClick.run(View.java:17576)
                                                                     at android.os.Handler.handleCallback(Handler.java:725)
                                                                     at android.os.Handler.dispatchMessage(Handler.java:92)
                                                                     at android.os.Looper.loop(Looper.java:153)
                                                                     at android.app.ActivityThread.main(ActivityThread.java:5356)
                                                                     at java.lang.reflect.Method.invokeNative(Native Method)
                                                                     at java.lang.reflect.Method.invoke(Method.java:511)
                                                                     at

直译就是一个被标为pre-verify的class引用了一个ref类,这个ref类被发现不是期待的实现方式,也就是被换掉了,去看一下异常抛出的位置以及如何调用到这个位置的。
在本例中,MainActivity中引用到了Test类的showText方法,执行MainActivity的onCreate方法时会尝试解析Test类。MainActivity的onCreate方法很简单。

public class MainActivity extends Activity {

  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    final TextView textView=(TextView) findViewById(R.id.mytv);
    textView.setText(new Test().showText());

  }
}

我们看一下反编译后onCreate的smali代码。


image.png

而解释器执行到new-instance时,会触发,最终会调用到dvmResolvedClass方法

HANDLE_OPCODE(OP_NEW_INSTANCE /*vAA, class@BBBB*/)
   {
       ClassObject* clazz;
       Object* newObj;

       EXPORT_PC();

       vdst = INST_AA(inst);
       ref = FETCH(1);
       ILOGV("|new-instance v%d,class@0x%04x", vdst, ref);
       clazz = dvmDexGetResolvedClass(methodClassDex, ref);
       if (clazz == NULL) {
           clazz = dvmResolveClass(curMethod->clazz, ref, false);
           if (clazz == NULL)
               GOTO_exceptionThrown();
       }

dvmResolvedClass方法位于/dalvik/vm/oo/Resolve.cpp中。

ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,
    bool fromUnverifiedConstant)
{
    DvmDex* pDvmDex = referrer->pDvmDex;
    ClassObject* resClass;
    const char* className;
    resClass = dvmDexGetResolvedClass(pDvmDex, classIdx);
    if (resClass != NULL)
        return resClass;
    className = dexStringByTypeIdx(pDvmDex->pDexFile, classIdx);
    if (className[0] != '\0' && className[1] == '\0') {
        /* primitive type */
        resClass = dvmFindPrimitiveClass(className[0]);
    } else {
        resClass = dvmFindClassNoInit(className, referrer->classLoader);
    }
    if (resClass != NULL) {
        if (!fromUnverifiedConstant &&
            IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED))
        {
            ClassObject* resClassCheck = resClass;
            if (dvmIsArrayClass(resClassCheck))
                resClassCheck = resClassCheck->elementClass;

            if (referrer->pDvmDex != resClassCheck->pDvmDex &&
                resClassCheck->classLoader != NULL) 
            {
                LOGW("Class resolved by unexpected DEX:"
                     " %s(%p):%p ref [%s] %s(%p):%p",
                    referrer->descriptor, referrer->classLoader,
                    referrer->pDvmDex,
                    resClass->descriptor, resClassCheck->descriptor,
                    resClassCheck->classLoader, resClassCheck->pDvmDex);
                LOGW("(%s had used a different %s during pre-verification)",
                    referrer->descriptor, resClass->descriptor);
                dvmThrowIllegalAccessError(
                    "Class ref in pre-verified class resolved to unexpected "
                    "implementation");
                return NULL;
            }
        }
      .........
    return resClass;
}

referrer是curMethod->clazz , 首先在dvmDexGetResolvedClass方法中判断是否解析过该类,很明显,该类是首次加载,所以返回结果为空,然后调用dvmFindClassNoInit方法用classloader去查找类,因为patch.dex已经在之前反射注入到了elements中,所以此时resClass不为空,此时检查MainActivity是否被打上了CLASS_ISPREVERIFIED,此时先给出结果,肯定是打上了的,进而转入到
if (referrer->pDvmDex != resClassCheck->pDvmDex && resClassCheck->classLoader != NULL)
这行代码翻译过来就是MainActivity所在的dex和Test所在的dex不是同一个且Test的类加载器不为空的情况下,就会抛出异常 "Class ref in pre-verified class resolved to unexpected implementation",现在大家都应该清楚了这个异常具体的来源。
出现问题了,看看如何解决?盗用腾讯bugly的一张图

image.png

当三个条件均满足时,会抛出异常,解决方案大致上有以下四种。

  • 修改fromUnverfiedConstant=true
    需要通过 native hook 拦截系统方法,更改方法的入口参数,将 fromUnverifiedConstant 统一改为 true,风险大,几乎无人采用。
  • 禁止dexopt过程打上CLASS_ISPREVERIFIED标记
    Q-zone方案突破了此限制,但是损失了性能。
  • 补丁类与引用类放在同一个dex中
    Tinker等全量合成方案突破了此限制。
  • 使dvmDexGetResolvedClass返回不为null,直接返回
    QFix的方案,可参考这篇文章QFix探索之路—手Q热补丁轻量级方案

各个方案都有各自的优缺点,我们从学习的角度看,学习一下Q-zone方案的实现。Q-zone方案的原理是在每个类的构造方法中加入一行代码,保证Hack.class在单独的dex中,选择在构造函数中进行可以不增加方法数。如下:

public class Test {
  public Test() {
    System.out.println(Hack.class);
  }
}

我们从源码的角度看一下,为什么加入了这行代码,每个插入的类中都不会打上CLASS_ISPREVERIFIED了。
dexopt的过程是分为verify+optimize两个步骤进行的,对于每个类的verify+optimize方法是在verifyAndOptimizeClass方法中进行的,源码位置在:
/dalvik/vm/analysis/DexPrepare.cpp

static void verifyAndOptimizeClass(DexFile* pDexFile, ClassObject* clazz,
    const DexClassDef* pClassDef, bool doVerify, bool doOpt)
{
    ....
    if (doVerify) {
        if (dvmVerifyClass(clazz)) {
            /*
             * Set the "is preverified" flag in the DexClassDef.  We
             * do it here, rather than in the ClassObject structure,
             * because the DexClassDef is part of the odex file.
             */
            assert((clazz->accessFlags & JAVA_FLAGS_MASK) ==
                pClassDef->accessFlags);
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;
            verified = true;
        } else {
            // TODO: log when in verbose mode
            LOGV("DexOpt: '%s' failed verification", classDescriptor);
        }
    }

    if (doOpt) {
        bool needVerify = (gDvm.dexOptMode == OPTIMIZE_MODE_VERIFIED ||
                           gDvm.dexOptMode == OPTIMIZE_MODE_FULL);
        if (!verified && needVerify) {
            LOGV("DexOpt: not optimizing '%s': not verified",
                classDescriptor);
        } else {
            dvmOptimizeClass(clazz, false);

            /* set the flag whether or not we actually changed anything */
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISOPTIMIZED;
        }
    }
}

很清晰,dvmVerifyClass如果校验通过了,该clazz就会被打上CLASS_ISPREVERIFIED标记。接下来我们主要看dvmVerifyClass方法都干了什么。源码位置:/dalvik/vm/analysis/DexVerify.cpp

bool dvmVerifyClass(ClassObject* clazz)
{
    int i;
    if (dvmIsClassVerified(clazz)) {
        LOGD("Ignoring duplicate verify attempt on %s", clazz->descriptor);
        return true;
    }
    for (i = 0; i < clazz->directMethodCount; i++) {
        if (!verifyMethod(&clazz->directMethods[i])) {
            LOG_VFY("Verifier rejected class %s", clazz->descriptor);
            return false;
        }
    }
    for (i = 0; i < clazz->virtualMethodCount; i++) {
        if (!verifyMethod(&clazz->virtualMethods[i])) {
            LOG_VFY("Verifier rejected class %s", clazz->descriptor);
            return false;
        }
    }
    return true;
}

在verifyMethod中会对Method的各个字段进行验证,篇幅原因,不进行逐层源码追踪了,在verifyMethod方法中,会调用dvmVerifyCodeFlow方法,接着调用doCodeVerification,会具体分析每一条指令,执行必要的解析及验证。对于每一条指令,是调用verifyInstruction方法来验证的。verifyInstruction方法的源码位置:/dalvik/vm/CodeVerify.cpp。在verifyInstruction中,注意这段代码。

  case OP_CONST_CLASS:
    case OP_CONST_CLASS_JUMBO:
        assert(gDvm.classJavaLangClass != NULL);
        /* make sure we can resolve the class; access check is important */
        resClass = dvmOptResolveClass(meth->clazz, decInsn.vB, &failure);
        if (resClass == NULL) {
            const char* badClassDesc = dexStringByTypeIdx(pDexFile, decInsn.vB);
            dvmLogUnableToResolveClass(badClassDesc, meth);
            LOG_VFY("VFY: unable to resolve const-class %d (%s) in %s",
                decInsn.vB, badClassDesc, meth->clazz->descriptor);
            assert(failure != VERIFY_ERROR_GENERIC);
        } else {
            setRegisterType(workLine, decInsn.vA,
                regTypeFromClass(gDvm.classJavaLangClass));
        }
        break;

为什么要关注OP_CONST_CLASS,因为我们插入的System.out.println(Hack.class);会生成const-class的dalvik指令,可以通过dexdump或者反编译apk来查看,此时会触发dvmOptResolveClass的调用。dvmOptResolveClass函数会去查找Hack.class,由于我们的dex没有Hack.class,肯定查不到,抛异常返回,此时这个类的dvmVerifyClass过程会返回false,这个类也就没有打上CLASS_ISPREVERIFIED,而verified为false,导致也不会进行optimize过程。

值得说明的是如果类没有打上CLASS_ISPREVERIFIED,那么verify+optimize都会在类第一次加载时dvmInitClass中进行,正常情况下每个类的verify+optimize只会在安装时dexopt中进行一次,verify过程非常重,会对类的所有方法的所有指令都进行校验,如果短时间内,大量的类进行verify,耗时是比较严重的,尤其在应用刚启动的时候,有可能造成白屏。

至于我们如何插入System.out.println(Hack.class),我们可以采用transformAPI+javaassist进行实现。实现过程注意两点:

  • Application不要插入Hack.class,因为application的构造函数执行时,我们还没有注入hack.apk
  • 在注入patch.dex前注入hack.apk,否则会找不到类

pre-verify方案验证demo,很简单,直接运行app,然后将patch.dex放到sdcard的根目录下即可。
Demo地址:https://github.com/jjlan/FixTest

参考:

  1. Android热补丁动态修复技术(一):从Dex分包原理到热补丁
  2. Android Classloader热修复技术之百家齐放
  3. 安卓App热补丁动态修复技术介绍
  4. QFix探索之路—手Q热补丁轻量级方案

目前本人在公司负责热修复相关的工作,主要是基于robust的热修复相关工作。感兴趣的同学欢迎进群交流。


image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,192评论 4 369
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,186评论 1 303
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,844评论 0 252
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,471评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,876评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,891评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,068评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,791评论 0 205
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,539评论 1 249
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,772评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,250评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,577评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,244评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,146评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,949评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,995评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,812评论 2 276

推荐阅读更多精彩内容