天天看點

Android 熱修複技術中的CLASS_ISPREVERIFIED問題

一、前言

上一篇部落格中,我們通過介紹dex分包原理引出了Android的熱更新檔技術,而現在我們将解決兩個問題。 

1. 怎麼将修複後的Bug類打包成dex 

2. 怎麼将外部的dex插入到ClassLoader中

二、建立測試Demo

2.1 目錄結構

2.2 源碼

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context=".MainActivity">

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:onClick="click"
        android:text="小喵叫一聲"/>
</RelativeLayout>      

MainActivity.class

package com.aitsuki.bugfix;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Toast;
import com.aitsuki.bugfix.animal.Cat;

public class MainActivity extends AppCompatActivity {

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

    public void click(View view) {
        Toast.makeText(this, mCat.say(),Toast.LENGTH_SHORT).show();
    }
}      

Cat.class

package com.aitsuki.bugfix.animal;

/**
 * Created by AItsuki on 2016/3/14.
 */
public class Cat {
    public String say() {
        return "汪汪汪!";
    }
}      

2.3 運作結果

假設這是我們公司的開發項目,剛剛上線就發現了嚴重bug,貓會狗叫。 

想修複bug,讓使用者再立刻更新一次顯然很不友好,此時熱更新檔修複技術就有用了。

三、制作更新檔

在加載dex的代碼之前,我們先來制作更新檔。 

1. 首先我們将Cat類修複,汪汪汪改成喵喵喵,然後重新編譯項目。(Rebuild一下就行了) 

2. 去儲存項目的地方,将Cat.class檔案拷貝出來,在這裡 

3. 建立檔案夾,要和該Cat.class檔案的包名一緻,然後将Cat.class複制到這裡,如圖 

4. 指令行進入到圖中的test目錄,運作一下指令,打包更新檔。如圖: 

然後test目錄是這樣的 

patch_dex.jar就是我們打包好的更新檔了,我們将它放到sdCard中,待會從這裡加載更新檔。

關于什麼用這麼複雜的方法打包更新檔的說明: 

你也可以直接将java檔案拷出來,通過javac -d帶包編譯再轉成jar。 

但我這麼麻煩是有原因的,因為用這種方法你可能會遇到ParseException,原因是jar包版本和dx工具版本不一緻。 

而從項目中直接将編譯好的class直接轉成jar就沒問題,因為java會向下相容,打出來的jar包和class版本是一緻的。 

總而言之,dx版本要和class編譯版本對應。

四、加載更新檔

4.1 思路

通過上一篇博文,我們知道dex儲存在這個位置 

BaseDexClassLoader–>pathList–>dexElements

  1. apk的classes.dex可以從應用本身的DexClassLoader中擷取。
  2. path_dex的dex需要new一個DexClassLoader加載後再擷取。
  3. 分别通過反射取出dex檔案,重新合并成一個數組,然後指派給盈通本身的ClassLoader的dexElements

4.2 代碼實作

加載外部dex,我們可以在Application中操作。 

首先建立一個HotPatchApplication,然後在清單檔案中配置,順便加上讀取sdcard的權限,因為更新檔就儲存在那裡。

HotPatchApplication代碼如下:

package com.aitsuki.hotpatchdemo;

import android.app.Application;
import android.os.Environment;
import android.util.Log;
import java.io.File;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import dalvik.system.DexClassLoader;

/**
 * Created by hp on 2016/4/6.
 */
public class HotPatchApplication 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()) {
            inject(dexPath);
        } else {
            Log.e("BugFixApplication", dexPath + "不存在");
        }
    }

    /**
     * 要注入的dex的路徑
     *
     * @param path
     */
    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);

            //======== 以下是測試是否成功注入 =================
            Object object = getField(pathList.getClass(), "dexElements", pathList);
            int length = Array.getLength(object);
            Log.e("BugFixApplication", "length = " + length);

        } 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;
    }

}      

