[关闭]
@TryLoveCatch 2021-04-30T17:29:28.000000Z 字数 13695 阅读 1130

Android基础之热修复

android android基础 热修复


参考

Android-热修复

【Android 修炼手册】常用技术篇 -- Android 热修复解析

预备知识

  1. 了解 android 基本开发
  2. 了解 ClassLoader 相关知识

看完本文可以达到什么程度

  1. 了解插件化常见的实现原理

阅读前准备工作

  1. clone CommonTec 项目,其中 hotfix 和 patch 是热修复代码
    示例代码基于 AndFix,NuWa,Robust 进行了调整,抽取主要部分用来讲解原理。

文章概览

一、热修复和插件化

插件化和热修复的原理,都是动态加载 dex/apk 中的类/资源,两者的目的不同:

目标不同,也就导致其实现方式上的差别:

二、使用 gradle 简化插件开发流程

如果看过Android 插件化分析里的 gradle 简化插件开发流程,这里可以略过~

在学习和开发热修复的时候,我们需要动态去加载补丁 apk,所以开发过程中一般需要有两个 apk,一个是宿主 apk,一个是补丁 apk,对应的就需要有宿主项目和补丁项目。
CommonTec 这里创建了 app 作为宿主项目,plugin 为插件项目。为了方便,我们直接把生成的插件 apk 放到宿主 apk 中的 assets 中,apk 启动时直接放到内部存储空间中方便加载。
这样的项目结构,我们调试问题时的流程就是下面这样:
修改插件项目 -> 编译生成插件 apk -> 拷贝插件 apk 到宿主 assets -> 修改宿主项目 -> 编译生成宿主 apk -> 安装宿主 apk -> 验证问题
如果每次我们修改一个很小的问题,都经历这么长的流程,那么耐心很快就耗尽了。最好是可以直接编译宿主 apk 的时候自动打包插件 apk 并拷贝到宿主 assets 目录下,这样我们不管修改什么,都直接编译宿主项目就好了。如何实现呢?还记得我们之前讲解过的 gradle 系列么?现在就是学以致用的时候了。
首先在 plugin 项目的 build.gradle 添加下面的代码:

  1. project.afterEvaluate {
  2. project.tasks.each {
  3. if (it.name == "assembleDebug") {
  4. it.doLast {
  5. copy {
  6. from new File(project.getBuildDir(), 'outputs/patch/debug/patch-debug.apk').absolutePath
  7. into new File(project.getRootProject().getProjectDir(), 'hotfix/src/main/assets')
  8. rename 'patch-debug.apk', 'patch.apk'
  9. }
  10. }
  11. }
  12. }
  13. }

这段代码是在 afterEvaluate 的时候,遍历项目的 task,找到打包 task 也就是 assembleDebug,然后在打包之后,把生成的 apk 拷贝到宿主项目的 assets 目录下,并且重命名为 plugin.apk。

然后在 app 项目的 build.gradle 添加下面的代码:

  1. project.afterEvaluate {
  2. project.tasks.each {
  3. if (it.name == 'mergeDebugAssets') {
  4. it.dependsOn ':patch:assembleDebug'
  5. }
  6. }
  7. }

找到宿主打包的 mergeDebugAssets 任务,依赖插件项目的打包,这样每次编译宿主项目的时候,会先编译插件项目,然后拷贝插件 apk 到宿主 apk 的 assets 目录下,以后每次修改,只要编译宿主项目就可以了。

三、ClassLoader

如果看过Android 插件化分析里的 ClassLoader 分析,这里可以略过~

ClassLoader 是热修复和插件化中必须要掌握的,因为插件是未安装的 apk,系统不会处理其中的类,所以需要我们自己来处理。

3.1 java 中的 ClassLoader

BootstrapClassLoader
负责加载 JVM 运行时的核心类,比如 JAVA_HOME/lib/rt.jar 等等

ExtensionClassLoader
负责加载 JVM 的扩展类,比如 JAVA_HOME/lib/ext 下面的 jar 包

