全部生成完成,结构完整,请查阅正文
1. 先聊清楚:我们要做的东西和为什么从编辑器下手
做闹钟应用,很多人第一反应是“通知权限、后台保活、铃声播放才难”。等真正把列表页写完之后,我反而觉得整个项目里最容易翻车的是那个看起来毫无技术含量的闹钟编辑器——不就是选个时间、点几个星期几、填个标签、选个铃声吗?自己动手写才发现,时间滚轮的滚动手感、重复周期的边界条件、新建和编辑两种模式的切换、数据持久化的时机,每一个点都能让本来挺顺的项目突然卡住。
这篇文章记录的是我在 Flutter + OpenHarmony 方向上实现一个高级闹钟 App 时,编辑器模块的完整落地过程。适合两类人读:一类是刚刚把 Flutter 环境跑到 OpenHarmony 上、想看跨端工程怎么组织的开发者;另一类是列表页已经写完、正准备写编辑器,想提前把坑都踩掉的同行。这期不会把闹钟的全部功能都讲完,核心就聚焦在“编辑器”这一条主线上,从环境准备、UI 拆解、数据模型到落地上线,中间穿插我自己实际遇到的排错过程。
1.1 编辑器的真实功能边界
用户从闹钟列表页点击右下角的加号进入编辑器,他需要完成的事情其实只有四件:把时间拨到某个点、选择这个闹钟在哪些天重复、给它起一个看得懂的标签、选一个顺耳的铃声。高级一点的使用场景里,还会涉及贪睡开关、是否震动这类附加项。
这些需求单拎出来每一个都不难,但组合起来之后,交互逻辑就复杂了。比如重复周期里,“每天”“工作日”“周末”这些快捷选项和用户手动勾选之间的关系怎么处理;用户选了全部七天之后又取消了周六周日,到底要不要联动更新快捷状态;新建模式下默认的标签是“闹钟”,还是上次输入的标签;编辑模式下这些数据又该怎么回填。这一堆问题在纸上画起来很清楚,写进 StatefulWidget 里就变得容易乱。
所以第一步真的不是写代码,而是把编辑器的功能边界画清楚。我当时在自己的项目文档里列了一份清单:基础信息负责时分,重复规则负责星期一至星期日的选择以及快捷项,附加信息负责标签、铃声、贪睡开关,保存动作负责校验、持久化和回写列表页。边界画完之后再动手,后面的开发基本就是按图索骥,很少出现“写着写着不知道这页到底还有什么功能”的情况。
1.2 为什么选择 Flutter 来做跨端闹钟
这个闹钟项目一开始就有一个绕不开的要求:要在 Android 和 OpenHarmony 两个平台上提供几乎一致的交互体验。如果两边都用原生开发,编辑器这种交互密集的页面就需要写两套代码、调两套滚动参数、维护两套 UI 样式,成本直接翻倍,而且后续加一个字段还要同步改两遍。
Flutter 的方案是渲染层完全自绘,只要平台侧的嵌入层能跑起来,UI 逻辑大可以在 Dart 层复用。编辑器里的时间滚轮、重复周期、表单输入,这些都不涉及太多系统原生 API,天然适合用 Flutter 来做。实际在 OpenHarmony 上跑还不是直接把 APK 装上去就行,还要用对应平台版本的适配层把 Flutter 引擎嵌进工程里,这部分我放到第二章细讲。
做编辑器这个模块时,我最大的体会是:跨端方案最大的价值不在“省了重写 UI 的功夫”,而在“同一套数据模型和交互逻辑只维护一份”。后面文章里你会看到,AlarmModel、状态管理、保存逻辑,这些核心代码在 Android 和 OpenHarmony 上完全没有任何差异,这才是 Flutter 在这个项目里真正的贡献。
1.3 编辑器的技术地图
为了让后面的内容不散,我先给出整个编辑器模块的技术地图:
- UI 层:时间滚轮选择器、重复周期选择、标签输入框、铃声选择弹窗
- 状态层:编辑器内部的临时状态、全局闹钟数据仓库
- 数据层:AlarmModel 定义、JSON 序列化、本地持久化
- 平台层:shared_preferences 跨端实现、后续提醒服务只做边界说明
后面的第三章对着 UI 层讲,第四章对着状态层和数据层讲,第五章讲持久化和平台适配,第六章把整个流程串成一趟实操,第七章集中放排错记录。这样读下来,你基本可以把编辑器模块完整复刻到自己的工程里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:在 OpenHarmony 上把 Flutter 跑起来
环境准备这部分听起来全是废话,但跨端项目恰恰就是死在环境不一致上。我最早在本地跑的时候,用的是默认的 Flutter 版本,结果打开 OpenHarmony 目标设备时构建直接报错,折腾了半天才发现是适配层版本和 Flutter 版本对不上,不是代码的问题。
2.1 工具版本与组合建议
我实测下来的办法是:Flutter 本体保持较新的稳定版本,OpenHarmony 侧的 SDK 和 IDE 配套使用当前稳定线,不要每个都追最新。因为 Flutter 依赖包解析是一个链式过程,pub get 的时候会拉一串依赖,版本稍微偏一点,就可能冒出一堆完全看不懂的编译错误。
这里建议你在团队内部固定一个版本组合,并且写进项目的 README。某一次我帮同事排查环境问题,发现他用的是几周前的新版本,而项目里某个插件还没有适配那个版本,最终构建失败的原因和业务代码完全没关系。固定版本组合看似保守,实际上能省掉大量看不见的沟通成本。
2.2 工程目录结构的变化
普通 Flutter 工程创建之后,只会生成 android、ios 这样的平台目录。在 OpenHarmony 适配版的 Flutter 能力里,工程创建后会自动多出一个类似 ohos 的目录,这个目录就是平台侧的嵌入工程。编辑器用到的 shared_preferences 插件也会在 pub get 时把 ohos 侧的实现一起拉下来。
我当时创建完工程,目录大概长这样:
code复制alarm_app/
android/
ios/
ohos/
lib/
main.dart
models/
pages/
providers/
utils/
pubspec.yaml
这个 ohos 目录其实和 android 目录的地位完全一样,只是平台侧壳子不同。编辑器模块从头到尾都没有去动过 ohos 目录下的原生代码,所有 UI 和业务逻辑都放在 lib 里,这个原则非常重要——一旦需要频繁修改平台侧代码,跨端优势就不存在了。
2.3 依赖锁定:能少装就不要多装
编辑器的依赖我控制在三五个以内,太多反而麻烦。pubspec 里我就放了这几行核心依赖:
yaml复制dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
shared_preferences: ^2.3.2
intl: ^0.19.0
provider 负责状态通知,shared_preferences 做本地持久化,intl 用来格式化时间文本。没有一上来就引入数据库、路由框架或者状态管理全家桶。原因很简单:编辑器模块用不到的东西,加了只会增加跨端适配的负担。特别是那些依赖原生实现的插件,在 OpenHarmony 上不一定都有对应实现,依赖树越浅,排查问题越轻松。
3. 闹钟编辑器 UI 拆解:每一项交互都有门槛
编辑器 UI 看起来就那么几块,但每一项做起来都有取舍。时间滚轮不是一个现成的组件就能完全满足,重复周期要考虑快捷操作,标签和铃声则要在交互细节上打磨。这一章我把每个区域的实现思路讲清楚。
3.1 页面整体布局怎么搭
编辑器的视觉结构必须让用户一眼知道优先级:时间选择占最核心位置,下面是重复与铃声等配置。我用的是一个 Column 结构,上半部分用 Expanded 装时间滚轮,下半部分用另一个 Expanded 装可滚动的配置卡片列表。外层套 SafeArea,顶部用 AppBar 承载返回操作和标题。
布局里最容易忽略的是比重问题。第一次实现时我在页面里放了三个 Expanded,看似都能撑满,实际上滚轮区域的可用高度并不够,手指滑动时每一格之间的距离很短,误触率特别高。后来我把时间滚轮单独用一个固定高度区域包起来,确保小时和分钟两列都能有足够的 itemExtent,滑动体验才算正常。这个坑后面在第七章会专门展开说。
配置卡片列表我用了 ListView,每个卡片是一组“图标 + 标题 + 当前值 + 箭头”的结构。这样做的好处是后续增加新配置项(比如震动开关)时,只要在列表里加一行,不需要改动页面骨架。重复周期、标签、铃声、贪睡开关这几张卡片,都是按这个模式搭出来的。
3.2 时间滚轮选择器的实现
Flutter 自带的 CupertinoDatePicker 可以展示时分,但样式偏 iOS,而且在双端平台上的滚动阻尼有细微差异。为了让小时和分钟两个滚轮在 Android 和 OpenHarmony 上表现一致,我选择了 CupertinoPicker 自己组装两个滚轮,可控性最强。
核心代码大概是这样的:
dart复制Row(
children: [
Expanded(
child: CupertinoPicker(
itemExtent: 40,
scrollController: FixedExtentScrollController(initialItem: model.hour),
onSelectedItemChanged: (index) {
viewModel.updateHour(index % 24);
},
children: List.generate(24, (index) => Center(
child: Text('$index 时'),
)),
),
),
Expanded(
child: CupertinoPicker(
itemExtent: 40,
scrollController: FixedExtentScrollController(initialItem: model.minute),
onSelectedItemChanged: (index) {
viewModel.updateMinute(index % 60);
},
children: List.generate(60, (index) => Center(
child: Text('$index 分'),
)),
),
),
],
)
这里有两个关键点。第一,scrollController 的 initialItem 必须和当前模型里的时分一致,否则进入编辑模式时滚轮会从 0 开始,用户看到的是“00:00”,实际保存的却是 7:30,这种问题非常隐蔽。第二,CupertinoPicker 默认是无限循环滚动,所以 index 会超过 23 和 59,必须用取模处理,否则数组越界直接崩溃。
我测试下来,这种自组装的方式在 Android 和 OpenHarmony 上都能稳定工作,滚轮的惯性、阻尼虽然有些差异,但不影响准确选中。如果你希望两个平台手感完全一致,可以把物理滚动参数统一设置,细节我在第七章讲。
3.3 重复周期选择:边界条件比你想的多
重复周期选择器用的是 7 个 ChoiceChip,周一排到周日,另外再加三个快捷按钮:每天、工作日、周末。
这里的逻辑很容易写乱。用户点“每天”时,应把七天全部选中;点“工作日”时,只选中周一至周五。如果用户之前手动选过周末,再点“工作日”,周末应当被取消。反过来,如果用户手动把某个工作日取消,快捷按钮就不应该再显示为选中状态,否则状态和显示就对不上了。
我当时设计的数据结构很简单,就是一个 Set:
dart复制Set<int> selectedDays = {}; // 1=周一, 2=周二 ... 7=周日
void applyQuickSelect(Set<int> days) {
setState(() {
selectedDays = days;
});
}
快捷按钮和手动勾选共用一个 selectedDays,点快捷按钮重新赋值整个集合,手动勾选则做单个元素增删。这样实现的优点是状态统一,不会出现“快捷按钮显示选中但实际集合里缺项”的情况。
还要处理一个边界条件:用户把全部重复日取消掉,界面上允许存在这种空状态,但保存时必须拦下来提示。不要在用户取消最后一个重复日时强制弹窗打断操作,那样交互会很难受。把校验放到保存节点,是更合理的做法。
3.4 标签与铃声:容易被低估的两种控件
标签这块我用的是 TextField 包在 InputDecorator 里,限制最大长度 20 个字符,并在右下角做一个实时计数器。这里有几个细节值得注意:默认值要区分新建和编辑,新建时用一个业务默认文案,编辑时回填原标签;输入框要设置 textInputAction 为 done,避免键盘右下角变成换行;保存前要做 trim,防止用户敲完空格导致标签看起来是空的。
铃声选择则是从配置卡片点击后弹出 BottomSheet,里面展示一组内置铃声的名字,默认支持“跟随系统”这类选项。我建议在编辑器里只用索引来保存铃声,不要保存文件路径。因为不同平台的铃声文件路径格式完全不一样,编辑器一旦存了路径,跨端就会出问题。索引是业务层的抽象,平台侧拿到索引之后再去映射成实际音频文件,这才是干净的分层。
另外一个容易被忽略的是删除按钮。新建模式下页面底部不显示删除按钮,编辑模式下显示一个红色的“删除闹钟”,点击后要二次确认。这里不要用系统默认弹窗,最好是自己做一个确认框,避免用户误触。整个编辑器的 UI 这么做下来,功能上已经完整了。
4. 状态管理与数据模型:编辑器不塌的基石
UI 只是壳,真正决定编辑器是否稳定的,是状态管理和数据模型。这一章讲的是 AlarmModel 怎么设计、为什么用 Provider、以及怎么处理新建和编辑两种模式。
4.1 闹钟数据结构怎么设计
编辑器的临时状态如果直接塞在 Widget 里,跨页面联动会很痛苦。我先把闹钟的数据结构定义成独立的 AlarmModel,所有可选字段都围绕“编辑器能改什么”来设计:
dart复制class AlarmModel {
final String id;
int hour;
int minute;
Set<int> repeatDays; // 1=周一 ... 7=周日
String label;
String ringtoneIndex;
bool enabled;
bool snooze;
AlarmModel({
required this.id,
this.hour = 7,
this.minute = 30,
this.repeatDays = const {1, 2, 3, 4, 5},
this.label = '闹钟',
this.ringtoneIndex = '0',
this.enabled = true,
this.snooze = false,
});
Map<String, dynamic> toJson() => {
'id': id,
'hour': hour,
'minute': minute,
'repeatDays': repeatDays.toList(),
'label': label,
'ringtoneIndex': ringtoneIndex,
'enabled': enabled,
'snooze': snooze,
};
factory AlarmModel.fromJson(Map<String, dynamic> json) {
return AlarmModel(
id: json['id'] as String,
hour: json['hour'] as int,
minute: json['minute'] as int,
repeatDays: (json['repeatDays'] as List).cast<int>().toSet(),
label: json['label'] as String,
ringtoneIndex: json['ringtoneIndex'] as String,
enabled: json['enabled'] as bool,
snooze: json['snooze'] as bool,
);
}
}
id 用时间戳加随机串生成,避免新建和编辑时冲突。repeatDays 使用 Set 而不是 List,天然去重,序列化时再转成 List。这里最需要注意的是 toJson 和 fromJson 字段名必须严格对应,一旦某个字段名写错,编辑器下一次进入就会静默丢数据,排错特别费劲。
新建和编辑模式的默认值也要分开。新建时 repeatDays 默认选中工作日,这是最常见的使用场景;编辑时则完全用原数据初始化,不做任何默认覆盖。
4.2 状态管理为什么选 Provider
我没有用 Bloc 或者 Riverpod,原因很实在:编辑器只是一个页面级交互,树形结构不深,Provider 的回调模型足够用。编辑器页面内部维护一个 EditorViewModel,内容修改立即反馈到 UI;保存时再把它传递给全局的 AlarmProvider。这样做的理由是,如果编辑器的临时状态也放进全局仓库,用户中途取消编辑时还要负责回滚数据,处理不好反而容易出现脏数据。
AlarmProvider 本质是一个 ChangeNotifier,持有 List
dart复制class AlarmProvider extends ChangeNotifier {
List<AlarmModel> _alarms = [];
List<AlarmModel> get alarms => List.unmodifiable(_alarms);
void add(AlarmModel alarm) {
_alarms.add(alarm);
notifyListeners();
}
void update(AlarmModel alarm) {
final index = _alarms.indexWhere((a) => a.id == alarm.id);
if (index >= 0) {
_alarms[index] = alarm;
notifyListeners();
}
}
void delete(String id) {
_alarms.removeWhere((a) => a.id == id);
notifyListeners();
}
}
列表页监听 AlarmProvider 后,编辑器 pop 返回时数据会自动刷新。如果列表页没有使用 Provider,就只能通过 pop 回传结果再手动刷新,那样的代码会绕很多。
4.3 把新建和编辑合并到同一个页面
同一个 AlarmEditorPage,我增加了一个可空的 AlarmModel 参数。为空时表示新建,使用默认时间 7:30;不为空时,用它的字段初始化编辑器内部状态。保存时如果是新建,就分配 id 并走 add;如果是编辑,就保留 id 并走 update:
dart复制class AlarmEditorPage extends StatefulWidget {
final AlarmModel? existing;
const AlarmEditorPage({super.key, this.existing});
...
}
这样做最大的好处是,列表页跳转时不需要区分“新建页”和“编辑页”,只需要判断有没有传 existing。页面内部拿到参数后在 initState 里做初始化,AppBar 标题根据模式切换成“新建闹钟”或“编辑闹钟”。这个设计让界面代码少了一半,而且后续改版时只需要改一个页面。
需要注意初始化时机:一定不要放在 build 方法里反复执行,否则每次页面刷新都会把用户正在编辑的内容重置回初始值。我在 initState 里做一次初始化,但 initState 里又拿不到某些依赖,所以实际拆成了两部分,这个点到第七章再展开。
5. 数据持久化与平台适配:跨端的最优解
闹钟编辑器最重要的平台能力是持久化,其次是和提醒服务的边界划分。这一章讲我怎么做存储、怎么处理时间数据,以及怎么把跨端差异降到最低。
5.1 存储方案:不折腾就是对的
闹钟条数撑死几十个,为了这个事情引入数据库属于杀鸡用牛刀。SharedPreferences 完全够用,关键是序列化方式:把整个 List
核心代码只有几行:
dart复制final prefs = await SharedPreferences.getInstance();
final raw = jsonEncode(alarms.map((e) => e.toJson()).toList());
await prefs.setString('alarm_key', raw);
读取时反向操作:
dart复制final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('alarm_key');
if (raw != null) {
final list = jsonDecode(raw) as List;
return list.map((e) => AlarmModel.fromJson(e as Map<String, dynamic>)).toList();
}
在 OpenHarmony 上,shared_preferences 插件由适配层提供实现,接口和 Android 端保持一致。编辑器模块不需要额外写 android 或 ohos 的原生代码,这层适配给项目省了很多事。如果你的环境里发现这个插件不可用,再去考虑用 MethodChannel 自己写桥,但绝大多数情况下,标准插件已经覆盖了需求。
5.2 保存校验与时间数据的正确姿势
保存前需要做的三件事:校验重复日非空、格式化标签为默认文案、确认时间合法。时间合法性在滚轮组件里其实已经被限制住了,hour 只能取 0 到 23,minute 只能取 0 到 59,所以这块可以放心。
存储时间时,我直接保存 hour 和 minute 两个字段,不保存时间戳。原因很简单:用户期望的是“每天那个时钟点响起”,而不是创建一个绝对时间点。如果这里存成带时区的时间戳,每次解析还要考虑夏令时、UTC 偏移这些问题,完全是在给自己挖坑。时区的转换应该放到真正调度通知的后台模块去做,编辑器不背这个锅。
还要注意一个用户体验细节:保存按钮点击后,如果校验失败,要给一个明确提示,不要无反应。我在项目里用 SnackBar 提示“请至少选择一个重复日期”,用户看到后回到周期选择卡片修改,走完整个流程很顺。
5.3 平台差异怎么最小化
闹钟编辑器真正的平台能力边界是持久化和提醒服务。提醒服务属于另一个模块,不在编辑器的范围里,但编辑器需要决定是否打开“启用闹钟”开关。在 OpenHarmony 端,通知权限和后台运行约束与 Android 不同,编辑器里我没有直接弹系统授权框,而是用一个文字提示让用户知道“保存后去开启提醒权限”,这样不会打断编辑器的操作流程。
如果未来某个需求必须调用只有单端才有的原生能力,最稳的方案是通过 MethodChannel 在平台侧补一个桥接方法,把原生上下文隔离开。但编辑器里我尽量不去依赖这类原生方法,把 UI 交互和数据层完全放在 Dart 侧。这个原则坚持下来,你会发现整个模块在 Android 和 OpenHarmony 上的行为几乎可以做到一致,排查问题也会轻松很多。
6. 实操过程:从零把编辑器做出来
前面把原理讲清楚了,这一章我们走一遍完整的落地流程。我会从依赖配置开始,一直写到页面实现和列表联动。
6.1 把依赖写进 pubspec
编辑器的依赖保持得很克制,先编辑 pubspec.yaml:
yaml复制dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
shared_preferences: ^2.3.2
intl: ^0.19.0
执行 flutter pub get 之后,插件会在 ohos 目录里自动生成对应实现。这个过程不需要手动干预,只需要确认网络正常即可。如果 pub get 报错,优先排查版本适配问题,而不是改代码。
6.2 编辑页面主体与滚轮代码
编辑页面的外壳很短,核心结构是一个 Scaffold,AppBar 标题根据新建和编辑切换,右侧放保存按钮,body 用 SafeArea 包住 Column,上半部分是时间滚轮,下半部分是配置列表:
dart复制Scaffold(
appBar: AppBar(
title: Text(isEdit ? '编辑闹钟' : '新建闹钟'),
actions: [
TextButton(onPressed: _save, child: const Text('保存')),
],
),
body: SafeArea(
child: Column(
children: [
SizedBox(height: 220, child: _buildTimeWheel()),
Expanded(child: _buildConfigList()),
],
),
),
)
时间滚轮部分使用 CupertinoPicker 组装两个轮子,代码和第三章的一致。这里把高度固定成 220,避免被 Column 里的另一个 Expanded 压缩。配置列表里依次放重复周期卡片、标签输入、铃声选择、贪睡开关。
6.3 保存逻辑和列表页联动
核心的保存方法,大概是这样的:
dart复制void _save() {
if (_selectedDays.isEmpty) {
_showToast('请至少选择一个重复日期');
return;
}
final model = widget.existing ?? AlarmModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
);
model
..hour = _hour
..minute = _minute
..repeatDays = Set<int>.from(_selectedDays)
..label = _labelController.text.trim().isEmpty
? '闹钟'
: _labelController.text.trim()
..ringtoneIndex = _selectedRingtoneIndex
..enabled = _snoozeSwitchValue == false ? true : true
..snooze = _snoozeSwitchValue;
if (widget.existing == null) {
context.read<AlarmProvider>().add(model);
} else {
context.read<AlarmProvider>().update(model);
}
Navigator.of(context).pop(true);
}
列表页通过监听 AlarmProvider 来自动刷新。这样用户在编辑器里点击保存后,即使不做任何手动通知,列表页也会立刻出现新闹钟。使用 Provider 的好处在这里展现得很明显,页面之间的数据同步变得自然且无副作用。
6.4 真机实测的注意事项
第一次在 OpenHarmony 目标设备上跑的时候,构建时间会比 Android 长一些,主要是适配层需要参与编译。编辑器页面打开后,我建议重点测以下几个场景:快速来回拨动滚轮,确认选中的时分不会跳变;连续进入两个编辑模式,确认回填数据正确;保存一个新闹钟后立刻杀掉应用重启,确认持久化数据还在。
如果跑不起来或者频繁崩溃,大概率是版本组合问题,优先去第二章检查环境配置,而不是一头扎进业务代码里。
7. 常见问题与避坑实录
这一章把我实际踩过的坑和排查思路整理成一份速查表,每个问题都是我真实遇到过的,希望能帮你省下几个小时的调试时间。
7.1 滚轮在窄屏溢出怎么修
第一个版本在模拟器上一切正常,换到窄屏手机就出现了 RenderFlex overflow。原因是 Column 里如果用多个 Expanded,滚轮区域的可用高度会被压缩,导致 itemExtent 无法满足。解决方法是不要裸用 Expanded,而是用固定高度包住滚轮:
dart复制SizedBox(
height: 220,
child: Row(
children: [/* 两个 CupertinoPicker */],
),
)
固定高度之后,即使下方配置列表很长,滚轮区域也不会被压缩。这个问题在 Android 和 OpenHarmony 上都会出现,属于双平台通用的坑。
7.2 重复周期状态回丢怎么排查
在编辑模式进入页面时,repeatDays 直接传给 Wrap 里的 ChoiceChip,但页面一刷新选中状态就被重置了。排查后发现,我把初始化代码写在了 build 里,setState 之后 build 又重新执行了初始化,导致用户刚点的选项瞬间消失。
正确做法是只在 initState 里初始化一次,或者增加一个“是否已初始化”的判断。我最后采用的是把选中集合从 widget.existing 拷贝到一个独立的局部字段,后续所有修改都只改这一步,不再读原有数据。这样无论页面重新 build 多少次,状态都不会回丢。
7.3 跨端持久化数据丢失问题
在 Android 上正常,切到 OpenHarmony 后发现应用杀掉再重启,闹钟列表空了。第一反应是 SharedPreferences 的 key 有问题,后来打日志才发现是读取时机太早——插件还没有准备好时就调用了 getInstance,返回的是空数据。
解决办法很简单:把加载数据的动作放到异步初始化流程里,在 main 函数里先 await prefs 读取数据再 runApp,而不是在列表页 build 时读取。这样保证进入界面时数据一定已经就绪,不会出现一闪而过的空列表。
7.4 双平台滚动体验不一致
同样的滚轮,在 Android 上的惯性比 OpenHarmony 上明显强一些。后来我把 itemExtent 和物理滚动参数固定为同一组数值,不再跟随平台默认设置,体验基本就一致了。编辑器的交互密集页面,不要指望平台默认值完全一致,主动统一参数才是正解。
另外还遇到过滚动到底部后再继续拽,列表出现白边的情况。处理方法是给滚轮设置合理的 overscroll 效果,或者直接在滚动边界加渐变遮罩,视觉上更自然。
8. 编辑器还能怎么扩展
编辑器模块做完之后,我心里其实还有几个想继续玩的方向。一个是模板预设,把一组常用的时间、重复日、标签组合做成卡片,新建闹钟时一键套用,进去微调即可。这个功能对固定作息的人来说非常实用,早、中、晚各一个模板,改起来效率高。
另一个是智能标签联想。当用户输入标签时,根据历史闹钟自动补全,比如输入“运”字,自动带出“运动会提醒”。虽然只是一个小功能,但编辑器的体验会上一个台阶。再做深一点,还可以记录用户编辑闹钟的高频时段,在列表页提示“你经常在周日晚上调整闹钟”。
如果想玩得更极致,可以把编辑器的状态做成可回放形式,每次修改记录 diff,实现撤销和重做。不过这是锦上添花,优先级不高,如果产品上没有明确需求,不建议花太多时间。
就我的实测体验来说,Flutter + OpenHarmony 这套组合真正舒服的点在于编辑器这类业务逻辑密集的页面可以完全用 Dart 写完,数据模型、状态管理、持久化全都跨端复用。真正的坑都在细节里:初始化时机、重复日边界、存储时不搞时区转换。我始终觉得,把编辑器这种“小模块”做扎实,比憋一个大而全的提醒服务更有价值,因为所有复杂功能最后都要落到一个清晰可维护的编辑入口上。如果你也在做类似的跨端项目,建议先从小模块练手,把这轮坑踩一遍,再往提醒服务、铃声同步这些方向扩展,会顺手很多。
