Flutter + OpenHarmony 夜间模式实战:主题切换与页面适配指南
2026/9/10 19:01:12 网站建设 项目流程

从记事本项目开始到现在,这个系列已经写到第四篇了。前面几篇把基础框架、笔记列表、编辑能力和数据存储都捋了一遍,功能上已经能正常记录日常内容。但应用做到这个阶段,有一个需求开始变得非常突出——夜间模式。

我自己的使用场景就很典型:晚上躺床上想记点东西,打开应用,白花花的背景直接刺得眼睛发酸,亮度调低了又看不清字。跑去应用商店看了一圈,几乎所有主流的笔记应用都有夜间模式,这已经是记事本类目的标配能力,不是加分项而是必需品。

所以这一篇就来聊聊,如何在 Flutter + OpenHarmony 环境下,给记事本应用实现一套完整的夜间模式。核心诉求有两个:一是要护眼,不能只是简单地把背景换成黑色,配色和亮度都要仔细斟酌;二是要美观,切换后整体观感要协调,不能出现某几个页面还是亮的、某几个页面已经黑了的割裂情况。

这系列文章本来就偏实战,所以这篇我打算直接按我实际改造项目的思路来写,从设计思路、颜色体系、状态管理,到页面适配、踩坑经历,全部展开讲清楚。如果你也在用 Flutter 写 OpenHarmony 应用,或者纯粹对 Flutter 的主题切换机制感兴趣,这篇应该能给你不少可以直接抄作业的内容。

1. 设计思路:为什么夜间模式不只是“换个黑色背景”

1.1 先搞清楚用户到底要什么

在动手写代码之前,我先想明白了一件事:夜间模式解决的到底是什么问题?很多人会觉得,夜间模式就是把界面变黑、文字变白,跟白天模式形成反色效果。但实际上,真正的需求是让用户在暗光环境下看屏幕不刺眼,同时保持内容清晰可读。

如果把界面做成纯黑背景加纯白文字,对比度反而过高,在白底下看久了眼睛一样累。Google 的 Material Design 规范和苹果的 HIG 规范里对暗色模式都有明确的设计指引,核心原则不是“反色”,而是降低整体亮度、减少亮色面积、用深灰色代替纯黑色、文字用高对比度但非纯白的颜色,让整个界面在夜间既柔和又清晰。

我在设计这个记事本的夜间模式时,给自己定了几个原则:

  • 背景色不用纯黑#000000,而是用带一点灰蓝调的深色,比如#121212#1A1A2E,避免纯黑带来的“死板感”,同时减少 OLED 屏幕上的晕影问题。
  • 正文文字用接近白色但带一点灰度的颜色,比如#E0E0E0,而不是#FFFFFF,这样长时间阅读会舒服很多。
  • 强调色、主按钮色在夜间模式下要适当降低饱和度,避免鲜艳颜色在暗背景下过度刺激眼睛。
  • 不同层级的卡片、弹窗、底部弹层之间要有明确的明度差异,保证层次感,但又不能相差太大导致刺眼。

这套思路说起来不复杂,但真正落地到代码里,就要有一套完整的设计系统来支撑,而不是在十几个页面里手工去改颜色值。

1.2 为什么用 Flutter 的 ThemeData 来做

Flutter 本身提供了非常成熟的主题机制,MaterialApp里可以传入ThemeData来定义整个应用的外观,包括颜色、字体、按钮样式、卡片样式等。当ThemeData切换时,所有使用Theme.of(context)获取样式的组件都会自动更新。

这其实就是我选择 ThemeData 方案的根本原因:它可以做到“一处切换、全局生效”,避免手动在每个页面传参改颜色的痛苦。

但这里有一个很重要的坑需要注意:很多开发者(包括刚开始的我)只会在ThemeData里定义primaryColorscaffoldBackgroundColor,然后就以为万事大吉了。真跑起来才发现,AppBar 的背景、Card 的背景、文字颜色、输入框的下划线、时间选择器的外观……全都不会自动适配。因为这些组件虽然会读取主题里的部分字段,但默认值在亮色和暗色模式下是一样的,只是亮度不同,并不是你想象中那种“全自动切换”。

