Demo地址:SKin

本文的思路来自于Databinding+LiveData轻松完成无重启换肤

基于MVVM的换肤方案

皮肤描述

咱们在开发中会有各种个款式的皮肤,比方白日(默许皮肤),夜间公祭日专属会员等等的,根据他们的特点,我划分为了2中类型:

  1. 互斥皮肤,即展现了某一种之后,另外一种就不能展现,比方白日与夜间,白日与会员,即展现了夜间就不展现白日的,展现了会员的就不展现白日的。
  2. 伴生皮肤,即每个皮肤都会具有的另外一个形状,比方公祭日皮肤,白日,夜间,等级会员等,他们都会有公祭日的皮肤。

以上针对是同一个UI控件的展现来做的区别。同样的,比方关于会员来说,白日会员夜间会员,依照这儿的区别来说是不同的两套皮肤。

换肤计划

本文是根据MVVM中的ViewModel与DataBinding来完成换肤,主要的feature是:

  1. 无需重启即可换肤
  2. 代码层次明晰,结构明晰
  3. 无内存泄漏问题,不会hook体系的api,没有if/else类型的代码块

他的缺陷也是比较显着的:

  1. 会增加安装包size
  2. 灵敏度一般,较难做到资源的更新。当然假设咱们能够保护一套资源更新体系那就还能够(后边说)。

主要完成

由于咱们运用的是运用内的换肤,所以咱们有必要先把咱们能够做到的换肤的类型界说出来,比方:

enum class AppThemeType {
    DEFAULT,RED, GREEN // 分别是默许类型,赤色,绿色
}

然后,咱们运用gradlesourceSets功用,把一切的皮肤界说在业务代码之外,独立开来,比方改成下面这样的构造:

基于MVVM的换肤方案

咱们的一套皮肤中会有本身资源与它的伴生资源,比方skin中便是有本身的资源与本身对应的伴生皮肤资源,他有多少个伴生皮肤就有多少个伴生资源。

为了让代码层次明晰,结构明晰,咱们需求做一些代码的约好:

  1. 所以涉及到换肤的资源,都需求写在换肤对应的文件中,包含默许的都需求独立一个资源文件,比方截图的skin
  2. 咱们的一切xml资源,都需求以自己所属的资源包称号作为前缀,其间伴生资源需求增加companion关键字,比方skin皮肤默许的资源称号最初需求为skin,比方colordrawabledimen等,它的伴生皮肤的资源最初需求以skin_companion最初。

最终,经过sourceSets把皮肤中的代码和资源兼并进去,例如:

sourceSets {
        main {
            res.srcDirs = ['src/main/res', 'src/skin-red/main/res', 'src/skin-green/main/res']
            java.srcDirs = ['src/main/java', 'src/skin-red/main/java', 'src/skin-green/main/java']
        }
    }

咱们是经过DataBinding来完成的换肤,所以咱们是在xml中刺进java代码来完成对资源的运用,假设控件不支撑咱们也能够运用@BindingAdapter改造来到达xml中运用代码的意图。

目前咱们的皮肤包中是有自己对应的资源与伴生资源,那么咱们就需求读取他们。咱们运用的是ViewModel,每个页面都有自己的ViewModel,咱们经过ViewModel中持有ObservableField<Theme>的办法,在初始化设置默许的ObservableField<Theme> or 后续替换皮肤/切换伴生形式的时分设置ObservableField<Theme>,来更新到xml中控件资源。

首要咱们界说个openAppBaseTheme,来设置一些每个页面都运用的资源,比方通用的字体色彩或许其他icon等,咱们的一切Theme都承继自改类。

然后咱们给每一个需求有换肤的页面(Activity/Fragment)界说一个专属的AppTheme,然后在发起换肤的时分就更改该AppTheme值。由于都需求,所以界说一个BaseSkinModel类,需求换肤的页面的ViewModel都需求承继自改类。