AppClassLoader
负责加载 classpath 里的 jar 包和目录

3.2 android 中的 ClassLoader

在这里,我们统称 dex 文件,包含 dex 的 apk 文件以及 jar 文件为 dex 文件
PathClassLoader
用来加载系统类和应用程序类,用来加载 dex 文件,但是 dex2oat 生成的 odex 文件只能放在系统的默认目录。

DexClassLoader
用来加载 dex 文件,可以从存储空间加载 dex 文件,可以指定 odex 文件的存放目录。

我们在插件化中一般使用的是 DexClassLoader。

3.3 双亲委派机制

每一个 ClassLoader 中都有一个 parent 对象,代表的是父类加载器,在加载一个类的时候,会先使用父类加载器去加载,如果在父类加载器中没有找到,自己再进行加载,如果 parent 为空,那么就用系统类加载器来加载。通过这样的机制可以保证系统类都是由系统类加载器加载的。
下面是 ClassLoader 的 loadClass 方法的具体实现。

  1. protected Class<?> loadClass(String name, boolean resolve)
  2. throws ClassNotFoundException
  3. {
  4. // First, check if the class has already been loaded
  5. Class<?> c = findLoadedClass(name);
  6. if (c == null) {
  7. try {
  8. if (parent != null) {
  9. // 先从父类加载器中进行加载
  10. c = parent.loadClass(name, false);
  11. } else {
  12. c = findBootstrapClassOrNull(name);
  13. }
  14. } catch (ClassNotFoundException e) {
  15. // ClassNotFoundException thrown if class not found
  16. // from the non-null parent class loader
  17. }
  18. if (c == null) {
  19. // 没有找到,再自己加载
  20. c = findClass(name);
  21. }
  22. }
  23. return c;
  24. }

3.4 如何加载插件中的类

要加载插件中的类,我们首先要创建一个 DexClassLoader,先看下 DexClassLoader 的构造函数需要那些参数。

  1. public class DexClassLoader extends BaseDexClassLoader {
  2. public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
  3. // ...
  4. }
  5. }

构造函数需要四个参数:
dexPath 是需要加载的 dex / apk / jar 文件路径
optimizedDirectory 是 dex 优化后存放的位置,在 ART 上,会执行 oat 对 dex 进行优化,生成机器码,这里就是存放优化后的 odex 文件的位置
librarySearchPath 是 native 依赖的位置
parent 就是父类加载器,默认会先从 parent 加载对应的类

创建出 DexClassLaoder 实例以后,只要调用其 loadClass(className) 方法就可以加载插件中的类了。具体的实现在下面:

  1. // 从 assets 中拿出插件 apk 放到内部存储空间
  2. private fun extractPlugin() {
  3. var inputStream = assets.open("plugin.apk")
  4. File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())
  5. }
  6. private fun init() {
  7. extractPlugin()
  8. pluginPath = File(filesDir.absolutePath, "plugin.apk").absolutePath
  9. nativeLibDir = File(filesDir, "pluginlib").absolutePath
  10. dexOutPath = File(filesDir, "dexout").absolutePath
  11. // 生成 DexClassLoader 用来加载插件类
  12. pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)
  13. }

四、热修复需要解决的难点

热修复不同于插件化,不需要考虑各种组件的生命周期,唯一需要考虑的就是如何能将问题的方法/类/资源/so 替换为补丁中的新方法/类/资源/so。
其中最重要的是方法和类的替换,所以有不少热修复框架只做了方法和类的替换,而没有对资源和 so 进行处理。

五、主流的热修复框架对比

这里选取几个比较主流的热修复框架进行对比

- Qzone/Nuwa AndFix Robust Tinker Sophix
dex 修复 y y y y y
so 修复 n n n y y
资源修复 n n n y y
全平台支持 y n y y y
即时生效 n y y n 同时支持
补丁包大小

上面是热修复框架的一些对比,如果按照实现 dex 修复的原理来划分的话,大概能分成下面几种:

下面对这几种热修复的方案进行详细分析。

六、dex 热修复方案

