登录

去注册

登录

注册

去登录

注册

热修复框架 Stark 项目由来

ximsfei   2018-08-10   收藏

项目地址:https://github.com/ximsfei/Stark

2018年3月,Google 发布了 Android P 预览版,做为一个合格的 Android 开发者,当然是紧跟 Google 的步伐,立即查看了 Android P 的最新变动,看到其中的应用兼容性变更,真是让人难过。下面这段是文档中的原话:

Android P 引入了针对非 SDK 接口的使用限制,无论是直接使用还是通过反射或 JNI 间接使用。保留非 SDK 接口的后果:在后续版本的 Developer Preview 中,各种访问非 SDK 接口的方式都会产生错误或者其他不希望的后果。

对主要从事插件化、热修复相关工作我来说,这一点真是致命的打击,一度怀疑自己是否要失业了,纵观 Android 插件化及热修复的发展历史,国内绝大部分的开源库都或多或少的使用了非 SDK 接口,而且核心实现也都依赖着这些非 SDK 接口。

虽然 Google 官方在文档中也说了,会通过提供浅灰名单的方式,开放部分非 SDK 接口的调用:

浅灰名单包含在 Android P 中继续工作,但我们不能保证在未来版本的平台中能够继续访问的函数和字段。 如由于某种原因,您不能实现替代列入浅灰名单的 API 的方案,则可以提交错误,以请求重新考虑此限制。

但还是会心有余悸,毕竟不能保证在未来版本的平台中能够继续访问的函数和字段。对于插件化框架来说,应用开发者们,可以选择组件化的模式来替代。但是对于热修复来说,还是有存在意义的,毕竟谁也无法保证自己开发的应用不会出现bug,当出现bug的时候,通过发版来修复,显得比较无力。所以还是需要寻找一种完全使用 SDK 接口的方式来实现热修复方案。

这里想到了美团点评的Robust,这个框架的实现原理参考了 Google 官方的 InstantRun,做到了没有调用任何非 SDK 接口实现代码的热修复,但是还是存在着诸多限制:暂时无法修复构造方法,无法修复资源,使用起来较为复杂等等。

研究了 InstantRun 源码,发现 InstantRun 也不是万能的,在更新资源的时候,InstantRun 也调用了非 SDK 接口。看 InstantRun 的源码,读者可能需要科学上网,可以通过Tencent/tinker的资源修复相关代码(其实也是参考了 InstantRun),来了解一下。

在坚持不调用任何非 SDK 接口,对开发者透明的原则下,开发了一个可以修复代码和资源的框架,代号为:Stark。

番外篇:至于为啥选 Stark 做为项目代号,当然是因为钢铁侠啊,他可以在生命危在旦夕的时候,制造方舟反应炉来挽救自己,我们也可以在出现线上问题的时候,选择制作补丁包来修复 bug 啦。哈哈,强行解释一波~~~

Stark 项目地址: https://github.com/ximsfei/Stark

Stark 实现原理

这里将会通过 APK 打包补丁生成运行时加载三个部分来讲述 Stark 实现原理:

APK 打包

  1. 代码重定向

Stark 在 APK 打包时,参考 InstantRun 在每个方法前注入了一段类似的代码:

public class AnyClass {
    public StarkChange $starkChange;
    public void init() {
        if ($starkChange != null) {
            $starkChange.access$dispatch("init.()V", new Object[]{this});
        } else {
            // 原方法内容
        }
    }
}
  1. 资源重定向

针对资源的热修复,Stark 研究了一种方案,可以在不调用非 SDK 接口的情况下实现资源热修复,在 APK 打包时,会修改所有的 Context 相关的组件的父类:

例如,所有继承自 Activity 的类,会自动修改为:

//public class MainActivity extends Activity {
public class MainActivity extends StarkActivity {
    
}

public abstract class StarkActivity extends Activity {
    @Override
    protected void attachBaseContext(Context newBase) {
        super.attachBaseContext(new StarkContextWrapper(newBase));
    }
}

public class StarkContextWrapper extends ContextWrapper {
    public StarkContextWrapper(Context base) {
        super(base);
    }

    @Override
    public AssetManager getAssets() {
        Resources resources = Stark.get().getResources();
        if (resources != null) {
            return resources.getAssets();
        }
        return super.getAssets();
    }

    @Override
    public Resources getResources() {
        Resources resources = Stark.get().getResources();
        if (resources != null) {
            return resources;
        }
        return super.getResources();
    }
}
  1. 代码监控

Stark 在 APK 打包时,会记录每个类内容的hash值,用于在修改代码后,生成补丁包时,判断该类是否需要被热修复。

  1. 资源监控

因为 Android 应用在每次打包的时候,资源的 id 值都有可能不一样,在新增资源后,资源 id 的值极有可能被打乱,在第 2 点资源重定向中我们看到,在调用 Activity 的 getResources() 方法时,会先判断是否有需要修复的资源,如果有,则直接使用基于补丁包创建的 Resources 对象,这时如果补丁包资源 id 和打包 APK 时的资源 id 不一致,那么就会导致资源的引用错乱,引发不可预知的问题。