所以做夜间模式,一个核心的工作就是:把应用里用到的所有颜色都“可视化”到主题体系里,然后逐个组件去检查、适配。这个过程很琐碎,但没有任何捷径,只能一处处过。

1.3 选状态管理方案:Provider 足够且不折腾

Flutter 的状态管理方案有很多,Bloc、GetX、Riverpod、Provider 都有各自的拥趸。这个项目为了不过度设计,我选了 Provider。原因有三:

  • 记事本应用的状态本来就不复杂,主题切换是一个全局状态,但它只影响 UI,不涉及业务逻辑,用 Provider 的ChangeNotifier模式最直接。
  • Provider 是 Flutter 官方文档推荐的入门级方案,资料多、坑少,遇到问题很容易搜到答案。
  • 这个项目后续还要接 OpenHarmony 的平台能力,状态管理层保持简单,能减少跨平台适配时的负担。

选型这件事上我吃过亏,以前用过重型的框架,在业务逻辑不复杂的时候完全是杀鸡用牛刀,反而增加了理解和维护成本。所以在这种个人项目里,选择足够用且够稳的工具,比选择功能最全的工具更重要。

2. 颜色体系设计:一套色板管亮暗两个世界

2.1 定义语义化颜色而不是一堆散落的色值

刚开始写项目时,我习惯在代码里直接写Color(0xFFF5F5F5)这样的色值,这样的做法在功能开发阶段没问题,可一旦要做到主题切换,麻烦就来了:十几个文件里遍布几十个颜色,改起来毫无头绪,你根本分不清哪个颜色是背景、哪个是分割线、哪个是文字。

所以做夜间模式前,我先做了一轮重构,把应用里所有颜色整理成了“语义化”的颜色体系。所谓语义化,就是给颜色命名时不再叫“浅灰”“深灰”,而是叫“背景色”“卡片背景”“主文字”“次要文字”“分割线”这种描述用途的名字。这样在代码里看到AppColors.background的时候,你立刻知道它表示的是当前主题下的背景色,而不是某个具体的色值。

我把这个记事本应用的颜色分成了这么几组:

  • 背景层:页面主背景、卡片背景、弹窗背景、输入框背景。
  • 文本层:主文字、次要文字、占位符文字、禁用文字。
  • 装饰层:分割线、边框、阴影。
  • 品牌层:主按钮背景、主按钮文字、链接色。
  • 状态层:成功色、警告色、错误色、信息色(主要用在一些操作反馈场景)。

然后为亮色和暗色分别定义一组完整的色值,形成两套“色板”。

语义名称亮色模式色值暗色模式色值用途说明
主背景#F7F7FA#121212页面根背景
卡片背景#FFFFFF#1E1E24卡片、列表项
输入框背景#F0F0F3#2A2A30输入区域
主文字#1A1A1A#E0E0E0标题、正文主要文字
次要文字#666666#9E9E9E辅助说明、时间戳
分割线#E5E5EA#333333列表分割线、边框
主按钮背景#4F6BED#5E7AFF主操作按钮
主按钮文字#FFFFFF#0F1120主按钮上的文字
错误状态#D32F2F#EF5350删除、错误提示

这两套色板是整个夜间模式的基石。后面所有页面的适配,本质上就是把原来散落的硬编码颜色替换成这些语义化颜色,然后根据主题取不同的值。

2.2 用 ThemeExtension 优雅地管理自定义颜色

有些团队喜欢直接把颜色常量定义成static const Color,然后在代码里通过Theme.of(context).brightness == Brightness.dark来手动判断当前模式并选择色值。这种写法能用,但不够优雅,而且容易漏改、改错。

Flutter 从 3.0 开始提供了ThemeExtension这个类,允许你在ThemeData里挂载任意自定义的“样式扩展对象”。简单来说,你可以在主题里注册一个自定义颜色集对象,然后在页面里通过Theme.of(context).extension<AppColors>()拿到当前主题下的颜色集合。当你切换主题时,这个扩展对象也会跟着切换,不需要手动判断。

