我报名参与金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动概况

前语

热修正到现在2022年现已不是一个新名词,但是作为Android开发中心技能栈的一部分,我这儿还得来一次冷饭热炒。

随着移动端业务复杂程度的添加,传统的版别更新流程明显无法满意业务和开发者的需求, 热修正技能的推出在很大程度上改进了这一局面。国内大部分老练的干流 App都具有自己的热更新技能,像手淘、支付宝、微信、QQ、饿了么、美团等。

能够说,一个好的热修正技能,将为你的 App助力百倍。关于每一个想在 Android 开发领域有所造诣的开发者,掌握热修正技能更是必备的素质

热修正是 Android 大厂面试中高频面试知识点,也是咱们必需求掌握的知识点。热修正技能,能够看作 Android平台开展老练至必定阶段的必定产品。 Android热修正了解吗?修正哪些东西? 常见热修正结构比照以及各原理剖析?

1.什么是热修正

热修正说白了便是不再运用传统的运用商店更新或许自更新办法,运用补丁包推送的办法在用户无感知的情况下,修正运用bug或许推送新的需求

传统更新热更新进程比照如下:

结业5年了还不知道热修正?

热修正优缺陷:

  • 长处:
    • 1.只需求打补丁包,不需求从头发版别。
    • 2.用户无感知,不需求从头下载最新运用
    • 3.修正成功率高
  • 缺陷
    • 补丁包滥用,简略导致运用版别不可控,需求开发一套完整的补丁包更新机制,会添加必定的成本

2.热修正计划

首要咱们得知道热修正修正哪些东西

  • 1.代码修正
  • 2.资源修正
  • 3.动态库修正

2.1:代码修正计划

从技能角度来说,咱们的意图是非常清晰的:把过错的代码替换成正确的代码。 注意这儿的替换,并不是直接擦写dx文件,而是供给一份新的正确代码,让运用运转时绕过过错代码,履行新的正确代码。

结业5年了还不知道热修正?

主意简略直接,但完成起来并不简略。现在主要有三类技能计划:

2.1.1.类加载计划

之前剖析类加载机制有说过: 加载流程先是遵循双亲派遣准则,假如派遣准则没有找到此前加载过此类, 则会调用CLassLoader的findClass办法,再去BaseDexClassLoader下面的dexElements数组中查找,假如没有找到,终究调用defineClassNative办法加载

代码修正便是根据这点: 将新的做了修正的dex文件,经过反射注入到BaseDexClassLoader的dexElements数组的第一个方位上dexElements[0],下次从头启动运用加载类的时分,会优先加载做了修正的dex文件,这样就到达了修正代码的意图。原理很简略

代码如下:

public class Hotfix {
    public static void patch(Context context, String patchDexFile, String patchClassName)
                    throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
        //获取体系PathClassLoader的"dexElements"特点值
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
        Object origDexElements = getDexElements(pathClassLoader);
        //新建DexClassLoader并获取“dexElements”特点值
        String otpDir = context.getDir("dex", 0).getAbsolutePath();
        Log.i("hotfix", "otpdir=" + otpDir);
        DexClassLoader nDexClassLoader = new DexClassLoader(patchDexFile, otpDir, patchDexFile, context.getClassLoader());
        Object patchDexElements = getDexElements(nDexClassLoader);
        //将patchDexElements刺进原origDexElements前面
        Object allDexElements = combineArray(origDexElements, patchDexElements);
        //将新的allDexElements从头设置回pathClassLoader
        setDexElements(pathClassLoader, allDexElements);
        //从头加载类
        pathClassLoader.loadClass(patchClassName);
}
private static Object getDexElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        //首要获取ClassLoader的“pathList”实例
        Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
        pathListField.setAccessible(true);//设置为可拜访
        Object pathList = pathListField.get(classLoader);
        //然后获取“pathList”实例的“dexElements”特点
        Field dexElementField = pathList.getClass().getDeclaredField("dexElements");
        dexElementField.setAccessible(true);
        //读取"dexElements"的值
        Object elements = dexElementField.get(pathList);
        return elements;
    }
    //合拼dexElements
    private static Object combineArray(Object obj, Object obj2) {
        Class componentType = obj2.getClass().getComponentType();
        //读取obj长度
        int length = Array.getLength(obj);
        //读取obj2长度
        int length2 = Array.getLength(obj2);
        Log.i("hotfix", "length=" + length + ",length2=" + length2);
        //创立一个新Array实例,长度为ojb和obj2之和
        Object newInstance = Array.newInstance(componentType, length + length2);
        for (int i = 0; i < length + length2; i++) {
                //把obj2元素刺进前面
                if (i < length2) {
                        Array.set(newInstance, i, Array.get(obj2, i));
                } else {
                        //把obj元素顺次放在后边
                        Array.set(newInstance, i, Array.get(obj, i - length2));
                }
        }
        //回来新的Array实例
        return newInstance;
    }
    private static void setDexElements(ClassLoader classLoader, Object dexElements) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        //首要获取ClassLoader的“pathList”实例
        Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
        pathListField.setAccessible(true);//设置为可拜访
        Object pathList = pathListField.get(classLoader);
        //然后获取“pathList”实例的“dexElements”特点
        Field declaredField = pathList.getClass().getDeclaredField("dexElements");
        declaredField.setAccessible(true);
        //设置"dexElements"的值
        declaredField.set(pathList, dexElements);
    }
}

