主流实现方式
现在很多的应用都有了动态换肤的功能,像 QQ 音乐,网易音乐等。这些应用主要是通过网上下载皮肤包,然后应用。我们可以通过查看 QQ 音乐或者网易云音乐安装包下面的文件,就可以发现有一些带 xxx.skinxxx
等等的一些文件,而把这些文件 push 到桌面上,然后用 ZIP 工具打开,会发现,这个皮肤包和一般的 APk
唯一的区别就是里面没有 src
目录(也就是说里面没有代码部分),除此之外,没有任何区别。而我们今天要讨论及实现的换肤框架也是如此,通过 Hook
等类似加载插件的方式来实现。
实现后功能
- XML 中 View 属性值的动态替换
- 导航栏和状态栏颜色的动态替换
- 应用中字体的动态替换
Inflate 实现原理
页面中 XML 布局文件的加载,都是通过 LayoutInflate.inflate
方法来实现的,而我们需要调用 动态替换 XML 中的属性的设置,就需要从这个方法入手,去分析 Framework 里是如何实现的。
从图上我们可以看出,通过 setConentView
方法设置的布局文件都是通过 LayoutInflate.inflate
方法来加载的。接着往下看:
可以看出我们的 XML 文件是由 XmlResourceParser
解析工具来进行解析的。继续往下:
最终我们从图上看到了,原来我们的 View 对象是由mFactory2
或者 mFactory
来创建的。我们再看看这个两个对象是否可以 Hook
?由程序来接管 View 的创建。
可以看到,mFactory2
和 mFactory
两个成员变量都提供了对应的 set 方法,且方法都是由 public
修辞的。由此我们就可以偷梁换柱的方法把 mFactory2
替换成我们自己的 Factory2
的实现类,并通过反射的方式把我们自己的 factory2
来进行替换。当然,这里其实不需要 Hook
,因为系统已经提供了一个 LayoutInflaterCompat
对象,我们可以直接通过它来进行设置。
注意
在通过反射调用 setFactory2()
的时候,有一个对 mFactorySet
变量是否为真的判断,所以我们在反射之前,需要把 mFactorySet
的值设置为 false
.
接下来,我们来看一下 LayoutInflate
是如何创建 View 的。
这里主要是判断从 XML 文件中解析到的 TAG 名称是否是带 .
的,如果没有带点,说明是标准控件(如:TextView),则进行前缀拼接,而如果带 .
的,说明是自定义控件,则不进行接拼。最后,我们来看一下 View 的最终创建的部分。
已经很清楚了,这里直接通过 ClassLoader 的 loadClass() 直接通过全类名去加载,并通过反射获取到两个参数的构造函数,去创建 View 对象。同时,这里对 loadClass 的操作还进行的缓存,以避免同一个布局文件里存在多个同样的控件而多次 loadClass 的情况。
下面是具体的代码实现(这里的代码大部分都可以在源码里找到):
1 | class LayoutInflateFactory( |
同时,创建 View 的代码,需要在 Application.ActivityLifecycleCallbacks
的回调中进行调用。 只有这里,我们拿到的 LayoutInflater
才是当前页面的,而只有拿到当前页面的 LayoutInflater
,才能够接管当前页面的 XML 中标签转对象的实现。
1 | override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) { |
资源加载过程
资源的加载的详细过程可以参看罗老师的博客的 Android应用程序资源的查找过程分析 ,这里只是简单过一下。
所有的资源都是使用 AssetManager
去加载的,当我们使用 Resources
对象根据资源 ID 去加载资源时,默认的会去本 APP 的目录下的 resources.arsc
文件里去找到资源 ID 对应的资源名称,然后 AssetManager
再通过名称去加载对应的资源。而我们如果想让 AssetManager
去加载指定路径(也就是皮肤包)的资源时,可以通过 AssetManager
的 addAssetPath
来指定路径,并创建属于皮肤包的 Resources
对象。如此一来,当我们在加载资源的时候,就可以加载到皮肤包下的资源了。
加载皮肤包资源过程
如果我们直接通过宿主应用中的资源 ID 去获取皮肤包时,虽然 Resources
对象已经是指向皮肤包的资源对象了,但此时的资源 ID 还是宿主应用中的资源 ID ,而用这些资源 ID 去加载资源,肯定是加载不到资源的。所有我们需要使用 APP 中的资源 ID 来进行一层转换。
获取一个资源 ID 需要资源包名,资源名称和资源类型,而资源名称和资源类型我们通过资源 ID 得到,所以,我们可以先通过 APP 中的资源 ID,获取到资源名称和资源类型,再通过 PackageManager
的 getPackageArchiveInfo
方法,来获取一个指定路径 APK 的包名。到这里三个参数就已经获得了。
最后,通过我们之前获得到的皮肤名对应的 Resources
对象的 getIdentifier
方法就可以获得皮肤包里对应资源的 ID 了。
具体代码实现的片段如下:
1 | ... |
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 | fun getAttrResId(context: Context?, themeId: IntArray): IntArray { |
第二步:
1 | skinResource.getColor(resIds) |
字体设置
字体也是属于 theme 中的属性,一般的处理是,我们先在宿主 APP 里进行占位,但不用赋值;后面在皮肤包里给定对应的路径即可。
1 | <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> |
在皮肤名里只需要把对应字体的路径对 string 属性 typeface
进行赋值即可。
加载字体的代码:
1 | fun getTypeface(attrResId: Int): Typeface { |
加载到对应字体后,再将其进行应用。而 APP 里的每一个 TextView 及其 子类 Button 都需要替换,而前面我们设置的 Factory2 类就是创建每一个 View 对象的地方,所以,在那里去进行字体替换是再合适不过的了。
自定义 View 的支持
因为自定义 View 的属性名称是变化的,这里只能保证在换肤的时候会给实现了 SkinSupport
接口的自定义 View 回调一下相应的 appSkin()
,而你可以使用皮肤包的资源对象进行对应资源的加载,也能进行换肤。前提是:宿主里的自定义属性和皮肤包中的自定义属性保持一致。
1 | public interface SkinViewSupport { |
属性替换
以上部分把如何使用皮肤包去替换宿主 APP 中对应资源的关键点都已经提到了。但具体替换哪些属性?如何进行替换?这些问题将会在下面的文章中进行说明。
先上代码:
1 |
|
这里主要是在 ActivityLifecycleCallbacks
的回调中将 XML 中的 TAG 转换为对象的过程中,对 attributes
集合中所需要配置的,需要被替换的属性进行过滤,并将符合条件的值存储起来。存储的结构为:
这里每一个 SkinView,存储了当前 View 与其属性名和属性值 ID 的映射关系。当应用被皮肤包的时候,就可以对 SkinView 集合进行遍历,然后,通过属性名, id 和包名去皮肤包里获得对应的值来对当前 APP 的资源进行替换,达到应用皮肤包的作用。
下面代码是对 SkinView 集合的具体实现:
1 | skinAttributes?.forEach { |
大功告成,关于 Android 动态主题的主要知识点就说到这里了,关于 9.0
后上面通过反射实现的部分代码会失效以及自定义 View 是否还有更好的实现方式,以后,有时间,再说吧。