原因是这样的,在App启动到一半的时候,所有需要发生变更的分类已经被加载过了,在Android系统中是无法对一个分类进行卸载的。而腾讯系的方案是让Classloader去加载新的类,如果不重启App,原有的类还在虚拟机中,就无法加载新类。因此,只有在下次App重启的时候,在还没有运行到业务逻辑之前抢先加载补丁中的新类,这样在后续访问这个类时,就会解析为新的类。从而达到热修复的目的。
Andfix采用的方法是直接在已经加载的类中native层替换掉原有的方法,是在原有类的基础上进行修改的。来看下 Andfix的具体实现,其核心在于 replaceMethod()
:
// AndFix/src/com/alipay/euler/andfix/AndFix.java
private static native void replaceMethod(Method dest, Method src);
这是一个native方法, 它的参数是在 Java层通过反射机制得到的 Method
, src对应被替换的原有方法,而dest对应的就是新方法,新方法存在于补丁包的新类中,也就是补丁方法。
// AndFix/jni/andfix.cpp
static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,
jobject dest) {
if (isArt) {
art_replaceMethod(env, src, dest);
} else {
dalvik_replaceMethod(env, src, dest);
}
}
这个方法对当前Android版本做了判断,4.4以下版本用的是Dalvik虚拟机,而在 4.4以上则用的是ART虚拟机。
在Art上调用的方法如下:
// AndFix/jni/art/art_method_replace.cpp
extern void attribute ((visibility (“hidden”))) art_replaceMethod(
JNIEnv* env, jobject src, jobject dest) {
if (apilevel > 23) {
replace_7_0(env, src, dest);
} else if (apilevel > 22) {
replace_6_0(env, src, dest);
} else if (apilevel > 21) {
replace_5_1(env, src, dest);
} else if (apilevel > 19) {
replace_5_0(env, src, dest);
}else{
replace_4_4(env, src, dest);
}
}
我们以 Art虚拟机为例,对于不同Android版本的Art虚拟机,底层Java对象的数据结构是不同的,因而会进一步区分出不同的替换函数,这里我们以Android 6.0版本为例子,对应的就是 replace_6_0()
// AndFix/jni/art/art_method_replace_6_0.cpp
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src); // 1
art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest); // 2
…
// 3
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;
smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
LOGD(“replace_6_0: %d , %d”,
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}
注释1、2:通过Method对象得到底层Java函数对应ArtMethod的真实地址。
注释3:把就函数的所有成员变量都替换为新的。(通过ArtMehtod结构体)(注意注意!这里相当于是替换了原结构体中的字段信息)
每一个Java方法在Art虚拟机中都对应着一个 ArtMethod
,ArtMethod记录了这个Java方法的所有信息,包括所属类、访问权限、代码执行地址等。
通过 env->FromReflectedMethod
,可以由 Method对象得到这个方法所对应的 ArtMethod的真正其实地址,然后就可以把它强制转化为ArtMethod指针,从而对其包含的所有成员进行修改。
这样全部替换完之后就完成了热修复功能。以后调用方法时就会直接运行到新方法中实现。
为什么这样替换完就可以实现热修复呢?这需要从虚拟机调用方法的原理说起。
在Android6.0版本中,Art虚拟机中的 Art虚拟机中ArtMethod的结构如下:
// art/runtime/art_method.h
class ArtMethod FINAL {
…
protected:
GcRootmirror::Class declaring_class_;
GcRootmirror::PointerArray dex_cache_resolved_methods_;
GcRoot<mirror::ObjectArraymirror::Class> dex_cache_resolved_types_;
uint32_t access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint32_t method_index_;
struct PACKED(4) PtrSizedFields {
void* entry_point_from_interpreter_; // 1
void* entry_point_from_jni_;
void* entry_point_from_quick_compiled_code_; //2
} ptr_sized_fields_;
…
}
在 ArtMethod结构体中,最重要的就是 注释1和注释2标注的内容,从名字可以看出来,他们就是方法的执行入口。
我们知道,Java代码在Android中会被编译为 Dex Code。
Art虚拟机中可以采用解释模式或者 AOT机器码模式执行 Dex Code
- 解释模式
就是去除Dex Code,逐条解释执行。
如果方法的调用者是以解释模式运行的,在调用这个方法时,就会获取这个方法的 entry_point_from_interpreter_
,然后跳转执行。
- AOT模式
就会预先编译好 Dex Code对应的机器码,然后在运行期直接执行机器码,不需要逐条解释执行Dex Code。如果方法的调用者是以AOT机器码方式执行的,在调用这个方法时,就是跳转到 entry_point_from_quick_compiled_code_
中执行。
那是不是只需要替换这个几个 entry_point_* 入口地址就能够实现方法替换了呢?
并没有那么简单,因为不论是解释模式还是AOT模式,在运行期间还会需要调用ArtMethod中的其他成员字段。
就以AOT模式为例,虽然Dex Code已经被编译成了机器码。但是机器码并非可以脱离虚拟机而单独运行,以下面这段简单的代码为例。
public class MainActivity extends Activity {
protected void onCreate(Bundle saveInstanceState) {
super.onCreate(saveInstanceState);
}
}
编译为 AOT机器码后,是这样的:
7:void com.rikkatheworld.demo.MainActivity.onCreate(android.os.Bundle) (dex_method_idx)
DEX CODE:
0x0000: 6f20 4600 1000 | invoke-super {v0, v1}, void anroid.App.Activity.onCreate(android.os.Bundle)
0x0003: 0e00 | return-void
CODE: (code_offset=0x006fdbac size_offset=0x006fdba8 size=96)
… …
0x006fdbe0: f94003e0 ldr x0, [sp]
;x0 = MainActivity.onCreate 对应的 ArtMethod指针
0x006fdbe4: b9400400 ldr w0, [x0, #4]
;w0 = [x0 + 4] = dex_cache_resolved_methods_ 字段
0x006fdbe8: f9412000 ldr x0, [w0, #576]
;x0 = [x0 + 576]; dex_cache_resolved_methods_ 数组的第72(=576/8)个元素,即对应 Activity.onCreate的 ArtMethod指针
0x006fdbec: f940181e ldr lr, [x0, #48]
;lr = [x0 + 48] = Activity.onCreate 的ArtMethod成员的执行入口点
;即 entry_point_from_quick_compiled_code_
0x006fdbf0: d63f03c0 blr lr
;调用 Activity.onCreate
这里去掉了一些校验之类的无关代码,可以看到,在调用一个方法时,获取了 ArtMethod中的 dex_cache_resolved_methods
文件,这是一个存放 ArtMethod* 的指针数组,通过它就可以访问到这个 Method所在Dex中所有的 Method所对应的 ArtMethod*
Activity.onCreate() 的方法索引是70,由于是64位系统,因此每个指针的大小为8kb,又由于ArtMethod*元素时从这个数组的第0×2 个位置开始存放的,因此偏移量为 (70 + 2) * 8 = 576的位置正是 Activity.onCreate() 方法的 ArtMethod指针。
这只是一个比较简单的例子,而在实际代码中,有许多更为复杂的调用情况,很多情况下还需要调用 dex_code_item_offset_
字段。由此可以看出,AOT机器码的执行过程,还是会有对虚拟机以及ArtMethod成员字段的依赖。
因此,当把一个旧方法的所有成员字段都换为新方法的成员字段后,执行时所有的数据就可以保持和新方法的数据一致。这样在所有执行到旧方法的地方,会获取新方法的执行入口、所属类型、方法索引号以及所属dex信息,然后像调用旧方法一样去执行新方法的逻辑。
然而,目前市场上几乎所有的native替换方案,比如 Andfix和其他安全界的Hook方案,ArtMethod结构体的结构都是固定的,这回带来巨大的兼容性问题。
从刚才的分析可以看到,虽然Andfix是把底层结构强转为 art::mirror::ArtMethod,但这里的 art::mirror::ArtMethod
并非等同于App运行时所在设备虚拟机底层的 art::mirror::ArtMethod
,而是 Andfix自己构造的 art::mirror::ArtMethod
。
由于 Android的源码是开源的,所以各个手机厂商都可以对代码进行改造,而Andfix里ArtMethod的结构是根据公开的Android源码中的结构编写的。如果某个厂商对这个 ArtMethod结构体进行了更改,就和原有的开源代码里的结构不一致,那么在这个修改过ArtMethod结构体的设备上,替换机制就会出现问题。
举个例子,在Andfix替换 declaring_class_
的地方:
//这里是设备本身的ArtMethod和AndFix的ArtMethod一样的情况下
smeth->declaring_class_ = demth->declaring_class;
由于 declaring_class_
是 Andfix里 ArtMethod的第一个成员,因此它和以下这行代码等价:
(uint32_t) (smth + 0) = (uint32_t) (dmeth + 0)
如果某个手机厂商在 ArtMethod结构体的 declaring_class_
前面添加了一个字段 additional_
,那么 additional_
就成为了 ArtMethod的第一个成员,所以 smeth + 0 这个位置在这台设备上实际就变成了 additional_
,所以这行代码的真正含义就变成了:
//这里是设备本身的ArtMethod和AndFix的ArtMethod不一样的情况下
smeth->additional_ = dmeth->additional_;
这样就和原有的替换逻辑不一致了。
这也正是Andfix不支持所有机型的原因,很大的可能,是因为这些手机机型修改了底层的虚拟机结构
============================================================================
知道了 native替换方式兼容性问题的原因,我们是否有办法寻求一种新的方式,不依赖ROM底层方法结构的实现而达到替换效果呢?
我们发现,这样的native层面替换思路,其实就是替换 ArtMethod的所有成员,那么,并不需要构造出ArtMethod具体的各个成员字段,只要把 ArtMethod作为整体进行替换,这样不就可以了吗?
因此 Andfix这一系列烦琐的替换:
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
…
可以缩写成:
memcpy(smeth, dmeth, sizeof(ArtMethod));
这正是 Sophix为了解决Andfix的问题,而研发出的新的方案。
刚才提到过,不同的手机厂商都可以对 ArtMethod
进行替换,只要像这样吧 ArtMethod
整个结构体完整替换,就能够把所有旧方法成员自动对应的换成新方法的成员。
但这其中最关键的地方在于,sizeof(ArtMethod)
的计算结果,如果计算结果有偏差,导致部分成员没有被替换,或者替换区域超出了边界,都会导致严重的问题。
对于ROM开发者而言,是在 Art源代码中开发,所以一个简单的 sizeof(ArtMethod)就行了,因为这是在编译器就可以决定的。
但对于上层开发者,App会被下发给各式各样的Android设备,所以需要在运行时动态地获取App运行设备中的底层 ArtMethod大小的,就没有那么简单了。
想要忽略ArtMethod具体结构成员直接获取其size的精确值,还是需要从虚拟机的源码入手,从底层的数据结构以及排列特点探寻答案。
在 Art中,初始化一个类的时候会给这个类的所有方法分配内存空间,我们可以看到这个分配内存空间的地方(Android 8.0代码):
// art/runtime/class_linker.cc
void ClassLinker::LoadClassMembers(Thread* self,
const DexFile& dex_file,
const uint8_t* class_data,
Handlemirror::Class klass) {
{
…
klass->SetMethodsPtr(
AllocArtMethodArray(self, allocator, it.NumDirectMethods() + it.NumVirtualMethods()),
it.NumDirectMethods(),
it.NumVirtualMethods());
…
}
类的方法中有 direct方法和 virtual方法。
direct方法包含static方法和所有不可继承的对象方法。而 virtual方法包含了所有可以继承的对象方法。
AllocArtMethodArray()
函数用来分配他们的方法所在区域,第三个参数传的是所有的direct方法和virtual的总数:
// class-linker.cc
LengthPrefixedArray* ClassLinker::AllocArtMethodArray(Thread* self,
LinearAlloc* allocator,
size_t length) {
if (length == 0) {
return nullptr;
}
const size_t method_alignment = ArtMethod::Alignment(image_pointer_size_);
const size_t method_size = ArtMethod::Size(image_pointer_size_);
const size_t storage_size =
LengthPrefixedArray::ComputeSize(length, method_size, method_alignment);
void* array_storage = allocator->Alloc(self, storage_size);
auto* ret = new (array_storage) LengthPrefixedArray(length);
CHECK(ret != nullptr);
// 1
for (size_t i = 0; i < length; ++i) {
new(reinterpret_cast<void*>(&ret->At(i, method_size, method_alignment))) ArtMethod;
}
return ret;
}
注释1中,建立所有方法总数和的一个循环。每次循环使用 ret->(i, method_size, method_alignment)
创建出一个ArtMethod。
而这个方法的作用就是分配空间,因为i是连续的,所以分配出来的空间也是连续的。
这是只是分配出内存空间,还没有对ArtMethod的各个成员赋值,不过这并不影响观察ArtMethod的空间结构,Artmethod空间结构如下图所示:
这里给了我们启示,ArtMethod是紧密排列的,所以一个ArtMethod的大小,不就是相邻两个ArtMethod的起始地址的差值吗?
正是如此,我们就从这个排列特点入手,自己构造一个类,以一种巧妙的方式获取到这个差值:
public class NativeStructsModel {
final public static void f1() {}
final public static void f2() {}
}
由于 f1和f2都是static方法,所以都属于 direct ArtMethod Array。由于 NativeStructsModel类中只存这两个方法,因此它们肯定是相邻的。
那么就可以在JNI层取得它们的地址差值:
size_t firMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, “f1”, “()V”); // 计算出 f1()的地址
size_t secMid = (size_t) env->GetStaticMethodID(nativeStructsModelClazz, “f2”, “()V”); // 计算出 f2()的地址
size_t methSize = secMid - firMid; //因为f2和f1是相邻的,所以 两者相减就是一个 ArtMethod的大小
memcpy(smeth, dmeth, methSize); //将size代入,完成替换
**值得一提的是,由于忽略了底层ArtMethod的结构差异,对于所有Android版本都不需要区分,而统一以 memcpy()
实现即可,代码量大大减少。
即使以后的Android版本不断修改ArtMethod的成员,只要保证ArtMethod数组仍是以线性结构排列,就无须再做适配。**
(1)方法调用时的权限检查
看到这里,你可能会产生疑惑:我们只是替换了ArtMethod的内容,但替换方案的所属类,和原有方法的所属类,是不同的类型,被替换的方法有权限访问这个类的其他private方法吗?
以这段简单的代码为例子:
public class Demo {
Demo() {
func();
}
private void func() {
}
}
假如我们想要替换func()
方法,会不会因为它是private的关系,而在构造函数中访问不了。
来看看 Demo()
这个构造函数的Dex Code和Native Code,看看它是怎么调用 func()
d的
void com.rikkatheworld.demo.Demo.() (dex_method_idx=20628)
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
学习路线+知识梳理
花了很长时间,就为了整理这张详细的知识路线脑图。当然由于时间有限、能力也都有限,毕竟嵌入式全体系实在太庞大了,包括我那做嵌入式的同学,也不可能什么都懂,有些东西可能没覆盖到,不足之处,还希望小伙伴们一起交流补充,一起完善进步。
这次就分享到这里吧,下篇见。
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
[外链图片转存中…(img-mQtozQeq-1713683824676)]
[外链图片转存中…(img-o8FjgCjL-1713683824678)]
[外链图片转存中…(img-zeM2Xl9M-1713683824679)]
[外链图片转存中…(img-URAmOgZO-1713683824680)]
[外链图片转存中…(img-iyVQT8LT-1713683824681)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
学习路线+知识梳理
花了很长时间,就为了整理这张详细的知识路线脑图。当然由于时间有限、能力也都有限,毕竟嵌入式全体系实在太庞大了,包括我那做嵌入式的同学,也不可能什么都懂,有些东西可能没覆盖到,不足之处,还希望小伙伴们一起交流补充,一起完善进步。
这次就分享到这里吧,下篇见。
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!