类加载进程如下:

结业5年了还不知道热修正?
微信Tinker,QQ 空间的超级补丁、手 QQ 的QFix 、饿了 么的 AmigoNuwa 等都是运用这个办法

缺陷:由于类加载后无法卸载,所以类加载计划有必要重启App,让bug类从头加载后才干收效。

2.1.2:底层替换计划

底层替换计划不会再次加载新类,而是直接在 Native 层 修正原有类, 这儿咱们需求说到Art虚拟机ArtMethod: 每一个Java办法在Art虚拟机中都对应着一个 ArtMethodArtMethod记录了这个Java办法的一切信息,包括所属类、拜访权限、代码履行地址等

结构如下:

// art/runtime/art_method.h
class ArtMethod FINAL {
...
 protected:
  GcRoot<mirror::Class> declaring_class_;
  GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;
  GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;
  uint32_t access_flags_;
  uint32_t dex_code_item_offset_;
  uint32_t dex_method_index_;
  uint32_t method_index_;
  struct PACKED(4) PtrSizedFields {
        void* entry_point_from_interpreter_;      // 1
        void* entry_point_from_jni_;
        void* entry_point_from_quick_compiled_code_;  //2
  } ptr_sized_fields_;
  ...
}

在 ArtMethod结构体中,最重要的便是 注释1和注释2标示的内容,从名字能够看出来,他们便是办法的履行进口。 咱们知道,Java代码在Android中会被编译为 Dex Code

Art虚拟机中能够选用解说形式或许 AOT机器码模式履行 Dex Code

  • 解说形式: 便是去除Dex Code,逐条解说履行。 假如办法的调用者是以解说形式运转的,在调用这个办法时,就会获取这个办法的 entry_point_from_interpreter_,然后跳转履行。
  • AOT形式: 就会预先编译好 Dex Code对应的机器码,然后在运转期直接履行机器码,不需求逐条解说履行Dex Code。 假如办法的调用者是以AOT机器码办法履行的,在调用这个办法时,便是跳转到 entry_point_from_quick_compiled_code_中履行。

那是不是只需求替换这个几个 entry_point_* 进口地址就能够完成办法替换了呢? 并没有那么简略,由于不论是解说形式仍是AOT形式,在运转期间还会需求调用ArtMethod中的其他成员字段

AndFix选用的是改动指针指向

// AndFix/jni/art/art_method_replace_6_0.cpp
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
    art::mirror::ArtMethod* smeth =
                    (art::mirror::ArtMethod*) env->FromReflectedMethod(src);  // 1
    art::mirror::ArtMethod* dmeth =
                    (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);  // 2
    ...
    // 3
    smeth->declaring_class_ = dmeth->declaring_class_;
    smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
    smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
    smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
    smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
    smeth->dex_method_index_ = dmeth->dex_method_index_;
    smeth->method_index_ = dmeth->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_);
}

