0%

高级UI-(02)2021.9.14-高性能换肤框架原理解析--leo老师

换肤的目的:

  • 保持新鲜感

  • 提高付费率

  • 配合节日营销

  • 写死的主题切换很 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’ 这种没有名字的,那么就不能实现换肤。

谢谢你的鼓励