五、CLASS_ISPREVERIFIED

運作一下Demo,報以下錯誤。(AndroidStudio 2.0可能不會報錯,需要打包的時候才會出現錯誤,這是Instant run導緻的) 

dexElements的length = 2,看來我們的patch_dex已經成功添加進去了。 

但是從黃色框框和黃色框上面那一段log提示中可以看出,MainActivity引用了Cat,但是發現他們在不同的Dex中。

看到這裡可能就會問: 

為什麼之前那麼多項目都采用分包方案,但是卻不會出現這個錯誤呢? 

我在這裡總結了一個過程,想知道詳細分析過程的請看QQ空間開發團隊的原文。

  1. 在apk安裝的時候,虛拟機會将dex優化成odex後才拿去執行。在這個過程中會對所有class一個校驗。
  2. 校驗方式:假設A該類在它的static方法,private方法,構造函數,override方法中直接引用到B類。如果A類和B類在同一個dex中,那麼A類就會被打上CLASS_ISPREVERIFIED标記
  3. 被打上這個标記的類不能引用其他dex中的類,否則就會報圖中的錯誤
  4. 在我們的Demo中,MainActivity和Cat本身是在同一個dex中的,是以MainActivity被打上了CLASS_ISPREVERIFIED。而我們修複bug的時候卻引用了另外一個dex的Cat.class,是以這裡就報錯了
  5. 而普通分包方案則不會出現這個錯誤,因為引用和被引用的兩個類一開始就不在同一個dex中,是以校驗的時候并不會被打上CLASS_ISPREVERIFIED
  6. 補充一下第二條:A類如果還引用了一個C類,而C類在其他dex中,那麼A類并不會被打上标記。換句話說,隻要在static方法,構造方法,private方法,override方法中直接引用了其他dex中的類,那麼這個類就不會被打上CLASS_ISPREVERIFIED标記。

5.1 解決方案

根據上面的第六條,我們隻要讓所有類都引用其他dex中的某個類就可以了。

下面是QQ控件給出的解決方案 

  1. 在所有類的構造函數中插入這行代碼 

    System.out.println(AntilazyLoad.class);

    這樣當安裝apk的時候,classes.dex内的類都會引用一個在不相同dex中的AntilazyLoad類,這樣就防止了類被打上CLASS_ISPREVERIFIED的标志了,隻要沒被打上這個标志的類都可以進行打更新檔操作。
  2. hack.dex在應用啟動的時候就要先加載出來,不然AntilazyLoad類會被标記為不存在,即使後面再加載hack.dex,AntilazyLoad類還是會提示不存在。該類隻要一次找不到,那麼就會永遠被标上找不到的标記了。
  3. 我們一般在Application中執行dex的注入操作,是以在Application的構造中不能加上

    System.out.println(AntilazyLoad.class);

    這行代碼,因為此時hack.dex還沒有加載進來,AntilazyLoad并不存在。
  4. 之是以選擇構造函數是因為他不增加方法數,一個類即使沒有顯式的構造函數,也會有一個隐式的預設構造函數。

5.2 插入代碼的難點

  1. 首先在源碼中手動插入不太可行,hack.dex此時并沒有加載進來,AntilazyLoad.class并不存在,編譯不通過。
  2. 是以我們需要在源碼編譯成位元組碼之後,在位元組碼中進行插入操作。對位元組碼進行操作的架構有很多,但是比較常用的則是ASM和javaassist
  3. 但AndroidStudio是使用Gradle建構項目,編譯-打包都是自動化的。

六、寫在後面

其實整個熱更新檔技術最難的地方不是原理,不是注入dex,而是位元組碼的注入。 

這需要我們隊Gradle建構腳本,Groovy語言有一定的了解。其中的知識量實在是太過龐大,這裡推薦幾篇博文預習一下。 

Gradle學習系列之一——Gradle快速入門 

深入了解Android之Gradle——by 阿拉神農