类加载机制实现Android热修复

来源:互联网 发布:91上的是真的吗 知乎 编辑:程序博客网 时间:2024/06/08 12:27

本文通过类加载机制实现Android热修复,Demo实现的功能:检测服务器是否存在补丁,存在即下载补丁,安装补丁,重启APP生效。支持多个补丁包修复:如果已经下载了多个补丁包,重启app对补丁包进行排序,并依次修复。本文比较贴近实际应用。

效果图

这里写图片描述

如果感觉不能一步步自己实现,可以看看本文将的热修复原理,然后直接下载完整代码,多敲几遍,就行了。

什么是Android热修复技术?

PS:本文通过 “类加载机制” 实现代码热修复,资源和so库类修复不在本文介绍范围内,是在想使用的话,请参考下文介绍的当下流行的热修复技术框架。

对于这个弱智的问题,相信不需要过多的解释,就是:在不重新安装apk的情况下,通过补丁,修复bug。盗用阿里的2张图(Thanks阿里)

这里写图片描述


这里写图片描述

目前主流的热修复技术框架

  • 阿里系的: AndfixHotfixSophix

  • 腾讯系的:QQ空间超级补丁技术QfixTinker

  • 美团系的:Robust

  • 饿了么的:Amigo

代码热修复实现原理和优缺点

Ps:上述4大系列框架都很全面,包括了代码修复、资源修复、so库修复等。本文我们只谈如何自己动手实现代码热修复(我觉得日常开发足够了)

代码热修复2种方案

  • 通过类加载机制实现

    优点:适用性强、修复范围广、限制少

    缺点:属于热修复中的冷修复、需要重启App

  • 通过底层替换方法实现

    优点:时效好、不需重启,即使生效

    缺点:受限制较多(需要修改虚拟机字段,如果手机厂商修改了虚拟机…….)

通过类加载机制实现代码热修复(来点干货)

类加载机制有什么是我们可利用的呢?

认识BaseDexClassLoaderPathClassLoaderDexClassLoader

  • PathClassLoader:系统运作,app运行时用于加载app所有需要的类。属于系统层面,正常情况下我们不可操作(二班情况下,就可以了,哈哈,我们接下类要做的就是通过反射机制修改它,达到热修复的目的)。

  • DexClassLoader:程序员运作,可以通过它加载我们想加载的资源,一般包括这么几种:jardexapk等。

  • BaseDexClassLoader:热修复中的大Boss,PathClassLoader和DexClassLoader均继承自BaseDexClassLoader,PathClassLoader和DexClassLoader的重要方法均在其父类BaseDexClassLoader中。(我们反编译就需要从BaseDexClassLoader入手)。

用到xx类的时候,虚拟机是怎么找到它的?

简单描述下:用到xx类的时候,虚拟机会利用PathClassLoader去遍历加载过的所有dex文件,从中查找到xx类,一旦找到就return。

BaseDexClassLoader查找类的源码:

@Override    protected Class<?> findClass(String name) throws ClassNotFoundException {        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();        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通过pathList.findClass查找类的,这里出现一个 大Boss “PathList

PathList:中保存类所有dex文件和信息,看一下它是怎么查找类的
PathList源码

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

前方高能:

看到没有,PathList从dexElements中查找类,如果clazz != null直接return class,这就是我们可以利用的地方,从源码看,dexElements应该是个数组或者集合,设想:我们是不是可以把我们修复bug后的xx类,打包成dex,插入到dexElements的最前面,这样,系统通过PathClassLoader,查找bug类的时候,就会下找到我们的修复bug的xx类,然后直接返回,不去管后面有bug的那个xx类,达到热修复的功能。

Ps:不放心,看看dexElements中到底是什么?
贴一部分能说明问题的代码:

private Element[] dexElements;static class Element {        private final File dir;        private final boolean isDirectory;        private final File zip;        private final DexFile dexFile;        private ClassPathURLStreamHandler urlHandler;        private boolean initialized;        public Element(File dir, boolean isDirectory, File zip, DexFile dexFile) {            this.dir = dir;            this.isDirectory = isDirectory;            this.zip = zip;            this.dexFile = dexFile;        }

这段你代码是从PahtList.java中复制的,dexElements是Element类型的数组,而Element是PahtList的内部类,其中保存了DexFile和路径等信息。证实我们的热修复方案是可行的。

推荐一个可以看在线看Android源码的网站

上面贴的源码是“dalvik”级别的,在studio中是看不到的,看到的是一堆抛异常的代码,我看了studio中的BaseDexClassLoader源码还傻傻的去别人博客留言说我的android版本源码变了,和你的不一样,还能不能实现热修复,现在想想挺搞笑。

* 在线查看Android各版本源码 *

理一下我们热修复的方案

  • 修复有bug的类,生成dex补丁包;

  • 通过反射机制得到PathClassLoader的成员你变量PathList字段(通过上面分析知道,PathList是PathClassLoader父类BaseDexClasLoader中的)

  • 然后再反射PathList获取它的dexElements字段(是一个存放dex的Element数组)

  • 将我们生成的dex补丁包,插入到dexElements的数组的最前端

代码实现Android热修复(代码开撸)

需求

现有一个app,MainActivity中有2个按钮,“跳转Activity2”和“查看People信息”,从服务器下载补丁,把“查看People信息”按钮改为“悄悄修改了代码”,将Acitivity2原来展示的信息,改为“我又来热修复了…”

实现步骤

  1. 编写改变前的app

  2. 编写热修复需要重写生成的类

  3. 通过2步骤的新类生成dex补丁包”001dex”,并放到本地的tomcat服务器,编写配置文件

  4. 编写补丁检测和下载代码

  5. 编写修复补丁代码

Ps:为了更清晰,一步步来,大牛请直接跳到第5部,哈哈~~~

编写原app

一共3个类:MainActivity、Acitivity2、People类,部分代码如下

  1. MainActivity.java
public class MainActivity extends AppCompatActivity {    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        setContentView(R.layout.activity_main);    }    // 跳转Activity2按钮点击回调方法    public void jumpToA2(View v){        Intent intent = new Intent(this, Activity2.class) ;        startActivity(intent) ;    }   // 展示People信息   public void showPeopleInfo(View v){        Toast.makeText(this, new People(20, "小明").toString(), Toast.LENGTH_LONG).show() ;   }}
  1. Activity2.java
public class Activity2 extends Activity {    private TextView view ;    @Override    protected void onCreate(@Nullable Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        setContentView(R.layout.activity2);        view = (TextView) findViewById(R.id.tv_info) ;        view.setText("Activit2") ;    }}
  1. People.java
public class People {    private int age ;    private String name ;    public People(int age, String name) {        this.age = age;        this.name = name;    }    @Override    public String toString() {        return "People{" +                "age=" + age +                ", name=" + name +'}';    }}

以上3个类特别简单,可以运行一下试试

修改 MainAtvity、Acitivity2类和People类
1. MainActivity类修改如下://将展示People信息按钮文字,改为"悄悄修改了代码"showPeopleInfoButton.setText("悄悄修改了代码") ;2. Activity2类修改如下:将view.setText("Activit2") ;改为:view.setText("我又来热修复啦...") ;3. People类修改如下:将 return "People{" +"age=" + age +", name=" + name +'}';改为:return "People{" +"age=" + age +", name=" + name +'}'+"史上最牛逼人物!!!";

编译生成class文件,修改也非常简单,我们就将这两个类做成”001dex”补丁

用class文件生成”001dex”补丁

android在sdk/build-tools/文件件下提供了”dx”命令工具,帮助我们将class文件生成dex文件

生成方式如下:

dx –dex –output=<要生成的文件> <’class’文件路径>

例如:
dx –dex –output=001.dex …MainAtvity …Actvity2.class …People.class

将”001.dex”放入tomcat服务器并编写配置文件
  1. 将001dex放到tomcat服务器这一步,我们不用写代码,只要能通过局域网访问到这个文件就行,”Tomcat7.0\apache-tomcat-7.0.64\webapps\examples\”新建”hotfix”文件夹,在hotfix文件夹中创建“config”文件夹和“patch”文件夹,将001.dex放入”patch”文件夹
    Ps:我的tomcat是7.0,”Tomcat7.0\apache-tomcat-7.0.64\”是我的安装目录(说白了,就是解压目录,我们都知道tomcat只需解压就能用,不用安装,当然也有安装的),根据自己的实际情况来。

  2. 编写配置文件
    所谓的配置文件,就是app用来检测是否存在补丁的一个文件,可以是.txt文件、xml文件、json文件等等。这里我们用json格式的文件。
    在“Tomcat7.0\apache-tomcat-7.0.64\webapps\examples\hotfix\config”目录创建”config.json”,内容如下:

{"patchCode":"001.dex","patchUrl":"http://192.168.1.106:8080/example/hotfix/patch/001.dex"}

配置文件中有2个字段:一个是补丁代码,一个是补丁下载地址。pathUrl是我的ip地址,只需将ip换成你自己的即可

* 开启tomct服务器,测试是否成功*
* 在浏览器输入config.json的地址”http://192.168.1.106:8080/examples/hotfix/config/config.json”

这里写图片描述

  • 在浏览器输入配置文件中”patchUrl”的地址”http://192.168.1.106:8080/example/hotfix/patch/001.dex“,会现在001.dex文件,表示成功

    至此,补丁服务器配置成功,下面可尽情的撸核心代码啦

    编写补丁检测和下载代码

    PS:网络操作,本例用的是Okhttp;json数据解析,用的是FastJson。还不熟悉的朋友可自行研究一下,很简单

检测补丁

/** * [检测服务器是否存在补丁和本地是否一下载过该补丁] * @type {Request} */public void checkPatch(){        //检测是否存在补丁包        Request request = new Request.Builder()                .get()                .url(CHECK_URL)                .build() ;        Call call = mClient.newCall(request) ;        call.enqueue(new Callback() {            @Override            public void onFailure(Call call, IOException e) {                Log.d(TAG, "check patch failed....") ;            }            @Override            public void onResponse(Call call, Response response) throws IOException {                JSONObject jsonObject = JSON.parseObject(response.body().string());                patchCode = jsonObject.getString("patchCode") ;                Log.d(TAG, "pathCode:"+patchCode) ;                //判断是否存在补丁                if(!"-1".equals(patchCode)){                    //判断当前补丁是否已经下载过                    if(isDownLoad()){                        Log.d(TAG, "this version pathCode is fixed...") ;                        fixPatch();                    }else{                        //获取补丁链接                        patchUrl = jsonObject.getString("patchUrl") ;                        //开启补丁下载                        downLoadPatch(patchUrl) ;                    }                }            }        });    }

下载补丁

Ps:这里需要注意,补丁文件要下载到我们的安装目录,只有我们自己可以访问,如果下载到存储卡,很容易被别人替换,影响app的安全。先贴一段初始化下载目录的代码:

//这句代码,的意思是:在我们app的安装目录新建一个叫“patch”的文件夹,//如果不存在,则创建,路径为: /data/data/app的包名/app_patch,//在安装目录创建的文件夹,均会被加上"app_"File fPatchPath = context.getDir("patch", Context.MODE_PRIVATE) ;//为了保险起见,我们判断一下此路径是否存在,不存在则创建if(fPatchPath.exists()){  fPatchPath.mkdirs() ;}
    /**     * [下载补丁]     * @type {Request}     */    private void downLoadPatch(String downUrl) {        Request request = new Request.Builder()                .get()                .url(downUrl)                .build() ;        Call call = mClient.newCall(request) ;        call.enqueue(new Callback() {            @Override            public void onFailure(Call call, IOException e) {                Log.d(TAG, "the patch download failed...") ;                Log.d(TAG, e.getMessage()) ;            }            @Override            public void onResponse(Call call, Response response) throws IOException {                //请求成功,获取补丁输入流,下面就是文件的读取和保存了                //相信都是平时大家写烂的东西了,就不备注了                byte[] buffer = new byte[2048] ;                int len = 0  ;                OutputStream os = new FileOutputStream(patchPath+File.separator+patchCode+".dex") ;                InputStream is = response.body().byteStream() ;                Log.d(TAG, "start download patch...") ;                while((len=is.read(buffer,0,buffer.length))!=-1){                    os.write(buffer,0, len);                }                os.close();                is.close();                Log.d(TAG, "download patch completion...") ;                //保存当前补丁编码                SharedPreferences sp = context.getSharedPreferences(HOTFIX_SP, Context.MODE_PRIVATE) ;                sp.edit().putString(HOTFIX_CODE, hotfixConfig.getPatchCode()).commit() ;            }        });    }
安装补丁包的2种方案(其实也没多大的意义,可以不看,直接看代码)

对于安装补丁包,我考虑了2种方案

  • 每次生成补丁包xxx.dex的时候,都将以前的补丁包含进去(就是将以前补丁的class文件一起打包成dex补丁包),这样可以保证,无论用户什么时候检测到补丁,都能保证修复所有bug的补丁。这样做:我们每次安装补丁,只需要安装下载的最新补丁包。

  • 每次生成的补丁包都是独立的,不包含之前的补丁,意味着:每次打补丁,都要通过循环的方式,将/data/data/app包名/app_patch/目录下的补丁重新安装一遍。当然,这样做的好处是:用户每次下载的文件大小可以减少一点。

小结:当然,如果采用第二种方式,我们搭建的简易服务器肯定是不行的,因为,如果有的用户没有下载安装上一个补丁,而直接安装了最新的,意味着上一个补丁修复的bug,他将永远带着(除非更新app)。

当然,也不能说第一种方案是完美的,对于用户来讲,dex补丁包越下载越大;对开发人员来说,每次都要保留上次修复bug的class,还要对比,这次有没有对该class做修改,也是比较麻烦的,很容易出错。

这里,虽然我们的服务器暂不完美,暂且使用第二种方式吧,可以多学到一点东西:比如:补丁打包的排序;遍历/app_patch目录安装每一个补丁等。

Ps:既然多个补丁都会安装了,那么,第一种方案的只安装一个补丁,应该是手到擒来的吧~

编写核心代码:安装dex补丁包

在回顾一下我们热修复的原理,核心就一句话:将我们的dex补丁插入到系统加载的dex数组之前,让系统查找类的收,先找到我们补丁中的类,而不再去加载后面的有bug的类。

具体实现步骤

  1. 通过反射机制拿到”PathClassLoader”中的”PathList”对象
  2. 通过反射机制拿到”PathList”对象中的”dexElements”数组
  3. 通过”DexClassLoader” 加载我们的xxx.dex补丁包
  4. 通过反射机制拿到”DexClassLoader”中的”PathList”对象
  5. 通过反射机制拿到”PathList”对象中的”dexElements”数组
  6. 将”DexClassLoader”的”dexElements”插入”PathClassLoader”的dexElements的前面

Ps:上面也说过,PathClassLoader和DexClassLoader均继承自BaseDexClassLoader,重要的方法都在BaseDexClassLoader中,包括”PathList”,所有我们重要反射BaseDexClassLoader就可以了。

安装补丁,就是通过反射机制实现,如果不熟悉反射机制,下面的代码可能会让你像坐过山车一样晕头转向。像了解反射机制的朋友可以看下我的上一篇文章 java/android中的反射机制

代码开撸

/** * [修复aap_patch目录下的所有补丁] * @type {File} */private void fixPatch() {        //获取patch文件夹下所有的补丁文件        File[] files = new File(patchPath).listFiles() ;        if(files.length>0){            //补丁按下载日期排序(最新补丁放前面)            patchSort(files);            for (File file : files) {                //判断file是否为补丁                if(file.isFile() && file.getAbsolutePath().endsWith(".dex")){                    System.out.println("---:"+file.getName());                    //开始加载补丁并修复                    loadPatch(file);                }            }            Log.d(TAG, "fiexd success....") ;        }    }

上面一段代码是遍历补丁文件加下所有的补丁,并对补丁排序。为什么要排序?因为,如果上次的补丁001.dex修复了”类A”的一个bug,而这次的002.dex补丁又对”类A”做了其他修复,那么,如果不排序,遍历补丁文件夹的时候,如果把001.dex补丁打在了002.dex补丁的前面,那么系统会先找到001.dex中的”类A”,002.dex补丁将永远不会生效。

按日期排序

/** * [按最后修改日期排序],排序方式有很多:冒泡排序、快速排序等等, * 这个随便,只要排序后,保证最新的dex补丁在最前面就行 * @type {[type]} */private void patchSort(File[] files){        Arrays.sort(files, new Comparator<File>() {            @Override            public int compare(File file, File t1) {                System.out.println(file.getName()+":"+file.lastModified());                System.out.println(t1.getName()+":"+t1.lastModified());                long d = t1.lastModified() - file.lastModified() ;                //从大到小排序                if(d>0){                    return -1 ;                }else if(d<0){                    return 1 ;                }else{                    return 0 ;                }            }            @Override            public boolean equals(Object obj) {                return true ;            }        });    }

加载并安装dex补丁

/** * 加载并安装补丁 * @type {[type]} */private void loadPatch(File file){        Log.d(TAG, file.getAbsolutePath()) ;        if(file.exists()){            Log.d(TAG,"文件存在...") ;        }else{            Log.d(TAG, "文件不存在...") ;        }        //获取系统PathClassLoader        PathClassLoader pLoader = (PathClassLoader) context.getClassLoader();        //获取PathClassLoader中的PathList        Object pPathList = getPathList(pLoader) ;        if(pPathList == null){            Log.d(TAG, "get PathClassLoader pathlist failed...") ;            return ;        }        //加载补丁        DexClassLoader dLoader = new DexClassLoader(file.getAbsolutePath(),optPath, null, pLoader) ;        //获取DexClassLoader的pathLit,即BaseDexClassLoader中的pathList        Object dPathList = getPathList(dLoader) ;        if(dPathList == null){            Log.d(TAG, "get DexClassLoader pathList failed...") ;            return ;        }        //获取PathList和DexClassLoader的DexElements        Object pElements = getElements(pPathList) ;        Object dElements = getElements(dPathList) ;        //将补丁dElements[]插入系统pElements[]的最前面        Object newElements = insertElements(pElements, dElements) ;        if(newElements == null){            Log.d(TAG, "patch insert failed...") ;            return ;        }        //用插入补丁后的新Elements[]替换系统Elements[]        try {            Field fElements = pPathList.getClass().getDeclaredField("dexElements") ;            fElements.setAccessible(true);            fElements.set(pPathList, newElements);        } catch (Exception e) {            e.printStackTrace();            Log.d(TAG, "fixed failed....") ;            return ;        }    }    /**     * 将补丁插入系统DexElements[]最前端,生成一个新的DexElements[]     * @param pElements     * @param dElements     * @return     */    private Object insertElements(Object pElements, Object dElements){        //判断是否为数组        if(pElements.getClass().isArray() && dElements.getClass().isArray()){            //获取数组长度            int pLen = Array.getLength(pElements) ;            int dLen = Array.getLength(dElements) ;            //创建新数组            Object newElements = Array.newInstance(pElements.getClass().getComponentType(), pLen+dLen) ;            //循环插入            for(int i=0; i<pLen+dLen;i++){                if(i<dLen){                    Array.set(newElements, i, Array.get(dElements, i));                }else{                    Array.set(newElements, i, Array.get(pElements, i-dLen)) ;                }            }            return newElements ;        }        return null ;    }    /**     *  获取DexElements     * @param object     * @return     */    private Object getElements(Object object){        try {            Class<?> c = object.getClass() ;            Field fElements = c.getDeclaredField("dexElements") ;            fElements.setAccessible(true);            Object obj = fElements.get(object) ;            return obj ;        } catch (Exception e) {            e.printStackTrace();        }        return null ;    }    /**     * 通过反射机制获取PathList     * @param loader     * @return     */    private Object getPathList(BaseDexClassLoader loader){        try {            Class<?> c = Class.forName("dalvik.system.BaseDexClassLoader") ;            //获取成员变量pathList            Field fPathList = c.getDeclaredField("pathList") ;            //抑制jvm检测访问权限            fPathList.setAccessible(true);            //获取成员变量pathList的值            Object obj = fPathList.get(loader) ;            return obj ;        } catch (Exception e) {            e.printStackTrace();        }        return null ;    }

上面的代码有点长,但是注释也很详细。如果你熟悉了热修复原理和反射机制,我觉得上面的代码对你来说,完全就是力气活。不熟悉就多敲几遍,熟能生巧。

Ps:记住检测和下载dex补丁,可以在程序的任何位置进行,但是安装dex补丁,一定要在代码逻辑未执行之前,即:在Application中的onCreate方法中安装补丁。
到这一步,热修复基本完成。经测试,可正常修复Activity,但是修复其他类,会报错,重复加载xxx类,就是传说中”CLASS_ISPREVERIFIED问题标志”问题。

解决CLASS_ISPREVERIFIED问题

经过测试,如果不修复CLASS_ISPREVERIFIED问,也是可以修复Activity和Service等类的。

CLASS_ISPREVERIFIED是怎么产生的呢?
dilvk虚拟机通过PathClassLoader加载类的时候,如果A类中用到了类B中的方法,而且A类和B类又在同一个Dex包中,那么B将被会被虚拟机打上CLASS_ISPREVERIFIED标志,再查找到我们的B类,则会报错。

解决方案,大致有2种

  1. QQ的提出的,创建一个”AntilazyLoad”类,并打包成hack.dex,让项目中的每个类都引用hack.dex中的”AntilazyLoad”类,就不会被虚拟机打上CLASS_ISPREVERIFIED标志了。有个牛逼的名字叫:代码入侵。

  2. 利用google的dex分包方案,将app中的某个X类打包带另一个dex中,app中的每个类都引用X类。

想深入了解的可自行差资料,这里我们采用QQ的方案,使用QQ的方案,晚上有很多介绍,都是使用gradle插件,我也不是很了解,也是看的别人的,其实也没那么难。我就不误人子弟了,这里推荐2篇文章,本文解决CLASS_ISPREVERIFIED问题,就是参考的这篇文章。我仅仅会用,可能教不好,还是劳烦各位朋友移步,或者自己百度解决方案。

两篇很有技术含量的文章。

http://blog.csdn.net/lmj623565791/article/details/49883661

http://blog.csdn.net/u010386612/article/details/51131642

总结

其实本文实现的热修复仅仅只是皮毛,仅仅做到了代码修复。当下有太多成熟的热修复框架了,能实现资源、代码、so库等的热修复。我们自己实现热修复是为在使用这些框架的时候,而不至于一脸茫然,不知其所以然。对于这些框架,我个人比较细化阿里系的“Sophix”,这里有一本阿里的Sophix热修复的书,推荐给大家: 深入探索Android热修复技术原理6.29b-final.pdf
贴一张对比图:

这里写图片描述

对于热修复技术框架的选型,大家心里应该清楚自己更需要哪个体系,各有有缺,为大家推荐一篇不错的文章:Android热修复技术选型——三大流派解析,希望对大家有帮助。

Demo源码下载

Demo源码下载

原创粉丝点击