[Gadget]Android换肤框架与思路分享

1,630 阅读17分钟

前言

最近对gadgets做了迭代,代码更新得差不多了但是该文档还没更新完(能看到这段话就代表还没更新完哈),笔者会尽快更新上去的,在此告知,望请见谅~

目前Android有很多成熟的换肤框架或方案,网上也有很多相关的文章,笔者在这里就不对这些框架作过多介绍了,主要是分享一下自己实现的一款换肤框架和思路,希望能和各位友好交流并得到指点,如果能再给个star就更好了~

简介

gadgets 是笔者开发的一个工具集,其中的 gadget-theme 便是本文的主角,作为一个换肤框架,它具有以下特点:

  • 侵入性低:除了一行hook代码,对项目无其他侵入性改动,开发者无感知,不想用了能很快移除。
  • 代码量少:截止 9a74b030a8 版本,代码量不到800行。
  • 扩展性强:可根据需要对各种view提供各种换肤支持。
  • 逐级分发:同一个页面可以分发不同的皮肤资源(比如皮肤预览界面能同时看到多个不同皮肤的示例效果)。

当然,它必然不会是完美的,不可能满足所有策划的需求,但笔者开发时尽可能保证了扩展性,能让你轻松地在这个框架基础上修改或迭代出满足你需求的功能。

示例项目介绍

gadget-theme-sample.gif

在介绍框架前先说一下示例项目,方便后续对文章内容的理解:

不关心框架相关介绍或想先了解怎么使用的,可以跳到最后一节 使用

  1. 示例项目的默认皮肤主题是明亮色。
  2. 示例项目会有一个暗黑色主题和一个diy主题加入,作为换肤场景的讨论内容。
  3. 为了区分换肤资源与非换肤资源,换肤资源的命名会带有前缀,示例项目里的前缀是 theme__。(这应该是正常的业务流程,至少笔者参与过的换肤项目都有对换肤资源加上前缀,用来区分非换肤资源并统一管理,所以这里笔者不认为这是侵入性的操作)

框架介绍

Drawing 2024-12-07 15.48.15.png

框架内有6个主要成员:

  • ThemeFactory:hook .xml 文件的 inflate 过程,根据 ThemeConfig 解析出 ThemeAttribute
  • ThemeConfig:定义皮肤资源的前缀和 ThemeAttribute 模板。
  • ThemeAttribute:换肤属性,在切换主题时做出响应,修改目标 view 的 ui 属性。
  • ThemeObject:连接目标 view 和 ThemeAttribute 的桥梁,通过 tag 绑定。
  • ThemeDispatcher:主题分发者,可以是 Context 也可以是 View ,管理该层级以下的主题。
  • Theme:皮肤包,或者说主题包的实例化对象,对外提供访问资源的接口,如 colordrawable 等。

流程介绍

inflate 流程

  1. 替换 Activity 的 LayoutInflater.Factory2,hook 布局文件的 inflate 过程。
class MainActivity : AppCompatActivity() {
    override fun onCreate(saveInstanceState: Bundle?) {
        val originFactory = layoutInflater.factory2
        layoutInflater.factory2 = ThemeFactory(originFactory, ThemeConfig)
        
        super.onCreate(saveInstanceState)
    }
}
  1. Factory2onCreateView 方法能获取到“将要实例化的 View”的各项属性,在这一步根据属性引用的资源是否是“换肤资源(即资源名是否带有 ThemeConfig 中的前缀)”,判断这一项属性是否“需要换肤支持”,如果是,则会从 ThemeConfig 中找到对应 ThemeAttribute 模板并 clone 一份。
