热修复知识预备,面试字节跳动的Android工程师该怎么准备

84 阅读12分钟

} else {

Class<?> activityThread = Class.forName("android.app.ActivityThread");

Field fMActiveResources = activityThread.getDeclaredField("mActiveResources");

fMActiveResources.setAccessible(true);

Object thread = getActivityThread(context, activityThread);

@SuppressWarnings("unchecked")

HashMap<?, WeakReference> map =

(HashMap<?, WeakReference>) fMActiveResources.get(thread);

references = map.values();

}

// 遍历并得到弱引用集合中的Resources,将Resources的mAssets字段引用替换成新的AssetManager

for (WeakReference wr : references) {

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());

}

}

} ...

注释1:创建一个新的 AssetManager

注释2:、注释3:通过反射调用 addAssetPath()加载外部(SD卡)的资源

注释4:遍历Activity列表,得到每个Activity的Resources

注释5:通过反射得到Resources的 AssetManager类型的 mAssets字段

注释6:改写 mAssets字段的引用为新的 AssetManager

注释7:采用同样的方式,将 Resources.Theme的 mAssets字段的引用替换为新创建的 AssetManager

紧接着根据SDK版本的不同,用不同方式得到 Resources的弱引用集合,再遍历这个弱引用集合,将弱引用集合中的 Resources的 mAssets字段引用替换成新创建的 AssetManager。

可以看出Instant Run中的资源热修复可以简单的总结为两点

  1. 创建新的AssetManager,通过反射调用 addAssetPath()加载外部的资源,这样新创建的 AssetManager就含有了外部的资源

  2. 将AssetManager类型的mAssets字段引用全部替换为新创建的 AssetManager

4.代码修复

=======================================================================

代码修复主要有3个方案,分别是底层替换方案、类加载方案和Instant Run方案。

4.1 类加载方案


类加载方案基于 Dex分包方案。为了理解Dex分包,我们将从65536限制LinearAlloc限制说起。

1.65536限制

随着应用功能越来越复杂,代码量不断增大,引入的库也越来越多,可能会在编译时提示如下异常:

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

这说明应用中引用的方法数超过了最大数65536个,产生这一问题的原因就是系统的65536限制,65536限制的主要原因是 DVM Bytecode的限制,DVM指令集的方法调用指令invoke-kind 索引为16bits,所以最多能引用65535个方法

2.LinearAlloc限制

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

为了解决上面两种限制,从而产生了 Dex分包方案:

打包时将代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。

当应用启动时先加载主Dex,等到应用启动后再动态地加载次Dex,从而缓解了主Dex的65536限制和 LinearAlloc限制。

Dex分包方案主要有两种,分别是:

  • Google官方方案

  • Dex自动拆包和动态加载方案

这里就不再讲解分包方案,接着来学习类加载方案,在之前学习了ClassLoader加载过程,其中一个环节就是 DexPathList.findClass():

// DexPathList.java

public Class<?> findClass(String name, List suppressed) {

for (Element element : dexElements) { //1

Class<?> clazz = element.findClass(name, definingContext, suppressed); // 2

if (clazz != null) {

return clazz;

}

}

if (dexElementsSuppressedExceptions != null) {

suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));

}

return null;

}

Element内部封装了DexFile,DexFile用于加载dex文件,因此每个dex文件对应一个Element。

多个Element组成了有序的Element数组 dexElements。当要查找类时,会在注释1处遍历dexElements,注释2处调用 Element.findClass(),最终会调用Native方法查找,如果找到了就返回该类,如果找不到就接着在下一个Element中进行查找。

根据上面的流程,我们将有Bug的类 Key.class进行修改,再将 Key.class打包成含dex的补丁包Patch.jar,放在Element数组 dexElements的第一个元素,这样首先会找到 Patch.dex中的Key.class去替换之前存在Bug的 Key.class,排在数组后面的 dex文件中存在Bug的Key.class根据ClassLoader双亲委托模式就不会被加载,这就是类加载的方案,如下图所示:

在这里插入图片描述

类加载方案需要重启App后让ClassLoader重新加载新的类,为什么要重启呢?这是因为类是无法被卸载的。要向重新加载新的类就需要重启App,因此采用类加载方案的热修复框架时不能即时生效的

