阿里云「飞天发布时刻」2024来啦!新产品、新特性、新能力、新方案,等你来探~ 了解详情
写点什么

暗黑模式在 Trip.com App 的实践

  • 2020-03-29
  • 本文字数:8074 字

    阅读完需:约 26 分钟

暗黑模式在 Trip.com App 的实践

一、背景

在 2019 年,随着 iOS 13 与 Android Q 的推出,Apple 和 Google 同时推出主打功能暗黑模式,分别为 Dark Mode(iOS)/Dark Theme(Android) ,下文我们统称为 Dark Theme。在前期预研中,我们发现 66% 的 iOS 13 用户选择打开 Dark Theme,可见用户对暗黑模式的喜爱和期待。


那么 Dark Theme 能带来哪些好处呢?


  • 更加省电,当代手机大部分都是 OLED 屏(OLED 屏黑色下不发光更省电),配合 Dark Theme 能耗更低;

  • 提供一致性的用户体验,当用户从 Dark Theme 的环境切换到我们的 App,仍然能够享受黑色的宁静,避免亮眼的白色带来的刺激感;

  • 提升品牌形象,及时跟进系统新特性,在享受新特性带来美好之外还能获得 Apple Store 和 Google Play 推荐位机会,提升整体品牌形象;

  • 为弱视以及对强光敏感的用户提高可视性,让用户在暗环境中轻松使用 App。


接下来,我们从视觉设计、实现方案和开发效率三个角度来介绍 Dark Theme 在 Trip.com App 的实践。

二、视觉设计

暗黑模式是一套全新的设计风格,非简单的颜色明暗处理。我们将设计理念归结为三大要点,并介绍我们整体的设计思路。

2.1 三大要点

1)元素层级越高,表面颜色越浅

UI 视觉层次致力于以一种用户能够快速理解的方式呈现产品内容,那么在 Dark Theme 下如何保证视觉层级依然有效呢?在 Light 模式中,我们使用带投影的白色卡片来模拟现实世界的空间深度感,而切换到 Dark 模式,则需要通过较浅的颜色表面来表示高度。层级越高,越接近于光源,表面的颜色就越浅。


2)降低饱和度,提升可读性

设计 Dark Theme 时,尽量避免使用高饱和度的颜色,因为这些颜色会在深色背景上产生视觉抖动,导致人眼产生疲劳。以 Trip.com 的品牌蓝为例,若颜色不做调整,直接展示在深色背景上,不仅信息的清晰度降低了,而且识别的费力度还增高了。这显然不是我们所希望的,所以在 Dark Theme 下我们选择更低饱和的颜色来达到更好的可读性。


3)增加对比度,提升可用性

依据 WCAG2.0 AA 设计标准,文本的视觉呈现以及文本图像至少要有 4.5:1 的对比度。深色表面选取白色文字达不到 AA 标准。


2.2 设计方案

遵循上述设计要点,我们制定了 Trip.com 的颜色映射和插画设计方案。

2.2.1 颜色映射方案

为了规范化管理颜色库,保证产品、设计、开发的理解一致性,我们采用最直观的方式来命名颜色。这种方式既统一了 Light 和 Dark 的颜色命名,又降低了各方的沟通难度。具体的映射效果如下:



UI 中的彩色,统一进行了降饱和处理,这些彩色会应用于不同的场景,可能是背景,行动点,标签,或者是图标等等地方,那么当彩色用于背景时,为了确保文字和背景色有足够对比度,低饱和度的浅色背景就需要配合深色字一起使用。


2.2.2 插画系统的设计

开启 Dark Theme,就像是我们把房间的窗帘拉上了,打开了一盏灯,不同层级高度的物体表面会受到不同的光照,表现出不同明暗的颜色。我们插画系统中的物体和人物沿用这种设计,在暗环境中,由于光线不够充足,人物的肤色会跟着变暗,衣服的颜色也会发生微妙的变化。比如白色、鲜亮的衣服,到了暗环境下,就会呈现灰色、低饱和度的暗色。


三、实现方案

