android Qzone的App热补丁热修复技术

转自:https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a;

          https://www.cnblogs.com/purpleraintear/p/6046390.html;

1.背景

当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装。有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布。
这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?
虽然Android系统并没有提供这个技术,但是很幸运的告诉大家,答案是:可以,我们QQ空间提出了热补丁动态修复技术来解决以上这些问题。

2.实际案例

空间Android独立版5.2发布后,收到用户反馈,结合版无法跳转到独立版的访客界面,每天都较大的反馈。在以前只能紧急换包,重新发布。成本非常高,也影响用户的口碑。最终决定使用热补丁动态修复技术,向用户下发Patch,在用户无感知的情况下,修复了外网问题,取得非常好的效果。

3.解决方案

该方案基于的是android dex分包方案的,关于dex分包方案,网上有几篇解释了,所以这里就不再赘述,具体可以看这里
简单的概括一下,就是把多个dex文件塞入到app的classloader之中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当用到这个重复的类的时候,系统会选择哪个类进行加载呢?
让我们来看看类加载的代码:

一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。
理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,如下图:

在此基础上,我们构想了热补丁的方案,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,如下图:

好,该方案基于第二个拆分dex的方案,方案实现如果懂拆分dex的原理的话,大家应该很快就会实现该方案,如果没有拆分dex的项目的话,可以参考一下谷歌的multidex方案实现。然后在插入数组的时候,把补丁包插入到最前面去。
好,看似问题很简单,轻松的搞定了,让我们来试验一下,修改某个类,然后打包成dex,插入到classloader,当加载类的时候出现了(本例中是QzoneActivityManager要被替换):

为什么会出现以上问题呢?
从log的意思上来讲,ModuleManager引用了QzoneActivityManager,但是发现这这两个类所在的dex不在一起,其中:

  1. ModuleManager在classes.dex中
  2. QzoneActivityManager在patch.dex中
    结果发生了错误。
    这里有个问题,拆分dex的很多类都不是在同一个dex内的,怎么没有问题?

让我们搜索一下抛出错误的代码所在,嘿咻嘿咻,找到了一下代码:

从代码上来看,如果两个相关联的类在不同的dex中就会报错,但是拆分dex没有报错这是为什么,原来这个校验的前提是:

如果引用者(也就是ModuleManager)这个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验。那么这个标志是什么时候被打上去的?让我们在继续搜索一下代码,嘿咻嘿咻~,在DexPrepare.cpp找到了一下代码:

这段代码是dex转化成odex(dexopt)的代码中的一段,我们知道当一个apk在安装的时候,apk中的classes.dex会被虚拟机(dexopt)优化成odex文件,然后才会拿去执行。
虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,上面doVerify变量为true,那么就会执行dvmVerifyClass进行类的校验,如果dvmVerifyClass校验类成功,那么这个类会被打上CLASS_ISPREVERIFIED的标志,那么具体的校验过程是什么样子的呢?
此代码在DexVerify.cpp中,如下:

  1. 验证clazz->directMethods方法,directMethods包含了以下方法:
    • static方法
    • private方法
    • 构造函数
  2. clazz->virtualMethods
    • 虚函数=override方法?

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

所以为了实现补丁方案,所以必须从这些方法中入手,防止类被打上CLASS_ISPREVERIFIED标志。
最终空间的方案是往所有类的构造函数里面插入了一段代码,代码如下:
if (ClassVerifier.PREVENT_VERIFY) { System.out.println(AntilazyLoad.class); }

其中AntilazyLoad类会被打包成单独的hack.dex,这样当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作。
然后在应用启动的时候加载进来.AntilazyLoad类所在的dex包必须被先加载进来,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包,那么他也是不存在的,这样屏幕就会出现茫茫多的类AntilazyLoad找不到的log。
所以Application作为应用的入口不能插入这段代码。(因为载入hack.dex的代码是在Application中onCreate中执行的,如果在Application的构造函数里面插入了这段代码,那么就是在hack.dex加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志)
其中:

之所以选择构造函数是因为他不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。
空间使用的是在字节码插入代码,而不是源代码插入,使用的是javaassist库来进行字节码插入的。
隐患:
虚拟机在安装期间为类打上CLASS_ISPREVERIFIED标志是为了提高性能的,我们强制防止类被打上标志是否会影响性能?这里我们会做一下更加详细的性能测试.但是在大项目中拆分dex的问题已经比较严重,很多类都没有被打上这个标志。
如何打包补丁包:

    1. 空间在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。
    2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包。
      备注:该方案现在也应用到我们的编译过程当中,编译不需要重新打包dex,只需要把修改过的类的class文件打包成patch dex,然后放到sdcard下,那么就会让改变的代码生效。

