全網最詳!暗黑模式在 Trip.com App 的實踐
- 2020 年 4 月 7 日
- 筆記
作者簡介
本文為聯合撰稿,作者為攜程國際業務研發部UED團隊靜靜,公共研發團隊祥星、旭仔、俊仔、增翼。

一、背景
在 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變化。
- iOS: RCTEventEmitter
- Android: RCTDeviceEventEmitter
3)RN業務方調用 theme
我們提供 IBUThemeContext & IBUThemeProvider 兩個類供產線獲取主題。 Context 提供了一個無需為每層組件手動添加 props,就能在組件樹間進行數據傳遞的方法。IBUThemeContext 是 Context 在 Theme 上的一個應用, IBUThemeProvider 負責同步 Theme 值,並將其傳遞給 IBUThemeContext.Provider。
// IBUThemeContext export const IBUThemeContext = React.createContext<'light' | 'dark'>('light'); //IBUThemeProvider export 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
5)WCAG21視覺標準:
https://www.w3.org/TR/WCAG21/#contrast-enhanced