Trip.com App 使用原生系统与 React Native 混合开发的模式。我们在各系统方案的基础上,结合 Trip.com 自身的特性,制定了一套 iOS、Android 和 React Native 三端的 Dark Theme 适配方案。

3.1 iOS

我们为 iOS 13 以上用户提供了两种主题模式的选择:


  • 自适应模式:跟随系统展示 Light/Dark 主题

  • 强制 Light 模式:App 保持 Light 主题,不随系统主题变化

3.1.1 适配原理

iOS 系统为 UIWindow、UIViewController、UIView 提供了 overrideUserInterfaceStyle 属性来控制 Light/Dark 主题,所以我们只要控制 KeyWindow 的该属性,就可以控制整个 App 的主题。

3.1.2 适配方案

1)设置开关


App 主题设置逻辑如图,KeyWindow 只有在 App 和系统都开启 Dark Theme 时,才会开启 Dark 主题。


跟随系统切换主题需要考虑到 App 运行时,系统主题被切换的情况:


  • 前往系统设置页手动切换

  • 开启自动切换后,系统会自动更新主题


这两种情况都需 App 进入后台,所以只需要添加 App 进入前台的监听,重复 1 的逻辑即可完成跟随系统变换主题的功能。

2)颜色适配

系统提供了 colorWithDynamicProvider 方法来适配 Light/Dark 模式下的颜色,我们依照视觉颜色映射方案封装颜色,覆盖绝大多数场景。部分无法通过动态色适配的场景,如 CGColor、RGB 颜色,可以通过 resolvedColorWithTraitCollection 方法解析出当前上下文所需要的颜色进行使用。

3)图片适配

系统早在 iOS12 就为 UITraitCollection 增加了 userInterface 属性,我们只要向 ImageAssets 注册 Light/Dark 下两种主题的图片,而后 UIImageView 根据 traitCollectionDidChange 变化自动获取 Light/Dark 图片。


App 内的静态图片资源可以通过 Images.xcassets 直接配置,通过网络下发或代码动态生成的图片可以通过 registerImage:withTraitCollection: 的方式进行动态注册。

4)注意事项

动态色或 ImageAssets 的原理都是根据容器的 userInterface 取得对应的内容,视图上的动态颜色或 ImageAssets 将根据视图的 userInterface 取值,App 内直接进行颜色计算或者图片处理的将会根据 UITraitCollection.currentColletion 进行取值。


设置 Window 的主题来完成 App 主题适配的工作,会存在 App 主题与系统主题不同步的情况,例如系统主题为 Dark,App 主题为 Light。此时直接对动态颜色或 ImageAssets 进行操作会取得错误的结果。所以对于这种场景,都不使用动态色或 ImageAssets,仅在发生主题切换时机进行视图刷新操作。

3.2 Android

我们不仅在 Android Q 上实现 Dark Theme,在 Android Q 以下的版本也适配了 Dark Theme。在 Android Q 上,用户可以选择跟随系统来展示 Dark Theme 或者强制关闭 Dark 保持 Light 主题。


在 Android Q 以下,我们也支持了 Dark Theme,用户可以选择强制打开或者强制关闭 Dark Theme。

3.2.1 适配原理

Android App 启动时会根据系统的配置加载不同的资源,以加载图片为例,高分辨率系统加载三倍图,低分辨率系统加载二倍图。同样地,系统也会根据 Dark Theme 的打开或者关闭来加载 Dark 或者 Light 资源。


我们会往 App 的 value 和 value-night 文件目录下放置 UED 提供的 Light 和 Dark 两套资源。当 App 打开 Dark Theme,系统选择从 value-night 目录加载资源,展示 Dark 界面;当 App 关闭 Dark Theme,系统选择从 value 目录加载资源,展示 Light 界面。

3.2.2 适配方案

我们通过开关设置、颜色适配、图片适配和其他注意事项四小节来介绍 Android 的 Dark Theme 适配方案。

1)开关设置

从上述代码可以看出,只有使用 AppCompat 的代码才具有 Dark Theme 特性,例如继承 AppCompatAcivity 和 AppCompatDialog 才支持 Dark Theme,而普通的 Activity 和 Dialog 不会展示 Dark Theme,同样地 Application 也不支持。