缺陷:存在一些兼容性问题,由于ArtMethod结构体是Android开源的一部分,所以每个手机厂商都或许会去更改这部分的内容,这就或许导致ArtMethod替换计划在某些机型上面呈现不知道过错。

Sophix为了规避上面的AndFix的危险,选用直接替换整个结构体。这样不论手机厂商如何更改体系,咱们都能够正确定位到办法地址

2.4.3:install run计划

Instant Run 计划的中心思想是——插桩在编译时经过插桩在每一个办法中刺进代码,修正代码逻辑,在需求时绕过过错办法,调用patch类的正确办法。

首要,在编译时Instant Run为每个类刺进IncrementalChange变量

IncrementalChange  $change;

为每一个办法添加类似如下代码:

public void onCreate(Bundle savedInstanceState) {
    IncrementalChange var2 = $change;
    //$change不为null,表明该类有修正,需求重定向
    if(var2 != null) {
        //经过access$dispatch办法跳转到patch类的正确办法
        var2.access$dispatch("onCreate.(Landroid/os/Bundle;)V", new Object[]{this, savedInstanceState});
    } else {
        super.onCreate(savedInstanceState);
        this.setContentView(2130968601);
        this.tv = (TextView)this.findViewById(2131492944);
    }
}

如上代码,当一个类被修正后,Instant Run会为这个类新建一个类,命名为xxx&override,且完成IncrementalChange接口,并且赋值给原类的$change变量。

public class MainActivity$override implements IncrementalChange {
}

此时,在运转时原类中每个办法的var2 != null,经过accessdispatch(参数是办法名和原参数)定位到patch类MainActivitydispatch(参数是办法名和原参数)定位到patch类MainActivityoverride中修正后的办法。

Instant Run是google在AS2.0时用来完成“热布置”的,一起也为“热修正”供给了一个绝佳的思路。美团的Robust便是根据此

2.2:资源修正计划

这儿咱们来看看install run的原理即可,市面上的常见修正计划大部分都是根据此办法。

