视觉模式适配(DarkMode)的七种武器

最近随着ios13,Android 10对DarkMode的深入支持,DarkMode的热度一下子高涨了起来。很多App都对DarkMode进行了支持。
那什么是DarkMode呢?先上官方文档
IOS:developer.apple.com/design/human-interface-guidelines/ios/visual-design/dark-mode/
Android:https://developer.…

我的理解是黑暗模式绝对不仅仅是颜色翻转版本,而是对每个界面中使用的颜色的完全重写,一开始就从暗处开始设计和配色。这个模式可以使我们真正与之交互和操作的内容可以被凸显出来,给用户沉浸式的体验。另外,它还很酷炫,省眼又省电:)
既然它是一整套的设计理念,对应而来的,就是对现有App设计元素的全盘重构,这在开发侧来讲,实在是一个庞杂繁琐的工程。
对此,我进行了一些思考,也实践了一些方案,我把它们成为DarkMode适配的七种武器。这些武器之前并不是独立的,可以根据App现有的生态环境配合使用。
七种武器
使用 setTheme 的方法让 Activity 重新设置主题;
定义两种主题,主题中定义控件的属性对应不同的色值。在编写布局文件时,引用定义好的属性。

<resources>
    <attr name="mainBackground" format="color|reference"></attr> 
</resources>
复制代码

复制

<style name="白色主题">
    <item name="android:textColor">@color/白色</item> //系统控件属性
    <item name="mainBackground">@color/黑色</item> //自定义属性
</style>
<style name="深色主题">
    <item name="android:textColor">@color/黑色</item>
    <item name="mainBackground">@color/白色</item>
</style>
复制代码

复制

” data-snippet-id=”ext.2a753c4bf27038b72afc29f534da9a31″ data-snippet-saved=”false” data-codota-status=”done”> <RelativeLayout xmlns:android=”schemas.android.com/apk/res/and…<TextView android:id=”@+id/tv” android:layout_below=”@id/btn_theme” android:layout_width=”match_parent” android:layout_height=”wrap_content” android:gravity=”center_horizontal” android:text=”通过setTheme()的方法” /> 

复制

这个方案还有一个变种,就是setTheme之后不recreate activity,而是发送一个广播。收到广播后,操作视图更新UI。

Resources.Theme theme = getTheme();
theme.resolveAttribute(R.attr.mainBackground, background, true);
mLayout.setBackgroundResource(background.resourceId);
复制代码

复制
设置 Android Support Library 中的 UiMode 来支持日间/夜间模式的切换;
使用 UiMode 的方法也很简单,我们需要把 colors.xml 定义为日间/夜间两种。之后根据不同的模式会去选择不同的 colors.xml 。在 Activity 调用 recreate() 之后,就实现了切换日/夜间模式的功能。
下面是 values/colors.xml :

#3F51B5
#303F9F
#FF4081
#FF000000
#FFFFFF

复制
除了 values/colors.xml 之外,我们还要创建一个 values-night/colors.xml 文件,用来设置夜间模式的颜色,其中 的 name 必须要和 values/colors.xml 中的相对应:

#3b3b3b
#383838
#a72b55
#FFFFFF
#3b3b3b

复制
在 styles.xml 中去引用我们在 colors.xml 中定义好的颜色:

<!– Customize your theme here. –>
<item name=&quot;colorPrimary&quot;>@color/colorPrimary</item>
<item name=&quot;colorPrimaryDark&quot;>@color/colorPrimaryDark</item>
<item name=&quot;colorAccent&quot;>@color/colorAccent</item>
<item name=&quot;android:textColor&quot;>@color/textColor</item>
<item name=&quot;mainBackground&quot;>@color/backgroundColor</item>

” data-snippet-id=”ext.2e3ea9df23c36fa9b3484d2e2ac898c9″ data-snippet-saved=”false” data-codota-status=”done”>  <style name=”AppTheme” parent=”Theme.AppCompat.Light.DarkActionBar”>  <item name=”colorPrimary”>@color/colorPrimary <item name=”colorPrimaryDark”>@color/colorPrimaryDark <item name=”colorAccent”>@color/colorAccent <item name=”android:textColor”>@color/textColor <item name=”mainBackground”>@color/backgroundColor  
复制

在页面创建时先选择一个默认的 Mode,在需要的时候切换,

getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES)” data-snippet-id=”ext.ac7b61b1439d3df32224ebe93bbeb49e” data-snippet-saved=”false” data-codota-status=”done”> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES)
复制
Android通过代码进行多语言切换的套路,完成模式切换
这里有我的一个脑洞,是基于UiMode方案的变种,既然有value-night,也可以有其他的value-xxx,但是安卓原生只能识别预定义的后缀,这里我们可以借用与locale类型,新增value-aa,value-ab等资源文件夹,每一套对应一个模式。然后在代码中,用这种方式切换