// 打开darkmode AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODENIGHTYES);
// 关闭darkmode AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODENIGHTNO);
// darkmode跟随系统 AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODENIGHTFOLLOW_SYSTEM);
复制代码

2)颜色适配

在 value 和 value-night 目录下定义 Light 和 Dark 相同名字的颜色,如下图:



在 XML 或者代码中使用


//xml android:textColor="@color/colorbrandingblue"
//Java kotlin ContextCompat.getColor(activity, R.color.colorbrandingblue)
复制代码


注意:Activity 必须是 AppCompatActivity 实例,不能是 ApplicationContext/Activity。另外由于带透明度的颜色必须一个一个在 XML 声明,为了减轻开发工作量,我们提供了一个脚本可以快速生成 Light 和 Dark 下的透明度颜色。

3)图片适配

图片适配工作分资源图片适配和自定义 drawable 适配:


  • drawable/mipmap:在 drawable-xxhdpi 和 drawable-night-xxhdpi 目录下放置 Light 和 Dark 相同名字的图片,系统根据 Light/Dark 加载图片。

  • IconFont/自定义 Shape/自定义 Selector/SVG:因为绘制使用颜色,所以用法同颜色。

4)注意事项

  • 在非 AppCompatActivity 内展示 Dark Theme ,利用下面的代码可在非 AppCompatActivity 内展示 Dark 颜色。


public class IBUDarkModeDelegate {
public static void applyNight(Context activity) { Activity conreteActivity = null; if (activity instanceof Activity) { conreteActivity = (Activity) activity; } else if (activity instanceof ThemedReactContext) { conreteActivity = (Activity) ((ThemedReactContext) activity).getBaseContext(); } if (conreteActivity != null) { AppCompatDelegate appCompatDelegate = AppCompatDelegate.create(conreteActivity, new AppCompatCallback() { public ActionMode onWindowStartingSupportActionMode(ActionMode.Callback callback) { return null; } }); appCompatDelegate.applyDayNight(); } }}
// Activity创建前调用即可 protected void onCreate(Bundle savedInstanceState) { IBUDarkModeDelegate.applyNight(this); super.onCreate(savedInstanceState); }
复制代码


  • 颜色名必须全 App 唯一。

  • 切换手机系统的 Dark Theme,会导致 Activity 重建,业务线按需做好状态保存恢复。

  • 做好全机型测试,防止个别机型出现异常展示问题。

3.3 ReactNative

3.3.1 适配方案

RN 桥接 Native 端,通过直接获取和动态监听两种方式获取 Native 端的主题变化。

1)从 Native 端获取当前的 theme 值

使用 Native Modules 的同步方法在 JS 端获取当前 theme 值,JS 端方法调用能直接得到 Native 同步方法的返回值,而非一个 Promise。


同步方法于 2017 年 1 月和 10 月先后被引入 ReactNative 的 Android 端和 iOS 端, 但直到现在,仍然没有被写入文档:


  • iOS: 使用 RCTEXPORTSYNCHRONOUSTYPEDMETHOD() 替换 RCTEXPORTMETHOD()(v0.51.0 及以上版本支持Commit)

  • Android: 在 @ReactMethod annotation 后面添加 (isBlockingSynchronousMethod = true) (v0.42.0 及以上版本支持Commit)


同步方法的缺点是无法在 Debug Remotely 时调用,所以必须在 Debug Remotely 时,提供默认值。我们接入 dark theme 时,选择了 dark 作为默认值。

2)theme 值变化监听

我们使用 RN 事件监听 Theme 变化。


3)RN 业务方调用 theme

我们提供 IBUThemeContext & IBUThemeProvider 两个类供产线获取主题。 Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。IBUThemeContext 是 Context 在 Theme 上的一个应用, IBUThemeProvider 负责同步 Theme 值,并将其传递给 IBUThemeContext.Provider。