public static void monkeyPatchExistingResources(Context context,
            String externalResourceFile, Collection<Activity> activities) {
    if (externalResourceFile == null) {
            return;
    }
    try {
// 创立一个新的AssetManager
        AssetManager newAssetManager = (AssetManager) AssetManager.class
                        .getConstructor(new Class[0]).newInstance(new Object[0]); // ... 1
        Method mAddAssetPath = AssetManager.class.getDeclaredMethod(
                        "addAssetPath", new Class[] { String.class }); // ... 2
        mAddAssetPath.setAccessible(true);
// 经过反射调用addAssetPath办法加载外部的资源(SD卡资源)
        if (((Integer) mAddAssetPath.invoke(newAssetManager,
                        new Object[] { externalResourceFile })).intValue() == 0) { // ... 3
                throw new IllegalStateException(
                                "Could not create new AssetManager");
        }
        Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod(
                        "ensureStringBlocks", new Class[0]);
        mEnsureStringBlocks.setAccessible(true);
        mEnsureStringBlocks.invoke(newAssetManager, new Object[0]);
        if (activities != null) {
            for (Activity activity : activities) {
                Resources resources = activity.getResources(); // ... 4
                try { 
// 反射得到Resources的AssetManager类型的mAssets字段
                    Field mAssets = Resources.class
                                    .getDeclaredField("mAssets"); // ... 5
                    mAssets.setAccessible(true);
// 将mAssets字段的引证替换为新创立的newAssetManager
                    mAssets.set(resources, newAssetManager); // ... 6
                } catch (Throwable ignore) {
                    ...
                }
// 得到Activity的Resources.Theme
                Resources.Theme theme = activity.getTheme();
                try {
                    try {
// 反射得到Resources.Theme的mAssets字段
                        Field ma = Resources.Theme.class
                                        .getDeclaredField("mAssets");
                        ma.setAccessible(true);
// 将Resources.Theme的mAssets字段的引证替换为新创立的newAssetManager
                        ma.set(theme, newAssetManager); // ... 7
                    } catch (NoSuchFieldException ignore) {
                            ...
                    }
                        ...
                } catch (Throwable e) {
                    Log.e("InstantRun",
                                    "Failed to update existing theme for activity "
                                                    + activity, e);
                }
                pruneResourceCaches(resources);
        }
        }
/**
*  根据SDK版别的不同,用不同的办法得到Resources 的弱引证调集
*/ 
        Collection<WeakReference<Resources>> references;
        if (Build.VERSION.SDK_INT >= 19) {
            Class<?> resourcesManagerClass = Class
                            .forName("android.app.ResourcesManager");
            Method mGetInstance = resourcesManagerClass.getDeclaredMethod(
                            "getInstance", new Class[0]);
            mGetInstance.setAccessible(true);
            Object resourcesManager = mGetInstance.invoke(null,
                            new Object[0]);
            try {
                Field fMActiveResources = resourcesManagerClass
                                .getDeclaredField("mActiveResources");
                fMActiveResources.setAccessible(true);
                ArrayMap<?, WeakReference<Resources>> arrayMap = (ArrayMap) fMActiveResources
                                .get(resourcesManager);
                references = arrayMap.values();
            } catch (NoSuchFieldException ignore) {
                Field mResourceReferences = resourcesManagerClass
                                .getDeclaredField("mResourceReferences");
                mResourceReferences.setAccessible(true);
                references = (Collection) mResourceReferences
                                .get(resourcesManager);
            }
        } else {
            Class<?> activityThread = Class
                            .forName("android.app.ActivityThread");
            Field fMActiveResources = activityThread
                            .getDeclaredField("mActiveResources");
            fMActiveResources.setAccessible(true);
            Object thread = getActivityThread(context, activityThread);
            HashMap<?, WeakReference<Resources>> map = (HashMap) fMActiveResources
                            .get(thread);
            references = map.values();
        }
//遍历并得到弱引证调集中的 Resources ,将 Resources mAssets 字段引证替换成新的 AssetManager
            for (WeakReference<Resources> wr : references) {
                Resources resources = (Resources) wr.get();
                if (resources != null) {
                    try {
                        Field mAssets = Resources.class
                                        .getDeclaredField("mAssets");
                        mAssets.setAccessible(true);
                        mAssets.set(resources, newAssetManager);
                    } catch (Throwable ignore) {
                        ...
                    }
                    resources.updateConfiguration(resources.getConfiguration(),
                                    resources.getDisplayMetrics());
                }
            }
    } catch (Throwable e) {
            throw new IllegalStateException(e);
    }
}
  • 注释1处创立一个新的 AssetManager ,
  • 注释2注释3 处经过反射调用 addAssetPath 办法加载外部( SD 卡)的资源。
  • 注释4 处遍历 Activity 列表,得到每个 Activity 的 Resources ,
  • 注释5 处经过反射得到 Resources 的 AssetManager 类型的 rnAssets 字段 ,
  • 注释6处改写 mAssets 字段的引证为新的 AssetManager 。

选用相同的办法

  • 注释7处将 Resources. Theme 的 m Assets 字段 的引证替换为新创立的 AssetManager 。
  • 紧接着 根据 SDK 版别的不同,用不同的办法得到 Resources 的弱引证调集,
  • 再遍历这个弱引证调集, 将弱引证调集中的 Resources 的 mAssets 字段引证都替换成新创立的 AssetManager 。

资源修正原理

  • 1.创立新的AssetManager,经过反射调用addAssetPath办法,加载外部资源,这样新创立的AssetManager就含有了外部资源
  • 2.将AssetManager类型的mAsset字段全部用新创立的AssetManager目标替换。这样下次加载资源文件的时分就能够找到包含外部资源文件的AssetManager。

2.3:动态链接库so的修正

1.接口调用替换计划:

sdk供给接口替换System默认加载so库接口

SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName)

SOPatchManager.loadLibrary接口加载 so库的时分优先测验去加载sdk 指定目录下的补丁so

加载策略如下:

假如存在则加载补丁 so库而不会去加载装置apk装置目录下的so库 假如不存在补丁so,那么调用System.loadLibrary去加载装置apk目录下的 so库。

