簡單易懂的tinker熱修復原理分析

weixin_34148340發表於2018-08-03

簡介

熱修復的方案有很多種,其中原理也各不相同。目前開源的比較有名的有阿里AndFix、美團Robust、qq的QZone以及tinker等。今天我們就來分析一下tinker熱修復的原理。(這裡以Android 6.0的原始碼來分析,之所以要以Android6.0原始碼來分析而不是以Android7.0或更新的原始碼分析,是因為Android7.0引入了混合編譯,對熱補丁有影響。但無論是6.0還是7.0,tinker熱修復最核心的原理是一樣的,我們分析tinker是為了理解它的運作機制,從而更好地去使用它。如果有對混合編譯感興趣的可以看文章Android_N混合編譯與對熱補丁影響解析)

Tinker熱修復原理

Android裡面載入類主要用到了兩個類載入器,一個是PathClassLoader,另一個是DexClassLoader,應用程式中的類一般都是通過PathClassLoader來載入類的,不信你在Activity裡面呼叫getClassLoader()方法,然後看得到的ClassLoader物件的型別是不是PathClassLoader型別,答案是肯定的。我們來看下PathClassLoader類的原始碼:

package dalvik.system;

/**
 * Provides a simple {@link ClassLoader} implementation that operates on a list
 * of files and directories in the local file system, but does not attempt to
 * load classes from the network. Android uses this class for its system class
 * loader and for its application class loader(s).
 */
public class PathClassLoader extends BaseDexClassLoader {

    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }


    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

複製程式碼

這是Android 6.0原始碼裡面的PathClassLoader類,注意看類開頭的註釋:“Android uses this class for its system class loader and for its application class loader”,看到這我們應該明白這個類是幹嘛的了吧,意思就是Android將此類用於其系統類載入器及其應用程式類載入器。也就是說,我們的Android應用程式,無論是系統的java類或是你自己寫的類,都是通過PathClassLoader來載入的。

那麼DexClassLoader是幹嘛的呢?我們看下DexClassLoader的原始碼中對它的介紹:

package dalvik.system;

import java.io.File;

//標註1
/**
 * A class loader that loads classes from {@code .jar} and {@code .apk} files
 * containing a {@code classes.dex} entry. This can be used to execute code not
 * installed as part of an application.
 *
 * <p>This class loader requires an application-private, writable directory to
 * cache optimized classes. Use {@code Context.getCodeCacheDir()} to create
 * such a directory: <pre>   {@code
 *   File dexOutputDir = context.getCodeCacheDir();
 * }</pre>
 *
 * <p><strong>Do not cache optimized classes on external storage.</strong>
 * External storage does not provide access controls necessary to protect your
 * application from code injection attacks.
 */
public class DexClassLoader extends BaseDexClassLoader {
    /**
     * Creates a {@code DexClassLoader} that finds interpreted and native
     * code.  Interpreted classes are found in a set of DEX files contained
     * in Jar or APK files.
     *
     * <p>The path lists are separated using the character specified by the
     * {@code path.separator} system property, which defaults to {@code :}.
     *
     * @param dexPath the list of jar/apk files containing classes and
     *     resources, delimited by {@code File.pathSeparator}, which
     *     defaults to {@code ":"} on Android
     * @param optimizedDirectory directory where optimized dex files
     *     should be written; must not be {@code null}
     * @param librarySearchPath the list of directories containing native
     *     libraries, delimited by {@code File.pathSeparator}; may be
     *     {@code null}
     * @param parent the parent class loader
     */
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
    }
}
複製程式碼

看標註1處“A class loader that loads classes from {@code .jar} and {@code .apk} files containing a {@code classes.dex} entry. This can be used to execute code not installed as part of an application.”,這說明這個類主要用於載入包含在dex和apk檔案中的類,這可用於執行未作為應用程式的一部分安裝的程式碼。也就是說,它可以載入那些未被系統安裝的類。