protected open fun parseThemeAttributes(
    name: String, context: Context, attrs: AttributeSet
): MutableList<ThemeAttribute>? {
    var themeAttributes: ArrayList<ThemeAttribute>? = null
    if (config != null && config.prefix.isNotEmpty() && config.attributes.isNotEmpty()) {
        for (index in 0 until attrs.attributeCount) {
            val attributeName = attrs.getAttributeName(index) ?: continue
            val attributeValue = attrs.getAttributeValue(index) ?: continue
            // 判断是否是引用类型的资源,如果是常量值则跳过。
            if (!attributeValue.startsWith('@')) continue
            val resourceId = attributeValue.substring(1).toIntOrNull() ?: continue
            val resourceName: String
            val resourceType: String
            try {
                resourceName = context.resources.getResourceEntryName(resourceId)
                resourceType = context.resources.getResourceTypeName(resourceId)
            } catch (e: Resources.NotFoundException) {
                e.printStackTrace()
                continue
            }
            // 判断是否是带有前缀的换肤资源,如果不是则跳过。
            if (!resourceName.startsWith(config.prefix)) continue
            // 从模板 clone 一份 ThemeAttribute 实例留给这个 View 使用。
            val attribute = config.obtainAttribute(attributeName, resourceId, resourceName, resourceType)
            if (attribute != null) {
                if (themeAttributes == null) {
                    themeAttributes = ArrayList(2)
                }
                themeAttributes.add(attribute)
            }
        }
    }
    return themeAttributes
}
  1. Factory2onCreateView 方法会实例化 View(也可能为空,为空的情况下文会讨论),将上一步解析出的 List<ThemeAttribute>(如果不为空)和这一步得到的 View,通过 ThemeObject 连接起来,并绑定到 View 的 tag
protected open fun bindThemeObject(view: View?, themeAttributes: MutableList<ThemeAttribute>?) {
    if (view != null && !themeAttributes.isNullOrEmpty()) {
        ThemeObject.bind(view).addThemeAttributes(themeAttributes)
    }
}
  1. 到此,inflate 流程结束,这个框架对项目唯一的侵入就是第1步中的替换 LayoutInflater.Factory2,后续工作都可以交由框架完成。

换肤流程

  1. 当 View attached的时候,绑定的 ThemeObject 会沿着视图树逐层向上查找,直到找到一个实现了 ThemeDispatcher 接口的实例(可能是 Context,也可能是 ViewParent,甚至可能自己就是那个实例)。找到后会监听该实例的主题分发接口,在后续主题切换时能及时做出响应。
private fun findThemeDispatcher(): ThemeDispatcher {
    // 如果绑定的 View 本身就是 ThemeDispatcher,直接返回。
    if (view is ThemeDispatcher) return view

    // 沿着视图树往上,尝试找到一个最近的实现了 ThemeDispatcher 的 ViewParent实例。
    var parent = view.parent
    while (parent != null) {
        if (parent is ThemeDispatcher) {
            return parent
        }
        if (parent is View) {
            // 当然为了减少不必要的追溯,如果上层有的 ViewParent 已经追溯到了 ThemeDispatcher 实例,
            // 那么直接用它的就可以了,否则极端情况下每个 View 都要找到 DecorView 去就没必要了。
            get(parent)?.themeDispatcher?.let { return it }
        }
        parent = parent.parent
    }

    // 视图树上确实没找到,那么对 ContextWrapper 逐层解包装,
    // 尝试找到一个实现了 ThemeDispatcher 的 Context 实例
    var context = view.context
    while (context is ContextWrapper) {
        if (context is ThemeDispatcher) {
            return context
        }
        context = context.baseContext
    }
    
    // 这里其实可以支持返回 Null,但我思考后还是直接抛出异常比较好,
    // 因为开发者应该清楚某个页面是否支持换肤。
    throw IllegalStateException("Can't find theme dispatcher for $view!")
}

override fun onViewAttachedToWindow(v: View) {
    // 当 View attached 时订阅主题分发接口,当然 detached 的时候也会移除的。
    if (themeDispatcher == null) {
        themeDispatcher = findThemeDispatcher().also {
            it.observableTheme().observeForever(this)
        }
    }
}
  1. 主题分发者 ThemeDispatcher 更换了主题,对下层视图发出事件通知,这里我用的是 LiveData 实现的,因为它简单,当然想拓展或修改成其他驱动方式(Flow、RxJava、Broadcast)都可以,只要事件能发出去被收到就行。
// 没错,它就只有一个接口
interface ThemeDispatcher {
    fun observableTheme(): LiveData<Theme>
}
  1. 主题事件下来被 ThemeObject 接收到之后,就会调用 ThemeAttribute 对 View 进行处理。
