各大热补丁方案分析和比较

最近开源界涌现了很多热补丁项目,但从方案上来说,主要包括DexposedAndFixClassLoader(来源是原QZone,现淘宝的工程师陈钟,在15年年初就已经开始实现)三种。前两个都是阿里巴巴内部的不同团队做的(淘宝和支付宝),后者则来自腾讯的QQ空间团队。

开源界往往一个方案会有好几种实现(比如ClassLoader方案已经有不下三种实现了),但这三种方案的原理却徊然不同,那么让我们来看看它们三者的原理和各自的优缺点吧。

Dexposed

基于Xposed的AOP框架,方法级粒度,可以进行AOP编程、插桩、热补丁、SDK hook等功能。

Xposed需要Root权限,是因为它要修改其他应用、系统的行为,而对单个应用来说,其实不需要root。 Xposed通过修改Android Dalvik运行时的Zygote进程,并使用Xposed Bridge来hook方法并注入自己的代码,实现非侵入式的runtime修改。比如蜻蜓fm和喜马拉雅做的事情,其实就很适合这种场景,别人反编译市场下载的代码是看不到patch的行为的。小米(onVmCreated里面还未小米做了资源的处理)也重用了dexposed,去做了很多自定义主题的功能,还有沉浸式状态栏等。

我们知道,应用启动的时候,都会fork zygote进程,装载class和invoke各种初始化方法,Xposed就是在这个过程中,替换了app_process,hook了各种入口级方法(比如handleBindApplication、ServerThread、ActivityThread、ApplicationPackageManager的getResourcesForApplication等),加载XposedBridge.jar提供动态hook基础。

具体到方法,可参见XposedBridge:

/**
 * Intercept every call to the specified method and call a handler function instead.
 * @param method The method to intercept
 */
private native synchronized static void hookMethodNative(Member method, Class<?> declaringClass, int slot, Object additionalInfo);

其具体native实现则在Xposed的libxposed_common.cpp里面有注册,根据系统版本分发到libxposed_dalvik和libxposed_art里面,以dalvik为例大致来说就是记录下原来的方法信息,并把方法指针指向我们的hookedMethodCallback,从而实现拦截的目的。

方法级的替换是指,可以在方法前、方法后插入代码,或者直接替换方法。只能针对java方法做拦截,不支持C的方法。

来说说硬伤吧,不支持art,不支持art,不支持art。
重要的事情要说三遍。尽管在6月,项目网站的roadmap就写了7、8月会支持art,但事实是现在还无法解决art的兼容。

另外,如果线上release版本进行了混淆,那写patch也是一件很痛苦的事情,反射+内部类,可能还有包名和内部类的名字冲突,总而言之就是写得很痛苦。

AndFix

同样是方法的hook,AndFix不像Dexposed从Method入手,而是以Field为切入点。

先看Java入口,AndFixManager.fix:

/**
 * fix
 *
 * @param file        patch file
 * @param classLoader classloader of class that will be fixed
 * @param classes     classes will be fixed
 */
public synchronized void fix(File file, ClassLoader classLoader, List<String> classes) {
		// 省略...判断是否支持,安全检查,读取补丁的dex文件
		ClassLoader patchClassLoader = new ClassLoader(classLoader) {
			@Override
			protected Class<?> findClass(String className) throws ClassNotFoundException {
				Class<?> clazz = dexFile.loadClass(className, this);
				if (clazz == null && className.startsWith("com.alipay.euler.andfix")) {
					return Class.forName(className);// annotation’s class not found
				}
				if (clazz == null) {
					throw new ClassNotFoundException(className);
				}
				return clazz;
			}
		};
		Enumeration<String> entrys = dexFile.entries();
		Class<?> clazz = null;
		while (entrys.hasMoreElements()) {
			String entry = entrys.nextElement();
			if (classes != null && !classes.contains(entry)) {
				continue;// skip, not need fix
			}
      // 找到了,加载补丁class
			clazz = dexFile.loadClass(entry, patchClassLoader);
			if (clazz != null) {
				fixClass(clazz, classLoader);
			}
		}
	} catch (IOException e) {
		Log.e(TAG, "pacth", e);
	}
}

看来最终fix是在fixClass方法:

private void fixClass(Class<?> clazz, ClassLoader classLoader) {
  Method\[\] methods = clazz.getDeclaredMethods();
  MethodReplace methodReplace;
  String clz;
  String meth;
  // 遍历补丁class里的方法,进行一一替换,annotation则是补丁包工具自动加上的
  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);
    }
  }
}
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
      Class<?> clzz = classLoader.loadClass(clz);
      // 这里也很黑科技,通过C层,改写accessFlags,把需要替换的类的所有方法(Field)改成了public,具体可以看Method结构体
      clazz = AndFix.initTargetClass(clzz);
    }
    if (clazz != null) {// initialize class OK
      mFixedClass.put(key, clazz);
      // 需要被替换的函数
      Method src = clazz.getDeclaredMethod(meth, method.getParameterTypes());
      // 这里是调用了jni,art和dalvik分别执行不同的替换逻辑,在cpp进行实现
      AndFix.addReplaceMethod(src, method);
    }
  } catch (Exception e) {
    Log.e(TAG, "replaceMethod", e);
  }
}