abstract class BaseSkinModel<T : AppBaseTheme> : ViewModel() {
    val theme = ObservableField<T>()
}

咱们在xml中就能够读取theme变量来设置xml中的特点,比方字体色彩,大小,布景等的xml特点,假设控件不支撑的能够经过扩展@BindingAdapter来设置。

那么咱们怎么获取每个皮肤的对应的Theme目标呢?是获取当时皮肤的Theme仍是伴生的Theme?做法便是咱们会在每一个皮肤中的java文件夹下,界说对应页面的对应皮肤的AppTheme,他们承继自主工程的对应页面Theme。比方Demo中FirstThemeFirstFragment默许的皮肤设置,GreenFirstThemeFirstFragmentskin_green时分的装备,后者就需求承继前者。

而关于伴生皮肤呢?咱们能够让他承继自默许皮肤的伴生,也能够承继自自己的伴生,看那种能够复用较多用那种即可。比方Demo中的是redgreen的伴生承继自默许的伴生。

那么咱们怎么给theme设置目标呢?假设决议运用的皮肤本身仍是他的伴生皮肤呢?首要是设置ViewModel中的theme,有三种机遇需求设置

  1. 初始化的时分,读取上一次的装备
  2. 替换皮肤的时分,比方由白日到黑夜的皮肤。
  3. 切换伴生的时分,比方我需求展现公祭日,那么无论是那种情况下的皮肤都需求展现他的公祭日款式。

咱们界说一个获取伴生仍是本身的Theme的接口

interface IAppBaseTheme<T : AppBaseTheme> {
    /**
     * 当时主题
     * @return
     */
    fun theme(): T
    /**
     * 伴生主题,相似,优先级比theme高,当敞开了之后有限运用companionTheme
     * @return
     */
    fun companionTheme(): T
}

然后界说一个决议是运用伴生仍是本身的抽象类,承继了IAppBaseTheme

abstract class AppBaseThemeOwner<T : AppBaseTheme> : IAppBaseTheme<T> {
    /**
     * 获取主题
     * @return
     */
    fun getTheme(): T {
        return if (AppThemeController.isShowCompanion) companionTheme() else theme()
    }
}

咱们经过AppThemeControllerisShowCompanion来决议运用本身仍是伴生款式,咱们的每个皮肤(包含默许),都承继AppBaseThemeOwner,去完成IAppBaseTheme接口,回来对应本身以及伴生的皮肤款式目标。比方Demo中的FirstThemeOwner

open class FirstThemeOwner : AppBaseThemeOwner<FirstTheme>() {
    override fun theme(): FirstTheme {
        return FirstTheme()
    }
    override fun companionTheme(): FirstThemeCompanionTheme {
        return FirstThemeCompanionTheme()
    }
}
open class FirstTheme : AppBaseTheme() {
    open val btnTextColor = ResUtil.getColor(R.color.skin_btn_text_color)
}
open class FirstThemeCompanionTheme : FirstTheme() {
    override val btnTextColor = ResUtil.getColor(R.color.skin_companion_btn_text_color)
}

然后便是咱们需求在ViewModel中获取详细的Theme了,设置目标咱们能够new,也能够经过反射的办法,随意。这儿的Demo运用了反射,咱们在BaseSkinModel界说一个抽象办法来回来不同皮肤对应的AppBaseThemeOwner的class,然后发射生成AppBaseThemeOwner目标,调用他的getTheme()办法,