override fun onChanged(theme: Theme?) {
    if (theme == null) return
    if (this.theme == theme && this.version >= theme.version) return
    if (this.theme == null && theme.isOrigin) {
        // 默认布局不处理,因为 View inflate 的时候已经用默认资源初始化UI了,这里不二次操作。
        this.theme = theme
        this.version = theme.version
    } else {
        this.theme = theme
        this.version = theme.version
        // 对这个 View 支持的换肤属性依次调用,改变它的UI
        themeAttributes.forEach { (_, attribute) -> attribute.apply(view, theme) }
    }
}
  1. 到此,换肤流程结束。

小结

其实目前流行的那些基于 Hook LayoutInflater.Factory 方案的换肤框架,底层逻辑都和笔者这个框架差不多,对比那些框架,笔者的框架在流程上主要做了如下一些小优化:

  • 不需要在 xml 布局里使用类似 skin:enable="true" 的代码,让开发者无感知。
  • 不需要手动管理换肤的 View 对象了。有些框架可能会用一个管理类专门负责换肤 View 的存储&移除操作,而本框架将其交由 View 的 tag 处理,随其生随其亡。

笔者不敢轻言说自己的框架就一定和那些框架一样好甚至更好,但希望通过本框架及流程的分享,或多或少能对初次接触换肤的开发者提供一点帮助。

设计思路与代码解析

ThemeFactory

回到一切的开始,看下 ThemeFactory 在 inflate 阶段具体做了什么(关于hook的内容网上很多相关的文章,这里不做重复介绍)。

open class ThemeFactory(
    private val factory: LayoutInflater.Factory2?,
    private val config: ThemeConfig?,
) : LayoutInflater.Factory2 {

    // LayoutInflater 调用的入口。
    final override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
        // 解析出支持换肤的属性。
        val themeAttributes = parseThemeAttributes(name, context, attrs)
        // 尝试实例化 View。
        val view = onCreateView(parent, name, context, attrs, themeAttributes)
        // 将 View 和 换肤属性连接起来并绑定到一个 ThemeObject 去。
        bindThemeObject(view, themeAttributes)
        return view
    }

    // Parent-Delegation,让上层先尝试实例化。
    fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet, themeAttributes: List<ThemeAttribute>?): View? =
        if (factory is ThemeFactory) {
            factory.onCreateView(parent, name, context, attrs, themeAttributes)
        } else {
            factory?.onCreateView(parent, name, context, attrs)
        } ?: createView(parent, name, context, attrs, themeAttributes)

    // 如果上层都实例化不出来,如果换肤属性不为空,那么这里再稍微尝试下手动初始化。
    protected open fun createView(parent: View?, name: String, context: Context, attrs: AttributeSet, themeAttributes: List<ThemeAttribute>?): View? {
        if (themeAttributes.isNullOrEmpty()) return null
        return try {
            val layoutInflater = LayoutInflater.from(context)
            if (!name.contains('.'))
                layoutInflater.createView(name, "android.widget.", attrs)
                    ?: layoutInflater.createView(name, "android.view.", attrs)
                    ?: layoutInflater.createView(name, "android.webkit.", attrs)
            else
                layoutInflater.createView(name, null, attrs)
        } catch (e: Exception) {
            null
        }
    }

    protected open fun parseThemeAttributes(name: String, context: Context, attrs: AttributeSet): MutableList<ThemeAttribute>? {
        // 上文介绍过了这里不重复说明。
    }

    protected open fun bindThemeObject(view: View?, themeAttributes: MutableList<ThemeAttribute>?) {
        // 上文介绍过了这里不重复说明。
    }
}

这里的设计笔者使用了 Parent-Delegation 的模型,也就是所谓的 “双亲委派”机制(吐槽下,笔者不太理解为啥是这个翻译),在实例化 View 的时候,都会优先交由上层 Factory 去处理,最后实在无法实例化,才会尝试通过 createView 方法去实例化,比如下面这个例子:

class MainActivity : AppCompatActivity() {
    override fun onCreate(saveInstanceState: Bundle?) {
        val originFactory = layoutInflater.factory2
        layoutInflater.factory2 = ThemeFactory(originFactory, ThemeConfig)
        
        super.onCreate(saveInstanceState)
    }
}

把 Activity 原来的 Factory 传了进去,在 inflate 时最先尝试实例化的就是 Activity 原来的 Factory。如果开发者对于实例化没有把握(毕竟这一步是有可能为空的),那么可以也传入自己实现的 Factory2,扩展性+1。

