Android如何一步步手動實現(xiàn)熱修復

熱修復技術自從QQ空間團隊搞出來之后便漸漸趨于成熟。

我們這個系列主要介紹如何一步步手動實現(xiàn)基本的熱修復功能,無需使用第三方框架。

在開始學習之前,需要對基本的熱修復技術有些了解,以下文章可以幫助到你:

一、dex文件的生成與加載

我們在這部分主要做的流程有:

  • 1.編寫基本的Java文件并編譯為.class文件。
  • 2.將.class文件轉(zhuǎn)為.dex文件。
  • 3.將轉(zhuǎn)好的dex文件放入創(chuàng)建好的Android工程內(nèi)并在啟動時將其寫入本地。
  • 4.加載解壓后的.dex文件中的類,并調(diào)用其方法進行測試。

Note: 在閱讀本節(jié)之前最好先了解一下類加載器的雙親委派模型、DexClassLoader的使用以及反射的知識點。

編寫基本的Java文件并編譯為.class文件

首先我們在一個工程目錄下開始創(chuàng)建并編寫我們的Java文件,你可能會選擇各種IDE來做這件事,但我在這里勸你不要這么做,因為有坑在等你。等把基本流程搞清楚可以再選擇更進階的方法。這里我們可以選擇文本編輯器比如EditPlus來對Java文件進行編輯。

新建一個Java文件,并命名為:com.sahadev.bean.ClassStudent.java,并在java文件內(nèi)鍵入以下代碼

public class com.sahadev.bean.ClassStudent {
    private String name;

    public com.sahadev.bean.ClassStudent() {

    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName(){
        return this.name + ".Mr";   
    }
}

Note: 這里要注意,不要對類添加包名,因為在后期對class文件處理時會遇到問題,具體問題會稍后說明。上面的getName方法在返回時對this.name屬性添加了一段字符串,這里請注意,后面會用到。

在文件創(chuàng)建好之后,對Java文件進行編譯:


將.class文件轉(zhuǎn)為.dex文件

好,現(xiàn)在我們使用class文件生成對應的dex文件。生成dex文件所需要的工具為dx,dx工具位于sdk的build-tools文件夾內(nèi),如下圖所示:


Tips: 為了方便使用,建議將dx的路徑添加到環(huán)境變量中。如果對dx工具不熟悉的,可以在終端中輸入dx --help以獲取幫助。

dx工具的基本用法是:

dx --dex [--output=<file>] [<file>.class | <file>.{zip,jar,apk} | <directory>]

Tips: 剛開始自己摸索的時候,就沒有仔細看命令,導致后面兩個參數(shù)的順序顛倒了,搞出了一些讓人疑惑難解的問題,最后又不得不去找dx工具的源碼調(diào)試,最后才發(fā)現(xiàn)自己的問題在哪。如果有對dx工具感興趣的,可以對dx的包進行反編譯或者獲取dx的相關源代碼進行了解。dx.lib文件位于dx.bat的下級目錄lib文件夾中,可以使用JD-GUI工具對其進行查看或?qū)С觥H绻枰@取源代碼的,請使用以下命令進行克隆:

git clone https://android.googlesource.com/platform/dalvik

我們使用以下命令生成dex文件:

dx --dex --output=user.dex com.sahadev.bean.ClassStudent.class

這里我為了防止出錯,提前在當前目錄下新建好了user.dex文件。上述命令依賴編譯.class文件的JDK版本,如果使用的是JDK8編譯的class會提示以下問題:

PARSE ERROR:
unsupported class file version 52.0
...while parsing com.sahadev.bean.ClassStudent.class
1 error; aborting

這里的52.0意味著class文件不被支持,需要使用JDK8以下的版本進行編譯,但是dx所需的環(huán)境還是需要為JDK8的,這里我編譯class文件使用的是JDK7,請注意。

上面我們提到了為什么先不要在ClassStudent中使用包名,因為在執(zhí)行dx的時候會報以下異常,這是因為以下第二項條件沒有通過,該代碼位于com.android.dx.cf.direct.DirectClassFile文件內(nèi):

    String thisClassName = thisClass.getClassType().getClassName();
    if(!(filePath.endsWith(".class") && filePath.startsWith(thisClassName) && (filePath.length()==(thisClassName.length()+6)))){
        throw new ParseException("class name (" + thisClassName + ") does not match path (" + filePath + ")");
    }