我用一个AppColors类继承了ThemeExtension<AppColors>,把上面表格里所有语义化颜色声明为成员变量,然后分别实现light()dark()两个工厂方法返回亮色和暗色的颜色实例,再实现copyWithlerp两个抽象方法完成主题切换时的动画插值。

lerp方法比较关键。Flutter 的AnimatedTheme在主题切换时会调用它来做不同主题之间的平滑过渡,如果没有正确实现,切换时会直接跳变,观感会生硬不少。好在lerp的实现不复杂,就是对每个颜色做线性插值。

贴上这个类的核心代码结构给你参考:

class AppColors extends ThemeExtension<AppColors> { final Color background; final Color cardBackground; final Color inputBackground; final Color primaryText; final Color secondaryText; final Color divider; final Color primaryButton; final Color primaryButtonText; final Color error; const AppColors({ required this.background, required this.cardBackground, required this.inputBackground, required this.primaryText, required this.secondaryText, required this.divider, required this.primaryButton, required this.primaryButtonText, required this.error, }); static const light = AppColors( background: Color(0xFFF7F7FA), cardBackground: Color(0xFFFFFFFF), inputBackground: Color(0xFFF0F0F3), primaryText: Color(0xFF1A1A1A), secondaryText: Color(0xFF666666), divider: Color(0xFFE5E5EA), primaryButton: Color(0xFF4F6BED), primaryButtonText: Color(0xFFFFFFFF), error: Color(0xFFD32F2F), ); static const dark = AppColors( background: Color(0xFF121212), cardBackground: Color(0xFF1E1E24), inputBackground: Color(0xFF2A2A30), primaryText: Color(0xFFE0E0E0), secondaryText: Color(0xFF9E9E9E), divider: Color(0xFF333333), primaryButton: Color(0xFF5E7AFF), primaryButtonText: Color(0xFF0F1120), error: Color(0xFFEF5350), ); @override AppColors copyWith({ Color? background, Color? cardBackground, Color? inputBackground, Color? primaryText, Color? secondaryText, Color? divider, Color? primaryButton, Color? primaryButtonText, Color? error, }) { return AppColors( background: background ?? this.background, cardBackground: cardBackground ?? this.cardBackground, inputBackground: inputBackground ?? this.inputBackground, primaryText: primaryText ?? this.primaryText, secondaryText: secondaryText ?? this.secondaryText, divider: divider ?? this.divider, primaryButton: primaryButton ?? this.primaryButton, primaryButtonText: primaryButtonText ?? this.primaryButtonText, error: error ?? this.error, ); } @override AppColors lerp(ThemeExtension<AppColors>? other, double t) { if (other is! AppColors) return this; return AppColors( background: Color.lerp(background, other.background, t)!, cardBackground: Color.lerp(cardBackground, other.cardBackground, t)!, inputBackground: Color.lerp(inputBackground, other.inputBackground, t)!, primaryText: Color.lerp(primaryText, other.primaryText, t)!, secondaryText: Color.lerp(secondaryText, other.secondaryText, t)!, divider: Color.lerp(divider, other.divider, t)!, primaryButton: Color.lerp(primaryButton, other.primaryButton, t)!, primaryButtonText: Color.lerp(primaryButtonText, other.primaryButtonText, t)!, error: Color.lerp(error, other.error, t)!, ); } }

定义好这个类之后,我把亮色和暗色两套ThemeData封装成一个工具方法,方便后续调用:

class AppTheme { static ThemeData light() { final base = ThemeData.light(); return base.copyWith( extensions: [AppColors.light], scaffoldBackgroundColor: AppColors.light.background, // 其他组件样式配置... ); } static ThemeData dark() { final base = ThemeData.dark(); return base.copyWith( extensions: [AppColors.dark], scaffoldBackgroundColor: AppColors.dark.background, // 其他组件样式配置... ); } }

这里有一个我自己踩过的坑:copyWith传入extensions: [AppColors.xxx]时,如果之前已经设置了别的extensions,直接传数组会把之前的全部覆盖掉。所以定义扩展的时候最好统一在extensions里只维护这一个自定义扩展对象,避免后面为了加别的扩展导致颜色丢失。

3. 核心实现:从存储到组件落地的完整流程

3.1 偏好存储:用 SharedPreferences 记住用户的选择

夜间模式有一类实现是把切换状态直接放在内存里,App 重启之后丢失。这对用户来说体验很差,谁都不希望每次打开应用都要重新切一次夜间模式。所以主题偏好一定要持久化。

在 Flutter 里做本地键值对存储,最常用的方案就是shared_preferences。它是官方插件,在 Android 和 OpenHarmony 上都有适配实现。代码非常简单:

class ThemePreference { static const _key = 'is_dark_mode'; static Future<bool> getIsDark() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_key) ?? false; } static Future<void> setIsDark(bool value) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_key, value); } }