二、QQ空间热修复原理深入解析

一、背景

 

App的上线发布是我们程序猿开心的事情,证明着一段时间来成果的进步和展现。但是随着App的上线手机App市场,接下来的更新维护工作便成了”家常便饭“。尤其是在创业公司,随着业务等不稳定性因素,前期App的更新工作更为频繁,可能两天一小改,三天一大改的情况经常发生。

那么应对版本更新的同时,需要我们不断将新版本上线,并下发到用户,此时两个典型的问题发生了:

(1)发版的周期过长

(2)用户的App版本更新进度缓慢

所以,在传统App的开发模式下,需要一种手段来改变当前存在的问题。如果存在一种方案可以在不发版的前提下也可以修复线上App的Bug,那么以上两个问题就都得以解决。此时一系列的第三方库扑面而来,阿里的AndFix、腾讯的Qzone修复、以及近期开源的微信Tinker应运而生。

关于各种热更新库的使用,网上已经有很多的博文来介绍。本篇博客着重和大家分享一下关于QQ空间热更新的原理解析。

 

二、热修复原理

 

关于原理的分析,大致分为如下模块:

(1)热修复机制的产生

(2)Android类加载机制

 

1、热修复机制的产生

 

随着App业务不断叠加,以及第三方库的多种依赖,相信很多人某天运行程序突然出现如下异常:

 1 java.lang.IllegalArgumentException: method ID not in [0, 0xffff]: 65536  
 2 at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:501)  
 3 at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:282)  
 4 at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:490)  
 5 at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:167)  
 6 at com.android.dx.merge.DexMerger.merge(DexMerger.java:188)  
 7 at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:439)  
 8 at com.android.dx.command.dexer.Main.runMonoDex(Main.java:287)  
 9 at com.android.dx.command.dexer.Main.run(Main.java:230)  
10 at com.android.dx.command.dexer.Main.main(Main.java:199)  
11 at com.android.dx.command.Main.main(Main.java:103)  

 

从异常信息中,我们不难发现:method ID not in 65536。并且大致可以看出是在dex层跑出的异常。什么意思呢?

我们编写的Java业务代码为.java类型文件,当我们编译运行一个完整的App项目时,系统会执行如下流程:

 

.Java  --> .class  --> dex  -->  (odex ) --> Apk

 

当class文件被打包成一个dex文件时,由于dex文件的限制,方法的ID为short型,所以一个dex文件存放的方法数最多为65536。超过了该数,系统就会抛出上面所述的异常。为了解决这个问题,Google为我们提供了multidex解决方案,即把dex文件分为主、副两类。系统只加载第一个dex文件(主dex),剩余的dex文件(副dex)在Application中的onCreate方法中,以资源的方式被加载到系统的ClassLoader。可以理解为:一个APK可以包含多个dex文件。这样就解决了65536问题的同时。也为热修复提供了实现方案:将修复后的dex文件下发到客户端(App),客户端在启动后就会加载最新的dex文件。

关于如何实现加载最新dex文件,我们还需要了解下Android Davilk虚拟机的类加载流程。

 

2、Android类加载机制

 

Android虚拟机对于类的加载机制为:同一个类只会加载一次。所以要实现热修复的前提就是:让下发到客户端的补丁包类要比之前存在bug的类优先加载。类似于一种“替换”的解决。如何实现优先加载呢?我们先来了解下Davilk虚拟机的类加载方式。

Java虚拟机JVM的类加载是:ClassLoader。同样Android系统提供了两种类加载方式:

(1)DexClassLoader

(2)PathClassLoader

 

首先从源码中深入:

 

libcore/dalvik/src/main/java/dalvik/system/

