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

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u013718120/article/details/70917947


一、背景


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

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

(1)发版的周期过长

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

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

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


二、热修复原理


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

(1)热修复机制的产生

(2)Android类加载机制

扫描二维码关注公众号,回复: 3839912 查看本文章


1、热修复机制的产生


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

UNEXPECTED TOP-LEVEL EXCEPTION:  
java.lang.IllegalArgumentException: method ID not in [0, 0xffff]: 65536  
at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:501)  
at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:282)  
at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:490)  
at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:167)  
at com.android.dx.merge.DexMerger.merge(DexMerger.java:188)  
at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:439)  
at com.android.dx.command.dexer.Main.runMonoDex(Main.java:287)  
at com.android.dx.command.dexer.Main.run(Main.java:230)  
at com.android.dx.command.dexer.Main.main(Main.java:199)  
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源码:

xref: /libcore/dalvik/src/main/java/dalvik/system/DexClassLoader.java

17package 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}
60

(2)PathClassLoader:
xref: /libcore/dalvik/src/main/java/dalvik/system/PathClassLoader.java

25public 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源码:

xref: /libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
 
17package dalvik.system;
18
19import java.io.File;
20import java.net.URL;
21import java.util.ArrayList;
22import java.util.Enumeration;
23import java.util.List;

29public 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源码如下:

xref: /libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

17package dalvik.system;
18
19import java.io.File;
20import java.io.IOException;
21import java.net.MalformedURLException;
22import java.net.URL;
23import java.util.ArrayList;
24import java.util.Arrays;
25import java.util.Collections;
26import java.util.Enumeration;
27import java.util.List;
28import java.util.zip.ZipFile;
29import libcore.io.ErrnoException;
30import libcore.io.IoUtils;
31import libcore.io.Libcore;
32import libcore.io.StructStat;
33import 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    }
可以看到,在findClass方法中,遍历Element元素数组,从每一个dex文件中查找目标类,在找到后即返回并停止遍历。
Element为DexPathList的静态内部类,然后取出Element对象中的DexFile,调用DexFile的loadClassBinaryName方法,继续来看DexFile源码:
xref: /libcore/dalvik/src/main/java/dalvik/system/DexFile.java

17package dalvik.system;
18
19import java.io.File;
20import java.io.FileNotFoundException;
21import java.io.IOException;
22import java.util.ArrayList;
23import java.util.Enumeration;
24import java.util.List;
25import libcore.io.ErrnoException;
26import libcore.io.Libcore;
27import 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 */
36public 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;

233    }
DexFile类中,loadClassBinaryName方法中调用了defineClass方法,该方法直接通过defineClassNative执行Android原生层代码...到此为止,整个加载流程就走完了。
所以,要实现热修复的就必须要在DexPathList中遍历Element元素时,让补丁dex在Element数组中的为止优先于原有已存在的dex。这样,当系统遍历dexElement时,就可以加载最新补丁dex,实现dex的 “替换”。
引用安卓App热补丁动态修复技术介绍历文章中的描述:
 

 

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

三、核心实现


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

    private Cal cl;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        cl = new Cal();
    }

    /**
     * 点击按钮测试
     * @param view
     */
    public void cal(View view) {
        cl.calculate();
    }
}
上面我们定义了测试用例代码。很明显,在调用Cal的calculate方法时系统会出现异常,运行程序如下:

 

(2)打补丁包,顾名思义,就是修补问题后的包。第一步需要先修改程序,并重新rebuild。
/**
 * Created by Song on 2017/5/15.
 */
public class Cal {
    public float calculate() {
        // 修改后的,不存在任何问题
        return 1 / 1;
    }
}
补丁包其实是一个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中加载补丁文件:
/**
 * Created by Song on 2017/5/15.
 */
public class MainApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        // 获取补丁 执行注入
        
        String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath().concat("/patch_dex.jar");
        File file = new File(dexPath);
        if (file.exists()) {
            Log.e("-----","开始.................");
            inject(dexPath);
        }
    }

    /**
     * 要注入的dex的路径
     */
    private void inject(String path) {
        try {
            // 获取classes的dexElements
            Class<?> cl = Class.forName("dalvik.system.BaseDexClassLoader");
            Object pathList = getField(cl, "pathList", getClassLoader());
            Object baseElements = getField(pathList.getClass(), "dexElements", pathList);
            // 获取patch_dex的dexElements(需要先加载dex)
            String dexopt = getDir("dexopt", 0).getAbsolutePath();
            DexClassLoader dexClassLoader = new DexClassLoader(path, dexopt, dexopt, getClassLoader());
            Object obj = getField(cl, "pathList", dexClassLoader);
            Object dexElements = getField(obj.getClass(), "dexElements", obj);
            // 合并两个Elements
            Object combineElements = combineArray(dexElements, baseElements);
            // 将合并后的Element数组重新赋值给app的classLoader
            setField(pathList.getClass(), "dexElements", pathList, combineElements);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        }
    }

    /**
     * 通过反射获取对象的属性值
     */
    private Object getField(Class<?> cl, String fieldName, Object object) throws NoSuchFieldException, IllegalAccessException {
        Field field = cl.getDeclaredField(fieldName);
        field.setAccessible(true);
        return field.get(object);
    }

    /**
     * 通过反射设置对象的属性值
     */
    private void setField(Class<?> cl, String fieldName, Object object, Object value) throws NoSuchFieldException, IllegalAccessException {
        Field field = cl.getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(object, value);
    }

    /**
     * 通过反射合并两个数组
     */
    private Object combineArray(Object firstArr, Object secondArr) {
        int firstLength = Array.getLength(firstArr);
        int secondLength = Array.getLength(secondArr);
        int length = firstLength + secondLength;

        Class<?> componentType = firstArr.getClass().getComponentType();
        Object newArr = Array.newInstance(componentType, length);
        for (int i = 0; i < length; i++) {
            if (i < firstLength) {
                Array.set(newArr, i, Array.get(firstArr, i));
            } else {
                Array.set(newArr, i, Array.get(secondArr, i - firstLength));
            }
        }
        return newArr;
    }
}
分析:
(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热修复技术-插桩分析

ok,以上就是关于QQ空间热修复的内容,文笔有限还望喜欢。


猜你喜欢

转载自blog.csdn.net/u013718120/article/details/70917947