AndFix使用源码分析
前言
关于热修复,我们从这三个问题开始:什么是热修复?目前主流热修复框架?为什么选择AndFix?
Answer Q1:
当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装。有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布。
这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?
虽然Android系统并没有提供这个技术,但是很幸运的告诉大家,答案是:可以
Answer Q2:Dexposed、AndFix、ClassLoader 热补丁方案比较
Answer Q3:Andfix支持Art模式,应用Patch不需要重启… 详情请参照 answer Q2 中博文中的“比较”模块。
使用流程原理
借鉴了下andfix的官方给的流程图,大致可以理解为这样一个流程:
检测到线上的版本有bug,创建一个分支去修改bug,修改完成并且测试通过后生成release版本apk,利用工具和线上版本进行对比生成差分文件即.patch文件,然后将.patch文件上传到线上服务器,最后将修复bug的分支代码合并到主分支。
简单使用配置
引入
dependencies {
compile ‘com.alipay.euler:andfix:0.4.0@aar’
}混淆配置
-keep class * extends java.lang.annotation.Annotation
-keepclasseswithmembernames class * {
native ;
}开始使用了
Apkpatch工具
与普通的第三方工具的不同的是,andfix除了要在代码中引入library dependency外,还需要一个辅助工具apkpatch.jar来生成差分文件。
现在我们就来看看它是如何生成差分文件的。
首先,看看apkpatch如何使用的:
命令行进入apkpatch.jar所在目录后执行命令:apkpatch -f new.apk -t old.apk -o outputDir -k app.jks -p kpassword -a alias -e epassword
命令执行完成后会在输出目录下面生成下面的文件:
如果存在多个.patch文件的话,可以用下面这个命令将其合并为一个:
apkpatch -m
Java层源码分析
Andfix的使用代码配置可简单分为四步:
//第一步 创建PatchManager 对象
PatchManager patchManager = new PatchManager(this);
try {
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
//第二步
patchManager.init(packageInfo.versionName);
//第三步
patchManager.loadPatch();
try {
//第四步
patchManager.addPatch(.patch 文件的路径);
} catch (IOException e) {
e.printStackTrace();
}
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
接下来我们安照这四步来分析andfix的源码,看看都是做了些什么操作。
创建PatchManager
首先我们来看看在new PatchManager(this)中都做了什么:
public PatchManager(Context context) {
mContext = context;
//1.创建AndFixManager对象
mAndFixManager = new AndFixManager(mContext);
//2.创建用来缓存.patch文件的目录 位置为/data/data/com.mime.andfixdemo/files/apatch
mPatchDir = new File(mContext.getFilesDir(), DIR);
//3.用来存放mPatchDir目录中.patch转换成Patch类的集合
mPatchs = new ConcurrentSkipListSet<Patch>();
//4.用来存放类加载器ClassLoader的集合,其实只有PathClassLoader这个元素
mLoaders = new ConcurrentHashMap<String, ClassLoader>();
}
我们到AndFixManager的构造方法中看看在创建它时都做了什么
public AndFixManager(Context context) {
mContext = context;
//查看是否支持 通过源码分析Andfix不支持阿里云系统,支持SDK<8,23>
//其中AndFix.setup()还调用了一个native方法,意思是设置虚拟机类型(ART/Dalvik)及Sdk版本,
//并返回是否置成功
mSupport = Compat.isSupport();
if (mSupport) {
//创建安全校验类 这个类是用来对.patch文件进行签名校验的
mSecurityChecker = new SecurityChecker(mContext);
//又创建了个目录/data/data/com.mime.andfixdemo/files/apatch_opt
//用来保存优化后的dex数据
mOptDir = new File(mContext.getFilesDir(), DIR);
if (!mOptDir.exists() && !mOptDir.mkdirs()) {// make directory fail
mSupport = false;
Log.e(TAG, "opt dir create error.");
} else if (!mOptDir.isDirectory()) {// not directory
mOptDir.delete();
mSupport = false;
}
}
}
通过上面的分析可以知道第一步时做了一些对象,目录的创建工作和校验当前系统是否是AndFix所支持的类型
PatchManager.init(appVersion)
一起看看init方法里面做了什么
public void init(String appVersion) {
if (!mPatchDir.exists() && !mPatchDir.mkdirs()) {// make directory fail
Log.e(TAG, "patch dir create error.");
return;
} else if (!mPatchDir.isDirectory()) {// not directory
mPatchDir.delete();
return;
}
SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,Context.MODE_PRIVATE);
String ver = sp.getString(SP_VERSION, null);
if (ver == null || !ver.equalsIgnoreCase(appVersion)) { //首次安装或版本升级时走这里
//清除所有旧版本的.patch文件,更新版本号存储信息
cleanPatch();
sp.edit().putString(SP_VERSION, appVersion).commit();
} else {
// 将mPatchDir缓存目录中的.patch文件转换成Patch对象添加到mPatchs集合中
initPatchs();
}
}
下面是cleanPatch和initPatchs方法
private void cleanPatch() {
File[] files = mPatchDir.listFiles();
for (File file : files) {
mAndFixManager.removeOptFile(file);
if (!FileUtil.deleteFile(file)) {
Log.e(TAG, file.getName() + " delete error.");
}
}
}
private void initPatchs() {
File[] files = mPatchDir.listFiles();
for (File file : files) {
addPatch(file);
}
}
private Patch addPatch(File file) {
Patch patch = null;
if (file.getName().endsWith(SUFFIX)) {
try {
patch = new Patch(file);
mPatchs.add(patch);
} catch (IOException e) {
Log.e(TAG, "addPatch", e);
}
}
return patch;
}
下面来分析下如何将.patch文件转换成Patch类的,我们先看下构造函数中做了什么
public Patch(File file) throws IOException {
mFile = file;
init();
}
//擦,发现这个注解让我很疑惑,因为并没有找到替换的新方法,是不是方法里面用了过期的方法
@SuppressWarnings("deprecation")
private void init() throws IOException {
JarFile jarFile = null;
InputStream inputStream = null;
try {
//将.patch文件转化成jar文件,其实.patch文件就是个压缩文件,
//我们可以将其后缀名改为.zip进行解压
jarFile = new JarFile(mFile);
//解压后你可以找到名为PATCH.MF的文件,文件的内容包括下面信息
//Manifest-Version: 1.0
//Patch-Name: app_1
//Created-Time: 17 Jun 2016 07:26:46 GMT
//From-File: app_1.0.1_01.apk
//To-File: app_1.0.1_02.apk
//Patch-Classes: com.mime.andfixdemo.MainActivity_CF,
com.mime.andfixdemo.AndFixDemoApplication_CF
//Created-By: 1.0 (ApkPatch)
JarEntry entry = jarFile.getJarEntry(ENTRY_NAME);
inputStream = jarFile.getInputStream(entry);
Manifest manifest = new Manifest(inputStream);
Attributes main = manifest.getMainAttributes();
//获取Patch-Name对应的属性值
mName = main.getValue(PATCH_NAME);
//获取Created-Time对应的属性值
mTime = new Date(main.getValue(CREATED_TIME));
mClassesMap = new HashMap<String, List<String>>();
Attributes.Name attrName;
String name;
List<String> strings;
//获取到Patch-Classes中所有的.classes文件名称并添加到mClassesMap集合中
for (Iterator<?> it = main.keySet().iterator(); it.hasNext();) {
attrName = (Attributes.Name) it.next();
name = attrName.toString();
if (name.endsWith(CLASSES)) {
strings = Arrays.asList(main.getValue(attrName).split(","));
if (name.equalsIgnoreCase(PATCH_CLASSES)) {
mClassesMap.put(mName, strings);
} else {
mClassesMap.put(
name.trim().substring(0, name.length() - 8),// remove
// "-Classes"
strings);
}
}
}
} finally {
if (jarFile != null) {
jarFile.close();
}
if (inputStream != null) {
inputStream.close();
}
}
}
通过上面分析可知init方法中做的事情是通过版本号判断是否是版本升级来处理缓存目录下的.patch文件
,那么缓存目录中的.patch文件是怎么产生的呢,下面来揭晓。
PatchManager.loadPatch()
我们来看看loadPatch方法里面做了什么事情
public void loadPatch() {
//这里获取到PathClassLoader并添加到mLoaders集合中
mLoaders.put("*", mContext.getClassLoader());// wildcard
Set<String> patchNames;
List<String> classes;
//对所有的.classes文件依次进行修改替换(注意这里的.classes文件是经过apkPatch工具
对比后发生修改的.java文件生成的,并且已经打上@MethodReplace注解标识)
for (Patch patch : mPatchs) {
patchNames = patch.getPatchNames();
for (String patchName : patchNames) {
classes = patch.getClasses(patchName);
//修复bug的重点逻辑在这里
mAndFixManager.fix(patch.getFile(), mContext.getClassLoader(),classes);
}
}
}
下面我们看看AndFixManager.fix()方法中具体做了哪些事情
public synchronized void fix(File file, ClassLoader classLoader,
List<String> classes) {
if (!mSupport) {//是否被andfix所支持
return;
}
//文件签名校验
if (!mSecurityChecker.verifyApk(file)) {// security check fail
return;
}
try {
//首次使用时mOptDir文件夹是空的,所有这个optfile文件不存在
File optfile = new File(mOptDir, file.getName());
boolean saveFingerprint = true;
if (optfile.exists()) {
// need to verify fingerprint when the optimize file exist,
// prevent someone attack on jailbreak device with
// Vulnerability-Parasyte.
// btw:exaggerated android Vulnerability-Parasyte
// http://secauo.com/Exaggerated-Android-Vulnerability-Parasyte.html
//如果optfile存在,进行文件校验,看看和上次保存的文件是否一致
//如果一致则下面不需要保存了,如果不一致说明有新的文件需要保存,同时删除旧文件
if (mSecurityChecker.verifyOpt(optfile)) {
saveFingerprint = false;
} else if (!optfile.delete()) {
return;
}
}
//打开dex文件,将优化后的dex数据保存到optfile文件中,如果优化过的格式已存在并且是最
//新的就直接使用它,否则虚拟机将尸体重新创建一个
final DexFile dexFile = DexFile.loadDex(file.getAbsolutePath(),
optfile.getAbsolutePath(), Context.MODE_PRIVATE);
//如果是首次进入或有新的.patch文件则将保存下指纹
if (saveFingerprint) {
mSecurityChecker.saveOptSig(optfile);
}
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;
//遍历所有发生改变的类文件,找到对应的Class,调用fixClass方法进行修改操作
while (entrys.hasMoreElements()) {
String entry = entrys.nextElement();
if (classes != null && !classes.contains(entry)) {
continue;// skip, not need fix
}
clazz = dexFile.loadClass(entry, patchClassLoader);
if (clazz != null) {
fixClass(clazz, classLoader);
}
}
} catch (IOException e) {
Log.e(TAG, "pacth", e);
}
}
下面我们看看fixClass()方法里是怎样进行修复操作的
private void fixClass(Class<?> clazz, ClassLoader classLoader) {
//利用反射获取到当前类中所有声明的方法,然后找到带MethodReplace注解的方法,
//即是发生变化或新增的方法
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)) {
//将通过类名,方法名,新的Method,进行替换操作
(classLoader, clz, meth, method);
}
}
}
接下来我们看看replaceMethod()方法中是如果进行偷梁换柱的
private void replaceMethod(ClassLoader classLoader, String clz,
String meth, Method method) {
try {
//这里用目标类名和pathClassLoader名称做key存入mFixedClass集合中感觉没有什么卵用
String key = clz + "@" + classLoader.toString();
//因为PatchManager.loadPatch方法是在Application的onCreate调用
//所以这里的mFixedClass每次都是空集合所以clazz为null
Class<?> clazz = mFixedClass.get(key);
if (clazz == null) {// class not load
//找到要替换方法的目标类
Class<?> clzz = classLoader.loadClass(clz);
// initialize target class
//对目标类进行初始操作,这里面具体做的事情是找到该类中的所有成员变量并调用了一个
//native方法setFiledFlag()做了些什么。。。
clazz = AndFix.initTargetClass(clzz);
}
if (clazz != null) {// initialize class OK
mFixedClass.put(key, clazz);
//获取到目标类中的目标方法,然后调用addReplaceMethod方法进行替换操作
Method src = clazz.getDeclaredMethod(meth,
method.getParameterTypes());
AndFix.addReplaceMethod(src, method);
}
} catch (Exception e) {
Log.e(TAG, "replaceMethod", e);
}
}
最后再看看addReplaceMethod()方法是如何进行旧方法的替换操作的
public static void addReplaceMethod(Method src, Method dest) {
try {
//native方法,最终的逻辑是在这里,暂时看不到。。。
replaceMethod(src, dest);
//和上面AndFix.initTargetClass()方法中所做的操作一样找到该类中的所有成员变量并调用了
//一个native方法setFiledFlag()做了些什么。。。难道是首尾呼应
initFields(dest.getDeclaringClass());
} catch (Throwable e) {
Log.e(TAG, "addReplaceMethod", e);
}
}
上面的整个逻辑的三大步是必须要走的逻辑,其实如果是二次进入并且.patch文件没有发生改变的话上面三步已经完成了方法的修改和替换操作,而不用走最下面一步了
PatchManager.addPatch(patchPath)
这个才是将.patch文件加载到内存中的方法,我们来看看里面做了什么
public void addPatch(String path) throws IOException {
//sd卡中的.patch文件
File src = new File(path);
//内存缓存目录下对应的.patch文件,首次进入肯定不存在
File dest = new File(mPatchDir, src.getName());
if(!src.exists()){
throw new FileNotFoundException(path);
}
//二次进入的话内存缓存目录下对应的.patch文件则存在,直接返回
if (dest.exists()) {
Log.d(TAG, "patch [" + path + "] has be loaded.");
return;
}
//首次进入和有新的.patch时则需要将.patch文件copy到内存
FileUtil.copyFile(src, dest);// copy to patch's directory
//将copy到内存缓存目录下.patch文件转换成Patch然后进行loadPatch的的修复之旅
Patch patch = addPatch(dest);
if (patch != null) {
loadPatch(patch);
}
}
这一步只有在首次打开app或者是.patch文件发生了改变会走进来,进行初次加载到内存或是替换文件操作,在这个流程中需要注意的是,这里判断的是否有新文件需要替换的依据是比较内存缓存中的.patch文件名称和sd卡中的.patch文件名称是否一致,这也印证了当我们用两个同名的.patch文件去操作时fix功能将失灵了。
总结:其实Java层的方法就是将.patch加载到内存,然后解析找到要替换的方法然后调用native层的replaceMethod方法去做具体的方法替换操作。
Native层代码分析
下面我们来看看native层都做了哪些事情,首先通过java层的方法调用可以看出主要调用了下面三个方法
//在创建AndFixManager的构造方法中判断是否是AndFix支持的系统的同时调用该方法设置了虚拟机类型
private static native boolean setup(boolean isArt, int apilevel);
//方法替换的核心操作
private static native void replaceMethod(Method dest, Method src);
//在AndFix的initFileds方法中找到所有的成员变量,然后循环遍历调用该方法
private static native void setFieldFlag(Field field);
我们用source insight打开Andfix的jni文件夹中的代码来看看
首先,我们看看setup()方法中做了什么
static jboolean setup(JNIEnv* env, jclass clazz, jboolean isart,
jint apilevel) {
isArt = isart;
LOGD("vm is: %s , apilevel is: %i", (isArt ? "art" : "dalvik"),
(int )apilevel);
if (isArt) {
return art_setup(env, (int) apilevel);
} else {
return dalvik_setup(env, (int) apilevel);
}
}
这段代码只是设置了成员变量isArt(是否是ART环境)和apilevel(api版本)的值,用于下面的判断。
按照调用顺序的话,接下来要看看setFieldFlag()方法中做了什么操作
static void setFieldFlag(JNIEnv* env, jclass clazz, jobject field) {
if (isArt) {
art_setFieldFlag(env, field);
} else {
dalvik_setFieldFlag(env, field);
}
}
这里以Dalvik为例看看dalvik_setFieldFlag()中做了什么
extern void dalvik_setFieldFlag(JNIEnv* env, jobject field) {
Field* dalvikField = (Field*) env->FromReflectedField(field);
dalvikField->accessFlags = dalvikField->accessFlags & (~ACC_PRIVATE)
| ACC_PUBLIC;
LOGD("dalvik_setFieldFlag: %d ", dalvikField->accessFlags);
}
这里通过反射将传进来的成员变量进行了一系列操作,但是需要注意是AndFix并不支持成员变量的修改及增删操作,如类中成员变量发生变化,在生成.patch文件时就会报错,那么这些代码的意义何在呢?难道是为了以后功能的扩展
//不支持新增或修改成员变量(生成.patch会报异常)
java.lang.RuntimeException: can,t add new Field:TAG(Ljava/lang/String;),
in class :Lcom/mime/andfixdemo/MainActivity;
at com.euler.patch.diff.DiffInfo.addAddedFields(DiffInfo.java:77)
at com.euler.patch.diff.DexDiffer.compareField(DexDiffer.java:132)
at com.euler.patch.diff.DexDiffer.compareField(DexDiffer.java:101)
at com.euler.patch.diff.DexDiffer.compareField(DexDiffer.java:95)
at com.euler.patch.diff.DexDiffer.diff(DexDiffer.java:32)
at com.euler.patch.ApkPatch.doPatch(ApkPatch.java:68)
at com.euler.patch.Main.main(Main.java:97)
最后我们来看看replaceMethod()方法中做了什么
static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,
jobject dest) {
if (isArt) {
art_replaceMethod(env, src, dest);
} else {
dalvik_replaceMethod(env, src, dest);
}
}
同样的还是以dalvik环境为例来看看里面的实现逻辑
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是对应的新方法,而下面这一堆指针的作用是将meth的方法对应的一些属性全部替换为target所对应的属性,最终达到偷梁换柱的目的。
Ps1:native层的代码不是很熟悉,只能看懂大概的意思,如分析有不对之处请及时指出,谢谢!
Ps2: 这里只分析了主要的逻辑源码,还有一些细节问题笔者认为对于理解不太重要没有带大家看,比如把.patch文件后缀名改为.zip解压后会得到META-INF文件夹和diff.dex文件而META-INF中的那些文件都是怎么来的,里面的内容代表什么,有什么意义,把diff.dex反编译后会看到一些以“_CF”结尾的.class文件,文件中@MethodReplace 注解标签是什么时候打上去的…这些疑问有兴趣的小伙伴可以去看看,可以加深对AndFix的理解,总之只有理解他的内部实现才能很好地应用,遇到问题时能更好更快的找到问题的原因。
Ps3:小道消息得知andfix在github上的开源的版本和其内部使用的版本有些不同,阿里集团内部版本总共有3个大神维护,github上开源的版本只有1个大神兼职维护及更新。
问题汇总(持续更新…)
使用注意事项
1.仅仅只是支持方法的新增或修改
2.不支持新增或修改成员变量(生成.patch会报异常)
3.不支持新增类 (生成.patch时正常,运行时报错——崩溃掉了)
4.源生工具不支持 multidex (可以通过JavaAssist工具修改apkpath这个包,来达到支持mutidex的目的)
5.支持在新增方法中增加的局部变量
6.不支持资源文件的增删及修改
7.强烈欢迎大家一起讨论发掘问题
问题汇总
1.目前andfix只编译armeabi和x86架构的so
解决办法:在build.gradle中加入如下代码
defaultConfig {
ndk {
abiFilters 'armeabi','x86'
}
}
2.与其他jar包之间的冲突问题
场景:修复app版本1后,顺利生成差分包,但是当app运行加载.patch文件时报错,分析查看.patch均正常,修复bug前后的apk版本均能正常运行。错误排查原因极有可能是与项目中其他jar包的冲突导致的
解决办法:分析中。。。
3.在修复bug时重写父类中回调的方法时无效
场景:修复bug,因没有实时申请获取联系人权限。所以在6.0以下手机系统中可正常运行,但是在6.0及以上系统会直接崩溃,修复bug时在类中重写了权限授权提示后的回调方法onRequestPermissionsResult(),但是在生成差分文件时,andfix认为该方法是新增方法,因此没有添加“MethodReplace”标签,所以在应用.patch文件时onRequestPermissionsResult被认为是一个新增的普通方法,而在授权回调时仍然走的是父类的没有被重写过的方法