Android 热补丁技术的探索与简单实战----Qzone方案

来源:互联网 发布:淘宝店刷钻是真的吗 编辑:程序博客网 时间:2024/05/20 06:23

Android app客户端与Web app相比的有一个劣势在于web app有更新不需要重新安装程序,而Android app如果有更新则需要重新下载最新版本安装完成更新,这个缺点无疑会给用户带来不小的麻烦与流量的浪费。
那么有没有办法解决这个问题呢?
热补丁技术的出现就是为了解决这个问题,现今我所知道的热补丁技术有淘宝的Dexposed、支付宝的AndFix以及Qzone的超级热补丁方案。

下面我就先总结一下我在使用Qzone的热补丁方案时遇到的问题和所学到的知识:

热补丁修复原理:

我们都知道Android程序要运行需要先编译打包成dex,之后才可以被Android虚拟机解析运行。因此我们如果想要即时修补bug就要让修复的代码被Android虚拟机识别,如何才能让虚拟机认识我们修改过的代码呢,也就是我们需要把修改过的代码打包成单独的dex
因此要实现热补丁修复,第一步就是将修改过后的代码打包成dex的jar包 ,具体打包步骤后面再说。

然后接下来要做的就是如何让虚拟机加载我们修改过后的dex jar包中的类呢?
我们需要了解的是类加载器是如何加载类的。
在Android中 有 2种类加载器:
PathClassLoader和DexClassLoader

对于PathClassLoader,从文档上的注释来看:

Provides a simple {@link ClassLoader} implementation that operates on a list of files and directories in the local file system, but does not attempt to load classes from the network. Android uses this class for its system class loader and for its application class loader(s).

可以看出,Android是使用这个类作为其系统类和应用类的加载器。并且对于这个类呢,只能去加载已经安装到Android系统中的apk文件。

对于DexClassLoader,依然看下注释:

A class loader that loads classes from {@code .jar} and {@code .apk} files containing a {@code classes.dex} entry. This can be used to execute code not installed as part of an application.

这样我们就了解了Android加载类的机制
简单的说 如果Android要加载一个类 就会调用ClassLoader的findClass方法 在dex中查找这个类 找到后加载到内存
而我们要做的就是在findClass的时候让类加载找到的是我们修复过后的类,而不是未修复的类。
举个例子,比如说要修复的类名字叫做NeedFixClass 我们要做的就是 将这个类修改完成过后 打包成dex的jar 然后想办法让类加载去查找我们打包的jar中的NeedFixClass类 而不是先前的NeedFixClass类 这样 加载类的时候使用的就是我们修复过后的代码,而忽略掉原本的有问题的代码。

那么如何让类加载器只找到我们修复过后的类呢???

我们来看一下类加载器查找类时的源码:

#BaseDexClassLoader@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {    Class clazz = pathList.findClass(name);     if (clazz == null) {        throw new ClassNotFoundException(name);    }    return clazz;}#DexPathListpublic Class findClass(String name) {    for (Element element : dexElements) {        DexFile dex = element.dexFile;        if (dex != null) {            Class clazz = dex.loadClassBinaryName(name, definingContext);            if (clazz != null) {                return clazz;            }        }    }    return null;}#DexFilepublic Class loadClassBinaryName(String name, ClassLoader loader) {    return defineClass(name, loader, mCookie);}private native static Class defineClass(String name, ClassLoader loader, int cookie);

当类加载器查找某个类的时候 会调用 findClass方法
BaseDexClassLoader是DexClassLoader和PathClassLoader的父类,其中findClass方法的实现如上述代码所示:

首先:

 Class clazz = pathList.findClass(name); 

pathList是一个DexPathList对象,调用这个对象的findClass方法,如下:

#DexPathListpublic Class findClass(String name) {    for (Element element : dexElements) {        DexFile dex = element.dexFile;        if (dex != null) {            Class clazz = dex.loadClassBinaryName(name, definingContext);            if (clazz != null) {                return clazz;            }        }    }    return null;}

从代码中可以看出首先遍历dexElements集合,一个classloader可以包含多个dex,其中这个集合中的对象就是所有的dex文件,然后调用从头开始遍历所有的dex 如果在dex中找到所需要的类,那么就直接返回,也就是说如果存在多个dex 在前一个dex中找到了需要找到的类,也就不会继续查找其他dex中有没有这个类了。

 Class clazz = dex.loadClassBinaryName(name, definingContext);