很多热修复框架都采用了类加载的方案,但他们在细节上面都有不同。

4.2 底层替换方案


与类加载方案不同的是,底层替换方案不会再次加载新类,而是直接在Native层修改原有类,由于在原有类进行修改限制会比较多,且不能增减原有的方法和字段,如果我们增加了方法数,那么方法索引数也会增加,这样访问方法时会无法通过索引找到正确的方法,同样的字段也是类似的情况。

底层替换方案和反射的原理有些关联,就拿方法替换来说,方法反射我们可以调用 java.lang.Class.getDeclaredMethod,假设我们要反射Key的show方法,会调用如下所示的代码:

Key.class.getDeclaredMethod("show").invoke(Ket.class.newInstance());

Android8.0的invoke()方法:

// Method.java

@FastNative

public native Object invoke(Object obj, Object... args)

throws IllegalAccessException, IllegalArgumentException, InvocationTargetException;

它是一个native方法,对应JNI层的代码为:

// java_lang_reflect_Method.cc

static jobject Method_invoke(JNIEnv* env, jobject javaMethod, jobject javaReceiver,

jobject javaArgs) {

ScopedFastNativeObjectAccess soa(env);

return InvokeMethod(soa, javaMethod, javaReceiver, javaArgs);

}

调用了 InvokeMethod()

// relection.cc

jobject InvokeMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject javaMethod,

jobject javaReceiver, jobject javaArgs, size_t num_frames) {

...

ObjPtrmirror::Executable executable = soa.Decodemirror::Executable(javaMethod);

const bool accessible = executable->IsAccessible();

ArtMethod* m = executable->GetArtMethod(); // 1

...

}

注释1:获取传入了 javaMethod在ART虚拟机中对应一个 ArtMethod指针,ArtMethod结构体中包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等,ArtMethod结构如下所示:

// art_method.h

class ArtMethod FINAL {

...

protected:

GcRootmirror::Class declaring_class_;

std::atomicstd::uint32_t access_flags_;

uint32_t dex_code_item_offset_;

uint32_t dex_method_index_;

uint16_t method_index_;

uint16_t hotness_count_;

struct PtrSizedFields {

ArtMethod** dex_cache_resolved_methods_; //1

void* data_;

void* entry_point_from_quick_compiled_code_; // 2

} ptr_sized_fields_;

}

在ArtMethod中结构中比较重要的字段是上述代码中注释1和注释2,他们表示的是方法的执行入口,当我们调用某一个方法时,就会取得这个方法(就比如前面的“show”)的执行入口。通过执行入口就可以跳过去执行show方法。

替换ArtMethod结构体中的字段或者替换掉整个ArtMethod结构体,这就是底层替换方案

AndFix采用的是替换ArtMethod结构体中的字段,这样会有兼容问题,因为厂商可能会修改ArtMethod结构体,导致方法替换失败。Sophix采用的是替换整个ArtMethod结构体,这样就不会存在兼容问题。底层替换方案直接替换了方法,可以理解生效而不需重启。采用底层替换方案主要使 阿里系为主,包括AndFix、Dexposed、阿里百川、Sophix。

4.3 Instant Run方案


除了资源的修复,代码修复同样可以借鉴Instant Run的原理,可以说Instant Run的出现推动了修复框架的发展。

Instant Run在第一次构建APK时,使用ASM在每一个方法中注入了类似如下的代码:

IncrementalChange localIncrementalChange = $change; //1

if (localIncrementalChange != null) { //2

localIncrementalChange.access$dispatch(

"onCreate.(Landroid/os/Bundle;)V", new Object[]{this, paramBundle});

return;

}

注释1处是一个成员变量 localIncrementalChange ,它的值为 $change$change实现了 IncrementalChange 这个抽象接口。

当我们点击 InstantRun时,如果方法没有变化则 $change为null,如果方法有变化,就生成替换类,这里我们假设MainActivity的onCreate方法做了修改,就会生成替换类 MainActivity$override,这个类实现了IncrementalChange 接口,同时也会生成一个 AppPatchesLoaderImpl类,这个类的 getPatchedClasses()会返回被修改的类的列表,根据列表会将MainActivity的 $chang 设置为 MainActivity$override,因此满足了注释2的条件,就会执行 它的access$dispatch()了

