Flutter for OpenHarmony 跨平台实战:浅色 / 深色主题切换方案

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

一、前言

Flutter for OpenHarmony(以下简称 “Flutter OHR”)是 Google Flutter 框架对 OpenHarmony 操作系统的官方适配实现。相比传统的纯 ArkTS 开发方式,Flutter OHR 允许开发者使用 Dart 语言编写跨平台业务逻辑,同时享受 OpenHarmony 原生能力扩展(Platform Channel)的红利。

在众多 UI 交互细节中,主题切换(浅色 / 深色模式)是现代应用不可或缺的能力之一。本文将以一个实际运行的 Flutter OHR 工程为例,讲解如何在应用层实现主题状态管理、持久化存储,以及全局 UI 颜色的动态适配,并提供完整的可运行代码与模拟器验证截图。

本文亮点

  • 基于 Flutter OHR 工程改造,不涉及环境安装配置
  • 完整闭环:状态管理 → 持久化 → UI 实时响应 → 模拟器验证
  • 所有代码均经过实际编译验证,可在 OpenHarmony 模拟器上直接运行

二、方案设计

2.1 整体架构

主题切换能力在架构上分为三层:

层级 职责 技术选型
数据层 主题状态持久化(用户下次打开仍保持选择) @ohos.data.preferences
逻辑层 主题状态读写、变更通知、模式映射 自定义 ThemeManager 单例
表现层 各页面/组件的颜色绑定与实时刷新 ArkTS @State + 观察者模式

整体思路是:应用启动时从 preferences 读取用户上次保存的主题首选项,注入到 ThemeManager 单例中;各页面通过监听主题变更事件,在状态更新后自动以 @State 驱动 UI 重绘。

2.2 主题颜色模型

我们将所有需要跟随主题变化的颜色收敛到一个 ThemeColors 类中,浅色和深色分别对应一套十六进制色值:

// 位置:entry/src/main/ets/utils/ThemeManager.ets

export enum ThemeMode {
  LIGHT = 'light',
  DARK = 'dark'
}

export class ThemeColors {
  // 背景色系
  public primaryBackground: string = '#FFFFFF';
  public secondaryBackground: string = '#F5F5F5';
  public cardBackground: string = '#FFFFFF';

  // 文本色系
  public primaryText: string = '#000000';
  public secondaryText: string = '#666666';
  public accentText: string = '#007DFF';

  // 按钮与分割线
  public primaryButton: string = '#007DFF';
  public primaryButtonText: string = '#FFFFFF';
  public divider: string = '#E0E0E0';
  public border: string = '#E0E0E0';

  constructor(mode: ThemeMode = ThemeMode.LIGHT) {
    if (mode === ThemeMode.DARK) {
      this.applyDarkMode();
    }
  }

  private applyDarkMode(): void {
    this.primaryBackground = '#000000';
    this.secondaryBackground = '#1A1A1A';
    this.cardBackground = '#1A1A1A';
    this.primaryText = '#FFFFFF';
    this.secondaryText = '#999999';
    this.accentText = '#4DA3FF';
    this.primaryButton = '#4DA3FF';
    this.primaryButtonText = '#000000';
    this.divider = '#333333';
    this.border = '#333333';
  }

  static fromMode(mode: ThemeMode): ThemeColors {
    return new ThemeColors(mode);
  }
}

这样做的好处是:主题切换本质上是替换一整个 ThemeColors 实例,UI 层不需要关心具体颜色值,只需要引用 this.colors.primaryBackground 这样的语义化属性。


三、核心实现

3.1 ThemeManager 单例

ThemeManager 是整个方案的核心,承担状态存储和发布-订阅职责:

// 位置:entry/src/main/ets/utils/ThemeManager.ets(完整版)

import preferences from '@ohos.data.preferences';
import { BusinessError } from '@kit.BasicServicesKit';

const PREFERENCES_NAME = 'theme_preferences';
const THEME_KEY = 'app_theme';

