做完了记事本的基础功能和数据存储之后,我盯着白晃晃的界面看了整整一周,总觉得哪里不对劲。直到某天晚上躺在床上用手机改笔记的时候,才反应过来问题出在哪:这个应用最密集的使用场景,压根就是睡前记想法、半夜补计划、随手存第二天要办的事。在灯光昏暗的卧室里,一个亮白色背景的记事本简直是在跟自己的眼睛过不去。
所以就有了这个系列第四篇的初衷:给 Flutter + OpenHarmony 的鸿蒙记事本加上一键夜间模式,既要护眼,又不能丑得让人不想开。这一篇不打算只贴几个颜色变量,而是把从方案选型、颜色体系设计、状态管理、持久化恢复,到 OpenHarmony 真机适配的整条链路完整走一遍。最终效果是:点击一个按钮,整个应用从背景、文字、输入框到状态栏图标同步切换,重启应用之后还能记住用户的选择。
如果你正在用 Flutter 开发 OpenHarmony 应用,或者单纯想给自己现有的项目加一套夜间模式,这篇内容应该能帮你省下不少自己踩坑的时间。
1. 需求拆解与方案选型
1.1 交互目标与边界确认
夜间模式听起来是个很简单的事情,但真正动手之前,我还是把需求边界列了一遍,避免做着做着开始失控。
首先明确交互形式:一键切换,不搞渐变滑块、不搞定时自动切换、不搞跟随系统亮度那种复杂逻辑。原因很简单,记事本是一个工具型应用,用户对它的预期是打开就能用,任何需要多一步操作的设置都是在增加使用成本。所以首版我只做单选切换:当前是日间模式就切到夜间,当前是夜间模式就切回日间,按钮就放在首页的 AppBar 右上角,一个图标搞定。
其次是影响范围:夜间模式不是只换一个背景色那么简单。在我的记事本里,至少包含以下这些元素需要跟着变:主背景、AppBar 背景、文字颜色、输入框边框和填充色、分割线、图标颜色、弹窗背景、按钮颜色、光标颜色、键盘适配后的页面背景、状态栏字体颜色。漏掉任何一个,切到夜间模式后都会出现一块很不协调的亮色区域。
然后是持久化问题:用户切到夜间模式后关掉应用,下次打开如果又变回白色,这种体验非常糟糕。所以切换结果必须持久化存储。
最后是平台适配:OpenHarmony 上的 Flutter 版本和社区版本有差异,有些插件并没有完全跟上主流版本,所以方案选型时必须考虑 OpenHarmony 生态的插件支持现状。
1.2 主流主题切换方案横评
在 Flutter 里做夜间模式,网上一搜能出来一堆方案,我用一个下午把主流的几种都试了一遍,逐个说下实测感受。
第一种是直接用ThemeData.light()和ThemeData.dark()两个内置主题,通过MaterialApp的theme和darkTheme两个参数切换。这个方案最省事,但缺点也很明显:内置主题太通用,风格跟应用自身的视觉语言完全不搭,尤其深色主题的默认背景色是偏灰的,放在记事本场景里看起来有点像系统设置页,没有记事本该有的专注感。
第二种是ColorScheme.fromSeed配合 Material 3 的动态配色。这种方案适合做个性化主题的应用,能根据用户的主色自动生成整套色板,但动态颜色在不同平台上的渲染效果不完全一致,而且调试时很难精确控制某个组件的具体颜色。我自己的项目里主色调已经定死了,不需要这种灵活性。
第三种是用第三方状态管理库来管理主题状态,比如provider、riverpod、bloc。这类方案适合大型项目,状态多、逻辑复杂,用统一的框架来管理是合理的。但记事本这个项目本身引入的第三方依赖已经不少了,为了一个布尔值的主题状态再引入一套状态管理框架,属于杀鸡用牛刀,还会增加维护成本。
最终我选择的是ValueNotifier<ThemeModel>+ValueListenableBuilder+ThemeData组合。理由很简单:主题状态本质上就是isDark这一个布尔值,Flutter 自带的ValueNotifier就能完美承担状态通知的职责,ValueListenableBuilder负责局部刷新,不需要让整棵组件树感知主题变化。ThemeData负责承载全部颜色配置,组件通过Theme.of(context)来取颜色,保证颜色来源统一。
1.3 为什么用 ValueNotifier 而不是其它方案
展开说一下这个选型背后的逻辑。我见过不少项目,为了一个夜间模式问题,强行引入provider,然后整个应用的状态都被塞进同一个ChangeNotifier里,导致每次 setState 都触发大范围 rebuild,性能问题反而不如不引入状态管理。
ValueNotifier的优势在于它足够轻,内部实现就是一个ChangeNotifier加上一个value值,只能在值变化时通知监听者。配合ValueListenableBuilder,它会把 builder 限定在某个局部范围内,切换主题时只有依赖了ValueListenableBuilder的那段内容会重建,其它组件不受影响。在主题切换这个场景里,这就够了。
当然,如果项目后续要扩展到多主题、跟随系统、定时切换等复杂需求,届时再引入provider或者riverpod也不迟。技术选型永远是为当前需求服务的,不要为了未来的可能性提前引入复杂度。
2. 颜色体系设计与 ThemeData 构建
2.1 从设计稿到代码的色板定义
夜间模式做得丑,九成是颜色没设计好,直接拿纯黑做背景、纯白做文字是绝对不行。纯黑背景下,纯白文字会产生极高的对比度,在暗光环境里依然刺激眼睛。夜间的核心逻辑是降低整体亮度和对比度,而不是简单地把颜色反转。
我按照 Material 3 的深色配色原则,结合 OpenHarmony 设计规范中推荐的深色背景色值,定义了一套记事本专属的色板。先看日间模式的:
日间模式主背景用的是偏冷的浅灰白色,色值为#F7F8FA,不是纯白。原因是在室内光线下,纯白的反射率太高,看久了眼睛容易累。AppBar 背景和页面背景保持一致,让视觉上有整体感,文字颜色用深灰色,而不是纯黑。输入框边框用了浅灰色描边,配合圆角,精致感就出来了。
夜间模式,背景换成了深蓝灰色#121212,这是 Material 3 深色主题的标准背景色,比纯黑柔和许多。文本颜色定义为#E0E0E0,不是纯白,亮度在80%左右,确保可读性的同时降低刺眼程度。输入框的边框颜色从浅灰变成中灰色,分割线从浅灰变成深灰。这样整套颜色过渡下来,对比度控制在一个舒适的区间。
我花了不少时间调整一个关键细节:日间模式和夜间模式的语义色。比如删除按钮的红色,日间模式用的#E53935,夜间模式就不能照搬,这个红色在深色背景上会显得更加刺眼,我改成了#EF9A9A,饱和度降低,看起来温和很多,但依然能明确传达"删除"的语义。
2.2 集中管理颜色,杜绝硬编码
之前版本里,所有颜色都是直接写在Color(0xFF...)形式混在代码里的,比如某个按钮的 background 直接写了一个色值,另一个页面要用同一个颜色时又复制了一份。这导致夜间模式改造时,我要像捕鱼一样把所有硬编码颜色的位置都捞出来。虽然编辑器支持全局搜索,但颜色值这么多,每处都要判断语义,效率极低。
这次改造我做一个中间层:创建了一个AppColors类,把所有的语义色统一管理,每个颜色只定义一次。日间模式和夜间模式各定义一组颜色。这样做的好处是:组件代码里不再出现Colors.white这种具体颜色,而是通过Theme.of(context).colorScheme.surface之类的方式获取语义色。
实际项目里,你会发现 Flutter 内置的ColorScheme并不能覆盖所有场景。比如记事本首页列表项的按下水波纹颜色,ColorScheme里没有直接对口的字段。这时候就通过ThemeExtension来扩展。我会建一个AppColorsExtension类,继承ThemeExtension,里面放上ColorScheme中没有的自定义字段,然后注册到ThemeData.extensions里。
方案比较清晰:
- 基础色板:
ThemeData自带的ColorScheme承载全部通用颜色 - 扩展色板:
AppColorsExtension承载记事本特有的颜色,比如首页卡片背景色、列表分割线色、标记标签的背景色
核心原则只有一条:组件里禁止直接写具体色值,全部从Theme取。日间和夜间只是配置不同,组件自身完全无感和透明。
2.3 ThemeData 构建的核心实现
理解了色板之后,来看ThemeData是怎么构建的。我封装一个AppTheme类,提供两个静态方法,一个生成日间ThemeData,一个生成夜间ThemeData。
生成日间主题时,通过ColorScheme.fromSeed生成基础色板,seedColor是记事本的主色调。这里要特别说明:ColorScheme.fromSeed生成的颜色都是动态计算的,不会完全等于你传入的 seed 值。它会把 seed 作为基准,推导出一整套协调的颜色组合。但它的推导算法不一定符合你的视觉预期,所以最终还是要手动覆盖关键字段。
夜间主题同理,但brightness要设置为Brightness.dark。这很重要,ColorScheme的brightness字段会影响 Flutter 内部大量组件的默认渲染逻辑,比如输入框光标颜色、AppBar 文字颜色、Dialog 背景色等等。如果只改颜色不改 brightness,会出现大量组件配色异常。
构建完ThemeData之后,还有个关键配置:字体在日夜间切换时的渲染效果。TextTheme我保持统一的字号字重,不做夜间专属调整。但我在实测中发现,某些低端设备上切换夜间模式后,Text 的渲染效果会有细微差异,这是 Flutter 引擎层对深色背景上的文字做了抗锯齿优化导致的,属于正常现象。
3. 主题状态管理与一键切换的核心实现
3.1 主题模型与切换入口
颜色定义好之后,就进入状态管理环节。我建了一个ThemeModel类,继承ChangeNotifier,内部持有一个ThemeMode枚举值,初始值为ThemeMode.light。对外暴露一个toggleTheme方法,切换isDark状态,然后通知监听者。
这时候MaterialApp需要监听ThemeModel,当主题切换时重建整棵组件树。实现方式是用ListenableBuilder包裹MaterialApp,这样它会在ThemeModel变化时重新 build,把新的theme和darkTheme传下去,同时通过themeMode告诉 Flutter 当前应该采用哪套主题。
这里有个常见误区:很多人会把ThemeModel放进InheritedWidget或者Provider里,然后每个组件都去读一遍主题状态。实际上,主题切换是全局性的,只有MaterialApp真正需要感知它。组件只需要通过Theme.of(context)就能拿到当前主题下的颜色,不需要感知ThemeModel本身。这正是 Flutter 主题系统的聪明之处:颜色配置集中,消费方式分布化。
一键切换按钮的逻辑就简单了:点击后调用_themeModel.toggleTheme(),ListenableBuilder监听到变化,MaterialApp带着新的themeMode重新 build,整棵树自动用到新主题。从用户角度看,整个界面在一帧内完成切换,几乎感知不到卡顿。
3.2 切换动画:AnimatedTheme 的妙用
夜间模式的切换如果毫无过渡,生硬地从一个主题跳到另一个主题,体验说实话不算差,但缺少提精致感。这里要介绍一个平时容易忽略的组件:AnimatedTheme。
AnimatedTheme是Theme的动画版本。当传入的ThemeData发生变化时,它会在新旧主题之间做一个隐式动画过渡,时长和曲线都可以配置。把MaterialApp内部的Theme换成AnimatedTheme,颜色、背景、文字颜色就会在几百毫秒内平滑过渡,观感上会有一种非常柔和自然的切换效果。
我在真机上分别测试了无动画和 400ms 动画的效果,实话实说,动画版本在观感上确实明显更好。但要注意两点:第一是过渡时间不能太长,超过 600ms 就会显得拖沓,400ms 是一个比较平衡的值,感知得到过渡又不会耽误操作;第二是 TransitionBuilder 需要正确处理,否则会出现列表项在切换动画期间闪白的情况。
3.3 持久化:让用户的选择被记住
主题切换完了,如果重启后被打回原形,这个功能基本算白做。所以就轮到持久化上场了。
Flutter 生态里做本地持久化,最常用的是shared_preferences,它是一个轻量级键值对存储方案,适用于存储这种简单的布尔值。但这里有一个 OpenHarmony 平台特有的坑:Flutter 官方版本的shared_preferences插件对 OpenHarmony 的支持并不完整,第一次直接引用官方版本,编译时报错了。查了一下,OpenHarmony 的 Flutter 适配仓库提供了一个专门的插件系列,其中就包括shared_preferences的 OpenHarmony 适配版。
这个问题的根源在于 Flutter 插件体系里,Android 平台的实现是有一套标准 API 的,OpenHarmony 如果要求完全兼容 Android API 也可以,但那样会依赖 Android 特有的机制。OpenHarmony 团队选择的是通过自有的ohos目录结构提供原生实现,所以插件要同步使用适配仓库里的版本。
持久化逻辑实现:初始化时从SharedPreferences里读isDarkMode这个键,如果有值就恢复对应的ThemeMode。切换主题时,先更新内存中的状态,再异步写入SharedPreferences。写的时候要注意异步失败的问题:SharedPreferences.setBool返回的是一个 Future,如果用户快速连续点击切换按钮,存在并发写入导致状态不一致的可能。我加了一个简单的锁或者直接利用async/await顺序执行,确保每次写入都以前一次的完成为前提。
另外补充一个细节:不要把SharedPreferences的初始化放在main()里阻塞等待,因为SharedPreferences.getInstance()在部分机型和平台上有一定的 IO 延迟,放在启动流程里会拉长启动耗时。我一般是在启动页异步初始化,主题恢复则放在SplashScreen阶段去读。这样不会阻塞首帧,也不会出现启动后黑白屏闪跳。
4. 页面适配实战:从首页到输入页的全面改造
4.1 用 ThemeData 统一替换硬编码颜色
整体配色框架就绪之后,最细碎也最耗时的就是逐页排查硬编码颜色。我前两版里随手写的Colors.white、Colors.black、Color(0xFFEEEEEE)之类的,全部要替换成语义色。
打开 IDE 的全局搜索,输入Color(0xFF,搜索结果里大概有三四十处。每一处都要判断它的语义是什么:是背景、文字、边框、还是水波纹颜色。然后对应改成Theme.of(context).colorScheme.surface、textTheme.bodyLarge?.color、dividerColor等等。
这里建议用 VS Code 的批量替换加手动确认双保险。全自动替换容易引出问题,比如有人把两个空格的颜色合并成一个字符串,导致报错。手动确认能保证每一处改动都是可控的。
排查完这些颜色之后,还有一个容易被忽略的重灾区:自定义的TextStyle。直接写在TextStyle(color: Colors.grey[600])里的颜色如果不改,在夜间模式下会变成深灰色文字趴在深色背景上,几乎看不清。正确的做法是把颜色交给组件层,TextStyle里只定义字号、字重、行高等,颜色通过Theme.of(context).textTheme统一控制,或者从Theme.of(context).colorScheme取。
4.2 首页卡片和列表项的夜间适配
首页是这个记事本的颜面,适配做得怎么样直接决定用户的第一印象。首页主要包含几个部分,AppBar、列表、新建按钮。
AppBar 在日间模式下是浅色背景配深色文字,夜间模式下需要变成深色背景配浅色文字。ThemeData的appBarTheme已经帮我处理了这个逻辑,但有一点要特别声明:设置scrolledUnderElevation属性,解决列表滚动到 AppBar 下方时出现的阴影叠加问题。在夜间模式下,这种阴影会显得格外突兀。
列表项卡片在夜间模式下要跟背景有层次区分,但又不能太白。我用ColorScheme.surfaceContainerHigh作为卡片背景色,夜间模式下它会自动变为比背景色稍微亮一点的深色,这样视觉上依然能分辨出卡片和背景的边界。
一个让我当时调试了很久的细节:列表项里我用了ListTile,它的selected状态默认会渲染一层半透明的覆盖色。夜间模式下这层覆盖色如果太明显,会干扰列表项的正常阅读。解决办法是给ListTile的selectedTileColor设置为Colors.transparent,或者不设置selected值,改用自定义的背景色方案。
4.3 编辑页输入框和键盘区域的特殊处理
编辑页是夜间模式最重要的战场,因为用户在这个页面停留的时间最长。输入框在日间模式用的是浅灰边框加白色背景,如果夜间模式直接改成深色背景加亮色边框,反而会很刺眼。我采用的是暗色背景加比背景色稍微亮一点的中性色边框,光标颜色保持主题色不变,这样视觉引导会比较清晰。
键盘区域的问题可能很多人没注意到:在 Flutter 中,键盘弹出和收起时,页面底部的padding会通过MediaQuery.viewInsets.bottom动态变化。在夜间模式下,键盘弹出瞬间,页面底部如果出现白条、白块,大概率是因为聚焦时的背景色没设置成主题背景。排查方法是观察键盘弹出动画的每一帧,看哪里出现白色闪烁。我当时定位到问题出在Scaffold的resizeToAvoidBottomInset行为,以及一个自定义的SafeArea背景色没有跟随主题。去掉硬编码后正常了。
4.4 状态栏与系统导航栏适配
状态栏和系统导航栏是主题切换时容易出现视觉断裂的部分。因为在 Flutter 的页面边界之外,状态栏和导航栏实际上是系统 UI,不归 Flutter 直接控制。夜间模式下,如果状态栏的图标依然是深色的,趴在深色背景上会完全看不见。
Flutter 里控制状态栏样式用SystemChrome.setSystemUIOverlayStyle,通过状态栏图标亮度来区分:深色背景用浅色图标,浅色背景用深色图标。
但由于 Flutter 的 UI 线程和系统 UI 不是同步渲染的,切换主题时会出现一个时间片,状态栏颜色已经变了但页面还在动画中,或者页面已经变了但状态栏延迟更新,视觉上看起来就是闪烁。我的处理方案是在AnimatedTheme的动画完成回调里再设置状态栏样式,或者用SystemChrome.setSystemUIOverlayStyle加上较短的延迟,让状态栏的颜色与页面主体颜色同步完成切换。
OpenHarmony 系统对窗口状态栏的处理方式和 Android 略有差异,在 OpenHarmony 真机上,单纯设置状态栏图标亮度可能对某些系统版本不生效,需要结合窗口的FullScreen状态设置来配合。我实测在标准 OpenHarmony 版本上,通过SystemChrome.setSystemUIOverlayStyle能够正常控制状态栏图标颜色,但个别预览版系统会出现失效,需要在设置里同步配置系统导航栏的对比度。
5. OpenHarmony 真机适配与验证
5.1 OpenHarmony Flutter 版本的特有适配
在 OpenHarmony 上跑 Flutter 应用,最先需要确认的就是 Flutter SDK 的版本。OpenHarmony 的 Flutter 适配走的是一个独立的仓库,版本跟进比 Google 官方 Flutter 慢不少。所以在开始主题改造之前,我确认了当前的 Flutter SDK 是 OpenHarmony 适配版。
某个版本的差异导致一个问题:ColorScheme中一些新字段在旧版 Flutter 的ThemeData中不存在。如果你按最新 Flutter 的 API 写代码,然后拿到 OpenHarmony 适配版去编译,会发现一堆 "undefined getter" 的报错。解决办法是参考 OpenHarmony 适配仓库的 API 变更说明,找出版本差异,用兼容写法来规避。比如surfaceContainerHigh这个字段在旧版中可能不存在,我就只能用surfaceVariant加透明度模拟。
还有一个值得注意的适配点:OpenHarmony 系统的MediaQuery在某些场景下返回的textScaleFactor行为和 Android 不同。夜间模式下如果开启了系统字体加粗,会影响页面里的文字布局。我在设计时统一用TextTheme的字号,不使用绝对值,这样就避免了系统字体调节带来的布局错乱。
5.2 真机验证:从模拟器到 RK3568 开发板
开发调试阶段我主要用模拟器,但最终适配验证必须在真机上做。我手上的真机是一块 RK3568 开发板,OpenHarmony 版本是标准 release。这里直接说结论:模拟器上表现正常的夜间模式,到真机上可能会出现颜色渲染偏差,比如深色背景出现色带,文字边缘出现紫边。用 RK3568 开机实测下来,问题集中在两块,一个是部分颜色主题在 GPU 合成阶段被做了色彩空间转换,导致色值稍微偏移;另一个是动画性能不足时,主题切换过程中会出现短暂的白屏闪烁。
这些问题的处理经验:
- 颜色偏差:在 OpenHarmony 上验证
Color的 ARGB 值,不要使用带透明度的叠加色。如果发现某区域颜色偏淡,把半透明遮罩改为不透明的纯色填充。 - 白屏闪烁:把主题切换动画的时长缩短到 300ms,同时避免在动画期间执行耗时操作,比如读写数据库。
- GPU 合成问题:在
AndroidManifest.xml(在 OpenHarmony 项目中的等效配置文件)里为页面开启硬件加速,同时关闭开关层合成,减少合成层数。
5.3 不同 OpenHarmony 设备上的表现差异
OpenHarmony 是一个跨设备系统,手机、平板、开发板、甚至 PC 都能跑。不同设备的屏幕色域、GPU 能力、系统版本都有差异,所以主题适配不能只在一个设备上验证。
我在 RK3568 开发板和另一台设备上都做了测试,发现夜间模式的体验差距主要来自屏幕素质:低端屏幕往往无法准确还原深色背景的色阶,导致灰色过渡出现明显的条纹感。这里没法通过代码完全规避,但可以让深色背景颜色的色阶跨度小一点,不做纯黑到纯白的极端对比,减少屏幕的压力。我最终的夜间模式背景色不是纯#000000而是#121212,其中一个原因就在这。
还有一个跨设备差异点是前面提到的SystemUIOverlayStyle。在不同设备上,系统 UI 对状态栏样式的接管程度不同,有的设备需要你主动设置,有的设备会跟随 Flutter 的AppBarTheme自动变化。为了避免行为不一致,我统一的策略是在 page 入口处主动调用一次SystemChrome.setSystemUIOverlayStyle,不依赖默认行为。
6. 常见问题与排查技巧实录
6.1 问题速查表
做夜间模式功能,我整理了一份出现问题时的排查顺序,按频率排序:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 切换后部分页面仍是白色 | 该页面组件硬编码了颜色 | 搜索Color(0xFF和Colors.white |
| 状态栏图标看不清 | 没有设置SystemUIOverlayStyle | 在页面入口主动设置图标亮度 |
| 切换时白屏闪烁 | 主题动画期间触发了耗时操作 | 缩短动画时长,或把耗时操作放到动画结束后 |
| 重启后主题没有恢复 | 持久化插件未适配 OpenHarmony | 确认使用的是 OpenHarmony 适配版插件 |
| 深色背景出现色带 | 颜色过渡范围过大或 GPU 色深不足 | 使用#121212之类的渐进色替代纯黑 |
| 某组件颜色不随主题变 | 使用了Theme.of(context)之外的常量 | 检查是否使用了全局常量颜色 |
排查顺序的核心思想是:先看是不是硬编码颜色的问题,再看是不是状态管理没有正确触发 rebuild,最后看是不是平台适配差异。绝大多数问题都集中在第一类。
6.2 硬编码颜色排查秘诀
全局搜索Color(0xFF是最基本的办法。但实际项目中,还有一些更隐蔽的硬编码方式,比如Colors.grey[300]、Colors.white.withOpacity(0.8)这类写满了整份代码的表达式。
我总结了一个更系统的排查办法:把应用切换到夜间模式后,利用 Flutter 的 debug 模式,启动Widgets Inspector,逐层审查各个组件的颜色属性。看到哪个组件显示的颜色不对劲,直接点进去就能看到它的渲染代码,然后顺着代码往上找,很快就能定位到颜色来源。
另一个更彻底的办法是禁止在状态类组件里写死颜色。我会给自己的代码定一个规则:组件中凡是涉及颜色的代码,必须通过Theme.of(context)或者它派生的语义色来获取。这个约束靠人肉执行不现实,需要通过代码评审去检查,我就在提交前用一个脚本扫描所有Color(关键字,检测到直接调用的场景就打回修改。
6.3 AnimatedTheme 引发的动画异常排查
我还遇到过一个有意思的问题:夜间模式切换时,首页列表的项在动画中间出现了一层淡淡的白色覆盖,像是一张半透明的纸被盖在上面,动画结束后又恢复。
排查过程比较曲折。一开始怀疑是AnimatedTheme的动画参数问题,后来逐步缩小范围,发现是列表项里的图标使用了IconTheme的默认色,在主题过渡过程中,IconTheme的颜色解析提前结束,导致图标暂时采用了一个不正确的主题颜色。这个问题在日间模式完全看不出来,因为默认色和最终色的差异不大,但深色模式下就非常明显。
解决方案是在列表项构建时显式设置图标颜色,不依赖IconTheme的隐式继承。这样虽然多写了几行代码,但避免了主题切换动画期间的不确定性。
6.4 关于动态主题的扩展建议
代码写完之后,我又考虑了几个扩展方向,给有兴趣深入的读者留个思路。
第一个是跟随系统主题。OpenHarmony 的系统设置里可以切换深色模式,如果你的应用想跟随系统自动切换,可以在MaterialApp的themeMode里设置为ThemeMode.system,然后利用MediaQuery的platformBrightness来获取当前系统亮度模式。需要注意的事情是,platformBrightness在 OpenHarmony 上的数据来源和更新时机,需要确保配置了相关权限或者在生命周期回调里做监听。
第二个是自定义主题色。现在用户的记事本可以只切换 dark/light,但未来如果想支持用户自定义主色调,可以在ColorScheme.fromSeed的seedColor上做文章,把用户选择的颜色作为一个参数传递进去。这里的架构已经天然支持了,颜色配置层已经抽整洁了,加一个用户选项就可以。
第三个是三套主题以上的场景。比如护眼模式、夜间模式和阅读模式。三套主题本质上还是靠ThemeMode枚举去控制,只是枚举值增加,颜色配置增加。但要注意,ThemeMode本身只有light/dark/system三个值,要支持三套以上的主题,需要自己维护一个主题索引,而不是单纯依赖ThemeMode。同时,MaterialApp的theme和darkTheme不够用了,得自己动态构建ThemeData。
7. 最终效果与一点经验心得
功能全部开发完、真机测试通过之后,我把应用部署到 RK3568 开发板上,在傍晚昏暗的光线下实际体验了十来分钟。说句实话,夜间模式带来的护眼效果,远远超过切换一个深色图标带来的表面观感。原本白色背景在强光下产生的眩目感消失了,编辑笔记时眼睛的疲惫感明显下降。而且切换动画让整个应用的质感也跟着上了一个台阶,不再是那种生硬的主题翻转。
这次改造工程量并不大,但给我最大的启发是:主题系统跟状态管理是一样的逻辑,要么一开始就设计好,要么后面用数倍的精力去补救。如果我第一版就建好AppColors语义色体系,而不是把颜色散落在各组件里,这次改造最多需要一天。因为前面偷懒,实际耗时三天,其中一半以上时间花在排查硬编码颜色上。
另外一个小经验:主题切换的持久化一定要考虑好版本升级场景。我第一版持久化只存了isDark一个字段,后来如果加了自定义主色,就面临键名变更、默认值偏移等问题。建议从一开始就设计成版本化的配置实体,后续扩展时只需要处理迁移逻辑。
如果你也在做类似的功能,建议先花半小时把色板和组件清单列齐,再开始动代码。否则切到一半发现漏了某个页面,又得重新翻一遍,效率很低。希望这篇内容能帮你少走弯路。