QMUI实战(四)— QMUI 换肤的实现

3,241 阅读9分钟

QMUI 2 一个最大的特色就是支持了换肤(夜间模式),今天就来聊聊 QMUI 换肤的使用与实现。

在阅读本文之前,可以先到 Github QMUI Wiki 上查看QMUI换肤的使用文档

QMUI 换肤最原始是为了适配 Dark Mode。但作为框架的实现者,就需要考虑到更通用的使用形式,并且要尽可能保证 API 的简洁性。因而 QMUI 是支持多套肤色的切换,而 Dark Mode 只是其中的一种。

在无需重启 Activity 的前提下,我们做换肤框架的实现思路其实是很简单的:就是当触发换肤时,遍历 View 树 来更新 View 的肤色相关的属性。基于这一思路,组件的意义就在于利用数据结构、设计模式、系统 API等来简化封装出一套足够方便的使用接口,避免业务使用时为了完成功能而堆砌一堆 if else 代码。

写组件也不是一个高大尚的事情,在业务开发过程中,我们应该尽可能多思考,构建一些好用的组件,持续锻炼,才能逐渐 Hold 住越来越强大的组件。此外,在业务开发之余,我们需要多读一些源码,如果你一直走业务线,可能不会发觉阅读源码的作用,而如果你有尝试封装组件,那么这些优秀的库往往会给你思路的启迪。如果是两年前的我来写换肤框架,我写出来的框架可能比现在差得很远。如果你阅读本文,也期望能给你以启迪。

微信读书阅读器早就有了换肤框架,其思路就是一套资源管理机制 + 简单的派发。虽然在多次迭代中完善了很多,但还是有很多痛点的,因此 QMUI 设计之初就有考虑这些:

  1. SkinManager 不应该是单例的。微信读书正常书籍是四套肤色、而漫画只有两套,因单例而有了各种奇怪的代码去兼容多处的使用。
  2. 换肤派发是可以拦截的,微信读书里有 “xx场景下xx View 不要跟肤色” 的场景,这个可能大家也会遇到,毕竟设计师都是喜欢 “差异化的统一”。
  3. 不要求 View 必须继承某个接口才能实现换肤,避免为了换肤而写一堆的自定义 View
  4. View 在处理换肤时不要通过写各种 if else 来适配各种肤色。
  5. 要能够处理动态 View 添加的情况。
  6. 要能够处理 DialogPopup 等组件。

微信读书使用换肤的界面只有阅读器,但是正常 App 要兼容 Dark Mode 时的界面就很多多了,因此我也考虑了一下几个因素:

  1. 组件层面提供默认换肤配置。例如全局的 TopBar 基本上都是一样的,但是也会存在一些特殊的界面使用特殊的肤色。有默认配置的话,我就不用每个界面都去适配一次换肤。
  2. 使用者可以自定义添加一些配置规则去适配一些自定义 View
  3. 最好能由设计师通过工具完成业务换肤(正在开发ing)。

换肤资源配置

QMUI 一个最大的特色就是使用 theme 和 attr 机制来做配置,以此去除各种 if else 判断。毕竟 QMUI 是强调配置的,一开始就挖掘了 attr 的功能来实现配置。这次换肤实现依旧是使用 attr 来控制资源的。

attr 是只是定义了资源名, 然后在 theme 里为其赋值, 不同的 theme 可以赋予不同的值。因而我们将每个肤色对应一个theme,业务方使用 attr 进行换肤配置,框架根据业务的 attr 去当前肤色下的 theme 取值,以此实现 View 肤色的更新。 通过这种机制,业务方关注的只是 attr 以及去配置它不同肤色下的值。这样就用配置取代了各种 if else。其逻辑伪代码如下:

// 定义 attr 资源
<attr name = "app_color_01"/>

// skin 01 的值
<style name="skin_01">
  <item name="app_color_01">#fff</item>
</style>

// skin 02 的值
<style name="skin_02" parent="skin_01">
  <item name="app_color_01">#fff</item>
</style>

// view 使用 attr
view.skin{
  background(R.attr.app_color_01)
}

使用 theme 的另一个好处是它可以继承,如果多套肤色的某个配置颜色相同,那么就可以只在基础类写一次就好。

其具体的实现逻辑就是QMUISkinManager.addSkin:

@MainThread
public void addSkin(int index, int styleRes) {
    //...
    skinItem = new SkinItem(styleRes);
    mSkins.append(index, skinItem);
}

class SkinItem {
    private int styleRes;

    SkinItem(int styleRes) {
        this.styleRes = styleRes;
    }