export class ThemeManager {
  private static instance: ThemeManager;
  private preferences: preferences.Preferences | null = null;
  private currentTheme: ThemeMode = ThemeMode.LIGHT;
  private listeners: Array<(theme: ThemeMode) => void> = [];

  private constructor() {}

  static getInstance(): ThemeManager {
    if (!ThemeManager.instance) {
      ThemeManager.instance = new ThemeManager();
    }
    return ThemeManager.instance;
  }

  // 应用启动时调用,从本地存储恢复主题状态
  async init(context: Context): Promise<void> {
    try {
      this.preferences = await preferences.getPreferences(context, PREFERENCES_NAME);
      const theme = await this.preferences.get(THEME_KEY, ThemeMode.LIGHT);
      this.currentTheme = theme as ThemeMode;
    } catch (error) {
      console.error('ThemeManager init error:', JSON.stringify(error));
      this.currentTheme = ThemeMode.LIGHT;
    }
  }

  getTheme(): ThemeMode {
    return this.currentTheme;
  }

  isDarkMode(): boolean {
    return this.currentTheme === ThemeMode.DARK;
  }

  getColors(): ThemeColors {
    return ThemeColors.fromMode(this.currentTheme);
  }

  // 设置主题:持久化写入 + 通知监听器
  async setTheme(theme: ThemeMode): Promise<void> {
    if (this.currentTheme === theme) {
      return;
    }
    this.currentTheme = theme;
    if (this.preferences) {
      try {
        await this.preferences.put(THEME_KEY, theme);
        await this.preferences.flush();
      } catch (error) {
        console.error('ThemeManager setTheme error:', JSON.stringify(error));
      }
    }
    this.notifyListeners();
  }

  async toggleTheme(): Promise<void> {
    const newTheme = this.currentTheme === ThemeMode.LIGHT
      ? ThemeMode.DARK
      : ThemeMode.LIGHT;
    await this.setTheme(newTheme);
  }

  // 观察者模式:添加/移除监听器
  addThemeChangeListener(callback: (theme: ThemeMode) => void): void {
    this.listeners.push(callback);
  }

  removeThemeChangeListener(callback: (theme: ThemeMode) => void): void {
    const index = this.listeners.indexOf(callback);
    if (index > -1) {
      this.listeners.splice(index, 1);
    }
  }

  private notifyListeners(): void {
    this.listeners.forEach(callback => callback(this.currentTheme));
  }
}

export const themeManager = ThemeManager.getInstance();

关键点解读:

  • init 方法在应用 onCreate 时调用,保证状态在首屏渲染前就已恢复。
  • toggleTheme 封装了"反转"逻辑,UI 层只需一行调用。
  • 观察者模式确保所有打开的页面同步收到主题变更通知,无需手动层层传递。

3.2 应用启动初始化

EntryAbilityonCreate 生命周期中初始化 ThemeManager,与国际化 i18nManager 并行启动:

// 位置:entry/src/main/ets/entryability/EntryAbility.ets

import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
import { i18nManager } from '../utils/I18nManager';
import { themeManager } from '../utils/ThemeManager';
import Want from '@ohos.app.ability.Want';
import AbilityConstant from '@ohos.app.ability.AbilityConstant';

export default class EntryAbility extends FlutterAbility {
  async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    super.onCreate(want, launchParam);
    await i18nManager.init(this.context);
    await themeManager.init(this.context);  // 新增:主题管理器初始化
  }

  configureFlutterEngine(flutterEngine: FlutterEngine): void {
    super.configureFlutterEngine(flutterEngine)
    GeneratedPluginRegistrant.registerWith(flutterEngine)
  }
}

3.3 页面级主题接入

以首页 Index.ets 为例,演示如何将硬编码颜色替换为动态主题色,并在 UI 上放置切换入口:

// 位置:entry/src/main/ets/pages/Index.ets