这里我做的决定是:默认值使用false,也就是亮色模式。但其实后来我考虑过要不要跟随系统深色模式来判断默认值,MediaQuery.of(context).platformBrightness可以拿到系统当前的亮度模式。如果做成默认跟随系统,可能体验更好一些。不过一方面这个记事本的使用场景相对单一,另一方面我在项目里想给用户提供完全自主的控制权,不自动跟随系统设置,所以还是采用了手动切换、默认亮色的方案。如果你的应用面向更广泛的用户群体,我建议在首次启动时用系统亮度作为默认值,然后把选择权交给用户。

3.2 使用 ChangeNotifier 管理主题状态

有了持久化能力之后,需要一个状态管理对象来连接“用户切换操作”和“界面主题更新”这两件事。我用ChangeNotifier创建了一个ThemeController

class ThemeController extends ChangeNotifier { ThemeMode _mode = ThemeMode.light; bool _isDark = false; ThemeMode get mode => _mode; bool get isDark => _isDark; Future<void> loadPreference() async { _isDark = await ThemePreference.getIsDark(); _mode = _isDark ? ThemeMode.dark : ThemeMode.light; notifyListeners(); } Future<void> toggle() async { _isDark = !_isDark; _mode = _isDark ? ThemeMode.dark : ThemeMode.light; notifyListeners(); await ThemePreference.setIsDark(_isDark); } }

然后在MaterialApp里注册这个控制器:

class MyApp extends StatelessWidget { final ThemeController themeController; const MyApp({super.key, required this.themeController}); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: themeController, builder: (context, _) { return MaterialApp( title: '我的记事本', theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: themeController.mode, home: const HomePage(), ); }, ); } }

ChangeNotifierProvider.valueThemeController注入到组件树里,就是一个完整的闭环了:

void main() async { WidgetsFlutterBinding.ensureInitialized(); final themeController = ThemeController(); await themeController.loadPreference(); runApp( ChangeNotifierProvider.value( value: themeController, child: const MyApp(themeController: themeController), ), ); }

这样做的好处是,不管是首页、编辑页还是设置页,任何地方只要调用context.read<ThemeController>().toggle(),整个应用的MaterialApp就会通过themeMode的变化自动切换到对应主题,整个组件树会跟着重建。这是 Flutter 主题机制最舒服的地方——你只需要关心状态怎么变,UI 的同步刷新是框架帮你完成的。

3.3 页面适配:AppBar、列表项、编辑页逐个击破

状态和主题都定义好了,接下来的体力活就是把页面里所有硬编码的颜色替换成统一封装的语义化颜色。

打开首页和编辑页的代码,你能看到大量类似的用法:

// 修改前 Scaffold( backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, title: Text('记事本', style: TextStyle(color: Colors.black)), ), ) // 修改后 Scaffold( backgroundColor: themeColors.background, appBar: AppBar( backgroundColor: themeColors.background, title: Text('记事本', style: TextStyle(color: themeColors.primaryText)), ), )

其中themeColors是通过Theme.of(context).extension<AppColors>()!获取的当前主题颜色集合。

这里有几个细节值得注意:

第一,AppBar 要区分“沉浸式”和“非沉浸式”场景。我的首页 AppBar 是沉浸式设计,背景跟页面背景一致(都是background色),这样白天和夜晚切换时视觉上是连贯的;但有些页面(比如编辑页的工具栏)需要跟内容区区分开,我就会给 AppBar 设置elevation: 0并用cardBackground来做背景色,让它稍微亮于背景形成层次。

