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

本文转载:http://blog.csdn.net/lmj623565791/article/details/49883661

1.背景

当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装。有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布。

这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?

虽然Android系统并没有提供这个技术,但是很幸运的告诉大家,答案是:热补丁动态修复技术来解决以上这些问题。

2.热修复的原理

Android的ClassLoader体系,android中加载类一般使用的是PathClassLoaderDexClassLoader。

 

对于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.

可以看出,该类呢,可以用来从.jar和.apk类型的文件内部加载classes.dex文件。可以用来执行非安装的程序代码。

ok,如果大家对于插件化有所了解,肯定对这个类不陌生,插件化一般就是提供一个apk(插件)文件,然后在程序中load该apk,那么如何加载apk中的类呢?其实就是通过这个DexClassLoader,具体的代码我们后面有描述。

  

PathClassLoaderDexClassLoader都继承自BaseDexClassLoader。在BaseDexClassLoader中有如下源码:

#BaseDexClassLoader
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    Class clazz = pathList.findClass(name);

    if (clazz == null) {
        throw new ClassNotFoundException(name);
    }

    return clazz;
}

#DexPathList
public 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;
}

#DexFile
public Class loadClassBinaryName(String name, ClassLoader loader) {
    return defineClass(name, loader, mCookie);
}
private native static Class defineClass(String name, ClassLoader loader, int cookie);

 

可以看出呢,BaseDexClassLoader中有个pathList对象,pathList中包含一个DexFile的集合dexElements,而对于类加载呢,就是遍历这个集合,通过DexFile去寻找。

一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。(来自:安卓App热补丁动态修复技术介绍)

注意下,是阻止引用者的类,也就是说,假设你的app里面有个类叫做LoadBugClass,再其内部引用了BugClass。发布过程中发现BugClass有编写错误,那么想要发布一个新的BugClass类,那么你就要阻止LoadBugClass这个类打上CLASS_ISPREVERIFIED的标志。

也就是说,你在生成apk之前,就需要阻止相关类打上CLASS_ISPREVERIFIED的标志了。对于如何阻止,上面的文章说的很清楚,让LoadBugClass在构造方法中,去引用别的dex文件,比如:hack.dex中的某个类即可。

其实就是两件事:1、动态改变BaseDexClassLoader对象间接引用的dexElements;2、在app打包的时候,阻止相关类去打上CLASS_ISPREVERIFIED标志。

 

3.解决方案

该方案基于android dex分包方案。就是把多个dex文件塞入到app的classloader中。

一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。

概括一下就是如果以上方法中直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED:

所以为了实现补丁方案,所以必须从这些方法中入手,防止类被打上CLASS_ISPREVERIFIED标志。

最终空间的方案是往所有类的构造函数里面插入了一段代码,代码如下:

if (ClassVerifier.PREVENT_VERIFY) {

System.out.println(AntilazyLoad.class);

}

中AntilazyLoad类会被打包成单独的hack.dex,这样当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作。

然后在应用启动的时候加载进来.AntilazyLoad类所在的dex包必须被先加载进来,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包,那么他也是不存在的,这样屏幕就会出现茫茫多的类AntilazyLoad找不到的log。

所以Application作为应用的入口不能插入这段代码。(因为载入hack.dex的代码是在Application中onCreate中执行的,如果在Application的构造函数里面插入了这段代码,那么就是在hack.dex加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志)

如何打包补丁包:

1. 空间在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。

2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包。

备注:该方案现在也应用到我们的编译过程当中,编译不需要重新打包dex,只需要把修改过的类的class文件打包成patch dex,然后放到sdcard下,那么就会让改变的代码生效。

 

4.阻止相关类打上CLASS_ISPREVERIFIDE标志

大致的流程是:在dx工具执行之前,将LoadBugClass.class文件呢,进行修改,再其构造中添加System.out.println(dodola.hackdex.AntilazyLoad.class),然后继续打包的流程。注意:AntilazyLoad.class这个类是独立在hack.dex中。

(1)如何修改一个类的class文件?

答:使用javassist来操作。

