深夜的书桌前,屏幕的白光像一盏探照灯直直打在脸上,手指还在键盘上敲着日记和待办,眼睛却已经开始抗议。这大概是每个长期用记事本类应用的开发者都经历过的场景。我之前用 Flutter 做了一个跨端记事本,计划里本身就包含 OpenHarmony 鸿蒙平台,一路做到第四期,今天专门聊其中一个被问得最多的功能:一键切换夜间模式。在 Flutter 与 OpenHarmony 鸿蒙的联合开发里,夜间模式不只是把背景色换深,更涉及主题体系设计、状态持久化、平台联动和一堆容易被忽略的细节。这篇文章会从需求分析讲到代码落地,再讲到鸿蒙设备上的真实适配,希望给正在做类似功能的你提供一套可以直接抄作业的思路。
1. 从“眼睛酸”到“一键切换”:夜间模式的价值和设计底线
1.1 记事本场景下的护眼逻辑
记事本和视频类 App 有个本质区别:用户会长时间盯着大段文本。晚上躺在沙发上写点东西,或者在被窝里临时记个灵感,亮白背景下的高亮度蓝光会让人很快疲劳。很多人觉得夜间模式就是“把白底换成黑底”,但实际做下来,纯黑底加纯白字的对比度太高,反而会刺眼。护眼的底层逻辑是降低屏幕整体亮度、减少蓝光比例,同时保持文本可读性。深灰色背景(比如 #121212)配合低饱和度的浅灰文字,比纯黑和纯白更合适。
我记得自己做第一版的时候图省事,直接把颜色取反,结果文字看起来像荧光棒一样亮。后来查了一些对比度资料,才意识到视觉舒适度是一个范围,不是越大越好。WCAG 建议正文文本对比度至少 4.5:1,大号文本可以到 3:1。夜间模式下,背景与文字对比度可以适当降低,但不能低于 7:1,否则长时间阅读会费力。
1.2 “美观”在夜间模式里的具体含义
美观不等于所有地方都用深色填充,而是要保留层次感。记事本界面通常包括背景、文字、输入框、按钮、卡片、图标等。如果所有元素都是同一个灰度,界面会变得扁平,重点信息丢失。我采用的策略是:背景用三级灰度,最底层页面背景最深(或最浅,取决于你想突出什么),卡片浮层略亮,弹窗再亮一级。文字也分主次,标题和正文用高对比度白色系,时间戳、字数统计、提示信息用低对比度灰色系。这样从视觉上引导用户视线,界面看起来有呼吸感,同时减少长时间注视的疲劳。
在我这个记事本中,夜间模式还承担着品牌感的表达。主题色选了蓝绿色系,在深夜场景下降低饱和度,避免太跳的颜色刺激眼睛。切换动画也别做得太花哨,200 到 300 毫秒的淡入淡出足够。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主题层设计:用 Flutter 的 ThemeData 和 ColorScheme 搭好地基
2.1 先搞清楚 Flutter 主题机制里几个关键概念
Flutter 中做深浅主题切换,核心是 ThemeData、ColorScheme 和 Brightness。ThemeData 是整个 App 的主题配置,里面包含很多子主题,比如文字、按钮、输入框、卡片、弹窗。ColorScheme 是 Material Design 的语义色板,定义 primary、secondary、surface、error 等抽象颜色。Brightness 则是区分亮色和暗色的标准,系统会根据它决定默认组件的表现方式。
很多新手直接把颜色写死在 TextStyle 里,一旦切换主题,页面上的字和背景就可能“阴阳两隔”。正确做法是让所有颜色都从 Theme.of(context) 或 ThemeExtension 中取,而不是使用 Colors.white 这样的常量。我在项目里定义了一个扩展主题类,专门放自定义语义颜色,这样记事本里的特殊需求也能被主题管理。
以下是我项目中主题定义的核心代码(Flutter 3.x 和 Material 3):
dart复制import 'package:flutter/material.dart';
class AppColors extends ThemeExtension<AppColors> {
final Color background;
final Color surfaceCard;
final Color textPrimary;
final Color textSecondary;
final Color cursor;
final Color selection;
final Color divider;
const AppColors({
required this.background,
required this.surfaceCard,
required this.textPrimary,
required this.textSecondary,
required this.cursor,
required this.selection,
required this.divider,
});
@override
AppColors copyWith({
Color? background,
Color? surfaceCard,
Color? textPrimary,
Color? textSecondary,
Color? cursor,
Color? selection,
Color? divider,
}) {
return AppColors(
background: background ?? this.background,
surfaceCard: surfaceCard ?? this.surfaceCard,
textPrimary: textPrimary ?? this.textPrimary,
textSecondary: textSecondary ?? this.textSecondary,
cursor: cursor ?? this.cursor,
selection: selection ?? this.selection,
divider: divider ?? this.divider,
);
}
@override
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
if (other is! AppColors) return this;
return AppColors(
background: Color.lerp(background, other.background, t)!,
surfaceCard: Color.lerp(surfaceCard, other.surfaceCard, t)!,
textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!,
textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!,
cursor: Color.lerp(cursor, other.cursor, t)!,
selection: Color.lerp(selection, other.selection, t)!,
divider: Color.lerp(divider, other.divider, t)!,
);
}
}
然后定义亮色和暗色两套主题:
dart复制class AppTheme {
static ThemeData light() {
const colorScheme = ColorScheme.light(
primary: Color(0xFF00696D),
secondary: Color(0xFF4A6365),
surface: Color(0xFFFCFDF6),
);
const appColors = AppColors(
background: Color(0xFFF7F8F3),
surfaceCard: Color(0xFFFFFFFF),
textPrimary: Color(0xFF191C1C),
textSecondary: Color(0xFF6B7475),
cursor: Color(0xFF00696D),
selection: Color(0x6600696D),
divider: Color(0x1A191C1C),
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: colorScheme,
extensions: [appColors],
// ...
);
}
static ThemeData dark() {
const colorScheme = ColorScheme.dark(
primary: Color(0xFF8FD1D2),
secondary: Color(0xFFB1C7C8),
surface: Color(0xFF121414),
);
const appColors = AppColors(
background: Color(0xFF121414),
surfaceCard: Color(0xFF1D2020),
textPrimary: Color(0xFFE4E7E4),
textSecondary: Color(0xFF9FA8A8),
cursor: Color(0xFF8FD1D2),
selection: Color(0x668FD1D2),
divider: Color(0x1AFFFFFF),
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: colorScheme,
extensions: [appColors],
// ...
);
}
}
2.2 让实际页面颜色从主题里取,而不是写死
记事本的主要界面是笔记列表和编辑页。列表项的背景、标题、时间戳、计数都要从主题里取。例如:
dart复制final theme = Theme.of(context);
final colors = theme.extension<AppColors>()!;
Text(
note.title,
style: TextStyle(
color: colors.textPrimary,
fontSize: 16,
),
)
Text(
note.updatedTime,
style: TextStyle(
color: colors.textSecondary,
fontSize: 12,
),
)
这样做的好处非常明显:切换主题时,所有依赖 Theme.of(context) 的组件会自动重建,不需要每个页面单独写状态判断。自定义组件里如果用了 ThemeExtension,需要我们自己实现 lerp 方法,这样在 AnimatedTheme 过渡时,自定义颜色也能平滑变化。
2.3 对比度计算:选色时别凭感觉
我一般会先用在线工具或脚本算一下对比度,保证符合 WCAG AA 标准。对比度公式是 (L1 + 0.05) / (L2 + 0.05),其中 L1 是较亮颜色的相对亮度,L2 是较暗颜色的相对亮度。选色时可以先定背景,再找满足对比度要求的文字色。比如深灰背景 #121212,白色文字对比度大约是 15:1,足够清晰;浅灰文字 #9FA8A8 对比度约 6.8:1,也适合次要文字。如果测试出来低于 4.5:1,我会手动调亮或调暗颜色,不把这个问题留给用户的眼睛。
3. 一键切换的实现:状态管理、持久化与动画过渡
3.1 状态管理选型:ValueNotifier 已经够用
夜间模式是一个全局 UI 状态,需要被 MaterialApp 监听,任何页面切换主题时都要同步更新。在 Flutter 里,我可以选择 Provider、Riverpod、Bloc,也可以直接使用 ValueNotifier。记事本项目不大,我不想为了一个主题状态引入太重的状态管理库,所以选择 ValueNotifier<ThemeMode>,配合 ValueListenableBuilder 或者直接在 MaterialApp 外层监听。
ThemeMode 有三个值:system、light、dark。我只做手动的一键切换,所以当前只需要 light 和 dark,但为了以后支持“跟随系统”,我仍然用 ThemeMode。
dart复制class ThemeController {
ThemeController() {
_load();
}
final ValueNotifier<ThemeMode> notifier = ValueNotifier(ThemeMode.light);
static const _prefsKey = 'theme_mode';
Future<void> toggle() async {
final next = notifier.value == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
notifier.value = next;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, next.name);
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
if (saved == 'dark') {
notifier.value = ThemeMode.dark;
}
}
}
MaterialApp 里这样接:
dart复制ValueListenableBuilder<ThemeMode>(
valueListenable: themeController.notifier,
builder: (context, mode, _) {
return MaterialApp(
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: mode,
home: const HomePage(),
);
},
)
这样在切换时,MaterialApp 会重新 build,所有使用 Theme.of(context) 的组件都会拿到新的主题。注意不要用 setState 包住整个 App,那会把页面状态也弄丢。
3.2 持久化:用户切完一次,下次打开还是夜间模式
上面的代码里用了 SharedPreferences。这是 Flutter 社区最常用的轻量级存储方案,存一个字符串足够。注意 _load 是异步的,所以启动时先按默认亮色渲染,等读取完成后再切换,可能会出现短暂的闪白。解决办法是启动时先读一次本地值,再决定渲染哪个主题。可以把读取逻辑提前到 main() 里,比如:
dart复制Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('theme_mode');
ThemeController().notifier.value = saved == 'dark' ? ThemeMode.dark : ThemeMode.light;
runApp(MyApp(controller: ThemeController()));
}
这里要特别注意,如果保存的值在初始化时没有读取成功,就会闪一下亮色。实测在鸿蒙设备上,闪白不是特别明显,但强迫症可以做一个 SplashScreen 或者直接在原生的启动页停留久一点。
3.3 用 AnimatedTheme 做平滑过渡
如果直接切 themeMode,整个界面会瞬间换色,虽然功能上没问题,但视觉上有点生硬。Flutter 提供了 AnimatedTheme,可以在主题变化时自动插值。但是 AnimatedTheme 需要包裹在 MaterialApp 内部才能生效,因为它需要拿到具体主题。
一个常见做法是在 builder 参数里处理:
dart复制builder: (context, child) {
return AnimatedTheme(
data: Theme.of(context),
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: child!,
);
}
AnimatedTheme 内部用的是 ThemeData.lerp,会对颜色、文字样式做插值。如果我们自定义 AppColors 也实现了 lerp,那么自定义颜色也会跟着动画变化。实测 250 毫秒比较合适,太短看不出来,太长会显得拖沓。
3.4 切换按钮和防抖
一键切换的入口我放在 AppBar 上,一个 IconButton,图标在太阳和月亮之间切换。这里有个小技巧:用 AnimatedSwitcher 包裹 Icon,切换时给不同 ValueKey,可以做一个淡入旋转效果。
dart复制IconButton(
onPressed: themeController.toggle,
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => RotationTransition(
turns: animation,
child: FadeTransition(opacity: animation, child: child),
),
child: isDark
? const Icon(Icons.dark_mode_outlined, key: ValueKey('dark'))
: const Icon(Icons.light_mode_outlined, key: ValueKey('light')),
),
)
防抖方面,toggle 本身是异步的,如果在动画过程中用户连续点击,会导致 notifier 多次赋值,最终结果还是切换一次,但会多写几次存储。我在项目里加了一个简单锁,如果上一次切换未完成,就忽略新的点击。这个细节在真机上会有体感差异。
4. 鸿蒙平台适配:在 OpenHarmony 上跑 Flutter 遇到的几个问题
4.1 系统深浅色模式的读取与联动
我的目标是让 App 内的一键切换优先于系统模式,但用户如果选择“跟随系统”,就要能读取鸿蒙设备的深浅色模式。Flutter 自带 MediaQuery.platformBrightnessOf(context),在 Android 和 iOS 上都能正确反映系统深浅色。在 OpenHarmony 鸿蒙设备上,Flutter 适配层是否完全支持这个接口,不同版本表现不一样。我在 OpenHarmony 3.2 的 RK3568 板子上实测,platformBrightnessOf 基本能正常工作,但某些定制版本会返回固定值。
最稳妥的方式是通过 MethodChannel 主动获取鸿蒙系统的深浅色模式。在 Flutter 侧写这样一个通道:
dart复制class SystemThemeChannel {
static const platform = MethodChannel('com.example.notepad/system_theme');
static Future<bool> isSystemDarkMode() async {
try {
final result = await platform.invokeMethod<bool>('isDarkMode');
return result ?? false;
} on PlatformException catch (_) {
return false;
}
}
}
鸿蒙侧如果用 ArkTS,可以在 UIAbility 或页面中注册 MethodChannel。具体位置视 Flutter 鸿蒙适配方案而定,不同集成方式略有差异。大致逻辑是获取系统 Configuration 里的 colorMode,然后返回给 Flutter。我不在这里贴太多平台代码,因为不同版本的 API 可能有变化,核心思路是:启动时或者在主题切换面板里拉取一次系统模式,然后与用户手动选择合并。
4.2 状态栏和导航栏的颜色适配
夜间模式下,如果整个页面变黑了,但系统状态栏还是黑色文字,就会看不清时间信号。Flutter 里常用 SystemChrome.setSystemUIOverlayStyle 来调整状态栏图标颜色,在鸿蒙上也能生效。切换主题时,需要同步调用。
dart复制void updateSystemOverlay({required bool darkMode}) {
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: darkMode ? Brightness.light : Brightness.dark,
systemNavigationBarColor: darkMode ? const Color(0xFF121414) : const Color(0xFFF7F8F3),
systemNavigationBarIconBrightness: darkMode ? Brightness.light : Brightness.dark,
),
);
}
要注意,在鸿蒙设备上如果页面内容延伸到状态栏后面,还需要确保页面顶部的 SafeArea 计算正确。夜间模式切换时,状态栏颜色如果不跟着变,会非常突兀。我选择在主题切换方法里直接调用这个函数,而不是依赖页面 build,因为 build 的时机不一定在 UI 绘制之前。
4.3 真机和模拟器调试差异
OpenHarmony 的开发者经常用的是 RK3568 / RK3588 开发板或者模拟器。在模拟器上,主题切换一般没问题,因为模拟器通常使用宿主机渲染。真机上有一个坑:系统栏颜色在切换后偶尔不刷新,原因是 Flutter 引擎和系统侧的 Surface 同步延迟。遇到这种情况,可以在 addPostFrameCallback 里再调用一次 updateSystemOverlay,强制刷新。
还有一次我在 RK3588 设备上测试,切换夜间模式后整个页面字体发虚,排查半天发现不是主题问题,而是设备系统开启了字体缩放,Flutter 的文字渲染在特定缩放下会触发一个旧的 bug。后来我把系统字体缩放调到 1.0 再测就正常了。这个和主题模式没有直接关系,但在做夜间模式适配时容易被误判成颜色问题。
4.4 性能:主题切换时避免整树重建
MaterialApp 的 themeMode 变化会触发所有页面重建。如果某些页面在 initState 里加载了文件或进行了解密操作,切换主题时会明显卡顿。我的做法是:
- 把不需要主题化的页面内容用
RepaintBoundary隔离,减少重绘范围; - 列表页使用
ListView.builder,让 item 尽量轻量; - 避免在 build 方法里执行耗时操作。
实测下来,在 RK3568 上切换主题大约 20 毫秒左右能完成布局更新,动画过程中帧率会略有下降,但不会掉到不可接受的程度。如果项目里使用了大量自定义绘制,就要检查绘制方法里是否缓存了颜色值。
5. 实战落地中的细节、坑与优化
5.1 编辑器光标、选中色、搜索高亮都要跟着主题走
记事本的核心是编辑器。TextField 的默认光标颜色是主题色,选中色是主题色的半透明。夜间模式下如果主题色过亮,选中文字会显得刺眼。我在 ThemeData.inputDecorationTheme 里统一设置了:
dart复制inputDecorationTheme: InputDecorationTheme(
cursorColor: colorScheme.primary,
selectionColor: colorScheme.primary.withAlpha(0x33),
selectionHandleColor: colorScheme.primary,
)
如果还用了自定义的富文本编辑器(比如 SelectableText 或自绘编辑器),需要额外处理 TextSelectionTheme:
dart复制textSelectionTheme: TextSelectionThemeData(
cursorColor: colorScheme.primary,
selectionColor: colorScheme.primary.withAlpha(0x33),
selectionHandleColor: colorScheme.primary,
)
不要忘了搜索关键词的高亮,如果背景是深灰,高亮色最好用低饱和度的黄色或绿色,并提高文字对比度。
5.2 浅色和深色主题下的图片与图标
记事本里如果插入了图片,夜间模式下直接把整张图的亮度和饱和度过一遍滤镜会更好看。可以在图片外面套一个 ColorFiltered,但这样会影响图片内容,用户可能觉得质量受损。比较稳妥的做法是给图片加一个半透明的黑色遮罩层,模拟“降亮度”效果。图标则建议全部使用 Icons.* 或者从主题取色的 SVG,不要用固定颜色的 PNG。如果实在要用 PNG,准备亮色和暗色两套资源,用 Theme.of(context).brightness 判断加载哪一套。
我的项目里一开始用了固定灰色 PNG 图标,夜间模式下灰乎乎一片,后来全换成了 IconData,通过 IconTheme 自动适配,省了很多事。
5.3 弹窗、底部弹窗和菜单的同步切换
很多应用只在主页面做了夜间模式,一弹出来 Dialog 还是白底的,非常难看。确保 Dialog、BottomSheet、PopupMenuButton 都使用 Theme.of(context) 自动继承主题。如果你在 showDialog 的 builder 里手动指定了白色背景,记得改成从主题取。我踩过的一个坑是:某个弹窗用了 Card 组件,但 Card 的颜色在亮暗主题下默认都是白色,需要显式设置 color: theme.colorScheme.surface 才能正确跟随。在 Material 3 中,Dialog 的背景色默认取自 colorScheme.surfaceContainerHigh,如果你的 ColorScheme 没定义这个属性,会使用默认值,可能导致与页面背景不一致。我在暗色主题里显式设置了 dialogTheme 和 bottomSheetTheme:
dart复制dialogTheme: DialogThemeData(
backgroundColor: const Color(0xFF1D2020),
surfaceTintColor: Colors.transparent,
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Color(0xFF1D2020),
surfaceTintColor: Colors.transparent,
),
这里用 surfaceTintColor: Colors.transparent 也是为了避免 Material 3 在暗色下给弹窗叠加一层紫色高光。
5.4 切换时的闪烁问题与定位方法
夜间模式切换最影响体验的是“闪白”。常见原因有三个:一是页面某些组件使用了硬编码的 Colors.white 或 Color(0xFFFFFFFF),在暗色主题下仍然保持白色;二是启动时本地存储还未读取完,以亮色渲染了一帧;三是路由栈里的页面没有同步重建,切换后返回上一页时仍是旧主题。
第一个问题最好在代码评审时通过全局搜索排查。硬编码颜色在项目初期很容易出现,越早发现越好。我后来写了一个临时的 lint 规则,禁止在 UI 层直接使用 Colors.white,全部改用 Theme.of(context).colorScheme.surface。
第二个问题通过把存储读取改为同步阻塞或启动时等 SharedPreferences 加载完成可以缓解。
第三个问题比较隐蔽。如果你用了 PageView 或自定义路由,旧页面可能被缓存。解决办法是在 MaterialApp 的 themeMode 变化时,给路由表加一个 KeyedSubtree,或者重启路由栈。但在 Flutter 中,只要页面 widget 在 build 期间引用了 Theme.of(context),当 MaterialApp 重建时会一并更新,一般不会出现旧主题问题。如果还出现,就要检查是否在某个 Overlay 里缓存了独立的 MaterialApp。
5.5 自查清单:夜间模式上线前过一遍
以下是我每次做主题切换功能时会在项目里过一遍的清单,按重要性排:
- 所有页面背景使用语义颜色,而不是
Colors.white; TextField光标、选中色、手柄颜色已设置;- 状态栏、导航栏图标颜色在切换后正确更新;
Dialog、BottomSheet、PopupMenu背景色与页面一致;- 图片图片预览区域增加暗色遮罩,图标使用主题色;
- 分割线颜色降低对比度,避免亮线刺眼;
- 切换动画流畅,无闪烁;
- 本地存储读写正常,重启 App 保持选择;
- 在鸿蒙真机上实际测试,系统栏刷新无延迟。
这个清单是我踩了无数坑总结出来的,你抄作业的时候可以直接当成验收标准。
最后再分享一个我自己觉得特别重要的小技巧:从项目第一天就用语义颜色管理主题,而不是等所有页面写完了再回头改。我在记事本项目里前期偷懒硬编码了几个 Colors.white,后来加夜间模式的时候,光排查这几个颜色就花了半个晚上。如果是新项目,早点建立 ThemeExtension 体系,后面接鸿蒙、接平板适配都会省很多力气。夜间模式看起来是一个很小的功能,但它是检验一个项目主题化设计是否规范的最直接标尺。
