Android动态主题实现原理

主流实现方式

现在很多的应用都有了动态换肤的功能,像 QQ 音乐,网易音乐等。这些应用主要是通过网上下载皮肤包,然后应用。我们可以通过查看 QQ 音乐或者网易云音乐安装包下面的文件,就可以发现有一些带 xxx.skinxxx 等等的一些文件,而把这些文件 push 到桌面上,然后用 ZIP 工具打开,会发现,这个皮肤包和一般的 APk 唯一的区别就是里面没有 src 目录(也就是说里面没有代码部分),除此之外,没有任何区别。而我们今天要讨论及实现的换肤框架也是如此,通过 Hook 等类似加载插件的方式来实现。

实现后功能

  1. XML 中 View 属性值的动态替换
  2. 导航栏和状态栏颜色的动态替换
  3. 应用中字体的动态替换

Inflate 实现原理

页面中 XML 布局文件的加载,都是通过 LayoutInflate.inflate 方法来实现的,而我们需要调用 动态替换 XML 中的属性的设置,就需要从这个方法入手,去分析 Framework 里是如何实现的。

从图上我们可以看出,通过 setConentView 方法设置的布局文件都是通过 LayoutInflate.inflate 方法来加载的。接着往下看:

可以看出我们的 XML 文件是由 XmlResourceParser 解析工具来进行解析的。继续往下:

最终我们从图上看到了,原来我们的 View 对象是由mFactory2 或者 mFactory 来创建的。我们再看看这个两个对象是否可以 Hook ?由程序来接管 View 的创建。

可以看到,mFactory2mFactory 两个成员变量都提供了对应的 set 方法,且方法都是由 public 修辞的。由此我们就可以偷梁换柱的方法把 mFactory2 替换成我们自己的 Factory2 的实现类,并通过反射的方式把我们自己的 factory2 来进行替换。当然,这里其实不需要 Hook,因为系统已经提供了一个 LayoutInflaterCompat 对象,我们可以直接通过它来进行设置。

注意
在通过反射调用 setFactory2() 的时候,有一个对 mFactorySet 变量是否为真的判断,所以我们在反射之前,需要把 mFactorySet 的值设置为 false.

接下来,我们来看一下 LayoutInflate 是如何创建 View 的。


这里主要是判断从 XML 文件中解析到的 TAG 名称是否是带 . 的,如果没有带点,说明是标准控件(如:TextView),则进行前缀拼接,而如果带 . 的,说明是自定义控件,则不进行接拼。最后,我们来看一下 View 的最终创建的部分。

已经很清楚了,这里直接通过 ClassLoader 的 loadClass() 直接通过全类名去加载,并通过反射获取到两个参数的构造函数,去创建 View 对象。同时,这里对 loadClass 的操作还进行的缓存,以避免同一个布局文件里存在多个同样的控件而多次 loadClass 的情况。