6.1 native hook 替换 ArtMethod 内容

6.1.1 原理

在解释 native hook 原理之前,先介绍一下虚拟机的一些简单实现。java 中的类,方法,变量,对应到虚拟机里的实现是 ClassArtMethodArtField。以 Android N 为例,简单看一下这几个类的一些结构。

  1. class Class: public Object {
  2. public:
  3. // ...
  4. // classloader 指针
  5. uint32_t class_loader_;
  6. // 数组的类型表示
  7. uint32_t component_type_;
  8. // 解析 dex 生成的缓存
  9. uint32_t dex_cache_;
  10. // interface table,保存了实现的接口方法
  11. uint32_t iftable_;
  12. // 类描述符,例如:java.lang.Class
  13. uint32_t name_;
  14. // 父类
  15. uint32_t super_class_;
  16. // virtual method table,虚方法表,指令 invoke-virtual 会用到,保存着父类方法以及子类复写或者覆盖的方法,是 java 多态的基础
  17. uint32_t vtable_;
  18. // public private
  19. uint32_t access_flags_;
  20. // 成员变量
  21. uint64_t ifields_;
  22. // 保存了所有方法,包括 static,final,virtual 方法
  23. uint64_t methods_;
  24. // 静态变量
  25. uint64_t sfields_;
  26. // class 当前的状态,加载,解析,初始化等等
  27. Status status_;
  28. static uint32_t java_lang_Class_;
  29. };
  30. class ArtField {
  31. public:
  32. uint32_t declaring_class_;
  33. uint32_t access_flags_;
  34. uint32_t field_dex_idx_;
  35. uint32_t offset_;
  36. };
  37. class ArtMethod {
  38. public:
  39. uint32_t declaring_class_;
  40. uint32_t access_flags_;
  41. // 方法字节码的偏移
  42. uint32_t dex_code_item_offset_;
  43. // 方法在 dex 中的 index
  44. uint32_t dex_method_index_;
  45. // 在 vtable 或者 iftable 中的 index
  46. uint16_t method_index_;
  47. // 方法的调用入口
  48. struct PACKED(4) PtrSizedFields {
  49. ArtMethod** dex_cache_resolved_methods_;
  50. GcRoot<mirror::Class>* dex_cache_resolved_types_;
  51. void* entry_point_from_jni_;
  52. void* entry_point_from_quick_compiled_code_;
  53. } ptr_sized_fields_;
  54. };

上面列出了三个结构的一部分变量,其实从这些变量可以比较清楚的看到:

简图如下:

这里也顺便说一下上面三个结构的内容是什么时候填充的,就是在 ClassLoader 加载类的时候。简图如下:

其实到这里,我们就简单理解了虚拟机的内部实现,也就很容易想到 native hook 的原理了。既然每次调用方法的时候,都是通过 ArtMethod 找到方法,然后跳转到其对应的字节码/机器码位置去执行,那么我们只要更改了跳转的目标位置,那么自然方法的实现也就被改变了。简图如下:

所以 native hook 的本质就是把旧方法的 ArtMethod 内容替换成新方法的 ArtMethod 内容。
具体的实现代码在这里(只实现了 Android N 上的修复),下面看一些重点代码。

6.1.2 实现代码

1、首先要找到替换的旧方法和新方法,这一步在 java 中进行,直接通过反射获取即可

  1. // 创建补丁的 ClassLoader
  2. pluginClassLoader = DexClassLoader(pluginPath, dexOutPath.absolutePath, nativeLibDir.absolutePath, this::class.java.classLoader)
  3. // 通过补丁 ClassLoader 加载新方法
  4. val toMethod = pluginClassLoader.loadClass("com.zy.hotfix.native_hook.PatchNativeHookUtils").getMethod("getMsg")
  5. // 反射获取到需要修改的旧方法
  6. val fromMethod = nativeHookUtils.javaClass.getMethod("getMsg")

