阿里百川HotFix2.0热修复初体验

一、什么是热修复

热修复说白了就是”打补丁”,比如你们公司上线一个app,用户反应有重大bug,需要紧急修复。如果按照通
常做法,那就是程序猿加班搞定bug,然后测试,重新打包并发布。这样带来的问题就是成本高,效率低。于是,热
修复就应运而生.一般通过事先设定的接口从网上下载无Bug的代码来替换有Bug的代码。这样就省事多了,用
户体验也好。(如下图所示:Android 插件化技术的三个技术点以及它们的应用场景)


二、热修复原理


ClassLoader

在 Java 中,要加载一个类需要用到 ClassLoader 。

Android 中有三个 ClassLoader, 分别为 URLClassLoader 、 PathClassLoader 、 DexClassLoader 。其中

  • URLClassLoader 只能用于加载jar文件,但是由于 dalvik 不能直接识别jar,所以在 Android 中无法使用这个加载器。
  • PathClassLoader 它只能加载已经安装的apk。因为 PathClassLoader 只会去读取 /data/dalvik-cache 目录下的 dex 文件。例如我们安装一个包名为 com.allan.xxx 的 apk,那么当 apk 安装过程中,就会在 /data/dalvik-cache 目录下生产一个名为 data@[email protected]@classes.dex 的 ODEX 文件。在使用 PathClassLoader 加载 apk 时,它就会去这个文件夹中找相应的 ODEX 文件,如果 apk 没有安装,自然会报 ClassNotFoundException 。
  • DexClassLoader 是最理想的加载器。它的构造函数包含四个参数:

    1.dexPath,指目标类所在的APK或jar文件的路径.类装载器将从该路径中寻找指定的目标类,
      该类必须是APK或jar的全路径.如果要包含多个路径,路径之间必须使用特定的分割符分隔,
      特定的分割符可以使用System.getProperty(“path.separtor”)获得.
    2.dexOutputDir,由于dex文件被包含在APK或者Jar文件中,因此在装载目标类之前需要先从APK或Jar文件
      中解压出dex文件,该参数就是制定解压出的dex 文件存放的路径.在Android系统中,
      一个应用程序一般对应一个Linux用户id,应用程序仅对属于自己的数据目录路径有写的权限,
      因此,该参数可以使用该程序的数据路径.
    3.libPath,指目标类中所使用的C/C++库存放的路径
    4.classload,是指该装载器的父装载器,一般为当前执行类的装载器


framework源码 中的 dalvik.system 包下,找到 DexClassLoader 源码,实际内容是在它的父类 BaseDexClassLoader 中,顺带一提,这个类最低在API14开始有用。包含了两个变量:

private final String originalPath;
private final DexPathList pathList;
//pathList就是多dex的结构列表,查看 其源码:
/** class definition context */
private final ClassLoader definingContext;

/** list of dex/resource (class path) elements */
private final Element[] dexElements;

/** list of native library directory elements */
private final File[] nativeLibraryDirectories;
dexElements 就是一个dex列表,那么我们就可以把每个 Element 当成是一个 dex。 

看下PathClassLoader代码

public class PathClassLoader extends BaseDexClassLoader {

    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
} 


DexClassLoader代码

public class DexClassLoader extends BaseDexClassLoader {

    public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}

两个ClassLoader就两三行代码,只是调用了父类的构造函数.

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

    @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 构造函数中创建一个DexPathList类的实例,这个DexPathList的构造函数会创建一个dexElements 数组

public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) {
        ... 
        this.definingContext = definingContext;
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        //创建一个数组
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions);
        ... 
    }

然后BaseDexClassLoader 重写了findClass方法,调用了pathList.findClass,跳到DexPathList类中.