下面是具体的代码实现(这里的代码大部分都可以在源码里找到):

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class LayoutInflateFactory(
private val activity: Activity,
private val typeface: Typeface,
private val skinFollowAttribute: SkinFollowAttribute = SkinFollowAttribute(typeface)
) :
LayoutInflater.Factory2, Observer {
companion object {

const val TAG = "LayoutInflateFactory"
}

private val classPrefixList = arrayOf("android.widget.", "android.webkit.", "android.app.", "android.view.")

private val constructorSignature: Array<Class<*>> = arrayOf(Context::class.java, AttributeSet::class.java)
private val constructorMap = HashMap<String?, View?>()
override fun onCreateView(parent: View?, name: String?, context: Context?, attrs: AttributeSet?): View? {
val view = createViewFromTag(name, context, attrs)
if (null == view) {
createView(name, context, attrs)
}
return view
}

private fun createViewFromTag(name: String?, context: Context?, attrs: AttributeSet?): View? {
if (-1 != name?.indexOf(".")) {
return null
}
var view: View? = null
classPrefixList.forEach {
view = createView(it + name, context, attrs)
if (null != view) {
skinFollowAttribute.filter(view, attrs)
return view
}
}
return view
}

private fun createView(name: String?, context: Context?, attrs: AttributeSet?): View? {
// implement a cache.
var view: View? = constructorMap[name]
if (view == null) {
view = getConstructor(name, context, attrs)
if (null != view) {
constructorMap[name] = view
return view
}
}
return null
}

private fun getConstructor(name: String?, context: Context?, attrs: AttributeSet?): View? {
try {
val subViewClz = context?.classLoader?.loadClass(name)?.asSubclass(View::class.java) ?: return null
val constructor = subViewClz.getConstructor(*constructorSignature)
constructor.isAccessible = true
return constructor.newInstance(context, attrs)
} catch (ex: Exception) {
Log.e(TAG, "load class exception. ex: $ex")
}
return null
}

override fun onCreateView(name: String?, context: Context?, attrs: AttributeSet?): View? {
return onCreateView(name, context, attrs)
}

override fun update(o: Observable?, arg: Any?) {
SkinThemeResUtils.updateStatusBarAndNavBar(activity)
SkinThemeResUtils.updateTypeface(activity)
skinFollowAttribute.applySkin()
}
}

同时,创建 View 的代码,需要在 Application.ActivityLifecycleCallbacks 的回调中进行调用。 只有这里,我们拿到的 LayoutInflater 才是当前页面的,而只有拿到当前页面的 LayoutInflater,才能够接管当前页面的 XML 中标签转对象的实现。

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
override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {

// update status bar.
SkinThemeResUtils.updateStatusBarAndNavBar(activity!!)

// update typeface.

val typeface = SkinThemeResUtils.updateTypeface(activity)

val inflater = LayoutInflater.from(activity)

try {
val inflateClazz = LayoutInflater::class.java
val mFactorySetFiled = inflateClazz.getDeclaredField(mFactorySet)

mFactorySetFiled.isAccessible = true
mFactorySetFiled.setBoolean(inflater, false)
} catch (ex: Exception) {
Log.e(TAG, "set mFactorySet filed exception. detail: $ex")
return
}

val layoutInflateFactory = LayoutInflateFactory(activity, typeface)
factory2Map[activity] = layoutInflateFactory
SkinManager.addObserver(layoutInflateFactory)
LayoutInflaterCompat.setFactory2(inflater, layoutInflateFactory)
}

资源加载过程

资源的加载的详细过程可以参看罗老师的博客的 Android应用程序资源的查找过程分析 ,这里只是简单过一下。

所有的资源都是使用 AssetManager 去加载的,当我们使用 Resources 对象根据资源 ID 去加载资源时,默认的会去本 APP 的目录下的 resources.arsc 文件里去找到资源 ID 对应的资源名称,然后 AssetManager 再通过名称去加载对应的资源。而我们如果想让 AssetManager 去加载指定路径(也就是皮肤包)的资源时,可以通过 AssetManageraddAssetPath 来指定路径,并创建属于皮肤包的 Resources 对象。如此一来,当我们在加载资源的时候,就可以加载到皮肤包下的资源了。

加载皮肤包资源过程

如果我们直接通过宿主应用中的资源 ID 去获取皮肤包时,虽然 Resources 对象已经是指向皮肤包的资源对象了,但此时的资源 ID 还是宿主应用中的资源 ID ,而用这些资源 ID 去加载资源,肯定是加载不到资源的。所有我们需要使用 APP 中的资源 ID 来进行一层转换。

获取一个资源 ID 需要资源包名,资源名称和资源类型,而资源名称和资源类型我们通过资源 ID 得到,所以,我们可以先通过 APP 中的资源 ID,获取到资源名称和资源类型,再通过 PackageManagergetPackageArchiveInfo 方法,来获取一个指定路径 APK 的包名。到这里三个参数就已经获得了。

