Android的熱修復(fù)

周一發(fā)布了新版本,當(dāng)天晚上用戶就為app未測試到的bug發(fā)飆了嚷缭,恩供嚎,很快就找到了問題所在,一個容易疏忽的空指針峭状。雖然只是一個小小的bug但是不修復(fù)是很影響用戶體驗的啊克滴,如果要重新修復(fù)上線,波及范圍太廣了优床,所有用戶又要重新下載劝赔。
我們可以讓這個bug“偷偷”的修復(fù)

類加載的過程

類加載由ClassLoader的實現(xiàn)類完成。玩過反編譯的都知道胆敞,我們在解壓了apk之后着帽,最終會需要dex格式的文件來搞事杂伟,這個dex由class文件打包而成。那么安卓中仍翰,要加載dex文件中的class文件赫粥,需要用到DexClassLoader或者PathClassLoader

我們可以直接在AS中點開,但是卻無法正常查看予借,因為這些是系統(tǒng)級的源碼越平。我們可以選擇下載源碼,或者直接在AndroidXRef中找一找灵迫。

1. 先來看看類加載器

PathClassLoader 可以加載Android系統(tǒng)中的dex文件
DexClassLoader 可以加載任意目錄的dex/zip/apk/jar文件 , 但是要指定optimizedDirectory.
這兩個類加載器都繼承BaseDexClassLoader, 并且在構(gòu)造函數(shù)中, DexClassLoader多傳入了一個optimizedDirectory, 這一點先暫記一下

看一下BaseDexClassLoader的構(gòu)造方法:

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

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

構(gòu)造方法中初始化了pathList, 傳入三個參數(shù) , 分別為
dexPath:目標(biāo)文件路徑(一般是dex文件秦叛,也可以是jar/apk/zip文件)所在目錄。熱修復(fù)時用來指定新的dex
optimizedDirectory:dex文件的輸出目錄(因為在加載jar/apk/zip等壓縮格式的程序文件時會解壓出其中的dex文件瀑粥,該目錄就是專門用于存放這些被解壓出來的dex文件的)挣跋。
libraryPath:加載程序文件時需要用到的庫路徑。
parent:父加載器

2. 加載類的過程

在BaseDexClassLoader中 , 緊接著構(gòu)造函數(shù)的是一個叫findClass的方法 , 這個方法用來加載dex文件中對應(yīng)的class文件.

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    //從pathList中找到相應(yīng)類名的class文件
    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;
}

大體上不難理解, 拿到初始化完成的 pathList 之后 , 根據(jù)類名找出相應(yīng)的class字節(jié)碼文件, 如果沒有異常直接返回class.
接下來我們繼續(xù)跟進(jìn)pathList

3. DexPathList

DexPathList 源碼在這里
好了, 點開源碼不要慌 , 我們目前只需要知道兩個東西:

  1. 構(gòu)造函數(shù). 我們在BaseDexClassLoader中實例化DexPathList需要用到
  2. findClass方法, 在BaseDexClassLoader的findClass中, 本質(zhì)調(diào)用了DexpathList的fndClass方法.
    其他的方法姑且不用關(guān)心.

1->構(gòu)造函數(shù)

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

     this.definingContext = definingContext;

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

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

     this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories, null,
                                                       suppressedExceptions);
     
}

首先 , 將傳入的classLoader保存起來 , 接下來使用makePathElements方法 ,來初始化Element數(shù)組 .

那接下來無疑是分析makeDexElements()方法了狞换,因為這部分代碼比較長避咆,引用一下大神的分析:

private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) {
    // 1.創(chuàng)建Element集合
    ArrayList<Element> elements = new ArrayList<Element>();
    // 2.遍歷所有dex文件(也可能是jar、apk或zip文件)
    for (File file : files) {
        ZipFile zip = null;
        DexFile dex = null;
        String name = file.getName();
        ...
        // 如果是dex文件
        if (name.endsWith(DEX_SUFFIX)) {
            dex = loadDexFile(file, optimizedDirectory);

        // 如果是apk修噪、jar蝇摸、zip文件(這部分在不同的Android版本中拔稳,處理方式有細(xì)微差別)
        } else {
            zip = file;
            dex = loadDexFile(file, optimizedDirectory);
        }
        ...
        // 3.將dex文件或壓縮文件包裝成Element對象墨榄,并添加到Element集合中
        if ((zip != null) || (dex != null)) {
            elements.add(new Element(file, false, zip, dex));
        }
    }
    // 4.將Element集合轉(zhuǎn)成Element數(shù)組返回
    return elements.toArray(new Element[elements.size()]);
}

總體來說静浴,DexPathList的構(gòu)造函數(shù)是將一個個的目標(biāo)(可能是dex磷籍、apk适荣、jar、zip , 這些類型在一開始時就定義好了)封裝成一個個Element對象院领,最后添加到Element集合中弛矛。

其實,Android的類加載器(不管是PathClassLoader比然,還是DexClassLoader)丈氓,它們最后只認(rèn)dex文件,而loadDexFile()是加載dex文件的核心方法强法,可以從jar万俗、apk、zip中提取出dex饮怯,但這里先不分析了闰歪,因為第1個目標(biāo)已經(jīng)完成,等到后面再來分析吧蓖墅。

2->findClass方法

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的構(gòu)造函數(shù)中已經(jīng)初始化了dexElements库倘,所以這個方法就很好理解了临扮,只是對Element數(shù)組進(jìn)行遍歷,一旦找到類名與name相同的類時教翩,就直接返回這個class杆勇,找不到則返回null。

為什么是調(diào)用DexFile的loadClassBinaryName()方法來加載class饱亿?這是因為一個Element對象對應(yīng)一個dex文件蚜退,而一個dex文件則包含多個class。也就是說Element數(shù)組中存放的是一個個的dex文件路捧,而不是class文件9匕浴!杰扫!這可以從Element這個類的源碼和dex文件的內(nèi)部結(jié)構(gòu)看出队寇。

熱修復(fù)的實現(xiàn)方法

加載class會使用BaseDexClassLoader,在加載時章姓,會遍歷文件下的element佳遣,并從element中獲取dex文件
方案 ,class文件在dex里面 , 找到dex的方法是遍歷數(shù)組 , 那么熱修復(fù)的原理, 就是將改好bug的dex文件放進(jìn)集合的頭部, 這樣遍歷時會首先遍歷修復(fù)好的dex并找到修復(fù)好的類 . 這樣 , 我們就能在沒有發(fā)布新版本的情況下 , 修改現(xiàn)有的bug凡伊。雖然我們無法改變現(xiàn)有的dex文件零渐,但是遍歷的順序是從前往后的,在舊dex中的目標(biāo)class是沒有機(jī)會上場的系忙。

手?jǐn)]一個熱修復(fù)Demo

在了解了大致的熱修復(fù)過程之后诵盼,我們要準(zhǔn)備好以下幾個東西:

  1. 帶有bug的apk,并且可以獲取到dex文件來修復(fù)
  2. 已修復(fù)bug的dex文件

因為修復(fù)工作是需要隱秘的進(jìn)行的 , 畢竟有bug也不是什么光彩的事兒 , 所以我吧dex的插入操作放在Splash界面中. 在Splash時先檢測有沒有dex文件, 如果有則進(jìn)行插入 , 否則直接進(jìn)入MainActivity.
1->寫一個有bug的程序
哇, 是不是第一次見到這么爽的需求~
首先在MainActivty中寫一個bug出來:

public class BugTest {
    public void getBug(Context context) {
        //模擬一個bug
        int i = 10;
        int a = 0;
        Toast.makeText(context, "Hello,Minuit:" + i / a, Toast.LENGTH_SHORT).show();
    }
}


public class MainActivity extends AppCompatActivity {