運行截圖如下所示:


好了,到此為止我們的目錄應該如下:


寫入dex到本地磁盤

接下來將生成好的user.dex文件放入Android工程的res\raw文件夾下:


在系統(tǒng)啟動時將其寫入到磁盤,這里不再貼出具體的寫入代碼,項目的MainActivity中包含了此部分代碼。

加載dex中的類并測試

在寫入完畢之后使用DexClassLoader對其進行加載。DexClassLoader的構造方法需要4個參數(shù),這里對這4個參數(shù)進行簡要說明:

  • String dexPath:dex文件的絕對路徑。在這里我將其放入了應用的cache文件夾下。
  • String optimizedDirectory:優(yōu)化后的dex文件存放路徑。DexClassLoader在構造完畢之后會對原有的dex文件優(yōu)化并生成一個新的dex文件,在這里我選擇的是.../cache/optimizedDirectory/目錄。此外,API文檔對該目錄有嚴格的說明:Do not cache optimized classes on external storage.出于安全考慮,請不要將優(yōu)化后的dex文件放入外部存儲器中。
  • String libraryPath:dex文件所需要的庫文件路徑。這里沒有依賴,使用空字符串代替。
  • ClassLoader parent:雙親委派模型中提到的父類加載器。這里我們使用默認的加載器,通過getClassLoader()方法獲得。

在解釋完畢DexClassLoader的構造參數(shù)之后,我們開始對剛剛的dex文件進行加載:

DexClassLoader dexClassLoader = new DexClassLoader(apkPath, file.getParent() + "/optimizedDirectory/", "", classLoader);

接來下開始load我們剛剛寫入在dex文件中的ClassStudent類:

Class<?> aClass = dexClassLoader.loadClass("com.sahadev.bean.ClassStudent");

然后我們對其進行初始化,并調(diào)用相關的get/set方法對其進行驗證,在這里我傳給ClassStudent對象一個字符串,然后調(diào)用它的get方法獲取在方法內(nèi)合并后的字符串:

    Object instance = aClass.newInstance();
    Method method = aClass.getMethod("setName", String.class);
    method.invoke(instance, "Sahadev");
            
    Method getNameMethod = aClass.getMethod("getName");
    Object invoke = getNameMethod.invoke(instance););

最后我們實現(xiàn)的代碼可能是這樣的:

    /**
     * 加載指定路徑的dex
     *
     * @param apkPath
     */
    private void loadClass(String apkPath) {
        ClassLoader classLoader = getClassLoader();

        File file = new File(apkPath);

        try {

            DexClassLoader dexClassLoader = new DexClassLoader(apkPath, file.getParent() + "/optimizedDirectory/", "", classLoader);
            Class<?> aClass = dexClassLoader.loadClass("com.sahadev.bean.ClassStudent");
            mLog.i(TAG, "com.sahadev.bean.ClassStudent = " + aClass);

            Object instance = aClass.newInstance();
            Method method = aClass.getMethod("setName", String.class);
            method.invoke(instance, "Sahadev");

            Method getNameMethod = aClass.getMethod("getName");
            Object invoke = getNameMethod.invoke(instance);

            mLog.i(TAG, "invoke result = " + invoke);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

最后附上我們的運行截圖:


二、Class文件的替換

在完成基本外部類加載之后,我們這一節(jié)開始對工程內(nèi)部的類實行替換。

Tips: 本章主要依賴文章http://blog.csdn.net/vurtne_ye/article/details/39666381中的未實現(xiàn)代碼實現(xiàn),實現(xiàn)思路也源自該文章,在閱讀本文之前可以先行了解。

這一節(jié)我們主要實現(xiàn)的流程有:

  • 在類的內(nèi)部創(chuàng)建與外部dex相同的類文件,但在調(diào)用getName()方法返回字符串時會稍有區(qū)別,以便進行結果驗證
  • 使用DexClassLoader加載外部的user.dex
  • 將DexClassLoader中的dexElements放在PathClassLoader的dexElements之前
  • 驗證替換結果

因為上節(jié)課中專門聲明了不可以對類聲明包名,但是這樣在Android工程中無法引用沒有包名的類,所以把不能聲明包名的問題解決了一下。

上一節(jié)課主要遇到的問題是在編譯Java文件時沒有使用正當?shù)拿睢邪腏ava文件應當使用以下命令:

javac -d ./ ClassStudent.java

經(jīng)過上面命令編譯后的.class文件便可以順利通過dx工具的轉(zhuǎn)換。

我們還是按照第一節(jié)的步驟將轉(zhuǎn)換后的user.dex文件放入工程中并寫入本地磁盤,以便稍后使用。

在開始之前還是先說一下具體的實現(xiàn)思路:一個類在使用之前必須要經(jīng)過加載器的加載才能使用,在加載器加載類之前會調(diào)用自身的findClass()方法進行查找。然而在Android中類的查找使用的是BaseDexClassLoader,BaseDexClassLoader對findClass()方法進行了重寫:

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = pathList.findClass(name);

        if (clazz == null) {
            throw new ClassNotFoundException(name);
        }

        return clazz;
    }