super.attachBaseContext(newContext);
复制代码

}
" data-snippet-id="ext.c583f5756066acf0db6bb642f2e3e685" data-snippet-saved="false" data-codota-status="done"> @Override protected void attachBaseContext(Context newBase) { Locale locale = new Locale("aa"); // aa:normal,ab:dark,.... final Resources res = newBase.getResources(); final Configuration config = res.getConfiguration(); config.setLocale(locale); // getLocale() should return a Locale final Context newContext = newBase.createConfigurationContext(config);
super.attachBaseContext(newContext); }
复制

通过资源 id 映射,回调自定义 ThemeChangeListener 接口来处理日间/夜间模式的切换。
这种方法的思路就是根据设置的主题去动态地获取资源 id 的映射,然后使用回调接口的方式让 UI 去设置相关的属性值。我们在这里先规定一下:夜间模式的资源在命名上都要加上后缀 “_night” ,比如日间模式的背景色命名为 color_background ,那么相对应的夜间模式的背景资源就要命名为 color_background_night
例如,我们可以提供一个工具类ThemeManager,这个工具类会维护资源id和主题的组合,并最终返回一个资源值。

//获取资源的规范
textview.setTextColor(getResources().getColor(
ThemeManager.getCurrentThemeRes(MainActivity.this, R.color.textColor)));" data-snippet-id="ext.b0c483c02df13dff90a92c382168e2f0" data-snippet-saved="false" data-codota-status="done"> //设置主题
ThemeManager.setThemeMode(ThemeManager.getThemeMode() == ThemeManager.ThemeMode.DAY ? ThemeManager.ThemeMode.NIGHT : ThemeManager.ThemeMode.DAY);
//获取资源的规范 textview.setTextColor(getResources().getColor( ThemeManager.getCurrentThemeRes(MainActivity.this, R.color.textColor)));
复制

仿照AppCompat类,将原生控件替换成自定义控件,统一控制
当我们引入appcompat-v7,有了AppCompatActivity的时候,我们发现我们渲染的TextView/Button等组件分别变成了AppCompatTextView和AppCompatButton。同理,我们可以借助于LayoutInflaterCompat设置我们自己的LayoutInflaterFactory。然后,参考google的方式,将xml里的原生控件,转化成我们的自定义控件。

// We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
// by using the parent's context
if (inheritContext &amp;&amp; parent != null) {
    context = parent.getContext();
}
if (readAndroidTheme || readAppTheme) {
    // We then apply the theme on the context, if specified
    context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
}
if (wrapContext) {
    context = TintContextWrapper.wrap(context);
}

View view = null;

// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
    case &quot;TextView&quot;:
        //view = new AppCompatTextView(context, attrs); 
        view = new SkinnableTextView(context, attrs); //自定义view
        break;
    case &quot;ImageView&quot;:
        view = new AppCompatImageView(context, attrs);
        break;
    ......
}

if (view == null &amp;&amp; originalContext != context) {
    // If the original context does not equal our themed context, then we need to manually
    // inflate it using the name so that android:theme takes effect.
    view = createViewFromTag(context, name, attrs);
}

if (view != null) {
    // If we have created a view, check it's android:onClick
    checkOnClickListener(view, attrs);
}