abstract class BaseSkinModel<T : AppBaseTheme> : ViewModel(), ISkinChange {
    val theme = ObservableField<T>()
    init {
        AppThemeController.registerSkinChange(this)
        val now = getSkins()[AppThemeController.getCurrent()]
        kotlin.runCatching { now?.newInstance() }
            .onFailure { it.printStackTrace() }
            .getOrNull()?.let {
                theme.set(it.getTheme())
            }
    }
    override fun onCleared() {
        super.onCleared()
        AppThemeController.unregisterSkinChange(this)
    }
    override fun onSkinChange(theme: AppThemeType) {
        val skins = getSkins()
        if (skins.isEmpty()) {
            return
        }
        val target = skins[theme] ?: return
        kotlin.runCatching {
            target.newInstance()
        }.onFailure {
            it.printStackTrace()
        }.getOrNull()?.let {
            this.theme.set(it.getTheme())
        }
    }
    /**
    * 回来对应皮肤的AppBaseThemeOwner的Class集合,经过发射生成AppBaseThemeOwner目标,然后调研getTheme办法获取详细的皮肤Theme目标
    */
    abstract fun getSkins(): Map<AppThemeType, Class<out AppBaseThemeOwner<T>>>
}

比方在完成类中,比方FirstViewModel

class FirstViewModel : BaseSkinModel<FirstTheme>() {
    override fun getSkins(): Map<AppThemeType, Class<out FirstTheme>> {
        return mapOf(
            AppThemeType.DEFAULT to FirstTheme::class.java,
            AppThemeType.RED to RedFirstTheme::class.java,
            AppThemeType.GREEN to GreenFirstTheme::class.java
        )
    }
}

这样咱们的FirstFragment就支撑了3中换肤了,其间每种换肤本身又有伴生皮肤。这儿看到初始化的时分,theme的值是经过反射生成的。

处理完成了初始化之后,咱们再处理更新的问题:即换肤的时分怎么告诉到每个页面的theme,以及切换伴生的时分怎么告诉。 咱们能够经过观察着形式来完成,例如

object AppThemeController {
    // 当时形式
    private var currentMode = AppThemeType.RED
    var isShowCompanion = false
    val globalTheme by lazy { MutableLiveData<GlobalTheme>() }
    private val mListener = mutableListOf<ISkinChange>()
    fun getCurrent() = currentMode
    @Synchronized
    fun registerSkinChange(listener: ISkinChange) {
        mListener.add(listener)
    }
    @Synchronized
    fun unregisterSkinChange(listener: ISkinChange) {
        mListener.remove(listener)
    }
    /**
     * 切换伴生皮肤
     */
    fun changeCompanion() {
        isShowCompanion = !isShowCompanion
        changeSkin(currentMode)
    }
    @Synchronized
    fun changeSkin(newTheme: AppThemeType) {
        // TODO 假设需求globalTheme,能够在这儿设置
        currentMode = newTheme
        if (mListener.isEmpty()) {
            return
        }
        mListener.forEach {
            it.onSkinChange(newTheme)
        }
    }
}
fun interface ISkinChange {
    fun onSkinChange(theme: AppThemeType)
}
open class GlobalTheme : AppBaseTheme() {
    // 能够设置一些不跟随页面变化的主题数
    // 不同的主题能够有不同的GlobalTheme
    // 子主题复写当时类即可
}

咱们界说了AppThemeController,需求感知换肤时间的能够经过注册来得知,经过反注册来防止内存走漏,而咱们的每个需求换肤的ViewModel都需求感知,所以最终咱们的BaseSkinModelonCleared中调用 AppThemeController.unregisterSkinChange(this),防止内存走漏。

同样的,伴生皮肤的更新也是经过观察者形式完成。

详细的运用便是ViewModel目标注入到了xml中,然后再xml中调用咱们的theme来运用资源,比方

...
    <data>
        <variable
            name="vm"
            type="com.example.daynightmode.FirstViewModel" />
    </data>
...
        <TextView
            android:id="@+id/textview_first"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/hello_first_fragment"
            app:layout_constraintBottom_toTopOf="@id/button_first"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:tvColor="@{vm.theme.textColor}" />
        <Button
            android:id="@+id/button_first"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/next"
            android:textColor="@{vm.theme.btnTextColor}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/button_first" />
...
</layout>