在dalvik和art上,系统的调用不同,但是原理类似,这里我们尝个鲜,以6.0为例art_method_replace_6_0:

// 进行方法的替换
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
	art::mirror::ArtMethod* smeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(src);
	art::mirror::ArtMethod* dmeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
	dmeth->declaring_class_->class_loader_ =
			smeth->declaring_class_->class_loader_; //for plugin classloader
	dmeth->declaring_class_->clinit_thread_id_ =
			smeth->declaring_class_->clinit_thread_id_;
	dmeth->declaring_class_->status_ = smeth->declaring_class_->status_-1;
  // 把原方法的各种属性都改成补丁方法的
	smeth->declaring_class_ = dmeth->declaring_class_;
	smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
	smeth->access_flags_ = dmeth->access_flags_;
	smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
	smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
	smeth->method_index_ = dmeth->method_index_;
	smeth->dex_method_index_ = dmeth->dex_method_index_;
  // 实现的指针也替换为新的
	smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
			dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
	smeth->ptr_sized_fields_.entry_point_from_jni_ =
			dmeth->ptr_sized_fields_.entry_point_from_jni_;
	smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
			dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
	LOGD("replace_6_0: %d , %d",
			smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
			dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}
// 这就是上面提到的,把方法都改成public的,所以说了解一下jni还是很有必要的,java世界在c世界是有映射关系的
void setFieldFlag_6_0(JNIEnv* env, jobject field) {
	art::mirror::ArtField* artField =
			(art::mirror::ArtField*) env->FromReflectedField(field);
	artField->access_flags_ = artField->access_flags_ & (~0x0002) | 0x0001;
	LOGD("setFieldFlag_6_0: %d ", artField->access_flags_);
}

在dalvik上的实现略有不同,是通过jni bridge来指向补丁的方法。

使用上,直接写一个新的类,会由补丁工具会生成注解,描述其与要打补丁的类和方法的对应关系。

ClassLoader

原腾讯空间Android工程师,也是我的启蒙老师的陈钟发明的热补丁方案,是他在看源码的时候偶然发现的切入点。

我们知道,multidex方案的实现,其实就是把多个dex放进app的classloader之中,从而使得所有dex的类都能被找到。而实际上findClass的过程中,如果出现了重复的类,参照下面的类加载的实现,是会使用第一个找到的类的。

public Class findClass(String name, List<Throwable> suppressed) {  
    for (Element element : dexElements) {  //每个Element就是一个dex文件
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
            if (clazz != null) {
              return clazz;
            }
        }
    }
    if (dexElementsSuppressedExceptions != null) {  
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

该热补丁方案就是从这一点出发,只要把有问题的类修复后,放到一个单独的dex,通过反射插入到dexElements数组的最前面,不就可以让虚拟机加载到打完补丁的class了吗。

说到此处,似乎已经是一个完整的方案了,但在实践中,会发现运行加载类的时候报preverified错误,原来在DexPrepare.cpp,将dex转化成odex的过程中,会在DexVerify.cpp进行校验,验证如果直接引用到的类和clazz是否在同一个dex,如果是,则会打上CLASS_ISPREVERIFIED标志。通过在所有类(Application除外,当时还没加载自定义类的代码)的构造函数插入一个对在单独的dex的类的引用,就可以解决这个问题。空间使用了javaassist进行编译时字节码插入。

开源实现有NuwaHotFixDroidFix

比较

Dexposed不支持Art模式(5.0+),且写补丁有点困难,需要反射写混淆后的代码,粒度太细,要替换的方法多的话,工作量会比较大。

AndFix支持2.3-6.0,但是不清楚是否有一些机型的坑在里面,毕竟jni层不像java曾一样标准,从实现来说,方法类似Dexposed,都是通过jni来替换方法,但是实现上更简洁直接,应用patch不需要重启。但由于从实现上直接跳过了类初始化,设置为初始化完毕,所以像是静态函数、静态成员、构造函数都会出现问题,复杂点的类Class.forname很可能直接就会挂掉。

ClassLoader方案支持2.3-6.0,会对启动速度略微有影响,只能在下一次应用启动时生效,在空间中已经有了较长时间的线上应用,如果可以接受在下次启动才应用补丁,是很好的选择。

总的来说,在兼容性稳定性上,ClassLoader方案很可靠,如果需要应用不重启就能修复,而且方法足够简单,可以使用AndFix,而Dexposed由于还不能支持art,所以只能暂时放弃,希望开发者们可以改进使它能支持art模式,毕竟xposed的种种能力还是很吸引人的(比如hook别人app的方法拿到解密后的数据,嘿嘿),还有比如无痕埋点啊线上追踪问题之类的,随时可以下掉。

原文出处:http://blog.zhaiyifan.cn/2015/11/20/HotPatchCompare/