// IBUThemeContextexport const IBUThemeContext = React.createContext<'light' | 'dark'>('light');//IBUThemeProviderexport class IBUThemeProvider extends Component<IBUThemeProviderProps, IBUThemeProviderState> {  // 引入文件时同步获取一次 theme  static theme: 'light' | 'dark' = isInChromeDebugMode ? 'dark' : IBUTheme.getTheme();  constructor(props: IBUThemeProviderProps) {    super(props);      // 实例创建时, 再次同步一次theme      const theme = isInChromeDebugMode ? 'dark' : IBUTheme.getTheme();      IBUThemeProvider.theme = theme;      this.state = {        theme,      }; }  render(): JSX.Element {    const { theme } = this.state;    const { children } = this.props;    return <IBUThemeContext.Provider value={theme}>{children}</IBUThemeContext.Provider>;  }}
复制代码


将 IBUThemeProvider 嵌入 App 的根节点, 组件树便能通过如下两种方法,获取 theme 值:


通过 IBUThemeProvider.theme 读取全局 theme。声明了 static contextType=IBUThemeContext 的类中使用 this.context,获取 theme 值。

4)颜色适配

我们提供下列方法供产线使用颜色,方法支持透明度的设置:


export declare class IBUColor{  static red(theme?: 'light' | 'dark', alpha?: number): string;  static green(theme?: 'light' | 'dark', alpha?: number): string;  static blue(theme?: 'light' | 'dark', alpha?: number): string;}
复制代码


所有方法均接受 theme 和 alpha 两个可选参数, 方法会先根据 theme 选择对应颜色的 hex 字符串色值,如果 theme 值为空, 则 fallback 到 IBUThemeProvider.theme , 之后再根据 alpha 值计算颜色的的 alpha hex 值,并拼接到 hex 字符串色值之后。如 alpha 为空,则不拼接 hex 色值。最后将对应的 hex 色值字符串返回。

5)图片适配

我们使用 lazy getters 解决 Light/Dark 图片展示的问题。方式如下:


RN 端图片之前已经作了统一的静态资源管理:


export const images = {  button: require('./images/button.png'),  logo: require('./images/logo.png'),}
复制代码


使用 lazy getters,稍作改造后,即能完美适配:


export const images = {  get button() {    const theme = IBUThemeProvider.theme;    return theme === 'dark' ? require('./images/button_dark.png') : require('./images/button.png');  },  get logo() {    const theme = IBUThemeProvider.theme;    return theme === 'dark' ? require('./images/logo_dark.png') : require('./images/logo.png');  }}
复制代码

6)DynamicStyle

ReactNative 导出的 StyleSheet 只会在文件引入时,初始化一次,不会随着 App DarkTheme 的变化而变化这就导致系统主题发生变化时,RN 无法更新 styles,导致 RN 页面与 Native 不一致的问题。为此我们提出 DynamicStyleSheet 来解决该问题。


type IBUNamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };export function IBUDynamicStyleSheet<T extends IBUNamedStyles<T> | IBUNamedStyles<any>>(  callback: () => T | IBUNamedStyles<T>): (theme?: 'light' | 'dark') => T {  const cache: { light?: T; dark?: T } = {    light: undefined,    dark: undefined,  };  return (theme?: 'light' | 'dark'): T => {    const currentTheme = theme || IBUThemeProvider.theme;    let style = cache[currentTheme];    if (!style) {      style = StyleSheet.create(callback());      cache[currentTheme] = style;    }    return style;  };}
复制代码


IBUDynamicStyleSheet 是一个 Function,它接受一个返回值是 style 的 Function 作为参数,并且返回一个 Function。这种 Function 也被称High Order Function


StyleSheet 创建 style 的代码被包在参数的 Function 中,这样可以保证每次取值都会取到当前的 theme 对应的 style。每次 render 前, 将返回的 Function 执行一次,并将这个 Function 的返回值作为真正的 style 使用。


IBUDynamicStyleSheet 内部对 light 和 dark 下的 style 作了缓存,这样大部分情况下 style 仍然只会被创建一次, theme 发生变化时 style 被创建两次, theme 发生多次变化时,style 最多只被创建两次。


采用 DynamicStyleSheet 这种方式,代码改动量不仅小, 而且性能损失少, 达到实时切换 Theme 的目的。

7)Examples