2、之后调用 native 方法替换 ArtMethod 内容

  1. nativeHookUtils.patch(fromMethod, toMethod)
  1. Java_com_zy_hotfix_native_1hook_NativeHookUtils_patch(JNIEnv* env, jobject clazz, jobject src, jobject dest) {
  2. // 获取到 java 方法对应的 ArtMethod
  3. art::mirror::ArtMethod* smeth =
  4. (art::mirror::ArtMethod*) env->FromReflectedMethod(src);
  5. art::mirror::ArtMethod* dmeth =
  6. (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
  7. reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->clinit_thread_id_ =
  8. reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->clinit_thread_id_;
  9. reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->status_ =
  10. static_cast<art::mirror::Class::Status>(reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->status_ -1);
  11. //for reflection invoke
  12. reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->super_class_ = 0;
  13. // 替换方法中的内容
  14. smeth->declaring_class_ = dmeth->declaring_class_;
  15. smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
  16. smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
  17. smeth->dex_method_index_ = dmeth->dex_method_index_;
  18. smeth->method_index_ = dmeth->method_index_;
  19. smeth->hotness_count_ = dmeth->hotness_count_;
  20. // 替换方法的入口
  21. smeth->ptr_sized_fields_.dex_cache_resolved_methods_ =
  22. dmeth->ptr_sized_fields_.dex_cache_resolved_methods_;
  23. smeth->ptr_sized_fields_.dex_cache_resolved_types_ =
  24. dmeth->ptr_sized_fields_.dex_cache_resolved_types_;
  25. smeth->ptr_sized_fields_.entry_point_from_jni_ =
  26. dmeth->ptr_sized_fields_.entry_point_from_jni_;
  27. smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
  28. dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
  29. }

通过上述方法的替换,再次调用旧方法,就会跳转到新方法的入口,自然也就执行新方法的逻辑了。

6.1.3 优缺点

优点
补丁可以实时生效
缺点
1. 兼容性差,由于 Android 系统每个版本的实现都有差别,所以需要做很多的兼容。(这也就是为什么上面提供的 demo 代码只能运行在 Android N 上,因为没有对其他版本做兼容)
2. 开发需要掌握 jni 相关知识

6.2 dex 插桩

6.2.1 原理

dex 插桩的实现,是 Qzone 团队提出来的,Nuwa 框架采用这种实现并且开源。系统默认使用的是 PathClassLoader,继承自 BaseDexClassLoader,在 BaseDexClassLoader 里,有一个 DexPathList 变量,在 DexPathList 的实现里,有一个 Element[] dexElements 变量,这里面保存了所有的 dex。在加载 Class 的时候,就遍历 dexElements 成员,依次查找 Class,找到以后就返回。

下面是重点代码。

  1. public class PathClassLoader extends BaseDexClassLoader {
  2. }
  3. public class BaseDexClassLoader extends ClassLoader {
  4. private final DexPathList pathList;
  5. }
  6. final class DexPathList {
  7. // 保存了 dex 的列表
  8. private Element[] dexElements;
  9. public Class findClass(String name, List<Throwable> suppressed) {
  10. // 遍历 dexElements
  11. for (Element element : dexElements) {
  12. DexFile dex = element.dexFile;
  13. if (dex != null) {
  14. // 从 DexFile 中查找 Class
  15. Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
  16. if (clazz != null) {
  17. return clazz;
  18. }
  19. }
  20. }
  21. // ...
  22. return null;
  23. }
  24. }

从上面 ClassLoader 的实现我们可以知道,查找 Class 的关键就是遍历 dexElements,那么自然就想到了把补丁 dex 插入到 dexElements 最前面,这样遍历 dexElements 就会优先从补丁 dex 中查找 Class 了。

具体的实现在这里,下面放一些重点代码。

6.2.2 实现代码
  1. public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
  2. // 创建补丁 dex 的 classloader,目的是使用其中的补丁 dexElements
  3. DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
  4. // 获取到旧的 classloader 的 pathlist.dexElements 变量
  5. Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
  6. // 获取到补丁 classloader 的 pathlist.dexElements 变量
  7. Object newDexElements = getDexElements(getPathList(dexClassLoader));
  8. // 将补丁 的 dexElements 插入到旧的 classloader.pathlist.dexElements 前面
  9. Object allDexElements = combineArray(newDexElements, baseDexElements);
  10. }
  11. private static PathClassLoader getPathClassLoader() {
  12. PathClassLoader pathClassLoader = (PathClassLoader) InsertDexUtils.class.getClassLoader();
  13. return pathClassLoader;
  14. }
  15. private static Object getDexElements(Object paramObject)
  16. throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
  17. return Reflect.on(paramObject).get("dexElements");
  18. }
  19. private static Object getPathList(Object baseDexClassLoader)
  20. throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
  21. return Reflect.on(baseDexClassLoader).get("pathList");
  22. }
  23. private static Object combineArray(Object firstArray, Object secondArray) {
  24. Class<?> localClass = firstArray.getClass().getComponentType();
  25. int firstArrayLength = Array.getLength(firstArray);
  26. int allLength = firstArrayLength + Array.getLength(secondArray);
  27. Object result = Array.newInstance(localClass, allLength);
  28. for (int k = 0; k < allLength; ++k) {
  29. if (k < firstArrayLength) {
  30. Array.set(result, k, Array.get(firstArray, k));
  31. } else {
  32. Array.set(result, k, Array.get(secondArray, k - firstArrayLength));
  33. }
  34. }
  35. return result;
  36. }