所以 Stark 在 APK 打包时,会备份 build 目录下的 R.txt 文件,用于在生成补丁时,修正补丁包中资源 id 不一致的问题。

  1. 混淆监控

如果项目中开启了混淆,那么和资源 id 类似,每次重新打包的时候,混淆后的类名、方法名以及成员变量名是不确定的。

所以 Stark 在 APK 打包时,发现项目中开启了混淆的话,会备份混淆生成的 mapping.txt 文件,用户生成补丁时,修复混淆后名称不一致的问题。

补丁生成

开发者发现线上 bug 修复代码/资源后,通过 starkGeneratePatch + BuildType 任务打包生成补丁文件,Stark 会完成下面这些内容:

  1. 监控代码修改

在打包过程中,Stark 会计算每个类内容的 hash 值,并跟打包时生成 hash 对比,来判断类是否需要修复,如果发现该类需要修复,会生成一个类似的补丁类:

public class AnyClass$starkoverride implements StarkChange {
    public static void init(AnyClass $this) {
        // 修复后的代码
    }
}
  1. 资源固定

每次 APK 打包生成的资源 id 都有可能是不同的,所以在生成补丁时,Stark 需要修改 aapt 生成的资源(resources.arsc, *.xml)文件中的资源 id 为备份的 R.txt 文件中的资源 id。

具体原理是,根据 Android framework 中定义的 ResourceTypes.h 资源文件格式,解析二进制产物(resources.arsc, *.xml),并修正资源 id。

  1. 资源diff

为了减小补丁包体积,Stark 会对线上 APK 和补丁包中的资源进行内容的 hash 值对比,只有被修改的资源才会打包到补丁包中,同时利用 jbsdiff 对 resources.arsc 做二进制差分。

  1. StarkPatchLoaderImpl

Stark 在生成补丁的时候,会生成一个类似的补丁加载类:

public class StarkPatchLoaderImpl extends AbstractPatchLoaderImpl {
    public StarkPatchLoaderImpl() {
    }

    public String[] getPatchedClasses() {
        return new String[]{"com.ximsfei.stark.app.AnyClass"};
    }
}

getPatchedClasses 会返回所有需要修复的类的全名。

运行时加载

  1. 类修复

Stark 在加载补丁包时,会遍历 StarkPatchLoaderImpl 中 getPatchedClasses 方法返回的所有类名,依次实例化对应的补丁类 AnyClass$starkoverride,并修改对应类中的 $starkChange 字段,达到代码重定向的效果。