1、首先新建几个类:

  package dodola.hackdex;

    public class AntilazyLoad {

  }

  package dodola.hotfix;

    public class BugClass {

    public String bug() {

      return "bug class";

    }

   }

  package dodola.hotfix;

    public class LoadBugClass {

      public String getBugString() {

        BugClass bugClass = new BugClass(); return bugClass.bug();

      }

    }

操作类:

 package test;

import javassist.ClassPool;

import javassist.CtClass;

import javassist.CtConstructor;

public class InjectHack {

public static void main(String[] args) {

try {

String path = "/Users/zhy/develop_work/eclipse_android/imooc/JavassistTest/";

ClassPool classes = ClassPool.getDefault();

classes.appendClassPath(path + "bin");//项目的bin目录即可

CtClass c = classes.get("dodola.hotfix.LoadBugClass");

CtConstructor ctConstructor = c.getConstructors()[0];

ctConstructor .insertAfter("System.out.println(dodola.hackdex.AntilazyLoad.class);");

c.writeFile(path + "/output");

}

catch (Exception e) {

e.printStackTrace();

}

}

}

ok,点击run即可了,注意项目中导入javassist-*.jar的包。首先拿到ClassPool对象,然后添加classpath,如果你有多个classpath可以多次调用。然后从classpath中找到LoadBugClass,拿到其构造方法,在其最后插入一行代码。ok,代码很好懂。

(2)如何在dx之前去进行(1)的操作

在执行dx之前,会先执行processWithJavassist这个任务。这个任务的作用呢,就和我们上面的代码一致了。而且源码也给出了,大家自己看下。

ok,到这呢,你就可以点击run了。ok,

 

5.动态改变BaseDexClassLoader对象间引用的dexElements


通过反射机制动态改变一个对象的某个引用。

具体做法:

jar cvf hack.jar dodola/hackdex/*
dx  --dex --output hack_dex.jar hack.jar 
cd进入项目文件后输入jar -cvf [jar包的名字] [需要打包的文件]————生成.jar文件

我们的app中部门类引用了AntilazyLoad.class,那么我们必须在应用启动的时候,降这个hack_dex.jar插入到dexElements,否则肯定会出事故的。

那么,Application的onCreate方法里面就很适合做这件事情,我们把hack_dex.jar放到assets目录

package dodola.hotfix;

import android.app.Application;

import android.content.Context;

import java.io.File;

import dodola.hotfixlib.HotFix;

/** * Created by sunpengfei on 15/11/4. */

public class HotfixApplication extends Application {

@Override public void onCreate() {

super.onCreate();

File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hackdex_dex.jar");

Utils.prepareDex(this.getApplicationContext(), dexPath, "hackdex_dex.jar");

HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hackdex.AntilazyLoad");

try {

this.getClassLoader().loadClass("dodola.hackdex.AntilazyLoad");

}

catch (ClassNotFoundException e) {

e.printStackTrace();

}

}

}

ok,在app的私有目录创建一个文件,然后调用Utils.prepareDex将assets中的hackdex_dex.jar写入该文件。 
接下来HotFix.patch就是去反射去修改dexElements了。

package dodola.hotfix;

/** * Created by sunpengfei on 15/11/4. */

public class Utils {

private static final int BUF_SIZE = 2048;

public static boolean prepareDex(Context context,

File dexInternalStoragePath, String dex_file) {

BufferedInputStream bis = null;

OutputStream dexWriter = null;

bis = new BufferedInputStream(context.getAssets().open(dex_file));

dexWriter = new BufferedOutputStream(new FileOutputStream(dexInternalStoragePath));

byte[] buf = new byte[BUF_SIZE];

int len;

while ((len = bis.read(buf, 0, BUF_SIZE)) > 0) {

dexWriter.write(buf, 0, len);

}

dexWriter.close();

bis.close();

return true;

}

其实就是文件的一个读写,将assets目录的文件,写到app的私有目录中的文件。

主要看patch方法

/*
 * Copyright (C) 2015 Baidu, Inc. All Rights Reserved.
 */
package dodola.hotfixlib;

import android.annotation.TargetApi;
import android.content.Context;

import java.io.File;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;

import dalvik.system.DexClassLoader;
import dalvik.system.PathClassLoader;

/* compiled from: ProGuard */
public final class HotFix
{
    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);
                } else
                {

                    injectBelowApiLevel14(context, patchDexFile, patchClassName);

                }
            } catch (Throwable th)
            {
            }
        }
    }
 }