结业5年了还不知道热修正?
咱们能够很清楚的看到这个计划的优缺陷: 长处:不需求对不同 sdk 版别进行兼容,由于一切的 sdk 版别都有 System.loadLibrary 这个接口。 缺陷:调用方需求替换掉 System 默认加载 so 库接口为 sdk供给的接口, 假如是现已编译混杂好的三方库的so 库需求 patch,那么是很难做到接口的替换

尽管这种计划完成简略,一起不需求对不同 sdk版别区别处理,但是有必定的局限性没法修正三方包的so库一起需求强制侵入接入方接口调用,接着咱们来看下反射注入计划。

2、反射注入计划

前面介绍过 System. loadLibrary ( “native-lib”); 加载 so库的原理,其实native-lib 这个 so 库终究传给 native 办法履行的参数是 so库在磁盘中的完整途径,比方:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so库会在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 变量所表明的目录下去遍历查找

sdk<23 DexPathList.findLibrary 完成如下

结业5年了还不知道热修正?

能够发现会遍历 nativeLibraryDirectories数组,假如找到了 loUtils.canOpenReadOnly (path)回来为 true, 那么就直接回来该 path, loUtils.canOpenReadOnly (path)回来为 true 的前提肯定是需求 path 表明的 so文件存 在的。那么咱们能够采纳类似类修正反射注入办法,只要把咱们的补丁so库的途径刺进到nativeLibraryDirectories数组的最前面就能够到达加载so库的时分是补丁 库而不是本来so库的目录,从而到达修正的意图。

sdk>=23 DexPathList.findLibrary 完成如下

结业5年了还不知道热修正?
sdk23 以上 findLibrary 完成现已发生了变化,如上所示,那么咱们只需求把补丁so库的完整途径作为参数构建一个Element目标,然后再刺进到nativeLibraryPathElements 数组的最前面就好了。

  • 长处:能够修正三方库的so库。一起接入方不需求像计划1 —样强制侵入用 户接口调用
  • 缺陷:需求不断的对 sdk 进行适配,如上 sdk23 为分界线,findLibrary接口完成现已发生了变化。

关于 so库的修正计划现在更多采纳的是接口调用替换办法,需求强制侵入用户 接口调用。 现在咱们的so文件修正计划采纳的是反射注入的计划,重启收效。具有更好的普遍性。 假如有so文件修正实时收效的需求,也是能够做到的,只是有些约束情况。

常见热修正结构?

特性 Dexposed AndFix Tinker/Amigo QQ Zone Robust/Aceso Sophix
技能原理 native底层替换 native底层替换 类加载 类加载 Instant Run 混合
所属 阿里 阿里 微信/饿了么 QQ空间 美团/蘑菇街 阿里
即时收效 YES YES NO NO YES 混合
办法替换 YES YES YES YES YES YES
类替换 NO NO YES YES YES YES
类结构修正 NO NO YES NO NO YES
资源替换 NO NO YES YES NO YES
so替换 NO NO YES NO NO YES
支撑gradle NO NO YES YES YES YES
支撑ART NO YES YES YES YES YES

能够看出,阿里系多选用native底层计划,腾讯系多选用类加载机制。其中,Sophix是商业化计划;Tinker/Amigo支撑特性较多,一起也更复杂,假如需求修正资源和so,能够挑选;假如仅需求办法替换,且需求即时收效,Robust是不错的挑选。

总结:

尽管热修正(或热更新)相关于迭代更新有诸多优势,市面上也有很多开源计划可供挑选,但现在热修正仍然无法代替迭代更新形式。有如下原因: 热修正结构多多少少会添加功用开销,或添加APK大小 热修正技能本身存在局限,比方有些计划无法替换so或资源文件 热修正计划的兼容性,有些计划无法一起兼顾Dalvik和ART,有些深度定制体系也无法正常工作 监管危险,比方苹果体系严格约束热修正

所以,关于功用迭代和常规bug修正,版别迭代更新仍然是干流。一般的代码修正,运用Robust能够处理,假如还需求修正资源或so库,能够考虑Tinker

参阅文章

  • Tinker-接入攻略

  • 热修正原理学习(2)底层替换原理和打破底层差异的办法

  • 深化理解Android热修正技能原理之so库热修正技能