第二,列表项的选中态和按压态要适配。Flutter 的ListTile在亮色模式下默认按压有水波纹效果,颜色自带透明叠加;但如果你给ListTile设置了自定义tileColor,按压反馈可能就不明显了。我在列表项的selectedTileColor里传入了divider色(在暗色模式下约等于#333333),这样按下去会有比较明显的深灰反馈,不至于感觉“点击没反应”。

第三,正文编辑区的背景和光标颜色要同步处理。这是很多新手最容易漏掉的地方。TextFieldTextFormField的默认光标颜色是主题的primaryColor在暗色模式下是亮色,但在沉浸式暗色背景下可能不够显眼。我给编辑页的TextField显式指定了cursorColor: themeColors.primaryButton,并且把keyboardAppearance: Brightness.dark传进去,这样在暗色模式下,系统键盘的底部提示栏也会以深色样式显示,不会出现应用内是深色、键盘弹出却是刺眼白条的情况。

我还把TextFielddecoration统一封装了,避免每个页面都重复写一套InputDecoration

InputDecoration inputDecoration(ThemeData theme, AppColors colors) { return InputDecoration( filled: true, fillColor: colors.inputBackground, hintStyle: TextStyle(color: colors.secondaryText), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colors.divider), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: colors.primaryButton, width: 1.5), ), ); }

这样只要在一个地方维护输入框样式,所有页面引用即可。后面如果想调圆角或者改边框,只改这一处就行。

3.4 底部弹窗与选择器:夜间模式下最容易翻车的地方

记事本应用里有几个会弹出的组件:新建笔记时的日期选择器、删除确认对话框、以及自定义的底部操作面板。这些组件天然带遮罩层和独立的背景,如果不做适配,很容易出现“应用主界面是暗色,弹出一个白底对话框”的尴尬情况。

对话框和底部弹窗的适配,核心在showDialogshowModalBottomSheet这两个方法。它们虽然可以传入backgroundColor,但如果你不传,它会默认用ThemeData.dialogThemeThemeData.bottomSheetTheme里的背景色。所以与其在每个弹窗调用处传颜色,不如在AppTheme里统一配置:

ThemeData.dark().copyWith( dialogTheme: const DialogThemeData( backgroundColor: Color(0xFF1E1E24), ), bottomSheetTheme: const BottomSheetThemeData( backgroundColor: Color(0xFF1E1E24), ), )

另一个容易忽略的组件是时间选择器和日期选择器。Flutter 的日历组件默认颜色是跟随主题的,在暗色模式下会自动变成深色底,但确定按钮和取消按钮的文本颜色默认取的是colorScheme.primary,在暗色模式下如果主色太亮会有点刺眼。我把主按钮色调整得温和一些(#5E7AFF),让它在暗背景上既有存在感又不至于太亮。

底部弹窗还有一个细节是“安全区”处理。在 OpenHarmony 真机上,底部的系统导航条区域默认是黑条,如果弹窗内容没有撑到安全区,会有一条白色空隙。处理方式是在弹窗内容的外层包一个SafeArea

showModalBottomSheet( context: context, backgroundColor: colors.cardBackground, builder: (context) => SafeArea( child: 操作面板内容, ), );

这些细节单独看都是小事,但全部处理好之后,整个夜间模式的完成度和一致性会明显高一个档次。

4. 状态管理与联动细节:一键切换背后的事

4.1 主题色切换按钮的 UI 呈现

夜间模式切换按钮的入口我放在了首页的 AppBar 右上角和一个设置页里。按钮的 UI 设计值得说说:单纯的文字按钮“夜间模式”太死板,一个图标加当前状态说明会更直观。我用了一个IconButton,图标根据当前主题切换:

IconButton( icon: Icon( isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, color: colors.primaryText, ), tooltip: isDark ? '切换到日间模式' : '切换到夜间模式', onPressed: () => context.read<ThemeController>().toggle(), )

点击后,toggle()会更新ThemeController_mode,通知AnimatedBuilder重建MaterialApp,主题切换就完成了。这里我额外用AnimatedTheme包了一下,让主题切换过程有大概 300ms 的渐变过渡,而不是瞬间跳变,视觉上会柔和很多。

AnimatedTheme的使用很简单,MaterialApp内部其实已经通过AnimatedTheme在切换主题时做动画了,你不需要额外包一层。但如果你自己定义路由或者组件,发现切换瞬间跳变,可以手动在根组件包一层:

AnimatedTheme( data: themeData, duration: const Duration(milliseconds: 300), child: child, )

这个动画时长的选择我测试过,200ms 太快,颜色变化有点生硬;500ms 又太慢,操作后的反馈不够利落;300ms 是折中方案,Swift 和 Material 规范里的过渡动画也基本都在 300ms 左右,可以参考这个经验值。

4.2 应用启动时的首帧问题

主题状态从SharedPreferences读取是异步操作,而runApp是同步执行的,这可能会导致应用启动时先用默认的亮色主题渲染一帧,等读取完成后再切换到暗色,产生一个“闪白”的效果。

这个问题的解决方案,我是在main()方法里加了一行await themeController.loadPreference(),提前完成偏好读取再执行runApp。这样首帧渲染时主题就已经是正确模式了,不会出现闪变。

Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final themeController = ThemeController(); await themeController.loadPreference(); runApp( ChangeNotifierProvider.value( value: themeController, child: const MyApp(themeController: themeController), ), ); }