(1)DexClassLoader源码: 

  • package dalvik.system;
    19import java.io.File;
     
    36public class DexClassLoader extends BaseDexClassLoader {
    37    /**
    38     * Creates a {@code DexClassLoader} that finds interpreted and native
    39     * code.  Interpreted classes are found in a set of DEX files contained
    40     * in Jar or APK files.
    41     *
    42     * <p>The path lists are separated using the character specified by the
    43     * {@code path.separator} system property, which defaults to {@code :}.
    44     *
    45     * @param dexPath the list of jar/apk files containing classes and
    46     *     resources, delimited by {@code File.pathSeparator}, which
    47     *     defaults to {@code ":"} on Android
    48     * @param optimizedDirectory directory where optimized dex files
    49     *     should be written; must not be {@code null}
    50     * @param libraryPath the list of directories containing native
    51     *     libraries, delimited by {@code File.pathSeparator}; may be
    52     *     {@code null}
    53     * @param parent the parent class loader
    54     */
    55    public DexClassLoader(String dexPath, String optimizedDirectory,
    56            String libraryPath, ClassLoader parent) {
    57        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    58    }
    59}

     

(2)PathClassLoader:  

25 public class PathClassLoader extends BaseDexClassLoader {
26    /**
27     * Creates a {@code PathClassLoader} that operates on a given list of files
28     * and directories. This method is equivalent to calling
29     * {@link #PathClassLoader(String, String, ClassLoader)} with a
30     * {@code null} value for the second argument (see description there).
31     *
32     * @param dexPath the list of jar/apk files containing classes and
33     * resources, delimited by {@code File.pathSeparator}, which
34     * defaults to {@code ":"} on Android
35     * @param parent the parent class loader
36     */
37    public PathClassLoader(String dexPath, ClassLoader parent) {
38        super(dexPath, null, null, parent);
39    }
40
41    /**
42     * Creates a {@code PathClassLoader} that operates on two given
43     * lists of files and directories. The entries of the first list
44     * should be one of the following:
45     *
46     * <ul>
47     * <li>JAR/ZIP/APK files, possibly containing a "classes.dex" file as
48     * well as arbitrary resources.
49     * <li>Raw ".dex" files (not inside a zip file).
50     * </ul>
51     *
52     * The entries of the second list should be directories containing
53     * native library files.
54     *
55     * @param dexPath the list of jar/apk files containing classes and
56     * resources, delimited by {@code File.pathSeparator}, which
57     * defaults to {@code ":"} on Android
58     * @param libraryPath the list of directories containing native
59     * libraries, delimited by {@code File.pathSeparator}; may be
60     * {@code null}
61     * @param parent the parent class loader
62     */
63    public PathClassLoader(String dexPath, String libraryPath,
64            ClassLoader parent) {
65        super(dexPath, null, libraryPath, parent);
66    }
67}

从源码可以看出,DexClassLoader和PathClassLoaderr继承自BaseDexClassLoader。

(1)PathClassLoader可以操作本地文件系统的文件列表或目录中的classes。PathClassLoader负责加载系统类和主Dex中的类。

(2)DexClassLoader是一个可以从包含classes.dex实体的.jar或.apk文件中加载classes的类加载器。DexClassLoader负责加载其他dex文件(副dex)中的类。

既然是类加载器,必然存在类加载方法,继续查看源码,可以发现BaseDexClassLoader提供了findClass方法用于加载类:

(1)BaseDexClassLoader源码:

17 package dalvik.system;
18
19 import java.io.File;
20 import java.net.URL;
21 import java.util.ArrayList;
22 import java.util.Enumeration;
23 import java.util.List;
 
29 public class BaseDexClassLoader extends ClassLoader {
30    private final DexPathList pathList;
 
45    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
46            String libraryPath, ClassLoader parent) {
47        super(parent);
48        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
49    }
50
51    @Override
52    protected Class<?> findClass(String name) throws ClassNotFoundException {
53        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
54        Class c = pathList.findClass(name, suppressedExceptions);
55        if (c == null) {
56            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
57            for (Throwable t : suppressedExceptions) {
58                cnfe.addSuppressed(t);
59            }
60            throw cnfe;
61        }
62        return c;
63    }

 

在findClass方法中,又调用了DexPathList对象的findClass方法,DexPathList源码如下:

  

17 package dalvik.system;
18
19 import java.io.File;
20 import java.io.IOException;
21 import java.net.MalformedURLException;
22 import java.net.URL;
23 import java.util.ArrayList;
24 import java.util.Arrays;
25 import java.util.Collections;
26 import java.util.Enumeration;
27 import java.util.List;
28 import java.util.zip.ZipFile;
29 import libcore.io.ErrnoException;
30 import libcore.io.IoUtils;
31 import libcore.io.Libcore;
32 import libcore.io.StructStat;
33 import static libcore.io.OsConstants.*;
 
