AndFix热修复 —— 实战与源码解析

当你的应用发布后第二天却发现一个重要的bug要修复,头疼的同时你可能想着赶紧修复重新打个包发布出去,让用户收到自动更新重新下载。但是万事皆有可能,万一隔一天又发现一个急需修复的bug呢?难道再次发布打扰用户一次?

这个时候就是热修复技术该登场的时候了,它可以让你在无需发布新版本的前提下修复小范围的问题。最近研究了下几个热修复的开源框架,其中Nuwa等框架的原理是修改了gradle的编译task流程,替换dex的方式来实现。但是可惜的是gradle plugin在1.5以后取消了predexdebug这个task,而Nuwa恰恰是依赖这个task的,所以导致Nuwa在gradle plugin1.5版本后无法使用。

所以我们这里将探讨另一个热修复框架AndFix,它的原理简单而纯粹。本文将从实战项目应用和原理两个角度来阐述,同时将阐述项目中引用该框架后带来的影响(微乎其微)。

 

引入


 

首先AndFix的主要实现是CPP实现,而且只有几个很小的文件。同时提供了dalvik和ART两个版本的so通过JNI供上层Java层调用。所以显然AndFix的一个最大优点是支持Dalvik和ART两种运行时环境,同时它支持Android2.3 - 6.0版本,支持arm和x86架构CPU的设备。改框架的作者团队是支付宝,相传已经应用到了阿里巴巴的一些应用上(真实性不详)

首先在你的项目中添加以下gradle依赖:

 

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

 

随后在你的自定义Application中加入一个属性,同时添加getter方法,这里后面要用到:

    private PatchManager patchManager;
public PatchManager getPatchManager() {
     return patchManager;
}

然后在Application的onCreate中初始化AndFix:

// init AndFix
patchManager = new PatchManager(this);
patchManager.init(AppUtils.getVersionName(this));
patchManager.loadPatch();

同时继续写上这么一段代码:

// get patch under new thread
Intent patchDownloadIntent = new Intent(this, PatchDownloadIntentService.class);
patchDownloadIntent.putExtra("url", "http://xxx/patch/app-release-fix-shine.apatch");
startService(patchDownloadIntent);

这段代码的含义后面讲具体阐述,这里你只需要知道我们新建了一个IntentService在另起的线程中下载http://xxx/patch/app-release-fix-shine.apatch这个patch文件,然后下载完毕后调用patchManager进行热修复工作。

详细的PatchDownloadIntentService代码:

/**
 * 用于下载Patch热修复文件的service
 */
public class PatchDownloadIntentService extends IntentService {

    private int fileLength, downloadLength;

    public PatchDownloadIntentService() {
        super("PatchDownloadIntentService");
    }

    @Override
    protected void onHandleIntent(Intent intent) {
        if (intent != null) {
            String downloadUrl = intent.getStringExtra("url");

            if (StrUtils.isNotNull(downloadUrl)) {
                downloadPatch(downloadUrl);
            }
        }
    }

