-
Notifications
You must be signed in to change notification settings - Fork 51
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
这里将会通过 APK 打包、补丁生成和运行时加载三个部分来讲述 Stark 实现原理:
- 代码重定向
Stark 在 APK 打包时,参考 InstantRun 在每个方法前注入了一段类似的代码:
public class AnyClass {
public static volatile StarkChange $starkChange;
public void init() {
if ($starkChange != null) {
$starkChange.access$dispatch("init.()V", new Object[]{this});
} else {
// 原方法内容
}
}
}
- 资源重定向
针对资源的热修复,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();
}
}
- 代码监控
Stark 在 APK 打包时,会记录每个类内容的hash值,用于在修改代码后,生成补丁包时,判断该类是否需要被热修复。
- 资源监控
因为 Android 应用在每次打包的时候,资源的 id 值都有可能不一样,在新增资源后,资源 id 的值极有可能被打乱,在第 2 点资源重定向中我们看到,在调用 Activity 的 getResources() 方法时,会先判断是否有需要修复的资源,如果有,则直接使用基于补丁包创建的 Resources 对象,这时如果补丁包资源 id 和打包 APK 时的资源 id 不一致,那么就会导致资源的引用错乱,引发不可预知的问题。
所以 Stark 在 APK 打包时,会备份 build 目录下的 R.txt 文件,用于在生成补丁时,修正补丁包中资源 id 不一致的问题。
- 混淆监控
如果项目中开启了混淆,那么和资源 id 类似,每次重新打包的时候,混淆后的类名、方法名以及成员变量名是不确定的。
所以 Stark 在 APK 打包时,发现项目中开启了混淆的话,会备份混淆生成的 mapping.txt 文件,用户生成补丁时,修复混淆后名称不一致的问题。
开发者发现线上 bug 修复代码/资源后,通过 starkGeneratePatch + BuildType 任务打包生成补丁文件,Stark 会完成下面这些内容:
- 监控代码修改
在打包过程中,Stark 会计算每个类内容的 hash 值,并跟打包时生成 hash 对比,来判断类是否需要修复,如果发现该类需要修复,会生成一个类似的补丁类:
public class AnyClass$starkoverride implements StarkChange {
public static void init(AnyClass $this) {
// 修复后的代码
}
}
- 资源固定
每次 APK 打包生成的资源 id 都有可能是不同的,所以在生成补丁时,Stark 需要修改 aapt 生成的资源(resources.arsc, *.xml)文件中的资源 id 为备份的 R.txt 文件中的资源 id。
具体原理是,根据 Android framework 中定义的 ResourceTypes.h 资源文件格式,解析二进制产物(resources.arsc, *.xml),并修正资源 id。
- 资源diff
为了减小补丁包体积,Stark 会对线上 APK 和补丁包中的资源进行内容的 hash 值对比,只有被修改的资源才会打包到补丁包中,同时利用 jbsdiff 对 resources.arsc 做二进制差分。
- StarkPatchLoaderImpl
Stark 在生成补丁的时候,会生成一个类似的补丁加载类:
public class StarkPatchLoaderImpl extends AbstractPatchLoaderImpl {
public StarkPatchLoaderImpl() {
}
public String[] getPatchedClasses() {
return new String[]{"com.ximsfei.stark.app.AnyClass"};
}
}
getPatchedClasses
会返回所有需要修复的类的全名。
- 类修复
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;
}
}
- 资源合并
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);
}
}
});
}
}
- 资源修复
利用 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;
}
}
- 无需重启应用,即可修复代码,资源。
- 参考Instant Run原理实现,补丁成功率高。
- 零私有api调用,适用于2.x~P。
- 补丁包中只包含需要修复的资源,下发补丁包的体积小。
- 编译时代码注入,适当增加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 项目地址: https://github.com/ximsfei/Stark,如果觉得 Stark 让你有所收获,欢迎 star。