    Button btnFix;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        init();
        new BugTest().getBug(MainActivity.this);
    }

    private void init() {
        btnFix = findViewById(R.id.btn_fix);
    }
}

運(yùn)行這段代碼必然會報錯的 , 但是我們要首先吧這段代碼裝到手機(jī)上 , 方便之后的修復(fù).

接下來編寫SplashActivity以及工具類 . 大家可以根據(jù)具體邏輯修改

/**
*@author Minuit
*@time 2018/6/25 0025 15:50
*/
public class FixDexUtil {

    private static final String DEX_SUFFIX = ".dex";
    private static final String APK_SUFFIX = ".apk";
    private static final String JAR_SUFFIX = ".jar";
    private static final String ZIP_SUFFIX = ".zip";
    public static final String DEX_DIR = "odex";
    private static final String OPTIMIZE_DEX_DIR = "optimize_dex";
    private static HashSet<File> loadedDex = new HashSet<>();

    static {
        loadedDex.clear();
    }

    /**
     * 加載補(bǔ)丁银还,使用默認(rèn)目錄:data/data/包名/files/odex
     *
     * @param context
     */
    public static void loadFixedDex(Context context) {
        loadFixedDex(context, null);
    }

    /**
     * 加載補(bǔ)丁
     *
     * @param context       上下文
     * @param patchFilesDir 補(bǔ)丁所在目錄
     */
    public static void loadFixedDex(Context context, File patchFilesDir) {
        // dex合并之前的dex
        doDexInject(context, loadedDex);
    }

    /**
    *@author Minuit
    *@time 2018/6/25 0025 15:51
    *@desc 驗證是否需要熱修復(fù)
    */
    public static boolean isGoingToFix(@NonNull Context context) {
        boolean canFix = false;
        File externalStorageDirectory = Environment.getExternalStorageDirectory();

        // 遍歷所有的修復(fù)dex , 因為可能是多個dex修復(fù)包
        File fileDir = externalStorageDirectory != null ?
                externalStorageDirectory :
                new File(context.getFilesDir(), DEX_DIR);// data/data/包名/files/odex(這個可以任意位置)

        File[] listFiles = fileDir.listFiles();
        for (File file : listFiles) {
            if (file.getName().startsWith("classes") &&
                    (file.getName().endsWith(DEX_SUFFIX)
                            || file.getName().endsWith(APK_SUFFIX)
                            || file.getName().endsWith(JAR_SUFFIX)
                            || file.getName().endsWith(ZIP_SUFFIX))) {

                loadedDex.add(file);// 存入集合
                //有目標(biāo)dex文件, 需要修復(fù)
                canFix = true;
            }
        }
        return canFix;
    }