App 开启 dark theme


export default class App extends Component{  render(){    return (      <IBUThemeProvider>        // ...      </IBUThemeProvider>    )  }}
复制代码


Class Component 接入


class MyClass extends React.Component {  //需要声明contextType, 否则该组件可能不会随theme变化而重新绘制  static contextType = IBUThemeContext;
constructor(props, context) { super(props, context) // context can be accessed now, https://github.com/facebook/react/issues/6598 const theme = this.context; // .... } // ... render() { const theme = this.context; // 'light'|'dark' /* render something based on the value of IBUThemeContext */ const styles = dynamicStyles(theme); return( <View style={{ backgroundColor: IBUColor.orange(theme), flex: 1 }}> <View style={styles.icon}/> {/* render something else */} </View> ) }}const dynamicStyles = IBUDynamicStyleSheet(() => ({ icon: { backgroundColor: IBUColor.quaternaryGray(), height: 20, },}));
复制代码


Functional Component 接入


export const MyComponent = () => {  const theme = React.useContext(IBUThemeContext);  // 'light'|'dark'  const styles = dynamicStyles(theme);  return (    <View style={{ backgroundColor: IBUColor.orange(theme), flex: 1 }}>        <View style={styles.icon}/>        {/* render something else */}    </View>  )}const dynamicStyles = IBUDynamicStyleSheet(() => ({  icon: {    backgroundColor: IBUColor.quaternaryGray(),    height: 20,  },}));
复制代码


注意:Component 必须声明 contextType, 否则不能在 theme 发生变化时触发 render 重绘。

四、工具 &效率

在建立颜色规范到方案落地的过程中,我们发现新的颜色命名虽然容易理解,由于对使用的名字命名,开发在使用时需要对照视觉稿查找对应的颜色命名,造成开发效率上的浪费。


例如视觉稿上显示 #287DFA,开发根据色值查找此颜色的映射名称 brandingBlue,再将颜色设置成 brandingBlue。


为了解决此问题,我们扩展了 Sketch Measure 插件,颜色一栏不再展示颜色的色值,取而代之的是颜色的命名。这样开发能依照视觉稿直接获取颜色名,大大减少工作量。


插件效果如下 :



至此完美解决了开发适配 Dark Theme 的效率问题。

五、结语

Dark Theme 适配是一项涉及多职能部门合作的项目。在规范的设计指导、完善的落地方案和便捷的效率工具加持下,我们的适配成本和资源大大降低。在各端仅投入一位研发人员的情况下,在两周内完成了从方案制定到方案落地,并推进产线接入。


Trip.com 一直致力于追随前沿新特性,带给用户最好的体验,让用户更舒适,旅行从此简单。



参考资料


1)Apple Dark Mode 介绍:


https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/dark-mode/


2)Implementing Dark Mode on iOS - WWDC2019:


https://developer.apple.com/videos/play/wwdc2019/214/


3)Android Dark Theme 介绍:


https://developer.android.com/guide/topics/ui/look-and-feel/darktheme


4)React Native 参考:


https://github.com/react-native-community/discussions-and-proposals/pull/11#discussion_r210370835 https://github.com/facebook/reactnative/commit/63fa3f21c5ab308def450bffb22054241a8842ef#diff-55c2992d993407398c62bf19f803088f


https://github.com/Lxxyx/react-native-dynamic-stylesheet https://developer.mozilla.org/enUS/docs/Web/JavaScript/Reference/Functions/get


https://medium.com/fantageek/how-to-structure-your-project-and-manage-static-resources-in-react-native-6f4cfc947d92


https://willowtreeapps.com/ideas/react-native-tips-and-tricks-2-0-managing-static-assets-with-absolute-paths


5)WCAG21 视觉标准:


https://www.w3.org/TR/WCAG21/#contrast-enhanced


作者介绍


本文为联合撰稿,作者为携程国际业务研发部 UED 团队静静,公共研发团队祥星、旭仔、俊仔、增翼。


本文转载自公众号携程技术(ID:ctriptech)。


原文链接


https://mp.weixin.qq.com/s?__biz=MjM5MDI3MjA5MQ==&mid=2697269484&idx=1&sn=1f5dcd45f90b2314050a04492e3a1cc0&chksm=8376efd8b40166ce60e86f8b4ac51f995e3119c59fef89ec9794e77f7c3624d1354ed74a2c4f&scene=27#wechat_redirect


2020-03-29 10:001671

评论

发布
暂无评论
发现更多内容

云起无垠CEO沈凯文博士获评“2023年度技术突破者”奖

云起无垠

拥抱未来:大语言模型解锁平台工程的无限可能

SEAL安全

平台工程 12 月 PK 榜 大语言模型

石磊:以人为本,精细运营 ,企业招聘管理的下半场

用友BIP

智能招聘

探索 Tokio Runtime丨Fabarta 技术专栏

Fabarta

人工智能 rust 图数据库 rust开发者大会 ​Rust

C语言自定义类型 | AI工程化部署

AIWeker

c AI工程化部署

专家观点∣用未来思考今天,ERP国产化价值替代的“五识”进阶能力

用友BIP

国产化价值替代

企业业务场景如何实现自动化连接?

RestCloud

AppLin 自动化连接

高精度时序分析工具PP-TSv2!一站式解决电力负荷预测、设备异常检测等多场景任务

飞桨PaddlePaddle

人工智能 开发者 开发工具 技术干货 时序分析工具

Wireshark中的ARP协议包分析

小魏写代码

每日一题:LeetCode-41. 缺失的第一个正数

半亩房顶

面试 算法 数组 LeetCode 哈希表

“软件赋能·链接未来”世亚国际软件产品博览会(世亚软博会)

AIOTE智博会

软件展会

企业服务大模型扎根生产一线,用友BIP为中国智造“再续新篇”!

用友BIP

企业大模型

微软远程管理Microsoft Remote Desktop怎么样?好用吗?

Rose

Mac远程控制软件 microsoft remote desktop mac破解软件下载 微软远程管理

聊点技术|数据爆炸性增长,Bonree ONE存储如何做到稳如泰山

博睿数据

数据库面试题从浅入深高频必刷「2024版」

王中阳Go

MySQL 数据库 后端 面试题 面经

SQL 也能搞复杂时序查询?-使用 SQL 在 GreptimeDB 上做 Range 查询

Greptime 格睿科技

数据库 sql 时序数据库

springboot集成activiti工作流实际项目(自定义工作流)

金陵老街

代购系统独立站的未来发展前景

Noah

高效会议指南:九种让你的会议更有价值的方法

PingCode

团队 会议

去哪儿“技术债”偿还实践:如何高效、低风险砍掉50%无用代码?

TakinTalks稳定性社区

springboot如何用jar包启动,同时为不同机房设置不同的配置文件 | 京东云技术团队

京东科技开发者

Java spring 后端 spring-boot jar包

哈工大副校长刘挺访问度小满 推进人工智能等方面技术合作

科技热闻

关于组态图和组态图设计

2D3D前端可视化开发

组态软件 组态 组态图库 组态界面 组态工具

时间复杂度为 O(n^2) 的排序算法 | 京东物流技术团队

京东科技开发者

算法 排序算法 O(n^2)

ios-class-guard - iOS代码混淆与加固实践

雪奈椰子

测试用例设计方法六脉神剑——第一剑:入门试招,等价边界初探 | 京东物流技术团队

京东科技开发者

测试 测试用例 测试 单元测试

基于机器深度学习的交通标志目标识别

3D建模设计

人工智能 机器学习 目标检测 目标识别 虚幻引擎合成数据生成

如何在 Mac 上创造一个纯 Windows 环境

Rose

Parallels Desktop

TinyVue 组件库助力赛意信息获得工业软件种子奖

OpenTiny社区

开源 前端 组件库

电子竞技将引发LED屏幕行业新一轮竞争

Dylan

电竞 电竞产业 LED显示屏 全彩LED显示屏 led显示屏厂家

用友电子凭证综合服务平台2.0重磅发布!

用友BIP

电子凭证

暗黑模式在 Trip.com App 的实践_大前端_IBU_InfoQ精选文章