当你的应用发布后第二天却发现一个重要的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.** { *; }