pathList是類DexPathList的實例,這里pathList.findClass的實現(xiàn)如下:

    public Class findClass(String name) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext);
                if (clazz != null) {
                    return clazz;
                }
            }
        }

        return null;
    }

由此我們可以得知類的查找是通過遍歷dexElements來進行查找的。所以為了實現(xiàn)替換效果,我們需要將DexClassLoader中的Element對象放到dexElements數(shù)組的第0個位置,這樣才能在BaseDexClassLoader查找類時先找到DexClassLoader所用的user.dex中的類。

Tips: 如果對上面這句話看不懂的,沒關系,可以先了解一下類的加載機制與ClassLoader的雙親委派模型。

好了,有了基本的實現(xiàn)思路之后,我們接下來對思路進行實踐。

下面的方法是我們主要的注入方法:


    public String inject(String apkPath) {
        boolean hasBaseDexClassLoader = true;

        File file = new File(apkPath);
        try {
            Class.forName("dalvik.system.BaseDexClassLoader");
        } catch (ClassNotFoundException e) {
            hasBaseDexClassLoader = false;
        }
        if (hasBaseDexClassLoader) {
            PathClassLoader pathClassLoader = (PathClassLoader) getClassLoader();
            DexClassLoader dexClassLoader = new DexClassLoader(apkPath, file.getParent() + "/optimizedDirectory/", "", pathClassLoader);
            try {
                Object dexElements = combineArray(getDexElements(getPathList(pathClassLoader)), getDexElements(getPathList(dexClassLoader)));
                Object pathList = getPathList(pathClassLoader);
                setField(pathList, pathList.getClass(), "dexElements", dexElements);
                return "SUCCESS";
            } catch (Throwable e) {
                e.printStackTrace();
                return android.util.Log.getStackTraceString(e);
            }
        }
        return "SUCCESS";
    }

這段代碼原封不動采用于http://blog.csdn.net/vurtne_ye/article/details/39666381文章中最后的實現(xiàn)代碼,但是該文章并沒有給出具體的注入細節(jié)。我們接下里的過程就是對沒有給全的細節(jié)進行補充與講解。

這段代碼的核心在于將DexClassLoader中的dexElements與PathClassLoader中的dexElements進行合并,然后將合并后的dexElements替換原先的dexElements。最后我們在使用ClassStudent類的時候便可以直接使用外部的ClassStudent,而不會再使用默認的ClassStudent類。默認情況下會加載默認的ClassStudent類。

