0%

换肤的目的:

  • 保持新鲜感

  • 提高付费率

  • 配合节日营销

  • 写死的主题切换很 Low

  • APK 瘦身

无闪烁,无需重启,架构独立、无继承的 换肤。换肤就是换啥?就是换资源,其实就是 res 下面的文件,图片、字体之类的

在 xml 中,为什么 SDK 自带的组件无需带上包名呢?如果看源码就知道了,在解析 xml 的时候,其实是会区分 xml 里面控件名称是否带有包名的!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//LayoutInflater
View createViewFromTag(xx,xx,xx,xx,xx) {
//这句很关键
View view = tryCreateView(parent, name, context, attrs);

if (view == null) {
//省略无关代码
try {
if (-1 == name.indexOf('.')) {
//如果名称中没有包含点,就是SDK 自带的,比如 TextView
view = onCreateView(context, parent, name, attrs);
} else {
//SDK 之外的组件
view = createView(context, name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
}

然后,如果你是 SDK 自带的组件,会没有包名,但是在createView 的时候,会给你补上包名的,因为最后是要通过反射去创建 View 对象,需要全路径。还补充一点,反射创建 View 的时候,调用的是有 2 个参数的那个构造函数:

1
2
3
4
5
6
7
8
//LayoutInflater 中的属性可以看出
static final Class<?>[] mConstructorSignature = new Class[] {
Context.class, AttributeSet.class};


public final View createView(xxx,xxx,xx,xx) {
constructor = clazz.getConstructor(mConstructorSignature);
}

在 createView 中创建出来的 constructor 会被放在 map 里面缓存起来,这样不用每次都去寻找,下次直接反射就行,提升性能。

在前面我们提到有一行代码很关键, tryCreateView 这个方法,我们看看它的源码(省略一些无关代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public final View tryCreateView(xx,xx,xx,xx) {
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}

if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}

return view;
}

可以看出来,如果我们有 mFactory2 的话,就会使用 mFactory2 来创建 View ,这为我们提供切入点去换肤

如果我们的 MainActivity 继承了 AppCompatActivity ,那么我们在 layout_main.xml 布局文件中写了个 TextView 然后通过 TextView tv = findViewById 去获取的时候,可以打印一下这个 tv ,发现它不是个 TextView,而是 AppcompatTextView! ,但是如果我们通过 TextView tv = new TextView(this) 然后看这个 tv ,就是 TextView 对象 !

那这肯定不对,明明在 xml 里面写了TextView ,为什么会变化呢?其实问题就在这个 mFactory2 , AppcompatActivity 中就自己写了这个 mFactory2。

注意,mFactory2 只能设置一次(应该是在一个 Activity 中只能设置一次),设置多次会报错。由于我们说 AppcompatActivity 自己会设置 mFactory2 ,所以我们必须在 MainActivity 的 super.onCreate() 之前就创建(在其之后创建会报错,因为只会允许创建一个)这样 AppcompatActivity 自己就不会创建了。如果要在之后创建,就得反射改它的 mFactory2 的值

换肤的思路

  1. 知道 xml 解析流程

  2. 如何拦截系统的创建 View 对象流程?——使用setFactory2 ,并且使用 ActivityLifeCycle 去做 aop 的思路实现

  3. 拦截后怎么做?——重写系统的创建过程的代码

  4. 搜集关注的 View ,以及找到 View 中的关注的属性——每个 Activity 的View 及属性都要搜集

  5. 创建皮肤包——只包含 res 的 apk 文件

  6. 使用皮肤包,怎么使用?(其实就是插件化,只不过只用其res,其实又到了构建 AssetManager 这里来了,因为不论是 asset 还是 res 里面的资源,最终都是 AssetManager 来操作,所以只需要搞 AssetManager 即可)

  7. 替换,因为打包成apk 之后,都变成 id 了,比如 0x7f070092,那么我们app 和皮肤apk 里面同一个资源的 id 肯定是不同的,相同的只是资源的名称(R.string.huhu) ,所以,在替换的时候,我们先拿到app 中这个资源的id ,再通过 id 获取其名字,再从 皮肤包中通过名字找到这个 皮肤资源的 id ,这样就能替换了。

1
2
3
4
5
6
//第七步的代码实现
// app的resId
String resName=mAppResources.getResourceEntryName(resId); // 通过app的resId 找到 resName
String resType=mAppResources.getResourceTypeName(resId);// 通过app的resId 找到 类型,layout、drawable
// 获取对应皮肤包的资源Id
int skinId=mSkinResources.getIdentifier(resName,resType,mSkinPkgName);

皮肤包是啥呢?其实就是一个 apk ,它里面的java 是空的,只有 res 目录。

由于我们基本上都是 getContext.getResource() 这样操作,所以可以断定 Resource 就是在 Context 里面

在 ActivityThread 的 performLaunchActivity 的时候,就会 查找/创建 (如果查找到了就不用创建了)Resource 对象,创建 Resource 的时候就会获取 AssetManager 对象。

Hook 思路: 不能改变原有的资源加载,所以需要单独创建一个 AssetManager 对象,专门用于加载皮肤包的资源,有同学说可以皮肤包和 App 用同一个 AssetManager 不?确实是可以,但是会有资源冲突问题(比如在App 中 R.string.haha 的生成的id 为 0x7f070092,但是在资源包中 0x7f070092是 R.string.diudiu 的值了,这就驴头不对马嘴了,这是造成冲突的原因),所以还是建议使用另外一个 AssetManager 。

我们都是根据名字来替换id达到换肤的功能,这个名字就很重要,所以如果你在 xml 里面写了 color=’#ff0000’ 这种没有名字的,那么就不能实现换肤。

首先,setContentView 的主要目的有 2 个:

  • 创建 DecorView: installDecor()

  • 获取到 Content : mLayoutInflater.inflate(xx, xx)

继承 Activity 的情况

如果继承 Activity ,setContentView 的时候源码点进去发现是 getWindow.setContentView() ,我们知道这个getWindow 就是 PhoneWindow 。在 ActivityThread.performActivity 启动 Activity 过程中,会调用 activity.attach ,在 attach 中 会创建 PhoneWindow 对象。之后才会有Activity 的 onCreate 回调。

在 PhoneWindow 的 installDecor 中会调用 generateDecor,其实最终就是直接 new 一个 DecorView:

1
2
3
4
protected DecorView generateDecor(int featureId) {
//省略无关代码
return new DecorView(context, featureId, this, getAttributes());
}

DecorView 实际上就是一个 FrameLayout 。

在 PhoneWindow 的 installDecor 还会生成 contentParent:

1
mContentParent = generateLayout(mDecor);

在里面会根据各种标记位 Flag 来选择不同的 layout xml布局文件作为 contentParent 的布局文件

面试题: 我在 setContentView 之后,再去设置 requestWindowFeature () 方法,为什么会报错?

直接原因:在 setContentView 方法执行完成之后,会将一个标志位置为 true, 等 requestWindowFeature方法调用的时候会判断这个标志位为true则报错。间接原因:为什么这么设计呢?这是因为在 generateLayout 的代码中, requestWindowFeature 设置的值也会作为 Flag 来选择 不同的layoutResource ,比如是否有 title (Window.FEATURE_NO_TITLE)等,所以需要这样 。

状态栏不在 setContentView 中,它是属于系统的。

继承 AppcompatActivity 情况

继承 Activity 的时候,可以使用 requestWindowFeature 来更改一些 Window 属性;但是如果继承 AppcompatActivity ,直接使用 requestWindowFeature 是没作用的,需要使用 supportRequestWindowFeature 方法。

将原始的 windowContentView 的 id 置为 NO_ID ,将自己的 subDecorView 的id 设置成原来的 windowContentView 的 id ,实现了个替换。后来就通过 PhoneWindow 的 setContentView 将 subDecorView 设置进去了。

所以,这个步骤相当于将 DecorView 中的内容完全替换了。

View 是如何根据xml创建的

上述会执行到:

1
LayoutInflater.from(mContext).inflate(resId, contentParent)

那么如何根据 resId 布局文件创建出布局中的哪些 View 呢?

再 createView 中,通过 clazz = Class.forName() 获得这些 View 的 class 对象,之后,通过 newInstance 的形式将 View 创建出来。

需要注意一点,如果 LayoutInflater.from(mContext).inflate 的时候,layout 是一个 merge 的,一定要 有parent 并且 attach 为 true:

1

ViewStub 继承的是 View ,首先它是 Gone 的,在显示的时候将自己的 id 设置为 NO_ID

## 面试题

  1. 为什么requestWindowFeature()要在setContentView()之前调用
    requestWindowFeature 实际调用的是 PhoneWindow.requestFeature,
    在这个方法里面会判断如果变量 mContentParentExplicitlySet 为true则报错,
    而这个变量会在 PhoneWindow.setContentView 调用的时候设置为true。
    1. 为什么这么设计呢?
      DecorView的xml布局是通过设置的窗口特征进行选择的。
    2. 为什么 requestWindowFeature(Window.FEATURE_NO_TITLE);设置无效?
      需要用 supportRequestWindowFeature(Window.FEATURE_NO_TITLE);,因为继承的是AppCompatActivity,这个类里面会覆盖设置。

2.LayoutInflate几个参数的作用?

LayoutInflater inflater = LayoutInflater.from(this);
// 方式一:布局添加成功,里面执行了 ll.addView(view)View view = inflater.inflate(R.layout.inflate_layout, ll, true);

// 方式二:报错,一个View只能有一个父亲(The specified child already has a parent.)
View view = inflater.inflate(R.layout.inflate_layout, ll, true);
ll.addView(view);

// 方式三:布局成功,第三个参数为false
// 目的:想要 inflate_layout 的根节点的属性(宽高)有效,又不想让其处于某一个容器中
View view = inflater.inflate(R.layout.inflate_layout, ll, false);
ll.addView(view);

// 方式四:root = null,这个时候不管第三个参数是什么,显示效果一样
// inflate_layout 的根节点的属性(宽高)设置无效,只是包裹子View,
// 但是子View(Button)有效,因为Button是处于容器下的
View view = inflater.inflate(R.layout.inflate_layout, null, false);
ll.addView(view);

3.描述下merge、include、ViewStub标签的特点
include:

  1. 不能作为根元素,需要放在 ViewGroup中
  2. findViewById查找不到目标控件,这个问题出现的前提是在使用include时设置了id,而在findViewById时却用了被include进来的布局的根元素id。
  3. 为什么会报空指针呢?
    如果使用include标签时设置了id,这个id就会覆盖 layout根view中设置的id,从而找不到这个id
    代码:LayoutInflate.parseInclude
    –》final int id = a.getResourceId(R.styleable.Include_id, View.NO_ID);
    –》if (id != View.NO_ID) {
    view.setId(id);
    }

merge:

  1. merge标签必须使用在根布局
  2. 因为merge标签并不是View,所以在通过LayoutInflate.inflate()方法渲染的时候,第二个参数必须指定一个父容器,
    且第三个参数必须为true,也就是必须为merge下的视图指定一个父亲节点.
  3. 由于merge不是View所以对merge标签设置的所有属性都是无效的.

ViewStub:就是一个宽高都为0的一个View,它默认是不可见的

  1. 类似include,但是一个不可见的View类,用于在运行时按需懒加载资源,只有在代码中调用了viewStub.inflate()
    或者viewStub.setVisible(View.visible)方法时才内容才变得可见。
  2. 这里需要注意的一点是,当ViewStub被inflate到parent时,ViewStub就被remove掉了,即当前view hierarchy中不再存在ViewStub,
    而是使用对应的layout视图代替。

一、Native 方法的注册

1.1 静态注册

可以参照系统中的 MediaRecorder.java 写一个简单的静态注册的范例:

1
2
3
4
5
6
7
8
public class MediaRecorder {
static {
System.loadLibrary("media_jni");
native_init();
}

private static native final void native_init();
}

在 static 代码块中我们调用 System.loadLibrary(“media_jni”); 来加载 so 的。如果让我们手动来编译 MediaRecorder.java 生成 JNI 方法,那么可以这样:

javac com/example/MediaRecorder.java

javah com .example .MediaRecorder

其中第一个命令会将 java 编译成 .class 文件,第二个 javah 命令会在当前目录(media/src/main/java)中生成 com_example _MediaRecorder.h 这种头文件 !这个头文件会包含如下代码:

1
2
JNIEXPORT void JNICALL Java_com_example_MediaRecorder_native_1init 
(JNIEnv *, jclass) ;//1

方法 Java_com_example_MediaRecorder_native_1init 以 Java_开头说明是在 Java 平台中调用 JNI 方法的,后面的 com_example_MediaRecorder_native_1init 指的是 包名 + 类名 + 方法名 的格式。其中参数JNIEnv是 Native 世界中 Java 环境的,通过 JNIEnv 指针就能在Native 访问 Java 世界的代码,不过,它只在创建它的线程中有效,不能跨线程传递。里面的 jclass 对应 Java 中的 java.lang.Class 实例,很多 java 中的类型对应到 Native 就是在前面加一个 j ,尤其是基本类型(基本类型除了 void ,其他的都是)。

1.1.1 静态注册后的调用

当我们在 Java 中调用 native_init 这个 native 方法时,就会从 JNI 中寻找 Java_com_example_MediaRecorder_native_1init 函数,如果没有就会报错,如果找到,就会建立 <native_init ,Java_com_example_MediaRecorder_native_1init> 映射,其实就是保存 JNI 函数指针,这样下次再调用就直接使用这个函数指针,避免每次重复查找。

静态注册就是根据方法名,将 Java 方法和 JNI 函数建立关联!这种静态注册的方式有一些缺点:

  • JNI 层的函数名称过长

  • 声明 Native 方法的类需要用 javah 生成头文件

  • 初次调用 Native 方法时需要建立关联,影响效率

如果 Java 的 Native 方法知道它在 JNI 中对应的函数指针,就能避免上述缺点,这就是动态注册

1.2 动态注册

系统的 MediaRecorder 采用的就是动态注册。JNI 中有一个用来记录 Java 方法和 Native 方法映射的数据结构: JNINativeMethod ,代码如下:

1
2
3
4
5
typedef stuct {
const char* name;//Java 方法的名字
const char* signature;//方法签名
void* fnPtr;//JNI中对应的方法指针
}

有了数据结构我们就能映射了,在 MediaRecorder 的 Native 代码中是这样的:

1
2
3
static const JNINativeMethod gMethods[] = {
{"start", "()V", (void *)android_media_MediaRecorder_start}
}

忽略了其他方法,说明一下:

  • 第一个参数 “start” 就是 Java 方法的名字,

  • 第二个参数 “()V” 是签名,为什么需要签名呢?因为 Java 中方法是可以重名的,只是入参或者返回值不同,有这个签名可以区分不同的方法

  • 第三个参数是 jni 中方法名

在System.loadLibrary 调用加载这个 so 之后,就会执行 register 来注册这种映射关系。体现在 Android 源码上就是在 AndroidRuntime。registerNativeMethods 函数的时候,注册系统需要的这些 native 方法。

1.3 方法签名

方法签名格式就是 :(参数签名格式… )返回值签名格式,比如有些签名是这样的:

1
"(Ljava/lang/Object; Ljava/lang/String;Lava/lang/String;)V"

这就说明这个方法入参是 Object,String,String ;返回值是 void

二、数据类型的转换

2.1 基本数据类型的转换

基本类型转换关系如下所示:

JNI基本类型转换关系

2.2 引用数据类型的转换

JNI引用类型映射

三、解析 JNIEnv

它是 Java 环境的代表,它只能在创建它的线程有效,不能跨线程传递。,它的主要作用有 2 点:

  • 调用 Java 方法

  • 操作 Java (中的变量和对象等)

在 jni.h 文件中除了定义 JNIEnv 还定义了 JavaVM ,JavaVM 代表的是 Java 虚拟机,每一个进程中只有一个 JavaVM,因此,该进程所有的线程都能使用这个 JavaVM ,通过 JavaVM 的 AttachCurrentThread 就能获取这个线程的 JNIEnv ,这样就能在不同的线程中调用 Java 方法了

注意:使用了 AttachCurrentThread 的情况下,在线程退出前务必调用 DetachCurrentThread 函数来释放资源 !

我们知道, JNIEnv 是 JNINativeInterface* 类型的,它里面包含了很多函数,这里列举 3 个比较常用的函数:

  • FindClass : 用来找到 Java 中指定名称的类

  • GetMethodID:用于获取 Java 中的方法

  • GetFieldID:用来得到 Java 中的成员变量

平时使用一般是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void
android_media_MediaRecorder_native_init(JNIEnv *env) {
jclass clazz;

clazz = env -> FindClass("android/media/MediaRecorder");
if (clazz == NULL) {
return;
}

fields.context = env -> GetFieldID(clazz, "mNativeContext", "J");
if (fields.context == NULL) {
return;
}

fields.surface = env -> GetFieldID(clazz, "mSurface", "Landroid/view/Surface;");
if (fields.surface == NULL) {
return;
}

jclass surface = env -> FindClass("android/view/Surface");
if (surface == NULL) {
return;
}

fields.post_event = env -> GetStaticMethodID(clazz, "postEventFromNative",
"(Ljava/lang/Object;IIILjava/lang/Object;)V");
if (fields.post_event == NULL) {
return;
}
}

上述保存clazz 和 jfildID 以及 jmethodID 类型的 变量有2个原因:

  • 为了效率考虑,如果每次调用相关方法时都要查询方法和变量,效率会变低

  • 成员变量是本地引用,在 android_media_MediaRecorder_native_ini函数返回时就会自动释放

四、引用类型

说明,第二节课关于热修复的代码落地就不看了,主要关注 06 字节码插桩技术,其他的问题下次有空再看然后补全

一、字节码插桩

1.1 什么是字节码插桩

我们平时在 Java 中编写代码,字节码插桩是在 .class 文件中编写代码。

我们在 AS 中查看 .class 文件的时候,是经过反编译的,变成 java 语言了,真正想看原始的 .class 文件,还需要特定的查看器,课程中老师使用 010 Editor,这个工具可以看很多文件的格式,比如 音频文件,视频文件等等,可以了解下。

所以流程,首先将 .class 读入内存,是个 byte[] ,即 byte 数组,之后再去修改,最后通过 IO 写回去,就完成了。我们不熟悉 .class 文件的格式,所以需要借助ASM这个工具去实现,就像不熟悉 json 格式,可以使用 Gson 框架去解析 json 一样。

二、 ASM 工具

使用步骤:

  1. 通过 testImplementation 的方式引入 ASM 这个工具

  2. 获取 Java 类的 class 文件,可以使用javac 去实现,或者通过 Build

  3. 自己通过 IO 将 class 文件读入 FileInputStream

  4. 通过 ASM 提供的 ClassReader 去读取这个 FileInputStream

  5. 后续监听 ClassReader 解析到的方法(通过 visitMethod 回调)

  6. 改完之后输出结果

我们在监听解析到的方法的时候,一般使用 AdviceAdapter ,因为它提供了2个很好用的方法:onMethodEnteronMethodExit ,一个是刚刚进入方法的时候调用,一个是方法退出的时候调用。这样我们就能很容易执行方法前后插入代码。

ASM 课程也没听完,08听到一半,后续再来

注:热修复demo 老师在最后面给出了 HotFix 的源码,可以用来参考

热修复这块知识,对于常用的解决方案(开源框架)需要了解,尤其是 类替换即时生效 这 2 个维度。

常见热修复框架对比

二、Robust 热修复原理

美团开源的,抖音也是用这个。它是 Java 开发的,但是它是怎么实现的即时生效?不是说只能在 NDK 去替换有问题的方法吗?其原理是:Robust的 gradle 插件对每个产品代码的每个函数在编译打包阶段自动插入了一段代码,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//原始代码
public long getIndex() {
return 100;
}


//处理之后的代码
public long getIndex() {
if(changeQuickRedirect != null) {
//PatchProxy中封装了获取当前className和methodName的逻辑,并在其内部最终调用了changeQuickRedirect的对应函数
if(PatchProxy.isSupport(new Object[0], this, changeQuickRedirect, false)) {
return ((Long)PatchProxy.accessDispatch(new Object[0], this, changeQuickRedirect, false)).longValue();
}
}
return 100L;
}

而修复后的patch包里面是这样的:

1
2
3
4
5
6
7
8
9
10
public class StatePatch implements ChangeQuickRedirect {
@Override
public Object accessDispatch(String methodSignature, Object[] paramArrayOfObject) {
String[] signature = methodSignature.split(":");
if (TextUtils.equals(signature[1], "a")) {//long getIndex() -> a
return 106;
}
return null;
}
}

所以呢,Robust 的实现也就是,打一个 补丁包,在本地利用类加载技术,将补丁加载进来。补丁是实现了 changeQuickRedirect 接口的,这样,当实例化补丁之后,可以通过反射的方式将补丁的实例赋值给类的 changeQuickRedirect 对象,所以,当有补丁包的时候 (changeQuickRedirect 就不会为 null 了,因此就不再执行原有逻辑,而是执行 changeQuickRedirect 的逻辑了 。

按照RoBust 官方的说法,这个方案规避了需要针对虚拟机、指令集的 Native 方案,在 Java 层就实现了热修复。它其实参考的还是 Instant Run

详细可以参考Android热更新方案Robust - 美团技术团队 (meituan.com)

Tinker

光从代码上讲(不考虑资源)Tinker 是打出差量包 patch ,然后在 App 端将 old.dex + patch包 = 修复bug的 dex。

在 ActivityThread 中(handleApplication 这个方法中),会创建出用于加载我们所写的项目代码的 PathClassLoader ,关于 classLoader ,有以下几个需要注意:

  • AppcompatActivity 是用 PathClassLoader 加载的,因为我们需要在gradle 里面引入 appcompat ,所以它和okhttp之类的引用没什么区别,都是属于项目代码

  • Activity 是用 BootClassLoader, 这个没问题,因为它是 sdk 的

  • Application.class.getClassLoader 肯定也是 BootClassLoader

  • 在 我们自己写的 MyApplication 中我们直接 getClassLoader() 获得的是我们整个程序所属的 ClassLoader,所以也是 PathClassLoader

一定要理解 Android 的类加载机制,然后才能知道 dexElements[] 那些dex 数组是怎么存放的,图片如下:

Android类加载机制

同学提问

有同学提问,补丁包下发之后,之前的 dex 能不能删掉?因为 补丁的dex 已经在 dexElements[] 数组最前面了。答案是肯定不能删除,我老的 dex 中可能有 A 类和 B 类, 但是补丁包只是为了修复 有bug 的A 类,所以补丁中没有 B 类的,如果把老的 Dex 删除了,那么后续要用到 B 类就没有了。

制作补丁包

如何制作补丁包呢?其实还是利用Android自己的打包工具,dx 命令,具体流程如下:

  1. 修复Bug ,然后编译,将有问题的类(比如是 com.test.Utils.java)生成 .class 文件

  2. 执行命令打包成 dex 或者 jar :

    dex –dex –output=patch.dex com/test/Utils.class

    注意,如果是想打包成 jar ,就将 patch.dex 改成 patch.jar 即可。

如何使用补丁包?

下载下来补丁包之后,其使用方法与诸如插件化之类的就很像了,都是涉及类加载,反射等,具体步骤有以下 6 步:

  1. 获取程序(当前应用)的 PathClassLoader 对象

  2. 反射获得 PathClassLoader 的 parent 的 BaseDexClassLoader 的 DexPathList 类型的 pathList 对象

  3. 反射获取 pathList 中Element[] 类型的 dexElements 对象(这是 oldElements 集合)

  4. 把补丁包变成 Element 数组 patchElements(反射执行 DexPathList 类中的 makePathElements 方法)

  5. 合并两个 Element 数组 oldElements + patchElements = newElements (Array.newInstance() 方式即可合并2个数组)

  6. 反射把 pathList 中的 dexElements 赋值成 newElements

注意,热修复补丁包一定要放在 dexElements 数组的最前面,需要先加载才能达到修复的目的!

so的修复也差不多,他们也是放在一个 Element 数组里面。

一、热修复演示

打包之后补丁包,里面类名和方法名都要根据 mapping.txt 来,比如,我们项目中 com.test.Utils 中 的 test() 方法出现bug ,而其在正式包中被混淆为 com.a.b 类,test() 方法也被混淆为 a() 方法,那么我们打出来的补丁包中,应该包含 com.a.b 类,其中有个修复的 a() 方法。

二、热修复要解决的问题

用户端:

  • 什么时候执行热修复

  • 怎么执行热修复(使用补丁包)?

  • Android 版本兼容问题

在开发端:

  • 补丁包是什么

  • 如何生成补丁包

  • 开启混淆之后呢?

  • 如何对比改动自动生成补丁包(gradle代码)?

三、AndFix 的流程分析

一般来说,使用类替换的方案实现的热修复都不能即时生效,非类替换的就能。这是为什么呢?我感觉是新的类需要重新加载,老的类已经加载在里面了,又不能卸载老的类。

AndFix 拿到补丁包之后,就加载补丁包,load 这些类,得到 Class 对象,读取其运行时注解获取它要替换掉的类以及其中要替换的方法。但是在 Java 层做不到这个效果,我们需要在 C/C+ 层来实现。其补丁形式如下图所示:

AndFix补丁形式

HookAMS 使用的是动态代理,Hook Handler 使用的是反射。

Android 10 的 AMS 变成 ATMS ,Singleton 貌似也变了,需要注意适配。Handler 的话,由于原理没变,一般不需要另行适配

二、资源加载

我们加载资源就关注2个地方:

  • assets 目录下的资源

  • res 目录下的资源

资源替换有2种可选方案:

  • 插件与宿主的资源直接合并,但是可能引起资源冲突

  • 专门创建一个新的 AssetManager (或者 Resource) 来加载插件的资源

2.1 创建 Resources 对象

首先通过 class.newInstance (这是因为 AssetManager 不能直接去new ,hide 的)的方式创建一个 AssetManager 对象 myAssetManager,然后通过 addAssetPath 方法将插件的路径给加进去。之后new 出来 Resources 对象,传入这个创建的 myAssetManager 即可。

那么,怎么让插件的 Activity 去用上这个 Resources 对象呢?我们可以借助 Application 去实现这个事情,分为以下几步:

  1. 在宿主中定义 Application ,并且其 getResource 返回我们自己创建的 Resource 对象

  2. 插件 Activity 都继承 BaseActivity

  3. 在插件的 BaseActivity 中,getResource 返回 getApplication.getResource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//宿主里面
public class MyApplication extends Application {

private Resources mResources;

@Override
public void onCreate() {
super.onCreate();
mResources = LoadUtil.loadResource(this);
}

@Override
public Resources getResources() {
return mResources == null ? super.getResources() : mResources;
}
}
1
2
3
4
5
6
7
8
9
10
11
//插件的 BaseActivity
public class BaseActivity extends AppCompatActivity {

@Override
public Resources getResources() {
if (getApplication() != null && getApplication().getResources() != null) {
return getApplication().getResources();
}
return super.getResources();
}
}

但是,这样对 宿主 App 是会有影响的,我们能不能插件自己的资源插件自己去加载?并且我们知道,插件的 Application 是不会被执行的。为了不影响宿主,我们可以在 BaseActivity 中去做 Resource 的加载:

1
2
3
4
5
6
7
8
9
public class BaseActivity extends AppCompatActivity {

@Override
public Resources getResources() {
Resources resources = LoadUtil.getResources(getApplication());
// 如果插件作为一个单独的app,返回 super.getResources()
return resources == null ? super.getResources() : resources;
}
}

这里要尤其注意的是,LoadUtil.getResouces(context) 的时候一定要传入 Application 这个Context, 如果传入this 也就是当前 Activity 的Context ,就会导致循环调用了。 看下 LoadUtil 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class LoadUtil {

private final static String apkPath = "/sdcard/plugin-debug.apk";

private static Resources mResources;

public static Resources getResources(Context context) {
if (mResources == null) {
mResources = loadResource(context);
}
return mResources;
}

private static Resources loadResource(Context context) {
// assets.addAssetPath(key.mResDir)
try {
AssetManager assetManager = AssetManager.class.newInstance();
// 让 这个 AssetManager对象 加载的 资源为插件的
Method addAssetPathMethod = AssetManager.class.getMethod("addAssetPath", String.class);
addAssetPathMethod.invoke(assetManager, apkPath);

// 如果传入的是Activity的 context 会不断循环,导致崩溃
Resources resources = context.getResources();

// 加载插件的资源的 resources
return new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

2.2 冲突导致崩溃

不过,这里要提一嘴,我们通过插件的 BaseActivity 这种方式能够完成插件资源加载并且不报错,是因为我们的 BaseActivity 继承了 Activity ,如果我们继承了 AppcompatActivity 的话,就会产生崩溃了,会提示这段代码:

1
2
3
mDecorContentParent = (DecorContentParent) subDecor
.findViewById(R.id.decor_content_parent);
mDecorContentParent.setWindowCallback(getWindowCallback());

其中的 mDecorContentParent 为 null ,为什么会这样呢?我们可以看打出的包中,我们知道,R.id.xxx 最终在打包好的 Apk 中是 数值存在的,一般是 0x7fxxxxx之类的,我们可以看下宿主APk 和 插件 APk 中 R.id.decor_content_parent 最终的数值:

// 宿主的
0x7f07004e decor_content_parent false

// 插件的
0x7f07004d decor_content_parent false

所以,在 宿主中,编译后应该是这样的:

1
2
mDecorContentParent = (DecorContentParent) subDecor
.findViewById(0x7f07004e);

在插件中应该是这样的:

1
2
mDecorContentParent = (DecorContentParent) subDecor
.findViewById(0x7f07004d);

说明二者是不一样的值,一个是 4e ,一个是 4d 。而由于双亲委派的原因,这个AppcompatActivity 用的是宿主的,所以就会产生问题。注意,这个问题不论是aapt 修改还是new 出来 Resourse(AssetManger)的方式,都会出现

这里还是没怎么搞懂,讲道理因为宿主有 0x7f07004e ,那么插件在运行的时候也能找到这个 0x7f07004e 这个资源。

老师说是因为 插件用的还是宿主那个 Context 导致的,所以需要在插件 BaseActivity 中创建自己的 Context (注意,这个操作不会影响宿主的,指挥对插件有影响):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class BaseActivity extends AppCompatActivity {

protected Context mContext;
// 不会影响到宿主
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Resources resources = LoadUtil.getResources(getApplication());

mContext = new ContextThemeWrapper(getBaseContext(), 0);

Class<? extends Context> clazz = mContext.getClass();
try {
Field mResourcesField = clazz.getDeclaredField("mResources");
mResourcesField.setAccessible(true);
mResourcesField.set(mContext, resources);
} catch (Exception e) {
e.printStackTrace();
}
}
}

然后,我们在插件的 Activity 中都要用这个创建出来的 mContext 去加载资源:

1
2
3
4
5
6
7
8
9
10
11
public class MainActivity extends BaseActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.e("leo", "onCreate: 启动插件的Activity");

View view = LayoutInflater.from(mContext).inflate(R.layout.activity_main, null);
setContentView(view);
}
}

这个看起来很麻烦,还需要自己创建Context ,那么为什么步通过反射替换 Activity 的Context 呢?这还是因为双亲委派操作,如果你替换了 Activity 的,根据双亲委派原则,宿主也会受影响了。

2.3 宿主与插件的资源合并

只要不与系统的 id 冲突,可以自己去设置id ,你都可以 0x10 开头,不过我们约定一般是 0x70 等开始,到 0x7e

插件化,建议宿主的 dex 文件在前面,不然如果插件的代码有问题,就把宿主也带崩了。所以,宿主先加载会好点。

一、整体回顾

因为 Activity 等四大组件是有生命周期的,所以我们不能仅仅只是将其通过 ClassLoader 加载进来,还需要通过 Intent 将其启动。又由于插件Apk 不在项目中,所以我们不能实现类似:

startActivity(new Intent(this, PluginActivity.class))

因为压根就找不到 PluginActivity ,所以我们只能通过 Compunent 的方式:

1
2
Intent intent = new Intent();
intent.setComponent(new Component("com.pkgname", "com.plugin.PluginActivity"));

通过包名和路径的方式去找到这个 Activity 。

通过 动态代理和 反射 去实现 Hook 技术,以便欺骗 AMS 对 Activity 的是否注册。查找 Hook 点的原则:

  • 尽量是静态变量或者单例对象

  • 尽量Hook public 的对象和方法

二、启动插件中的 Activity

其实插件化的Hook 流程如下图所示:

插件化的流程

所以我们的思路是:

  • 在进入 AMS 之前,找到 Hook 点将目标 Activity 替换成占位 Activity (在宿主的 AndroidManifest中注册了的)

  • 在从 AMS 中返回 App 的时候,将占位 Activity 换回 目标 Activity

所以,其实整个过程都是在找 startActivity 时候传入的 Intent ,只需要正确找到替换 Intent 的地方就行。

三、适配和总结

判断某个点是否可以Hook,就需要反推,一直往前面找调用方,一直找到有对象的,或者是静态的地方,就可以从那里开始了。

一、什么是插件化

插件化解决的问题:

  • App 体积越来越大,因为功能越来越多,如果App太大,用户都不想下载了

  • 模块之间的耦合度搞,协同开发的成本高了

  • 规避方法数超过 65535 问题,不过可以用 MutiApplication 了

  • 可以用于ABTest,某些用户下载插件A版本,某些下载插件B版本

  • 换肤,多开技术等都可以

选择开源框架的时候,根据自身需求:

  • 如果加载的插件不需要和宿主有任何耦合,也无需和宿主进行通信,那么推荐使用 RePlugin

  • 其他情况推荐 VirtualApk

插件化和组件化的区别?能用插件化的情况下不用组件化,组件化做不到按需下载,代码一起再 App 里面。可以做到各个插件的单独升级和下载。

二、插件化前置知识

插件化我们需要解决的问题:

  • 如何加载插件中的类

  • 如何启动四大组件,因为有生命周期

  • 如何加载插件中的资源

2.1 对于反射的了解

反射的思想:我们一个类,比如 Person 类,它的描述也是可以封装成一个 Class 对象,用于描述其成员变量、构造方法、方法等。

其中的属性,也是个对象 Field ,这个在反射中我们就能用到,所以我们可以构造出 Field 之后,对其进行 set 和 get 来设置和获取值,只不过,如果是静态属性,则get 的时候传入 null 即可,因为它是属于类级别;如果是成员属性,则需要传入这个类的对象,表示你要获取哪个对象的这个属性。

为什么反射耗时:

  • 产生临时对象,导致GC

  • 需要检查可见性,比较耗时

  • 字节码没有优化

  • 会做装箱、拆箱的操作,如 int 会变为 Integer

2.2 类加载器

使用反射的时候,forName 和 loadClass 会有什么区别?

  • forName 会进行类加载,验证、准备、解析,初始化;

  • loadClass 只会做类加载

澄清一下网上的错误说法,

关于classLoader的错误说法

其实 PathClassLoader 也是能加载未安装的 APK 的!老师在课程里面用代码验证了。

只是在 8.0 之前,DexClassLoader 构造函数在需要传入一个 odex 存放的位置,就这一个区别。在 8.0 之后基本上没有区别了。

PathClassLoader 和 BootClassLoader 有什么区别?

  • PathClassLoader 加载应用的类,比如你写的 MainActivity

  • BootClassLoader 加载 SDK 的类,比如 Activity 类,但是 AppcompactActivity 这种就是

ClassLoader 中 loadClass 方法加载类的步骤:

  • 首先 findLoadedClass 看看类是否已经加载过了,加载过了直接用

  • 其次让parent加载

  • 如果parent加载还没有,就自己 findClass 加载

整体的调用步骤如下图所示:

AndroidClassLoader加载类过程

BootClassLoader shi ClassLoader 的内部,它的 loadClass 只有 2 步, 首先 findLoadedClass , 如果没有直接就自己 find Class 了,因为它上面没有父 ClassLoader 。

为什么Android 要设计这样的双亲委派机制?基于2点:一是避免重复加载,父加载器已经加载的情况下,就不要加载了;二是安全性考虑,防止核心 API 被随意篡改

三、启动插件

通过对 ClassLoader 加载类的源码追踪,我们能看到如下的时序图:

Android加载Dex 的时序图

可以知道,最终是通过 DexPathList 去 findClass ,查看里面的源码可以知道:

  1. 一个 dex 文件对应一个 Element 对象

  2. 但是一个 Apk 可能有多个 dex 文件

  3. 所以使用 Element[] dexElements 数组存放app 中所有的 element

所以我们的思想步骤可以分为如下4 步:

  1. 获取宿主的 dexElements获取插件的 dexElements

  2. 获取插件的 newdexElements

  3. 合并 2 个 dexElements 得到新的 newdexElements

  4. newdexElements 赋值给宿主的 dexElements

个人理解,组件化的精华在于组件之间的通信,到这里基本上了解得差不多了,后续有空再详细来看。