一种基于 MVVM 的 Android 换肤方案
一、背景
目前市面上很多 App 都有换肤功能,包括会员 & 非会员皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于 MVVM 的 Android 换肤方案,希望能给有以上换肤需求的同学带来帮助。
二、目标
及时生效:一个非会员购买会员后,身份立刻发生变更。用户点击 App 内的暗夜模式按钮后,需要立刻从白天模式切换到暗夜模式。换肤的首要目标应该是及时生效的,不需要重启 App。
稳定性:作为一个线上成熟的产品,对稳定性也是有较高要求的。所以换肤方案需要稳定,不能因换肤产生 Crash & ANR。
动态能力:通常设计图同学会根据不同的时节设计不同的皮肤,例如春节有对应的春节皮肤、周年庆有周年庆皮肤。所以换肤方案还应该保持一定的动态能力,皮肤可以动态下发。
三、整体思路
基于以上提到的 3 大目标之一的动态化换肤。一个可能的实现方案是把需要换肤的图片放入一个独立的工程内,然后把该工程编译出 apk 安装包,在主工程加载该安装包。然后再需要获取资源的时候能够加载到皮肤包内的资源即可。
3.1 技术选型
目前市场上有很多换肤方案、基本思路总结如下:
通过反射 AssertManager 的 AddAssertPath 函数,创建自己的 Resources. 然后通过该 Resources 获取资源 id;
实现 LayoutInflater.Factory2 接口来替换系统默认的。
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);//自定义的Factory
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
}
该方案在上线后遇到了一些 crash,堆栈如下。
该 crash 暂未找到修复方案,因此需要寻找一种新的稳定的换肤方案。从以上堆栈分析,可能和替换了 LayoutInflater.Factory2 有关系。于是新的方案尝试只使用上述方案的第一步骤来获取资源 ID,而不使用第二步,即不修改 view 的创建的逻辑。
3.2 生成资源
因为项目本身基于 jetpack,基本通过 DataBinding 实现与数据 & View 直接的交互。我们不打算替换系统的 setFactory2,因为这块的改动涉及面会比较的大,机型的兼容性也比较的不可控,只是 hook AssetManager,生成插件资源的 Resource。然后我们的 xml 中就可以编写对应的 java 代码来实现换肤。整体流程图如下。
3.3 获取资源
上面是我们生成 Res 对象的过程,下面是我们通过该 Res 获取具体的资源的过程,首先是资源的定义,以下是换肤能够支持的资源种类:
drawable
color
dimen
mipmap
string
目前是打算支持这五种的换肤,使用一个ArrayMap
来存储具体的缓存数据:key 是上面的类型,Entry 类型为SoftReference
,是的对应 type 所有的缓存数据,每一条缓存数据的 key 是对应的 name 值与插件资源对应的 Id 值。例如:
color ->
skin_tab -> 0x7Fxxxx
skin_text -> 0x7Fxxxx
dimen ->
skin_height -> 0x7Fxxxx
skin_width -> 0x7fxxxx
具体流程如下
3.4 使用资源
然后我们通过 get 系列(XLSkinManager.getString():String
)方法就能够拿得到对应的插件资源(正常情况下),然后就是使用它。
由于之前项目中已经有了一套会员的 UI 了(就是在项目中的,不是通过皮肤 apk 下发的),为了改动较少,就把基础换肤设置为 4 种,即本地自身不通过换肤插件就可以实现的:
白天非会员
夜间非会员
白天会员
夜间会员
然后我们的 apk 可以下发对应的资源修改对应的模式,比如需要修改白天非会员的某一个控件的颜色就下发对应的控件资源 apk,然后启用该换肤插件即可。
目前项目提供了一系列的接口提供给 xml 使用,使用过程:
在 xml 中设置了之后,会触发到对应 View 的 set 方法,最终可以设置到最终的 View 的对应属性中。
同样的,在需要改属性变更的时候(例如白天切换到页面),我们也只需要修改 ViewMode 变更该 xml 中对应的ObservableField
即可,或者是在 View 中注册对应的事件(例如白天到夜间的事件)。
因为项目深度使用 DataBinding,所以我们就通过自定义 View 的方式,利用了我们可以直接在 xml 中使用 View 的 set 方法的形式,比如:
class DayNightMemberImageView : xxxView {
fun setDayResource(res: Int) {
//....
}
}
// 我们就可以在xml中使用该View的dayResource属性
<com.xxx.DayNightMemberImageView
app:dayResource="@ {R.color.xxx}"
/>
这样就可以通过传入的 Id 值,在setDayResource
中拿到最终的插件的 id 值给 View 设置。具体的例子:
/** 每一种View的基础可用属性,即用于View的属性设置*/
interface IDayNightMember {
// 白天资源
fun setDayResource(res: Int)
//夜间资源
fun setNightResource(res: Int)
// 会员白天
fun setMemberDayResource(res: Int)
// 会员夜间
fun setMemberNightResource(res: Int)
}
// 提供给xml使用的,当该控件可以是不同的会员不同的展示就可以使用该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IMemberNotify {
fun setMemberFlag(isMember: Boolean?)
}
// 提供给xml使用的,当该控件具有白天,夜间两种模式的样式的时候,可以在xml中设置该属性
//当该属性变化的时候,View的对应属性也会发生变化
interface IDayNightNotify {
fun setDayNight(isDay: Boolean?)
}
// 然后具体的实现类
class DayNightMemberAliBabaTv :
ALIBABABoldTv, IDayNightNotify, IMemberNotify, IDayNightMember {
private val handle = HandleOfDayNightMemberTextColor(this)
constructor(context: Context) : this(context, null)
constructor(context:.Context, attrs: AttributeSet?) : this(context, attrs, -1)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
override fun setDayNight(isDay: Boolean?) {
handle.setDayNight(isDay)
}
override fun setMemberFlag(isMember: Boolean?) {
handle.setMemberFlag(isMember)
}
override fun setDayResource(res: Int) {
handle.setDayResource(res)
}
override fun setNightResource(res: Int) {
handle.setNightResource(res)
}
override fun setMemberDayResource(res: Int) {
handle.setMemberDayResource(res)
}
override fun setMemberNightResource(res: Int) {
handle.setMemberNightResource(res)
}
}
// 其中的HandleOfDayNightMemberTextColor是继承了HandleOfDayNightMember,后者是做了一个优化,避免了一些重复刷新的情况,也会被其他的类复用。
abstract class HandleOfDayNightMember(view: View) :
IDayNightNotify, IMemberNotify, IDayNightMember {
var isDay: Boolean? = null
var isMember: Boolean? = null
// 日,夜,会员字体颜色
var day: Int? = null
var night: Int? = null
// 假如memberHasNight=true,则要有会员日间,会员夜间两种配置
var memberDay: Int? = null
var memberNight: Int? = null
init {
if (!view.isInEditMode) {
isDay = DayNightController.isDayMode()
}
}
/** 检测是否可以刷新,避免无用的刷新 */
open fun detect() {
if (isMember.isTrue()) {
if (memberHasNight) {
if (isDay.isTrue() && memberDay == null) {
return
}
if (isDay.isFalseStrict() && memberNight == null) {
return
}
} else if (!memberHasNight && member == null) {
return
}
} else if (isDay.isTrue() && day == null) {
return
} else if (isDay.isFalseStrict() && night == null) {
return
}
handleResource()
}
override fun setMemberFlag(isMember: Boolean?) {
if (isMember == null) {
return
}
this.isMember = isMember
detect()
}
override fun setDayNight(isDay: Boolean?) {
if (isDay == null) {
return
}
this.isDay = isDay
detect()
}
override fun setDayResource(res: Int) {
this.day = res
if (isDay.isTrue() && isMember.isFalse()) {
handleResource()
}
}
//...代码省略,其他的方法也是类似的
// 获取适合当前的资源
fun getResourceInt(): Int? {
return when {
isMember.isTrue() -> {
if (memberHasNight) {
when {
isDay.isTrue() -> memberDay
isDay.isFalseStrict() -> memberNight
else -> null
}
} else {
member
}
}
isDay.isTrue() -> {
day
}
isDay.isFalseStrict() -> {
night
}
else -> null
}
}
/** 获取资源,告知外部 */
abstract fun handleResource()
}
class HandleOfDayNightMemberTextColor(private val target: TextView) :
HandleOfDayNightMember(target) {
override fun handleResource() {
val textColor = getResourceInt()?: return
if (textColor <= 0) {
return
}
// 获取皮肤包的资源,假如插件化没有对应的资源或者是未开启换肤或者是换肤失败
// 则会返回当前apk的对应资源
target.setTextColor(XLSkinManager.getColor(textColor))
}
}
目前项目支持的换肤控件:
- DayNightBgConstraintLayout & DayNightMemberRecyclerView & DayNightView:对背景支持四种基础样式的换肤,资源类型支持 drawable & color。
- DayNightLinearLayout & DayNightRelativeLayout:
对背景支持四种基础样式的换肤,资源类型支持 drawable & color。
支持 padding。 - DayNightMemberAliBabaTv,集成自ALIBABABoldTv,是阿里巴巴的字体 Tv:对字体颜色支持四种基础样式的换肤,资源类型为 color。
- DayNightMemberImageView:对 ImageView 的 Source 支持四种基础样式的换肤,资源类型支持 drawable & mipmap。
- DayNightMemberTextView:
对字体颜色支持四种基础样式的换肤,资源类型为 color。
支持 padding。
支持背景换肤,类型为 drawable。
支持 drawableEnd 属性换肤,类型为 drawable。
支持夜间与白天的文字的高亮颜色设置,资源类型为 color。
3.5 资源组织方式
目前项目的支持换肤的资源都是每种资源都独立在一个文件中,存放在最底层的 base 库。换肤的资源都是以 skin 开头,会员的是以skin_member开头,会员夜间的以skin_member_night,夜间以 skin_night 开头。
通过 sourceSets 把资源合并进去。
android {
sourceSets {
main {
res.srcDirs = ['src/main/res','src/main/res-day','src/main/res-night','src/main/res-member']
}
}
}
三、总结 & 展望
经过上线运行,该方案非常稳定,满足了业务的换肤需求。
该方案使用起来,需要自定义支持换肤的 View,使用起来有一定成本。一种低成本接入的可能方案是:无需自定义 View,利用 BindingAdapter 来实现给 View 的属性直接设置皮肤的资源,在 xml 中使用原始的系统 View。ViewModel 中提供一个 theme 属性,xml 中 View 的值都通过该属性的成员变量去拿到。
以上优化思路,感兴趣的读者可以去尝试下。该思路也是笔者下一步的优化方向。