一种基于MVVM的Android换肤方案

一、背景

目前市面上很多App都有换肤功能,包括会员 & 非会员 皮肤,日间 & 夜间皮肤,公祭日皮肤。多种皮肤混合的复杂逻辑给端上开发同学带来了不少挑战,本文实践了一种基于MVVM的换肤方案,希望能给有以上换肤需求的同学带来帮助。

二、目标

一个非会员购买会员后,身份是立刻发生了变更。用户点击了App内的暗夜模式按钮后,需要立刻从白天模式,切换到暗夜模式。基于以上原因,换肤的首要目标应该是及时生效的,不需要重启App.

作为一个线上成熟的产品,对稳定性也是有较高要求的 。所以换肤方案是需要觉得稳定的 ,不能因换肤产生Crash & ANR

通常设计图同学会根据不同的时节设计不同的皮肤,例如春节有对应的春节皮肤、周年庆有周年庆皮肤。所以换肤方案还应该保持一定的动态能力,皮肤可以动态下发 。

三、整体思路

基于以上提到的3大目标之一的 动态化换肤。一个可能的实现方案是把需要换肤的图片放入一个独立的工程内,然后把该工程编译出apk安装包, 在主工程加载该安装包。然后再需要获取资源的时候能够加载到皮肤包内的资源即可

3.1 技术选型

目前市场上有很多换肤方案、基本思路总结如下 :

1、通过反射AssertManager的AddAssertPath函数,创建自己的 Resources.然后通过该 Resources获取资源id ;

2、实现LayoutInflater.Factory2接口来替换系统默认的

typescript 复制代码
@Override
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获取具体的资源的过程,首先是资源的定义,以下是换肤能够支持的资源种类

  1. drawable
  2. color
  3. dimen
  4. mipmap
  5. string

目前是打算支持这五种的换肤,使用一个ArrayMap<String, SoftReference<ArrayMap<String, Int>>>来存储具体的缓存数据:key是上面的类型,Entry类型为SoftReference<ArrayMap>,是的对应type所有的缓存数据,每一条缓存数据的key是对应的name值与插件资源对应的Id值。例如:

rust 复制代码
color->
    skin_tab->0x7Fxxxx
    skin_text->0x7Fxxxx
dimen->
    skin_height->0x7Fxxxx    skin_width->0x7fxxxx

具体流程如下

3.2使用资源

然后我们通过get系列(例如XLSkinManager.getString() :String)方法就能够拿得到对应的插件资源(正常情况下),然后就是使用它。

由于之前项目中已经有了一套会员的UI了(就是在项目中的,不是通过皮肤apk下发的),为了改动较少,就把基础换肤设置为4种,即本地自身不通过换肤插件就可以实现的。

  1. 白天非会员
  2. 夜间非会员
  3. 白天会员
  4. 夜间会员

然后我们的apk可以下发对应的资源修改对应的模式,比如需要修改白天非会员的某一个控件的颜色就下发对应的控件资源apk,然后启用该换肤插件即可。

目前项目提供了一系列的接口提供给xml使用,使用过程

  1. 在xml中设置了之后,会触发到对应View的set方法,最终可以设置到最终的View的对应属性中
  2. 同样的,在需要改属性变更的时候(例如白天切换到页面),我们也只需要修改ViewMode变更该xml中对应的ObservableField即可,或者是在View中注册对应的事件(例如白天到夜间的事件)

因为项目深度使用DataBinding,所以我们就通过自定义View的方式,利用了我们可以直接在xml中使用View的set方法的形式,比如

kotlin 复制代码
class DayNightMemberImageView : xxxView{
    fun setDayResource(res: Int){
        //....
    }
}
// 我们就可以在xml中使用该View的dayResource属性
<com.xxx.DayNightMemberImageView
app:dayResource="@{R.color.xxx}"
/>

这样就可以通过传入的Id值,在setDayResource中拿到最终的插件的id值给View设置。具体的例子:

kotlin 复制代码
/** 每一种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?)
}

然后具体的实现类

kotlin 复制代码
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,后者是做了一个优化,避免了一些重复刷新的情况,也会被其他的类复用。

kotlin 复制代码
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))
    }
}

目前项目支持的换肤控件

  1. DayNightBgConstraintLayout & DayNightMemberRecyclerView & DayNightView
  2. 对背景支持四种基础样式的换肤,资源类型支持drawable & color
  3. DayNightLinearLayout & DayNightRelativeLayout
  4. (1) 对背景支持四种基础样式的换肤,资源类型支持drawable & color
  5. (2) 支持padding
  6. DayNightMemberAliBabaTv,集成自ALIBABABoldTv,是阿里巴巴的字体Tv
  7. 对字体颜色支持四种基础样式的换肤,资源类型为color
  8. DayNightMemberImageView
  9. 对ImageView的Source支持四种基础样式的换肤,资源类型支持drawable & mipmap
  10. DayNightMemberTextView
  11. (1)对字体颜色支持四种基础样式的换肤,资源类型为color
  12. (2)支持padding
  13. (3) 支持背景换肤,类型为drawable
  14. (4)支持drawableEnd属性换肤,类型为drawable
  15. (5)支持夜间与白天的文字的高亮颜色设置,资源类型为color

3.4 资源组织 方式

目前项目的支持换肤的资源都是每种资源都独立在一个文件中,存放在最底层的base库。换肤的资源都是以skin开头,会员的是以skin_member开头,会员夜间的以skin_member_night,夜间以skin_night开头。

通过sourceSets把资源合并进去

css 复制代码
android {
    sourceSets {
        main {
            res.srcDirs = ['src/main/res', 'src/main/res-day','src/main/res-night','src/main/res-member']
        }
    }
}

四、总结 & 展望

经过上线运行,该方案非常稳定,满足了业务的换肤需求。

该方案使用起来,需要自定义支持换肤的View ,使用起来有一定成本 。一种低成本接入的可能方案是:

  1. 无需自定义View,利用BindingAdapter来实现给View的属性直接设置皮肤的资源,在xml中使用原始的系统View
  2. ViewModel中提供一个theme属性,xml中View的值都通过该属性的成员变量去拿到。

以上优化思路,感兴趣的读者可以去尝试下。该思路也是笔者下一步的优化方向。

相关推荐
inmK13 小时前
蓝奏云官方版不好用?蓝云最后一版实测:轻量化 + 不限速(避更新坑) 蓝云、蓝奏云第三方安卓版、蓝云最后一版、蓝奏云无广告管理工具、安卓网盘轻量化 APP
android·工具·网盘工具
giaoho3 小时前
Android 热点开发的相关api总结
android
咖啡の猫4 小时前
Android开发-常用布局
android·gitee
程序员老刘5 小时前
Google突然“变脸“,2026年要给全球开发者上“紧箍咒“?
android·flutter·客户端
Tans55 小时前
Androidx Lifecycle 源码阅读笔记
android·android jetpack·源码阅读
雨白5 小时前
实现双向滑动的 ScalableImageView(下)
android
峥嵘life5 小时前
Android Studio新版本编译release版本apk实现
android·ide·android studio
studyForMokey8 小时前
【Android 消息机制】Handler
android
敲代码的鱼哇8 小时前
跳转原生系统设置插件 支持安卓/iOS/鸿蒙UTS组件
android·ios·harmonyos
翻滚丷大头鱼8 小时前
android View详解—动画
android