ThemeConfig

这是个什么东西?其实很简单:

open class ThemeConfig(
    val prefix: String,
    val attributes: List<ThemeAttribute>,
) {

    protected open val attributesMap = attributes.associateBy { it.attributeName }

    open fun obtainAttribute(attributeName: String, resourceId: Int, resourceName: String, resourceType: String): ThemeAttribute? =
        attributesMap[attributeName]?.copy(resourceId, resourceName, resourceType)
}

它主要有2个成员(说3个也可以)

  • prefix:换肤资源的命名前缀,前文有介绍过,在 inflate 阶段解析 xml 属性的时候,判断一个属性引用的资源是否是换肤资源,进而确定这个属性是否是换肤属性,最后得出结论这个 View 是否支持换肤(这一步就可以优化掉skin:enable="true"的使用)。
  • attributes:换肤属性模板(具体的马上就介绍),用属性名 attributeName 查找对应的属性模板,然后 clone 一份,最终绑定给 View 使用。

ThemeAttribute

先上个例子

<androidx.appcompat.widget.AppCompatTextView
    android:id="@+id/Name"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/theme__text_color"/>
class TextColor : ThemeAttribute(attributeName = "textColor") {
    override fun apply(view: View, theme: Theme) {
        val color = theme.getColor(resourceId) ?: return
        when (view) {
            is TextView -> {
                if (color is ColorStateList) {
                    view.setTextColor(color)
                } else if (color is Int) {
                    view.setTextColor(color)
                }
            }
        }
    }
}

有这么一个 xml 文件,里面的 TextView 的字体颜色需要支持换肤,那么要怎么实现呢?那个 TextColor 类就是一个换肤属性模板,它存在于 ThemeConfig.attributesMap 中,在 inflate 阶段解析出这个 TextView 有个 android:textColor 属性使用了换肤资源 theme__text_color,所以用属性名 textColorThemeConfig.attributesMap 中找到了 TextColor 这个模板,然后 copy 一个实例(本质是 clone,同时还把解析出来的 resourceId、resourceName、resourceType 等也 copy 过去了)并绑定给这个 TextView,在后续换肤事件下来时最终会调用到这个 TextColorapply 方法,执行具体的换肤操作,这里就是更改字体的颜色。

小结

ThemeFactoryThemeConfigThemeAttribute 这3个角色的配合下,在 inflate 阶段我们得到了一个 View 和它的换肤属性 List<ThemeAttribute>。接下来要介绍的 ThemeObject 连接了 inflate 流程和换肤流程。

ThemeObject

我们先看它是如何将 View 和换肤属性绑定的(可回头看 ThemeFactory.bindThemeObject 方法)。

class ThemeObject private constructor(val view: View) : View.OnAttachStateChangeListener, Observer<Theme> {

    companion object {
        @JvmField
        val TAG_ID = R.id.gadgets_theme_object

        @JvmStatic
        fun get(view: View): ThemeObject? = view.getTag(TAG_ID) as? ThemeObject

        @JvmStatic
        fun bind(view: View): ThemeObject {
            val tag = view.getTag(TAG_ID)
            if (tag != null) {
                if (tag is ThemeObject) {
                    return tag
                } else {
                    throw IllegalStateException("$view already bind an object($tag)!")
                }
            }
            return ThemeObject(view)
        }

        @JvmStatic
        fun unbind(view: View) {
            get(view)?.release()
        }
    }

    private val themeAttributes = HashMap<String, ThemeAttribute>(2)

    fun addThemeAttribute(attribute: ThemeAttribute) = apply {
        themeAttributes[attribute.attributeName] = attribute
        theme?.let { attribute.apply(view, it) }
    }
}

很简单的逻辑,ThemeObject 持有了 View 和 attributes,并将自身绑定到 View 的 tag 中去,最终完成了三者间的联系。

从这里我们也可以延伸出如何在运行时动态地让一个 View 支持换肤,可以思考下怎么做。

答案:其实就是参考用 ThemeFactory.bindThemeObject 里的写法就可以了。

至于 ThemeObject 换肤部分的工作,前文讲换肤流程的时候已经介绍完了,是的,没有更多内容,就是简单的沿视图树逐层往上直到找到一个 ThemeDispatcher 实例并监听它的事件分发接口,事件下来后一个个地调用 List<ThemeAttribute> 里的元素的 apply 方法。

