Android的热修复

周一发布了新版本,当天晚上用户就为app未测试到的bug发飙了,恩,很快就找到了问题所在,一个容易疏忽的空指针。虽然只是一个小小的bug但是不修复是很影响用户体验的啊,如果要重新修复上线,波及范围太广了,所有用户又要重新下载。
我们可以让这个bug“偷偷”的修复

类加载的过程

类加载由ClassLoader的实现类完成。玩过反编译的都知道,我们在解压了apk之后,最终会需要dex格式的文件来搞事,这个dex由class文件打包而成。那么安卓中,要加载dex文件中的class文件,需要用到DexClassLoader或者PathClassLoader

我们可以直接在AS中点开,但是却无法正常查看,因为这些是系统级的源码。我们可以选择下载源码,或者直接在AndroidXRef中找一找。

1. 先来看看类加载器

PathClassLoader 可以加载Android系统中的dex文件
DexClassLoader 可以加载任意目录的dex/zip/apk/jar文件 , 但是要指定optimizedDirectory.
这两个类加载器都继承BaseDexClassLoader, 并且在构造函数中, DexClassLoader多传入了一个optimizedDirectory, 这一点先暂记一下

看一下BaseDexClassLoader的构造方法:

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

构造方法中初始化了pathList, 传入三个参数 , 分别为
dexPath:目标文件路径(一般是dex文件,也可以是jar/apk/zip文件)所在目录。热修复时用来指定新的dex
optimizedDirectory:dex文件的输出目录(因为在加载jar/apk/zip等压缩格式的程序文件时会解压出其中的dex文件,该目录就是专门用于存放这些被解压出来的dex文件的)。
libraryPath:加载程序文件时需要用到的库路径。
parent:父加载器

2. 加载类的过程

在BaseDexClassLoader中 , 紧接着构造函数的是一个叫findClass的方法 , 这个方法用来加载dex文件中对应的class文件.

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    //从pathList中找到相应类名的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 之后 , 根据类名找出相应的class字节码文件, 如果没有异常直接返回class.
接下来我们继续跟进pathList

3. DexPathList

DexPathList 源码在这里
好了, 点开源码不要慌 , 我们目前只需要知道两个东西:

  1. 构造函数. 我们在BaseDexClassLoader中实例化DexPathList需要用到
  2. findClass方法, 在BaseDexClassLoader的findClass中, 本质调用了DexpathList的fndClass方法.
    其他的方法姑且不用关心.

1->构造函数

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数组 .

那接下来无疑是分析makeDexElements()方法了,因为这部分代码比较长,引用一下大神的分析:

private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) {
    // 1.创建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版本中,处理方式有细微差别)
        } 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集合转成Element数组返回
    return elements.toArray(new Element[elements.size()]);
}

总体来说,DexPathList的构造函数是将一个个的目标(可能是dex、apk、jar、zip , 这些类型在一开始时就定义好了)封装成一个个Element对象,最后添加到Element集合中。

其实,Android的类加载器(不管是PathClassLoader,还是DexClassLoader),它们最后只认dex文件,而loadDexFile()是加载dex文件的核心方法,可以从jar、apk、zip中提取出dex,但这里先不分析了,因为第1个目标已经完成,等到后面再来分析吧。

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的构造函数中已经初始化了dexElements,所以这个方法就很好理解了,只是对Element数组进行遍历,一旦找到类名与name相同的类时,就直接返回这个class,找不到则返回null。

为什么是调用DexFile的loadClassBinaryName()方法来加载class?这是因为一个Element对象对应一个dex文件,而一个dex文件则包含多个class。也就是说Element数组中存放的是一个个的dex文件,而不是class文件!!!这可以从Element这个类的源码和dex文件的内部结构看出。

热修复的实现方法

加载class会使用BaseDexClassLoader,在加载时,会遍历文件下的element,并从element中获取dex文件
方案 ,class文件在dex里面 , 找到dex的方法是遍历数组 , 那么热修复的原理, 就是将改好bug的dex文件放进集合的头部, 这样遍历时会首先遍历修复好的dex并找到修复好的类 . 这样 , 我们就能在没有发布新版本的情况下 , 修改现有的bug。虽然我们无法改变现有的dex文件,但是遍历的顺序是从前往后的,在旧dex中的目标class是没有机会上场的。

手撸一个热修复Demo

在了解了大致的热修复过程之后,我们要准备好以下几个东西:

  1. 带有bug的apk,并且可以获取到dex文件来修复
  2. 已修复bug的dex文件