那麼我們Android為什麼要再實現兩個類載入器而不是用java裡面已經實現好的類載入器呢?原因是Android中對虛擬機器做了很多優化,傳統java的ClassLoader可以載入Class檔案,而在Android中並不是這樣,無論是dalvik還是art,它們載入的不再是class檔案,而是dex檔案。大家都知道,我們生成的apk檔案解壓後會發現裡面有classes.dex檔案,如果你引入了multidex,解壓出的安裝包裡面會有多個dex檔案,而我們今天要講的tinker熱修復原理,就是在這些dex中做文章。

首先看一張圖,來了解一下PathClassLoader的載入機制:

Android在載入一個類的時候,會去眾多的dex檔案裡面有順序的找,比如要找一個Man.class類,先會從classes.dex裡面找,如果沒找到,會繼續去第二個classes2.dex檔案裡面找,如果找不到,依次往下一個dex包裡面找,如果所有的dex裡面都沒有,就會丟擲異常。

下面我們就來看看原始碼:由上面PathClassLoader類可知,PathClassLoader繼承自BaseDexClassLoader,我們看一下BaseDexClassLoader的原始碼:

package dalvik.system;

import java.io.File;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;

/**
 * Base class for common functionality between various dex-based
 * {@link ClassLoader} implementations.
 */
public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

    /**
     * @hide
     */
    public void addDexPath(String dexPath) {
        pathList.addDexPath(dexPath, null /*optimizedDirectory*/);
    }

    @Override
    protected URL findResource(String name) {
        return pathList.findResource(name);
    }

    @Override
    protected Enumeration<URL> findResources(String name) {
        return pathList.findResources(name);
    }

    @Override
    public String findLibrary(String name) {
        return pathList.findLibrary(name);
    }

    @Override
    protected synchronized Package getPackage(String name) {
        if (name != null && !name.isEmpty()) {
            Package pack = super.getPackage(name);

            if (pack == null) {
                pack = definePackage(name, "Unknown", "0.0", "Unknown",
                        "Unknown", "0.0", "Unknown", null);
            }

            return pack;
        }

        return null;
    }

    /**
     * @hide
     */
    public String getLdLibraryPath() {
        StringBuilder result = new StringBuilder();
        for (File directory : pathList.getNativeLibraryDirectories()) {
            if (result.length() > 0) {
                result.append(':');
            }
            result.append(directory);
        }

        return result.toString();
    }

    @Override public String toString() {
        return getClass().getName() + "[" + pathList + "]";
    }
}

複製程式碼

BaseDexClassLoader類的載入主要就是靠findClass方法,看findClass(String name)方法的這一行:

Class c = pathList.findClass(name, suppressedExceptions);
複製程式碼

由此可知BaseDexClassLoader對於類的載入主要還是委託pathList的findClass()方法,這個pathList是個DexPathList型別。
看BaseDexClassLoader的構造方法:

public BaseDexClassLoader(String dexPath, File optimizedDirectory,String librarySearchPath, ClassLoader parent) {
    super(parent);
    this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
}
複製程式碼

BaseDexClassLoader的構造方法會傳入dexPath,這個是dex檔案的路徑,然後會根據dexPath建立一個DexPathList並賦值給pathList。

同樣的,我們看一下DexPathList檔案的原始碼(DexPathList原始碼很多,我們只貼出重要的部分):

