Android热修复/热更新的简单实现-----dex插桩方案

原理

先说一下Java的主动引用,主要场景有:

1、遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,类的class如果没有被加载会先被ClassLoader加载到内存,然后才能创建对象,读取或设置静态字段,调用静态方法等。
2、反射,Class.forName()
3、子类初始化前会先初始化父类
4、包含main方法的类,虚拟机启动时会先初始化该类 
5、JNI的方式:JNI.findClass()

每个class要使用时,也就是主动引用时会先通过ClassLoader把字节码文件信息加载到内存,Android也是类似,它默认使用dalvik.system.PathClassLoader类来加载class(dex文件)的,Android系统还提供了DexClassLoader类,开发者可以使用它来手动加载class(dex文件),PathClassLoader和DexClassLoader都继承了dalvik.system.BaseDexClassLoader类。

通过查看源码得知,BaseDexClassLoader类在构造方法里会实例化一个成员变量:DexPathList pathList,并将对应的dex文件目录传给pathList成员变量。事实上查找并解析class文件的操作都是在DexPathList类里面进行。DexPathList通过调用makePathElements()方法将dex、zip以及目录文件封装成Element实例,并存放到成员变量DexPathList.Element[] dexElements里。每次BaseDexClassLoader类加载一个class时实际上就是通过调用DexPathList.findClass()方法,遍历成员变量DexPathList.Element[] dexElements,如果找到匹配的class那么就直接返回结果,也就是最先被找到的class会被直接使用,不管后面的dex文件里是否还有相同的class。

举个例子:app.apk里有两个dex文件分别是1.dex和2.dex,这两个dex文件都有一个相同com.app.A的class文件,那么BaseDexClassLoader在加载com.app.A的class时,假设1.dex文件对应的Element实例是成员变量数组dexElements的第一个元素,2.dex对应的Element实例是第二个元素,那么通常情况下2.dex里的com.app.A的class永远都不会被加载到。

因此要实现热修复或更新,我们可以把需要修复或更新的类编写好并编译成.class文件后,使用Android sdk提供的dx工具把这些.class文件打包成.dex文件,然后通过使用DexClassLoader手动加载把.dex文件加载进来,上文已提到BaseDexClassLoader在构造时会实例化一个DexPathList类型的成员变量,而这个成变量初始化时也会把.dex文件封装成Element类型并存入DexPathList.Element[] dexElements数组里,然后我们可以通过反射把这个数组和系统默认使用的PathClassLoader里的dexElements数组进行合并,然后把合并后的新数组重新赋值给系统默认使用的PathClassLoader里的成员变量dexElements,这样就可以实现热修复/更新了。

注意:这里要先把我们手动加载的dex生成的Element数组放到合并后的新数组的最前面!

 

使用Android提供的dx工具打dex文件的命令:

 dx --dex --output out.dex com/log/hotfixjava/Call.class

笔者电脑把dx所在的路径已经添加到系统环境变量,所以可以直接使用:dx命令
Mac电脑下该工具的默认路径:/Users/[用户名]/Library/Android/sdk/build-tools/28.0.2

 

注意事项

由于程序都是启动后,先去服务器下载补丁dex文件后,再插桩上去,这样会导致如果要修复或更新的类的class文件已经加载到内存了,那么就算重新插桩了dexElements数组,也不会加载到补丁dex文件里的class,因为ClassLoader加载类时都是会先从内存缓存里找,没有找到才会去dex文件里找。所以补丁打完后,需要重新启动app,并且每次启动都需要在Application初始化后重新打补丁。

由于懒得写服务器代码,这里直接把打好的补丁文件放到/assets目录下,模拟从服务器下载dex文件到手机里。

 

工程目录结构:

 

package com.log.hotfixjava;

import android.content.Context;
import android.text.TextUtils;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Field;

import dalvik.system.BaseDexClassLoader;
import dalvik.system.DexClassLoader;
import dalvik.system.PathClassLoader;

