在这里插入图片描述

当你把“记录/统计/导入导出”做得差不多之后,用户会开始关注体验层:

  • 白天用浅色,晚上用深色
  • 或者让 App 跟随系统

主题外观看起来像 UI 小功能,但它牵扯三件工程事:

  • ThemeMode 如何全局生效
  • 用户选择如何持久化
  • 设置页面如何做到“改完立刻见效”

这一篇我们只讲主题外观的实现链路,所有代码都来自 lib/app/fillup_app.dart
每段代码后紧跟解释。


1. 全局主题配置:在 FillUpApp 同时提供 theme 与 darkTheme

主题外观一定不是某个页面的局部样式。
项目在根部 GetMaterialApp 里同时配置了浅色与深色主题。

真实代码如下(节选):

        return GetMaterialApp(
          title: 'FillUp 油耗追踪器',
          debugShowCheckedModeBanner: false,
          initialBinding: FillUpBindings(),
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
            useMaterial3: true,
          ),
          darkTheme: ThemeData(
            brightness: Brightness.dark,
            colorScheme: ColorScheme.fromSeed(
              seedColor: Colors.teal,
              brightness: Brightness.dark,
            ),
            useMaterial3: true,
          ),
          themeMode: ThemeMode.system,

解释:

  • theme:浅色主题的默认配置
  • darkTheme:深色主题配置(显式 brightness: Brightness.dark
  • themeMode: ThemeMode.system:默认跟随系统

这里有一个很重要的约束:

  • 你只配置 theme 而不配置 darkTheme,深色模式下体验会非常不可控。

项目选择 ColorScheme.fromSeed(seedColor: Colors.teal) 也有好处:

  • 你只要一个主色,就能生成相对完整的颜色体系
  • 不需要手工维护一大堆颜色常量

2. 绑定注入:FillUpSettingsController 必须是全局单例

主题控制器如果不是全局的,设置页改了也无法影响其它页面。
项目在 FillUpBindings 里注入 FillUpSettingsController

真实代码如下:

class FillUpBindings extends Bindings {
  
  void dependencies() {
    Get.put(FillUpShellController());
    Get.put(FillUpSettingsController());
    Get.put(VehiclesController());
    Get.put(FillUpsController());
  }
}

解释:

  • Get.put(FillUpSettingsController()) 让它在整个 App 生命周期内可用
  • 任何页面都可以 Get.find<FillUpSettingsController>()

这种结构也意味着:

  • themeMode 的变更必须是“即时生效”,否则用户会觉得设置无效

3. SettingsController 的数据模型:用 Rx 承载主题状态

项目用 GetX 的 Rx 来承载主题模式:

class FillUpSettingsController extends GetxController {
  final Rx<ThemeMode> _themeMode = ThemeMode.system.obs;
  final RxString _distanceUnit = 'km'.obs;
  final RxString _fuelUnit = 'L'.obs;
  final RxString _currency = 'CNY'.obs;

  ThemeMode get themeMode => _themeMode.value;
  String get distanceUnit => _distanceUnit.value;
  String get fuelUnit => _fuelUnit.value;
  String get currency => _currency.value;

解释:

  • _themeMode 的默认值是 ThemeMode.system
  • 对外暴露 themeMode getter,而不是把 Rx 直接暴露给 UI

把 Rx 封装起来的价值是:

  • UI 不需要知道 _themeMode 是 Rx
  • 未来你要换状态管理或加校验,改动面更小

4. setThemeMode:状态更新 + 立即切换主题

主题外观最容易做错的一点是:

  • 你只更新了本地变量,却没有让 App 立即切换主题。

项目的 setThemeMode 同时做两件事:

  void setThemeMode(ThemeMode mode) {
    _themeMode.value = mode;
    Get.changeThemeMode(mode);
  }

解释:

  • _themeMode.value = mode:更新本地状态
  • Get.changeThemeMode(mode):让 GetMaterialApp 立即切换到指定 ThemeMode

这里的关键是第二句。
如果你缺了它,UI 只能等到重启应用才会生效。

另外注意:

  • setThemeMode 并不负责持久化
  • 持久化是 save() 的职责

职责分离能让代码更清晰。


5. load:从 SharedPreferences 恢复主题设置

主题模式必须持久化。
项目使用 SharedPreferences,并把存储值设计成字符串:

  • light
  • dark
  • system

真实代码如下:

  Future<void> load() async {
    final prefs = await SharedPrefs.instance.get();
    final theme = prefs.getString('theme_mode') ?? 'system';
    ThemeMode parsed;
    switch (theme) {
      case 'light':
        parsed = ThemeMode.light;
        break;
      case 'dark':
        parsed = ThemeMode.dark;
        break;
      default:
        parsed = ThemeMode.system;
        break;
    }
    _themeMode.value = parsed;
    Get.changeThemeMode(parsed);
    _distanceUnit.value = prefs.getString('distance_unit') ?? 'km';
    _fuelUnit.value = prefs.getString('fuel_unit') ?? 'L';
    _currency.value = prefs.getString('currency') ?? 'CNY';
  }

这段代码里跟主题相关的核心点有三个。

5.1 先取字符串,再解析

    final theme = prefs.getString('theme_mode') ?? 'system';

解释:

  • SharedPreferences 存字符串最稳定
  • 即使以后扩展,也不需要迁移复杂对象

5.2 switch 做解析映射

    switch (theme) {
      case 'light':
        parsed = ThemeMode.light;
        break;
      case 'dark':
        parsed = ThemeMode.dark;
        break;
      default:
        parsed = ThemeMode.system;
        break;
    }

解释:

  • default 回落到 ThemeMode.system
  • 避免 prefs 存了非法值导致崩溃

5.3 load 里也要调用 Get.changeThemeMode

    _themeMode.value = parsed;
    Get.changeThemeMode(parsed);

解释:

  • load 不仅恢复状态,也要让主题立即生效
  • 否则用户明明之前选了深色,打开应用却仍然是浅色

6. save:把 ThemeMode 写回 SharedPreferences

保存逻辑与 load 是对称的。
项目仍然把 ThemeMode 转成字符串存储。

真实代码如下:

  Future<void> save() async {
    final prefs = await SharedPrefs.instance.get();
    String theme;
    switch (_themeMode.value) {
      case ThemeMode.light:
        theme = 'light';
        break;
      case ThemeMode.dark:
        theme = 'dark';
        break;
      case ThemeMode.system:
        theme = 'system';
        break;
    }
    await prefs.setString('theme_mode', theme);
    await prefs.setString('distance_unit', _distanceUnit.value);
    await prefs.setString('fuel_unit', _fuelUnit.value);
    await prefs.setString('currency', _currency.value);
  }

解释:

  • save() 只负责把当前状态写入 prefs
  • 写入顺序无强制要求,但保持可读性很重要

save() 是 async,UI 侧一般不需要 await,但要避免在高频场景里反复触发。


7. onInit:控制器初始化时自动 load

控制器是全局单例,所以它初始化时就应该恢复设置。

真实代码如下:

  
  void onInit() {
    super.onInit();
    load();
  }

解释:

  • load() 在初始化时执行
  • 这保证用户进入任何页面时主题状态已经准备好

主题切换尽量在应用启动早期完成,避免出现 UI 一闪(先浅后深)。


8. 设置页的主题选择:RadioListTile 三选一

项目在 SettingsPage 里放了一个卡片,用单选的方式让用户选主题模式。

真实代码如下(完整摘录):

          Card(
            child: Padding(
              padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
              child: Obx(() {
                final mode = settings.themeMode;
                return Column(
                  children: <Widget>[
                    RadioListTile<ThemeMode>(
                      value: ThemeMode.system,
                      groupValue: mode,
                      onChanged: (v) {
                        if (v != null) settings.setThemeMode(v);
                        settings.save();
                      },
                      title: const Text('跟随系统'),
                    ),
                    RadioListTile<ThemeMode>(
                      value: ThemeMode.light,
                      groupValue: mode,
                      onChanged: (v) {
                        if (v != null) settings.setThemeMode(v);
                        settings.save();
                      },
                      title: const Text('浅色'),
                    ),
                    RadioListTile<ThemeMode>(
                      value: ThemeMode.dark,
                      groupValue: mode,
                      onChanged: (v) {
                        if (v != null) settings.setThemeMode(v);
                        settings.save();
                      },
                      title: const Text('深色'),
                    ),
                  ],
                );
              }),
            ),
          ),

解释这段 UI 的几个关键点。

8.1 用 Obx 监听 themeMode

                final mode = settings.themeMode;

解释:

  • settings.themeMode 变更后,Obx 内部会自动重建
  • 这保证选中的 Radio 立即更新

8.2 onChanged 做两件事:setThemeMode + save

                        if (v != null) settings.setThemeMode(v);
                        settings.save();

解释:

  • setThemeMode 负责立即生效
  • save 负责持久化

注意这里并没有 await。
原因是:

  • save 是 I/O
  • UI 不应该被阻塞

8.3 为什么这里用 Radio 而不是 Switch

Switch 只有两态。
但 ThemeMode 有三态:

  • system
  • light
  • dark

因此 RadioListTile 更符合语义。


9. 功能中心里的“主题外观”页:_buildTheme 的简化开关

项目里同时提供了一个 feature 页版本的主题开关。
它不是三选一,而是一个“深色模式”的简单开关。

真实代码如下:

  List<Widget> _buildTheme(BuildContext context) {
    final settings = Get.find<FillUpSettingsController>();
    return <Widget>[
      const _SectionHeader(title: '主题外观'),
      SizedBox(height: 10.h),
      Obx(() {
        final mode = settings.themeMode;
        return Column(
          children: <Widget>[
            SwitchListTile(
              value: mode == ThemeMode.dark,
              onChanged: (v) {
                settings.setThemeMode(v ? ThemeMode.dark : ThemeMode.light);
                settings.save();
              },
              title: const Text('深色模式'),
              subtitle: const Text('仅影响 UI 展示'),
            ),
          ],
        );
      }),
    ];
  }

解释:

  • SwitchListTile 的 value 用 mode == ThemeMode.dark 判断
  • onChanged 时在 dark/light 之间切换

你会发现它没有提供 system 选项。
这是一个典型的“功能中心占位页”取舍:给一个最常用的开关,不在 feature 页里塞满配置项;subtitle 也明确“仅影响 UI 展示”。


10. 小结

这一篇我们把主题外观实现串成完整链路:

  • FillUpApp 同时配置 theme/darkTheme/themeMode
  • FillUpSettingsControllerRx<ThemeMode> 承载状态
  • setThemeMode 负责立即生效(Get.changeThemeMode
  • load/save 负责 SharedPreferences 持久化
  • 设置页用 RadioListTile 做三态选择,feature 页用 SwitchListTile 做简化开关

文章底部添加社区引导:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