/*package*/ final class DexPathList {

    /**
     * List of dex/resource (class path) elements.
     * Should be called pathElements, but the Facebook app uses reflection
     * to modify 'dexElements' (http://b/7726934).
     */
    private Element[] dexElements;

    /** List of native library path elements. */
    private final Element[] nativeLibraryPathElements;

    /** List of application native library directories. */
    private final List<File> nativeLibraryDirectories;

    /** List of system native library directories. */
    private final List<File> systemNativeLibraryDirectories;

    /**
     * Exceptions thrown during creation of the dexElements list.
     */
    private IOException[] dexElementsSuppressedExceptions;


    public DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory) {

        if (definingContext == null) {
            throw new NullPointerException("definingContext == null");
        }

        if (dexPath == null) {
            throw new NullPointerException("dexPath == null");
        }

        if (optimizedDirectory != null) {
            if (!optimizedDirectory.exists())  {
                throw new IllegalArgumentException(
                        "optimizedDirectory doesn't exist: "
                        + optimizedDirectory);
            }

            if (!(optimizedDirectory.canRead()
                            && optimizedDirectory.canWrite())) {
                throw new IllegalArgumentException(
                        "optimizedDirectory not readable/writable: "
                        + optimizedDirectory);
            }
        }

        this.definingContext = definingContext;

        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // save dexPath for BaseDexClassLoader
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext);

        this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
        this.systemNativeLibraryDirectories =
                splitPaths(System.getProperty("java.library.path"), true);
        List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
        allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);

        this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories,
                                                          suppressedExceptions,
                                                          definingContext);

        if (suppressedExceptions.size() > 0) {
            this.dexElementsSuppressedExceptions =
                suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
        } else {
            dexElementsSuppressedExceptions = null;
        }
    }


    public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

    //...

}
複製程式碼

重點來了,我們看DexPathList的findClass方法,它會從內部的dexElements陣列裡面遍歷Element去尋找這個類檔案。那麼這個dexElements是從哪裡來的呢?它是從DexPathList的構造方法裡面建立的,根據構造方法傳入的dexpath按一定的規則拿到路徑下的所有dex包,然後封裝成Element物件。那麼這個findClass方法就很好理解了,它會去遍歷dexElements,一個一個的找是否有要尋找的class,如果有就返回,沒有就繼續往下一個dex裡面找,如果所有的dex都沒找到這個類,就丟擲異常。

好了,到這裡也許大家都應該明白我們tinker是從哪裡入手做熱更新的吧!如果不明白接著往下看(~~)!

既然我們知道Android系統去載入一個類是按照一定的規則的(規則就是上面講的載入順序),那麼假如我當前app中有一個Test類的一個方法被呼叫會導致系統崩潰,我們想要利用類載入機制去修復它,應該怎樣去修復呢?

首先我們需要在程式碼裡把這個類的bug給修復,然後打出修復後的apk包,並把這個類放入修復後的apk的特定dex裡(注:把class放入特定的dex並做出這個拆分包是一項略微麻煩的操作,這裡我們只需要知道要把這個dex拿到去替換就行,同時tinker也給我們提供了工具),這樣我們就能拿到修復好的含有Test類的dex了,接著就是如何把修復好的dex包放到使用者手機上,讓classloader去載入修復好的dex了。把dex放入使用者手機這一步肯定需要一個放dex的伺服器,然後app啟動的時候根據版本去伺服器請求是否有dex,如果有就下載下來放入特定的目錄,然後apk下次啟動的時候就可以把修復好的dex插入dexElements陣列的前面,這樣應用程式通過PathClassLoader去載入類就會優先找到修復好的dex裡面的Test類,這樣bug就被修復了。

為了分析替換dex的核心原理,下載修復好的dex這個步驟我們就先略去,直接來看如何載入修復好的dex:

假設我們已經拿到修復好的dex,現在要做替換,那麼便先要建立一個classLoader去載入修復好的dex包:

//dex表示已經拿到修復好的dex檔案
File dex = context.getDir("dexpath", Context.MODE_PRIVATE);
String optimizeDir = dex.getAbsolutePath() + File.separator + "opt_dex";
File fopt = new File(optimizeDir);
//建立一個DexClassLoader去載入這個dex
DexClassLoader dexClassLoader = new DexClassLoader(dex.getAbsolutePath(), fopt.getAbsolutePath(), null, context.getClassLoader());
複製程式碼

然後我們還需要拿到系統的classLoader,通過反射獲取到它的dexElements,然後把dexClassLoader的dexElements插入系統classLoader的dexElements前面,這樣我們的系統再去找這個Test類,就會優先找到我們修復包裡面的Test類,便達到修復bug的目的。下面繼續看程式碼:

