注册

一种基于MVVM的Android换肤方案

一、背景


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


二、目标


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


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


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


三、整体思路


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


3.1 技术选型


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


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


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


@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代码来实现换肤。


整体流程图如下


流程图 (5).jpg


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值。例如:


color->
skin_tab->0x7Fxxxx
skin_text->0x7Fxxxx
dimen->
skin_height->0x7Fxxxx skin_width->0x7fxxxx

具体流程如下


流程图 (4).jpg


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方法的形式,比如


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))
}
}

目前项目支持的换肤控件



  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把资源合并进去


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的值都通过该属性的成员变量去拿到。

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


作者:货拉拉技术
来源:juejin.cn/post/7314587257956417586

0 个评论

要回复文章请先登录注册