【热修复】Andfix源码分析

转载请标注来源:http://www.cnblogs.com/charles04/p/8471301.html


 Andfix源码分析

0、目录

  1. 背景介绍
  2. 源码分析
  3. 方案评价
  4. 总结与思考
  5. 参考文献

1、背景介绍

热修复技术是移动端领域近年非常活跃的一项新技术,通过热修复技术可以在不发布应用市场版本,在用户无感知的情况下对线上Bug进行紧急修复。正所谓修复于千里之外,剿灭与无形之中,实乃移动端开发运营中一项必备之尖端技术。其主要的运行原理如下:

简而言之,热修复就是通过一定的技术手段,让用户在程序的实际运行操作中,走到修复的Patch逻辑序列,而绕开存在问题的逻辑片段,实现问题的紧急规避。目前实现的技术手段主要有腾讯系的基于ClassLoader的热修复方案(例如微信的Tinker,qq空间的超级补丁)以及阿里系的基于Method Hook的热修复方案(例如Andfix,Sophix等)。今天主要介绍的就是阿里巴巴的Andfix。

2、源码分析

如前所述,Andfix是阿里巴巴推出的一款基于Method Hook的热修复技术,目前Github点赞数5.7K,是一款安全性高,较为稳定,性能比较优异的方法级替换的热修复技术。代码实现上条理清晰,架构设计合理,可读性强,是一个实现上非常优雅的开源框架。下面我们重点介绍下Andfix的源码及其设计。

一个经典的开源框架首先要友好的对外暴露接口,这样才能更便于接入,实现快速启动。所以,在介绍核心源码之前,我们首先关注下Andfix的外部接口部分。

2.1. 初始化部分

为了尽可能的覆盖BUG修复的范围,和其他的热修复技术一样,Andfix选择在APP启动的时候对热补丁进行加载,也即Application的OnCreate过程。整体的外部接口调用如下所示:

 1 @Override
 2     public void onCreate() {
 3         super.onCreate();
 4         // patch的初始化
 5         mPatchManager = new PatchManager(this);
 6         mPatchManager.init("1.0");
 7         Log.d(TAG, "inited.");
 8 
 9         // 加载缓存中的patch
10         mPatchManager.loadPatch();
11         Log.d(TAG, "apatch loaded.");
12 
13         // 将外部存储中的patch加载到当前运行的ART中
14         try {
15             // .apatch file path
16             String patchFileString = Environment.getExternalStorageDirectory()
17                     .getAbsolutePath() + APATCH_PATH;
18             mPatchManager.addPatch(patchFileString);
19             Log.d(TAG, "apatch:" + patchFileString + " added.");
20         } catch (IOException e) {
21             Log.e(TAG, "", e);
22         }
23     }

这部分接口非常简洁,大概分为三步:patch的初始化,patch的缓存加载,patch的外部存储加载。

缓存加载是为了加载之前已经从外部存储载入到缓存(data目录下)中的patch,外部存储加载是为了从外部存储中加载patch到缓存。Andfix的整体外部调用就是上面的几步,下面我们来看下Andfix的具体实现部分。

2.2. 核心实现 

Andfix的具体实现上主要分为三部分:Patch管理部分,Fix管理部分,Native Hook部分,其整体的UML架构图如下所示:

//todo 增加整体UML

 

 

2.2.1. PatchManager

整体的初始化函数的源码如下:

 1   /**
 2      * Patch的初始化工作
 3      * @param appVersion App的版本号
 4      */
 5     public void init(String appVersion) {
 6         if (!mPatchDir.exists() && !mPatchDir.mkdirs()) {// make directory fail
 7             Log.e(TAG, "patch dir create error.");
 8             return;
 9         } else if (!mPatchDir.isDirectory()) {// not directory
10             mPatchDir.delete();
11             return;
12         }
13         SharedPreferences sp = mContext.getSharedPreferences(SP_NAME,
14                 Context.MODE_PRIVATE);
15         String ver = sp.getString(SP_VERSION, null);
16         if (ver == null || !ver.equalsIgnoreCase(appVersion)) {
17             cleanPatch();
18             sp.edit().putString(SP_VERSION, appVersion).commit();
19         } else {
20             initPatchs();
21         }
22     }

其中mPatchDir表示data私有目录下存放Patch文件的文件夹。首先是关于mPatchDir的简单文件夹操作,在mPatchDir文件夹初始化完成之后,紧接着比较当前的APP版本和SharedPreferences中保存的Patch对应的APP版本,两者如果不相等的话,会直接清除掉本地缓存的Patch文件和对应Patch相关的数据。这是因为热补丁是跟APP强相关的,Patch只能精确的修复对应版本的Bug。清除的源码如下所示:

1     private void cleanPatch() {
2         File[] files = mPatchDir.listFiles();
3         for (File file : files) {
4             mAndFixManager.removeOptFile(file);
5             if (!FileUtil.deleteFile(file)) {
6                 Log.e(TAG, file.getName() + " delete error.");
7             }
8         }
9     }

在版本号匹配之后,紧接着是Patch文件的初始化部分(initPatchs()),其源码如下:

1     private void initPatchs() {
2         File[] files = mPatchDir.listFiles();
3         for (File file : files) {
4             addPatch(file);
5         }
6     }

在上述函数中,ART会遍历Patch文件,并将Patch文件通过addPatch方法添加到内存中。

addPatch方法有两种多态实现,分别如下:

  • private Patch addPatch(File file)
  • public void addPatch(String path) throws IOException

其中第一个方法是从Patch文件中获取Patch对象,具体的源码如下:

 1 /**
 2      * add patch file
 3      * 
 4      * @param file
 5      * @return patch
 6      */
 7     private Patch addPatch(File file) {
 8         Patch patch = null;
 9         if (file.getName().endsWith(SUFFIX)) {
10             try {
11                 patch = new Patch(file);
12                 mPatchs.add(patch);
13             } catch (IOException e) {
14                 Log.e(TAG, "addPatch", e);
15             }
16         }
17         return patch;
18     }

此方法中把Patch文件夹映射为Patch对象,然后将Patch对象统一存放在mPatchs数据集里面。

第二个方法是从本地路径中获取Patch文件,然后从Patch文件中解析出Patch对象,之后触发Patch的加载过程,具体源码如下: 

 1 public void addPatch(String path) throws IOException {
 2         File src = new File(path);
 3         File dest = new File(mPatchDir, src.getName());
 4         if(!src.exists()){
 5             throw new FileNotFoundException(path);
 6         }
 7         if (dest.exists()) {
 8             Log.d(TAG, "patch [" + path + "] has be loaded.");
 9             return;
10         }
11         FileUtil.copyFile(src, dest);// copy to patch's directory
12         Patch patch = addPatch(dest);
13         if (patch != null) {
14             loadPatch(patch);
15         }
16     }

获取完Patch的对象列表之后,接下来的内容就是加载Patch中的内容,并根据Patch中的内容进行Hotfix。此过程是通过Patchmanager类中的loadPatch方法实现的。loadPatch方法一共有三个多态,分别如下:

  • public void loadPatch(String patchName, ClassLoader classLoader)
  • public void loadPatch()
  • private void loadPatch(Patch patch)

三个方法入参不同,会通过不同的ClassLoader加载不同的Patch文件,已第三个方法为例,该函数中对数据进行封装之后,最终会循环调用AndfixManager中的fix方法,具体的源码如下:

 1 private void loadPatch(Patch patch) {
 2         Set<String> patchNames = patch.getPatchNames();
 3         ClassLoader cl;
 4         List<String> classes;
 5         for (String patchName : patchNames) {
 6             if (mLoaders.containsKey("*")) {
 7                 cl = mContext.getClassLoader();
 8             } else {
 9                 cl = mLoaders.get(patchName);
10             }
11             if (cl != null) {
12                 classes = patch.getClasses(patchName);
13                 mAndFixManager.fix(patch.getFile(), cl, classes);
14             }
15         }
16     }

PatchManager的源码基本就如上所述,主要是对Patch的管理与加载过程,代码简洁易懂,可读性强。

2.2.2. AndFixManager