48/*package*/ final class DexPathList {
49    private static final String DEX_SUFFIX = ".dex";
50    private static final String JAR_SUFFIX = ".jar";
51    private static final String ZIP_SUFFIX = ".zip";
52    private static final String APK_SUFFIX = ".apk";
53
54    /** class definition context */
55    private final ClassLoader definingContext;
56
57    /**
58     * List of dex/resource (class path) elements.
59     * Should be called pathElements, but the Facebook app uses reflection
60     * to modify 'dexElements' (http://b/7726934).
61     */
62    private final Element[] dexElements;
63
64    /** List of native library directories. */
65    private final File[] nativeLibraryDirectories;
305    /**
306     * Finds the named class in one of the dex files pointed at by
307     * this instance. This will find the one in the earliest listed
308     * path element. If the class is found but has not yet been
309     * defined, then this method will define it in the defining
310     * context that this instance was constructed with.
311     *
312     * @param name of class to find
313     * @param suppressed exceptions encountered whilst finding the class
314     * @return the named class or {@code null} if the class is not
315     * found in any of the dex files
316     */
317    public Class findClass(String name, List<Throwable> suppressed) {
318        for (Element element : dexElements) {
319            DexFile dex = element.dexFile;
320
321            if (dex != null) {
322                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
323                if (clazz != null) {
324                    return clazz;
325                }
326            }
327        }
328        if (dexElementsSuppressedExceptions != null) {
329            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
330        }
331        return null;
332    }

 

  1.   
可以看到,在findClass方法中,遍历Element元素数组,从每一个dex文件中查找目标类,在找到后即返回并停止遍历。
Element为DexPathList的静态内部类,然后取出Element对象中的DexFile,调用DexFile的loadClassBinaryName方法,继续来看DexFile源码:
  
package dalvik.system;

19 import java.io.File;
20 import java.io.FileNotFoundException;
21 import java.io.IOException;
22 import java.util.ArrayList;
23 import java.util.Enumeration;
24 import java.util.List;
25 import libcore.io.ErrnoException;
26 import libcore.io.Libcore;
27 import libcore.io.StructStat;
28
29/**
30 * Manipulates DEX files. The class is similar in principle to
31 * {@link java.util.zip.ZipFile}. It is used primarily by class loaders.
32 * <p>
33 * Note we don't directly open and read the DEX file here. They're memory-mapped
34 * read-only by the VM.
35 */
36 public final class DexFile {
37    private int mCookie;
38    private final String mFileName;
39    private final CloseGuard guard = CloseGuard.get();
207    /**
208     * See {@link #loadClass(String, ClassLoader)}.
209     *
210     * This takes a "binary" class name to better match ClassLoader semantics.
211     *
212     * @hide
213     */
214    public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
215        return defineClass(name, loader, mCookie, suppressed);
216    }
218    private static Class defineClass(String name, ClassLoader loader, int cookie,
219                                     List<Throwable> suppressed) {
220        Class result = null;
221        try {
222            result = defineClassNative(name, loader, cookie);
223        } catch (NoClassDefFoundError e) {
224            if (suppressed != null) {
225                suppressed.add(e);
226            }
227        } catch (ClassNotFoundException e) {
228            if (suppressed != null) {
229                suppressed.add(e);
230            }
231        }
232        return result;

DexFile类中,loadClassBinaryName方法中调用了defineClass方法,该方法直接通过defineClassNative执行Android原生层代码...到此为止,整个加载流程就走完了。

所以,要实现热修复的就必须要在DexPathList中遍历Element元素时,让补丁dex在Element数组中的为止优先于原有已存在的dex。这样,当系统遍历dexElement时,就可以加载最新补丁dex,实现dex的 “替换”。
引用安卓App热补丁动态修复技术介绍历文章中的描述:
  

  

遍历dexElement:优先补丁dex + 替换bug dex  = 热修复 
以上就是QQ空间热修复的核心解决方案。为了进一步深入热修复原理,接下来我们以代码为例,具体看看是如何实现的。

 

三、核心实现


(1)编写demo代码,工程名称为QQHotUpdate
1 /**
2  * Created by Song on 2017/5/15.
3  */
4 public class Cal {
5     public float calculate() {
6         // 很明显,会有算数异常抛出
7         return 1 / 0;
8     }
9 }

  

 1 public class MainActivity extends AppCompatActivity {
 2  
 3     private Cal cl;
 4     @Override
 5     protected void onCreate(Bundle savedInstanceState) {
 6         super.onCreate(savedInstanceState);
 7         setContentView(R.layout.activity_main);
 8         cl = new Cal();
 9     }
10  
11     /**
12      * 点击按钮测试
13      * @param view
14      */
15     public void cal(View view) {
16         cl.calculate();
17     }

 

上面我们定义了测试用例代码。很明显,在调用Cal的calculate方法时系统会出现异常,运行程序如下: 

  

(2)打补丁包,顾名思义,就是修补问题后的包。第一步需要先修改程序,并重新rebuild。
 
1 /**
2  * Created by Song on 2017/5/15.
3  */
4 public class Cal {
5     public float calculate() {
6         // 修改后的,不存在任何问题
7         return 1 / 1;
8     }
9 }

补丁包其实是一个dex文件。dex文件的形成过程为:.class --> jar --> dex。所以,先要将class文件打包为jar。

重新编译后的class文件在app / build / intermediates / classes / debug / 包名 / ...
将目录copy到桌面,删除不必要的文件,留下Cal.class即可。然后执行如下命令:
jar -cvf pat.jar com上述命令将com目录下的文件打包为pat.jar文件。
接下来需要将jar文件打包为dex文件,我们使用SDK24.0版本下的dx.bat,进入该目录,在dos下执行:
dx --dex --output=patch_dex.jar C:/Users/Song/Desktop/pat.jar最终打包出的dex文件为patch_dex.jar。

(3)加载补丁

打开Android Device Monitor,将补丁放入SD卡根目录:
  
选择右上角第二个按钮,将patch_dex.jar导入到模拟器。
创建Application,在Application中加载补丁文件:
 1 /**
 2  * Created by Song on 2017/5/15.
 3  */
 4 public class MainApplication extends Application {
 5  
 6     @Override
 7     public void onCreate() {
 8         super.onCreate();
 9         // 获取补丁 执行注入
10         
11         String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath().concat("/patch_dex.jar");
12         File file = new File(dexPath);
13         if (file.exists()) {
14             Log.e("-----","开始.................");
15             inject(dexPath);
16         }
17     }
18  
19     /**
20      * 要注入的dex的路径
21      */
22     private void inject(String path) {
23         try {
24             // 获取classes的dexElements
25             Class<?> cl = Class.forName("dalvik.system.BaseDexClassLoader");
26             Object pathList = getField(cl, "pathList", getClassLoader());
27             Object baseElements = getField(pathList.getClass(), "dexElements", pathList);
28             // 获取patch_dex的dexElements(需要先加载dex)
29             String dexopt = getDir("dexopt", 0).getAbsolutePath();
30             DexClassLoader dexClassLoader = new DexClassLoader(path, dexopt, dexopt, getClassLoader());
31             Object obj = getField(cl, "pathList", dexClassLoader);
32             Object dexElements = getField(obj.getClass(), "dexElements", obj);
33             // 合并两个Elements
34             Object combineElements = combineArray(dexElements, baseElements);
35             // 将合并后的Element数组重新赋值给app的classLoader
36             setField(pathList.getClass(), "dexElements", pathList, combineElements);
37         } catch (ClassNotFoundException e) {
38             e.printStackTrace();
39         } catch (IllegalAccessException e) {
40             e.printStackTrace();
41         } catch (NoSuchFieldException e) {
42             e.printStackTrace();
43         }
44     }
45  
46     /**
47      * 通过反射获取对象的属性值
48      */
49     private Object getField(Class<?> cl, String fieldName, Object object) throws NoSuchFieldException, IllegalAccessException {
50         Field field = cl.getDeclaredField(fieldName);
51         field.setAccessible(true);
52         return field.get(object);
53     }
54  
55     /**
56      * 通过反射设置对象的属性值
57      */
58     private void setField(Class<?> cl, String fieldName, Object object, Object value) throws NoSuchFieldException, IllegalAccessException {
59         Field field = cl.getDeclaredField(fieldName);
60         field.setAccessible(true);
61         field.set(object, value);
62     }
63  
64     /**
65      * 通过反射合并两个数组
66      */
67     private Object combineArray(Object firstArr, Object secondArr) {
68         int firstLength = Array.getLength(firstArr);
69         int secondLength = Array.getLength(secondArr);
70         int length = firstLength + secondLength;
71  
72         Class<?> componentType = firstArr.getClass().getComponentType();
73         Object newArr = Array.newInstance(componentType, length);
74         for (int i = 0; i < length; i++) {
75             if (i < firstLength) {
76                 Array.set(newArr, i, Array.get(firstArr, i));
77             } else {
78                 Array.set(newArr, i, Array.get(secondArr, i - firstLength));
79             }
80         }
81         return newArr;
82     }
83 }

分析:

(1)在补丁包存在的情况下,向App的BaseDexClassLoader的dexElements下注入补丁。
(2)通过本身BaseDexClassLoader,利用反射获取classes的dexElements。
(3)加载补丁包的dexElements。
(4)合并两个补丁包,生成新的dexElements。
(5)将新的dexElements重新设置到App下的BaseDexClassLoader。

重点在合并代码上,可以发现,在合并的过程中,我们将新的补丁文件设置到了最前面。从上面原理部分我们知道,相同文件只会加载一次!当虚拟机优先加载了最前面的补丁包后,遇到相同文件就不会再重复加载。这就达到了修复的作用。
ok,执行代码,等待惊喜.... 麻蛋,又出现异常:
 Class ref in pre-verified class resolved to unexpected implementation

百度后发现原来是因为类校验产生的问题:
    (1)在apk安装的时候系统会将dex文件优化成odex文件,在优化的过程中会涉及一个预校验的过程。
    (2)如果一个类的static方法,private方法,override方法以及构造函数中引用了其他类,而且这些类都属于同一个dex文件,此时该类就会被打上CLASS_ISPREVERIFIED。
    (3)如果在运行时被打上CLASS_ISPREVERIFIED的类引用了其他dex的类,就会报错。
    (4)所以MainActivity的onCreate()方法中引用另一个dex的类就会出现上文中的问题。
    (5)正常的分包方案会保证相关类被打入同一个dex文件。
    (6)想要使得patch可以被正常加载,就必须保证类不会被打上CLASS_ISPREVERIFIED标记。而要实现这个目的就必须要在分完包后的class中植入对其他dex文件中类的引用。

    要在已经编译完成后的类中植入对其他类的引用,就需要操作字节码,惯用的方案是插桩。常见的工具有javaassist,asm等。其实QQ空间热修复也是利用的插桩的方式来实现了在apk文件安装的时候不被打上CLASS_ISPREVERIFIED标记。完成热修复工作。关于插桩此处就不再赘述了,这里推荐给大家一篇教程:Android热修复技术-插桩分析

 三、更详细介绍:

介绍

Qzone 超级补丁技术基于dex分包方案,使用了多dex加载(multidex)的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的dex文件,然后插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。

该方案的灵感来源? 
没错就是类加载机制,相信大部分同学都对它有所了解吧。

ClassLoader 类加载机制

Android应用程序本质上使用的是java开发,使用标准的java编译器编译出Class文件,和普通的java开发不同的地方是把class文件再重新打包成dex类型的文件,这种重新打包会对Class文件内部的各种函数表、变量表等进行优化,最终产生了odex文件。odex文件是一种经过android打包工具优化后的Class文件,因此加载这样特殊的Class文件就需要特殊的类装载器,所以android中提供了DexClassLoader类。

类图: 
clipboard_mh1534131797938.jpg

Android使用的是Dalvik虚拟机装载class文件,所以classloader不同于java默认类库rt.jar包中java.lang.ClassLoader, 可以看到android中的classloader做了些修改,但是原理还是差不多的。 
学过java的同学都知道, 类加载器是采用双亲委派机制来进行类加载的。

双亲委托模式是什么?

某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

为什么要用双亲委托模式?

  1. 可以避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
  2. 安全性考虑,防止核心API库被随意篡改。我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中的定义类型,这样会存在非常大的安全隐患。

双亲委派机制 从ClassLoader.java 源代码可以清晰的看出来: 
ClassLoader.java 
classloader.png
流程大概如下: 
1.判断类是否已经加载过; 
2.父类加载器优先加载; 
3.parent为null,则调用BootstrapClassLoader进行加载 ; 
4.如果class依旧没有找到,则调用当前类加载器的findClass方法进行加载;

BaseDexClassLoader.java 
baseDexClassLoader.png

DexPathList.java 
dexpathllist.png

DexFile.java(\dalvik\dx\src\com\android\dx\dex\file\DexFile.java) 
dexfile.png

defineClassNative(android4.4版本,区分ART 和Dalvik两种情况) 
1.ART 环境 [art\runtime\native\dalvik_system_DexFile.cc] 
defineclassnative.png

2.Dalvik 环境 [\dalvik\vm\native\dalvik_system_DexFile.cpp ] 
dvmdefine.png

(注:dvmDefineClass函数则是类加载机制中最为核心的逻辑,由于和本文深入探索的方向关联性不强,就不作深究了。源码在 dalvik2/vm/oo/Class.cpp中,有兴趣可自行研究。)

原理分析

从以上类加载机制的源码中我们可以分析出,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到继续从下一个dex文件查找。理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,Qzone方案的灵感就是从上述的DexPathList类中的for循环体而来。 
qzone1.png

在此基础上,Qzone 团队构想了热补丁的方案,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,如下图: 
qzone2.png

如果懂拆分dex的原理的话,大家应该很快就会实现该方案。如果没有拆分dex的项目的话,可以参考一下谷歌的multidex方案实现,然后在插入数组的时候,把补丁包插入到最前面去。

当patch.dex中包含Main.class时就会优先加载,在后续的DEX中遇到Main.class的话就会直接返回而不去加载,这样就达到了修复的目的。看似问题很简单,轻松的搞定了,Qzone一开始按照以上思路进行了实践,但在实际操作中,出现了 unexpected DEX 的异常。这个问题是因为在Dalvik环境中,类被打上CLASS_ISPREVERIFIED的标志,主动抛出异常报错。

为什么系统要给类打上CLASS_ISPREVERIFIED的标志?

我们知道,在APK安装时,虚拟机需要将classes.dex优化成odex文件,然后才会执行。在这个过程中,会进行类的verify操作,为了提高运行性能,如果调用关系的类都在同一个DEX中的话,就会被打上CLASS_ISPREVERIFIED的标志,然后写入odex文件,表明它没有被其他Dex的类引用。

规避 Dalvik 下 “unexpected DEX” 的异常。 
如下是 dalvik 的一段源码,当补丁安装后,首次使用到补丁里的类时会调用到这里, 源代码如下:

[dalvik/vm/oo/Resolve.cpp] 
resolve.png

从代码逻辑我们可以看出,需要同时满足代码中标出来的三个条件,才会出现异常,这三个条件的含义如下: 
threeCase.png

因此,想要避免补丁类加载时发生 “unexpected DEX ” 的异常,则需要从以上三个地方来入手。

Qzone 的超级补丁方案采用的是通过绕过这里的第二个判断来避免报错的。如果一个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验。而要避免报错,首先得弄清楚它是什么条件下才会被打上。继续搜索源代码,发现在DexPrepare.cpp找到了如下代码:

[dalvik\vm\analysis\DexPrepare.cpp] 
dexprepare.png

这段代码是dex转化成odex(dexopt)的代码中的一段,我们知道当一个apk在安装的时候,apk中的classes.dex会被虚拟机(dexopt)优化成odex文件,然后才会拿去执行。 
虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,上面doVerify变量为true,那么就会执行dvmVerifyClass进行类的校验,如果dvmVerifyClass函数校验类成功,那么这个类会被打上CLASS_ISPREVERIFIED的标志。

DexClassDef 结构体代码: 
struct DexClassDef { 
u4 classIdx; //类的类型, DexTypeId中的索引下标 
u4 accessFlags; //类的访问标志 
u4 superclassIdx; //父类类型, DexTypeId中的索引下标 
u4 interfacesOff; //接口偏移, 指向DexTypeList的结构 
u4 sourceFileIdx; //源文件名, DexStringId中的索引下标 
u4 annotationsOff; //注解偏移, 指向DexAnnotationsDirectoryItem的结构 
u4 classDataOff; //类数据偏移, 指向DexClassData的结构 
u4 staticValuesOff; //类静态数据偏移, 指向DexEncodedArray的结构 
};

而具体的校验过程,即dvmVerifyClass函数是什么样子的呢?我们继续往下探索。 
代码在DexVerify.cpp中,如下: 
[dalvik\vm\analysis\DexVerify.cpp] 
dvmverifyclass.png

该方法做了三件事情: 
1. 是否已被校验过? 
2. 验证clazz->directMethods方法,directMethods包含了以下方法: 
● static方法 
● private方法 
● 构造方法 
3. clazz->virtualMethods。虚函数=override方法?

概括一下就是,只要在static方法,private方法,构造方法,override方法中直接引用了其他dex中的类,那么这个类就不会被打上CLASS_ISPREVERIFIED标记。也就是说如果以上方法中直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED。

搞清了来龙去脉,所以就可以从这些地方入手。最终Qzone的方案是往所有补丁类的构造函数里面插入了一段代码,来引用另外一个dex的类,防止类被打上CLASS_ISPREVERIFIED标志。代码如下:

if (ClassVerifier.PREVENT_VERIFY) {
    System.out.println(AntilazyLoad.class);
}

  

Qzone方案的大致实现流程如下:

打补丁包: 
1.在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。 
2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包。

Hook及加载patch操作: 
1. 打包过程中,会往所有补丁类的构造函数里面插一段代码。 
2. 其中AntilazyLoad类会被打包成单独的hack.dex,当安装apk的时候,patch.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志,只要没被打上这个标志的类都可以进行打补丁操作。 
3. 先加载进来AntilazyLoad类,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包也于事无补。 
4. 获取到当前应用的Classloader,即为BaseDexClassloader。 
5. 通过反射获取到它的DexPathList属性对象pathList。 
6. 通过反射调用pathList的dexElements方法把补丁包patch.dex转化为Element[]。 
7. 两个Element[]进行合并,把patch.dex放到最前面去。 
8. 加载Element[],达到修复目的。

该方案之所以选择构造函数进行插入代码,是因为它不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。 
细节:Qzone使用的是在字节码插入代码,而不是源代码插入,使用的是java assist库来进行字节码插入的。

互动问题:思考一下,除了通过防止补丁类被打上CLASS_ISPREVERIFIED标志,我们还可以想到有哪些方式来解决Dalvik下的 “unexpected DEX” 异常问题?

Qzone方案总结

优势: 
1.没有合成整包(和微信Tinker比起来),输出产物比较小,比较灵活。 
2.可以实现类替换,兼容性较高。(某些三星手机不起作用)

不足: 
1.不支持即时生效,必须通过重启才能生效。 
2.为了实现修复这个过程,必须在应用中加入两个dex, dalvikhack.dex中只有一个类,对性能影响不大,但是对于patch.dex来说,修复的类到了一定数量,就需要花不少的时间加载。对大型应用来说,启动耗时增加2s以上是很难接受的事。 
3.在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,这会导致ART下的补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。

其中第二点不足,即性能无法提升的原因: 
插桩的解决方案会影响到运行时性能的原因在于:app 内的所有补丁类都预埋引用一个独立 dex 的空类,导致安装 dexopt 阶段的 preverify 失败,运行时将再次 verify+optimize。 
另外即使后期发布版本实际上无需发布补丁,我们也需要预埋插桩的逻辑,这本身也是不合理的一点,所以确实有必要去探索新的方向,既保留补丁的能力,同时去掉插桩带来的负面影响。

第三点不足,即ART模式下补丁包异常大的原因: 
ART(Android Runtime)是Android在4.4版本中引入的新虚拟机环境,在5.0版本正式取代了Dalvik VM。ART环境下,App安装时其包含的Dex文件将被dex2oat预编译成目标平台的机器码,从而提高了App的运行效率。在这个预编译过程中,dex2oat对目标代码的优化过程与Dalvik VM下的dexopt有较大区别,尤其是在5.0版本以后ART环境下新增的方法内联优化,由于方法内联改变了原本的方法分布和调用流程。

方法内联之所以会导致优先加载补丁Dex的方案出现问题,本质上是因为补丁Dex只覆盖了旧Dex里的一部分类,一旦被覆盖的类的方法被内联到了调用者里,则加载类的过程还是正常的,即从补丁Dex里加载了新版本的类。但由于内联,执行流程并未跳转到新的方法里,于是所有关于新版本的类的方法、成员、字符串的查找用的就都是旧方法里的索引了。因此,在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,这会导致ART下的补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。

参考文章: 
《安卓App热补丁动态修复技术介绍》 - QQ空间开发团队 
《QFix探索之路》 - 手Q热补丁轻量级方案

 

posted @ 2018-09-18 15:40  linghu_java  阅读(875)  评论(0编辑  收藏  举报