Demo地址:SKin
本文的思路来自于Databinding+LiveData轻松完成无重启换肤
皮肤描述
咱们在开发中会有各种个款式的皮肤,比方白日
(默许皮肤),夜间
,公祭日
,专属会员
等等的,根据他们的特点,我划分为了2中类型:
- 互斥皮肤,即展现了某一种之后,另外一种就不能展现,比方白日与夜间,白日与会员,即展现了夜间就不展现白日的,展现了会员的就不展现白日的。
- 伴生皮肤,即每个皮肤都会具有的另外一个形状,比方公祭日皮肤,白日,夜间,等级会员等,他们都会有公祭日的皮肤。
以上针对是同一个UI控件的展现来做的区别。同样的,比方关于会员来说,白日会员
与夜间会员
,依照这儿的区别来说是不同的两套皮肤。
换肤计划
本文是根据MVVM中的ViewModel与DataBinding来完成换肤,主要的feature是:
- 无需重启即可换肤
- 代码层次明晰,结构明晰
- 无内存泄漏问题,不会hook体系的
api
,没有if/else
类型的代码块
他的缺陷也是比较显着的:
- 会增加安装包size
- 灵敏度一般,较难做到资源的更新。当然假设咱们能够保护一套资源更新体系那就还能够(后边说)。
主要完成
由于咱们运用的是运用内的换肤,所以咱们有必要先把咱们能够做到的换肤的类型界说出来,比方:
enum class AppThemeType {
DEFAULT,RED, GREEN // 分别是默许类型,赤色,绿色
}
然后,咱们运用gradle
的sourceSets
功用,把一切的皮肤界说在业务代码之外,独立开来,比方改成下面这样的构造:
咱们的一套皮肤中会有本身资源与它的伴生资源,比方skin
中便是有本身的资源与本身对应的伴生皮肤资源,他有多少个伴生皮肤就有多少个伴生资源。
为了让代码层次明晰,结构明晰,咱们需求做一些代码的约好:
- 所以涉及到换肤的资源,都需求写在换肤对应的文件中,包含默许的都需求独立一个资源文件,比方截图的
skin
。 - 咱们的一切xml资源,都需求以自己所属的资源包称号作为前缀,其间伴生资源需求增加
companion
关键字,比方skin
皮肤默许的资源称号最初需求为skin
,比方color
,drawable
,dimen
等,它的伴生皮肤的资源最初需求以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中控件资源。
首要咱们界说个open
类AppBaseTheme
,来设置一些每个页面都运用的资源,比方通用的字体色彩或许其他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中FirstTheme
是FirstFragment
默许的皮肤设置,GreenFirstTheme
是FirstFragment
在skin_green
时分的装备,后者就需求承继前者。
而关于伴生皮肤呢?咱们能够让他承继自默许皮肤的伴生,也能够承继自自己的伴生,看那种能够复用较多用那种即可。比方Demo中的是red
和green
的伴生承继自默许的伴生。
那么咱们怎么给theme
设置目标呢?假设决议运用的皮肤本身仍是他的伴生皮肤呢?首要是设置ViewModel中的theme
,有三种机遇需求设置
- 初始化的时分,读取上一次的装备
- 替换皮肤的时分,比方由白日到黑夜的皮肤。
- 切换伴生的时分,比方我需求展现公祭日,那么无论是那种情况下的皮肤都需求展现他的公祭日款式。
咱们界说一个获取伴生仍是本身的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()
}
}
咱们经过AppThemeController
的isShowCompanion
来决议运用本身仍是伴生款式,咱们的每个皮肤(包含默许),都承继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都需求感知,所以最终咱们的BaseSkinModel
的onCleared
中调用 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>
经过上面的这一套,咱们就能够较完美的完成运用内的换肤功用了,一般过程便是
- 界说皮肤品种,创立对应的文件夹,经过
sourceSets
参加源码中,需求注意的是资源的称号前缀是最好以当时资源称号最初,好做区别和保护。 - 界说
BaseSkinModel
,持有当时皮肤theme
目标,然后再xml中经过引证该目标的特点设置对应的特点值 - 界说皮肤包中对应页面的
AppThemeController
,然后经过他获取当时皮肤的伴生目标或许是本身的Theme
,这些Theme类需求承继自对应页面的默许Theme
,然后复写有需求专归于当时皮肤的装备即可。然后经过对应页面的ViewModel的getSkins
办法回来AppThemeController
的Class。 - 经过观察者形式来注册换肤事情,在不需求的当地清除。
其他
- 某些页面或许不好获取到ViewModel目标,比方咱们的全局Taost设置的布景,色彩等,那么咱们应该怎么处理?我推荐的计划是界说一个
GlobalTheme
,在AppThemeController
中持有该目标,切换资源或许是伴生的时分修改它,然后咱们无法便利读取到ViewModel的当地就经过获取该GlobalTheme
设置资源值,一起注册一个ISkinChange
来感知换肤,伴生切换事情。 - 针对与运用Java的办法来获取资源,而不是xml的情况:比方我某个控件的特点便是需求经过代码设置,那么我推荐的处理计划是:
a. 假设能够获取到他所属页面的ViewModel,就调用该ViewModel目标的
theme
来获取资源,一起注册ISkinChange
来感知换肤,伴生切换事情 b. 假设也较难获取或许无法获取到ViewModel目标,则界说一套与BaseSkinModel
相似的架构来获取theme
。
动态换肤
由于本文运用运用内的换肤,所以一般无法做到资源更新,可是假设是有必要的要做资源的随时可更新,那么我有两个主张
- 运用装备下发的办法完成动态更新,比方咱们在皮肤中界说了一些资源称号,咱们就能够经过接口下发的时分下这些称号对应的资源。读取的时分优先从数据库中读取,不存在咱们再去运用运用内界说的。比方咱们界说了一个
color
,称号为skin_text_color
,那么咱们下发的数据里面,就下发一个skin
皮肤下color
特点的称号为skin_text_color
的可转为色彩的资源(比方#f00
),然后该数据存入数据库中。去读的时分,经过经过id拿到资源的称号,即getResourceEntryName
办法,然后再去获取数据库中对应name
的对应特点的值。图片的也是相似的,假设咱们界说了一个drawable
或许是本地图片,是skin_red
中的资源,称号为skin_red_bg
。咱们下发数据的时分,就下发一个skin_red
中drawable
特点的称号为
skin_red_bg,值为
xxx的数据,然后刺进数据库,一起下发一张图片称号也为
xxx。咱们的空间设置资源的时分经过
@BindingAdapter完成,
Theme中回来一个
Drawable目标,有限读取本地对应皮肤的
skin_red_bg特点的值,这儿的话便是
xxx,然后获取该图片回来一个
Drawable`目标,假设不存在的话就运用运用内的。 - 运用插件化,比方现在的Android-Skin-Loader和Android-skin-support结构。运用他们作为一个兜底策略,经过开关控制。翻开的时分,咱们一切
Theme
中的资源的获取都经过结构去读取而不是咱们直接去获取,然后咱们还需求在换肤事情/伴生切换去重新经过结构更新资源。当然他或许还存在一些其他的兼容性问题,究竟运用了反射去hook体系的api。还有一点便是运用结构的时分不主张hook体系的setFacotry
办法,咱们只用结构的获取资源的办法即可,这样能防止少出一些体系兼容的问题,咱们只用结构读取资源即可,设置空间的特点仍是能够经过DataBinding
设置。
本文只是重在讲述换肤的计划,详细的完成大家也能够详细去发挥,大致的思路便是不同的皮肤不同文件去办理,然后获取不同文件中的资源进行设置。