
前言DAY8的进度定位、目标拆解与适用人群做到第8天智能备忘录项目已经跑通了基础的笔记增删改查、搜索和列表展示数据层和路由层也已经有了雏形。但把APP用起来之后很多人会开始觉得功能都有了哪里不太对劲——列表永远是新增顺序晚上用手机屏幕白得刺眼设置改起来得进代码改常量。DAY8这一天的目标就是把这些问题一次性收拾干净笔记时间排序、深浅主题切换、设置页面搭建、全局风格统一优化。这四件事单拆开都不难难的是把它们串起来的时候对项目结构的影响——主题状态怎么做全局共享、排序逻辑应该放在数据层还是UI层、设置页的配置项怎么和业务逻辑解耦这些才是今天真正要处理的点。这篇内容适合两类人看。一类是刚跟着实战系列把备忘录写到能用的朋友可以照着代码把DAY8的功能完整补齐另一类是已经在写Flutter应用、但一直觉得自己的项目代码也能跑就是很乱的开发者这篇里的全局统一和主题架构思路能直接搬到你自己的项目里。所有示例代码都基于Flutter开发OpenHarmony应用的场景涉及平台的差异点我会单独标注。我习惯先讲清楚每块功能的定位再给代码不然只照着敲一遍遇到异常情况还是不会排查。1. 笔记时间排序先理清什么时候排、按什么排、排完放哪1.1 排序触发链路在数据层统一处理而不是UI层反复sortDAY7之前我一直在列表页的build方法里直接调notes.sort((a, b) b.createTime.compareTo(a.createTime))当时图省事。但到了DAY8我决定把排序逻辑收拢到一个地方因为随着功能变多笔记列表出现了好几种来源主页全部笔记、搜索结果的过滤列表、某个标签下的筛选列表。如果三种列表分别在各自的build里写sort早晚会出现两个页面排序规则不一致的情况——比如搜索页忘了写二级排序结果时间相同的时候就乱序。我当时在项目里新增了一个统一入口叫NoteRepository所有笔记列表的获取都经过它class NoteRepository { FutureListNoteEntity fetchNotes({String? keyword, String? tagId}) async { var notes await localDb.queryNotes( keyword: keyword, tagId: tagId, ); return NoteSortUtil.applyDefaultSort(notes); } }这样所有页面拿到的列表天然就是排好序的UI层不需要关心排序逻辑。NoteSortUtil.applyDefaultSort是一个纯静态方法便于单独测试。1.2 复合排序时间相同的时候不能靠运气排备忘录的场景里用户能感知到的排序维度有三个置顶状态最优先、编辑时间最近改的在上面、创建时间完全同时创建时的兜底。这里有一个非常隐蔽的坑Dart的List.sort在标准库文档里标记为不稳定排序在OpenHarmony设备上和其它平台上跑同样的比较器结果可能不一致。如果你的比较器遇到两本时间完全一致时返回0最终谁前谁后就不再可控。我第一次发现这个问题是两个测试账本在同一天里加了多条笔记用毫秒级时间戳依然有撞车的列表顺序在两次冷启动后会变。排查下来的根因就是比较器没有构成全序关系。解决思路很朴素让任意两条笔记的比较结果都不为0比较器按优先级依次比较多个字段。class NoteSortUtil { static ListNoteEntity applyDefaultSort(ListNoteEntity notes) { final sorted ListNoteEntity.from(notes); sorted.sort((a, b) { // 第一优先级置顶 if (a.isPinned ! b.isPinned) { return a.isPinned ? -1 : 1; } // 第二优先级编辑时间倒序 int byUpdated b.updatedAt.compareTo(a.updatedAt); if (byUpdated ! 0) return byUpdated; // 第三优先级创建时间倒序 int byCreated b.createdAt.compareTo(a.createdAt); if (byCreated ! 0) return byCreated; // 兜底用id保证排序结果确定 return a.id.compareTo(b.id); }); return sorted; } }注意这里我是先ListNoteEntity.from(notes)复制了一份避免直接修改上层传入的原始列表。很多人在Repository里直接对返回的列表原地sort一旦调用方还持有同一个List对象的引用排序就会产生外部副作用查起来非常费劲。1.3 编辑时间和创建时间到底哪个做主排序字段大多数笔记应用其实应该用更新时间做主排序因为用户对最近动过的笔记出现在顶部有强烈预期——尤其是搜索场景里你搜到一条旧笔记然后改了内容回到列表却发现它还在原来的位置这个反馈是很糟糕的。创建时间作为二级排序就够了。但有一个例外如果你做的是每日待办清单或者按照日期归档的日记本那创建时间应该排在前面因为这种场景的时间锚点是哪天记的不是哪天改的。我在DAY8给NoteSortUtil留了一个枚举参数默认走编辑时间优先日记模式传SortMode.byCreatedAt。设置为默认排序方式之后把它暴露到设置页面里这也是后面设置页能接进来的关键抓手。另一个要注意的点是置顶。置顶不能只在排序比较器里加一个布尔值判断还要考虑置顶内部的顺序——用户把A置顶后又把B置顶通常期望B在更上面所以置顶内部还是得按时间倒序。用上面这段代码就已经覆盖了但得留意如果做拖动手动排序功能置顶组和普通组之间的拖拽边界判断要单独设计DAY8不做那么深先给一个可扩展的比较器结构。1.4 排序后的列表更新策略setState前先确认引用变了没在列表页拿到返回的排序结果后我当时在第一版代码里直接执行了setState(() _notes sorted)。这没问题但隐藏着一个性能隐患假如后续要支持排序方案切换比如按标题字母排每次切换都重建整个列表倒也合理但如果列表本身已经几十条配合缩略图整页rebuild会产生肉眼可见的闪烁。这里的优化手法是给ListView加itemExtent或者使用ListView.builder的addAutomaticKeepAlives配合RepaintBoundary这些可以在后面的全局优化部分展开。DAY8这一步先把排序逻辑做干净列表性能优化跟着全局统一一起做避免一天改十处。2. 深浅色主题切换从能换色到换得彻底、换得顺畅2.1 主题状态管理选型ValueNotifier能解决的事不用上重量级方案做深浅主题首先要回答一个问题主题切换这个状态放在哪里。它不像笔记数据那样需要跨模块查询只需要在切换的瞬间通知所有订阅者重建UI同时把用户的选择持久化到本地存储。这个需求用Flutter自带的ValueNotifierThemeMode就够了不需要为了主题单独引入Provider或Riverpod。我是这样设计的class ThemeController { ThemeController(this._prefs) { _loadThemeMode(); } final SharedPreferences _prefs; final ValueNotifierThemeMode modeNotifier ValueNotifier(ThemeMode.system); static const _themeModeKey theme_mode_index; Futurevoid _loadThemeMode() async { final index _prefs.getInt(_themeModeKey) ?? ThemeMode.system.index; modeNotifier.value ThemeMode.values[index]; } void setThemeMode(ThemeMode mode) { modeNotifier.value mode; _prefs.setInt(_themeModeKey, mode.index); } }然后在MaterialApp顶层监听ValueListenableBuilderThemeMode( valueListenable: themeController.modeNotifier, builder: (context, mode, _) { return MaterialApp( theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: mode, ); }, )这里要特别说明一下GlobalKey需要放在ValueListenableBuilder的外面或者MaterialApp内部的NavigatorKey不能跟着builder重建。我第一次写的时候把scaffoldMessengerKey直接定义在了builder的局部作用域里结果切换主题后所有SnackBar全部失效调试了很久才发现是key被重建导致的。正确做法是把这些Key声明成顶层全局变量。2.2 深浅色不是简单把background换黑要设计一套语义色板很多人做深色模式就是在build里写Theme.of(context).brightness Brightness.dark ? Colors.black : Colors.white这种三元表达式看得人头皮发麻而且没法覆盖成功绿警告橙次要文字这些场景。我建议把颜色做成语义化的AppColors扩展而不是直接散落各种Color。我建了一个AppColors抽象类浅色深色各自实现一份abstract class AppColors { Color get background; Color get card; Color get primary; Color get textPrimary; Color get textSecondary; Color get divider; Color get surfaceVariant; } class LightColors extends AppColors { override Color get background const Color(0xFFF7F8FA); override Color get card Colors.white; override Color get primary const Color(0xFF5B6AF0); override Color get textPrimary const Color(0xFF1A1D24); override Color get textSecondary const Color(0xFF6E7380); override Color get divider const Color(0xFFE8EAF0); override Color get surfaceVariant const Color(0xFFF0F1F5); } class DarkColors extends AppColors { override Color get background const Color(0xFF121316); override Color get card const Color(0xFF1E2026); override Color get primary const Color(0xFF8B97FF); override Color get textPrimary const Color(0xFFF2F3F7); override Color get textSecondary const Color(0xFF9A9FAD); override Color get divider const Color(0xFF2C2E36); override Color get surfaceVariant const Color(0xFF2A2D35); }然后再通过一个BuildContext扩展取用extension AppThemeContext on BuildContext { AppColors get colors { return Theme.of(this).brightness Brightness.dark ? DarkColors() : LightColors(); } }业务代码里就变成了context.colors.card所有页面都不再直接出现Color数值。这个改动的收益在全局统一阶段会被放大很多。2.3 三态主题选择跟随系统、浅色、深色设置页必须给全这里要先聊一个产品层面的判断深浅色切换在移动端上最合理的交互不是浅色/深色二选一而是三选一包含跟随系统。因为很多用户不希望APP跟系统强绑定夜间想在被窝里用白底应用或者白天想用深色让OLED省电但也不可能每次都手动切跟随系统就是那个免打扰的中间态。对应的设置页里的外观区域我选择了三个单选条目而不是一个Switch。如果用Switch表达深色模式开/关就没法承载跟随系统这个选项——除非再加一个跟随系统的开关那交互会变得很啰嗦。三态存储到SharedPreferences时的取法在上面已经写了。这里补一个启动顺序的坑SharedPreferences.getInstance()是异步的如果MaterialApp在首帧就依赖主题值你会看到一帧白屏闪一下然后才切到正确的深色或浅色。解决办法是让根Widget在拿到prefs之前先显示一个启动画面。我在DAY8的实际处理是Futurevoid main() async { WidgetsFlutterBinding.ensureInitialized(); final prefs await SharedPreferences.getInstance(); final themeController ThemeController(prefs); runApp(MemoApp(themeController: themeController)); }也就是先异步拿prefs再runApp避免首帧主题闪烁。这种做法需要你在启动流程里接受等待SharedPreferences初始化的时间实测几乎无感但能彻底省掉首帧闪白问题。2.4 在OpenHarmony上的深层适配状态栏颜色、字体权重、滚动回弹差异只看标题的人可能以为深浅主题切换就是MaterialApp传两个ThemeData但真跑到OpenHarmony设备上还有几个容易忽略的细节。第一个是系统状态栏图标颜色。浅色模式下状态栏背景是浅色那状态栏上的时间、信号图标必须是深色切到深色模式后图标要跟着反白。在OpenHarmony上需要用到SystemChrome.setSystemUIOverlayStyle并且不能只在App启动时执行一次必须在主题切换监听里同步调用。我当时在ValueListenableBuilder的builder里根据mode动态设置SystemChrome.setSystemUIOverlayStyle( Theme.of(context).brightness Brightness.dark ? SystemUiOverlayStyle.light // 白色图标适合深色背景 : SystemUiOverlayStyle.dark, // 深色图标适合浅色背景 );第二个是字体渲染。OpenHarmony平台上某些字重的渲染效果和标准Flutter预期不同尤其是FontWeight.w500这个中间档有些设备会直接渲染成w400的效果深浅色模式下观感差异更明显。我在项目里的兜底方案是正文用w400标题用w600少用w500避免出现看上去该是重点标题却和正文一样粗的尴尬。第三个是SafeArea和刘海屏状态栏高度。OpenHarmony机型的系统状态栏高度在深浅色模式切换时不会变但如果你用了AppBar切换主题时AppBar的颜色会变状态栏背景色也要跟着AppBar走。我的项目里统一用AnnotatedRegionSystemUiOverlayStyle包住Scaffold确保状态栏逻辑和AppBar颜色是同一个数据源驱动的而不是各写各的。这些都是实际跑测试机时陆续发现的如果你只在模拟器上看效果前两条大概率会漏掉。3. 设置页面搭建不是堆一堆ListTile就完事要处理好数据持久化和异步反馈3.1 设置页的页面结构与导航方式备忘录的设置页我按分区组织外观主题、排序方式、通知开关提醒、提醒时间、数据管理导出、清空缓存、清除全部数据、关于版本号、开源许可。分区用ListView包若干个顺序排列的SectionCard实现每个SectionCard内部是一个Column包几个ListTile这样比起直接在一个大ListView里用section header视觉上更清爽也方便整体控制卡片圆角和间距。页面本身用Navigator.push推入而不是Tab切换。因为设置页打开频率低、信息密度高独立页面更符合用户直觉。在OpenHarmony上如果用户开启了侧滑返回手势Navigator.push的默认动画能够正常工作不需要额外配置。3.2 设置项的核心结构Switch、RadioListTile、普通跳转项设置页里最常见的三种交互形态我分别处理开关类例如开启每日提醒。对应SwitchListTilevalue从配置读取onChanged里写入配置并同步重置本地通知。注意开启通知需要动态申请权限切换开关后应该先请求权限如果被拒绝要把Switch拨回原地并给用户Toast提示不能假装开启成功。单选项例如主题模式、首页默认排序方式。对应RadioListTile或者ListTile 选中标记点击后立即持久化并反馈高亮选中项、关闭弹层/弹窗。跳转类例如数据导出版本信息。对应ListTile带trailing: Icon(Icons.chevron_right)跳到对应子页面或弹窗。其中单选项我专门做了一个小组件封装参数是当前值、可选值列表、标题点击事件回调UI统一用右侧圆点选中的形式和系统设置的视觉习惯保持一致。class _RadioGroupItemT extends StatelessWidget { final String title; final ListT values; final T selected; final ValueChangedT onChanged; const _RadioGroupItem({ required this.title, required this.values, required this.selected, required this.onChanged, }); override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), child: Text(title, style: Theme.of(context).textTheme.titleSmall), ), ...values.map((value) { final isSelected value selected; return RadioListTileT( value: value, groupValue: selected, title: Text(_labelFor(value)), onChanged: (next) { if (next ! null next ! selected) onChanged(next); }, selected: isSelected, ); }), ], ); } }这里有个小细节RadioListTile在被选中时只有把selected设为true才会渲染出选中态的勾选视觉否则只是圆点变化不明显。很多新手在这里漏掉会导致点了但看起来没选中的错觉。3.3 设置项的持久化与即时生效设置页面里牵涉到的变更大体分两种一种是马上会改变全局状态的主题、排序方式一种是只影响后续行为的通知开关、提醒时间。统一的做法是变更后立刻写SharedPreferences并通过各Controller的ValueNotifier通知全局刷新而不是等用户退出设置页再批量保存。我在项目里专门封装了一个SettingsRepository负责以下几类数据的读取和写入themeModeIndex主题当前值影响MaterialApp级defaultSortMode默认排序影响笔记列表数据获取dailyReminderEnabledreminderTime通知影响本地通知调度cacheSize数据管理页展示用每次进入页面实时计算这个Repository和ThemeController、NoteRepository三者之间的依赖方向必须控制好SettingsRepository只做读写不持有业务状态ThemeController从SettingsRepository读主题NoteRepository从SettingsRepository读排序方式。如果反了比如Repository直接调用ThemeController就会出现初始化顺序问题冷启动时容易空指针。时效性上有个反直觉的体验点普通开关类变更不需要全局rebuild但你需要在对应业务模块里能感知到变化。比如用户关闭了每日提醒正在运行中的定时器并不会因为SharedPreferences写入就自己停掉。我是在设置页的onChanged里直接调用ReminderService.cancelDailyReminder()的配置持久化只是下一次启动时读取的底账真正让功能当下生效的是业务服务方法的显式调用。这一点最容易漏。3.4 数据管理区导出与清空的二次确认清除全部数据这种高危操作一定不能只用一个Switch或者一个ListTile点一下就直接执行。我在点击后弹了一个BottomSheet显示将删除所有笔记且不可恢复需要输入DELETE或者长按确认按钮才能执行。这个交互不是故作高深而是真实项目里误触之后找不回数据的代价太大了。导出功能我用的是最简单的兜底方案把所有笔记序列化为JSON字符串写入到应用文档目录下的导出文件然后用系统分享面板发出去。实现核心是final jsonList notes.map((note) note.toJson()).toList(); final file await File(${appDocDir.path}/memo_export_${timestamp}.json) .writeAsString(jsonEncode(jsonList));DAY8的导出属于先跑通级别只保证文件能生成、能读回后续DAY9如果做云同步或备份恢复可以在这个基础上再扩展导入校验和冲突合并。3.5 关于页的细节版本号显示别写死设置页底部的版本号我一开始写的是Text(v1.0.0)后来发现每次打包发布都要改而且发测试包时容易忘了改。更好的做法是从包信息里读取版本号。在Flutter里可以用PackageInfo插件实现PackageInfo.fromPlatform().then((info) { setState(() _versionText v${info.version} (${info.buildNumber})); });这个改动虽小但是能杜绝测试包和线上包版本号一样这种尴尬问题。4. 全局风格统一优化把散落的硬编码收进设计系统一次改到位4.1 一次颜色普查全局搜索Color(和EdgeInsets后触目惊心做风格统一之前我先干了一件事全局搜索Color(和EdgeInsets.only看看项目里有多少处硬编码。结果在自己的项目里找到了七十多处颜色常量、二十多种间距值。白色有时候用Colors.white有时候用Color(0xFFFFFFFF)边距有4、6、8、10、12、16浮动圆角有6、8、10、12。这种程度的混乱后期想改一个主题几乎等于把所有页面重写一遍。风格统一的核心不是把所有页面写得一模一样而是把设计决策收敛到尽可能少的文件里以后想调整就只改一个地方。所以这一阶段的任务是定义Spacing和Radius标准定义文字排版标准定义通用组件标准逐个页面替换硬编码4.2 轻量级设计常量间距、圆角、文字样式我把间距固定为4的整数倍基础单位是4class AppSpacing { static const double xs 4; static const double sm 8; static const double md 12; static const double lg 16; static const double xl 24; static const double xxl 32; } class AppRadius { static const double sm 6; static const double md 10; static const double lg 14; static const double xl 20; }在替换过程中会遇到一个现实问题有的页面里间距是视觉上看起来差不多的6和7并存你会忍不住想要不要顺手统一但我劝你不要顺手改。只做机械替换——把EdgeInsets.all(6)替换成EdgeInsets.all(AppSpacing.sm)——而不改变原本的视觉间距。因为一旦改变了视觉布局你需要重新检查所有页面在DAY8这个阶段会拖慢进度。真实项目里间距微调和设计系统落地是两件不同的事。文字样式我建了AppTextTheme里面统一了标题、正文、辅助文字的size和weightclass AppTextStyle { static const TextStyle pageTitle TextStyle(fontSize: 20, fontWeight: FontWeight.w600); static const TextStyle sectionTitle TextStyle(fontSize: 15, fontWeight: FontWeight.w600); static const TextStyle body TextStyle(fontSize: 14, fontWeight: FontWeight.w400); static const TextStyle caption TextStyle(fontSize: 12, fontWeight: FontWeight.w400); }注意这些样式类没有绑定颜色颜色一律从context.colors取。如果绑定到ThemeData的textTheme里深浅色模式下可以自动切换但代价是所有页面都要走Theme的textTheme在存量代码里一下子改不过来用独立样式类的方案更渐进新代码用AppTextStyle.body旧代码逐渐替换。4.3 公共组件收敛空状态、加载态、提示条不再各写各的在备忘录这种工具型应用里最容易出现风格分裂的是空状态、加载中和保存成功的提示。空状态我统一做成了EmptyPlaceholder组件一个图标、一行主文案、一行可选副文案、一个可选操作按钮。页面里再也不用写Center( child: Column( children: [Icon(Icons.inbox), Text(还没有笔记), Text(点击右下角新建)], ), )而是直接EmptyPlaceholder( icon: Icons.inbox_outlined, title: 还没有笔记, message: 点击右下角新建你的第一条备忘录, actionLabel: 快速新建, onAction: _createNote, )加载状态我用了一个全局通用的LoadingIndicator并且区分全屏加载和局部加载。保存成功的反馈统一从ScaffoldMessenger.of(context).showSnackBar收敛到一个AppToast.show(context, 已保存)包装函数里这个包装函数内部负责处理SnackBar的时长、背景色、主题变化后的样式。SnackBar这里有个坑如果你在主题切换后还持有旧的ScaffoldMessengerState弹出的SnackBar背景色可能是旧的浅色主题。解决方式是别缓存SnackBar状态每次show的时候从当前context重新取ScaffoldMessenger.of(context)。这也是为什么前面强调scaffoldMessengerKey要在顶层稳定持有的另一个原因。4.4 列表性能与整体观感itemExtent、RepaintBoundary、SafeArea、滚动弹簧风格统一不只是颜色和字体还包括交互反馈的一致。DAY8里我顺手处理了三个影响整体质感的问题第一列表项高度混乱的问题。备忘录列表里每条笔记的高度会随标题两行/一行、摘要有无而变化滚动时会出现明显的跳动。我给列表项外层套了RepaintBoundary让每条卡片独立绘制滚动时不会把整列表都重绘。同时在ListView.builder里设置了itemExtent虽然不能完全固定两行摘要的高度但固定了卡片的最小高度实测滚动流畅度提升非常明显。第二手势与滚动冲突。在OpenHarmony设备上某些系统版本的下拉刷新手势和CustomScrollView的physics有冲突表现为下拉刷新偶尔拉不动或者松手后列表回弹到异常位置。我最终在ScrollConfiguration里设置了全局的physics: ClampingScrollPhysics()关闭了部分平台默认的弹性效果这虽然牺牲了一点iOS式的回弹手感但换来的是OpenHarmony上行为的一致性。第三深浅色模式下SafeArea区域的处理。设置页最底部如果贴着导航条深色模式下会有一条浅色留白非常难看。解决方案是给设置页的根Container设置color: context.colors.background让底部的安全区域也被背景色覆盖Scaffold( backgroundColor: context.colors.background, body: SafeArea( child: ListView(...), ), )干净利落一劳永逸。4.5 样式替换过程中可以顺带建立的自查清单全局统一是一个漫长的替换过程容易改着改着就漏掉角落。我在项目里整理了一份简单自查清单每次替换完一个页面就对照检查一遍检查项问题现象处理方式硬编码颜色页面里有Color(0xFFxxxx)换成context.colors.xxx硬编码间距EdgeInsets.all(7)这类非标值换成最近的AppSpacing档位状态栏对比度深色模式下状态栏图标看不见用AnnotatedRegion包裹并动态设置样式空状态/加载态样式每个页面长得不一样统一使用EmptyPlaceholder和LoadingIndicatorSnackBar频繁闪烁切换主题后提示条颜色异常改用AppToast.show统一入口阴影溢出卡片阴影在深色模式下过深统一阴影颜色按主题区分浅色shadow更轻、深色shadow更透明这个清单不是一次性任务它是给整个DAYS阶段兜底的。DAY8把它建立起来后面继续加功能时新页面照着清单走就不会再产生新的风格债。收尾DAY8做完之后我对这个项目的架构新感受写到DAY8最明显的变化不是功能列表多了三四个而是项目的改造成本开始显著下降。排序逻辑收进Repository之后我在搜索页里排查一个时间相同但顺序不稳定的bug只用了十分钟主题颜色收进context.colors之后想调整主色调只需要改两个类文件。这些都是前期没有专门规划、但做到一半时顺手获得的收益。如果让我给正在做同样事情的朋友一个建议DAY8这一天尽量把设计系统当成一个正式任务来做而不是顺便优化。即便一开始只定义间距、颜色和三个公共组件也让后续每一个新页面的开发成本低一个量级。下一阶段我计划做云同步和跨设备冲突合并到时候设置页里数据管理这一块就能真正发挥作用了——不过那是后话了先把今天的改动多跑几台OpenHarmony设备确认深浅色切换和状态栏适配没有边界问题再往下走也不迟。