经过上面的这一套,咱们就能够较完美的完成运用内的换肤功用了,一般过程便是

  1. 界说皮肤品种,创立对应的文件夹,经过sourceSets参加源码中,需求注意的是资源的称号前缀是最好以当时资源称号最初,好做区别和保护。
  2. 界说BaseSkinModel,持有当时皮肤theme目标,然后再xml中经过引证该目标的特点设置对应的特点值
  3. 界说皮肤包中对应页面的AppThemeController,然后经过他获取当时皮肤的伴生目标或许是本身的Theme,这些Theme类需求承继自对应页面的默许Theme,然后复写有需求专归于当时皮肤的装备即可。然后经过对应页面的ViewModel的getSkins办法回来AppThemeController的Class。
  4. 经过观察者形式来注册换肤事情,在不需求的当地清除。

其他

  1. 某些页面或许不好获取到ViewModel目标,比方咱们的全局Taost设置的布景,色彩等,那么咱们应该怎么处理?我推荐的计划是界说一个GlobalTheme,在AppThemeController中持有该目标,切换资源或许是伴生的时分修改它,然后咱们无法便利读取到ViewModel的当地就经过获取该GlobalTheme设置资源值,一起注册一个ISkinChange来感知换肤,伴生切换事情。
  2. 针对与运用Java的办法来获取资源,而不是xml的情况:比方我某个控件的特点便是需求经过代码设置,那么我推荐的处理计划是: a. 假设能够获取到他所属页面的ViewModel,就调用该ViewModel目标的theme来获取资源,一起注册ISkinChange来感知换肤,伴生切换事情 b. 假设也较难获取或许无法获取到ViewModel目标,则界说一套与BaseSkinModel相似的架构来获取theme

动态换肤

由于本文运用运用内的换肤,所以一般无法做到资源更新,可是假设是有必要的要做资源的随时可更新,那么我有两个主张

  1. 运用装备下发的办法完成动态更新,比方咱们在皮肤中界说了一些资源称号,咱们就能够经过接口下发的时分下这些称号对应的资源。读取的时分优先从数据库中读取,不存在咱们再去运用运用内界说的。比方咱们界说了一个color,称号为skin_text_color,那么咱们下发的数据里面,就下发一个skin皮肤下color特点的称号为skin_text_color的可转为色彩的资源(比方#f00),然后该数据存入数据库中。去读的时分,经过经过id拿到资源的称号,即getResourceEntryName办法,然后再去获取数据库中对应name的对应特点的值。图片的也是相似的,假设咱们界说了一个drawable或许是本地图片,是skin_red中的资源,称号为skin_red_bg。咱们下发数据的时分,就下发一个skin_reddrawable特点的称号为skin_red_bg,值为xxx的数据,然后刺进数据库,一起下发一张图片称号也为xxx。咱们的空间设置资源的时分经过@BindingAdapter完成,Theme中回来一个Drawable目标,有限读取本地对应皮肤的skin_red_bg特点的值,这儿的话便是xxx,然后获取该图片回来一个Drawable`目标,假设不存在的话就运用运用内的。
  2. 运用插件化,比方现在的Android-Skin-Loader和Android-skin-support结构。运用他们作为一个兜底策略,经过开关控制。翻开的时分,咱们一切Theme中的资源的获取都经过结构去读取而不是咱们直接去获取,然后咱们还需求在换肤事情/伴生切换去重新经过结构更新资源。当然他或许还存在一些其他的兼容性问题,究竟运用了反射去hook体系的api。还有一点便是运用结构的时分不主张hook体系的setFacotry办法,咱们只用结构的获取资源的办法即可,这样能防止少出一些体系兼容的问题,咱们只用结构读取资源即可,设置空间的特点仍是能够经过DataBinding设置。

本文只是重在讲述换肤的计划,详细的完成大家也能够详细去发挥,大致的思路便是不同的皮肤不同文件去办理,然后获取不同文件中的资源进行设置。