    public int getStyleRes() {
        return styleRes;
    }

    @NonNull
    Resources.Theme getTheme() {
        Resources.Theme theme = sStyleIdThemeMap.get(styleRes);
        if (theme == null) {
            // 每一个 styleRes 我们都 new 一个新的 theme,然后通过 applyStyle 读取其值。
            theme = mResources.newTheme();
            theme.applyStyle(styleRes, true);
            sStyleIdThemeMap.put(styleRes, theme);
        }
        return theme;
    }
}

我们从 theme 里面读取 color、drawable 等是通过系统 Theme.resolveAttribute() 实现的, QMUIResHelper 也都对其做了封装。

View 的换肤配置存储

一般而言,实现换肤都是用 View 实现某个接口,这个接口告诉 View 当前肤色是什么,然后 View 根据当前肤色写一堆 if else 去做更新。 另一种是提供一堆的 BaseView,然后提供一堆的属性去存储换肤配置,你需要通过 set 方法去设置换肤属性。这种方式效率高,但是使用不方便,当一个 View 某些场景需要换肤,某些场景不需要换肤时,你就必须搞一个父类和一个子类加以区分,或者在非换肤场景忍受一堆换肤代码。

在看了鸿洋大神的换肤代码后,我觉得 View.setTag 是一个好东西,可以用来存储 View 的换肤配置,因此我的 View 换肤配置是这样的:

先通过 QMUISkinValueBuilder 收集换肤配置:

    val builder = QMUISkinValueBuilder()
    builder.textColor(R.attr.xxx)
    builder.background(R.attr.xxx)

然后将其拼装成 String 设置给 View:

view.setTag(R.id.qmui_skin_value, "background:xxx|textColor:xxx")

转换为一个 String 进行存储还有另外一个可以探索的点,可以尝试设计师通过可视化工具去为 View 配置换肤, 其思路是这样:

  1. 设计师通过可视化工具为 view 选取 attr, 我们将其拼接成一个字符串,并根据属性名、id 等进行存储。
  2. 在编译期间,用 gradle 插件将上面的代码注入到项目工程中。

目前 QMUI 项目中有一个 skin-maker 子工程,就是在做这件事(虽然已经停工很久了...),目前流程是可以通的,但是工程上还有很多问题待解决。

通过上述工作,我们解决了 View 实例的换肤配置,那组建层面如何提供默认配置呢? QMUI 提供了 IQMUISkinDefaultAttrProvider 接口, View 可以通过它来实现组件的默认配置。

组件默认配置的使用场景一般都是 TopBar 这种全局统一的组件的背景、标题颜色等等。还有另一种默认配置的使用场景:TopBar 的左右 Button,虽然都是 View 的实例,但是最好都走同样的默认配置,这个时候如果不想去写一些 类实现 IQMUISkinDefaultAttrProvider 接口, 可以在实例上使用 QMUISkinHelper.setSkinDefaultProvider() 方法, 其实现也是通过 View.setTag 做的:

public static void setSkinDefaultProvider(@NonNull View view, IQMUISkinDefaultAttrProvider provider) {
    view.setTag(R.id.qmui_skin_default_attr_provider, provider);
}

可见 View.setTag 虽然简单,但是如果把它作为 View 的存储工具,可以有很多用途,后面我们还会用到它。

QMUI换肤也支持 xml 配置,但是只是利用 LayoutInflater.Factory2,网上有很多教程,这里就不展开了,有兴趣的话可以看看源码。

换肤的派发

一般的换肤派发很简单,就是 View 树的遍历,一般是深度优先遍历:

void dispatch(View view, int currentSkin, Resources.Theme theme){
    applyTheme(view, skinIndex, theme)
    if(view instance ViewGroup){
        ViewGroup viewGroup = (ViewGroup) view;
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            dispatch(viewGroup.getChildAt(i), skinIndex, theme);
        }
    }
}

只是这样做的话,有一个问题: 我们无法派发完成后动态添加的 View。因此,需要添加一点辅助手段:

我在派发时,每个 View 会记住当前的 skinIndex,手段还是 View.setTag(), 因此换肤更新实际上是子 View 同步父 View 的skinIndex。这样动态添加 View 时,只需要同步其父 View 的 skinIndex。

但这个时候又有另一个问题:父 View 如何知道子 View 的添加?ViewGroup.addView() 是业务方调用,我们要避免业务方感知,所以我们不能用方法子类覆盖的方式,要另寻它法:ViewGroup 提供了 setOnHierarchyChangeListener() 方法,当有子 View 的添加和回调时,它就会被回调,但是他是个 set 方法而不是 add 方法,因而不安全,有的系统组件会依赖它做一些事情,使用它很可能造成一些组件功能的失常。