ThemeDispatcher

最简单的一个成员,没有之一,它的作用就是管理视图树上自它这一层往下(直到另一个 ThemeDispatcher)所有 View 的主题,当你想改变这一个层或者说这一个页面的主题时,调用它的分发接口抛出一个新的主题就可以了。

Theme

最后一个成员,一个把“皮肤主题”这么一个抽象的概念给具象化的成员,简单概括它的作用就是通过它可以拿到该皮肤下对应的资源,代码比较少直接全贴上来了。

abstract class Theme @MainThread constructor(
    val name: String,
    val parent: Theme?,
) {
    internal companion object : AtomicReference<Theme>() {
        const val START_VERSION = -1
    }

    val isOrigin: Boolean = parent == null

    var version: Int = START_VERSION + 1; protected set

    protected val children = ArrayList<Theme>()


    init {
        if (parent == null) {
            val origin = Theme.getAndSet(this)
            if (origin != null) {
                throw IllegalStateException("Can only have 1 original theme! Otherwise it should have a parent theme.")
            }
        } else {
            parent.children.add(this)
        }
    }


    @MainThread
    fun upgrade() {
        ++version
        children.forEach { child -> child.upgrade() }
    }

    /**
     * @return ColorStateList or Int or null(not found).
     */
    abstract fun getColor(@ColorRes id: Int): Any?

    abstract fun getColorInt(@ColorRes id: Int): Int

    abstract fun getColorStateList(@ColorRes id: Int): ColorStateList?

    abstract fun getDrawable(@DrawableRes id: Int): Drawable?

    abstract fun getString(@StringRes id: Int, vararg formatArgs: Any): String?
}

这里有两个设计点:父主题和版本。

父主题

除了默认皮肤主题(isOrigin == true)之外,每个皮肤主题都会且应该有一个父主题,当这个皮肤主题找不到所需要的资源的时候就会向上找父主题拿。这样设计有两个好处:

  1. 避免因为找不到资源而导致的异常问题。
  2. 减少子主题的皮肤包大小,比如新上线的主题在设计上和默认主题只有首页背景图案不一样,那么这个新主题可以只包含一张背景图,剩下的资源通通找默认主题拿。
版本

参考了 LiveData 里的版本设计,主要有两个作用:

  1. 默认情况下 ThemeDispatcher 连续分发相同的主题是不会重复触发 ThemeObject 的换肤流程的,这样可以避免不必要的刷新。但某些情况下(比如出现bug需要强制刷新换肤来紧急处理)想要强制触发,可以通过 upgrade 升级后再分发主题,这时 THemeObject 就会触发换肤流程。
  2. 某个主题的资源发生改变(看需求,一般不会有这种场景,但谁知道呢),而它的子主题恰巧引用了这个资源,这个主题通过升级刷新UI后,也要依次升级其子主题(但后续触发子主题刷新的逻辑没做,毕竟一般不会有这种场景)。

最后留意到后面的5个抽象方法(笔者本来只打算提供3个,后迫于需求把 color 拆成了3个),主要就是提供了最常用的3种资源的获取接口(color、drawable、string)。如果你的需求里还需要其他的资源(比如字体),这么简单的框架尽管扩展就行,扩展性+1。下面举个例子看下具体是怎么提供的资源的:

open class ResourceTheme @MainThread constructor(
    name: String,
    parent: Theme?,
    val resources: Resources,
) : Theme(name, parent) {

    open fun getIdentifier(@AnyRes id: Int): Int {
        if (isOrigin) return id
        return idCache.getOrPut(id) {
            try {
                val name = APPLICATION.resources.getResourceEntryName(id)
                val type = APPLICATION.resources.getResourceTypeName(id)
                resources.getIdentifier(name, type, themeId)
            } catch (throwable: Throwable) {
                ResourcesCompat.ID_NULL
            }
        }
    }

    override fun getColor(id: Int): Any? {
        val id2 = getIdentifier(id)
        if (id2 == ResourcesCompat.ID_NULL) {
            return parent?.getColor(id)
        }
        val colorStateList = try {
            ResourcesCompat.getColorStateList(resources, id2, null)
        } catch (throwable: Throwable) {
            null
        }
        if (colorStateList != null) {
            return colorStateList
        }
        val color = try {
            ResourcesCompat.getColor(resources, id2, null)
        } catch (throwable: Throwable) {
            null
        }
        if (color != null) {
            return color
        }
        return null
    }

    override fun getColorInt(id: Int): Int {
        val id2 = getIdentifier(id)
        try {
            if (id2 != ResourcesCompat.ID_NULL) {
                return ResourcesCompat.getColor(resources, id2, null)
            }
        } catch (throwable: Throwable) {
            throwable.printStackTrace()
        }
        return parent!!.getColorInt(id)
    }

    override fun getColorStateList(id: Int): ColorStateList? {
        val id2 = getIdentifier(id)
        try {
            if (id2 != ResourcesCompat.ID_NULL) {
                return ResourcesCompat.getColorStateList(resources, id2, null)
            }
        } catch (throwable: Throwable) {
            throwable.printStackTrace()
        }
        return parent?.getColorStateList(id)
    }

    override fun getDrawable(id: Int): Drawable? {
        val id2 = getIdentifier(id)
        if (id2 == ResourcesCompat.ID_NULL) {
            return parent?.getDrawable(id)
        }
        val drawable = try {
            ResourcesCompat.getDrawable(resources, id2, null)
        } catch (throwable: Throwable) {
            null
        }
        if (drawable != null) {
            return drawable
        }
        return null
    }

    override fun getString(id: Int, vararg args: Any): String? {
        val id2 = getIdentifier(id)
        if (id2 == ResourcesCompat.ID_NULL) {
            return parent?.getString(id, *args)
        }
        val string = try {
            if (args.isEmpty()) {
                resources.getString(id2)
            } else {
                resources.getString(id, *args)
            }
        } catch (throwable: Throwable) {
            null
        }
        if (string != null) {
            return string
        }
        return null
    }
}

这是一个 Theme 的子类,正如它的类名,它是 Resources 来提供资源的,关于如何制作皮肤包后文会介绍,这里只需了解它是如何从 Resources 获取资源的就好。

小结

关于框架里的主要成员的介绍到此结束,如果上面贴出来的代码你都看完了,那么你就几乎把整个框架的代码都看完了,应该还算是简单的吧,如果有疑问欢迎友好交流。下面会结合实际使用来介绍这个框架的用法,以及框架之外还提供了什么好用的功能。

使用

这部分内容可以结合示例项目一起看。

示例项目和框架一样,代码很少的。

准备工作