在这个dex中查找相应名字的类,之后 defineClass把字节码交给虚拟机就完成了类的加载。

既然类的加载机制我们了解了,那么我们想要加载我们后来打包好的dex文件中的类替换掉原本已有的类,只需要让我们打包的这个dex放到原本的dex之前,就可以覆盖掉原本的有问题的类了。

问题又转变到了如何让我们自己打包的dex文件放到原本的dex文件之前,也就是把我们打包的dex放到dexElements集合的靠前的位置

通俗的说 也就是我们要改变的是dexElements中的内容,在其中添加一个dex 而且放在靠前的位置,而dexElements是 PathClassLoader类中的一个成员变量。

说到这应该已经知道怎么改变了吧,如果想改变一个类中的字段,可这个字段又是私有的,我们可以通过反射来改变它,下面就是利用反射把我们自己的dex放到dexElements中了,这个不是很复杂,对反射有一定了解都可以实现,这里就不细说了。

到这里应该就已经对这种热补丁修复了解了吧。

其中在Qzone的修复方案中还提到了一个问题,如果你在一个A类中引用了B类,而后来我们发现了B类中有错误,需要热补丁修复,这时候需要把B类单独打包在一个jar中,假设为patch.jar 而A类在原本的jar中,假设为classes.jar ,这时候就可能出现问题,出现问题的原因是A类所在的jar和B类所在的jar不一致,因为B类是我们后来打包进去的jar,所以不一致,但这个问题是可以解决的,在什么情况才会出现这个问题呢?

1. 验证clazz->directMethods方法,directMethods包含了以下方法:    1. static方法    2. private方法    3. 构造函数2. clazz->virtualMethods    1. 虚函数=override方法?

如果在上述方法中直接引用到的类都和当前类在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED标记,打上这个标记的在加载类时如果发现不在同一个dex中 就会报错, 那么要解决这个问题,也就是让类中调用一下一个其他的dex中的类就可以了。

Qzone给出的解决方法是 使用javassist框架 动态在 原本的A类的构造函数中 增加一行代码,该行代码需要做的就是调用一个其他dex中的类,这个dex最好是单独的,这样就可以解决这个问题了。

至此,差不多解释完了热补丁修复的原理。

热补丁修复方案实战:

现在有一个简单的项目,其中只有一个MainActivity 和 一个BugClass 两个java文件。

public class MainActivity extends AppCompatActivity {    private TextView txt;    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        setContentView(R.layout.activity_main);        txt = (TextView) findViewById(R.id.txt);        txt.setText(""+new BugClass().obtainString());    }}
public class BugClass {    private String content;    public String getContent() {        return content;    }    public void setContent(String content) {        this.content = content;    }    public String obtainString(){        return "这是我动态修复之前的代码";    }}

在MainAcivity类中 调用 BugClass的obtainString方法 在TextView中输出一段话。

程序运行后 如下:
修复前的界面

现在假设我们遇到了这样一种情况,我们发现BugClass这个类 我们写错了 下面的写法才是正确的:

public class BugClass {    private String content;    public String getContent() {        return content;    }    public void setContent(String content) {        this.content = content;    }    public String obtainString(){        return "这是我动态修复之后的代码asdasf";    }}

而我们又不想重新安装程序,这时候我们就需要使用热补丁修复技术了。

第一步:
修改原本要修改的类BugClass 改成正确的代码,之后将BugClass打包成dex 打包的过程很简单,先将BugClass.java 使用 javac命令编译成BugClass.class文件, 创建一个和BugClass.class的包名相同的文件路径把BugClass.class放进去 比如 我的BugClass.java的 全类名是 com.yangsheng.ydzd_lb.hotfixpro.BugClass 那么 我就需要创建一个com/yangsheng/ydzd_lb/hotfixpro文件夹 把BugClass.class放到这个文件夹下 (假如我的这个路径是在 E盘 app路径下的)然后在命令行运行命令
*dx –dex –output patch_dex.jar E:\app*
这个命令需要做的是 把 E:\app\下的文件打包成patch_dex.jar 其中 dx工具在android sdk build-tools\版本号 下 可以cd到该目录 执行 或者 把路径配置到环境变量 执行,如果配置到环境变量后 不要忘记重新打开cmd窗口 否则不生效。 这样 就完成了 新修改的BugClass的打包工作