这个方法会根据参数: "onCreate.(Landroid/os/Bundle;)V"执行 MainActivity.override的onCreate(),从而实现了 onCreate方法的修改。借鉴Instant Run的原理的热修复框架有 Robust和Aceso

上面有个概念,什么是ASM?

ASM是一个Java字节码操控框架,它能够动态生成类或增强现有类的功能,ASM可以直接产生class文件,也可以在类被加载到虚拟机之前动态改变类的行为。

5.动态链接库修复

==========================================================================

Android平台的动态链接库主要是指 so库,热修复框架的 so的修复主要是更新 so,换句话所就是重新加载so库,因此so库的修复的基础原理就是加载so库。

5.1 System的load和loadLibrary方法


加载so库主要用到了 System类的load和 loadLibrary(),如下所示

public final class System {

...

@CallerSensitive

public static void load(String filename) {

Runtime.getRuntime().load0(VMStack.getStackClass1(), filename); // 1

}

@CallerSensitive

public static void loadLibrary(String libname) {

Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname); // 2

}

}

System的load()传入的参数是so库在磁盘的完整路径,用于加载路径的so。System的 loadLibrary()传入的参数时so的名称,用于加载App安装后自动从apk包中复制到 /data/data/packagename/lib 下的so库。

目前so库的修复都是基于这两个方法,这里分别对这两个方法进行讲解。

1. System的load()

注释1处的 Runtime.getRuntime()会得到当前Java应用程序需的运行环境 Runtime,Runtime.load()如下:

// Runtime.java

synchronized void load0(Class<?> fromClass, String filename) {

if (!(new File(filename).isAbsolute())) {

throw new UnsatisfiedLinkError(

"Expecting an absolute path of the library: " + filename);

}

if (filename == null) {

throw new NullPointerException("filename == null");

}

// 1

String error = doLoad(filename, fromClass.getClassLoader());

if (error != null) {

throw new UnsatisfiedLinkError(error);

}

}

注释1之前都是对路径名进行查错,然后调用 doLoad(),并将该类的类加载器作为参数传了进去!

// Runtime.java

private String doLoad(String name, ClassLoader loader) {

String librarySearchPath = null;

if (loader != null && loader instanceof BaseDexClassLoader) {

BaseDexClassLoader dexClassLoader = (BaseDexClassLoader) loader;

librarySearchPath = dexClassLoader.getLdLibraryPath();

}

synchronized (this) {

return nativeLoad(name, loader, librarySearchPath);

}

}

doLoad() 会调用native方法nativeLoad(),关于这个方法后面会讲到。

2. System的loadLibrary()

接着来查看System的loadLibrary(),它会调用 Runtime.loadLibrary0()

// Runtime.java