接下来,我们重点分析下AndfixManager类,该类中主要介绍Andfix的BugFix的核心流程。通过之前的PatchManager类的源码分析可知,AndfixManager的关键入口函数为fix方法。其源码如下所示:

 1 public synchronized void fix(File file, ClassLoader classLoader, List<String> classes) {
 2         if (!mSupport) {
 3             return;
 4         }
 5 
 6         if (!mSecurityChecker.verifyApk(file)) {// security check fail
 7             return;
 8         }
 9 
10         try {
11             File optfile = new File(mOptDir, file.getName());
12             boolean saveFingerprint = true;
13             if (optfile.exists()) {
14                 // need to verify fingerprint when the optimize file exist,
15                 // prevent someone attack on jailbreak device with
16                 // Vulnerability-Parasyte.
17                 // btw:exaggerated android Vulnerability-Parasyte
18                 // http://secauo.com/Exaggerated-Android-Vulnerability-Parasyte.html
19                 if (mSecurityChecker.verifyOpt(optfile)) {
20                     saveFingerprint = false;
21                 } else if (!optfile.delete()) {
22                     return;
23                 }
24             }
25 
26             final DexFile dexFile = DexFile.loadDex(file.getAbsolutePath(),
27                     optfile.getAbsolutePath(), Context.MODE_PRIVATE);
28 
29             if (saveFingerprint) {
30                 mSecurityChecker.saveOptSig(optfile);
31             }
32 
33             ClassLoader patchClassLoader = new ClassLoader(classLoader) {
34                 @Override
35                 protected Class<?> findClass(String className)
36                         throws ClassNotFoundException {
37                     Class<?> clazz = dexFile.loadClass(className, this);
38                     if (clazz == null
39                             && className.startsWith("com.alipay.euler.andfix")) {
40                         return Class.forName(className);// annotation’s class
41                                                         // not found
42                     }
43                     if (clazz == null) {
44                         throw new ClassNotFoundException(className);
45                     }
46                     return clazz;
47                 }
48             };
49             Enumeration<String> entrys = dexFile.entries();
50             Class<?> clazz = null;
51             while (entrys.hasMoreElements()) {
52                 String entry = entrys.nextElement();
53                 if (classes != null && !classes.contains(entry)) {
54                     continue;// skip, not need fix
55                 }
56                 clazz = dexFile.loadClass(entry, patchClassLoader);
57                 if (clazz != null) {
58                     fixClass(clazz, classLoader);
59                 }
60             }
61         } catch (IOException e) {
62             Log.e(TAG, "pacth", e);
63         }
64     }

在此方法中,主要包括安全校验,bugFix两部分,具体如下;

(1)安全校验

Andfix会对传进来的Patch文件进行安全校验,包括准确性校验和完整性校验。

安全校验的具体实现在SecurityChecker类中,其结构体如下:

//todo 补充SecurityChecker UML

其中准确性校验(签名校验)的具体实现如下:

 1 /**
 2      * @param path
 3      *            Apk file
 4      * @return true if verify apk success
 5      */
 6     public boolean verifyApk(File path) {
 7         if (mDebuggable) {
 8             Log.d(TAG, "mDebuggable = true");
 9             return true;
10         }
11 
12         JarFile jarFile = null;
13         try {
14             jarFile = new JarFile(path);
15 
16             JarEntry jarEntry = jarFile.getJarEntry(CLASSES_DEX);
17             if (null == jarEntry) {// no code
18                 return false;
19             }
20             loadDigestes(jarFile, jarEntry);
21             Certificate[] certs = jarEntry.getCertificates();
22             if (certs == null) {
23                 return false;
24             }
25             return check(path, certs);
26         } catch (IOException e) {
27             Log.e(TAG, path.getAbsolutePath(), e);
28             return false;
29         } finally {
30             try {
31                 if (jarFile != null) {
32                     jarFile.close();
33                 }
34             } catch (IOException e) {
35                 Log.e(TAG, path.getAbsolutePath(), e);
36             }
37         }
38     }
39 
40     // verify the signature of the Apk
41     private boolean check(File path, Certificate[] certs) {
42         if (certs.length > 0) {
43             for (int i = certs.length - 1; i >= 0; i--) {
44                 try {
45                     certs[i].verify(mPublicKey);
46                     return true;
47                 } catch (Exception e) {
48                     Log.e(TAG, path.getAbsolutePath(), e);
49                 }
50             }
51         }
52         return false;
53     }

上述过程对APK进行证书签名校验,符合签名的APK为合法的APK,否则为非法的APK,中断热修复过程。

