首页 / 资讯中心 / 文章详情

Flutter for OpenHarmony备忘录开发:排序、主题切换与全局风格统一

Flutter for OpenHarmony备忘录开发:排序、主题切换与全局风格统一 ★ FEATURED ARTICLE
做到DAY8这个Flutter for OpenHarmony的智能备忘录项目终于开始有了“成品”的样子。前7天把笔记增删改查、富文本、标签、搜索、提醒这些核心功能都跑通了但说实话离一个能真正日常使用的App还差一口气笔记列表永远按插入时间排晚上打开屏幕能亮瞎眼想找个设置入口只能靠猜各个页面写出来的风格跟拼盘一样。所以DAY8这天的目标很简单也很务实——把时间排序、深浅主题切换、设置页面、全局风格统一这四件事一次做完让项目从“功能齐”往“体验整”走一大步。这一天的内容不新增业务功能但对整个App的精致程度影响极大。我做完之后的感受是这四件事其实是相互咬合的排序涉及列表数据流的组织方式主题切换会影响到每一个页面的配色设置页面要把散落的状态都收拢进来全局风格统一又依赖于前面几天的代码重构底子。这篇文章就把我DAY8踩过的坑和可直接抄的作业完整写下来适合正在做Flutter应用、尤其是准备适配OpenHarmony的开发者参考。1. 写在DAY8之前这一天的任务清单1.1 为什么把四件事安排在同一天做有朋友问我排序、主题、设置页、风格统一随便拆开都是两三天的工作量为什么非要挤在一天其实我的理由很直接这四件事共享同一套数据状态和UI基础分开做反而会反复改同一块代码。举几个实际例子排序要读用户的偏好设置而偏好设置在设置页面里调整主题模式也同样要读偏好设置这三者天然都依赖一个统一的本地存储层。全局风格统一表面上只是换颜色、换组件但如果你没有先搭好主题体系改起来就是微博热搜式的连环翻车。所以我把它们放在同一天先用半天把“状态层”理清楚再用半天集中调整UI层最后留出时间统一排查。这种“先收数据、再收样式”的顺序我建议你也试试效率比自己想到哪改到哪高得多。另外一个重要原因是项目本身已经到了“终端打磨期”。前7天已经把功能骨架搭完剩下的是体验细节这时候再分散到两三天去做很容易出现“今天改主题、明天忘了改设置页”的记忆断层。集中处理还有一个隐性收益改一次测试一轮你能立刻发现模块之间的联动问题。比如你会发现切换主题后设置页的某个开关颜色不对这属于跨模块bug分散做很难及时暴露。1.2 先给OpenHarmony平台做点心理建设这里要提前说几句关于OpenHarmony适配的真心话。Flutter for OpenHarmony虽然API大部分兼容标准Flutter但你不能完全用Android的思维写代码。我在DAY8遇到几个比较典型的问题本地存储的路径不能用死路径要用平台提供的目录接口主题切换时状态栏颜色需要单独处理部分第三方包在OpenHarmony上没有完全适配所以能用标准库解决的就不引第三方依赖。这不是劝退而是提醒你做这类项目时要把“平台差异处理”当成功能的一部分来排期。像我今天做的主题切换在标准Flutter上只用MaterialApp的themeMode就行在OpenHarmony上还要额外照顾系统状态栏的图标颜色、页面切换时的背景闪变等问题。这些差异不会让工作量翻倍但确实需要静下心来逐项验证。后面第5章我会把OpenHarmony相关的适配细节单独列出来讲。2. 笔记时间排序别让sort()背锅2.1 排序策略怎么定修改时间优先还是创建时间优先先问自己一个问题用户在备忘录里点开列表最想看到的是“最近写过的”还是“最早写的”几乎所有人都会选前者。所以我的默认排序策略是“修改时间倒序”最近编辑过的笔记排在前面。同时考虑到有些用户会把备忘录当资料库使用更希望按“创建时间”追踪原始内容我也保留了“创建时间倒序”这个选项在设置页里可以让用户自己切换。实现上我在数据仓库里注入了排序模式而不是在UI层直接排序。这样做的好处是不管笔记列表是从数据库加载、还是从搜索结果过滤最终都会走同一套排序逻辑不会出现“列表页按修改时间、搜索结果却按插入时间”的不一致。具体实现用一个简单的Comparatorenum NoteSortMode { editTime, createTime, title } ComparatorNoteEntity buildNoteComparator(NoteSortMode mode) { switch (mode) { case NoteSortMode.editTime: return (a, b) { final int timeCmp b.updatedAt.compareTo(a.updatedAt); return timeCmp ! 0 ? timeCmp : b.id.compareTo(a.id); }; case NoteSortMode.createTime: return (a, b) { final int timeCmp b.createdAt.compareTo(a.createdAt); return timeCmp ! 0 ? timeCmp : b.id.compareTo(a.id); }; case NoteSortMode.title: return (a, b) a.title.toLowerCase().compareTo(b.title.toLowerCase()); } }注意最后加了一行b.id.compareTo(a.id)。这可不是多余的当两个笔记的修改时间完全一样的时候比如批量导入没有这个次级排序列表刷新时顺序会抖动。这是我在实际项目里踩出来的时间戳相同的情况下Dart的sort不是稳定排序顺序一次一个样特别影响观感。有朋友可能会问为什么不在数据库SQL里直接ORDER BY updated_at DESC两条原因第一这个App的数据量目前就是几百条级别内存排序开销可以忽略第二后续我会在搜索、标签过滤等多个入口复用同一份笔记集合在内存里统一排序比在每个查询里拼接ORDER BY灵活得多。等将来笔记量真的大到需要优化再考虑把排序下沉到SQL也不迟但现阶段别提前优化。2.2 时间格式化让用户一眼看懂“刚刚、昨天、上周”排序解决的是顺序问题但界面上显示时间同样影响使用体验。如果每一条笔记都显示“2025年6月15日 14:32”不但占地方而且用户很难快速判断这条笔记“新不新”。我做的方案是分层级显示今天内的笔记只显示时间比如“14:32”昨天的笔记显示“昨天”一周内的笔记显示星期几比如“周五”更早的笔记再显示完整日期“2025年6月2日”。这套规则很多主流笔记App都在用核心目的就是“让最近的时间一眼可读让老笔记的日期仍然可回溯”。我这里没有引入第三方时间库直接用Dart的DateTime和简单的判断函数手写避免在OpenHarmony上排查包兼容问题String friendlyTime(DateTime time) { final DateTime now DateTime.now(); final DateTime today DateTime(now.year, now.month, now.day); final DateTime targetDay DateTime(time.year, time.month, time.day); final int dayDiff today.difference(targetDay).inDays; if (dayDiff 0) { return ${time.hour.toString().padLeft(2, 0)}:${time.minute.toString().padLeft(2, 0)}; } else if (dayDiff 1) { return 昨天; } else if (dayDiff 1 dayDiff 7) { const weekDays [周一, 周二, 周三, 周四, 周五, 周六, 周日]; return weekDays[time.weekday - 1]; } return ${time.year}年${time.month}月${time.day}日; }这里有个平台差异值得注意time.weekday返回1到7对应周一1到周日7我初始化中文数组时按weekDays[time.weekday - 1]取顺序不能错否则会把周一显示成周日。这类细节调试的时候不太容易一眼发现建议写完单测覆盖一下。另外我在格式化时强制用24小时制的HH:mm补零格式而不是让系统决定。原因是在OpenHarmony部分设备上默认locale可能是12小时制直接格式化会出现“2:32 PM”这种与中文界面很违和的效果。对于中文备忘录场景固定24小时制更干净。2.3 排序切换交互与列表刷新不是盖个菜单就行UI交互上我在列表页AppBar右侧放了一个排序图标点击后弹出底部选择面板提供“最近编辑”“创建时间”“标题排序”三个选项。为什么用底部弹窗而不是下拉菜单因为移动端拇指操作区域集中在屏幕下半部分底部弹窗抬手就能够到而且在OpenHarmony平板上也不会显得局促。切换排序模式后整个列表需要重新排序。我目前用的是整个列表页setState重建的方式因为笔记数量不大几百条数据的列表重排根本感觉不到卡顿。如果你做的是上千条笔记级别的应用建议用ListView.builder加AnimatedList做局部移动动画但现阶段没必要为性能牺牲代码简洁度。还有一个很容易忽略的点排序模式变了之后当前正在编辑的笔记应该立刻跳到列表最前面。我是在笔记编辑保存后把数据仓库的updatedAt刷新成当前时间同时通知列表监听器刷新。这样用户保存完回到列表会发现自己刚写的笔记排在第一位这种“即时反馈”是判断一个备忘录好不好用的重要指标。3. 深浅主题切换白天看得清半夜不刺眼3.1 主题模式与状态管理主题切换的第一个决策是支持几种模式我的答案是三种跟随系统、浅色、深色。跟随系统是默认项这样大多数用户不需要手动设置就能让App跟随OpenHarmony系统的亮暗切换而变化。设置页里用单选框让用户自己选和备忘录的场景很匹配。状态管理我没有引入Provider或者Riverpod原因很简单项目目前的状态并不复杂主题模式本质上一个枚举值。我用一个ChangeNotifier管理它然后在MaterialApp外边包一个ListenableBuilderclass ThemeController extends ChangeNotifier { ThemeMode _mode ThemeMode.system; ThemeMode get mode _mode; Futurevoid load() async { // 从本地存储读取用户选择 final String saved prefs.getString(theme_mode) ?? system; _mode ThemeMode.values.firstWhere((e) e.name saved); notifyListeners(); } Futurevoid setMode(ThemeMode mode) async { _mode mode; notifyListeners(); // 持久化下次启动仍保留 await prefs.setString(theme_mode, mode.name); } }这段代码看起来简单但有一个隐藏陷阱启动时加载顺序。如果load()是异步的而应用在load()完成之前就执行了runApp那用户会先看到默认主题、再突然跳变到已保存的主题这种闪变非常掉价。我的处理是应用启动时先加载设置等load()完成后再runApp。相当于用本地存储读取当了一道“启动闸门”确保首帧就是正确的主题状态。3.2 色板与语义色暗色翻车重灾区主题切换最怕的不是切换本身而是某个页面写死了颜色。比如笔记列表里用了Colors.black、Colors.white这种绝对色值一旦切到深色模式文字还可能黑乎乎地糊在深色背景上。我的做法是统一建立一套语义色把所有页面都改成引用语义色而不是直接写色值。核心的几个变量surface页面背景色亮色下偏白暗色下用深灰cardSurface卡片底色比页面背景高一级textPrimary主文字色textSecondary次要文字色divider分割线颜色具体实现参考Flutter的ColorScheme我基于ColorScheme.fromSeed生成M3色系再覆盖背景和卡片色ThemeData buildTheme(Brightness brightness) { final ColorScheme scheme ColorScheme.fromSeed( seedColor: const Color(0xFF3D5AFE), brightness: brightness, ).copyWith( surface: brightness Brightness.light ? const Color(0xFFF7F7F8) : const Color(0xFF121216), surfaceContainerLow: brightness Brightness.light ? Colors.white : const Color(0xFF1C1C22), onSurface: brightness Brightness.light ? const Color(0xFF1A1A1D) : const Color(0xFFECECEF), ); return ThemeData( colorScheme: scheme, brightness: brightness, ); }这里我不想让背景色直接用纯黑或纯白。纯白在亮色下太刺眼纯黑在暗色下和内容缺乏层次区分所以我用了偏暖的深灰#121216和偏米白的#F7F7F8长时间阅读眼睛会舒服很多。这是从主流的深色方案里学到的细节实际体验差距很大。还要注意卡片色和背景色之间要有足够的明度差。否则暗色模式下列表卡片和页面背景糊成一整块用户很难区分层级。我用surfaceContainerLow表示卡片它比surface高一级亮度在暗色下就是深灰和浅灰的区别清晰但不突兀。3.3 切换动效与局部组件适配主题从浅色切到深色如果瞬间整个页面硬切用户会觉得“闪了一下”。Flutter提供了一个非常轻量的方案在MaterialApp里设置themeAnimationDuration和themeAnimationCurve这样主题变化时会有一个几百毫秒的过渡动画视觉上柔和很多。MaterialApp( theme: buildTheme(Brightness.light), darkTheme: buildTheme(Brightness.dark), themeMode: controller.mode, themeAnimationDuration: const Duration(milliseconds: 400), themeAnimationCurve: Curves.easeOutCubic, );不过仅仅有过渡动画还不够有些局部组件在深色模式下有自己的“脾气”。我遇到的一个典型问题是底部弹出菜单的阴影深色模式下组件阴影基本看不见导致弹窗浮层没有立体感。解决方法是给弹窗设置一个比背景更亮的表面色或者用细边框代替阴影让浮层轮廓清晰。另一个容易暴露的问题是输入框的光标色和选中色。如果不设置TextSelectionTheme有些默认值在暗色背景下可能看不清。我在构建主题时一并设置了textSelectionTheme: const TextSelectionThemeData( cursorColor: Color(0xFF3D5AFE), selectionColor: Color(0x663D5AFE), selectionHandleColor: Color(0xFF3D5AFE), ),这些看着都是小细节但正是这些小细节决定了用户对“这个App专不专业”的感知。4. 设置页面搭建把散落的状态都收拢进来4.1 页面框架与设置项分类设置页是用户偏好的“终点站”。DAY8的实践中我没有做花哨的UI而是老老实实按系统设置的交互习惯来顶部嵌套列表每一项用ListTile组合分组之间用小标题隔开。设置项我分了四类外观设置主题模式选择跟随系统/浅色/深色笔记设置默认排序方式、默认字体大小数据管理导出数据、清空全部笔记关于版本信息、隐私说明入口放在笔记列表页的AppBar右上角一个齿轮图标。这样做符合直觉用户想“调整设置”时基本都会去首页找右上角。页面的骨架结构我用了Scaffold加ListView并在每个分组前用一个小尺寸的Text作为分组标题而不是把整个页面塞进一个复杂的CustomScrollView。原因很简单设置页是功能型页面用户需要的是“快速找到目标项”分组层次清晰比视觉效果惊艳更重要。4.2 开关、弹窗与危险操作设置页的核心组件是SwitchListTile和RadioListTile它们自带标题、副标题、开关/单选项三件套代码量少而且交互语义清晰。默认排序方式我用的是RadioListTile三选一主题模式同样三选一。这里需要特别注意的是修改这些选项时要立刻更新页面状态同时持久化不能等用户退出页面再保存。我的处理是在onChanged回调里同步调用controller.setMode或prefs.setString保证用户设置完杀掉App重启后设置依然生效。危险操作——清空全部笔记——我做了双重确认。第一层是一个普通的AlertDialog提示“清除所有笔记后将无法恢复”。第二层更狠用户必须在对话框里手动输入“删除”两个字才能执行清空。这个设计我参考了某些开发工具的做法能有效防止误触尤其是清空操作没有回收站兜底一旦执行就是永久性数据丢失。弹窗的按钮颜色也有讲究。清空按钮我用的是红色系的危险操作色而普通确认按钮用主题色这样用户在点击前就能从视觉上判断操作的风险等级。4.3 数据管理导出与清空“导出数据”我是怎么做的把数据库里的全部笔记序列化成JSON文件写到应用文档目录文件名带导出时间戳比如notes_20250615_1432.json。这样用户可以把数据备份到电脑或者其他设备。实现导出时在OpenHarmony上有一个容易踩坑的点文档目录的路径不能写死要通过path_provider获取。我一开始图省事直接用了一个固定的本地路径结果在部分设备上根本找不到导出文件排查了半天才发现是不同设备的应用沙箱路径不一致。正确做法是final Directory docs await getApplicationDocumentsDirectory(); final File file File(${docs.path}/notes_${timestamp}.json); await file.writeAsString(jsonEncode(noteList));导出完成后我在界面上弹一个SnackBar同时显示文件绝对路径。为什么显示路径而不是做一个“打开文件夹”按钮因为跨平台打开文件夹需要平台通道支持在OpenHarmony上适配工作量大而显示路径配合系统文件管理器定位是完全能用的方案。清空笔记我放在“危险操作”区域单独一块、用红色文字区分与普通设置项隔开足够的视觉距离。这个交互细节可以避免用户滑动列表时不小心碰到。4.4 关于页面别只写版本号“关于”页面不该只放一个版本号就完事。我放了App名称和图标、版本号、开源许可声明、隐私说明入口。隐私说明里明确写了笔记数据不会上传到任何服务器数据只保存在本地。这个页面对建立用户信任很有帮助尤其是备忘录这种承载隐私数据的产品。虽然开发阶段很多人觉得关于页“没什么技术含量”但它属于“用户会看”的页面认真做能减少很多客服问询。5. 全局风格统一从“能用”到“成套”5.1 Design Token把散装颜色收进一个文件DAY8之前项目里的颜色是灾难现场有的页面用Color(0xFF1976D2)有的用Color(0xFF2196F3)明明是同一种蓝却看得像两个蓝。字体大小也是有14、15、16、17、18各种零散值排版看起来很不统一。这次我花了一个多小时做了彻底重构把所有颜色、间距、圆角、字号统一收进一个design_token.dart文件定义常量类。核心定义类似这样class AppSpacing { static const double xs 4; static const double sm 8; static const double md 16; static const double lg 24; static const double xl 32; } class AppRadius { static const double sm 8; static const double md 12; static const double lg 16; } class AppFontSize { static const double caption 12; static const double body 14; static const double title 16; static const double headline 20; }定义常量只是第一步真正花时间的是把旧页面里的魔法值全部替换掉。这个操作很机械但绝不能省不替换的话定义得再好的设计系统也只是个摆设新页面仍然会用随意写的色值破坏统一性。替换完成后我让关键词颜色和字号在全项目里只出现一次以后要调主题色只需改seedColor一处即可。在暗色模式适配时这个设计Token的作用更明显。因为所有页面都引用语义色而不是直接写死颜色所以我在创建deep主题时只要覆盖一次语义映射整个App的深浅色就全部变了。这就是为什么我用了一天就能把主题切换落地——前面统一得干净后面自然快。5.2 公共组件重构零散的页面里我抽出了三个最常用公共组件NoteCard、PrimaryButton、EmptyView。NoteCard是这次重构的重头戏。笔记列表卡片在DAY1到DAY7里被写了好几遍每次的布局细节都有微妙偏差比如标题字数截断多了1个字符、封面图圆角不统一、点击波纹颜色不一致。统一后维护成本大幅降低而且排序、主题切换、风格统一都只需要改这一个组件。EmptyView让空状态终于不长“多种多样”了。以前搜索无结果、笔记列表为空、标签页无笔记时页面各显神通有的只显示一行灰字有的放了一个别扭的图标。现在统一成居中图标加主副标题再搭配一个可选的行动按钮比如“去创建笔记”。PrimaryButton统一了全项目的按钮样式、圆角、压暗色、禁用态。以前“保存”“导出”“确认”三个按钮三种样式现在全部指向同一套规范。组件化的收益在DAY8远大于前7天。当我把主题切换做完了以后想检查还有没有页面漏了适配只需要检查这几个公共组件是否处理得当而不是把几十个页面翻一遍。5.3 OpenHarmony上的平台适配细节既然项目是跑在OpenHarmony上有几个平台相关的细节必须单独说明状态栏与系统UI颜色OpenHarmony默认不跟随Flutter主题自动切换状态栏图标颜色。亮色模式下状态栏图标需用深色暗色模式下需用浅色否则会与背景撞色看不清。我用AnnotatedRegionSystemUiOverlayStyle包住主要页面根据当前主题模式切换statusBarIconBrightness和statusBarColor。安全区适配OpenHarmony设备的刘海屏、圆角屏、底部手势条与Android差异不大但还是要检查SafeArea。否则设置页底部的“清空数据”按钮很容易被手势条遮挡点击区域变得很小。中文字体渲染Flutter在OpenHarmony上读中文字体的逻辑总体正常但个别系统字体回退可能有差异。我在全局文本样式里显式指定了fontFamily为空即使用系统默认避免部分设备因缺失指定字体而返回奇怪渲染。如果你在项目中用了自定义字体文件一定测试OpenHarmony设备上的字体加载路径。屏幕方向备忘录是典型的手持竖屏工具。我没做强制竖屏而是让系统自适应。但设置页里的部分列表项在横屏时显示太宽所以所有内容宽度通过constraints: BoxConstraints(maxWidth: 600)限制在平板上也不会被拉得过宽。6. 踩坑实录DAY8的6个现场6.1 按时间排序后列表内容错乱第一个坑做完排序切换后快速翻动列表发现有的卡片显示的是别的笔记标题。排查后发现是ListView的item复用导致状态残留。Flutter的ListView.builder会回收移出屏幕的item如果卡片不绑定唯一的key排序变化后复用的widget可能带着旧数据绘制。解决方法是给每个NoteCard显式设置一个ValueKey(note.id)NoteCard( key: ValueKey(note.id), note: note, )这样Flutter就知道item的对应关系排序变化后不会复用错乱。这个坑在列表短的时候很难触发要在笔记多、排序切换频繁时才会出现非常阴间。6.2 深色模式下文本“隐身”切到深色模式后有一个页面的副标题文字完全看不清。查了一圈发现这个页面的副标题用了Colors.grey.shade300写的时候以为是“灰色文字”结果是接近白色的亮灰在暗色背景上亮晶晶地从背景里“消失”了。后来我把全项目所有用绝对色值的地方统一替换成语义色textSecondary在暗色模式下映射为中等亮度的灰色问题才彻底解决。这再次印证了语义色不是设计洁癖是深色模式的最低保障。6.3 主题切换首帧闪白有几次从浅色切到深色页面会先白一下再变深。排查发现不是我主题配置有问题而是MaterialApp的themeMode在被持久化数据覆盖之前先执行了一次亮色模式。解决方式比较直接启动时先花几十毫秒读取本地存储的主题设置确认controller.load()完成后再runApp。这样首帧渲染时themeMode就是最终状态不会再白闪。6.4 设置页导出文件找不到第一次做导出功能时我把JSON文件写到了getExternalStorageDirectory()路径下结果在OpenHarmony设备上返回null导出直接失败。后来切到getApplicationDocumentsDirectory()才稳定。这里给大家一个提示如果你在OpenHarmony上做文件读写优先使用path_provider提供的应用目录接口不要依赖某个固定路径。不同设备、不同版本的沙箱目录映射可能不同宁可多写两行代码获取路径也别为了“省事”写死路径。6.5 时间格式化与12小时制时间排序做出来后有用户反馈“昨天”那一条显示成了“下午2:32”这种英文风格。查了才发现是设备系统语言设成英文导致我手写的格式化函数走了12小时逻辑。因为我没有做系统的locale判断只按24小时制写了。最终我做了一个妥协时间显示统一用中文格式反正这个App的中文笔记场景占比最大在中文环境下格式始终一致。如果你做多语言版本这里需要用intl包的DateFormat并按locale适配而不是像我这样手写。6.6 状态栏文字颜色冲突开启深色模式后页面背景变暗但状态栏的文字图标仍然是深色的在深色背景上几乎看不见。我一开始只在首页处理了AnnotatedRegion结果设置页、详情页、搜索页全都中招。正确的做法是做一个全局的AppScaffold组件内部统一处理AnnotatedRegion所有页面都用这个组件打底而不是每个页面各写一套状态栏逻辑。这也是全局风格统一的一部分能避免漏网之鱼。7. 关于下一步这个基座能长出更多东西DAY8做完之后这个备忘录App终于有了一个完整且统一的底座数据层有排序策略、设置层有偏好持久化、UI层有设计Token和公共组件、视觉层有深浅双主题。后面再往上加功能就不需要担心新页面和旧页面风格打架了。我个人体会最深的一点是一个应用从“能跑”到“好用”中间隔着的大多是这些不起眼的整理工作。排序、主题、设置页、风格统一单独拿出来每一个都不算什么大功能但合在一起提升的是整个产品给人的“信任感”。用户不会意识到你做了主题切换的动画、统一了圆角尺寸但他会模糊地觉得“这个App挺精致的”。如果你也在做Flutter项目我的建议是功能开发阶段可以快但每个阶段结束前至少要留出一个类似DAY8的“整理日”把散落的样式规则和用户偏好收拢一次。这种整理带来的收益不是立竿见影的KPI但它会在你后续每写一行代码时都在帮你节省时间。DAY9我打算在这个基座上继续加两块内容一个是笔记的全文搜索高亮优化另一个是提醒通知的本地调度。到时候如果折腾出有用的方案再写一篇分享出来。
阅读完成 · 觉得有帮助?
咨询建站