但要注意,await loadPreference()会让runApp延后几十毫秒执行,在低端设备上可能会让用户感觉启动稍微慢了一点点。不过考虑到闪白对夜间模式用户来说更加不可接受,这个取舍是值得的。

如果你做了“跟随系统亮度”的默认设置,还需要监听系统亮度变化事件,用WidgetsBindingObserver.didChangePlatformBrightness来动态更新主题。这个功能我用户群体里很少有人需要,所以没有深入做,如果你想支持,逻辑也不复杂,感兴趣可以自己试验一下。

4.3 不同页面之间的主题联动

除了全局的MaterialApp.themeMode,有些页面里的局部组件可能需要单独感知主题变化。比如我的笔记列表页里自定义了一个“标签标签”组件,它显示笔记的分类标签(工作、生活、学习等),标签的背景色和文字色是自定义的。

这种情况我会在组件内部通过context.watch<ThemeController>()或者直接Theme.of(context)来获取主题,确保组件随着主题变化而重建。有一个容易忽略的点是:如果你在组件里用const修饰构造函数,并且这个组件内部没有被context的 InheritedWidget 依赖,那么主题变化时组件可能不会重建,因为const组件会被当作完全相同的实例跳过重建。

所以,所有依赖主题的组件,要么不要滥用const,要么通过context.watch建立依赖关系。这个细节在团队协作时特别容易踩,我自己的项目里好几次排查半天,最后发现是某个const组件没有跟随主题更新。

5. 组件适配:容易被忽略的暗色细节

5.1 SafeArea 与底部导航栏区域的处理

OpenHarmony 设备上,系统底部有导航条或者手势条区域。亮色模式下,这部分区域是白色或者灰白色,跟页面背景融合得很好;但在暗色模式下,如果页面背景是#121212,底部系统导航条如果还是白色,会非常突兀。

解决办法是在Scaffold或者页面根组件外面套一个AnnotatedRegion,把系统导航栏颜色跟页面背景做同步:

AnnotatedRegion<SystemUiOverlayStyle>( value: isDark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, child: Scaffold(...), )

这里的SystemUiOverlayStyle.light表示状态栏和导航栏的图标用浅色(白)绘制,适合暗色背景;SystemUiOverlayStyle.dark反之。这个操作在不同平台上表现略有差异,但基本都能控制状态栏图标的明暗。

还有一个小细节:OpenHarmony 上如果你需要把页面内容拓展到状态栏后面(沉浸式状态栏),需要调用平台的窗口设置方法,用 Flutter 的话可以借助SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)来开启全屏边缘布局。开启之后状态栏是透明的,但图标明暗同样需要AnnotatedRegion控制。我在 OpenHarmony 真机上测试发现,这个 API 在鸿蒙环境下的行为跟 Android 原生基本一致,只是个别系统版本对浅色图标的识别不是特别灵敏,需要实测调整。

