性能优化 (九) APP 稳定性之热修复原理探索

2,863 阅读6分钟

性能优化系列

APP 启动优化

UI 绘制优化

内存优化

图片压缩

长图优化

电量优化

Dex 加解密

动态替换 Application

APP 稳定性之热修复原理探索

APP 持续运行之进程保活实现

ProGuard 对代码和资源压缩

APK 极限压缩

代码传送阵

完整代码传送阵

热修复的背景

  • 刚发布的版本出现了严重的 bug ,需要开发者去解决 bug,然后在测试打包重新发布,这会耗费大量的人力,物力,代价比较大。
  • 如果当前的 bug 不影响用户使用也不会崩溃,但是了下个版本是大版本,那么两个版本之间间隔时间会很长,这样要等到下个大版本发布在修复 bug , 而之前版本的 bug 还存在,虽说不影响使用,但是是一个潜在的 bug。
  • 版本升级率不高,并且需要长时间来完成版本迭代,前版本的 bug 就会一直影响不升级的用户。
  • 有一些小但是很重要的功能需要在短时间内完成版本迭代,比如假日活动。

..等等, 这里只是拿几个常见的举例说明。

热修复的效率

热修复框架对比

框架名称 所属公司 是否开源 修复方式
Dexposed alibaba 开源 实时修复
Andfix alibaba 开源 实时修复
Hotfix alibaba 暂未开源 实时修复
Qzone 超级补丁 QQ 空间 暂未开源 冷启动修复
QFix 手 Q 团队 开源 冷启动修复
Robust 美团 开源 实时修复
Nuwa 大众点评 开源 冷启动修复
RocooFix 百度金融 开源 冷启动修复
Aceso 美丽说蘑菇街 开源 实时修复
Amigo 饿了么 开源 冷启动修复
Tinker 微信 开源 冷启动修复
Sophix alibaba 未开源 实时修复 + 冷启动修复

代码修复(今日主题 - 类加载方式)

底层替换方式

  • 在已加载的类中直接替换原有方法,是在原有类的基础上进行修改,无法实现对原有类进行方法和字段的增减,这样会破坏原有类的结构。
  • 不稳定。直接修改 JVM 方法实体的具体字段来实现的。Android 是开源的,不同的手机厂商开源对代码进行修改,所以像 Andfix 就会出现在部分机型上的修复失败的现象。

ClassLoader 类加载方式

  • APP 重新启动后,让 ClassLoader 去加载新的类。

  • class 暂未被加载到系统中,收到推送利用插桩原理让 ClassLoader 优先加载修复好的 dex 。

实现自己的热修复框架

Dex 分包

65536 限制

com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536

当应用程序报 65536 错误的根本原因是,应用的方法数量超过了最大数 65536 个,因为 DVM Bytecode 的限制, DVM 指令集的方法调用指令 invoke-kind 索引为 16 bits, 最多能引用 65535 个方法

LinearAlloc 限制

INSTALL_FAILED_DEXOPT

在安装应用时可能会提示 上面的错误,产生的原因是 LinearAlloc 限制。 DVM 中的 LinearAlloc 是一个固定的缓存区,当方法数超出缓存区的大小时会报错。

解决

为了解决 65536 限制和 LinearAlloc 限制,从而产生了 Dex 分包机制。 Dex 分包方案主要做的时在打包时将应用代码分成多个 Dex,将应用启动时必须用到的类和这些类的直接引用类放到主 Dex 中,其它代码放到次 Dex 中。当应用启动时先加载主 Dex,等到应用启动后再动态地加载次Dex,从而缓解了主 Dex 的 65536 限制和 LinearAlloc 限制

  • gradle 配置

    android {
        compileSdkVersion 26
        defaultConfig {
            applicationId "com.ykun.hotfix"
            minSdkVersion 15
            targetSdkVersion 26
            versionCode 1
            versionName "1.0"
            testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    
            // 开启分包
            multiDexEnabled true
            // 设置分包配置文件
            multiDexKeepFile file('multidex.keep')
        }
        dexOptions {
            javaMaxHeapSize "4g"
            preDexLibraries = false
            additionalParameters = [ // 配置multidex参数
                                     '--multi-dex', // 多dex分包
                                     '--set-max-idx-number=50000', // 每个包内方法数上限
                                     '--main-dex-list=' + '/multidex.keep', // 打包到主classes.dex的文件列表
                                     '--minimal-main-dex'
            ]
        }
    
    }
    
  • 配置 multidex.keep 将指定的 class 放入 class.dex 中

    格式:

    //参考
    com/ykun/hotfix/BaseActivity.class
    com/ykun/hotfix/BaseApplication.class
    com/ykun/hotfix/MainActivity.class
    
  • 效果