第二步:
下一步就是将第一步打包成的jar 放到dexElements集合的前面 ,这里我直接使用的HotFix的代码,修复的代码写在Application中,在程序启动时就执行。

public class MyApplication extends Application {    @Override    public void onCreate() {        super.onCreate();        File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "patch_dex.jar");//这句代码的主要目的是创建一个文件叫 patch_dex.jar  用于后来将我们修复后的jar写到这个文件中        Utils.prepareDex(this.getApplicationContext(), dexPath, "patch_dex.jar");  //使用流把我们修复后的jar写到上一行代码的路径中 此处我把修复后的jar放在assest中了,如果是在服务器或者本地,也是一样的 只要保证可以把这个dex写到上述路径就可以了 使用流        HotFix.patch(this, dexPath.getAbsolutePath(), "com.yangsheng.ydzd_lb.hotfixpro.BugClass"); //核心代码  把修复后的dex插入到dexElements的前面        try        {            this.getClassLoader().loadClass("com.yangsheng.ydzd_lb.hotfixpro.BugClass");        } catch (ClassNotFoundException e)        {            e.printStackTrace();        }    }}

patch方法实现:

 public static void patch(Context context, String patchDexFile, String patchClassName) {        if (patchDexFile != null && new File(patchDexFile).exists()) {            try {                if (hasLexClassLoader()) {                    injectInAliyunOs(context, patchDexFile, patchClassName);                } else if (hasDexClassLoader()) {                    injectAboveEqualApiLevel14(context, patchDexFile, patchClassName); //由于外部的dex是使用DexClassLoader 所以执行此行代码                  } else {                    injectBelowApiLevel14(context, patchDexFile, patchClassName);                }            } catch (Throwable th) {            }        }    }

injectAboveEqualApiLevel14方法实现:
首先我们要了解 这几个变量之间的关系:
pathList dexElements BaseDexClassLoader
BaseDexClassLoader类中 有一个pathList成员 而pathList中存放有 dexElements这个集合,此集合中存放的就是要查找的dex文件

/**参数一 上下文参数二 要插入的dex的文件的路径参数三 要加载的类的名称*/private static void injectAboveEqualApiLevel14(Context context, String dexFilePath, String className)            throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader(); //获取类加载器        Object dexElements = getDexElements(getPathList(pathClassLoader));  //获取类加载器加载的原有的dex的dexElements        Object fixDexElements = getDexElements(getPathList(                new DexClassLoader(dexFilePath, context.getDir("dex", 0).getAbsolutePath(), dexFilePath, context.getClassLoader())));//获取修复后的dexElements        Object a = combineArray(dexElements, fixDexElements); //合并 插入        Object a2 = getPathList(pathClassLoader);        setField(a2, a2.getClass(), "dexElements", a);  //反射设置pathList的dexElements字段为 合并后的a        pathClassLoader.loadClass(className); //加载类    }

getPathList:
通过反射获取classloader中的 pathList变量

  private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,            IllegalAccessException {        return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");    }

getDexElements:
通过反射获取 PathList中dexElements成员

  private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {        return getField(obj, obj.getClass(), "dexElements");    }

getField:
反射的真正实现 获取 obj对象中的 str名称的成员 cls 是 str名称的所在类

private static Object getField(Object obj, Class cls, String str)            throws NoSuchFieldException, IllegalAccessException {        Field declaredField = cls.getDeclaredField(str);        declaredField.setAccessible(true);        return declaredField.get(obj);    }

combineArray:
合并两个集合

 private static Object combineArray(Object obj, Object obj2) {        Class componentType = obj2.getClass().getComponentType();        int length = Array.getLength(obj2);        int length2 = Array.getLength(obj) + length;        Object newInstance = Array.newInstance(componentType, length2);        for (int i = 0; i < length2; i++) {            if (i < length) {                Array.set(newInstance, i, Array.get(obj2, i));            } else {                Array.set(newInstance, i, Array.get(obj, i - length));            }        }        return newInstance;    }

第三步:
启动程序,运行结果如下
修复后结果

这样就完成了热补丁修复 。
由于实例简单,并没有遇到上面所说的 class不在同一个dex的问题,如果出现 按照上面所说的处理即可。

参考博客:
Android 热补丁动态修复框架小结

安卓App热补丁动态修复技术介绍

有其他问题可以在这两篇博客中寻找 都写得非常详细

1 0
原创粉丝点击