今天看啥  ›  专栏  ›  ximsfei

零私有api调用,实现Android热修复

ximsfei  · 掘金  ·  · 2018-05-21 01:18

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 项目地址: github.com/ximsfei/Sta…

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体积。

主流热修复框架对比:

Stark Tinker QZone AndFix Robust
修复代码 yes yes yes yes yes
修复资源 yes yes yes no no
修复so no yes no no no
全平台支持 yes yes yes yes yes
即时生效 yes no no yes yes
新能损耗 较小 较小 较大 较小 较小
补丁包大小 较小 较小 较大 一般 一般
开发透明 yes yes yes no no
复杂度 较低 较低 较低 复杂 复杂
成功率 最高 较高 较高 一般 最高

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

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

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


原文地址:访问原文地址
快照地址: 访问文章快照