    private void downloadPatch(String downloadUrl) {
        File dir = new File(Environment.getExternalStorageDirectory() + "/shine/patch");
        if (!dir.exists()) {
            dir.mkdir();
        }

        File patchFile = new File(dir, String.valueOf(System.currentTimeMillis()) + ".apatch");
        downloadFile(downloadUrl, patchFile);
        if (patchFile.exists() && patchFile.length() > 0 && fileLength > 0) {
            try {
                CustomApplication.getInstance().getPatchManager().addPatch(patchFile.getAbsolutePath());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void downloadFile(String downloadUrl, File file){
        FileOutputStream fos = null;
        try {
            fos = new FileOutputStream(file);
        } catch (FileNotFoundException e) {
            L.e("can not find saving dir");
            e.printStackTrace();
        }
        InputStream ips = null;
        try {
            URL url = new URL(downloadUrl);
            HttpURLConnection huc = (HttpURLConnection) url.openConnection();
            huc.setRequestMethod("GET");
            huc.setReadTimeout(10000);
            huc.setConnectTimeout(3000);
            fileLength = Integer.valueOf(huc.getHeaderField("Content-Length"));
            ips = huc.getInputStream();
            int hand = huc.getResponseCode();
            if (hand == 200) {
                byte[] buffer = new byte[8192];
                int len = 0;
                while ((len = ips.read(buffer)) != -1) {
                    if (fos != null) {
                        fos.write(buffer, 0, len);
                    }
                    downloadLength = downloadLength + len;
                }
            } else {
                L.e("response code: " + hand);
            }

        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (fos != null) {
                    fos.close();
                }
                if (ips != null) {
                    ips.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

}

到此,一个关键问题来了,就是那个.apatch文件到底是什么?它是怎么来的?

 

热修复开发流程和patch文件制作


首先放出大致的推荐开发流程:

简单来说,假如我们把目前已经上线的apk的名字叫做app-release-online.apk(即文件名),在这个发布后我们及时打上Tag,做一个历史快照。当后面发现bug需要发起热修复时,就在该Tag上新建branch进行修改,修改完毕后的apk的文件名是app-release-fix.apk,随后我们通过AndFix提供过的apkpatch工具来制作.apatch文件(即对比两个apk的差异,后面将介绍),验证无误后,将.apatch文件发布。这样子已经发布的版本会实时收到patch文件并进行热修复工作,用户正在使用的软件即可在不知不觉的中修复了bug。随后我们将修复后的代码merge会主分支。

这里针对我们实际的项目进行一步步操作讲解。

我们的上线apk名字假设也为app-release-online.apk,它其中的关于界面要显示当前的版本号:

版本已经发布,用户已经在使用中,随后我们想将前面的那个"v1.5.1"中的"v"改成“hello world”,同时用户是无感知的收到更新。这个时候在已发布版本的代码Tag上我们修改代码,其实就是修改一个Activity即一个java文件中的某一行。然后打包生成了一个新的apk叫做app-release-fix.apk。

然后将两个apk文件放到项目代码的app目录下(这里随你而定,放在这里主要是因为签名文件也在这个文件夹下,方面使用apkpatch命令而已)。将apkpatch这个工具下载后,加入环境变量。随后输入命令:

apkpatch -f app-release-fix.apk -t app-release-online.apk -o D:\Work\patchresult -k debug.keystore -p xxx -a xxx -e xxx

这个时候你会发现在D:\work\patchresult文件夹中生成了:

这个.apatch就是补丁文件,然后我们把它改名为app-release-fix-shine.apatch,然后用FTP工具上传到上述IntentService中指定的那个目录。

到这里,当用户再次启动app后,发现关于界面已经变成了这样:

 

大功告成!热修复成功!

当然实际开发中,如果能对patch文件进行更加精细的管理控制那就更好了,这里通过上传到ftp服务器,Android客户端下载该文件进行修复也是个不错的办法。

同时,友盟提供了在线参数的功能,我们可以设置一个参数,实时的让客户端检查是否需要打补丁,然后再下载patch文件进行打补丁操作。

 

原理浅析


.apatch实际是一个压缩文件,解压后如下:

meta-inf文件夹为:

打开patch.mf文件可以发现两个apk的差异信息:

Manifest-Version: 1.0
Patch-Name: app-release-fix
To-File: app-release-online.apk
Created-Time: 30 Mar 2016 06:26:27 GMT
Created-By: 1.0 (ApkPatch)
Patch-Classes: com.qianmi.shine.activity.me.AboutActivity_CF
From-File: app-release-fix.apk

这个Patch-CLasses标志了哪些类有修改,这里会显示完全的类名同时加上一个_CF后缀。AndFix首先会读取这个文件里面的东西,保存在Patch类的一个对象里,备用。

然后我们反编译classes.dex来查看里面的类,用jd-gui来查看:

可以看到这个dex里面只有一个class,而且在我们所修改的方法上有一个"@MethodReplace"注解,在代码中可以明显的看到了我们加入的“hello world”这段代码!

 

patchManager.init(AppUtils.getVersionName(this));

上一节我们再Application所调用的patchManager.init方法,首先判断传入的版本号“1.0”是否是已有补丁对应的版本号。不是,说明APP版本已经升级,需要把老版本的clean掉。然后初始化补丁包:遍历APP 的私有目录(/data/data/xxx.xxx.xxx/file/apatch)下所有文件,找到以“apatch”为后缀的文件。解析文件 ->读取文件必要信息(主要是PATCH.MF中)->存放在mPatchs(类型:SortedSet<Patch>)中。

 

patchManager.loadPatch();

遍历mPatchs,针对每个补丁文件:安全校验->解析dex->加载类->找到含有MethodReplace注解的方法->hook替换.

需要注意的时上述所说的是已经下载的patch文件,那么当心下载一个patch文件时(例如上述例子中在PatchDownloadIntentService中),需要调用addpatch方法来载入新的patch文件:

CustomApplication.getInstance().getPatchManager().addPatch(patchFile.getAbsolutePath());

 

这个时候虚拟机就会自动的加载准备替换的class,替换被标注的方法。那么这里是怎么做到的呢?这里开始查看AndFix的相关源码。

 

源码浅析


 

首先Java层的入口为AndFixManager.java,找到fixClass这个方法:

private void fixClass(Class<?> clazz, ClassLoader classLoader) {
    Method[] methods = clazz.getDeclaredMethods();
    MethodReplace methodReplace;
    String clz;
    String meth;
    for (Method method : methods) {
        methodReplace = method.getAnnotation(MethodReplace.class);
        if (methodReplace == null)
            continue;
        clz = methodReplace.clazz();
        meth = methodReplace.method();
        if (!isEmpty(clz) && !isEmpty(meth)) {
            replaceMethod(classLoader, clz, meth, method);
        }
    }
}

/**
 * replace method
 * 
 * @param classLoader classloader
 * @param clz class
 * @param meth name of target method 
 * @param method source method
 */
private void replaceMethod(ClassLoader classLoader, String clz,
        String meth, Method method) {
    try {
        String key = clz + "@" + classLoader.toString();
        Class<?> clazz = mFixedClass.get(key);
        if (clazz == null) {// class not load
            Class<?> clzz = classLoader.loadClass(clz);
            // initialize target class
            clazz = AndFix.initTargetClass(clzz);
        }
        if (clazz != null) {// initialize class OK
            mFixedClass.put(key, clazz);
            Method src = clazz.getDeclaredMethod(meth,
                    method.getParameterTypes());
            AndFix.addReplaceMethod(src, method);
        }
    } catch (Exception e) {
        Log.e(TAG, "replaceMethod", e);
    }
}

它以方法的粒度进行了替换,走到最后其实就是AndFix.addReplace这个方法,这个方法在AndFix.java中:

public class AndFix {

    static {
        try {
            Runtime.getRuntime().loadLibrary("andfix");
        } catch (Throwable e) {
            Log.e(TAG, "loadLibrary", e);
        }
    }

    private static native boolean setup(boolean isArt, int apilevel);

    private static native void replaceMethod(Method dest, Method src);

    private static native void setFieldFlag(Field field);

    /**
     * replace method's body
     * 
     * @param src
     *            source method
     * @param dest
     *            target method
     * 
     */
    public static void addReplaceMethod(Method src, Method dest) {
        try {
            replaceMethod(src, dest);
            initFields(dest.getDeclaringClass());
        } catch (Throwable e) {
            Log.e(TAG, "addReplaceMethod", e);
        }
    }

    。。。
}

这个Java文件载入了libandfix.so,最后其实是调用了cpp实现的replaceMethod方法,在这个之前调用了setup方法进行了设置。走到了这里我觉得他实际上是调用了dalvik的函数来进行底层的替换,所以我觉得setup方法一定获取了dalvik的句柄。对了这里提一下,AndFix对于libandfix.so提供了两个实现,一个是Dalvik的一个是ART的,所以AndFix是顺利的支持两种模式,这里仅仅对Dalvik进行分析。

下面我们来看libandfix.so的dalvik实现,即dalvik_method_replace.cpp

首先是native的setup函数:

extern jboolean __attribute__ ((visibility ("hidden"))) dalvik_setup(
        JNIEnv* env, int apilevel) {
    void* dvm_hand = dlopen("libdvm.so", RTLD_NOW);
    if (dvm_hand) {
        dvmDecodeIndirectRef_fnPtr = dvm_dlsym(dvm_hand,
                apilevel > 10 ?
                        "_Z20dvmDecodeIndirectRefP6ThreadP8_jobject" :
                        "dvmDecodeIndirectRef");
        if (!dvmDecodeIndirectRef_fnPtr) {
            return JNI_FALSE;
        }
        dvmThreadSelf_fnPtr = dvm_dlsym(dvm_hand,
                apilevel > 10 ? "_Z13dvmThreadSelfv" : "dvmThreadSelf");
        if (!dvmThreadSelf_fnPtr) {
            return JNI_FALSE;
        }
        jclass clazz = env->FindClass("java/lang/reflect/Method");
        jClassMethod = env->GetMethodID(clazz, "getDeclaringClass",
                        "()Ljava/lang/Class;");

        return JNI_TRUE;
    } else {
        return JNI_FALSE;
    }
}

这个dvm_hand就是dalvik的句柄,通过dlsym系统调用获得了dalvik的_Z20dvmDecodeIndirectRefP6ThreadP8_jobject和_Z13dvmThreadSelfv函数指针,这里还针对apilevel是否大于10进行判断。

这两个函数在后面的替换Method中是直接用到的,换句话而已,AndFix实际上最终是调用了dalvik的上述两个方法来获取源方法和目标方法的句柄,从而进行“方法粒度”的无感知替换,当虚拟机误以为方法还是之前的“方法”。

 

在native的replaceMethod中:

extern void __attribute__ ((visibility ("hidden"))) dalvik_replaceMethod(
        JNIEnv* env, jobject src, jobject dest) {
    jobject clazz = env->CallObjectMethod(dest, jClassMethod);
    ClassObject* clz = (ClassObject*) dvmDecodeIndirectRef_fnPtr(
            dvmThreadSelf_fnPtr(), clazz);
    clz->status = CLASS_INITIALIZED;

    Method* meth = (Method*) env->FromReflectedMethod(src);
    Method* target = (Method*) env->FromReflectedMethod(dest);
    LOGD("dalvikMethod: %s", meth->name);

    meth->clazz = target->clazz;
    meth->accessFlags |= ACC_PUBLIC;
    meth->methodIndex = target->methodIndex;
    meth->jniArgInfo = target->jniArgInfo;
    meth->registersSize = target->registersSize;
    meth->outsSize = target->outsSize;
    meth->insSize = target->insSize;

    meth->prototype = target->prototype;
    meth->insns = target->insns;
    meth->nativeFunc = target->nativeFunc;
}

我们看到源方法(meth)的各个属性被替换成了新的方法(target)的各个属性,这样子就完成了方法的替换,完成了热修复操作。

看到这里我们其实也了解了AndFix的缺陷,它既然是方法的替换,那么如果新的apk增加了新的类,或者是增加修改了xml资源,那么AndFix则无从下手了。所以,AndFix仅仅支持android 方法的替换,不支持资源文件、xml的修复!

 

影响


 

由于AndFix的实现非常简单,仅有一些很普通的源代码,所以项目引入后对于apk的大小的影响是微乎其微的,这里进行了一个引入前后的对比:

发现仅仅是增加了22KB左右,基本上可以忽略不计

 

其次,本文中每次Application在onCreate中都进行了下载patch补丁的操作,实际开发中应该注意下不要重复下载。这里可以做一些操作,不要重复打同样的补丁。

 

混淆

 


 

请加入下列混淆语句

 

# AndFix
-keep class * extends java.lang.annotation.Annotation
-keepclasseswithmembernames class * { native <methods>; }
-keep class com.alipay.euler.andfix.** { *; }

 

 

 

 

转载请注明:http://www.cnblogs.com/soaringEveryday/p/5338214.html

posted @ 2016-03-30 18:06  soaringEveryday  阅读(3833)  评论(2编辑  收藏  举报