6.2.3 优缺点

优点
1. 实现简单
2. 不需要太多的适配

缺点
1. 需要重新启动补丁才能生效。因为在插桩之前加载的类是不会再重新加载的,所以需要重新启动,让已经加载过的 Class 重新加载才能应用到补丁
2. class verify 问题。关于这个问题可以看Qzone 的解释,这里就不详细展开了
3. Art 虚拟机上由于 oat 导致的地址偏移问题,可能会需要在补丁包中打入补丁无关的类,导致补丁包体积增大

6.3 dex 替换

dex 替换的方案,主要是 tinker 在使用,这里生成的补丁包不只是需要修改的类,而是包含了整个 app 所有的类,在替换时原理和 dex 插桩类似,也是替换掉 dexElements 中的内容即可,这里就不详细说了。

6.4 InstantRun

6.4.1 原理

InstantRun 是 AndroidStudio 2.0 新增的功能,方便快速的增量编译应用并部署,美团参照其原理实现了 Robust 热修复框架。
其中的原理是,给每个 Class 中新增一个 changeQuickRedirect 的静态变量,并在每个方法执行之前,对这个变量进行了判断,如果这个变量被赋值了,就调用补丁类中的方法,如果没有被赋值,还是调用旧方法。
原理比较简单,下面看看实现。具体实现在这里

6.4.2 实现代码
  1. public class InstantRunUtils {
  2. // 上文中说的 changeQuickRedirect 变量,改了一下名字
  3. public static PatchRedirect patchRedirect;
  4. // 需要补丁的方法
  5. public int getValue() {
  6. // 判断 patchRedirect 是否为空
  7. if (patchRedirect != null) {
  8. // 不为空,说明方法需要打补丁,由于一个类中有很多方法,所以这里需要判断此方法是否需要补丁
  9. if (patchRedirect.needPatch("getValue")) {
  10. // 需要补丁,就调用补丁中的方法
  11. return (String) patchRedirect.invokePatchMethod("getValue");
  12. }
  13. }
  14. return 100;
  15. }
  16. // 注入补丁
  17. public static void inject(ClassLoader classLoader) {
  18. try {
  19. // 获取到补丁中的补丁信息
  20. Class patchInfoClass = classLoader.loadClass("com.zy.hotfix.instant_run.PatchInfo");
  21. patchInfoClass.getMethod("init").invoke(null);
  22. // patchMap 中存着 className -> PatchRedirect,即需要补丁的类描述符和对应的 PatchRedirect
  23. Map<String, Object> patchMap = (Map<String, Object>) patchInfoClass.getField("patchMap").get(null);
  24. for (String key: patchMap.keySet()) {
  25. PatchRedirect redirect = (PatchRedirect) patchMap.get(key);
  26. Class clazz = Class.forName(key);
  27. // 替换 class 中的 PatchRedirect
  28. clazz.getField("patchRedirect").set(null, redirect);
  29. }
  30. } catch (Exception e) {
  31. e.printStackTrace();
  32. }
  33. }
  34. }