import common from '@ohos.app.ability.common';
import router from '@ohos.router';
import { FlutterPage } from '@ohos/flutter_ohos'
import { i18nManager } from '../utils/I18nManager';
import { stringManager } from '../utils/StringManager';
import { ThemeColors, ThemeMode, themeManager } from '../utils/ThemeManager';

let storage = LocalStorage.getShared()

(storage)

struct Index {
  private context = getContext(this) as common.UIAbilityContext
  ('viewId') viewId: string = "";
   currentLanguage: string = 'zh-CN'
   displayText: string = ''
   themeMode: ThemeMode = ThemeMode.LIGHT
   colors: ThemeColors = ThemeColors.fromMode(ThemeMode.LIGHT)

  // 主题变更回调(闭包绑定,避免 this 丢失)
  private onThemeChange = (theme: ThemeMode) => {
    this.themeMode = theme
    this.colors = ThemeColors.fromMode(theme)
  }

  aboutToAppear() {
    // 恢复语言偏好
    this.currentLanguage = i18nManager.getLanguage();
    this.updateDisplayText();
    i18nManager.addLanguageChangeListener(this.onLanguageChange.bind(this))

    // 恢复主题偏好并注册监听
    this.themeMode = themeManager.getTheme()
    this.colors = themeManager.getColors()
    themeManager.addThemeChangeListener(this.onThemeChange)
  }

  aboutToDisappear() {
    i18nManager.removeLanguageChangeListener(this.onLanguageChange.bind(this))
    themeManager.removeThemeChangeListener(this.onThemeChange)
  }

  onLanguageChange(language: string) {
    this.currentLanguage = language
    this.updateDisplayText()
  }

  updateDisplayText() {
    this.displayText = stringManager.getString('welcome') + '\n' +
                        stringManager.getString('description')
  }

  async switchLanguage() {
    await i18nManager.switchLanguage()
  }

  // 一行切换主题
  async toggleTheme() {
    await themeManager.toggleTheme()
  }

  build() {
    Column() {
      // 标题栏:语言切换 + 主题切换按钮
      Row() {
        Text(stringManager.getString('app_name'))
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 20, left: 20 })
          .fontColor(this.colors.primaryText)  // 语义化颜色

        Blank()

        Button(stringManager.getString('switch_language'))
          .margin({ top: 20, right: 12 })
          .onClick(() => this.switchLanguage())
          .backgroundColor(this.colors.primaryButton)
          .fontColor(this.colors.primaryButtonText)

        // 主题切换按钮:文字随当前模式动态变化
        Button(this.themeMode === ThemeMode.DARK ? '浅色' : '深色')
          .margin({ top: 20, right: 20 })
          .onClick(() => this.toggleTheme())
          .backgroundColor(this.colors.primaryButton)
          .fontColor(this.colors.primaryButtonText)
      }
      .width('100%')

      // 主内容区:背景跟随主题
      Column() {
        Text(this.displayText)
          .fontSize(18)
          .textAlign(TextAlign.Center)
          .margin({ top: 20 })
          .fontColor(this.colors.primaryText)
      }
      .margin({ top: 20 })

      Column() {
        Text(stringManager.getString('search_filter_demo'))
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .margin({ bottom: 12 })
          .fontColor(this.colors.primaryText)

        Button(stringManager.getString('go_to_data_list'))
          .width('80%')
          .height(44)
          .fontSize(16)
          .backgroundColor(this.colors.primaryButton)
          .fontColor(this.colors.primaryButtonText)
          .onClick(() => {
            router.pushUrl({ url: 'pages/DataListPage' });
          })
      }
      .width('100%')
      .padding({ top: 16, bottom: 16 })
      .backgroundColor(this.colors.secondaryBackground)
      .margin({ top: 16, left: 16, right: 16 })
      .borderRadius(8)

      Divider().margin({ top: 20 })

      Text(stringManager.getString('flutter_view'))
        .fontSize(16)
        .margin({ top: 10 })
        .fontColor(this.colors.primaryText)

      Column() {
        FlutterPage({ viewId: this.viewId })
          .width('100%')
          .layoutWeight(1)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(this.colors.primaryBackground)  // 全屏背景跟随主题
  }
}