5.2 分割线、边框和阴影在暗色模式下的处理

分割线和边框是页面层次感的重要来源,但在暗色模式下需要格外小心。亮色模式下常用的浅灰色分割线(#E5E5EA)在暗色背景下会因为对比度不够而几乎看不见;而如果直接用白色,又会显得非常突兀,像页面被切了一刀。

我的处理方式是:暗色模式的分割线使用#333333,在#121212背景上刚好能看出一点分界,又不会抢视觉中心。这里有个经验值:暗色模式的“次级背景”和“分割线”之间的亮度差控制在 5% 到 10% 左右,既保证可辨识度,又保证柔和度。

阴影在暗色模式下也需要调整。Flutter 的BoxShadow默认是黑色,在亮色模式下没问题,但在深色背景下黑色阴影几乎不可见,而且会让组件显得“没有层次”。正确做法是让阴影颜色稍微带一点亮色,或者在暗色模式下直接改用边框来表现层次。

我在暗色模式下给卡片设置了border: Border.all(color: colors.divider, width: 0.5),用细边框替代阴影,同时把原本的阴影透明度调低。这样组件在暗色背景上也能有明确的边界感。

5.3 图片和图标在暗色模式下的对比度问题

如果你的记事本支持插入图片,那么暗色模式下图片的展示也需要考虑。一般情况下图片会保持原样,但如果图片背景是白色的,在深色 UI 中会显得很“跳”。一个轻量级的处理方式是给图片加一层弱化效果,比如在暗色模式下把图片的透明度降低 10% 到 20%,或者给图片外面加一个圆角边框,让它看起来更像卡片内容。

图标方面,我统一使用了Icons里的线性图标(outlined 系列),并通过color参数设置成secondaryText色,这样在亮色和暗色模式下都能保持可辨识度。特别提醒一下,不要用IconTheme全局修改颜色来适配图标,因为有些图标本身有语义颜色(比如Icons.delete应该是红色),全局改色会把这类语义颜色也覆盖掉。按需给每个图标设置颜色,虽然代码多几行,但可控性更好。

6. 常见问题与排查技巧实录

6.1 启动瞬间白屏闪烁的问题

这个问题在我实际测试过程中出现过一次:早上打开应用,暗色模式正常,但每次冷启动都会先闪一下白,然后变成暗色。

排查过程:先检查main()方法,确认loadPreference()已经await了,排除掉“先runApp再异步读偏好”的问题。接着检查MaterialAppthemedarkTheme是不是都正确传入了,发现也是对的。最后打开日志,发现SharedPreferences读取速度很快,理论上不会造成可见的白屏。

真正的原因出在MyAppAnimatedBuilder上。我把themeMode绑定到了themeController.mode,但在main()中我先runAppawait loadPreference()的时候,AnimatedBuilder会先渲染一次默认的亮色主题,等loadPreference完成、notifyListeners()触发后才会切到暗色。虽然整个时间间隔只有几十毫秒,但在低端设备上就可能产生肉眼可见的闪白。

最终把await loadPreference()提前到runApp之前,闪白问题解决。这是我在这个项目里真正踩过的坑,印象很深。

6.2 键盘弹出后的白屏问题

另一个很容易被忽略的问题是键盘弹出时,页面背景是暗色,但键盘上方的文本输入候选条区域是白色的。

这个问题的根源是TextFieldkeyboardAppearance没设置。这个属性控制的是系统键盘区域的颜色模式,默认值跟随系统的Brightness,跟应用的明暗无关。如果用户系统是亮色模式,即使你的应用切到了暗色,键盘仍然显示亮色外观,从视觉上看就是“暗色应用 + 白色键盘”的割裂感。

解决办法是在编辑页的TextFieldTextFormField上显式指定:

TextField( keyboardAppearance: isDark ? Brightness.dark : Brightness.light, )

如果你有多处TextField,建议封装一个公共的AppTextField组件,把keyboardAppearance、光标颜色、输入装饰统一配置,避免每个页面单独修改遗漏。我在实际改造中把编辑页的标题输入框和正文输入框都替换成了这个封装组件,一次性解决了键盘区域颜色适配的问题。

6.3 自定义组件没有跟随主题的问题

还有一类组件是“继承了CustomPaint”或者“通过Painter绘制”的自定义图形。这类组件里用到的颜色是在paint方法里直接写的,不经过 Widget 树的主题传递,所以主题切换时它们不会自动更新。

解决思路有两种:一种是在CustomPainter的构造函数里传入需要的颜色,并在组件build时从Theme.of(context)获取颜色传进去;另一种是在shouldRepaint方法里比较颜色是否变化,如果变化则返回true触发重绘。

这两种方式我都用过,但更推荐第一种,因为第二种要额外写比较逻辑,而且很容易因为颜色对象是同一个引用而判断失误,导致重绘不触发。把颜色作为构造参数传进去,语义更清晰,后续维护也更直观。

6.4 OpenHarmony 真机上的适配问题

这个项目是重点面向 OpenHarmony 环境的,所以真机适配也需要专门说几句。我在 OpenHarmony 的开发板(RK3568 平台)上测试夜间模式时,发现几个和 Android 不太一样的地方:

第一,SystemUiOverlayStyle在部分 OpenHarmony 版本上对浅色图标的支持不一致。同一个代码,在 API 9 上状态栏图标变成白色了,在 API 10 上又变回黑色。这个大概率是系统版本差异导致的,没有统一的兼容办法,只能在代码里根据系统版本写分支判断。不过好在这个问题影响的只是状态栏图标颜色,不涉及核心功能,优先级不高。

第二,showModalBottomSheet的默认背景色在 OpenHarmony 上有时候不跟随ThemeData.bottomSheetTheme,导致弹出的面板是白底。我遇到这个问题后,放弃了在ThemeData里统一配置的方案,改为在每个showModalBottomSheet调用处显式传入backgroundColor。虽然代码冗余了一点,但兼容性最好,在开发板上测试没有再出现白底问题。

第三,SharedPreferences在 OpenHarmony 上的实现底层可能依赖于系统偏好存储,读写速度比 Android 上略慢。如果你的应用需要在启动时读取主题偏好,建议做一个“先默认亮色,再异步切换暗色”的降级方案,避免启动时阻塞太久。不过在我看来,等待几十毫秒换取无闪白的使用体验,还是值得的。

这些坑不一定每个开发者都会遇到,但如果你也在 OpenHarmony 真机上做 Flutter 应用,提前有个心理预期,遇到问题时排查起来会快很多。

7. 从夜间模式延伸出去的思考:主题体系的后续扩展

夜间模式做完之后,我回头看这个项目的整体架构,发现这次改造带来的收益其实远超“加了一个暗色皮肤”本身。

一方面,通过这套语义化颜色体系和 ThemeExtension 机制,后续做“自定义主题色”、“跟随壁纸取色”这类功能都会变得非常顺手。只要加一组新的颜色实例,在AppTheme里注册一下,系统就能自动完成切换、动画、组件联动。主题能力已经从“硬编码时代”正式升级成了“配置化时代”。

另一方面,这次主题改造逼迫我把整个应用的 UI 层重构了一遍,所有硬编码颜色、重复的样式声明、不够规范的自定义组件都被清理掉了。现在改一个界面的动效或者圆角,只需要修改公共封装里的几行代码,不用再满项目搜Color(去替换。这种维护成本的下降,在做完这个功能之后感受特别明显。

如果你也想给自己的应用加夜间模式,我的建议是不要急着改代码,先花半天时间把应用的“颜色清单”整理一遍,想清楚哪些颜色属于背景层、文本层、装饰层、品牌层,然后按照这个分类去重新组织代码。这个设计阶段投入的时间,会在后面所有页面的适配过程中成倍地省回来。

我在这个项目里实际验证下来,Flutter 自带的主题系统完全够用,并不需要额外的第三方主题库。真正决定夜间模式体验好坏的,不是技术选型,而是你有没有一套清晰的颜色语义定义,愿不愿意花时间把每个组件都过一遍。做到这两点,一个护眼又美观的夜间模式,就真的离你不远了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询