暗黑模式在 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。

// 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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章