public void loadDex(Context context) {
    //dex表示已經拿到修復好的dex檔案
    File dex = context.getDir("dexpath", Context.MODE_PRIVATE);
    String optimizeDir = dex.getAbsolutePath() + File.separator + "opt_dex";
    File fopt = new File(optimizeDir);
    //建立一個DexClassLoader去載入這個dex
    DexClassLoader dexClassLoader = new DexClassLoader(dex.getAbsolutePath(), fopt.getAbsolutePath(), null, context.getClassLoader());
    //系統的classLoader
    PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();

    try {
        //1.先獲取到dexClassLoader裡面的DexPathList型別的pathList
        Class myDexClazzLoader=Class.forName("dalvik.system.BaseDexClassLoader");
        Field  myPathListFiled=myDexClazzLoader.getDeclaredField("pathList");
        myPathListFiled.setAccessible(true);
        Object myPathListObject =myPathListFiled.get(dexClassLoader);
        
        //2.通過DexPathList拿到dexElements物件
        Class  myPathClazz=myPathListObject.getClass();
        Field  myElementsField = myPathClazz.getDeclaredField("dexElements");
        myElementsField.setAccessible(true);
        Object myElements=myElementsField.get(myPathListObject);

        //3.拿到應用程式使用的類載入器的pathList
        Class baseDexClazzLoader=Class.forName("dalvik.system.BaseDexClassLoader");
        Field  pathListFiled=baseDexClazzLoader.getDeclaredField("pathList");
        pathListFiled.setAccessible(true);
        Object pathListObject = pathListFiled.get(pathClassLoader);
        
        //4.獲取到系統的dexElements物件
        Class  systemPathClazz=pathListObject.getClass();
        Field  systemElementsField = systemPathClazz.getDeclaredField("dexElements");
        systemElementsField.setAccessible(true);
        Object systemElements=systemElementsField.get(pathListObject);
        
        //5.新建一個Element[]型別的dexElements例項
        Class<?> sigleElementClazz = systemElements.getClass().getComponentType();
        int systemLength = Array.getLength(systemElements);
        int myLength = Array.getLength(myElements);
        int newSystenLength = systemLength + myLength;
        Object newElementsArray = Array.newInstance(sigleElementClazz, newSystenLength);
        
        //6.按著先加入dex包裡面elment的規律依次加入所有的element,這樣就可以保證classLoader先拿到的是修復包裡面的Test類。
        for (int i = 0; i < newSystenLength; i++) {
            if (i < myLength) {
                Array.set(newElementsArray, i, Array.get(myElements, i));
            }else {
                Array.set(newElementsArray, i, Array.get(systemElements, i - myLength));
            }
        }
        
        //7.將新的dexElements陣列放入系統的classLoader裡面。
        Field  elementsField=pathListObject.getClass().getDeclaredField("dexElements");
        elementsField.setAccessible(true);
        elementsField.set(pathListObject,newElementsArray);
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    }

}

複製程式碼

反射獲取類的變數相信大家如果有反射的知識,一定可以看懂了吧。根據註釋裡面的7個步驟,我們就可以完成把修復包裡面的Test類載入到dexElements的最前面。然後我們只需要在應用程式的程式啟動的時候呼叫這個方法,就可以實現載入Test類的時候載入的是修復包裡面的。程式碼如下:

public class MyAplication extends Application {

    @Override
    protected void attachBaseContext(Context base) {
        MultiDex.install(base);
        loadDex(base);
        super.attachBaseContext(base);
    }

    //...
    
}
複製程式碼

好了,分析到這裡我們應該都明白tinker熱修復的原理了!它的核心思想就是根據classLoader的載入機制在應用程式啟動的時候把修復好的dex包加在有bug的dex包的前面實現對有bug的類的替換。但是tinker整個框架遠遠不是這麼簡單,因為作為一個框架它要考慮的東西要複雜得多,如文章開頭提到的Android N混合編譯以及其他如dex的驗證機制還有針對Android各個版本的相容性問題等等。

備註

轉載請註明出處,如覺得作者寫的還不錯或想要需瞭解更多框架原始碼剖析,請前往github Android三方框架原始碼剖析,歡迎star!!!

參考

聯絡方式

  • email: xiasem@163.com

相關文章