/* package */final class DexPathList {
    ...
    public Class findClass(String name, List<Throwable> suppressed) {
            //遍历该数组
        for (Element element : dexElements) {
            //初始化DexFile
            DexFile dex = element.dexFile;

            if (dex != null) {
                //调用DexFile类的loadClassBinaryName方法返回Class实例
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }       
        return null;
    }
    ...
} 
会遍历这个数组,然后初始化DexFile,如果DexFile不为空那么调用DexFile类的loadClassBinaryName方法返回Class实例.

此时我们整理一下思路,DexClassLoader 包含有一个dex数组 Element[] dexElements ,其中每个dex文件是一个Element,当需要加载类的时候会遍历 dexElements,如果找到类则加载,如果找不到从下一个 dex 文件继续查找。

那么我们的实现就是把这个插件 dex 插入到 Elements 的最前面,这么做的好处是不仅可以动态的加载一个类,并且由于 DexClassLoader 会优先加载靠前的类,所以我们同时实现了宿主 apk 的热修复功能。


三、热修复之HotFix2.0体验

先来看一下他的通俗易懂的原理图




接下来说一下如何实现hotfix,首先打开阿里百川网址http://baichuan.taobao.com,下载最新官方的SDK,其中包括打包工具,调试工具

1.首先在阿里百川的我的应用中添加一个个人应用,记下来应用的APP IDAPPKey,和RSA密钥


2.创建好应用后,打开AndroidStudio新建一个Project,然后集成hotfix

gradle远程仓库依赖, 打开项目找到app的build.gradle文件,添加如下配置:

添加maven仓库地址:

repositories {
   maven {
       url "http://repo.baichuan-android.taobao.com/content/groups/BaichuanRepositories"
   }
}

添加gradle坐标版本依赖:

dependencies {
    compile 'com.taobao.android:alisdk-hotfix:2.0.9'
}

如果编译期间报utdid重复, 所以此时进行如下处理即可, 关闭传递性依赖:

compile ('com.taobao.android:alisdk-hotfix:2.0.9') {
     exclude(module:'utdid4all')
}

添加权限

<! -- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<! -- 外部存储读权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

如果API 23以上别忘了添加运行时权限,因为读写SD卡为危险权限


配置AndroidManifest文件,这时用到我们前面所说的那三个id,key,密钥了

AndroidManifest.xml中间的application节点下添加如下配置,吧value的数值改成那三个即可:

<meta-data
android:name="com.taobao.android.hotfix.IDSECRET"
android:value="App ID" />
<meta-data
android:name="com.taobao.android.hotfix.APPSECRET"
android:value="App Secret" />
<meta-data
android:name="com.taobao.android.hotfix.RSASECRET"
android:value="RSA密钥" />
3.接下来进行初始化工做,官方文档建议在App全局的Application的onCreate()方法进行初始化

SophixManager.getInstance().setContext(this)
                .setAppVersion(appVersion)
                .setAesKey(null)
                .setEnableDebug(true)
                .setPatchLoadStatusStub(new PatchLoadStatusListener() {
                    @Override
                    public void onLoad(final int mode, final int code, final String info, final int handlePatchVersion) {
                        // 补丁加载回调通知
                        if (code == PatchStatus.CODE_LOAD_SUCCESS) {
                            // 表明补丁加载成功
                        } else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {
                            // 表明新补丁生效需要重启. 开发者可提示用户或者强制重启;
                            // 建议: 用户可以监听进入后台事件, 然后应用自杀
                        } else if (code == PatchStatus.CODE_LOAD_FAIL) {
                            // 内部引擎异常, 推荐此时清空本地补丁, 防止失败补丁重复加载
                            // SophixManager.getInstance().cleanPatches();
                        } else {
                            // 其它错误信息, 查看PatchStatus类说明
                        }
                    }
                }).initialize();
SophixManager.getInstance().queryAndLoadNewPatch();

》》》》》》》》》》》》下面内容摘取自官方文档《《《《《《《《《《《《《
1.3.2 接口说明
1.3.2.1 initialize方法

    initialize(): <必选>
    该方法主要做些必要的初始化工作以及如果本地有补丁的话会加载补丁, 但不会自动请求补丁。因此需要自行调用queryAndLoadNewPatch方法拉取补丁。这个方法调用需要尽可能的早, 推荐在Application的onCreate方法中调用, initialize()方法调用之前你需要先调用如下几个方法, 方法调用说明如下:
    setContext(this): <必选> Application上下文context
    setAppVersion(appVersion): <必选> 应用的版本号
    setAesKey(aesKey): <可选> 用户自定义aes秘钥, 会对补丁包采用对称加密。这个参数值必须是16位数字或字母的组合,是和补丁工具设置里面AES Key保持完全一致, 补丁才能正确被解密进而加载。此时平台无感知这个秘钥, 所以不用担心百川平台会利用你们的补丁做一些非法的事情。
    setEnableDebug(true/false): <可选> 默认为false, 是否调试模式, 调试模式下会输出日志以及不进行补丁签名校验. 线下调试此参数可以设置为true, 查看日志过滤TAG:Sophix, 同时强制不对补丁进行签名校验, 所有就算补丁未签名或者签名失败也发现可以加载成功. 但是正式发布该参数必须为false, false会对补丁做签名校验, 否则就可能存在安全漏洞风险
    setPatchLoadStatusStub(new PatchLoadStatusListener()): <可选> 设置patch加载状态监听器, 该方法参数需要实现PatchLoadStatusListener接口, 接口说明见1.3.2.2说明
    setUnsupportedModel(modelName, sdkVersionInt):<可选> 把不支持的设备加入黑名单,加入后不会进行热修复。modelName为该机型上Build.MODEL的值,这个值也可以通过adb shell getprop | grep ro.product.model取得。sdkVersionInt就是该机型的Android版本,也就是Build.VERSION.SDK_INT,若设为0,则对应该机型所有安卓版本。

1.3.2.2 queryAndLoadNewPatch方法

该方法主要用于查询服务器是否有新的可用补丁. SDK内部限制连续两次queryAndLoadNewPatch()方法调用不能短于3s, 否则的话就会报code:19的错误码. 如果查询到可用的话, 首先下载补丁到本地, 然后

    应用原本没有补丁, 那么如果当前应用的补丁是热补丁, 那么会立刻加载(不管是冷补丁还是热补丁). 如果当前应用的补丁是冷补丁, 那么需要重启生效.
    应用已经存在一个补丁, 首先会把之前的补丁文件删除, 然后不立刻加载, 而是等待下次应用重启再加载该补丁
    补丁在后台发布之后, 并不会主动下行推送到客户端, 需要手动调用queryAndLoadNewPatch方法查询后台补丁是否可用.
    只会下载补丁版本号比当前应用存在的补丁版本号高的补丁, 比如当前应用已经下载了补丁版本号为5的补丁, 那么只有后台发布的补丁版本号>5才会重新下载.

同时1.4.0以上版本服务后台上线了“一键清除”补丁的功能, 所以如果后台点击了“一键清除”那么这个方法将会返回code:18的状态码. 此时本地补丁将会被强制清除, 同时不清除本地补丁版本号
1.3.2.3 cleanPatches()方法

清空本地补丁
1.3.2.4 PatchLoadStatusListener接口

该接口需要自行实现并传入initialize方法中, 补丁加载状态会回调给该接口, 参数说明如下:

    mode: 补丁模式, 0:正常请求模式 1:扫码模式 2:本地补丁模式
    code: 补丁加载状态码, 详情查看PatchStatusCode类说明
    info: 补丁加载详细说明, 详情查看PatchStatusCode类说明
    handlePatchVersion: 当前处理的补丁版本号, 0:无 -1:本地补丁 其它:后台补丁

这里列举几个常见的code码说明, 详情查看SDK中PatchStatus类的代码,其中有具体说明

    code: 1 补丁加载成功
    code: 6 服务端没有最新可用的补丁
    code: 11 RSASECRET错误,官网中的密钥是否正确请检查
    code: 12 当前应用已经存在一个旧补丁, 应用重启尝试加载新补丁
    code: 13 补丁加载失败, 导致的原因很多种, 比如UnsatisfiedLinkError等异常, 此时应该严格检查logcat异常日志
    code: 16 APPSECRET错误,官网中的密钥是否正确请检查
    code: 18 一键清除补丁
    code: 19 连续两次queryAndLoadNewPatch()方法调用不能短于3s



4. 生成补丁
patch补丁包生成需要使用到打补丁工具SophixPatchTool, 如还未下载打包工具,请前往文档SDK下载&版本更新记录下载Android打包工具。
打开这个工具

  • 旧包:<必填> 选择基线包路径(有问题的APK)
  • 新包:<必填> 选择新包路径(修复过该问题APK)
  • 日志:打开日志输出窗口。
  • 高级:展开高级选项,见2.2.1。
  • 设置:配置其他信息。
  • GO!:开始生成补丁!
按照说明先选择旧版本(有Bug)的apk包,和新版本的apk包,接下来我来演示一个Demo
【下面这两个Demo的app我就把有bug的叫做bapp 】
1>首先看bapp的源码【篇幅有限,只看有bug的部分】

    /**
     * 监听返回键
     */
//    @Override
//    public void onBackPressed() {
//        if(drawerLayout.isDrawerOpen(nv)){
//            drawerLayout.closeDrawers();
//            return;
//        }
//        if (System.currentTimeMillis() - newTime > 2000) {
//            newTime = System.currentTimeMillis();
//            Snackbar snackbar = Snackbar.make(pager, "再按一次返回键退出程序", Snackbar.LENGTH_SHORT);
//            snackbar.getView().setBackgroundColor(getResources().getColor(R.color.colorPrimary));
//            snackbar.show();
//        } else {
//            finish();
//        }
//    }

很简单,在MainActivity中,将返回键退出程序的监听给注释掉,也就是,bapp点击返回键会没有任何效果
2>然后app的源码,就不赘述了,很简单,就是取消bapp的注释,让程序重新可以监听返回键退出
下面我们来编译baap和app
【我们的目的是通过SophixPatchTool将这两个app对比,生成一个补丁,在不推送升级app的情况下修复bapp的不能退出程序的bug】

使用SophixPatchTool,按照提示选择bapp和app,点击Go,生成一个baichuan-hotfix-patch.jar,然后将这个jar文件
移动到手机的根目录,然后打开我们上面下载的Sophix调试工具(一个安卓端的apk),接下来请看高端操作
上gif:::
【讲解下这个图片,首先这个Demo是bapp,不具备点击返回键退出功能,在Sophix调试工具上输入bapp包名,输入jar补丁绝对路径,点击应用补丁,重启bapp后,bapp就修复了不能点击返回键退出的bug,理论上不用重启】


差不多也就这些了,有什么问题可以留言交流

猜你喜欢

转载自blog.csdn.net/Allan_Bst/article/details/72904721