最后,通过我们之前获得到的皮肤名对应的 Resources 对象的 getIdentifier 方法就可以获得皮肤包里对应资源的 ID 了。

具体代码实现的片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
...
val assetManagerClz = AssetManager::class.java
val assetManagerObj = assetManagerClz.newInstance()

val addAssetPathMethod = assetManagerClz.getMethod("addAssetPath", String::class.java)
addAssetPathMethod.isAccessible = true

addAssetPathMethod.invoke(assetManagerObj, skinPath)

// current main app resource.
val appResources = application.resources
// skin pkg resource.
val skinResource = Resources(assetManagerObj, appResources.displayMetrics, appResources.configuration)
...

Fragment 换肤

Fragment 中的 LayoutInflate 对象与 Activity 用的并不是同一个对象,但是 Fragment 中使用的 Factory2 与 Activity 中是同一个,所以,不用专门去处理 Fragment 的换肤,只要 Activity 可以完成换肤,在 Fragment 中自然也就可以换肤了。

状态栏和导航栏的换肤

4.4 及以下是无法更换 statusBar 的颜色的。statusBar 对应的属性名为 android.R.attr.statusBarColor,如果没有设置的话,会使用主题里的 colorPrimaryDark 属性所对应的值。所以,在替换状态栏时,优先找 android.R.attr.statusBarColor,没找到时,再去找 colorPrimaryDark。而导航栏对应的属性名为 android.R.attr.navigationBarColor

无论是状态栏或者是导航栏都是 theme 下对应的值,所以,都需要经过两步才能获得最终的属性。第一步是:获取到主题所对应的属性 ID,第二步:再通过这个 ID,去获取对应的值。而修改普通 View 的属性只需要第二步就可以获取到对应的值。具体是先通过 context?.obtainStyledAttributes 获取到对应的 TypedArray,在从 TypeArray 里获取到 theme 所对应的属性 ID;然后,再通过 Resources 对象的 getColor 去获取到对应的值。

第一步:

1
2
3
4
5
6
7
8
9
fun getAttrResId(context: Context?, themeId: IntArray): IntArray {
val resIds = IntArray(themeId.size) { 0 }
val typedArray = context?.obtainStyledAttributes(themeId)
for (i in 0 until typedArray?.indexCount!!) {
resIds[i] = typedArray.getResourceId(i, 0)
}
typedArray.recycle()
return resIds
}

第二步:

1
skinResource.getColor(resIds)

字体设置

字体也是属于 theme 中的属性,一般的处理是,我们先在宿主 APP 里进行占位,但不用赋值;后面在皮肤包里给定对应的路径即可。

1
2
3
4
5
6
7
8
9
10
11
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
// 先占位
<item name="typeface">@string/typeface</item>
</style>


// 对应的路径为 0
<string name="typeface"/>

在皮肤名里只需要把对应字体的路径对 string 属性 typeface 进行赋值即可。

加载字体的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun getTypeface(attrResId: Int): Typeface {
val realTypefacePath = getString(attrResId)
if (realTypefacePath.isNullOrEmpty()) {
return Typeface.DEFAULT
}
try {
if (isDefault) {
// load app default typeface.
return Typeface.createFromAsset(appResource?.assets, realTypefacePath)
}
// load skin pkg typeface.
return Typeface.createFromAsset(skinResource?.assets, realTypefacePath)
} catch (ex: Exception) {
Log.e("TAG", "typeface load exception. ex: $ex")
}

return Typeface.DEFAULT
}

加载到对应字体后,再将其进行应用。而 APP 里的每一个 TextView 及其 子类 Button 都需要替换,而前面我们设置的 Factory2 类就是创建每一个 View 对象的地方,所以,在那里去进行字体替换是再合适不过的了。

自定义 View 的支持

