最近随着ios13,Android 10对DarkMode的深入支持,DarkMode的热度一下子高涨了起来。很多App都对DarkMode进行了支持。 那什么是DarkMode呢?先上官方文档 IOS:developer.apple.com/design/huma… Android:developer.android.com/guide/topic…
我的理解是黑暗模式绝对不仅仅是颜色翻转版本,而是对每个界面中使用的颜色的完全重写,一开始就从暗处开始设计和配色。这个模式可以使我们真正与之交互和操作的内容可以被凸显出来,给用户沉浸式的体验。另外,它还很酷炫,省眼又省电:) 既然它是一整套的设计理念,对应而来的,就是对现有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…" xmlns:tools="schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/mainBackground"> <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="colorPrimary">@color/colorPrimary</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorAccent">@color/colorAccent</item> <item name="android:textColor">@color/textColor</item> <item name="mainBackground">@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 && 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;
}
" 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则是一个样式值。客户端在渲染时,则按照预定的规则读取对于的样式值。这种方式的关键在于这个读取--设置的过程,是集中化的,还是每个视图都需要自己控制。它取决了协议的设计水平,也决定了开发的成本。这个我们在后面优酷实践的部分会详细介绍一下。
通过开关设置不同的参数
服务端下发当前希望的模式,组件开发者把这个模式当作是普通的参数一样,对不同的参数,进行不同的渲染。这把所有的工作量都放到了组件开发的部分。设计最简单。
写在最后
上面列举了多种实现模式切换的方案,本身并没有优劣之分,具体使用哪种取决于业务需求和技术生态。比如,切换theme或者uimode的方案中就有需要recreate activtiy的痛点,资源id映射的方式又需要修改通常获得资源的方式,仿CSS设置为代码的自定义视图或实现特定接口的方式,虽然拥有很大的灵活性和通用性,又需要修改每一个的组件,这对于超级App的工作量是非常可怕的。
具体到DarkMode这个场景,它是对系统定义的一种视觉模式的适配。系统为它做好了一部分前期工作,这就让DesignToken方案显得简单又高效,在优酷,结合designToken体系和已有的换肤方案,可以覆盖绝大部分场景,而只需要进行一些细节的走查。而且DarkMode的切换在最新系统版本是系统行为,在较低的系统版本应该是由用户主动触发,所以似乎也没有由服务端控制的需求。