Andfix的过程不仅进行签名校验,还进行完整性校验。完整性校验是为了防止出现在进行patch下载的过程中下载不完整,导致修复出现异常的情况。完整性校验是通过校验MD4来实现的,具体如下;

 1 /**
 2      * @param path
 3      *            Dex file
 4      * @return true if verify fingerprint success
 5      */
 6     public boolean verifyOpt(File file) {
 7         String fingerprint = getFileMD5(file);
 8         String saved = getFingerprint(file.getName());
 9         if (fingerprint != null && TextUtils.equals(fingerprint, saved)) {
10             return true;
11         }
12         return false;
13

 (2)Bug Fix

Andfix热修复的核心实现中,分为两个步骤:

  1. 找到需要修复的Class;
  2. 替换需要进行修复的Method。

第一步的具体实现如下:

 1 /**
 2      * fix class
 3      * 
 4      * @param clazz
 5      *            class
 6      */
 7     private void fixClass(Class<?> clazz, ClassLoader classLoader) {
 8         Method[] methods = clazz.getDeclaredMethods();
 9         MethodReplace methodReplace;
10         String clz;
11         String meth;
12         for (Method method : methods) {
13             methodReplace = method.getAnnotation(MethodReplace.class);
14             if (methodReplace == null)
15                 continue;
16             clz = methodReplace.clazz();
17             meth = methodReplace.method();
18             if (!isEmpty(clz) && !isEmpty(meth)) {
19                 replaceMethod(classLoader, clz, meth, method);
20             }
21         }
22     }

第二部的具体实现如下:

 1 /**
 2      * replace method
 3      * 
 4      * @param classLoader classloader
 5      * @param clz class
 6      * @param meth name of target method 
 7      * @param method source method
 8      */
 9     private void replaceMethod(ClassLoader classLoader, String clz,
10             String meth, Method method) {
11         try {
12             String key = clz + "@" + classLoader.toString();
13             Class<?> clazz = mFixedClass.get(key);
14             if (clazz == null) {// class not load
15                 Class<?> clzz = classLoader.loadClass(clz);
16                 // initialize target class
17                 clazz = AndFix.initTargetClass(clzz);
18             }
19             if (clazz != null) {// initialize class OK
20                 mFixedClass.put(key, clazz);
21                 Method src = clazz.getDeclaredMethod(meth, method.getParameterTypes());
22                 AndFix.addReplaceMethod(src, method);
23             }
24         } catch (Exception e) {
25             Log.e(TAG, "replaceMethod", e);
26         }
27

其中核心函数AndFix.addReplaceMethod(src, method)的具体实现如下:

 1 /**
 2      * replace method's body
 3      * 
 4      * @param src
 5      *            source method
 6      * @param dest
 7      *            target method
 8      * 
 9      */
10     public static void addReplaceMethod(Method src, Method dest) {
11         try {
12             replaceMethod(src, dest);
13             initFields(dest.getDeclaringClass());
14         } catch (Throwable e) {
15             Log.e(TAG, "addReplaceMethod", e);
16         }
17

可以观察到,Andfix中函数的替换是通过Native方法replaceMethod(Method dest, Method src)实现的。从JNI中找到这部分的源码如下:

1 static void replaceMethod(JNIEnv* env, jclass clazz, jobject src, jobject dest) {
2     if (isArt) {
3         art_replaceMethod(env, src, dest);
4     } else {
5         dalvik_replaceMethod(env, src, dest);
6     }
7

Native层面进行Method Hook的原理是将源方法中的各个属性替换为目标方法的属性。由于不同虚拟机,甚至同样虚拟机下不同API对应的方法结构体的不同,在进行Method Hook的过程中,对不同情况,要适配不同的方法。

不同的Android版本,对于的虚拟机不同:Android 4.4以下用的是Dalvik虚拟机,而Android 4.4以上用的是ART(Android Running Time)虚拟机。如上面代码实现,在进行热修复的过程中,ART虚拟机下调用的是art_replaceMethod(env, src, dest)方法;Dalvik虚拟机调用的是dalvik_replaceMethod(env, src, dest)方法。 

而对于ART虚拟机,不同Android API的系统,可能会对应不同的方法结构体(ArtMethod),所以会有对应的不同的适配实现,其代码如下:

 1 extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod(
 2         JNIEnv* env, jobject src, jobject dest) {
 3     if (apilevel > 23) {
 4         replace_7_0(env, src, dest);
 5     } else if (apilevel > 22) {
 6         replace_6_0(env, src, dest);
 7     } else if (apilevel > 21) {
 8         replace_5_1(env, src, dest);
 9     } else if (apilevel > 19) {
10         replace_5_0(env, src, dest);
11     }else{
12         replace_4_4(env, src, dest);
13     }
14 } 

不同API的实现类如下:

所以说Andfix可以兼容Android2.3到7.0版本,对于超过Android7.0的版本,如果ArtMethod相比较7.0有较大的改变,就可能存在兼容性问题,这是后话。

以7.0版本为例,Andfix中Method hook的属性替换的具体实现如下:

 1 void replace_7_0(JNIEnv* env, jobject src, jobject dest) {
 2     art::mirror::ArtMethod* smeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(src);
 3 
 4     art::mirror::ArtMethod* dmeth =
 5             (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
 6 
 7 //    reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->class_loader_ =
 8 //            reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->class_loader_; //for plugin classloader
 9     reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->clinit_thread_id_ =
10             reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->clinit_thread_id_;
11     reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->status_ =
12             reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->status_ -1;
13     //for reflection invoke
14     reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->super_class_ = 0;
15 
16     smeth->declaring_class_ = dmeth->declaring_class_;
17     smeth->access_flags_ = dmeth->access_flags_  | 0x0001;
18     smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
19     smeth->dex_method_index_ = dmeth->dex_method_index_;
20     smeth->method_index_ = dmeth->method_index_;
21     smeth->hotness_count_ = dmeth->hotness_count_;
22 
23     smeth->ptr_sized_fields_.dex_cache_resolved_methods_ =
24             dmeth->ptr_sized_fields_.dex_cache_resolved_methods_;
25     smeth->ptr_sized_fields_.dex_cache_resolved_types_ =
26             dmeth->ptr_sized_fields_.dex_cache_resolved_types_;
27 
28     smeth->ptr_sized_fields_.entry_point_from_jni_ =
29             dmeth->ptr_sized_fields_.entry_point_from_jni_;
30     smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
31             dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
32 
33     LOGD("replace_7_0: %d , %d",
34             smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
35             dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
36 
37

首先调用ART的方法获取Andfix中源方法(smeth)和目标方法(dmeth)的句柄,然后将源方法的各个属性(例如declaring_class_:所属类,access_flags:访问权限,method_index_:代码执行地址等)替换为目标方法的各个属性,从而实现方法层面的Hook,实现Hotfix。

3.方案评价

3.1.优点

(1)即时生效

(2)基于Method Hook的实现,对原始APK侵入较小,性能影响几乎忽略不计

3.2.缺点

(1)只能用于方法级的修复

Andfix最为明显的缺点是只能实现方法级别的修复。而无法实现xml,资源文件级别的修复,也无法增加或者删除class类,这一点从原理分析上能够很明显的看到。但是,热修复的精髓就是在不重新发布版本,不影响性能和体验的前提下,实现对线上紧急Bug的灵活修复。在大多数情况下,通过方法级别的修复就能够达到热修复的目的,Andfix做到了小而精,改动小,影响小但性能优异,效果稳定,个人认为在一定程度上已经满足了热修复的需求。与Andfix形成鲜明对比的是微信推出的Tinker,Tinker追求的是广而博,能够实现类,xml,资源文件,so库等的修复,甚至可以新增export属性为false的Activity类,从某种意义上讲,甚至可以小型功能的发布,有点插件化的味道。

这里不过多评价两种插件化框架的优劣,和谈恋爱一样,没有最好的,只有最合适的,选择适合自己项目的热修复框架,然后用好,就可以了。

(2)兼容性问题

由于Java方法对应的底层数据结构体的差异,在进行native层面的Method Hook过程中,不同虚拟机之间要使用不同的方法,甚至在ART架构中,不同的API的Android版本间也可能要使用不同的适配方法。

目前Andfix在实现的时候,根据AOSP开源代码中不同API版本对ArtMethod的定义,将运行的Java Method强行地转换为art::mirror::ArtMethod,但是由于Android源码是公开的,在实际的设备上,不同的手机厂商可能会对ArtMethod做个性化修改,这样就有可能会导致基于开源标准代码实现的Method Hook无法兼容有些设备的情况。

为了解决Andfix的兼容性问题,阿里巴巴随后推出了Andfix的改进版热修复方案——Sophix。Sophix与Andfix的区别在于,在进行Method Hook的时候,不再进行ArtMethod属性的替换,而是直接将ArtMethod作为一个整体进行替换, 其Method Hook的核心实现如下:

  • memcpy(dmeth, smeth, sizeof(ArtMethod));

Sophix通过进行整体方法体的替换,完美的解决了Andfix中的兼容性问题,这样,不仅在不能的厂商的设备上可以达到兼容,而且对于后续发布的Android版本也能够做到向后兼容,保障了热修复方案的健壮性。

4.总结与思考

本文对Andfix的原理进行了分析介绍,并对Andfix客户端的源码实现进行了简要分析,其中重点介绍了客户端在获取Patch后进行Class匹配与Method替换的过程。

初次此外,在开发过程中,有几个技术细节也有较大的可挖掘性,具体如下:

(1)Andfix中热修复Patch的生成原理;

(2)Patch的下载流程(推荐自己搭建服务器框架,通过okhttp实现下载流程),更新,版本管理;

(3)MultiDex下的Andfix;

(4)ClassLoader的内核原理;

(5)Android Running Time与Dalvik;

(6)其他同类型的热修复框架,例如腾讯微信的Tinker,美团的Robust,饿了么的MiGo,大众点评的Nuwa等。

5.参考文献

原文地址:https://www.cnblogs.com/charles04/p/8471301.html