因为自定义 View 的属性名称是变化的,这里只能保证在换肤的时候会给实现了 SkinSupport 接口的自定义 View 回调一下相应的 appSkin(),而你可以使用皮肤包的资源对象进行对应资源的加载,也能进行换肤。前提是:宿主里的自定义属性和皮肤包中的自定义属性保持一致。

1
2
3
4
public interface SkinViewSupport {

void applySkin();
}

属性替换

以上部分把如何使用皮肤包去替换宿主 APP 中对应资源的关键点都已经提到了。但具体替换哪些属性?如何进行替换?这些问题将会在下面的文章中进行说明。

先上代码:

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
33
34
35
36
37
38
39
40
41
42
43
44

// attributes include values need to replace by skin pkg.
private val attributes: Array<String> = arrayOf(
"background",
"src",
"textColor"
)

fun filter(view: View?, attrs: AttributeSet?) {
val skinAttributes: MutableList<SkinAttribute> = mutableListOf()

for (index in 0 until attrs?.attributeCount!!) {
val attributeName = attrs.getAttributeName(index)
if (attributes.contains(attributeName)) {
val attributeValue = attrs.getAttributeValue(index)
if (attributeValue.startsWith("#")) {
// #ff00ff represent attribute value is solid,not to replace.
continue
}

var attrResId: Int?
if (attributeValue.startsWith("?")) {
// ?colorAccent represent attribute from current theme. need to acquire value of attr.xml.
val themeId = attributeValue.substring(1).toInt()
attrResId = SkinThemeResUtils.getAttrResId(view?.context, IntArray(1) { themeId })[0]
} else {
// @23232323 normal resource value.
attrResId = attributeValue.substring(1).toInt()
}

val skinAttribute = SkinAttribute()
skinAttribute.attributeName = attributeName
skinAttribute.resId = attrResId
skinAttributes.add(skinAttribute)
}
}

if (skinAttributes.isNotEmpty()) {
val skinFollowView = SkinFollowView()
skinFollowView.view = view
skinFollowView.skinAttributes = skinAttributes
skinFollowViews.add(skinFollowView)
}
}

这里主要是在 ActivityLifecycleCallbacks 的回调中将 XML 中的 TAG 转换为对象的过程中,对 attributes 集合中所需要配置的,需要被替换的属性进行过滤,并将符合条件的值存储起来。存储的结构为:

这里每一个 SkinView,存储了当前 View 与其属性名和属性值 ID 的映射关系。当应用被皮肤包的时候,就可以对 SkinView 集合进行遍历,然后,通过属性名, id 和包名去皮肤包里获得对应的值来对当前 APP 的资源进行替换,达到应用皮肤包的作用。

下面代码是对 SkinView 集合的具体实现:

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
33
34
35
36
37
38
39
40
41
 skinAttributes?.forEach {
when (it.attributeName) {
"background" -> {
val background = SkinResources.getBackground(it.resId)
if (background is Int) {
view?.setBackgroundColor(background)
return
}
view?.setBackgroundResource(background as Int)
}

"textColor" -> {
val colorVal = SkinResources.getColor(it.resId)

val textView = view as? TextView
textView?.setTextColor(colorVal!!)
}

"src" -> {
val background = SkinResources.getBackground(it.resId)
val imageView = view as? ImageView
if (background is Int) {

val colorDrawable = ColorDrawable(background)
imageView?.setImageDrawable(colorDrawable)
return
}

imageView?.setImageDrawable(background as? Drawable)
}
}

// get id of skin pkg by id of app.
fun getIdentifier(appResId: Int): Int? {
if (isDefault) {
return appResId
}

val resourceTypeName = appResource?.getResourceTypeName(appResId)
val resourceEntryName = appResource?.getResourceEntryName(appResId)
return skinResource?.getIdentifier(resourceEntryName, resourceTypeName, pkgName)
}

大功告成,关于 Android 动态主题的主要知识点就说到这里了,关于 9.0 后上面通过反射实现的部分代码会失效以及自定义 View 是否还有更好的实现方式,以后,有时间,再说吧。