    private static void doDexInject(Context appContext, HashSet<File> loadedDex) {
        String optimizeDir = appContext.getFilesDir().getAbsolutePath() +
                File.separator + OPTIMIZE_DEX_DIR;
        // data/data/包名/files/optimize_dex(這個必須是自己程序下的目錄)

        File fopt = new File(optimizeDir);
        if (!fopt.exists()) {
            fopt.mkdirs();
        }
        try {
            // 1.加載應(yīng)用程序dex的Loader
            PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
            for (File dex : loadedDex) {
                // 2.加載指定的修復(fù)的dex文件的Loader
                DexClassLoader dexLoader = new DexClassLoader(
                        dex.getAbsolutePath(),// 修復(fù)好的dex(補(bǔ)斗缒)所在目錄
                        fopt.getAbsolutePath(),// 存放dex的解壓目錄(用于jar、zip蛹疯、apk格式的補(bǔ)督洳啤)
                        null,// 加載dex時需要的庫
                        pathLoader// 父類加載器
                );
                // 3.開始合并 
                // 合并的目標(biāo)是Element[],重新賦值它的值即可

                /**
                 * BaseDexClassLoader中有 變量: DexPathList pathList
                 * DexPathList中有 變量 Element[] dexElements
                 * 依次反射即可
                 */
                
                //3.1 準(zhǔn)備好pathList的引用
                Object dexPathList = getPathList(dexLoader);
                Object pathPathList = getPathList(pathLoader);
                //3.2 從pathList中反射出element集合 
                Object leftDexElements = getDexElements(dexPathList);
                Object rightDexElements = getDexElements(pathPathList);
                //3.3 合并兩個dex數(shù)組
                Object dexElements = combineArray(leftDexElements, rightDexElements);
                
                // 重寫給PathList里面的Element[] dexElements;賦值
                Object pathList = getPathList(pathLoader);// 一定要重新獲取,不要用pathPathList捺弦,會報錯
                setField(pathList, pathList.getClass(), "dexElements", dexElements);
            }
            Toast.makeText(appContext, "修復(fù)完成", Toast.LENGTH_SHORT).show();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 反射給對象中的屬性重新賦值
     */
    private static void setField(Object obj, Class<?> cl, String field, Object value) throws NoSuchFieldException, IllegalAccessException {
        Field declaredField = cl.getDeclaredField(field);
        declaredField.setAccessible(true);
        declaredField.set(obj, value);
    }

    /**
     * 反射得到對象中的屬性值
     */
    private static Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }


    /**
     * 反射得到類加載器中的pathList對象
     */
    private static Object getPathList(Object baseDexClassLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    /**
     * 反射得到pathList中的dexElements
     */
    private static Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException {
        return getField(pathList, pathList.getClass(), "dexElements");
    }

    /**
     * 數(shù)組合并
     */
    private static Object combineArray(Object arrayLhs, Object arrayRhs) {
        Class<?> clazz = arrayLhs.getClass().getComponentType();
        int i = Array.getLength(arrayLhs);// 得到左數(shù)組長度(補(bǔ)丁數(shù)組)
        int j = Array.getLength(arrayRhs);// 得到原dex數(shù)組長度
        int k = i + j;// 得到總數(shù)組長度(補(bǔ)丁數(shù)組+原dex數(shù)組)
        Object result = Array.newInstance(clazz, k);// 創(chuàng)建一個類型為clazz饮寞,長度為k的新數(shù)組
        System.arraycopy(arrayLhs, 0, result, 0, i);
        System.arraycopy(arrayRhs, 0, result, i, j);
        return result;
    }
}

接下來 , 我們在Splash中進(jìn)行檢測以及修復(fù)工作

        if (FixDexUtil.isGoingToFix(activity)) {
            FixDexUtil.loadFixedDex(activity, Environment.getExternalStorageDirectory());
        }

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2000);
                    startActivity(new Intent(activity,MainActivity.class));
                    finish();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();

接下來 , 在As中一定一定要把instance run取消勾選,因為instance run用到的原理也是熱修復(fù)的原理列吼,也就是在重新運(yùn)行app時不會完整的安裝幽崩,只會安裝你修改過的代碼。


編譯運(yùn)行:



恩 寞钥, 接下來我們要修復(fù)bug慌申,并且將修復(fù)好的包放進(jìn)sd卡里面,這樣在Splash開始時就會自動遍歷到dex凑耻。

2->編寫修復(fù)好的dex
定位一下bug是出現(xiàn)在BugTest 中 , 所以我們首先修復(fù)bug

public void getBug(Context context) {
        int i = 10;
        int a = 1;
        Toast.makeText(context, "Hello,Minuit:" + i / a, Toast.LENGTH_SHORT).show();
    }

然后將class文件打包成dex文件
首先點擊Build->Rebuild Project 來重新構(gòu)建, 構(gòu)建完成之后, 可以在app / build / interintermediate / debug / 包名/ 找到你剛剛修改的class文件 , 將他拷貝出來



注意 , 拷貝出來要連同包名一起, 像這樣



因為在dex中的class文件是包名.類名的形式 , 所以我們在做dex文件時, 也要講相對應(yīng)的包名加上 . 這里反編譯一個demo作為例子:
反編譯class文件,挑出一個類

接下來就要生成dex文件了
要將class文件打包成dex文件太示,就需要用到dx指令柠贤,這個dx指令類似于java指令。dx指令也需要有程序來提供类缤,它就在Android SDK的build-tools目錄下各個Android版本目錄之中臼勉。


接下來使用指令來編譯, shift+右擊 打開命令行 , 輸入指令:
dx --dex --output c:\Users\Administrator\Desktop\dex\classes.dex c:\Users\Administrator\Desktop\dex
具體的語法大家手動dx --help自己看一下 , 輸入如下指令后 , 我桌面的dex文件下, 與剛剛拷貝的文件夾平級會出現(xiàn)一個classes.dex的文件


接下來將dex文件拷貝到sd卡下面 , 當(dāng)然如果是真實項目去下載的話 , 當(dāng)然是要下載到特定目錄了

至此, 在Splash界面的檢測時會見到到目標(biāo)的dex文件, 返回true , 會開始進(jìn)行熱修復(fù)(拼接Element數(shù)組)的操作, 再次進(jìn)入到主界面當(dāng)然就不會報錯了.
那么, 出錯的那個class去哪里了??? 它還在整個Elements的集合中的某一個dex中, 只不過沒有機(jī)會調(diào)用到了而已餐弱。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末宴霸,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子膏蚓,更是在濱河造成了極大的恐慌瓢谢,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,451評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件驮瞧,死亡現(xiàn)場離奇詭異氓扛,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)论笔,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,172評論 3 394
  • 文/潘曉璐 我一進(jìn)店門采郎,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人狂魔,你說我怎么就攤上這事蒜埋。” “怎么了最楷?”我有些...
    開封第一講書人閱讀 164,782評論 0 354
  • 文/不壞的土叔 我叫張陵整份,是天一觀的道長。 經(jīng)常有香客問我籽孙,道長烈评,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,709評論 1 294
  • 正文 為了忘掉前任蚯撩,我火速辦了婚禮础倍,結(jié)果婚禮上烛占,老公的妹妹穿的比我還像新娘胎挎。我一直安慰自己,他們只是感情好忆家,可當(dāng)我...
    茶點故事閱讀 67,733評論 6 392
  • 文/花漫 我一把揭開白布犹菇。 她就那樣靜靜地躺著,像睡著了一般芽卿。 火紅的嫁衣襯著肌膚如雪揭芍。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,578評論 1 305
  • 那天卸例,我揣著相機(jī)與錄音称杨,去河邊找鬼肌毅。 笑死,一個胖子當(dāng)著我的面吹牛姑原,可吹牛的內(nèi)容都是我干的悬而。 我是一名探鬼主播,決...
    沈念sama閱讀 40,320評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼锭汛,長吁一口氣:“原來是場噩夢啊……” “哼笨奠!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起唤殴,我...
    開封第一講書人閱讀 39,241評論 0 276
  • 序言:老撾萬榮一對情侶失蹤般婆,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后朵逝,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蔚袍,經(jīng)...
    沈念sama閱讀 45,686評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,878評論 3 336
  • 正文 我和宋清朗相戀三年配名,在試婚紗的時候發(fā)現(xiàn)自己被綠了页响。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,992評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡段誊,死狀恐怖闰蚕,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情连舍,我是刑警寧澤没陡,帶...
    沈念sama閱讀 35,715評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站索赏,受9級特大地震影響盼玄,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜潜腻,卻給世界環(huán)境...
    茶點故事閱讀 41,336評論 3 330
  • 文/蒙蒙 一埃儿、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧融涣,春花似錦童番、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,912評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至忽你,卻和暖如春幼东,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,040評論 1 270
  • 我被黑心中介騙來泰國打工根蟹, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留脓杉,地道東北人。 一個月前我還...
    沈念sama閱讀 48,173評論 3 370
  • 正文 我出身青樓简逮,卻偏偏與公主長得像丽已,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子买决,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,947評論 2 355

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