因而我们只能退而求其次,用View.addOnLayoutChangeListener() 方法,子 View 被添加后必定触发 requestLayout(),因而就会有 onLayoutChange 回调,我们可以在这个时候同步 skinIndex,虽然这样会多次调用,但是因为在向子类派发换肤时有 skinIndex 判等,实际上只会派发一次,不会有太多的性能损耗。

目前 RecyclerViewViewPagerAdapterView 使用了 setOnHierarchyChangeListener 方法同步,因为一般不会用到这个方法,是安全的,除此,如果你确定你的自定义 View 不会用到 setOnHierarchyChangeListener,你也可以给自定义 View 加上 QMUISkinListenWithHierarchyChange 注解,使得这个 View 的实例换肤派发走 setOnHierarchyChangeListener 方法。

View 换肤的解析与更新

我们同过 View.setTag()View 配置了例如 bordertextColor 等的 attr,其中没一项我称为一个 rule,其中 border 这些为它的 rule 名, attr 为它的 rule 值,框架为每个 rule 提供一个 handler,他们都实现了 IQMUISkinRuleHandler:

public interface IQMUISkinRuleHandler {
    void handle(QMUISkinManager skinManager, View view, Resources.Theme theme, String name, int attr);
}

然后 QMUISkinManager 就存储了 rule 名以及其对应的 handler:

private static HashMap<String, IQMUISkinRuleHandler> sRuleHandlers = new HashMap<>();

static {
    sRuleHandlers.put(QMUISkinValueBuilder.BACKGROUND, new QMUISkinRuleBackgroundHandler());
    IQMUISkinRuleHandler textColorHandler = new QMUISkinRuleTextColorHandler();
    sRuleHandlers.put(QMUISkinValueBuilder.TEXT_COLOR, textColorHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.SECOND_TEXT_COLOR, textColorHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.SRC, new QMUISkinRuleSrcHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.BORDER, new QMUISkinRuleBorderHandler());
    IQMUISkinRuleHandler separatorHandler = new QMUISkinRuleSeparatorHandler();
    sRuleHandlers.put(QMUISkinValueBuilder.TOP_SEPARATOR, separatorHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.RIGHT_SEPARATOR, separatorHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.BOTTOM_SEPARATOR, separatorHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.LEFT_SEPARATOR, separatorHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.TINT_COLOR, new QMUISkinRuleTintColorHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.ALPHA, new QMUISkinRuleAlphaHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.BG_TINT_COLOR, new QMUISkinRuleBgTintColorHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.PROGRESS_COLOR, new QMUISkinRuleProgressColorHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.TEXT_COMPOUND_TINT_COLOR, new QMUISkinRuleTextCompoundTintColorHandler());
    IQMUISkinRuleHandler textCompoundSrcHandler = new QMUISkinRuleTextCompoundSrcHandler();
    sRuleHandlers.put(QMUISkinValueBuilder.TEXT_COMPOUND_LEFT_SRC, textCompoundSrcHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.TEXT_COMPOUND_TOP_SRC, textCompoundSrcHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.TEXT_COMPOUND_RIGHT_SRC, textCompoundSrcHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.TEXT_COMPOUND_BOTTOM_SRC, textCompoundSrcHandler);
    sRuleHandlers.put(QMUISkinValueBuilder.HINT_COLOR, new QMUISkinRuleHintColorHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.UNDERLINE, new QMUISkinRuleUnderlineHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.MORE_TEXT_COLOR, new QMUISkinRuleMoreTextColorHandler());
    sRuleHandlers.put(QMUISkinValueBuilder.MORE_BG_COLOR, new QMUISkinRuleMoreBgColorHandler());
}

使用者也可以覆写或者添加新的规则:

public static void setRuleHandler(String name, IQMUISkinRuleHandler handler){
    sRuleHandlers.put(name, handler);
}

如此, View 在收到换肤 skinIndex 改变时,就解析 tag 里的配置,然后调用各个 handler 完成更新。

好了,QMUI 换肤的实现方案大体就是这些,很多东西,最基本的上的原理都是很简单的,但要完成各种细节功能和实现 API 的简洁性还是很考验设计模式、数据结构的,还是要多读源码、多写,才能出现灵感一现。

下一篇博文,我们来讨论一个问题,在 RecyclerView 中,如果我们的 itemView 要渲染 Adapter 数据源之外的数据,例如要显示播放状态、下载进度等,如何做才能优雅?