return view;
复制代码

}
" data-snippet-id="ext.31d55951ca97c8a347768df7cc13bc38" data-snippet-saved="false" data-codota-status="done"> public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) { final Context originalContext = context; // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy // by using the parent's context if (inheritContext && parent != null) { context = parent.getContext(); } if (readAndroidTheme || readAppTheme) { // We then apply the theme on the context, if specified context = themifyContext(context, attrs, readAndroidTheme, readAppTheme); } if (wrapContext) { context = TintContextWrapper.wrap(context); }
View view = null; // We need to 'inject' our tint aware Views in place of the standard framework versions switch (name) { case "TextView": //view = new AppCompatTextView(context, attrs); view = new SkinnableTextView(context, attrs); //自定义view break; case "ImageView": view = new AppCompatImageView(context, attrs); break; ...... }
if (view == null && originalContext != context) { // If the original context does not equal our themed context, then we need to manually // inflate it using the name so that android:theme takes effect. view = createViewFromTag(context, name, attrs); }
if (view != null) { // If we have created a view, check it's android:onClick checkOnClickListener(view, attrs); }
return view; }
复制

每次需要换肤的时候就调用setDayNightMode函数, 它会去通知View层级中所有实现了Skinnable接口的对象. 调用他们的applyDayNight方法, 来切换他们的样式.
我们在View生成的时候, 记录下了它引用的一些资源id, 然后因为切换了UiMode后, 获取相同资源 id 得到的实质资源不一样的特性来完成夜间模式切换的方案.
类似CSS的视图控制方案
服务端下发一份类似CSS的样式表,key由客户端定义,对应客户端某一类的视图的某一个属性,value则是一个样式值。客户端在渲染时,则按照预定的规则读取对于的样式值。这种方式的关键在于这个读取--设置的过程,是集中化的,还是每个视图都需要自己控制。它取决了协议的设计水平,也决定了开发的成本。这个我们在后面优酷实践的部分会详细介绍一下。
通过开关设置不同的参数
服务端下发当前希望的模式,组件开发者把这个模式当作是普通的参数一样,对不同的参数,进行不同的渲染。这把所有的工作量都放到了组件开发的部分。设计最简单。这个在优酷也有对应的场景,我们后面会介绍一下。
演武台
具体到优酷来说,目前已经进行了这样一些实践。
流派:下发模式开关 + 资源 id 映射
代表:播放页沉浸式
截图:

具体介绍一下,播放页沉浸式的核心控制类ImmersivePageModeUtil,每个组件都通过读取它来确定自己的渲染结果。

public boolean getImmersiveMode() {
return mIsImmersiveMode;
}
" data-snippet-id="ext.afb805d3155cea21da9c03d845fac6a3" data-snippet-saved="false" data-codota-status="done"> public void setIsImmersiveMode(boolean isImmersiveMode) { mIsImmersiveMode = isImmersiveMode; }
public boolean getImmersiveMode() { return mIsImmersiveMode; }
复制

public static void setTextColor(TextView title, int colorImmersiveId, int colorId) {
    if (title == null) {
        return;
    }
    if (ImmersivePageModeUtil.getInstance().getImmersiveMode()) {
        title.setTextColor(ContextCompat.getColor(title.getContext(), colorImmersiveId));
    } else {
        title.setTextColor(ContextCompat.getColor(title.getContext(), colorId));
    }
}
复制代码

复制
播放页还有一部分内容是星球的评论sdk,评论sdk的模式切换方式就是典型的资源 id 映射。

 if (mIsImmersiveMode && !TextUtils.isEmpty(mImmersiveUrl)){
        //评论星球设置沉浸式
        ThemeManager.getInstance().loadScene("darkScence");
        HashMap<String, String> map = new HashMap<>(3);
        map.put(ThemeManager.KEY_OBSCURE_URL, mImmersiveUrl);
        map.put(ThemeManager.KEY_OBSCURE_BG_COLOR, IMMERSIVE_COLOR);
        map.put(ThemeManager.KEY_OBSCURE_LEVEL, String.valueOf(IMMERSIVE_LEVEL));
        ThemeManager.getInstance().setObscureBgMap(map);
        if (null != mCallback) {
            mCallback.onGetUrlSuccess(mImmersiveUrl);
        }
    } else {
        //评论星球设置默认模式
        ThemeManager.getInstance().loadScene("defaultScence");
        ThemeManager.getInstance().setObscureBgMap(null);
    }
复制代码

复制
流派:CSS样式表下发 + 自定义控件
代表:首页高清频道
截图:

高清频道的实现是基于优酷的统一组件渲染架构的能力,在优酷统一组件渲染架构中,我使用了万花筒的插件模型,对所有的组件渲染过程进行了抽象,为统一控制组件渲染行为提供了接口,而在组件内部,则设计了MVP的结构,所以我们可以通过在V的基类中进行操作,从而完成对组件样式的集中式设置,大大减少具体组件开发人员的工作量。
前提有了,剩下就是要打通从氛围配置到最终渲染的数据链路了。考虑到灵活性,我们把氛围配置放到了运营侧的柏拉图平台,然后通过哥伦布进行下发,客户端则在解析页面,抽屉,组件时,获取服务端样式描述数据,建立起氛围key与样式描述文件的关系,创建组件View时,由AbsPresenter根据样式协议找到相应的View应用氛围value,也提供代码中手动绑定view和氛围value的方式。


流派:UIMode + DesignToken
代表:精选页 / 会员页 / 搜索结果页
截图:






这个方案是基于我们的另一个业务架构模块公共组件库的,从下图中,可以看到,每个主题都是从一组组value一层层搭建起来的,而优酷的组件代码中,也直接引用的是这些designtoken的引用。这就为我们统一修改提供了技术基础。我们可以在暗黑模式下,对色值的designtoken设置另一套值,就可以对应用视觉进行整体的变化。这对于designtoken体系,是天然支持的。我们在资源文件中,新建一个-night文件夹,在需要的时候,调用上文提到的getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES)接口,进行模式的切换。


写在最后
上面列举了多种实现模式切换的方案,本身并没有优劣之分,具体使用哪种取决于业务需求和技术生态。比如,切换theme或者uimode的方案中就有需要recreate activtiy的痛点,资源id映射的方式又需要修改通常获得资源的方式,仿CSS设置为代码的自定义视图或实现特定接口的方式,虽然拥有很大的灵活性和通用性,又需要修改每一个的组件,这对于超级App的工作量是非常可怕的。

具体到DarkMode这个场景,它是对系统定义的一种视觉模式的适配。系统为它做好了一部分前期工作,这就让DesignToken方案显得简单又高效,在优酷,结合designToken体系和已有的换肤方案,可以覆盖绝大部分场景,而只需要进行一些细节的走查。而且DarkMode的切换在最新系统版本是系统行为,在较低的系统版本应该是由用户主动触发,所以似乎也没有由服务端控制的需求。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享