核心改造要点

  1. 添加 @State 状态变量themeModecolors,任一变化都会触发 build() 方法重绘。
  2. 注册 / 注销监听器:在 aboutToAppear 中订阅,aboutToDisappear 中取消,防止内存泄漏。
  3. 语义化颜色属性:所有 .fontColor().backgroundColor() 替换为 this.colors.XXX,颜色定义与 UI 代码解耦。

3.4 通用组件的主题化

SearchFilterComponent 是项目中的通用筛选组件,通过 @Prop 接收外部传入的 colors 实例,实现组件级别的零成本主题化:

// 位置:entry/src/main/ets/components/SearchFilterComponent.ets

import { FilterCriteria, ItemStatus,
  getAllCategories, getAllTags,
  getAllStatuses, createDefaultFilter } from '../model/DataItem';
import { stringManager } from '../utils/StringManager';
import { ThemeColors } from '../utils/ThemeManager';


export struct SearchFilterComponent {
   filterCriteria: FilterCriteria;
   isFilterExpanded: boolean;
   searchPlaceholder: string = '';
   colors: ThemeColors = new ThemeColors();  // 接收主题色
  onSearchChange?: (text: string) => void;
  onFilterChange?: (criteria: FilterCriteria) => void;

  build() {
    Column() {
      this.SearchBar()
      if (this.isFilterExpanded) {
        this.FilterPanel()
      }
    }
    .width('100%')
    .backgroundColor(this.colors.secondaryBackground)
    .padding({ top: 8, bottom: 8 })
  }

  
  SearchBar() {
    Row() {
      Search({ placeholder: this.searchPlaceholder,
               value: this.filterCriteria.searchText })
        .width('85%')
        .height(40)
        .backgroundColor(this.colors.cardBackground)
        .margin({ left: 12, right: 8 })
        .onChange((value: string) => {
          this.filterCriteria.searchText = value;
          if (this.onSearchChange) this.onSearchChange(value);
        })

      Button({ type: ButtonType.Circle }) {
        Text(this.isFilterExpanded ? '▲' : '▼')
          .fontSize(14)
          .fontColor(this.colors.primaryButtonText)
      }
      .width(40)
      .height(40)
      .backgroundColor(
        this.hasActiveFilters()
          ? this.colors.primaryButton
          : this.colors.secondaryText
      )
      .margin({ right: 12 })
      .onClick(() => { this.isFilterExpanded = !this.isFilterExpanded; })
    }
    .width('100%')
  }

  
  FilterPanel() {
    Column() {
      // 分类、状态、价格、标签筛选...
      this.FilterActions()
    }
    .width('100%')
    .backgroundColor(this.colors.cardBackground)
    .margin({ top: 8, left: 12, right: 12 })
    .borderRadius(8)
    .padding(12)
  }

  
  FilterActions() {
    Row() {
      Button(stringManager.getString('reset_filter'))
        .width('45%')
        .height(36)
        .fontSize(14)
        .backgroundColor(this.colors.divider)
        .fontColor(this.colors.primaryText)
        .onClick(() => this.resetFilters())

      Blank().width('10%')

      Button(stringManager.getString('apply_filter'))
        .width('45%')
        .height(36)
        .fontSize(14)
        .backgroundColor(this.colors.primaryButton)
        .fontColor(this.colors.primaryButtonText)
        .onClick(() => {
          if (this.onFilterChange) this.onFilterChange(this.filterCriteria);
        })
    }
    .width('100%')
    .margin({ top: 8 })
  }

  private hasActiveFilters(): boolean {
    return this.filterCriteria.categories.length > 0 ||
           this.filterCriteria.statuses.length > 0 ||
           this.filterCriteria.tags.length > 0 ||
           this.filterCriteria.minPrice > 0 ||
           this.filterCriteria.maxPrice < 99999;
  }