首先我們通過classLoader獲取各自的pathList對象:

    public Object getPathList(BaseDexClassLoader classLoader) {
        Class<? extends BaseDexClassLoader> aClass = classLoader.getClass();

        Class<?> superclass = aClass.getSuperclass();
        try {

            Field pathListField = superclass.getDeclaredField("pathList");
            pathListField.setAccessible(true);
            Object object = pathListField.get(classLoader);

            return object;
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }

在使用以上反射的時候要注意,pathList屬性屬于基類BaseDexClassLoader。所以如果直接對DexClassLoader或者PathClassLoader獲取pathList屬性的話,會得到null。

其次是獲取pathList對應的dexElements,這里要注意dexElements是個數(shù)組對象:

    public Object getDexElements(Object object) {
        if (object == null)
            return null;

        Class<?> aClass = object.getClass();
        try {
            Field dexElements = aClass.getDeclaredField("dexElements");
            dexElements.setAccessible(true);
            return dexElements.get(object);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;

    }

接下來我們將兩個數(shù)組對象合并成為一個:

    public Object combineArray(Object object, Object object2) {
        Class<?> aClass = Array.get(object, 0).getClass();

        Object obj = Array.newInstance(aClass, 2);

        Array.set(obj, 0, Array.get(object2, 0));
        Array.set(obj, 1, Array.get(object, 0));

        return obj;
    }

上面這段代碼我們根據(jù)數(shù)組對象的類型創(chuàng)建了一個新的大小為2的新數(shù)組,并將兩個數(shù)組的第一個元素取出,將dex中的dexElement放在了第0個位置。這樣可以確保在查找類時優(yōu)先從dex的dexElement中查找。

最后將原先的dexElements覆蓋:

    public void setField(Object pathList, Class aClass, String fieldName, Object fieldValue) {

        try {
            Field declaredField = aClass.getDeclaredField(fieldName);
            declaredField.setAccessible(true);
            declaredField.set(pathList, fieldValue);

        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

    }

運行驗證結果:

大功告成!

類的加載機制簡要介紹

一個類在被加載到內(nèi)存之前要經(jīng)過加載、驗證、準備等過程。經(jīng)過這些過程之后,虛擬機才會從方法區(qū)將代表類的運行時數(shù)據(jù)結構轉(zhuǎn)換為內(nèi)存中的Class。

我們這節(jié)內(nèi)容的重點在于一個類是如何被加載的,所以我們從類的加載入口開始。

類的加載是由虛擬機觸發(fā)的,類的加載入口位于ClassLoader的loadClassInternal()方法:

    // This method is invoked by the virtual machine to load a class.
    private Class<?> loadClassInternal(String name)
        throws ClassNotFoundException
    {
        // For backward compatibility, explicitly lock on 'this' when
        // the current class loader is not parallel capable.
        if (parallelLockMap == null) {
            synchronized (this) {
                 return loadClass(name);
            }
        } else {
            return loadClass(name);
        }
    }

這段方法還有段注釋說明:這個方法由虛擬機調(diào)用用來加載一個類。我們看到這個類的內(nèi)部最后調(diào)用了loadClass()方法。那我們進入loadClass()方法看看:

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

loadClass()方法方法內(nèi)部調(diào)用了loadClass()的重載方法:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

loadClass()方法大概做了以下工作:

  • 首先查找該類是否已經(jīng)被加載.

  • 如果該ClassLoader有父加載器,那么調(diào)用父加載器的loadClass()方法.

  • 如果沒有父加載器,則調(diào)用findBootstrapClassOrNull()方法進行加載,該方法會使用引導類加載器進行加載。普通類是不會被該加載器加載到的,所以這里一般返回null.

  • 如果前面的步驟都沒找到,那調(diào)用自身的findClass()方法進行查找。

好,ClassLoader的findClass()方法是個空方法,所以這個過程一般是由子加載器實現(xiàn)的。Java的加載器這么設計是有一定的淵源的,感興趣的讀者可以自行查找書籍了解。

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }

在Android中,ClassLoader的直接子類是BaseDexClassLoader,我們看一下BaseDexClassLoader的findClass()實現(xiàn):

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = pathList.findClass(name);

        if (clazz == null) {
            throw new ClassNotFoundException(name);
        }

        return clazz;
    }

Tips: 有需要虛擬機以及類加載器全套代碼的,請使用以下命令克隆:

git clone https://android.googlesource.com/platform/dalvik-snapshot

相關代碼位于項目的ics-mr1分支上。

看到這里我們可以知道,Android中類的查找是通過這個pathList進行查找的,而pathList又是個什么鬼呢?

在BaseDexClassLoader中聲明了以下變量:

    /** structured lists of path elements */
    private final DexPathList pathList;

所以我們可以看看DexPathList的findClass()方法做了什么:

    public Class findClass(String name) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext);
                if (clazz != null) {
                    return clazz;
                }
            }
        }

        return null;
    }

這里通過遍歷dexElements中的Element對象進行查找,最終走的是DexFile的loadClassBinaryName()方法:

    public Class loadClassBinaryName(String name, ClassLoader loader) {
        return defineClass(name, loader, mCookie);
    }

    private native static Class defineClass(String name, ClassLoader loader, int cookie);

到此為止,我們就將一個類真正的加載過程梳理完了。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內(nèi)容