精华内容
参与话题
问答
  • 热修复——深入浅出原理与实现

    万次阅读 多人点赞 2017-11-14 19:28:43
    一、简述热修复无疑是这2年较火的新技术,是作为安卓工程师必学的技能之一。在热修复出现之前,一个已经上线的app中如果出现了bug,即使是一个非常小的bug,不及时更新的话有可能存在风险,若要及时更新就得将app...

    一、简述

    热修复无疑是这2年较火的新技术,是作为安卓工程师必学的技能之一。在热修复出现之前,一个已经上线的app中如果出现了bug,即使是一个非常小的bug,不及时更新的话有可能存在风险,若要及时更新就得将app重新打包发布到应用市场后,让用户再一次下载,这样就大大降低了用户体验,当热修复出现之后,这样的问题就不再是问题了。

    目前较火的热修复方案大致分为两派,分别是:

    1. 阿里系:DeXposed、andfix:从底层二进制入手(c语言)。
    2. 腾讯系:tinker:从java加载机制入手。

    本篇的主题并非讲述上面两种方案的使用,而是基于java加载机制,来研究热修复的原理与实现。(类似tinker,当然tinker没这么简单)

    二、Android中如何动态修复bug

    关于bug的概念自己百度百科吧,我认为的bug一般有2种(可能不太准确):

    • 代码功能不符合项目预期,即代码逻辑有问题。
    • 程序代码不够健壮导致App运行时崩溃。

    这两种情况一般是一个或多个class出现了问题,在一个理想的状态下,我们只需将修复好的这些个class更新到用户手机上的app中就可以修复这些bug了。但说着简单,要怎么才能动态更新这些class呢?其实,不管是哪种热修复方案,肯定是如下几个步骤:

    1. 下发补丁(内含修复好的class)到用户手机,即让app从服务器上下载(网络传输)
    2. app通过**“某种方式”**,使补丁中的class被app调用(本地更新)

    这里的**“某种方式”**,对本篇而言,就是使用Android的类加载器,通过类加载器加载这些修复好的class,覆盖对应有问题的class,理论上就能修复bug了。所以,下面就先来了解和分析Android中的类加载器吧。

    三、Android中的类加载器

    Android跟java有很大的渊源,基于jvm的java应用是通过ClassLoader来加载应用中的class的,但我们知道Android对jvm优化过,使用的是dalvik,且class文件会被打包进一个dex文件中,底层虚拟机有所不同,那么它们的类加载器当然也是会有所区别,在Android中,要加载dex文件中的class文件就需要用到 PathClassLoaderDexClassLoader 这两个Android专用的类加载器。

    1、源码查看

    一般的源码在Android Studio中可以查到,但 PathClassLoaderDexClassLoader 的源码是属于系统级源码,所以无法在Android Studio中直接查看。不过,有两种方式可以在外部进行查看:第一种是通过下载Android镜像源码的方式进行查看,但一般镜像源码体积较大,不好下载,而且就只是为了看3、4个文件的源码动不动就下载3、4个g的源码,确实不太明智,所以我们一般采用第二种方式:到androidxref.com这个网站上直接查看,下面会列出之后要分析的几个类的源码地址,供看客们方便浏览。

    以下是Android 5.0中的部分源码:

    2、PathClassLoader与DexClassLoader的区别

    1)使用场景

    • PathClassLoader:只能加载已经安装到Android系统中的apk文件(/data/app目录),是Android默认使用的类加载器。
    • DexClassLoader:可以加载任意目录下的dex/jar/apk/zip文件,比PathClassLoader更灵活,是实现热修复的重点。

    2)代码差异

    因为PathClassLoader与DexClassLoader的源码都很简单,我就直接将它们的全部源码复制过来了:

    // PathClassLoader
    public class PathClassLoader extends BaseDexClassLoader {
        public PathClassLoader(String dexPath, ClassLoader parent) {
            super(dexPath, null, null, parent);
        }
    
        public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
            super(dexPath, null, librarySearchPath, parent);
        }
    }
    
    // DexClassLoader
    public class DexClassLoader extends BaseDexClassLoader {
        public DexClassLoader(String dexPath, String optimizedDirectory,
                String librarySearchPath, ClassLoader parent) {
            super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
        }
    }
    

    通过比对,可以得出2个结论:

    • PathClassLoader与DexClassLoader都继承于BaseDexClassLoader。
    • PathClassLoader与DexClassLoader在构造函数中都调用了父类的构造函数,但DexClassLoader多传了一个optimizedDirectory。

    3、BaseDexClassLoader

    通过观察PathClassLoader与DexClassLoader的源码我们就可以确定,真正有意义的处理逻辑肯定在BaseDexClassLoader中,所以下面着重分析BaseDexClassLoader源码。

    1)构造函数

    先来看看BaseDexClassLoader的构造函数都做了什么:

    public class BaseDexClassLoader extends ClassLoader {
    	...
    	public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent){
            super(parent);
            this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
        }
    	...
    }
    
    • dexPath:要加载的程序文件(一般是dex文件,也可以是jar/apk/zip文件)所在目录。
    • optimizedDirectory:dex文件的输出目录(因为在加载jar/apk/zip等压缩格式的程序文件时会解压出其中的dex文件,该目录就是专门用于存放这些被解压出来的dex文件的)。
    • libraryPath:加载程序文件时需要用到的库路径。
    • parent:父加载器

    ***tip:**上面说到的"程序文件"这个概念是我自己定义的,因为从一个完整App的角度来说,程序文件指定的就是apk包中的classes.dex文件;但从热修复的角度来看,程序文件指的是补丁。

    因为PathClassLoader只会加载已安装包中的dex文件,而DexClassLoader不仅仅可以加载dex文件,还可以加载jar、apk、zip文件中的dex,我们知道jar、apk、zip其实就是一些压缩格式,要拿到压缩包里面的dex文件就需要解压,所以,DexClassLoader在调用父类构造函数时会指定一个解压的目录。

    不过,从Android 8.0开始,BaseDexClassLoader的构造函数逻辑发生了变化,optimizedDirectory过时,不再生效,详情可查看Android 8.0的BaseDexClassLoader.java源码

    2)获取class

    类加载器肯定会提供有一个方法来供外界找到它所加载到的class,该方法就是findClass(),不过在PathClassLoader和DexClassLoader源码中都没有重写父类的findClass()方法,但它们的父类BaseDexClassLoader就有重写findClass(),所以来看看BaseDexClassLoader的findClass()方法都做了哪些操作,代码如下:

    private final DexPathList pathList;
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    	// 实质是通过pathList的对象findClass()方法来获取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;
    }
    

    可以看到,BaseDexClassLoader的findClass()方法实际上是通过DexPathList对象(pathList)的findClass()方法来获取class的,而这个DexPathList对象恰好在之前的BaseDexClassLoader构造函数中就已经被创建好了。所以,下面就来看看DexPathList类中都做了什么。

    4、DexPathList

    在分析一个代码量较多的源码之前,我们要明确要从这段源码中要知道些什么?这样才不会在“码海”中迷失方向,我自己就定了2个小目标,分别是:

    • DexPathList的构造函数做了什么事?
    • DexPathList的findClass()方法是怎么获取class的?

    为什么是这2个目标?因为在BaseDexClassLoader的源码中主要就用到了DexPathList的构造函数和findClass()方法。

    1)构造函数

    private final Element[] dexElements;
    
    public DexPathList(ClassLoader definingContext, String dexPath,
            String libraryPath, File optimizedDirectory) {
        ...
    	this.definingContext = definingContext;
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,suppressedExceptions);
        ...
    }
    

    这个构造函数中,保存了当前的类加载器definingContext,并调用了makeDexElements()得到Element集合。

    通过对splitDexPath(dexPath)的源码追溯,发现该方法的作用其实就是将dexPath目录下的所有程序文件转变成一个File集合。而且还发现,dexPath是一个用冒号(":")作为分隔符把多个程序文件目录拼接起来的字符串(如:/data/dexdir1:/data/dexdir2:…)。

    那接下来无疑是分析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()

    再来看DexPathList的findClass()方法:

    public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
    		// 遍历出一个dex文件
            DexFile dex = element.dexFile;
    
            if (dex != null) {
    			// 在dex文件中查找类名与name相同的类
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }
    

    结合DexPathList的构造函数,其实DexPathList的findClass()方法很简单,就只是对Element数组进行遍历,一旦找到类名与name相同的类时,就直接返回这个class,找不到则返回null。

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

    四、热修复的实现原理

      终于进入主题了,经过对PathClassLoader、DexClassLoader、BaseDexClassLoader、DexPathList的分析,我们知道,安卓的类加载器在加载一个类时会先从自身DexPathList对象中的Element数组中获取(Element[] dexElements)到对应的类,之后再加载。采用的是数组遍历的方式,不过注意,遍历出来的是一个个的dex文件。

      在for循环中,首先遍历出来的是dex文件,然后再是从dex文件中获取class,所以,我们只要让修复好的class打包成一个dex文件,放于Element数组的第一个元素,这样就能保证获取到的class是最新修复好的class了(当然,有bug的class也是存在的,不过是放在了Element数组的最后一个元素中,所以没有机会被拿到而已)。

    五、热修复的简单实现

    通过前面的一堆理论之后,是时候实践一把了。

    1、得到dex格式补丁

    1)修复好有问题的java文件

    这一步根据bug的实际情况修改代码即可。

    2)将java文件编译成class文件

    在修复bug之后,可以使用Android Studio的Rebuild Project功能将代码进行编译,然后从build目录下找到对应的class文件。

    将修复好的class文件复制到其他地方,例如桌面上的dex文件夹中。需要注意的是,在复制这个class文件时,需要把它所在的完整包目录一起复制。假设上图中修复好的class文件是SimpleHotFixBugTest.class,则到时复制出来的目录结构是:

    3)将class文件打包成dex文件

    a. dx指令程序

    要将class文件打包成dex文件,就需要用到dx指令,这个dx指令类似于java指令。我们知道,java的指令有javac、jar等等,之所以可以使用这类指令,是因为我们有安装过jdk,jdk为我们提供了java指令,相同的,dx指令也需要有程序来提供,它就在Android SDK的build-tools目录下各个Android版本目录之中。

    b. dx指令的使用

    dx指令的使用跟java指令的使用条件一样,有2种选择:

    • 配置环境变量(添加到classpath),然后命令行窗口(终端)可以在任意位置使用。
    • 不配环境变量,直接在build-tools/安卓版本 目录下使用命令行窗口(终端)使用。

    第一种方式参考java环境变量配置即可,这里我选用第二种方式。下面我们需要用到的命令是:

    dx --dex --output=dex文件完整路径 (空格) 要打包的完整class文件所在目录,如:

    dx --dex --output=C:\Users\Administrator\Desktop\dex\classes2.dex C:\Users\Administrator\Desktop\dex

    具体操作看下图:

    在文件夹目录的空白处,按住shift+鼠标右击,可出现“在此处打开命令行窗口”。

    2、加载dex格式补丁

    根据原理,可以做一个简单的工具类:

    /**
     * @创建者 CSDN_LQR
     * @描述 热修复工具(只认后缀是dex、apk、jar、zip的补丁)
     */
    public class FixDexUtils {
    
        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) {
            if (context == null) {
                return;
            }
            // 遍历所有的修复dex
            File fileDir = patchFilesDir != null ? patchFilesDir : 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合并之前的dex
            doDexInject(context, loadedDex);
        }
    
        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
                PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
                for (File dex : loadedDex) {
                    // 2.加载指定的修复的dex文件
                    DexClassLoader dexLoader = new DexClassLoader(
                            dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
                            fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
                            null,// 加载dex时需要的库
                            pathLoader// 父类加载器
                    );
                    // 3.合并
                    Object dexPathList = getPathList(dexLoader);
                    Object pathPathList = getPathList(pathLoader);
                    Object leftDexElements = getDexElements(dexPathList);
                    Object rightDexElements = getDexElements(pathPathList);
                    // 合并完成
                    Object dexElements = combineArray(leftDexElements, rightDexElements);
                    // 重写给PathList里面的Element[] dexElements;赋值
                    Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
                    setField(pathList, pathList.getClass(), "dexElements", dexElements);
                }
            } 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<?> componentType = arrayLhs.getClass().getComponentType();
            int i = Array.getLength(arrayLhs);// 得到左数组长度(补丁数组)
            int j = Array.getLength(arrayRhs);// 得到原dex数组长度
            int k = i + j;// 得到总数组长度(补丁数组+原dex数组)
            Object result = Array.newInstance(componentType, k);// 创建一个类型为componentType,长度为k的新数组
            System.arraycopy(arrayLhs, 0, result, 0, i);
            System.arraycopy(arrayRhs, 0, result, i, j);
            return result;
        }
    }
    

    代码虽然较长,但注释写得很清楚,请仔细看,这里要说两点:

    1)Class ref in pre-verified class resolved to unexpected implementation

    经反馈,这个是大家遇到的最多的一个问题,这里我把注意事项和我的解决方法写清楚:

    a.FixDexUtils

    // 合并完成
    Object dexElements = combineArray(leftDexElements, rightDexElements);
    // 重写给PathList里面的Element[] dexElements;赋值
    Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
    setField(pathList, pathList.getClass(), "dexElements", dexElements);
    

    在合并守Element数组后,一定要再重新获取一遍App中的原有的pathList,不要复用前面的pathPathList,绝对会报错(Class ref in pre-verified class resolved to unexpected implementation)。

    b.Instant Run

    Android Studio的Instant Run功能也是用到了热修复的原理,在重新安装app时并不会完整安装,只会动态修改有更新的class部分,它会影响到测试结果,在跟着本文做试验的同学请确保Instant Run已经关闭。

    c.模拟器

    我在测试的过程中,使用的是Genymotion,发现Android 4.4的模拟器一直无法打上补丁,但是Android 5.0的模拟器却可以,真机测试也没问题,所以建议不要使用Android 5.0以下的模拟器来测试,强烈建议用真机测试!!

    2)dexPath与optimizedDirectory的目录问题

    DexClassLoader dexLoader = new DexClassLoader(
            dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
            fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
            null,// 加载dex时需要的库
            pathLoader// 父类加载器
    

    上面的代码是创建一个DexClassLoader对象,其中第1个和第2个参数有个细节需要注意:

    • 参数1是dexPath,指的是补丁所有目录,可以是多个目录(用冒号拼接),而且可以是任意目录,比如说SD卡。
    • 参数2是optimizedDirectory,就是存放从压缩包时解压出来的dex文件的目录,但不能是任意目录,它必须是程序所属的目录才行,比如:data/data/包名/xxx。

    如果你把optimizedDirectory指定成SD卡目录,则会报如下错误:

    java.lang.IllegalArgumentException: Optimized data directory /storage/emulated/0/opt_dex is not owned by the current user. Shared storage cannot protect your application from code injection attacks.

    意思是说SD卡目录不属于当前用户。此外,这里再校正之前的一个小问题,optimizedDirectory不仅仅存放从压缩包出来的dex文件,如果补丁文件就是一个dex文件,那么它也会将这个补丁文件复制到optimizedDirectory目录下。

    3、加载jar、apk、zip格式补丁

    前面已经说了很多次DexClassLoader可以加载jar、apk、zip格式补丁文件了,那这类格式的补丁文件有什么要求吗?

    答案是:这类压缩包中必须放着一个dex文件,而且对名字有要求,必须是classes.dex。Why?这就需要分析DexPathList类中的loadDexFile()方法了。

    private static DexFile loadDexFile(File file, File optimizedDirectory)
            throws IOException {
    	// 如果optimizedDirectory为null,其实就是PathClassLoader加载dex文件的处理方式
        if (optimizedDirectory == null) {
            return new DexFile(file);
        } 
    	// 如果optimizedDirectory不是null,这就是DexClassLoader加载dex文件的处理方式了,重点看这个
    	else {
            String optimizedPath = optimizedPathFor(file, optimizedDirectory);
            return DexFile.loadDex(file.getPath(), optimizedPath, 0);
        }
    }
    

    参数一file,可能是dex文件,也可能是jar、apk、zip文件。

    从上面的源码中,不难看出else分支才是DexClassLoader加载dex文件的处理方式,它调用的是optimizedPathFor()方法拿到之后dex文件在optimizedDirectory目录下的全路径:

    private static String optimizedPathFor(File path, File optimizedDirectory) {
        String fileName = path.getName();
        if (!fileName.endsWith(DEX_SUFFIX)) {
            int lastDot = fileName.lastIndexOf(".");
    		// 如果补丁没有后缀,就给它加一个".dex"后缀
            if (lastDot < 0) {
                fileName += DEX_SUFFIX;
            } 
    		// 不管补丁后缀是dex、jar、apk还是zip,最终放到optimizedDirectory目录下的一定是dex文件
    		else {
                StringBuilder sb = new StringBuilder(lastDot + 4);
                sb.append(fileName, 0, lastDot);
                sb.append(DEX_SUFFIX);
                fileName = sb.toString();
            }
        }
    
        File result = new File(optimizedDirectory, fileName);
        return result.getPath();
    }
    

    前面已经说过了,Android的类加载器最终只认dex文件,即使补丁是jar、apk、zip等压缩文件,它也会把其中的dex文件解压出来,所以该方法得到的文件名一定是以dex结尾的。好了,这个optimizedPathFor()方法并不是重点,回头看loadDexFile()中的else分支还有一个DexFile.loadDex()方法,这个方法就相当重要了。

    static public DexFile loadDex(String sourcePathName, String outputPathName, int flags) throws IOException {
        return new DexFile(sourcePathName, outputPathName, flags);
    }
    

    这个方法中就调用了一下自己的构造函数,并传入各个参数,接着来看看DexFile的构造函数:

    /**
     * Open a DEX file, specifying the file in which the optimized DEX
     * data should be written.  If the optimized form exists and appears
     * to be current, it will be used; if not, the VM will attempt to
     * regenerate it.
     *
     * This is intended for use by applications that wish to download
     * and execute DEX files outside the usual application installation
     * mechanism.  This function should not be called directly by an
     * application; instead, use a class loader such as
     * dalvik.system.DexClassLoader.
     *
     * @param sourcePathName
     *  Jar or APK file with "classes.dex".  (May expand this to include
     *  "raw DEX" in the future.)
     * @param outputPathName
     *  File that will hold the optimized form of the DEX data.
     * @param flags
     *  Enable optional features.  (Currently none defined.)
     * @return
     *  A new or previously-opened DexFile.
     * @throws IOException
     *  If unable to open the source or output file.
     */
    private DexFile(String sourceName, String outputName, int flags) throws IOException {
        if (outputName != null) {
            try {
                String parent = new File(outputName).getParent();
                if (Libcore.os.getuid() != Libcore.os.stat(parent).st_uid) {
                    throw new IllegalArgumentException("Optimized data directory " + parent
                            + " is not owned by the current user. Shared storage cannot protect"
                            + " your application from code injection attacks.");
                }
            } catch (ErrnoException ignored) {
                // assume we'll fail with a more contextual error later
            }
        }
    
        mCookie = openDexFile(sourceName, outputName, flags);
        mFileName = sourceName;
        guard.open("close");
        //System.out.println("DEX FILE cookie is " + mCookie + " sourceName=" + sourceName + " outputName=" + outputName);
    }
    

    奇怪吗,这次我没有把构造函数的注释去掉,原因是在它的注释中就已经有我们想要的答案了:

    @param sourcePathName Jar or APK file with "classes.dex".  (May expand this to include "raw DEX" in the future.)
    

    这名注释的意思就是说,jar或apk格式的补丁文件中需要有一个classes.dex。至此,对于压缩格式的补丁文件的要求就弄明白了。那么接下来就只需要生成这几种格式的补丁试一试就好了。制作这类压缩文件也很简单,直接用压缩软件压缩成zip文件,然后改下后缀就可以。

    六、测试

    这部分其实本不想写的,因为比较简单,但想了想不写又觉得不完整,那接下来就来测试一波吧。

    1、代码

    1)Activity

    布局文件就俩按钮,很简单就不贴布局文件代码了,看这两个按钮的点击事件就行。

    public class SimpleHotFixActivity extends AppCompatActivity {
    
        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_simple_hot_fix);
        }
    
    	// “修复”按钮的点击事件
        public void fix(View view) {
            FixDexUtils.loadFixedDex(this, Environment.getExternalStorageDirectory());
        }
    
    	// “计算”按钮的点击事件
        public void clac(View view) {
            SimpleHotFixBugTest test = new SimpleHotFixBugTest();
            test.getBug(this);
        }
    }
    

    可以看到,“修复”按钮的点击事件是去加载SD卡目录下的补丁文件。

    2)SimpleHotFixBugTest

    public class SimpleHotFixBugTest {
        public void getBug(Context context) {
            int i = 10;
            int a = 0;
            Toast.makeText(context, "Hello,I am CSDN_LQR:" + i / a, Toast.LENGTH_SHORT).show();
        }
    }
    

    会发生什么事呢?除数是0异常,一个简单的运行时异常,修复它也很简单,把a的值改为非0即可。

    2、演示

    1、bug

    不多说,看操作。

    妥妥的ArithmeticException。

    Caused by: java.lang.ArithmeticException: divide by zero

    2、动态修复bug

    首先,我将补丁文件classes2.dex放到手机的SD目录下。

    然后先点击修复按钮,再点计算按钮。

    大功告成,压缩格式的补丁跟dex格式的补丁一样,直接丢掉SD卡目录下就行了,但一定要注意,压缩格式的补丁中的文件一定是classes.dex!!!

    最后贴下Demo地址

    https://github.com/GitLqr/HotFixDemo

    权限申请:本文的提供的Demo是读取SD卡下的补丁文件,但却没有为Android6.0以上适配动态权限申请,如果你有使用该demo进行测试,那要注意自己测试机的Android版本,若是6.0以上,请务必先为Demo分配SD卡读写操作权限,否则App崩溃都不知道是不是因为bug造成的 ,切记。

    欢迎关注微信公众号:全栈行动

    展开全文
  • 浅谈Android热修复

    万次阅读 2016-07-28 10:13:19
    于是就有了热修复这个概念的产生!它可以在不发布版本的情况下修复出bug的代码。我们来一探究竟。 目前可能用的相对广泛的热修复框架有如下几个: https://github.com/dodola/HotFix https://github.com/jasonro

    前言:

    很多时候测试完的产品上线后,突然发现一个小的bug。这时候考虑到用户体验、和时间成本,不能为了一点点bug而重新发布新版本。于是就有了热修复这个概念的产生!它可以在不发布版本的情况下修复出bug的代码。我们来一探究竟。

    目前可能用的相对广泛的热修复框架有如下几个:
    https://github.com/dodola/HotFix
    https://github.com/jasonross/Nuwa
    https://github.com/bunnyblue/DroidFix
    https://github.com/alibaba/AndFix

    看完他们的简介,基本可以了解到,他们都是基于:动态修复技术Android Dex分包方案,这两篇文章的原理,所以在使用前,是完全有必要去阅读这两篇文章的。

    今天我就以阿里巴巴的热修复框架(AndFix),来做个简单的演示:
    为什么会选择这个来做演示?是因为看了git上的更新记录,其他的几个要么是用起来比较复杂、要么就是N久已经没有更新了,使用起来不安全。

    1.使用方法:

    添加依赖库是必要的:

    AndroidStudio:

    compile 'com.alipay.euler:andfix:0.3.1@aar'

    Eclipse直接导入AndFix工程项目即可:下载AndFix

    然后在Application.onCreate() 中添加以下代码

    patchManager = new PatchManager(context);
    patchManager.init(appversion);//current version
    patchManager.loadPatch();
    可以用这句话获取appversion
    String appversion= getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
    注意每次appversion变更都会导致所有补丁被删除,如果appversion没有改变,则会加载已经保存的所有补丁。
    然后在需要的地方调用PatchManager的addPatch方法加载新补丁,比如可以在下载补丁文件之后调用。
    之后就是打补丁的过程了,首先生成一个apk文件,然后更改代码,在修复bug后生成另一个apk。
    通过官方提供的工具apkpatch
    生成一个.apatch格式的补丁文件,需要提供原apk,修复后的apk,以及一个签名文件。
    可以直接使用命令apkpatch查看具体的使用方法。
    使用示例:
    生成差异文件命令 : apkpatch.bat -f new.apk -t old.apk -o output1 -k debug.keystore -p android -a androiddebugkey -e android
    -f <new.apk> :新版本
    -t <old.apk> : 旧版本
    -o <output> : 输出目录
    -k <keystore>: 打包所用的keystore
    -p <password>: keystore的密码
    -a <alias>: keystore 用户别名
    -e <alias password>: keystore 用户别名密码
    通过网络传输或者adb push的方式将apatch文件传到手机上,然后运行到addPatch的时候就会加载补丁。
    加载过的补丁会被保存到data/packagename/files/apatch_opt目录下,所以下载过来的补丁用过一次就可以删除了。

    2.原理

    apkpatch将两个apk做一次对比,然后找出不同的部分。可以看到生成的apatch了文件,后缀改成zip再解压开,里面有一个dex文件。通过jadx查看一下源码,里面就是被修复的代码所在的类文件,这些更改过的类都加上了一个_CF的后缀,并且变动的方法都被加上了一个叫@MethodReplace的annotation,通过clazz和method指定了需要替换的方法。
    然后客户端sdk得到补丁文件后就会根据annotation来寻找需要替换的方法。最后由JNI层完成方法的替换。


    3.AndFix涉及的安全性问题

    readme提示开发者需要验证下载过来的apatch文件的签名是否就是在使用apkpatch工具时使用的签名,如果不验证那么任何人都可以制作自己的apatch文件来对你的APP进行修改。
    但是我看到AndFix已经做了验证,如果补丁文件的证书和当前apk的证书不是同一个的话,就不能加载补丁。
    官网还有一条,提示需要验证optimize file的指纹,应该是为了防止有人替换掉本地保存的补丁文件,所以要验证MD5码,然而SecurityChecker类里面也已经做了这个工作。。但是这个MD5码是保存在sharedpreference里面,如果手机已经root那么还是可以被访问的。

    4.一些缺点
    不支持YunOS
    无法添加新类和新的字段
    需要使用加固前的apk制作补丁,但是补丁文件很容易被反编译,也就是修改过的类源码容易泄露。
    使用加固平台可能会使热补丁功能失效(演示样例时已经验证过)。


    接下来我们来看样例(我是在Eclipse写的这个样例):

    如图AndFix是上面链接下载的工程,AndFixDemo2是我自己的工程。

    依赖都整理好,就开始编写我们自己的工程代码:


    我写了三个类:

    AndFixSay是用来模拟出bug的类:

    public class AndFixSay {
    	public String say(){
    		return "I'm not fix";
    	} 
    }
    MainActivity是用来显示修复前后结果的:
    public class MainActivity extends ActionBarActivity {
    
    	static TextView tv_say_content;
    	@Override
    	protected void onCreate(Bundle savedInstanceState) {
    		super.onCreate(savedInstanceState);
    		setContentView(R.layout.activity_main);
    
    		if (savedInstanceState == null) {
    			getSupportFragmentManager().beginTransaction()
    					.add(R.id.container, new PlaceholderFragment()).commit();
    		}
    	}
    
    	@Override
    	public boolean onCreateOptionsMenu(Menu menu) {
    
    		getMenuInflater().inflate(R.menu.main, menu);
    		return true;
    	}
    
    	@Override
    	public boolean onOptionsItemSelected(MenuItem item) {
    		int id = item.getItemId();
    		if (id == R.id.action_settings) {
    			return true;
    		}
    		return super.onOptionsItemSelected(item);
    	}
    
    	/**
    	 * A placeholder fragment containing a simple view.
    	 */
    	public static class PlaceholderFragment extends Fragment {
    
    		public PlaceholderFragment() {
    		}
    
    		@Override
    		public View onCreateView(LayoutInflater inflater, ViewGroup container,
    				Bundle savedInstanceState) {
    			View rootView = inflater.inflate(R.layout.fragment_main, container,
    					false);
    			tv_say_content = (TextView)rootView.findViewById(R.id.tv_say_content);
    			Button btn_say = (Button)rootView.findViewById(R.id.btn_say);
    			btn_say.setOnClickListener(new OnClickListener() {
    				@Override
    				public void onClick(View v) {
    					tv_say_content.setText(new AndFixSay().say());
    				}
    			});
    			return rootView;
    		}
    	}
    
    }

    MainApplication是用来写初始化AndFix的

    ublic class MainApplication extends Application {
    
    	public PatchManager mPatchManager;
    
    	private static final String TAG = "euler";
    
    	private static final String APATCH_PATH = "/out.apatch";
    	@Override
    	public void onCreate() {
    		super.onCreate();
    		try {
    			//注意每次appversion变更都会导致所有补丁被删除,如果appversion没有改变,则会加载已经保存的所有补丁。
    			String appversion= getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
    			mPatchManager = new PatchManager(this);
    			mPatchManager.init(appversion);
    			mPatchManager.loadPatch();
    			//添加patch,只需指定patch的路径即可,补丁会立即生效
    			// add patch at runtime
    				// .apatch file path
    			String patchFileString = Environment.getExternalStorageDirectory()
    						.getAbsolutePath() + APATCH_PATH;
    			mPatchManager.addPatch(patchFileString);
    			Log.d(TAG, "apatch:" + patchFileString + " added.");
    			
    		} catch (NameNotFoundException e) {
    			e.printStackTrace();
    		}catch (IOException e) {
    			Log.e(TAG, "", e);
    		}
    	}
    	
    }

    安装应用到手机上,会有个按钮,点击后会显示:I'm not fix。

    然后我们修改两个地方:
    AndFix中添加方法sayFix();

    public class AndFixSay {
    	public String say(){
    		return "I'm not fix";
    	} 
    	public String sayFix(){
    		return "I'm fixed -- lala";
    	}
    }
    MainActivity我们修改如下:
    tv_say_content.setText(new AndFixSay().sayFix());


    然后就是比较产生差异文件(比较的包不能加固,加固后会修复失败):
    apkpatch.bat -f FixDemo_new.apk -t FixDemo_old.apk -o output1 -k *** -p *** -a *** -e ***

    生成一个.apatch格式的补丁文件!(参数前面有介绍)

    正式开发时补丁文件可以通过请求接口下载然后修复,这里我就直接复制粘贴到sd卡上面,然后给个文件路径,意思都差不多。

    最后显示的结果是:I'm fixed -- lala

    这个虽然简单,但是有很多的局限性,不能更换资源文件、不能添加类等!

    完整项目下载地址:http://download.csdn.net/detail/caihongdao123/9588337


    展开全文
  • Android 热修复 Tinker接入及源码浅析

    万次阅读 多人点赞 2017-02-06 10:03:22
    一、概述 放了一个大长假,happy,先祝大家2017年笑口常开。 假期中一行代码没写,但是想着马上要上班了,赶紧写篇...现在热修复的技术基本上有阿里的AndFix、QZone的方案、美团提出的思想方案以及腾讯的Tinker等。

    本文已在我的公众号hongyangAndroid首发。
    转载请标明出处:
    http://blog.csdn.net/lmj623565791/article/details/54882693
    本文出自张鸿洋的博客

    一、概述

    放了一个大长假,happy,先祝大家2017年笑口常开。

    假期中一行代码没写,但是想着马上要上班了,赶紧写篇博客回顾下技能,于是便有了本文。

    热修复这项技术,基本上已经成为项目比较重要的模块了。主要因为项目在上线之后,都难免会有各种问题,而依靠发版去修复问题,成本太高了。

    现在热修复的技术基本上有阿里的AndFix、QZone的方案、美团提出的思想方案以及腾讯的Tinker等。

    其中AndFix可能接入是最简单的一个(和Tinker命令行接入方式差不多),不过兼容性还是是有一定的问题的;QZone方案对性能会有一定的影响,且在Art模式下出现内存错乱的问题(其实这个问题我之前并不清楚,主要是tinker在MDCC上指出的);美团提出的思想方案主要是基于Instant Run的原理,目前尚未开源,不过这个方案我还是蛮喜欢的,主要是兼容性好。

    这么看来,如果选择开源方案,tinker目前是最佳的选择,tinker的介绍有这么一句:

    Tinker已运行在微信的数亿Android设备上,那么为什么你不使用Tinker呢?

    好了,说了这么多,下面来看看tinker如何接入,以及tinker的大致的原理分析。希望通过本文可以实现帮助大家更好的接入tinker,以及去了解tinker的一个大致的原理。

    二、接入Tinker

    接入tinker目前给了两种方式,一种是基于命令行的方式,类似于AndFix的接入方式;一种就是gradle的方式。

    考虑早期使用Andfix的app应该挺多的,以及很多人对gradle的相关配置还是觉得比较繁琐的,下面对两种方式都介绍下。

    (1)命令行接入

    接入之前我们先考虑下,接入的话,正常需要的前提(开启混淆的状态)。

    • 对于API

      一般来说,我们接入热修库,会在Application#onCreate中进行一下初始化操作。然后在某个地方去调用类似loadPatch这样的API去加载patch文件。

    • 对于patch的生成

      简单的方式就是通过两个apk做对比然后生成;需要注意的是:两个apk做对比,需要的前提条件,第二次打包混淆所使用的mapping文件应该和线上apk是一致的。

    最后就是看看这个项目有没有需要配置混淆;

    有了大致的概念,我们就基本了解命令行接入tinker,大致需要哪些步骤了。

    依赖引入

    dependencies {
        // ...
        //可选,用于生成application类
        provided('com.tencent.tinker:tinker-android-anno:1.7.7')
        //tinker的核心库
        compile('com.tencent.tinker:tinker-android-lib:1.7.7')
    }

    顺便加一下签名的配置:

    android{
      //...
        signingConfigs {
            release {
                try {
                    storeFile file("release.keystore")
                    storePassword "testres"
                    keyAlias "testres"
                    keyPassword "testres"
                } catch (ex) {
                    throw new InvalidUserDataException(ex.toString())
                }
            }
        }
    
        buildTypes {
            release {
                minifyEnabled true
                signingConfig signingConfigs.release
                proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            }
            debug {
                debuggable true
                minifyEnabled true
                signingConfig signingConfigs.release
                proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            }
        }
    }

    文末会有demo的下载地址,可以直接参考build.gradle文件,不用担心这些签名文件去哪找。

    API引入

    API主要就是初始化和loadPacth。

    正常情况下,我们会考虑在Application的onCreate中去初始化,不过tinker推荐下面的写法:

    @DefaultLifeCycle(application = ".SimpleTinkerInApplication",
            flags = ShareConstants.TINKER_ENABLE_ALL,
            loadVerifyFlag = false)
    public class SimpleTinkerInApplicationLike extends ApplicationLike {
        public SimpleTinkerInApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
            super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
        }
    
        @Override
        public void onBaseContextAttached(Context base) {
            super.onBaseContextAttached(base);
        }
    
        @Override
        public void onCreate() {
            super.onCreate();
            TinkerInstaller.install(this);
        }
    }

    ApplicationLike通过名字你可能会猜,并非是Application的子类,而是一个类似Application的类。

    tinker建议编写一个ApplicationLike的子类,你可以当成Application去使用,注意顶部的注解:@DefaultLifeCycle,其application属性,会在编译期生成一个SimpleTinkerInApplication类。

    所以,虽然我们这么写了,但是实际上Application会在编译期生成,所以AndroidManifest.xml中是这样的:

     <application
            android:name=".SimpleTinkerInApplication"
            .../>

    编写如果报红,可以build下。

    这样其实也能猜出来,这个注解背后有个Annotation Processor在做处理,如果你没了解过,可以看下:

    通过该文会对一个编译时注解的运行流程和基本API有一定的掌握,文中也会对tinker该部分的源码做解析。

    上述,就完成了tinker的初始化,那么调用loadPatch的时机,我们直接在Activity中添加一个Button设置:

    
    public class MainActivity extends AppCompatActivity {
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
        }
    
    
        public void loadPatch(View view) {
            TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),
                    Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed.apk");
        }
    }
    

    我们会将patch文件直接push到sdcard根目录;

    所以一定要注意:添加SDCard权限,如果你是6.x以上的系统,自己添加上授权代码,或者手动在设置页面打开SDCard读写权限。

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    除以以外,有个特殊的地方就是tinker需要在AndroidManifest.xml中指定TINKER_ID。

    <application>
      <meta-data
                android:name="TINKER_ID"
                android:value="tinker_id_6235657" />
        //...
    </application>

    到此API相关的就结束了,剩下的就是考虑patch如何生成。

    patch生成

    tinker提供了patch生成的工具,源码见:tinker-patch-cli,打成一个jar就可以使用,并且提供了命令行相关的参数以及文件。

    命令行如下:

    java -jar tinker-patch-cli-1.7.7.jar -old old.apk -new new.apk -config tinker_config.xml -out output

    需要注意的就是tinker_config.xml,里面包含tinker的配置,例如签名文件等。

    这里我们直接使用tinker提供的签名文件,所以不需要做修改,不过里面有个Application的item修改为与本例一致:

    <loader value="com.zhy.tinkersimplein.SimpleTinkerInApplication"/>

    大致的文件结构如下:

    SouthEast

    可以在tinker-patch-cli中提取,或者直接下载文末的例子。

    上述介绍了patch生成的命令,最后需要注意的就是,在第一次打出apk的时候,保留下生成的mapping文件,在/build/outputs/mapping/release/mapping.txt

    可以copy到与proguard-rules.pro同目录,同时在第二次打修复包的时候,在proguard-rules.pro中添加上:

    -applymapping mapping.txt

    保证后续的打包与线上包使用的是同一个mapping文件。

    tinker本身的混淆相关配置,可以参考:

    如果,你对该部分描述不了解,可以直接查看源码即可。

    测试

    首先随便生成一个apk(API、混淆相关已经按照上述引入),安装到手机或者模拟器上。

    然后,copy出mapping.txt文件,设置applymapping,修改代码,再次打包,生成new.apk。

    两次的apk,可以通过命令行指令去生成patch文件。

    如果你下载本例,命令需要在[该目录]下执行。

    最终会在output文件夹中生成产物:

    SouthEast

    我们直接将patch_signed.apk push到sdcard,点击loadpatch,一定要观察命令行是否成功。

    SouthEast

    本例修改了title。

    点击loadPatch,观察log,如果成功,应用默认为重启,然后再次启动即可达到修复效果。

    到这里命令行的方式就介绍完了,和Andfix的接入的方式基本上是一样的。

    值得注意的是:该例仅展示了基本的接入,对于tinker的各种配置信息,还是需要去读tinker的文档(如果你确定要使用)tinker-wiki

    (2)gradle接入

    gradle接入的方式应该算是主流的方式,所以tinker也直接给出了例子,单独将该tinker-sample-android以project方式引入即可。

    引入之后,可以查看其接入API的方式,以及相关配置。

    在你每次build时,会在build/bakApk下生成本地打包的apk,R文件,以及mapping文件。

    如果你需要生成patch文件,可以通过:

    ./gradlew tinkerPatchRelease  // 或者 ./gradlew tinkerPatchDebug

    生成。

    生成目录为:build/outputs/tinkerPatch

    SouthEast

    需要注意的是,需要在app/build.gradle中设置相比较的apk(即old.apk,本次为new.apk),

    ext {
        tinkerEnabled = true
        //old apk file to build patch apk
        tinkerOldApkPath = "${bakPath}/old.apk"
        //proguard mapping file to build patch apk
        tinkerApplyMappingPath = "${bakPath}/old-mapping.txt"
    }

    提供的例子,基本上展示了tinker的自定义扩展的方式,具体还可以参考:

    所以,如果你使用命令行方式接入,也不要忘了学习下其支持哪些扩展。

    三、Application是如何编译时生成的

    从注释和命名上看:

    //可选,用于生成application类
    provided('com.tencent.tinker:tinker-android-anno:1.7.7')

    明显是该库,其结构如下:

    SouthEast

    典型的编译时注解的项目,源码见tinker-android-anno

    入口为com.tencent.tinker.anno.AnnotationProcessor,可以在该services/javax.annotation.processing.Processor文件中找到处理类全路径。

    再次建议,如果你不了解,简单阅读下Android 如何编写基于编译时注解的项目该文。

    直接看AnnotationProcessor的process方法:

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        processDefaultLifeCycle(roundEnv.getElementsAnnotatedWith(DefaultLifeCycle.class));
        return true;
    }

    直接调用了processDefaultLifeCycle:

    private void processDefaultLifeCycle(Set<? extends Element> elements) {
            // 被注解DefaultLifeCycle标识的对象
            for (Element e : elements) {
              // 拿到DefaultLifeCycle注解对象
                DefaultLifeCycle ca = e.getAnnotation(DefaultLifeCycle.class);
    
                String lifeCycleClassName = ((TypeElement) e).getQualifiedName().toString();
                String lifeCyclePackageName = lifeCycleClassName.substring(0, lifeCycleClassName.lastIndexOf('.'));
                lifeCycleClassName = lifeCycleClassName.substring(lifeCycleClassName.lastIndexOf('.') + 1);
    
                String applicationClassName = ca.application();
                if (applicationClassName.startsWith(".")) {
                    applicationClassName = lifeCyclePackageName + applicationClassName;
                }
                String applicationPackageName = applicationClassName.substring(0, applicationClassName.lastIndexOf('.'));
                applicationClassName = applicationClassName.substring(applicationClassName.lastIndexOf('.') + 1);
    
                String loaderClassName = ca.loaderClass();
                if (loaderClassName.startsWith(".")) {
                    loaderClassName = lifeCyclePackageName + loaderClassName;
                }
    
                 // /TinkerAnnoApplication.tmpl
                final InputStream is = AnnotationProcessor.class.getResourceAsStream(APPLICATION_TEMPLATE_PATH);
                final Scanner scanner = new Scanner(is);
                final String template = scanner.useDelimiter("\\A").next();
                final String fileContent = template
                    .replaceAll("%PACKAGE%", applicationPackageName)
                    .replaceAll("%APPLICATION%", applicationClassName)
                    .replaceAll("%APPLICATION_LIFE_CYCLE%", lifeCyclePackageName + "." + lifeCycleClassName)
                    .replaceAll("%TINKER_FLAGS%", "" + ca.flags())
                    .replaceAll("%TINKER_LOADER_CLASS%", "" + loaderClassName)
                    .replaceAll("%TINKER_LOAD_VERIFY_FLAG%", "" + ca.loadVerifyFlag());
                    JavaFileObject fileObject = processingEnv.getFiler().createSourceFile(applicationPackageName + "." + applicationClassName);
                    processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Creating " + fileObject.toUri());
              Writer writer = fileObject.openWriter();
                PrintWriter pw = new PrintWriter(writer);
                pw.print(fileContent);
                pw.flush();
                writer.close();
    
            }
        }

    代码比较简单,可以分三部分理解:

    • 步骤1:首先找到被DefaultLifeCycle标识的Element(为类对象TypeElement),得到该对象的包名,类名等信息,然后通过该对象,拿到@DefaultLifeCycle对象,获取该注解中声明属性的值。
    • 步骤2:读取一个模板文件,读取为字符串,将各个占位符通过步骤1中的值替代。
    • 步骤3:通过JavaFileObject将替换完成的字符串写文件,其实就是本例中的Application对象。

    我们看一眼模板文件:

    package %PACKAGE%;
    
    import com.tencent.tinker.loader.app.TinkerApplication;
    
    /**
     *
     * Generated application for tinker life cycle
     *
     */
    public class %APPLICATION% extends TinkerApplication {
    
        public %APPLICATION%() {
            super(%TINKER_FLAGS%, "%APPLICATION_LIFE_CYCLE%", "%TINKER_LOADER_CLASS%", %TINKER_LOAD_VERIFY_FLAG%);
        }
    
    }

    对应我们的SimpleTinkerInApplicationLike

    @DefaultLifeCycle(application = ".SimpleTinkerInApplication",
            flags = ShareConstants.TINKER_ENABLE_ALL,
            loadVerifyFlag = false)
    public class SimpleTinkerInApplicationLike extends ApplicationLike {}

    主要就几个占位符:

    • 包名,如果application属性值以点开始,则同包;否则则截取
    • 类名,application属性值中的类名
    • %TINKER_FLAGS%对应flags
    • %APPLICATION_LIFE_CYCLE%,编写的ApplicationLike的全路径
    • “%TINKER_LOADER_CLASS%”,这个值我们没有设置,实际上对应@DefaultLifeCycle的loaderClass属性,默认值为com.tencent.tinker.loader.TinkerLoader
    • %TINKER_LOAD_VERIFY_FLAG%对应loadVerifyFlag

    于是最终生成的代码为:

    /**
     *
     * Generated application for tinker life cycle
     *
     */
    public class SimpleTinkerInApplication extends TinkerApplication {
    
        public SimpleTinkerInApplication() {
            super(7, "com.zhy.tinkersimplein.SimpleTinkerInApplicationLike", "com.tencent.tinker.loader.TinkerLoader", false);
        }
    
    }

    tinker这么做的目的,文档上是这么说的:

    为了减少错误的出现,推荐使用Annotation生成Application类。

    这样大致了解了Application是如何生成的。

    接下来我们大致看一下tinker的原理。

    四、原理

    SouthEast

    来源于:https://github.com/Tencent/tinker

    tinker贴了一张大致的原理图。

    可以看出:

    tinker将old.apk和new.apk做了diff,拿到patch.dex,然后将patch.dex与本机中apk的classes.dex做了合并,生成新的classes.dex,运行时通过反射将合并后的dex文件放置在加载的dexElements数组的前面。

    运行时替代的原理,其实和Qzone的方案差不多,都是去反射修改dexElements。

    两者的差异是:Qzone是直接将patch.dex插到数组的前面;而tinker是将patch.dex与app中的classes.dex合并后的全量dex插在数组的前面。

    tinker这么做的目的还是因为Qzone方案中提到的CLASS_ISPREVERIFIED的解决方案存在问题;而tinker相当于换个思路解决了该问题。

    接下来我们就从代码中去验证该原理。

    本片文章源码分析的两条线:

    • 应用启动时,从默认目录加载合并后的classes.dex
    • patch下发后,合成classes.dex至目标目录

    五、源码分析

    (1)加载patch

    加载的代码实际上在生成的Application中调用的,其父类为TinkerApplication,在其attachBaseContext中辗转会调用到loadTinker()方法,在该方法内部,反射调用了TinkerLoader的tryLoad方法。

    @Override
    public Intent tryLoad(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag) {
        Intent resultIntent = new Intent();
    
        long begin = SystemClock.elapsedRealtime();
        tryLoadPatchFilesInternal(app, tinkerFlag, tinkerLoadVerifyFlag, resultIntent);
        long cost = SystemClock.elapsedRealtime() - begin;
        ShareIntentUtil.setIntentPatchCostTime(resultIntent, cost);
        return resultIntent;
    }

    tryLoadPatchFilesInternal中会调用到loadTinkerJars方法:

    private void tryLoadPatchFilesInternal(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag, Intent resultIntent) {
        // 省略大量安全性校验代码
    
        if (isEnabledForDex) {
            //tinker/patch.info/patch-641e634c/dex
            boolean dexCheck = TinkerDexLoader.checkComplete(patchVersionDirectory, securityCheck, resultIntent);
            if (!dexCheck) {
                //file not found, do not load patch
                Log.w(TAG, "tryLoadPatchFiles:dex check fail");
                return;
            }
        }
    
        //now we can load patch jar
        if (isEnabledForDex) {
            boolean loadTinkerJars = TinkerDexLoader.loadTinkerJars(app, tinkerLoadVerifyFlag, patchVersionDirectory, resultIntent, isSystemOTA);
            if (!loadTinkerJars) {
                Log.w(TAG, "tryLoadPatchFiles:onPatchLoadDexesFail");
                return;
            }
        }
    }

    TinkerDexLoader.checkComplete主要是用于检查下发的meta文件中记录的dex信息(meta文件,可以查看生成patch的产物,在assets/dex-meta.txt),检查meta文件中记录的dex文件信息对应的dex文件是否存在,并把值存在TinkerDexLoader的静态变量dexList中。

    TinkerDexLoader.loadTinkerJars传入四个参数,分别为application,tinkerLoadVerifyFlag(注解上声明的值,传入为false),patchVersionDirectory当前version的patch文件夹,intent,当前patch是否仅适用于art。

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    public static boolean loadTinkerJars(Application application, boolean tinkerLoadVerifyFlag, 
        String directory, Intent intentResult, boolean isSystemOTA) {
            PathClassLoader classLoader = (PathClassLoader) TinkerDexLoader.class.getClassLoader();
    
            String dexPath = directory + "/" + DEX_PATH + "/";
            File optimizeDir = new File(directory + "/" + DEX_OPTIMIZE_PATH);
    
            ArrayList<File> legalFiles = new ArrayList<>();
    
            final boolean isArtPlatForm = ShareTinkerInternals.isVmArt();
            for (ShareDexDiffPatchInfo info : dexList) {
                //for dalvik, ignore art support dex
                if (isJustArtSupportDex(info)) {
                    continue;
                }
                String path = dexPath + info.realName;
                File file = new File(path);
    
                legalFiles.add(file);
            }
            // just for art
            if (isSystemOTA) {
                parallelOTAResult = true;
                parallelOTAThrowable = null;
                Log.w(TAG, "systemOTA, try parallel oat dexes!!!!!");
    
                TinkerParallelDexOptimizer.optimizeAll(
                    legalFiles, optimizeDir,
                    new TinkerParallelDexOptimizer.ResultCallback() {
                    }
                );
    
            SystemClassLoaderAdder.installDexes(application, classLoader, optimizeDir, legalFiles);
            return true;
        }
    

    找出仅支持art的dex,且当前patch是否仅适用于art时,并行去loadDex。

    关键是最后的installDexes:

    @SuppressLint("NewApi")
    public static void installDexes(Application application, PathClassLoader loader, File dexOptDir, List<File> files)
        throws Throwable {
    
        if (!files.isEmpty()) {
            ClassLoader classLoader = loader;
            if (Build.VERSION.SDK_INT >= 24) {
                classLoader = AndroidNClassLoader.inject(loader, application);
            }
            //because in dalvik, if inner class is not the same classloader with it wrapper class.
            //it won't fail at dex2opt
            if (Build.VERSION.SDK_INT >= 23) {
                V23.install(classLoader, files, dexOptDir);
            } else if (Build.VERSION.SDK_INT >= 19) {
                V19.install(classLoader, files, dexOptDir);
            } else if (Build.VERSION.SDK_INT >= 14) {
                V14.install(classLoader, files, dexOptDir);
            } else {
                V4.install(classLoader, files, dexOptDir);
            }
            //install done
            sPatchDexCount = files.size();
            Log.i(TAG, "after loaded classloader: " + classLoader + ", dex size:" + sPatchDexCount);
    
            if (!checkDexInstall(classLoader)) {
                //reset patch dex
                SystemClassLoaderAdder.uninstallPatchDex(classLoader);
                throw new TinkerRuntimeException(ShareConstants.CHECK_DEX_INSTALL_FAIL);
            }
        }
    }

    这里实际上就是根据不同的系统版本,去反射处理dexElements。

    我们看一下V19的实现(主要我看了下本机只有个22的源码~):

    private static final class V19 {
    
        private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                                    File optimizedDirectory)
            throws IllegalArgumentException, IllegalAccessException,
            NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
    
            Field pathListField = ShareReflectUtil.findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
            ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
                new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
                suppressedExceptions));
            if (suppressedExceptions.size() > 0) {
                for (IOException e : suppressedExceptions) {
                    Log.w(TAG, "Exception in makeDexElement", e);
                    throw e;
                }
            }
        }
    }        
    1. 找到PathClassLoader(BaseDexClassLoader)对象中的pathList对象
    2. 根据pathList对象找到其中的makeDexElements方法,传入patch相关的对应的实参,返回Element[]对象
    3. 拿到pathList对象中原本的dexElements方法
    4. 步骤2与步骤3中的Element[]数组进行合并,将patch相关的dex放在数组的前面
    5. 最后将合并后的数组,设置给pathList

    这里其实和Qzone的提出的方案基本是一致的。如果你以前未了解过Qzone的方案,可以参考此文:

    (2)合成patch

    这里的入口为:

     TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),
                    Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed.apk");

    上述代码会调用DefaultPatchListener中的onPatchReceived方法:

    # DefaultPatchListener
    @Override
    public int onPatchReceived(String path) {
    
        int returnCode = patchCheck(path);
    
        if (returnCode == ShareConstants.ERROR_PATCH_OK) {
            TinkerPatchService.runPatchService(context, path);
        } else {
            Tinker.with(context).getLoadReporter().onLoadPatchListenerReceiveFail(new File(path), returnCode);
        }
        return returnCode;
    
    }

    首先对tinker的相关配置(isEnable)以及patch的合法性进行检测,如果合法,则调用TinkerPatchService.runPatchService(context, path);

    public static void runPatchService(Context context, String path) {
        try {
            Intent intent = new Intent(context, TinkerPatchService.class);
            intent.putExtra(PATCH_PATH_EXTRA, path);
            intent.putExtra(RESULT_CLASS_EXTRA, resultServiceClass.getName());
            context.startService(intent);
        } catch (Throwable throwable) {
            TinkerLog.e(TAG, "start patch service fail, exception:" + throwable);
        }
    }

    TinkerPatchService是IntentService的子类,这里通过intent设置了两个参数,一个是patch的路径,一个是resultServiceClass,该值是调用Tinker.install的时候设置的,默认为DefaultTinkerResultService.class。由于是IntentService,直接看onHandleIntent即可,如果你对IntentService陌生,可以查看此文:Android IntentService完全解析 当Service遇到Handler

    @Override
    protected void onHandleIntent(Intent intent) {
        final Context context = getApplicationContext();
        Tinker tinker = Tinker.with(context);
    
    
        String path = getPatchPathExtra(intent);
    
        File patchFile = new File(path);
    
        boolean result;
    
        increasingPriority();
        PatchResult patchResult = new PatchResult();
    
        result = upgradePatchProcessor.tryPatch(context, path, patchResult);
    
        patchResult.isSuccess = result;
        patchResult.rawPatchFilePath = path;
        patchResult.costTime = cost;
        patchResult.e = e;
    
        AbstractResultService.runResultService(context, patchResult, getPatchResultExtra(intent));
    
    }

    比较清晰,主要关注upgradePatchProcessor.tryPatch方法,调用的是UpgradePatch.tryPatch。ps:这里有个有意思的地方increasingPriority(),其内部实现为:

    private void increasingPriority() {
        TinkerLog.i(TAG, "try to increase patch process priority");
        try {
            Notification notification = new Notification();
            if (Build.VERSION.SDK_INT < 18) {
                startForeground(notificationId, notification);
            } else {
                startForeground(notificationId, notification);
                // start InnerService
                startService(new Intent(this, InnerService.class));
            }
        } catch (Throwable e) {
            TinkerLog.i(TAG, "try to increase patch process priority error:" + e);
        }
    }

    如果你对“保活”这个话题比较关注,那么对这段代码一定不陌生,主要是利用系统的一个漏洞来启动一个前台Service。如果有兴趣,可以参考此文:关于 Android 进程保活,你所需要知道的一切

    下面继续回到tryPatch方法:

    # UpgradePatch
    @Override
    public boolean tryPatch(Context context, String tempPatchPath, PatchResult patchResult) {
        Tinker manager = Tinker.with(context);
    
        final File patchFile = new File(tempPatchPath);
    
        //it is a new patch, so we should not find a exist
        SharePatchInfo oldInfo = manager.getTinkerLoadResultIfPresent().patchInfo;
        String patchMd5 = SharePatchFileUtil.getMD5(patchFile);
    
        //use md5 as version
        patchResult.patchVersion = patchMd5;
        SharePatchInfo newInfo;
    
        //already have patch
        if (oldInfo != null) {
            newInfo = new SharePatchInfo(oldInfo.oldVersion, patchMd5, Build.FINGERPRINT);
        } else {
            newInfo = new SharePatchInfo("", patchMd5, Build.FINGERPRINT);
        }
    
        //check ok, we can real recover a new patch
        final String patchDirectory = manager.getPatchDirectory().getAbsolutePath();
        final String patchName = SharePatchFileUtil.getPatchVersionDirectory(patchMd5);
        final String patchVersionDirectory = patchDirectory + "/" + patchName;
    
        //copy file
        File destPatchFile = new File(patchVersionDirectory + "/" + SharePatchFileUtil.getPatchVersionFile(patchMd5));
        // check md5 first
        if (!patchMd5.equals(SharePatchFileUtil.getMD5(destPatchFile))) {
            SharePatchFileUtil.copyFileUsingStream(patchFile, destPatchFile);
        }
    
        //we use destPatchFile instead of patchFile, because patchFile may be deleted during the patch process
        if (!DexDiffPatchInternal.tryRecoverDexFiles(manager, signatureCheck, context, patchVersionDirectory, 
                    destPatchFile)) {
            TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, try patch dex failed");
            return false;
        }
    
        return true;
    }

    拷贝patch文件拷贝至私有目录,然后调用DexDiffPatchInternal.tryRecoverDexFiles

    protected static boolean tryRecoverDexFiles(Tinker manager, ShareSecurityCheck checker, Context context,
                                                    String patchVersionDirectory, File patchFile) {
        String dexMeta = checker.getMetaContentMap().get(DEX_META_FILE);
        boolean result = patchDexExtractViaDexDiff(context, patchVersionDirectory, dexMeta, patchFile);
        return result;
    }

    直接看patchDexExtractViaDexDiff

    private static boolean patchDexExtractViaDexDiff(Context context, String patchVersionDirectory, String meta, final File patchFile) {
        String dir = patchVersionDirectory + "/" + DEX_PATH + "/";
    
        if (!extractDexDiffInternals(context, dir, meta, patchFile, TYPE_DEX)) {
            TinkerLog.w(TAG, "patch recover, extractDiffInternals fail");
            return false;
        }
    
        final Tinker manager = Tinker.with(context);
    
        File dexFiles = new File(dir);
        File[] files = dexFiles.listFiles();
    
        ...files遍历执行:DexFile.loadDex
         return true;
    }

    核心代码主要在extractDexDiffInternals中:

    private static boolean extractDexDiffInternals(Context context, String dir, String meta, File patchFile, int type) {
        //parse meta
        ArrayList<ShareDexDiffPatchInfo> patchList = new ArrayList<>();
        ShareDexDiffPatchInfo.parseDexDiffPatchInfo(meta, patchList);
    
        File directory = new File(dir);
        //I think it is better to extract the raw files from apk
        Tinker manager = Tinker.with(context);
        ZipFile apk = null;
        ZipFile patch = null;
    
        ApplicationInfo applicationInfo = context.getApplicationInfo();
    
        String apkPath = applicationInfo.sourceDir; //base.apk
        apk = new ZipFile(apkPath);
        patch = new ZipFile(patchFile);
    
        for (ShareDexDiffPatchInfo info : patchList) {
    
            final String infoPath = info.path;
            String patchRealPath;
            if (infoPath.equals("")) {
                patchRealPath = info.rawName;
            } else {
                patchRealPath = info.path + "/" + info.rawName;
            }
    
            File extractedFile = new File(dir + info.realName);
    
            ZipEntry patchFileEntry = patch.getEntry(patchRealPath);
            ZipEntry rawApkFileEntry = apk.getEntry(patchRealPath);
    
            patchDexFile(apk, patch, rawApkFileEntry, patchFileEntry, info, extractedFile);
        }
    
        return true;
    }

    这里的代码比较关键了,可以看出首先解析了meta里面的信息,meta中包含了patch中每个dex的相关数据。然后通过Application拿到sourceDir,其实就是本机apk的路径以及patch文件;根据mate中的信息开始遍历,其实就是取出对应的dex文件,最后通过patchDexFile对两个dex文件做合并。

    private static void patchDexFile(
                ZipFile baseApk, ZipFile patchPkg, ZipEntry oldDexEntry, ZipEntry patchFileEntry,
                ShareDexDiffPatchInfo patchInfo,  File patchedDexFile) throws IOException {
        InputStream oldDexStream = null;
        InputStream patchFileStream = null;
    
        oldDexStream = new BufferedInputStream(baseApk.getInputStream(oldDexEntry));
        patchFileStream = (patchFileEntry != null ? new BufferedInputStream(patchPkg.getInputStream(patchFileEntry)) : null);
    
        new DexPatchApplier(oldDexStream, patchFileStream).executeAndSaveTo(patchedDexFile);
    
    }

    通过ZipFile拿到其内部文件的InputStream,其实就是读取本地apk对应的dex文件,以及patch中对应dex文件,对二者的通过executeAndSaveTo方法进行合并至patchedDexFile,即patch的目标私有目录。

    至于合并算法,这里其实才是tinker比较核心的地方,这个算法跟dex文件格式紧密关联,如果有机会,然后我又能看懂的话,后面会单独写篇博客介绍。此外dodola已经有篇博客进行了介绍:

    感兴趣的可以阅读下。

    好了,到此我们就大致了解了tinker热修复的原理~~

    测试demo地址:

    当然这里只分析了代码了热修复,后续考虑分析资源以及So的热修、核心的diff算法、以及gradle插件等相关知识~


    最后欢迎关注我的公众号~

    我的微信公众号:hongyangAndroid
    (可以给我留言你想学习的文章,支持投稿)
    2b15652affddc0cceb24.jpg

    展开全文
  • Android热修复原理(一)热修复框架对比和代码修复

    万次阅读 多人点赞 2018-03-12 01:20:29
    如果只是会这些热修复框架的使用那意义并不大,我们还需要了解它们的原理,这样不管热修复框架如何变化,只要基本原理不变,我们就可以很快的掌握它们。这一个系列不会对某些热修复框架源码进行解析,...

    title: “Android热修复原理(一)热修复框架对比和代码修复”
    date: 2018-03-12 00:18
    photos:

    • https://s2.ax1x.com/2019/05/29/VnQrOx.png
      tag:
    • Android热修复原理
      categories:
    • Android应用层

    本文首发于微信公众号「后场村刘皇叔」

    关联系列
    解析ClassLoader系列

    前言

    在Android应用开发中,热修复技术被越来越多的开发者所使用,也出现了很多热修复框架,比如:AndFix、Tinker、Dexposed和Nuwa等等。如果只是会这些热修复框架的使用那意义并不大,我们还需要了解它们的原理,这样不管热修复框架如何变化,只要基本原理不变,我们就可以很快的掌握它们。这一个系列不会对某些热修复框架源码进行解析,而是讲解热修复框架的通用原理。

    1.热修复的产生概述

    在开发中我们会遇到如下的情况:

    1. 刚发布的版本出现了严重的bug,这就需要去解决bug、测试并打渠道包在各个应用市场上重新发布,这会耗费大量的人力物力,代价会比较大。
    2. 已经改正了此前发布版本的bug,如果下一个版本是一个大版本,那么两个版本的间隔时间会很长,这样要等到下个大版本发布再修复bug,这样此前版本的bug会长期的影响用户。
    3. 版本升级率不高,并且需要很长时间来完成版本覆盖,此前版本的bug就会一直影响不升级版本的用户。
    4. 有一个小而重要的功能,需要短时间内完成版本覆盖,比如节日活动。

    为了解决上面的问题,热修复框架就产生了。对于Bug的处理,开发人员不要过于依赖热修复框架,在开发的过程中还是要按照标准的流程做好自测、配合测试人员完成测试流程。

    2.热修复框架的对比

    热修复框架的种类繁多,按照公司团队划分主要有以下几种:

    类别 成员
    阿里系 AndFix、Dexposed、阿里百川、Sophix
    腾讯系 微信的Tinker、QQ空间的超级补丁、手机QQ的QFix
    知名公司 美团的Robust、饿了么的Amigo、美丽说蘑菇街的Aceso
    其他 RocooFix、Nuwa、AnoleFix

    虽然热修复框架很多,但热修复框架的核心技术主要有三类,分别是代码修复、资源修复和动态链接库修复,其中每个核心技术又有很多不同的技术方案,每个技术方案又有不同的实现,另外这些热修复框架仍在不断的更新迭代中,可见热修复框架的技术实现是繁多可变的。作为开发需需要了解这些技术方案的基本原理,这样就可以以不变应万变。

    部分热修复框架的对比如下表所示。

    特性 AndFix Tinker/Amigo QQ空间 Robust/Aceso
    即时生效
    方法替换
    类替换
    类结构修改
    资源替换
    so替换
    支持gradle
    支持ART
    支持Android7.0

    我们可以根据上表和具体业务来选择合适的热修复框架,当然上表的信息很难做到完全准确,因为部分的热修复框架还在不断更新迭代。
    从表中也可以发现Tinker和Amigo拥有的特性最多,是不是就选它们呢?也不尽然,拥有的特性多也意味着框架的代码量庞大,我们需要根据业务来选择最合适的,假设我们只是要用到方法替换,那么使用Tinker和Amigo显然是大材小用了。另外如果项目需要即时生效,那么使用Tinker和Amigo是无法满足需求的。对于即时生效,AndFix、Robust和Aceso都满足这一点,这是因为AndFix的代码修复采用了底层替换方案,而Robust和Aceso的代码修复借鉴了Instant Run原理,现在我们就来学习代码修复。

    3.代码修复

    代码修复主要有三个方案,分别是底层替换方案、类加载方案和Instant Run方案。

    3.1 类加载方案

    类加载方案基于Dex分包方案,什么是Dex分包方案呢?这个得先从65536限制和LinearAlloc限制说起。
    65536限制
    随着应用功能越来越复杂,代码量不断地增大,引入的库也越来越多,可能会在编译时提示如下异常:

    com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536
    

    这说明应用中引用的方法数超过了最大数65536个。产生这一问题的原因就是系统的65536限制,65536限制的主要原因是DVM Bytecode的限制,DVM指令集的方法调用指令invoke-kind索引为16bits,最多能引用 65535个方法。
    LinearAlloc限制
    在安装时可能会提示INSTALL_FAILED_DEXOPT。产生的原因就是LinearAlloc限制,DVM中的LinearAlloc是一个固定的缓存区,当方法数过多超出了缓存区的大小时会报错。

    为了解决65536限制和LinearAlloc限制,从而产生了Dex分包方案。Dex分包方案主要做的是在打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex,从而缓解了主Dex的65536限制和LinearAlloc限制。

    Dex分包方案主要有两种,分别是Google官方方案、Dex自动拆包和动态加载方案。因为Dex分包方案不是本章的重点,这里就不再过多的介绍,我们接着来学习类加载方案。
    Android解析ClassLoader(二)Android中的ClassLoader中讲到了ClassLoader的加载过程,其中一个环节就是调用DexPathList的findClass的方法,如下所示。
    libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

     public Class<?> findClass(String name, List<Throwable> suppressed) {
            for (Element element : dexElements) {//1
                Class<?> clazz = element.findClass(name, definingContext, suppressed);//2
                if (clazz != null) {
                    return clazz;
                }
            }
            if (dexElementsSuppressedExceptions != null) {
                suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
            }
            return null;
        }
    

    Element内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element。
    多个Element组成了有序的Element数组dexElements。当要查找类时,会在注释1处遍历Element数组dexElements(相当于遍历dex文件数组),注释2处调用Element的findClass方法,其方法内部会调用DexFile的loadClassBinaryName方法查找类。如果在Element中(dex文件)找到了该类就返回,如果没有找到就接着在下一个Element中进行查找。
    根据上面的查找流程,我们将有bug的类Key.class进行修改,再将Key.class打包成包含dex的补丁包Patch.jar,放在Element数组dexElements的第一个元素,这样会首先找到Patch.dex中的Key.class去替换之前存在bug的Key.class,排在数组后面的dex文件中的存在bug的Key.class根据ClassLoader的双亲委托模式就不会被加载,这就是类加载方案,如下图所示。
    VnQD61.png

    类加载方案需要重启App后让ClassLoader重新加载新的类,为什么需要重启呢?这是因为类是无法被卸载的,因此要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。
    虽然很多热修复框架采用了类加载方案,但具体的实现细节和步骤还是有一些区别的,比如QQ空间的超级补丁和Nuwa是按照上面说得将补丁包放在Element数组的第一个元素得到优先加载。微信Tinker将新旧apk做了diff,得到patch.dex,然后将patch.dex与手机中apk的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素。饿了么的Amigo则是将补丁包中每个dex 对应的Element取出来,之后组成新的Element数组,在运行时通过反射用新的Element数组替换掉现有的Element 数组。

    采用类加载方案的主要是以腾讯系为主,包括微信的Tinker、QQ空间的超级补丁、手机QQ的QFix、饿了么的Amigo和Nuwa等等。

    3.2 底层替换方案

    与类加载方案不同的是,底层替换方案不会再次加载新类,而是直接在Native层修改原有类,由于是在原有类进行修改限制会比较多,不能够增减原有类的方法和字段,如果我们增加了方法数,那么方法索引数也会增加,这样访问方法时会无法通过索引找到正确的方法,同样的字段也是类似的情况。
    底层替换方案和反射的原理有些关联,就拿方法替换来说,方法反射我们可以调用java.lang.Class.getDeclaredMethod,假设我们要反射Key的show方法,会调用如下所示。

       Key.class.getDeclaredMethod("show").invoke(Key.class.newInstance());
    

    Android 8.0的invoke方法,如下所示。
    libcore/ojluni/src/main/java/java/lang/reflect/Method.java

        @FastNative
        public native Object invoke(Object obj, Object... args)
                throws IllegalAccessException, IllegalArgumentException, InvocationTargetException;
    
    

    invoke方法是个native方法,对应Jni层的代码为:
    art/runtime/native/java_lang_reflect_Method.cc

    static jobject Method_invoke(JNIEnv* env, jobject javaMethod, jobject javaReceiver,
                                 jobject javaArgs) {
      ScopedFastNativeObjectAccess soa(env);
      return InvokeMethod(soa, javaMethod, javaReceiver, javaArgs);
    

    Method_invoke函数中又调用了InvokeMethod函数:
    art/runtime/reflection.cc

    jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,
                         jobject javaReceiver, jobject javaArgs, size_t num_frames) {
    
    ...
      ObjPtr<mirror::Executable> executable = soa.Decode<mirror::Executable>(javaMethod);
      const bool accessible = executable->IsAccessible();
      ArtMethod* m = executable->GetArtMethod();//1
    ...
    }
    

    注释1处获取传入的javaMethod(Key的show方法)在ART虚拟机中对应的一个ArtMethod指针,ArtMethod结构体中包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等等,ArtMethod结构如下所示。
    art/runtime/art_method.h

    class ArtMethod FINAL {
    ...
     protected:
      GcRoot<mirror::Class> declaring_class_;
      std::atomic<std::uint32_t> access_flags_;
      uint32_t dex_code_item_offset_;
      uint32_t dex_method_index_;
      uint16_t method_index_;
      uint16_t hotness_count_;
     struct PtrSizedFields {
        ArtMethod** dex_cache_resolved_methods_;//1
        void* data_;
        void* entry_point_from_quick_compiled_code_;//2
      } ptr_sized_fields_;
    }
    

    ArtMethod结构中比较重要的字段是注释1处的dex_cache_resolved_methods_和注释2处的entry_point_from_quick_compiled_code_,它们是方法的执行入口,当我们调用某一个方法时(比如Key的show方法),就会取得show方法的执行入口,通过执行入口就可以跳过去执行show方法。
    替换ArtMethod结构体中的字段或者替换整个ArtMethod结构体,这就是底层替换方案。
    AndFix采用的是替换ArtMethod结构体中的字段,这样会有兼容问题,因为厂商可能会修改ArtMethod结构体,导致方法替换失败。Sophix采用的是替换整个ArtMethod结构体,这样不会存在兼容问题。
    底层替换方案直接替换了方法,可以立即生效不需要重启。采用底层替换方案主要是阿里系为主,包括AndFix、Dexposed、阿里百川、Sophix。

    3.3 Instant Run方案

    除了资源修复,代码修复同样也可以借鉴Instant Run的原理, 可以说Instant Run的出现推动了热修复框架的发展。
    Instant Run在第一次构建apk时,使用ASM在每一个方法中注入了类似如下的代码:

    IncrementalChange localIncrementalChange = $change;//1
    		if (localIncrementalChange != null) {//2
    			localIncrementalChange.access$dispatch(
    					"onCreate.(Landroid/os/Bundle;)V", new Object[] { this,
    							paramBundle });
    			return;
    		}
    

    其中注释1处是一个成员变量localIncrementalChange ,它的值为$change$change实现了IncrementalChange这个抽象接口。当我们点击InstantRun时,如果方法没有变化则$change为null,就调用return,不做任何处理。如果方法有变化,就生成替换类,这里我们假设MainActivity的onCreate方法做了修改,就会生成替换类MainActivity$override,这个类实现了IncrementalChange接口,同时也会生成一个AppPatchesLoaderImpl类,这个类的getPatchedClasses方法会返回被修改的类的列表(里面包含了MainActivity),根据列表会将MainActivity的$change设置为MainActivity$override,因此满足了注释2的条件,会执行MainActivity$overrideaccess$dispatch方法,accessdispatch方法中会根据参数&quot;onCreate.(Landroid/os/Bundle;)V&quot;执行‘MainActivitydispatch方法中会根据参数&quot;onCreate.(Landroid/os/Bundle;)V&quot;执行`MainActivitydispatch"onCreate.(Landroid/os/Bundle;)V"MainActivityoverride`的onCreate方法,从而实现了onCreate方法的修改。
    借鉴Instant Run的原理的热修复框架有Robust和Aceso。


    这里不仅分享大前端、Android、Java等技术,还有程序员成长类文章。
    ZmD7sH.jpg
    展开全文
  • 点击上方的终端研发部,右上角选择“设为星标”每日早10点半,技术文章准时送上公众号后台回复“学习”,获取作者独家秘制精品资料往期文章记五月的一个Android面试经今日头...
  • 热修复(基础)

    2019-07-09 08:51:36
    引入热修复技术: 动态修复已经发布的app的bug或者新增功能,不需要重新发版,用户无感知 热修复执行流程: 热修复的可选方案: 1.AndFix(基于JNI) 2.QQ超级补丁技术 3.Sophix(sdk第三方接入) 4....
  • App热修复

    2020-07-14 16:02:15
    在此说明,本次说讲的热修复为精简版 不涉及到字节码插桩 基于Android9源码、没有做版本适配、基于原理实现简单的热修复 正文=====正文============================正文 首先,热修复设计到类的加载,那么,我们的...
  • 热修复介绍

    2019-09-24 14:34:30
    热修复介绍 1.热修复简介 ​ 热修复说白了就是”打补丁”,比如你们公司上线一个app,用户反应有重大bug,需要紧急修复。如果按照通 常做法,那就是程序猿加班搞定bug,然后测试,重新打包并发布。这样带来的问题就是...
  • 热修复

    2020-11-01 18:28:17
    Android中ClassLoader的种类&特点 BootClassLoader: 用于加载Android Framework层class文件。 PathClassLoader: 用于加载已经安装到系统中的apk中的...更新 DexClassLoader 包含有一个dex数组Element[] dex
  • Android学习——手把手教你实现Android热修复

    万次阅读 热门讨论 2018-07-07 20:50:50
    最近一段时间看了一些关于Android热修复的知识,比如Andfix,Tinker,Sophix等,看了这些框架的原理,就想着自己能不能手撸一个简单的demo。下面我们就来自己动手实现Android热修复吧。 热修复实现原理 所谓热修复...
  • 手把手教你使用腾讯的热修复框架-Tinker

    万次阅读 多人点赞 2018-08-11 15:20:53
    演示如何使用腾讯的热修复框架-Tinker 项目地址 Tinker热更新演示(请star支持) 演示demo下载 Tinker简介 Tinker是微信官方的Android热补丁解决方案,它支持动态下发代码、So库以及资源,让应用...
  • Tinker热修复加固

    千次阅读 2017-05-11 11:09:16
    一直等着Tinker更新版本,终于在前段时间,Tinker和各大加固厂商协作支持了加固的热修复,因为Tinker热修复需要后台去发布补丁,所以我们就选择了同样是腾讯旗下的Bugly,Bugly还支持异常捕获分析和运营统计等。...
  • 前言 不知你是否遇到这样的情况?千辛万苦上开发了一个版本,好不容易上线了,突然发现了一个严重bug需要进行紧急修复,怎么办?...当下热修复框架的种类繁多,其中有名的包括阿里的AndFix,微信的Tin...
  • 文章目录目录Tinker 介绍使用TinkerApplicaition``SampleApplicaitionLike``TinkerInstaller总结运行时 Tinker 是如何加载补丁加载 dex混合编译与热修复加载资源加载 sodex diffdex 格式tinker patch 格式了解diff ...
  • Tinker热修复简单接入

    千次阅读 2016-10-31 15:21:42
    热修复Tinker的简单接入Tinker接入我们分为以下步骤进行: Tinker的简单介绍 Tinker的基本配置 差异文件的生成 修复bug Tinker的简单介绍 Tinker:https://github.com/Tencent/tinker 微信android热修复技术Tinker...
  • 腾讯官网介绍:... 腾讯github源码:https://github.com/Tencent/tinker 为什么选择tinkerTinker QZone AndFix Robust 类替换 yes yes no no So替换 yes ...
  • Android热修复Tinker

    2018-05-13 13:20:46
    微信Tinker热修复的集成Demo,快速高效的热修复技术集成和使用。微信Tinker热修复的集成Demo,快速高效的热修复技术集成和使用。
  • SO文件加载的时机和Dex跟资源的加载有些不一样,像Dex和资源的加载都是系统在特定的时机自动去加载,而SO加载的时机则是让开发者自己控制.开发者可以通过System类对外暴露出来的两个静态方法load和loadLibarary加载SO....
  • 想要做资源的更新首先需要了解分析资源加载流程,这样才能找到突破口.一般我们在应用中使用和加载资源都是通过Context对象的getResources方法.这里以Android 6.0的源码分析资源加载的流程.
  • Tinker 是一个开源项目(Github链接),它是微信官方的 Android 补丁解决方案,它支持动态下发代码、So 库以及资源,让应用能够在不需要重新安装的情况下实现更新,当前市面的补丁方案有很多,其中比较出名的有...
  • Android热修复-微信Tinker

    千次阅读 2016-11-09 16:17:30
    11/9/2016 1:13:49 PM Android的热修复前言:随着时代的发展,由于公司的项目需要去求变化平凡计划总赶不上变化,H5的高灵活性,开发周期短,更新速度快H5以及一些混合开发越来越被看好,然而主要原因之一:这种混合开发的...
  • 微信Tinker 热修复介绍及接入(一)

    千次阅读 2017-06-22 20:34:22
    热修复(HotFix)是以补丁的方式动态修复紧急Bug,不再需要重新发布App,不需要用户重新下载覆盖安装的方式来实现代码的替换修改。这里就不多啰嗦了,可以自行搜索网上的介绍。目前主流HotFix方案对比...
  • android集成Tinker热修复

    千次阅读 2019-03-26 11:35:46
    Android开发的各位应该都碰到过这样的问题,我们在项目...Android 热修复解决方案,能让用户在没有察觉,没有更新app的情况下实现修复。 比较一下各个热修复实现方案。 添加依赖 首先添加插件到project的build...
  • 3、几种热修复方案对比 1、tinker的class文件修复 1.1、先说dex文件的加载和类的查找过程 1.1.1、dex文件的加载过程 Java层通过我们会通过创建一个DexClassLoader来加载我们的dex,下面就以此为切入点进行 ...
  • 热修复Tinker简单使用

    千次阅读 2017-01-13 23:13:09
    官方说明Tinker是微信官方的Android补丁解决方案,它支持动态下发代码、So库以及资源,让应用能够在不需要重新安装的情况下实现更新。当然,你也可以使用Tinker来更新你的插件。github地址:...
  • 当时我回答得不好,毕竟以前的项目都没有用,又不敢装逼,mmp,但是基本流程还是知道的,所以我们来初探下Tinker 这个热修复,如果我是Andriod studio 2.3的话,我还不怎么想写这个文章,毕竟太多了,没有什么坑了,...
  • 热更新: 就是在应用无需重新安装情况下实现更新,从而实现动态修复功能。 在热更新之前,如果线上的应用出现bug, 是需要用户重新下载...目前比较火的热修复的分为阿里系、腾讯系、 其他大厂。 阿里系 : DeXposed...
  • Tinker是腾讯出的一款热修复框架,关于热修复框架Tinker介绍本文就不描述太多了,网络已经很多了 本文主要介绍主要基础的集成使用,本文主要使用Gradle方式集成 Tinker的github地址,详细介绍可以看wiki ...
  • 可以参考上一篇 Android 热修复方案分析文章中说到的Qzone方案,要给除了Application子类所有的类注入一个独立dex中的类引用,来避免class被打上CLASS_ISPREVERIFIED标记.而这个独立的dex是在Application启动之后加载...
  • Android 热修复方案Tinker(七) 插桩实现

    千次阅读 2017-02-03 14:17:01
    Tinker V1.7.5是最后一个还支持回退Qzone方案插桩实现补丁修复的版本.由于Tinker的全量合成需要拿到原dex,而第三方的加固通常会将原dex隐藏起来做保护所以使用了第三方加固就只能用V1.7.5的Qzone模式.除非自己做dex...

空空如也

1 2 3 4 5 ... 20
收藏数 43,296
精华内容 17,318
关键字:

热修复