因为修复工作是需要隐秘的进行的 , 毕竟有bug也不是什么光彩的事儿 , 所以我吧dex的插入操作放在Splash界面中. 在Splash时先检测有没有dex文件, 如果有则进行插入 , 否则直接进入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);
    }
}

运行这段代码必然会报错的 , 但是我们要首先吧这段代码装到手机上 , 方便之后的修复.

接下来编写SplashActivity以及工具类 . 大家可以根据具体逻辑修改

/**
*@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();
    }

    /**
     * 加载补丁,使用默认目录:data/data/包名/files/odex
     *
     * @param context
     */
    public static void loadFixedDex(Context context) {
        loadFixedDex(context, null);
    }

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

    /**
    *@author Minuit
    *@time 2018/6/25 0025 15:51
    *@desc 验证是否需要热修复
    */
    public static boolean isGoingToFix(@NonNull Context context) {
        boolean canFix = false;
        File externalStorageDirectory = Environment.getExternalStorageDirectory();

        // 遍历所有的修复dex , 因为可能是多个dex修复包
        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);// 存入集合
                //有目标dex文件, 需要修复
                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.加载应用程序dex的Loader
            PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
            for (File dex : loadedDex) {
                // 2.加载指定的修复的dex文件的Loader
                DexClassLoader dexLoader = new DexClassLoader(
                        dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
                        fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
                        null,// 加载dex时需要的库
                        pathLoader// 父类加载器
                );
                // 3.开始合并 
                // 合并的目标是Element[],重新赋值它的值即可

                /**
                 * BaseDexClassLoader中有 变量: DexPathList pathList
                 * DexPathList中有 变量 Element[] dexElements
                 * 依次反射即可
                 */
                
                //3.1 准备好pathList的引用
                Object dexPathList = getPathList(dexLoader);
                Object pathPathList = getPathList(pathLoader);
                //3.2 从pathList中反射出element集合 
                Object leftDexElements = getDexElements(dexPathList);
                Object rightDexElements = getDexElements(pathPathList);
                //3.3 合并两个dex数组
                Object dexElements = combineArray(leftDexElements, rightDexElements);
                
                // 重写给PathList里面的Element[] dexElements;赋值
                Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
                setField(pathList, pathList.getClass(), "dexElements", dexElements);
            }
            Toast.makeText(appContext, "修复完成", 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");
    }

    /**
     * 数组合并
     */
    private static Object combineArray(Object arrayLhs, Object arrayRhs) {
        Class<?> clazz = arrayLhs.getClass().getComponentType();
        int i = Array.getLength(arrayLhs);// 得到左数组长度(补丁数组)
        int j = Array.getLength(arrayRhs);// 得到原dex数组长度
        int k = i + j;// 得到总数组长度(补丁数组+原dex数组)
        Object result = Array.newInstance(clazz, k);// 创建一个类型为clazz,长度为k的新数组
        System.arraycopy(arrayLhs, 0, result, 0, i);
        System.arraycopy(arrayRhs, 0, result, i, j);
        return result;
    }
}

接下来 , 我们在Splash中进行检测以及修复工作

        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用到的原理也是热修复的原理,也就是在重新运行app时不会完整的安装,只会安装你修改过的代码。


编译运行:



恩 , 接下来我们要修复bug,并且将修复好的包放进sd卡里面,这样在Splash开始时就会自动遍历到dex。

2->编写修复好的dex
定位一下bug是出现在BugTest 中 , 所以我们首先修复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 来重新构建, 构建完成之后, 可以在app / build / interintermediate / debug / 包名/ 找到你刚刚修改的class文件 , 将他拷贝出来



注意 , 拷贝出来要连同包名一起, 像这样



因为在dex中的class文件是包名.类名的形式 , 所以我们在做dex文件时, 也要讲相对应的包名加上 . 这里反编译一个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文件下, 与刚刚拷贝的文件夹平级会出现一个classes.dex的文件


接下来将dex文件拷贝到sd卡下面 , 当然如果是真实项目去下载的话 , 当然是要下载到特定目录了

至此, 在Splash界面的检测时会见到到目标的dex文件, 返回true , 会开始进行热修复(拼接Element数组)的操作, 再次进入到主界面当然就不会报错了.
那么, 出错的那个class去哪里了??? 它还在整个Elements的集合中的某一个dex中, 只不过没有机会调用到了而已。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容