public class Stark {
    public void load(Context context) {
        DexClassLoader dexClassLoader = new DexClassLoader(patch.getAbsolutePath(),
                context.getCacheDir().getPath(), context.getCacheDir().getPath(),
                getClass().getClassLoader());
        try {
            Class<?> aClass = Class.forName("com.ximsfei.stark.core.runtime.StarkPatchLoaderImpl",
                    true, dexClassLoader);
            PatchLoader patchLoader = (PatchLoader) aClass.newInstance();
            mPatchLoaded = patchLoader.load();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

public class StarkPatchLoaderImpl extends AbstractPatchLoaderImpl {
    
}

public abstract class AbstractPatchLoaderImpl extends PatchLoader {
    
    public abstract String[] getPatchedClasses();

    @Override
    public boolean load() {
        for (String className : getPatchedClasses()) {
            try {
                ClassLoader cl = getClass().getClassLoader();
                Class<?> aClass = cl.loadClass(className + "$starkoverride");
                Object o = aClass.newInstance();

                Class<?> originalClass = cl.loadClass(className);
                Field changeField = originalClass.getDeclaredField("$starkChange");
                // force the field accessibility as the class might not be "visible"
                // from this package.
                changeField.setAccessible(true);

                Object previous =
                        originalClass.isInterface()
                                ? patchInterface(changeField, o)
                                : patchClass(changeField, o);

                // If there was a previous change set, mark it as obsolete:
                if (previous != null) {
                    Field isObsolete = previous.getClass().getDeclaredField("$starkObsolete");
                    if (isObsolete != null) {
                        isObsolete.set(null, true);
                    }
                }
            } catch (Exception e) {
                return false;
            }
        }
        return true;
    }
}
  1. 资源合并

Stark 在做资源修复的时候是进行全量的替换 Resources 对象,业务层在获取到补丁文件后,调用 applyPatchAsync 方法,Stark 会将补丁包和已安装在手机上的 APK 进行合并,补丁包中有的,则用补丁包的,没有的,则使用原 APK 中的。

public class Stark {
    private static final String ARSC_FILE = "resources.arsc";
    private static final String ARSC_JDIFF = "resources.arsc.jdiff";
    private static final String ARSC_TMP = "resources.arsc.tmp";
    private static final String STARK_PROPERTIES = "stark.properties";
    private ExecutorService mSingleExecutor = Executors.newSingleThreadExecutor();

    /**
     * @param context
     * @param patchPath Patch's path in the file system.
     * @return
     */
    public boolean applyPatch(Context context, String patchPath) {
        try {
            ZipFile patchApk = new ZipFile(patchFile);
            ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(mergedFile));
            ZipFile installedApk = new ZipFile(installedFile);
            Enumeration<? extends ZipEntry> entries = installedApk.entries();
            while (entries.hasMoreElements()) {
                ZipEntry entry = entries.nextElement();
                String name = entry.getName();
                ZipEntry patchEntry = patchApk.getEntry(name);
                if (patchEntry != null) {
                    ZipUtils.writeEntry(patchApk, zos, patchEntry);
                } else if (name.equals(ARSC_FILE)) {
                    ZipEntry jdiffEntry = patchApk.getEntry(ARSC_JDIFF);
                    if (jdiffEntry != null) {
                        File arsc = new File(patchFile.getParent(), ARSC_FILE);
                        FileUtils.copyFile(installedApk.getInputStream(entry), arsc);
                        File jdiff = new File(patchFile.getParent(), ARSC_JDIFF);
                        FileUtils.copyFile(patchApk.getInputStream(jdiffEntry), jdiff);
                        File arscTmp = new File(patchFile.getParent(), ARSC_TMP);
                        boolean merged = false;
                        try {
                            FileUI.patch(arsc, arscTmp, jdiff);
                            ZipEntry ze2 = new ZipEntry(entry.getName());
                            ze2.setTime(entry.getTime());
                            ze2.setComment(entry.getComment());
                            ze2.setExtra(entry.getExtra());
                            zos.putNextEntry(ze2);
                            FileInputStream is = new FileInputStream(arscTmp);
                            byte[] bytes = new byte[is.available()];
                            is.read(bytes);
                            zos.write(bytes);
                            merged = true;
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                        arsc.delete();
                        jdiff.delete();
                        arscTmp.delete();
                        if (!merged) {
                            ZipUtils.writeEntry(installedApk, zos, entry);
                        }
                    } else {
                        ZipUtils.writeEntry(installedApk, zos, entry);
                    }
                } else if (name.startsWith("assets/")
                        || name.startsWith("res/")
                        || name.equals("AndroidManifest.xml")) {
                    ZipUtils.writeEntry(installedApk, zos, entry);
                }
            }
            ZipEntry entry = patchApk.getEntry(STARK_PROPERTIES);
            ZipUtils.writeEntry(patchApk, zos, entry);
            patchApk.close();
            installedApk.close();
            zos.flush();
            zos.close();
            patchFile.delete();
            finalPatch.delete();
            FileUtils.copyFile(mergedFile, finalPatch);
            mergedFile.delete();
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }

    /**
     * Apply patch asynchronous.
     *
     * @param context
     * @param path      Patch's path in the file system.
     * @param immediate If true. Take effect immediately after the patch is applied.
     */
    public void applyPatchAsync(final Context context, final String path, final boolean immediate) {
        mSingleExecutor.execute(new Runnable() {
            @Override
            public void run() {
                boolean applied = applyPatch(context, path);
                if (applied && immediate) {
                    load(context);
                }
            }
        });
    }
}
  1. 资源修复

利用 PackageManager 的 getResourcesForApplication 方法生成补丁包 Resources 对象,避免调用 AssetManager 中的非 SDK 方法 addAssetPath。

public class Stark {
    private boolean loadResources(Context context) {
        try {
            File patch = getPatchFile(context);
            if (!patch.exists()) {
                return false;
            }
            if (!checkPatchValid(patch)) {
                patch.delete();
                return false;
            }
            ApplicationInfo info = context.getApplicationInfo();
            info.sourceDir = patch.getAbsolutePath();
            info.publicSourceDir = patch.getAbsolutePath();
            mResources = context.getPackageManager().getResourcesForApplication(info);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
}

Stark 优缺点

优点

  1. 无需重启应用,即可修复代码,资源。
  2. 参考Instant Run原理实现,补丁成功率高。
  3. 零私有api调用,适用于2.x~P。
  4. 补丁包中只包含需要修复的资源,下发补丁包的体积小。

缺点

  • 编译时代码注入,适当增加dex体积。

主流热修复框架对比:

StarkTinkerQZoneAndFixRobust
修复代码yesyesyesyesyes
修复资源yesyesyesnono
修复sonoyesnonono
全平台支持yesyesyesyesyes
即时生效yesnonoyesyes
性能损耗较小较小较大较小较小
补丁包大小较小较小较大一般一般
开发透明yesyesyesnono
复杂度较低较低较低复杂复杂
成功率最高较高较高一般最高

注:表格中部分数据来自tinker

以上就是 Stark 实现原理的全部内容,如果想要了解具体实现细节,可以根据上面的内容结合着源码进行研究学习,发现有什么问题,或有什么好的想法,也可以提 issues 或者 pr

Stark 项目地址: https://github.com/ximsfei/Stark,如果觉得 Stark 让你有所收获,欢迎 star。