Categories
程式開發

暗黑模式在 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 模式,則需要通過較淺的顏色表面來表示高度。層級越高,越接近於光源,表面的顏色就越淺。

暗黑模式在 Trip.com App 的實踐 1

2)降低飽和度,提升可讀性

設計 Dark Theme 時,盡量避免使用高飽和度的顏色,因為這些顏色會在深色背景上產生視覺抖動,導致人眼產生疲勞。以 Trip.com 的品牌藍為例,若顏色不做調整,直接展示在深色背景上,不僅信息的清晰度降低了,而且識別的費力度還增高了。這顯然不是我們所希望的,所以在 Dark Theme 下我們選擇更低飽和的顏色來達到更好的可讀性。

暗黑模式在 Trip.com App 的實踐 2

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

依據 WCAG2.0 AA 設計標準,文本的視覺呈現以及文本圖像至少要有4.5:1的對比度。深色表面選取白色文字達不到 AA 標準。

暗黑模式在 Trip.com App 的實踐 3

2.2 設計方案

遵循上述設計要點,我們制定了 Trip.com 的顏色映射和插畫設計方案。

2.2.1 顏色映射方案

為了規範化管理顏色庫,保證產品、設計、開發的理解一致性,我們採用最直觀的方式來命名顏色。這種方式既統一了 Light 和 Dark 的顏色命名,又降低了各方的溝通難度。具體的映射效果如下:

暗黑模式在 Trip.com App 的實踐 4

UI中的彩色,統一進行了降飽和處理,這些彩色會應用於不同的場景,可能是背景,行動點,標籤,或者是圖標等等地方,那麼當彩色用於背景時,為了確保文字和背景色有足夠對比度,低飽和度的淺色背景就需要配合深色字一起使用。

暗黑模式在 Trip.com App 的實踐 5

2.2.2 插畫系統的設計

開啟 Dark Theme,就像是我們把房間的窗簾拉上了,打開了一盞燈,不同層級高度的物體表面會受到不同的光照,表現出不同明暗的顏色。我們插畫系統中的物體和人物沿用這種設計,在暗環境中,由於光線不夠充足,人物的膚色會跟著變暗,衣服的顏色也會發生微妙的變化。比如白色、鮮亮的衣服,到了暗環境下,就會呈現灰色、低飽和度的暗色。

暗黑模式在 Trip.com App 的實踐 6

三、實現方案

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)設置開關

暗黑模式在 Trip.com App 的實踐 7

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 相同名字的顏色,如下圖:

暗黑模式在 Trip.com App 的實踐 8

在 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。

// IBUThemeContext
export const IBUThemeContext = React.createContext('light');
//IBUThemeProvider
export class IBUThemeProvider extends Component {
  // 引入文件时同步获取一次 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 {children};
  }
}

將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 = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
export function IBUDynamicStyleSheet(
  callback: () => T | IBUNamedStyles
): (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 (
      
        // ...
      
    )
  }
}

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(
      
        
        {/* render something else */}
      
    )
  }
}
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 (
    
        
        {/* render something else */}
    
  )
}
const dynamicStyles = IBUDynamicStyleSheet(() => ({
  icon: {
    backgroundColor: IBUColor.quaternaryGray(),
    height: 20,
  },
}));

注意:Component必須聲明contextType, 否則不能在theme發生變化時觸發render重繪。

四、工具&效率

在建立顏色規範到方案落地的過程中,我們發現新的顏色命名雖然容易理解,由於對使用的名字命名,開發在使用時需要對照視覺稿查找對應的顏色命名,造成開發效率上的浪費。

例如視覺稿上顯示 #287DFA,開髮根據色值查找此顏色的映射名稱 brandingBlue,再將顏色設置成 brandingBlue。

為了解決此問題,我們擴展了 Sketch Measure 插件,顏色一欄不再展示顏色的色值,取而代之的是顏色的命名。這樣開發能依照視覺稿直接獲取顏色名,大大減少工作量。

插件效果如下 :

暗黑模式在 Trip.com App 的實踐 9

至此完美解決了開發適配 Dark Theme 的效率問題。

五、結語

Dark Theme適配是一項涉及多職能部門合作的項目。在規範的設計指導、完善的落地方案和便捷的效率工具加持下,我們的適配成本和資源大大降低。在各端僅投入一位研發人員的情況下,在兩週內完成了從方案製定到方案落地,並推進產線接入。

Trip.com一直致力於追隨前沿新特性,帶給用戶最好的體驗,讓用戶更舒適,旅行從此簡單。

暗黑模式在 Trip.com App 的實踐 10

參考資料

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