当你的应用发布后第二天却发现一个重要的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
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; } -keep class com.alipay.euler.andfix.** { *; }
转载请注明:http://www.cnblogs.com/soaringEveryday/p/5338214.html