什么是插桩?

源码:

/**遍历需要找到需要加载的 class */ 
public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            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;
    }

插桩原理:

通过源码得知 findClass 是通过遍历 dexElements 来找到 class, 如果我们反射得到 DexPathList 的私有数组 dexElements,我们外部改变这个数组内部顺序索引,将修复好的 dex 放入 [0] 的位置,那么是不是能够优先使用修复好的 dex 勒? 很明显,是成立的。下面开始撸代码吧。

代码实现

  1. 接收来至服务器发来的补丁包,如果修复包已经存在则删除,copy 到私有目录防止用户不小心删除。

    /**这里模拟已经下载好的 dex 补丁包*/    
    private void downloadPatch() {
            //1 从服务器下载dex文件 比如v1.1修复包文件(classes2.dex)
            File sourceFile = new File(Environment.getExternalStorageDirectory(), "classes2.dex");
            // 目标路径:私有目录
            //getDir("odex", Context.MODE_PRIVATE) data/user/0/包名/app_odex
            File targetFile = new File(getDir("hotfix",
                    Context.MODE_PRIVATE).getAbsolutePath() + File.separator + "classes2.dex");
            if (targetFile.exists()) {
                targetFile.delete();
            }
            try {
                // 复制dex到私有目录
                FileUtils.copyFile(sourceFile, targetFile);
                Toast.makeText(this, "Bug 修复成功!", Toast.LENGTH_SHORT).show();
                FixDexUtils.loadFixedDex(this);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
  2. 创建修复包的类加载器 DexClassLoader (通过源码得知是继承的 BaseDexClassLoader)

        /**
         * 创建类加载器
         *
         * @param context
         * @param fileDir
         */
        private static void createDexClassLoader(Context context, File fileDir) {
            String optimizedDirectory = fileDir.getAbsolutePath() + File.separator + "opt_dex";
            File fOpt = new File(optimizedDirectory);
            if (!fOpt.exists()) {
                fOpt.mkdirs();
            }
            DexClassLoader classLoader;
            for (File dex : loadedDex) {
                //初始化类加载器
                classLoader = new DexClassLoader(dex.getAbsolutePath(), optimizedDirectory, null,
                        context.getClassLoader());
                //热修复
                hotFix(classLoader, context);
            }
        }
    
  3. 获取系统的 PathClassLoader

    PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
    
  4. 获取修复包的 dexElements

    Object pathList = ReflectUtils.reflect(myClassLoader).field("pathList").get();
    Object myDexElements = ReflectUtils.reflect(pathList).field("dexElements").get();
    
  5. 获取系统的 dexElements

    Object sysPathList = ReflectUtils.reflect(pathClassLoader).field("pathList").get();
    Object sysDexElements = ReflectUtils.reflect(sysPathList).field("dexElements").get();
    
  6. 将系统的 dexElements 和 修复包的 dexElements merge 成新的 dexElements

    // 合并,这里利用插桩原理进行合并数组,将修复好的 class2.dex 放入第一位,优先加入就行了
    Object dexElements = ArrayUtils.combineArray(myDexElements, sysDexElements);
    
  7. 重新赋值给 DexPathList 的 dexElements 属性

    //重新赋值
    ReflectUtils.reflect(sysPathList).field("dexElements", dexElements);
    

热修复未来发展

  1. 热修复 = “黑科技”?

    • 热修复不同于国内 APP 进程保活这种 “黑科技”,让 app 常驻后台,既耗电又占用内存,浪费很多手机资源。还有 APP 的推送服务,无节操地对用户进行信息轰炸。还有更无节操的全家桶 app。导致 Android手机卡顿不堪,这些所谓的 “黑科技” 都是为了手机厂商的利益而损害用户的体验。

    • 而热修复是能够让开发者和用户双赢的。不仅厂商能快速迭代更新 app,使功能尽快上线,而且热更新过程用户无感知,节省大量更新时间,提高用户体验。更重要的能保证 app 的功能稳定,bug 能及时修复。

  2. IOS 封杀了热修复功能,Android 的热修复也会被 pass 掉吗?

    • google 和 apple 公司在中国的 diwei 不一样

    • Android 和 IOS 的开放性不同

  3. 热修复未来发展前景是很乐观的。