  private resetFilters(): void {
    this.filterCriteria = createDefaultFilter();
    if (this.onFilterChange) this.onFilterChange(this.filterCriteria);
  }
}

父页面 DataListPage.ets 只需在调用处传入当前主题色:

SearchFilterComponent({
  filterCriteria: $filterCriteria,
  isFilterExpanded: $isFilterExpanded,
  searchPlaceholder: stringManager.getString('search_placeholder'),
  colors: this.colors,   // 传入当前主题色
  onSearchChange: this.onSearchChange.bind(this),
  onFilterChange: this.onFilterChange.bind(this)
})

四、构建与模拟器验证

4.1 构建 unsigned HAP

在工程根目录执行以下命令,构建产物中将包含 entry-default-unsigned.hap(跳过签名步骤):

# 在 DevEco Studio 终端或命令行中执行
hvigorw.bat assembleApp -p product=default -p buildMode=debug --no-daemon

注意:由于本地 keystore 环境问题,SignHap 任务会报错,但 PackageHap 会在此之前完成,entry-default-unsigned.hap 文件会正常产出。

4.2 安装到模拟器

确保 OpenHarmony 模拟器处于运行状态(可通过 hdc list targets 确认设备连接),然后执行安装命令:

# 查看已连接的模拟器
hdc list targets
# 输出示例:127.0.0.1:5555

# 安装 unsigned HAP(-r 表示覆盖安装)
hdc -t 127.0.0.1:5555 install -r "entry\build\default\outputs\default\entry-default-unsigned.hap"

# 安装成功输出:
# [Info]App install path:... entry-default-unsigned.hap msg:install bundle successfully.

4.3 启动应用

hdc -t 127.0.0.1:5555 shell aa start -a EntryAbility -b com.example.oh_demo8

4.4 验证效果

应用启动后,观察以下内容:
在这里插入图片描述
在这里插入图片描述

  • 首页右上角:应看到两个按钮,分别是"切换语言"和"浅色/深色"切换按钮
  • 点击"浅色/深色"按钮:背景色、按钮色、卡片色、文本色立即切换
  • 杀进程后重新打开:主题选择应保持上次状态(已持久化到 preferences)

五、进阶方向

5.1 系统级深色模式联动

当前方案是纯应用内主题。如果需要跟随操作系统的深色模式开关,可以在 EntryAbility 中监听 configuration 变更事件,将系统主题与应用主题联动:

// 在 EntryAbility 中监听系统深色模式
import ConfigurationConstant from '@ohos.app.ability.ConfigurationConstant';

this.context.getApplicationContext().on('environment', (data) => {
  if (data.memColorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK) {
    themeManager.setTheme(ThemeMode.DARK);
  } else {
    themeManager.setTheme(ThemeMode.LIGHT);
  }
});

5.2 过渡动画

build() 方法外层使用 animateTo 包裹颜色状态切换,可以实现平滑的主题过渡效果:

animateTo({ duration: 300 }, () => {
  this.colors = ThemeColors.fromMode(newTheme);
})

六、总结

本文从方案设计出发,完整实现了 Flutter OHR 工程中的浅色 / 深色主题切换能力:

  • ThemeManager 单例:封装状态读写、发布-订阅和 preferences 持久化,职责清晰。
  • ThemeColors 语义化颜色模型:将所有主题相关颜色收敛到一类中,UI 层无需记忆具体色值。
  • 页面级零成本接入:通过 @State + 观察者模式,页面只需注册一次监听即可自动响应。
  • 通用组件主题化@Prop colors 注入方案使组件无需内部维护主题状态。

该方案已通过 hvigor 编译验证,并在 OpenHarmony 模拟器上实际运行通过。


参考链接

  • 本文示例代码托管于 AtomGit:https://atomgit.com (仓库链接待项目公开后补充)
  • Flutter for OpenHarmony 官方文档:https://gitee.com/openharmony/flutter
  • OpenHarmony 应用开发文档:https://developer.huawei.com/consumer/cn/doc/

感谢各位阅读!

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