/**
 * 热修复管理类
 */
public class DexManager {

    public static final String TAG = "DexManager";

    /**
     * 补丁dex文件存储位置。官方建议放在app私有的目录下。
     *
     * @param context
     * @return
     */
    public static String getDexPath(Context context) {
        return context.getDir("out", Context.MODE_PRIVATE).getAbsolutePath() + File.separator;
    }

    /**
     * 补丁dex文件解压后的位置
     *
     * @param context
     * @return
     */
    public static String getOptimizedDirectory(Context context) {
        return context.getDir("dex", Context.MODE_PRIVATE).getAbsolutePath();
    }

    /**
     * 热修复辅助方法
     *
     * @param context
     */
    public static void fix(Context context) {
        String dexPath = getDexPath(context);
        File dexFile = new File(dexPath);
        if (!dexFile.exists()) {
            dexFile.mkdir();
        }
        // 查找存放dex补丁目录下所有的.dex文件
        File[] files = dexFile.listFiles(new FileFilter() {
            @Override
            public boolean accept(File pathname) {
                return pathname.getName().lastIndexOf(".dex") != -1;
            }
        });
        for (File file : files) {
            fix(context, file.getAbsolutePath(), getOptimizedDirectory(context));
        }
    }

    /**
     * 热修复
     *
     * @param context
     * @param dexPath
     * @param optimizedDirectory
     */
    private static void fix(Context context, String dexPath, String optimizedDirectory) {
        if (TextUtils.isEmpty(dexPath) || TextUtils.isEmpty(optimizedDirectory)) {
            throw new NullPointerException("The argument dexPath or optimizedDirectory is null");
        }

        try {
            // PathClassLoader的父类BaseDexClassLoader有一个私有的成员变量:DexPathList pathList
            // DexPathList类有一个私有的成员变量:Element[] dexElements,这个数组就是用来存放dex和resource文件实例的
            PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
            ElementWrapper dexElementsOld = getElements(pathClassLoader);

            // DexClassLoader的父类也是BaseDexClassLoader
            DexClassLoader dexClassLoader = new DexClassLoader(dexPath, optimizedDirectory, null, context.getClassLoader());
            // 获取补丁的dexElements数组实例
            ElementWrapper dexElementsNew = getElements(dexClassLoader);

            // 通过反射获取两个数组的值,并把补丁的dex文件加到数组的最前面
            // 因为BaseDexClassLoader里重写了findClass()方法,实际调用的是DexPathlist的findClass()方法,
            // 从源码可以知道,它查找class时会遍历dexElements数组,如果找到对应的class那么就返回,
            // 所以要把补丁dex加到数组前面才能保证在加载class时使用的是补丁文件里的class,从而实现热修复、热更新等功能

            // 通过java.lang.reflect.Array可以对一个Object实例(实际类型是数组)进行数组的操作
            // 因为Element[] dexElements的类型是android系统内部的类,不能显式的强制转换,不然开发工具会报找不到类的错
            int oldDexElementsLen = Array.getLength(dexElementsOld.dexElements);
            int newDexElementsLen = Array.getLength(dexElementsNew.dexElements);
            Object newElements = Array.newInstance(Array.get(dexElementsOld.dexElements, 0).getClass(), oldDexElementsLen + newDexElementsLen);
            // 合并新旧两个dexElements数组
            // 把补丁的dex文件加载数组的前面
            for (int i = 0; i < oldDexElementsLen; i++) {
                Array.set(newElements, i, Array.get(dexElementsNew.dexElements, i));
            }
            // 把旧的dex文件加载到数组的后面
            for (int i = 0; i < newDexElementsLen; i++) {
                Array.set(newElements, i + oldDexElementsLen, Array.get(dexElementsOld.dexElements, i));
            }
            // 把合并后的Element数组重新赋值给系统的DexPathList实例的成员变量:dexElements
            dexElementsOld.dexElementsField.set(dexElementsOld.dexPathList, newElements);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static ElementWrapper getElements(BaseDexClassLoader baseDexClassLoader) {
        ElementWrapper elementWrapper = null;
        try {
            Class<?> baseDexClassLoaderClass = baseDexClassLoader.getClass().getSuperclass();
            Field dexPathListField = baseDexClassLoaderClass.getDeclaredField("pathList");
            dexPathListField.setAccessible(true);
            // DexPathList的实例
            Object dexPathList = dexPathListField.get(baseDexClassLoader);
            // 获得DexPathList的Class,从而通过反射获得它的成员变量dexElements
            Class<?> dexPathListClass = dexPathListField.getType();
            Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
            dexElementsField.setAccessible(true);
            elementWrapper = new ElementWrapper();
            elementWrapper.dexPathList = dexPathList;
            elementWrapper.dexElementsField = dexElementsField;
            // 获取dexElements数组实例
            elementWrapper.dexElements = dexElementsField.get(dexPathList);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return elementWrapper;
    }

    private static class ElementWrapper {
        Object dexPathList;
        Object dexElements;
        Field dexElementsField;
    }

    /**
     * 模拟下载dex文件
     *
     * @param destPath
     */
    public static void downloadFile(InputStream inputStream, String destPath) {
        BufferedInputStream bis = null;
        BufferedOutputStream bos = null;
        try {
            File destFile = new File(destPath);
            if (destFile.exists()) {
                destFile.delete();
            }

            bis = new BufferedInputStream(inputStream);
            bos = new BufferedOutputStream(new FileOutputStream(destFile));
            byte[] buf = new byte[1024];
            int len;
            while ((len = bis.read(buf)) != -1) {
                bos.write(buf, 0, len);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (bis != null) {
                    bis.close();
                }
                if (bos != null) {
                    bos.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

}

 

需要修复的类:

package com.log.hotfixjava;

public class Call {

    public int divide() {
        // out.dex文件里把这里修复成:return 100 /10;
        return 100 / 0 ;
    }

}

 

Application类:

package com.log.hotfixjava;

import android.app.Application;
import android.content.Context;

public class App extends Application {

    // 这个被onCreate方法更早调用
    @Override
    protected void attachBaseContext(Context base) {
        /**
         * 每次都需要在Application启动的时候进行打补丁,不然bug依然会存在
         */
        DexManager.fix(base);
        super.attachBaseContext(base);
    }

}

测试的Activity:

package com.log.hotfixjava;

import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;

import java.io.IOException;

public class MainActivity extends AppCompatActivity {

    private TextView tv_result;
    private Handler handler = new Handler() {
        @Override
        public void handleMessage(@NonNull Message msg) {
            Toast.makeText(MainActivity.this, "补丁下载完毕", Toast.LENGTH_SHORT).show();
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tv_result = findViewById(R.id.tv_result);
    }

    public void onCall(View view) {
        Call call = new Call();
        tv_result.setText("" + call.divide());
    }

    public void onFix(View view) {
        DexManager.fix(this);
    }

    public void onDownload(View view) {
        new Thread() {
            @Override
            public void run() {
                try {
                    String dexPath = DexManager.getDexPath(MainActivity.this) + "out.dex";
                    DexManager.downloadFile(getAssets().open("out.dex"), dexPath);
                    handler.sendEmptyMessage(1);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }.start();
    }
}

布局文件:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center_horizontal"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_call"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="调用"
        android:onClick="onCall" />

    <Button
        android:id="@+id/btn_fix"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="修复"
        android:layout_toRightOf="@+id/btn_call"
        android:onClick="onFix" />

    <Button
        android:id="@+id/btn_download"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="下载dex文件"
        android:layout_toRightOf="@+id/btn_fix"
        android:onClick="onDownload" />

    <TextView
        android:id="@+id/tv_result"
        android:layout_below="@+id/btn_fix"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</RelativeLayout>

效果图:

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值