synchronized void loadLibrary0(ClassLoader loader, String libname) {

if (libname.indexOf((int)File.separatorChar) != -1) {

throw new UnsatisfiedLinkError(

"Directory separator should not appear in library name: " + libname);

}

String libraryName = libname;

if (loader != null) {

// 1

String filename = loader.findLibrary(libraryName);

if (filename == null) {

throw new UnsatisfiedLinkError(loader + " couldn't find "" +

System.mapLibraryName(libraryName) + """);

}

// 2

String error = doLoad(filename, loader);

if (error != null) {

throw new UnsatisfiedLinkError(error);

}

return;

}

String filename = System.mapLibraryName(libraryName);

List candidates = new ArrayList();

String lastError = null;

// 3

for (String directory : getLibPaths()) {

// 4

String candidate = directory + filename;

candidates.add(candidate);

if (IoUtils.canOpenReadOnly(candidate)) {

// 5

String error = doLoad(candidate, loader);

if (error == null) {

return;

}

lastError = error;

}

}

if (lastError != null) {

throw new UnsatisfiedLinkError(lastError);

}

throw new UnsatisfiedLinkError("Library " + libraryName + " not found; tried " + candidates);

}

loadLibrary0() 分成两个部分,一个是传入的 ClassLoader不为null的部分,另一个是ClassLoader为null的部分。

(1)我们先来看看 传入的ClassLoader为null的情况:

注释3:遍历 getLibPaths()这个方法,这个方法会返回 java.library.path选项配置的路径数组。

注释4:拼接一条so库的路径,当然这个路径是暴力拼的,为了验证其是否是正确的,就把它丢到 doLoad()中。直到找到它。

(2)当ClassLoader不为null时的情况:

注释2:同样的调用了 doLoad(),其中第一个参数时通过注释1处的 ClassLoader.findLibrary()获取到的路径。

findLibrary() 在 ClassLoader的实现类 BaseDexClassLoader中实现:

// BaseDexClassLoader.java

@Override

public String findLibrary(String name) {

return pathList.findLibrary(name);

}

调用了 DexPathList.findLibrary()

// DexPathList

public String findLibrary(String libraryName) {

String fileName = System.mapLibraryName(libraryName);

for (NativeLibraryElement element : nativeLibraryPathElements) {

// 1

String path = element.findNativeLibrary(fileName);

if (path != null) {

return path;

}

}

return null;

}

这个方法和 findClass()类似,在 NativeLibraryElement数组的每一个 NativeLibraryElement对应一个so库,在注释1处调用 NativeLibraryElement.findNativeLibrary()就可以返回so库的路径。

上面结合类加载方案,就可以得到so的修复的一种方案,就是将so补丁插入到 NativeLibraryElement数组的前面,让so补丁的路径先被返回,并调用Runtime的doLoad()进行加载,在doLoad中会调用 native方法 nativeLoad()

也就是说 load()loadLibrary()这两个方法殊途同归,最终都会调用native方法 nativeLoad(),那我们就深入到JNI去了解这个方法。

5.2 nativeLoad()分析


先来看看其JNI层中函数

// Runtime.c

JNIEXPORT jstring JNICALL

Runtime_nativeLoad(JNIEnv* env, jclass ignored, jstring javaFilename,

jobject javaLoader, jstring javaLibrarySearchPath)

{

return JVM_NativeLoad(env, javaFilename, javaLoader, javaLibrarySearchPath);

}

在 Runtime_nativeLoad中调用了 JVM_NativeLoad()

// OpenjdkJvm.cc

JNIEXPORT jstring JVM_NativeLoad(JNIEnv* env,

jstring javaFilename,

jobject javaLoader,

jstring javaLibrarySearchPath) {

// 将so的文件名称转换为ScopedUtfChars类型

ScopedUtfChars filename(env, javaFilename);

if (filename.c_str() == NULL) {

return NULL;

}

std::string error_msg;

{

// 获取当前运行时的虚拟机

art::JavaVMExt* vm = art::Runtime::Current()->GetJavaVM();

// 虚拟机加载so库

bool success = vm->LoadNativeLibrary(env,

filename.c_str(),

javaLoader,

javaLibrarySearchPath,

&error_msg);

if (success) {

return nullptr;

}

}

env->ExceptionClear();

return env->NewStringUTF(error_msg.c_str());

}

上面的代码是先获取当前运行时的JVM指针,然后调用JVM的 LoadNativeLibrary()来加载so库,也就是说 :

so库是被JVM加载的,它的加载方法是 LoadNativeLibrary()

LoadNativeLibrary()的方法有点多,这里分成3个part来讲:

part.1 判断是否加载过该so库

bool JavaVMExt::LoadNativeLibrary(JNIEnv* env,

总结

最后为了帮助大家深刻理解Android相关知识点的原理以及面试相关知识,这里放上相关的我搜集整理的24套腾讯、字节跳动、阿里、百度2019-2021面试真题解析,我把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包知识脉络 + 诸多细节

还有 高级架构技术进阶脑图、Android开发面试专题资料 帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。

一线互联网面试专题

379页的Android进阶知识大全

379页的Android进阶知识大全

点击:《Android架构视频+BAT面试专题PDF+学习笔记​》

即可免费获取~

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

2021年虽然路途坎坷,都在说Android要没落,但是,不要慌,做自己的计划,学自己的习,竞争无处不在,每个行业都是如此。相信自己,没有做不到的,只有想不到的。祝大家2021年万事大吉。