然后我们看看补丁中的 PatchRefirect 是怎么实现的

  1. public class InstantRunUtilsRedirect extends PatchRedirect {
  2. @Override
  3. public Object invokePatchMethod(String methodName, Object... params) {
  4. // 根据方法描述符调用对应的方法
  5. if (methodName.equals("getValue")) {
  6. return getValue();
  7. }
  8. return null;
  9. }
  10. @Override
  11. public boolean needPatch(String methodName) {
  12. // 判断方法是否需要补丁
  13. if ("getValue".equals(methodName)) {
  14. return true;
  15. }
  16. return false;
  17. }
  18. // 补丁方法,返回正确的值
  19. public int getValue() {
  20. return 200;
  21. }
  22. }
6.4.3 优缺点

优点
1. 使用 java 实现,开发方便
2. 兼容性好
3. 补丁实时生效

缺点
1. 代码是侵入比较高,需要在原有代码中新增逻辑,而且需要对方法进行插桩,将这里逻辑自动化处理
2. 增大包体积

七、资源热修复方案

关于资源的修复方案,没有像代码修复一样方法繁多,基本上集中在对 AssetManager 的修改上。

7.1 替换 AssetManager

这个是 InstantRun 采用的方案,就是构造一个新的 AssetManager,反射调用其 addAssetPath 函数,把新的补丁资源包添加到 AssetManager 中,从而得到含有完整补丁资源的 AssetManager,然后找到所有引用 AssetManager 的地方,通过反射将其替换为新的 AssetManager。

7.2 添加修改的资源到 AssetManager 中,并重新初始化

这个是 Sophix 采用的方案,原理是构造一个 package id 为 0x66 的资源包,只含有改变的资源,将其直接添加到原有的 AssetManager 中,这样不会与原来的 package id 0x7f 冲突。然后将原来的 AssetManager 重新进行初始化即可,就不需要进行繁琐的反射替换操作了。

八、so 热修复方案

8.1 对加载过程进行封装,替换 System.loadLibrary

在加载 so 库的时候,系统提供了两个接口

  1. System.loadLibrary(String libName):用来加载已经安装的 apk 中的 so
  2. System.load(String pathName):可以加载自定义路径下的 so

通过上面两个方法,我们可以想到,如果有补丁 so 下发,我们就调用 System.load 去加载,如果没有补丁 so 没有下发,那么还是调用 System.loadLibrary 去加载系统目录下的 so,原理比较简单,但是我们需要再上面进行一层封装,并对调用 System.loadLibrary 的地方都进行替换。

8.2 反射注入补丁 so 路径

还记得上面 dex 插桩的原理么?在 DexPathList 中有 dexElements 变量,代表着所有 dex 文件,其实 DexPathList 中还有另一个变量就是 Element[] nativeLibraryPathElements,代表的是 so 的路径,在加载 so 的时候也会遍历 nativeLibraryPathElements 进行加载,代码如下:

  1. public String findLibrary(String libraryName) {
  2. String fileName = System.mapLibraryName(libraryName);
  3. // 遍历 nativeLibraryPathElements
  4. for (Element element : nativeLibraryPathElements) {
  5. String path = element.findNativeLibrary(fileName);
  6. if (path != null) {
  7. return path;
  8. }
  9. }
  10. return null;
  11. }

看到这里我们就知道如何去做了吧,就像 dex 插桩一样的方法,将 so 的路径插入到 nativeLibraryPathElements 之前即可。

九、总结

参考资料

https://www.cnblogs.com/popfisher/p/8543973.html
https://tech.meituan.com/2016/09/14/android-robust.html
深入探索Android热修复技术原理

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注