你可以使用笔者开发的 gadgets 工具集来引入,但这里笔者直接用单独引入的方式来介绍吧。(这里查看发布的版本

  1. 添加 jitpack.io 仓库。(略)

  2. 在 module 的 build.gradle 添加依赖

dependencies {
    implementation("com.github.Zhupff.gadgets:theme:<version>")
}

开始开发

  1. 起手第一式,使用 ThemeFactory hook LayoutInflater.Factory2
class MainActivity : AppCompatActivity() {
    override fun onCreate(saveInstanceState: Bundle?) {
        val originFactory = layoutInflater.factory2
        layoutInflater.factory2 = ThemeFactory(originFactory, YourThemeConfig)
        
        super.onCreate(saveInstanceState)
    }
}
  1. 第二式,定义你的 ThemeConfig
class YourThemeConfig : ThemeConfig(
    prefix = "theme__", // 按照示例项目,使用 theme__ 作为换肤资源的前缀
    attributes = YourThemeUtil.getAttributes(),
) 
  1. 第三式,定义你需要的换肤属性模板

    假设你要对 TextView 的字体颜色进行换肤支持,

<androidx.appcompat.widget.AppCompatTextView
    android:id="@+id/Name"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="@color/theme__text_color"/>

可以定义如下模板,并提供给你定义的 ThemeConfig

object YourThemeUtil {
    private val attributes = listof(
        TextColor()
    )

    fun getAttributes(): List<ThemeAttribute> = attributes

    class TextColor @JvmOverloads constructor(
        resourceId = ResourcesCompat.ID_NULL,
    ) : ThemeAttribute(attributeName = "textColor", resourceId = resourceId) {
        override fun apply(view: View, theme: Theme) {
            val color = theme.getColor(resourceId) ?: return
            when (view) {
                is TextView -> {
                    if (color is ColorStateList) {
                        view.setTextColor(color)
                    } else if (color is Int) {
                        view.setTextColor(color)
                    }
                }
            }
        }
    }
}
  1. 第四式,定义你的默认皮肤主题。
object YourThemeUtil {
    val LIGHT = ResourceTheme(name = "Light", parent = null, resources = Application.resources)
    
    val current = MutableLiveData<Theme>(LIGHT)
}
  1. 第五式,安排你的主题分发者(这里以 MainActivity 举例,你也可以自定义一个 View 然后实现 ThemeDispatcher 接口)
class MainActivity : AppCompatActivity(), ThemeDispatcher {
    override fun observableTheme(): LiveData<Theme> = ThemeUtil.current
}
  1. 到这里,你的 TextView 就支持字体颜色的换肤功能啦,还想实现什么 View 的什么换肤效果,参考第三式。也可以参考示例项目里使用 AutoService 的方式来收集管理 ThemeAttribute,开发效率更高哦~

皮肤资源的准备

你的应用准备好换肤了,但是还没有皮肤资源给它换呢。先解释下皮肤包是什么?

皮肤包就是一个没有任何代码,只包含资源文件的Apk文件

当然这是大部分换肤框架使用的所谓皮肤包的本质,但其实对于笔者的框架来说,皮肤包除了可以是 apk 文件,也可以是一个 zip 文件,也可以是一份 json,总之可以是任意一种承载着换肤资源的文件,而开发者需要做的就只是自定义 Theme 的实现类把文件里的“资源”通过对外接口(比如color、drawable、string)暴露出来就可以了。

只不过对于这里使用到的 ResourceTheme 来说,它用到的皮肤资源的载体就是一个 apk 文件。

多举个例子,Android 的 Material Design 3 可以通过解析一张图得到一份 Material Design 的配色方案,也就是说如果你的应用想使用 Material Design 作为换肤方案,你的皮肤包可以只是一张图片。

下面我们来一步步创建一个皮肤包

  1. 创建一个新的工程项目,或者在原项目下新建一个 application module,里面什么代码都不需要有,能删的都可以删了。(这里假设开发者的做法是在原项目下新建 application module

  2. 上文中假设你的 TextView 用到了一个叫 theme__text_color 名字的 color 类型的资源,那么你需要在新 module 中也添加一个同名的 color 资源,只是里面的颜色值不同(因为是两个不同的主题,颜色当然不一样)。

  3. 在新 module 的 assets 资源目录下新建一个 theme.json 文件,里面包含如下一条内容:

{
    "theme_id": "your.new.application.package.name"
}

这里的 theme_id 是你新建项目或新建 application modulepackage name,作为区分不同的皮肤包,它需要是唯一的,如果对 package name 不理解或觉得笔者表述不清,可以查看示例项目中的 theme-diy module。

  1. assemble,打出来的 apk 文件就是你的皮肤包了。

皮肤包的使用

前面你顺利的打出了一个皮肤包,接下来说下怎么加载这个皮肤包

这里介绍的是对框架提供的 ResourceTheme 的加载和使用,开发者大可扩展满足自己需求的 Theme 类型以及对应的皮肤包文件。

object YourThemeUtil {
    val DIY = loadResourceTheme("diy", yourFilePath)

    fun loadResourceTheme(themeName: String, filePath: String): ResourceTheme {
        val res = ResourceTheme.loadResources(filePath)
        return ResourceTheme(name = themeName, parent = LIGHT, resources = res)
    }
}

这里提供一个参考方式,是的,ResourceTheme 封装了对 apk 中 Resource 资源的加载方法,你可以直接用,或者你有其他方式当然也可以,实例化皮肤包之后,让 ThemeDispatcher 去分发,即可完成这个页面的换肤。

class MainActivity : AppCompatActivity(), ThemeDispatcher {
    fun onUserSwitchTheme() {
        YourThemeUtil.current.postValue(YourThemeUtil.DIY)
        // 其实就是触发 observableTheme() 接口
    }
}

小结

至此,笔者的换肤框架的使用说明就结束了,如果有疑问的可以友好交流或者查看示例项目。整个使用流程下来,笔者主观上觉得还是很简单易用,如果有什么好的建议欢迎反馈。

框架上的使用就差不多是这样了,但是笔者仍然提供了一些框架外的辅助功能,下面也介绍一下。

插件的使用

当开发者打出皮肤包后,可以通过网络下载的方式将皮肤包下载至设备本地再使用,但是也许会有这么一些场景你会希望即使在网络不给力甚至没有网络的情况下也能使用皮肤包进行换肤,比如常见的深色模式切换。

这种场景的解决方案有很多,笔者这里介绍一种比较主流的方案,是将皮肤包资源放在主项目的 assets 目录下一起打包发布,在需要使用的时候从 assets 保存到设备本地再进行加载。

那么每次更新资源的时候都要手动打皮肤包再拷贝到 assets 也太麻烦了,特别时不时还 clean 一下那就更烦了。这里笔者提供的第一个插件就是专门用来处理这个问题的。

假设开发者是在主项目下新建 application module 的方式来制作皮肤包的(参考示例项目里的 theme-diy 模块),请按照以下步骤操作:

  1. 在项目根目录的 build.gradle 引入插件。
buildscript {
    dependencies {
        classpath("com.github.Zhupff.gadgets:theme-plugin:<version>")
    }
}
  1. 在主项目的 application module 的 build.gradle 使用 merge plugin。(注意这里的 application module 不是皮肤包那个,是我们熟知的那个 app 模块)
plugins {
    id("zhupff.gadgets.theme.merge")
}
  1. 在皮肤包 application module 的 build.gradle 使用 pack plugin
plugins {
    id("zhupff.gadgets.theme.pack")
}

这样配置好后,每当主项目 build 的时候,皮肤包都会自动打包并拷贝到主项目的 build/themepacks/ 目录中去参与构建并打包到 assets 去,免去手动打包拷贝的操作。

自动任务使用的是 @CacheableTask,不用担心重复打包的问题。

什么?你觉得还不够方便?你觉得 深色模式 下的资源还要单独打包运行时下载再加载效率太低了?你想直接就能使用到 深色模式 的资源?

也。。。也不是不行,那我把对应的 深色模式 的资源也一起打包进主项目的 apk 不就好了?不罗嗦了直接说笔者这里提供的第二个插件的做法。请按照以下步骤操作:

  1. 皮肤包项目(也就是那个新建的 application module)还是要有,但是那个 theme.json 可以不要了,毕竟它不再作为单独的皮肤包对待了。

  2. 主项目仍旧引入 merge plugin 不变。

  3. 在皮肤包 application module 的 build.gradle 使用 inject plugin

plugins {
    id("zhupff.gadgets.theme.inject")
}
themeInject {
    prefix = "theme__",
    variant = "dark",
}

直接说结论,使用 inject plugin 后,同样在主项目 build 的时候,皮肤包项目都会将其资源文件拷贝一份到主项目的 build/themeinjects/ 目录中,然后遍历这些文件的内容(主要是 .xml 文件)并把其中的 prefix 字段都改成 variantprefix,文件的名字也同样如此重命名,也就是说你的 theme__text_color 会变成 darktheme__text_color,这些资源会参与主项目app的编译构建并打包。

当运行时实例化 dark 皮肤资源时就不是使用 ResourceTheme 了,而是使用 ResourceVariantTheme

object YourThemeUtil {
    val DARK = ResourceVariantTheme(name = "Dark", variant = "dark", parent = LIGHT)
}

这样你就可以在 app 启动的时候就加载出 DARK 皮肤,而不需要从 assets 里导出再加载了。(可参考示例项目的 theme-dark 模块)

自动任务同样使用的是 @CacheableTask,不用担心重复拷贝的问题。

就这样,同一个皮肤包项目,通过不同的插件,可以实现不同的使用方式。

总结

以上便是笔者自己写的换肤框架的解析与思路分享,各位看官如果觉得一路看下来或多或少有点收获的话,也算我没白写,哈哈~

这必然不是一个完美的框架,甚至是否是一个好的框架都难说,毕竟它还没有经历过大项目的考验,但仍希望能得到各位看官的反馈和建议,使我能不断完善它,谢谢~

笔者不才,行文多有不妥之处,请各位看官指点与见谅!能点个赞或给笔者github来个Star就更好了哈哈~