这里很据系统中ClassLoader的类型做了下判断,原理都是反射,我们看其中一个分支hasDexClassLoader();

private static boolean hasDexClassLoader()
{
    try
    {
        Class.forName("dalvik.system.BaseDexClassLoader");
        return true;
    } catch (ClassNotFoundException e)
    {
        return false;
    }
}


 private static void injectAboveEqualApiLevel14(Context context, String str, String str2)
            throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException
{
    PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
    Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
            getDexElements(getPathList(
                    new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))));
    Object a2 = getPathList(pathClassLoader);
    setField(a2, a2.getClass(), "dexElements", a);
    pathClassLoader.loadClass(str2);
}

首先查找类dalvik.system.BaseDexClassLoader,如果找到则进入if体。

在injectAboveEqualApiLevel14中,根据context拿到PathClassLoader,然后通过getPathList(pathClassLoader),拿到PathClassLoader中的pathList对象,在调用getDexElements通过pathList取到dexElements对象。

ok,那么我们的hack_dex.jar如何转化为dexElements对象呢?

通过源码可以看出,首先初始化了一个DexClassLoader对象,前面我们说过DexClassLoader的父类也是BaseDexClassLoader,那么我们可以通过和PathClassLoader同样的方式取得dexElements。

ok,到这里,我们取得了,系统中PathClassLoader对象的间接引用dexElements,以及我们的hack_dex.jar中的dexElements,接下来就是合并这两个数组了。

可以看到上面的代码使用的是combineArray方法。

合并完成后,将新的数组通过反射的方式设置给pathList.

接下来看一下反射的细节:

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

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

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

其实都是取成员变量的过程,应该很容易懂~~

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

ok,这里的两个数组合并,只需要注意一件事,将hack_dex.jar里面的dexElements放到新数组前面即可。

到此,我们就完成了在应用启动的时候,动态的将hack_dex.jar中包含的DexFile注入到ClassLoader的dexElements中。这样就不会查找不到AntilazyLoad这个类了。

ok,那么到此呢,还是没有看到我们如何打补丁,哈,其实呢,已经说过了,打补丁的过程和我们注入hack_dex.jar是一致的。

你现在运行HotFix的app项目,点击menu里面的测试

会弹出:调用测试方法:bug class

6.完成热修复

ok,那么我们假设BugClass这个类有错误,需要修复:

package dodola.hotfix;

public class BugClass
{
    public String bug()
    {
        return "fixed class";
    }
}

可以看到字符串变化了:bug class -> fixed class .

然后,编译,将这个类的class->jar->dex。步骤和上面是一致的。

 jar cvf path.jar dodola/hotfix/BugClass.class 
 dx  --dex --output path_dex.jar path.jar 

拿到path_dex.jar文件。

正常情况下,这个玩意应该是下载得到的,当然我们介绍原理,你可以直接将其放置到sdcard上。

然后在Application的onCreate中进行读取,我们这里为了方便也放置到assets目录,然后在Application的onCreate中添加代码:

public class HotfixApplication extends Application
{

    @Override
    public void onCreate()
    {
        super.onCreate();
        File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hackdex_dex.jar");
        Utils.prepareDex(this.getApplicationContext(), dexPath, "hack_dex.jar");
        HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hackdex.AntilazyLoad");
        try
        {
            this.getClassLoader().loadClass("dodola.hackdex.AntilazyLoad");
        } catch (ClassNotFoundException e)
        {
            e.printStackTrace();
        }

        dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "path_dex.jar");
        Utils.prepareDex(this.getApplicationContext(), dexPath, "path_dex.jar");
        HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hotfix.BugClass");

    }
}

其实就是添加了后面的3行,这里需要说明一下,第一行依旧是复制到私有目录,如果你是sdcard上,那么操作基本是一致的,这里就别问:如果在sdcard或者网络上怎么处理~

 

posted @ 2017-05-24 17:20  想不起来的角落  阅读(511)  评论(0编辑  收藏  举报