
上周接到一个挺有意思的需求在 OpenHarmony 设备上做一个 TodoList但产品经理提了两条“不常规”的要求——第一条任务的截止日期必须允许为空不是每件事都有明确的 DDL有些任务就是“有空再做”的第二条不能只把日期字段显示在界面上得把时间状态可视化用户一眼扫过去就能知道哪些任务快到期、哪些已经逾期、哪些还没排期。技术栈很明确Flutter因为团队只有一套 Flutter 代码后面还想复用回 Android 和 iOS。这就引出了一个基于可空截止日期与时间语义可视化的 TodoList 时间管理子系统也是这篇文章想完整记录的内容。整篇文章适合谁看想接 OpenHarmony Flutter 项目的同学、做跨端工具类 App 的开发者、以及正在设计“时间感知型任务管理”功能的人。我会把从数据模型、语义计算、可视化映射到 Flutter for OpenHarmony 工程适配的完整过程都拆开讲最后再把我在调试中踩过的坑列成排查清单。内容偏实战尽量少废话。1. 项目背景与整体思路1.1 需求拆解为什么“可空截止日期”是硬需求先聊聊需求本身。普通 TodoList 最常见的模型是给每个任务硬性配一个 dueDate但真实使用场景根本不是这么回事。我自己的任务列表里“处理报销单”这种是有明确日期的“整理读书笔记”这种就是没有日期的。如果把没有日期的任务强行塞一个默认日期比如 API 层的0001-01-01或者null被序列化成了空字符串后面排序、筛选、统计全都会乱。所以在建模阶段我把任务的时间状态明确拆成三态有截止日期且未完成真正需要被时间驱动的任务。没有截止日期未规划任务不能被时间逻辑绑架。已完成无论有没有日期都应该从活跃视图里淡出。这也意味着截止日期在数据模型里必须是DateTime?也就是可空类型。整个子系统所有下游逻辑——排序、筛选、语义计算、可视化标签——都必须围绕这个“可空”来设计。很多团队在这里会犯一个隐蔽错误把空日期当成“遥远的未来”来处理导致无日期任务永远沉底用户根本找不到。后文我会详细给出我对空日期的处理方式。1.2 技术选型Flutter Riverpod Hive技术栈上我直接定了 Flutter 作为 UI 框架。原因很简单OpenHarmony 在应用层对 Flutter 的支持已经比较成熟Flutter 官方和 OpenHarmony 社区一直有适配版本在迭代一套 Dart 代码可以同时跑在 OHOS、Android、iOS 上对这个项目来说性价比最高。状态管理我选的是 Riverpod 2.x没有用 Bloc也没有用 Provider。理由有三点Riverpod 的 provider 是编译期安全的写错了直接编译报错而不是运行期黑屏。它天然支持派生状态比如“根据时间语义计算后的任务列表”可以用一个派生 provider 缓存时间变化时自动重算不需要手动 setState。它的ref.watch和ref.listen机制非常适合这个场景每次进入页面、切回前台、或跨天时时间语义都需要重新计算。本地存储选了 Hive。放弃 sqflite 和 drift 的原因很现实sqflite 在 OpenHarmony 上需要平台通道的原生实现drift 依赖 sqlite3 原生库适配成本高且容易在 OHOS 的构建链上出幺蛾子。Hive 是纯 Dart 实现的 NoSQL 数据库几乎没有原生依赖OpenHarmony 上直接就能跑存储任务这种结构化但小体量的数据绰绰有余。1.3 子系统边界划分这个 TodoList 虽然是个小项目但我还是把它拆成了三个相对独立的模块避免越往后代码越乱数据层Task 模型、Hive Box、序列化、增删改查。语义层时间语义枚举、计算规则、排序规则。可视化层日期选择器、语义标签、TodoCard、分组列表。每一层只依赖下一层语义层不直接感知 UIUI 层通过 riverpod 的 provider 拉取语义计算结果。这样划分之后如果之后想从“时间语义可视化”扩展成“日历热力图”或者“统计报表”只需要在语义层加方法在可视化层加组件数据层完全不用动。2. 核心数据模型与时间语义计算2.1 可空截止日期的数据建模先说模型层。Task 模型里我定义了一个dueDate字段类型是DateTime?这是整个子系统的地基。具体字段如下class Task { final String id; final String title; final DateTime? dueDate; final bool isCompleted; final DateTime createdAt; final DateTime? completedAt; const Task({ required this.id, required this.title, this.dueDate, this.isCompleted false, required this.createdAt, this.completedAt, }); factory Task.fromJson(MapString, dynamic json) { return Task( id: json[id] as String, title: json[title] as String, dueDate: json[dueDate] ! null ? DateTime.parse(json[dueDate] as String) : null, isCompleted: json[isCompleted] as bool? ?? false, createdAt: DateTime.parse(json[createdAt] as String), completedAt: json[completedAt] ! null ? DateTime.parse(json[completedAt] as String) : null, ); } MapString, dynamic toJson() { return { id: id, title: title, dueDate: dueDate?.toIso8601String(), isCompleted: isCompleted, createdAt: createdAt.toIso8601String(), completedAt: completedAt?.toIso8601String(), }; } }这里要重点提醒一个问题DateTime序列化时一定要用toIso8601String()并且存进去之前先统一转成本地时区。因为我实际测试时发现如果 Dart 侧直接存了一个 UTC 时间Read 出来用DateTime.parse()解析后会带Z后缀这时候拿isBefore(DateTime.now())去比较会出现 8 小时的时差导致“明明还没到截止日期却显示成已逾期”。比较稳妥的做法是写入前调用dueDate.toLocal()再序列化。2.2 时间语义的分层计算可视化之前先要把“时间”这个连续变量离散化成几个语义类别。我定义了一个枚举enum TimeSemantic { completed, // 已完成 overdue, // 已逾期 urgent, // 临期24小时内 upcoming, // 未来时间充裕 undated, // 未设置日期 }计算规则我放在一个纯静态类里输入一个 Task 和当前时间输出语义。这样方便单元测试也能直接调用。class TimeSemanticEvaluator { static const urgentThreshold Duration(hours: 24); static TimeSemantic evaluate(Task task, DateTime now) { if (task.isCompleted) return TimeSemantic.completed; final due task.dueDate; if (due null) return TimeSemantic.undated; if (due.isBefore(now)) return TimeSemantic.overdue; if (due.difference(now) urgentThreshold) return TimeSemantic.urgent; return TimeSemantic.upcoming; } }这里“临期”的阈值 24 小时是我在项目里直接定义的硬规则产品侧觉得粒度够了就没有再调。如果你想做得更细可以改成比例规则如果任务创建到截止日期的总周期是 7 天剩余时间小于总周期的 20% 就算临期。我试过效果也不错但会引入额外复杂度小项目没有必要。还需要考虑一个边界场景dueDate等于当前时间比如2025-06-01 10:00:00用户在10:00:00.500查看了任务。isBefore会判定逾期但实际上用户可能只是秒级误差。我在实际项目里给 overdue 判断加了一个 60 秒的“宽限期”比due晚 60 秒以内不标记逾期只标记 urgent。这个细节看起来吹毛求疵但对于“强时间感知”的产品少一次误报就是少一次用户信任损伤。2.3 可视化映射与排序规则语义算出来之后要映射成用户能直接理解的东西。这里我做了两层可视化第一层是颜色编码。逾期用红色临期用橙色未来用蓝色未定日期用灰色已完成用灰绿色。这个颜色体系我参考了交通信号灯的心理暗示红色天然代表“停一下/危险”橙色代表“注意”蓝色代表“正常推进”。这些映射我是集中放在一个语义映射类里的class TimeSemanticStyle { final Color color; final IconData icon; final String label; const TimeSemanticStyle({ required this.color, required this.icon, required this.label, }); } TimeSemanticStyle styleFor(TimeSemantic semantic) { switch (semantic) { case TimeSemantic.completed: return TimeSemanticStyle( color: const Color(0xFF607D8B), icon: Icons.check_circle_outline, label: 已完成, ); case TimeSemantic.overdue: return TimeSemanticStyle( color: const Color(0xFFD32F2F), icon: Icons.error_outline, label: 已逾期, ); case TimeSemantic.urgent: return TimeSemanticStyle( color: const Color(0xFFF57C00), icon: Icons.alarm, label: 即将到期, ); case TimeSemantic.upcoming: return TimeSemanticStyle( color: const Color(0xFF1976D2), icon: Icons.event, label: 进行中, ); case TimeSemantic.undated: return TimeSemanticStyle( color: const Color(0xFF9E9E9E), icon: Icons.schedule, label: 未设置日期, ); } }第二层是相对时间文案。不直接显示 “2025-06-01”而是显示“今天 18:00”“明天上午”“逾期 3 天”“还有 5 天”这样的自然语言。我需要写一个相对时间格式化函数它和语义计算共用同一套输入这样文案不会和颜色冲突。比如明明颜色是红色“已逾期”文案却是“将于明天到期”那就是 bug我在联调阶段真的遇到过。排序规则上我的最终策略是已完成任务永远排最后且视觉上弱化。未完成任务里逾期最久的排最前。然后按紧急程度排urgent 优先于 upcoming。未设置日期的任务排在所有有日期任务之后。同语义的按剩余时间升序。这个排序逻辑直接体现在比较器里int compareTasks(Task a, Task b, DateTime now) { final sa TimeSemanticEvaluator.evaluate(a, now); final sb TimeSemanticEvaluator.evaluate(b, now); final orderA orderOf(sa); final orderB orderOf(sb); if (orderA ! orderB) return orderA.compareTo(orderB); if (sa TimeSemantic.overdue) { // 逾期越早排越前 return a.dueDate!.compareTo(b.dueDate!); } if (sa TimeSemantic.undated || sa TimeSemantic.completed) { return b.createdAt.compareTo(a.createdAt); } return a.dueDate!.compareTo(b.dueDate!); }为什么逾期任务要按“截止日期更早的排更前”而不是按“添加时间”排因为用户一旦错过截止日期最关心的就是“哪件事拖得最久”需要优先补救。这个是我实际用了两天后才调整过来的最开始按添加时间倒序逾期列表里新加的任务排在前面把真正拖了两周的老任务埋在了下面结果重要的事一直在漏。3. Flutter for OpenHarmony 工程适配3.1 开发环境与工程创建OpenHarmony 上跑 Flutter 工程和标准 Flutter 流程有些区别。首先要确认你拿到的 Flutter SDK 是支持 OpenHarmony 的版本不是普通主线版本。配置好环境变量之后创建工程时加一个--platforms参数flutter create --platforms ohos todo_time如果创建后没有生成ohos目录基本就是 Flutter SDK 版本不对。另外 DevEco Studio 的 SDK 路径要配置到local.properties里不然构建 HAP 时找不到 OpenHarmony SDK。构建产物是 HAP 包。我用的命令是flutter build hap --release构建完成后在build/ohos目录下可以找到.hap文件之后用 DevEco Studio 连接开发板或模拟器安装运行。这里要注意签名配置真机调试时必须有签名模拟器可以跳过。第一次跑起来时会感觉到启动速度比 Android 慢一点这是正常的不用慌。XTS 认证这里提一嘴OpenHarmony 生态里的设备如果要上架或过兼容性认证应用要跑 XTS 兼容性测试套件。我们做的 Flutter 应用在适配过程中可以主动去了解设备厂商对 XTS 的约束比如哪些权限不能申请、哪些后台行为会被检测。和这个 TodoList 相关的主要是存储权限和数据持久化行为自测时我就是在设备上跑了几轮 XTS 的相关用例。对个人开发者来说不必一上来就做全量认证但了解这个约束能避免后面上架踩坑。3.2 插件选型与组件通信策略在 OpenHarmony 上跑 Flutter最容易出问题的不是 UI 代码而是插件。Flutter 生态里大量插件依赖 Android/iOS 的原生实现到了 OHOS 上如果对应平台还没有实现MethodChannel 就会直接抛 MissingPluginException。所以我在选第三方库时坚持了一个原则能用纯 Dart 库解决就不碰带原生代码的库。时间格式化用intl纯 Dart安全。本地存储用Hive纯 Dart安全。路由直接用 Flutter 自带 Navigator不引 go_router 也行但 go_router 本身也是纯 Dart可以放心用。日历选择器我直接用 Flutter Material 自带的showDatePicker避免引入table_calendar这种带复杂依赖的库。组件通信方面Flutter 应用内部我用 Riverpod 统一管理状态页面之间通过 provider 共享数据。如果后续需要 Flutter 和 OpenHarmony 原生页面互相跳转那是另一套逻辑要用 Flutter 的 MethodChannel 封装一个原生侧 plugin。我实际在这个项目里没有涉及太深但可以告诉你的排查经验是——前后端联调时Flutter 报e/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] unhandled exception大多不是 Dart 代码问题而是原生 plugin 没实现或者没注册先去插件目录看有没有ohos实现文件再去 MainActivity 里看初始化代码。3.3 Impeller 渲染引擎的注意点Flutter 的新渲染引擎 Impeller 在 OpenHarmony 上也能启用但我实际测试发现对 GPU 型号敏感。开发板上如果启用后出现字体渲染模糊或者界面闪烁可以在运行时强制切回 Skiaflutter run --no-enable-impeller这个小参数我调了好久才找到。因为代码一样运行在 Android 上一切正常换到 OpenHarmony 板子上就闪当时还以为是状态管理写崩了最后才定位到是渲染引擎的问题。经验就是跨平台出现无法解释的视觉奇观先怀疑渲染层。4. 实操子系统关键代码实现4.1 数据层Hive 持久化与仓储封装数据层我封装了一个 TaskRepository对外暴露loadTasks、addTask、updateTask、deleteTask四个方法。Hive 的 Box 是异步初始化的所以我在 repository 里缓存了一个late FutureBoxTaskclass TaskRepository { late FutureBoxTask _boxFuture; TaskRepository() { _boxFuture Hive.openBoxTask(tasks); } FutureListTask loadTasks() async { final box await _boxFuture; return box.values.toList(); } Futurevoid addTask(Task task) async { final box await _boxFuture; await box.put(task.id, task); } }Hive 存自定义对象时要注意注册 adapter。我在 main 函数启动时手动调用了Hive.registerAdapter(TaskAdapter())不然运行时会报 “type not registered” 的错误。这个 adapter 我一开始是手写的后来发现手写容易漏字段改用hive_generator自动生成反而更稳。另外Hive 自带的TaskAdapter只能存取Task类型如果你的任务里还嵌套了子对象也要一并处理。实际项目中我还遇到了一个 Hive 和 Riverpod 联动的问题Hive 的 Box 在clear()后Riverpod 的状态不一定同步更新。我的处理方式很简单Repository 每次增删改后显式返回最新的ListTaskRiverpod 的 provider 直接替换整个列表而不是在旧列表上 mutate。状态不可变其实是很多人写 Flutter 时不太注意的小细节但配合 Riverpod 的重算机制特别好用。4.2 语义层与状态管理Riverpod 这里我用了两个 provider。第一个是任务列表第二个是派生出来的“带语义的可视化列表”在第二个 provider 内部完成排序和语义计算final taskListProvider StateNotifierProviderTaskListNotifier, ListTask((ref) { return TaskListNotifier(ref.read(taskRepositoryProvider)); }); final visibleTasksProvider ProviderVisibleTaskList((ref) { final tasks ref.watch(taskListProvider); final now DateTime.now(); final sorted ListTask.from(tasks)..sort( (a, b) compareTasks(a, b, now), ); return VisibleTaskList(tasks: sorted, now: now); });为什么不直接在taskListProvider里排序而是单独拆一个派生 provider因为后续筛选功能需要几种不同视图全部任务、仅逾期、仅今天到期、仅无日期。如果排序和筛选都塞在同一个 provider 里过滤条件一变整个列表就重建状态会乱。拆成两个 provider 后筛选逻辑可以在visibleTasksProvider里叠加或者再加一层筛选 provider各管各的职责。时间刷新也是一个关键点。如果你只在 build 时取一次DateTime.now()那么用户把 App 放在后台跨过午夜十二点再回来看语义不会自动更新——昨天还是“明天到期”的任务今天就该显示“今天到期”了。我专门写了一个Timer.periodic(Duration(minutes: 1))在页面层触发一次nowProvider的更新final nowProvider StateProviderDateTime((ref) DateTime.now()); // 在页面 initState 里 Timer.periodic(const Duration(minutes: 1), (_) { ref.read(nowProvider.notifier).state DateTime.now(); });一分钟刷新一次足够不必秒级刷新。如果你的任务按小时对精度有更高要求把 Timer 周期调成 30 秒也行只是别在大量列表项里都开 Timer会白费电量。4.3 UI 层任务卡片与语义标签TodoCard 的布局其实比较简单核心是把语义标签放在右上角卡片左侧用语义颜色画一条竖条让用户在扫视列表时靠颜色就能定位异常任务。卡片尾部显示相对时间文案。class TodoCard extends StatelessWidget { final Task task; final DateTime now; final VoidCallback onToggle; const TodoCard({ super.key, required this.task, required this.now, required this.onToggle, }); override Widget build(BuildContext context) { final semantic TimeSemanticEvaluator.evaluate(task, now); final style styleFor(semantic); return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: ListTile( leading: Checkbox( value: task.isCompleted, onChanged: (_) onToggle(), ), title: Text( task.title, style: TextStyle( decoration: task.isCompleted ? TextDecoration.lineThrough : TextDecoration.none, ), ), subtitle: Text(formatRelativeTime(task.dueDate, now)), trailing: _SemanticTag(style: style), ), ); } }语义标签组件我单独拆了一个这样如果之后想在日历视图或统计页里复用标签直接引用即可。组件内部就是一个带圆角的容器里面放图标文字颜色由传入的 style 决定class _SemanticTag extends StatelessWidget { final TimeSemanticStyle style; const _SemanticTag({required this.style}); override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: style.color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(style.icon, size: 14, color: style.color), const SizedBox(width: 4), Text( style.label, style: TextStyle(fontSize: 12, color: style.color), ), ], ), ); } }注意我用的withValues(alpha: 0.12)是 Flutter 3.27 之后的新 API旧代码里用的是withOpacity(0.12)。如果你还在维护旧项目编译报错就是这个原因。4.4 可空日期选择器实现这是整个项目里最值得单独讲明白的一块。Flutter 自带的showDatePicker是不支持“空值初始日期”的它的initialDate参数是必填的DateTime而且如果你在/上直接传 null 会崩溃。所以我的做法是当任务还没有截止日期时点击日期按钮先弹起一个底部弹窗让用户决定是“设置日期”还是“不设置日期”选择设置后再打开系统日期选择器。FutureDateTime? _pickDueDate( BuildContext context, DateTime? current, ) async { if (current null) { // 第一次设置先给用户一个选择入口 final action await showModalBottomSheetString( context: context, builder: (ctx) SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( title: const Text(设置截止日期), onTap: () Navigator.pop(ctx, pick), ), ListTile( title: const Text(不设置日期), onTap: () Navigator.pop(ctx, none), ), ], ), ), ); if (action none || action null) return current; } final picked await showDatePicker( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2030), helpText: 选择截止日期, cancelText: 取消, confirmText: 确定, ); return picked; }这个交互设计背后有个心理学考量直接把“不设置日期”藏在一个二级按钮里用户会以为日期是必填的产生不必要的负担。设成一个显式选项后用户知道自己有选择权任务录入率也会提高。我后来在产品验收时发现这个细节反而被夸奖了。另外日期选择器返回的DateTime默认是当天的零点但真正的截止日期往往应该是“当天的 23:59:59”。如果存的是零点那么当天到期的任务在用户任务的当天早上就会被判定为逾期因为now已经超过了 00:00:00。我用了两种解法取一种即可一种是在写入前DateTime(now.year, now.month, now.day, 23, 59, 59)手动补时间另一种是语义计算时对“距今天数刚好等于 0”的任务单独判断。我更推荐前一种数据模型里存的是真实的截止时刻语义层不用特判。4.5 分组与筛选最后是筛选视图。我用了一个 SegmentedButton 放在列表顶部提供“全部 / 逾期 / 临期 / 即将 / 无日期”五类筛选。筛选条件直接作用在visibleTasksProvider派生逻辑之上把过滤函数抽出来ListTask filterBySemantic(ListTask tasks, TimeSemantic? semantic, DateTime now) { if (semantic null) return tasks; return tasks .where((t) TimeSemanticEvaluator.evaluate(t, now) semantic) .toList(); }当时我还加了统计逻辑组件顶部显示五类任务的数量比如“逾期 3 件”“临期 5 件”“未设置 12 件”。这个统计不需要额外写数据库查询直接在 provider 里做一次分组就可以final countProvider ProviderMapTimeSemantic, int((ref) { final tasks ref.watch(taskListProvider); final now ref.watch(nowProvider); final count TimeSemantic, int{}; for (final t in tasks) { final sem TimeSemanticEvaluator.evaluate(t, now); count[sem] (count[sem] ?? 0) 1; } return count; });这一步看起来平平无奇但实际用起来很爽用户的“时间压力”是一个整体概念单独看某条任务是“还有三天”不够直观汇总成“今天有 5 件要处理”就立刻有紧迫感了。这正是标题里提到的“时间语义可视化”的一种整体呈现。5. 常见问题与排查技巧实录5.1 可空日期的序列化与排序隐蔽 Bug这个坑我必须第一个讲。把 null 当成默认值的时候千万别顺手存成 1970-01-01也别在排序时直接a.dueDate.compareTo(b.dueDate)会直接空指针崩溃。我一开始就因为是野路子写的代码排序那行没有判空结果无日期任务一多列表一滑动就崩。更隐蔽的问题在 Hive 的版本兼容上。Hive 对同一个 Box 的读写如果跨 isolate 或者跨页面 initState会出现并发修改冲突。我碰到过一次“明明删掉了任务刷新后又回来了”的情况排查后确认是 Box 没有及时 flush后来所有写操作都显式 await。还有一个小但很烦的Hive 的TaskAdapter升级后字段不兼容旧数据读出来全是默认值。遇到这种情况别硬修直接在开发阶段把所有 Box 删掉重新初始化一次等上生产前再写 migration 逻辑。5.2 Flutter 运行日志中常见的 Unhandled Exception我在 OpenHarmony 设备上遇到过几次这种日志E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] unhandled exception这种日志非常笼统好多人一看到就慌了。把我排查过的几类原因列成表格供你有针对性地去定位常见错误常见原因排查方向MissingPluginException插件没有 OHOS 原生实现检查插件源码里是否有 ohos 目录是否注册了 MethodChannelBad state: Cannot add new events after calling closeStream 被提前关闭查页面 dispose 里是否关掉了下游还在用的 StreamControllerLateInitializationErrorlate 属性在赋值前被访问查 provider 初始化顺序重点看 ref.read 是否发生在 provide 之前RangeError列表索引越界排查筛选后列表长度变化旧索引没同步清理PlatformException原生侧主动抛错打开 DevEco Studio 的 native log 过滤一下关键字这里有个很实用的经验遇到unhandled exception先把堆栈信息完整复制出来重点看前 20 行里提到的是package:xxx还是dart:async还是io/flutter。io/flutter开头的通常是平台通道问题package:xxx的多半是业务代码逻辑问题。5.3 时间语义显示的时区与跨天刷新问题时区问题前面提了一嘴再展开一下。OpenHarmony 设备默认时区是可以被用户改的如果用户在设置里切换了时区App 里旧的DateTime对象不会自动变。所以我在nowProvider里不只是用DateTime.now()而是用DateTime.now().toLocal();然后所有语义判断统一基于这个本地时间。跨天刷新问题就是我在 4.2 里说的 Timer这个方案唯一要注意的是Timer 回调里不能直接改 widget 的 state否则在页面已经 dispose 后会崩溃。我的做法是把定时器放在 Riverpod 的外部监听机制里或者干脆放在一个顶层 provider 内部管理页面销毁时自动取消。5.4 构建 HAP 时的签名与路径问题OpenHarmony 构建 HAP 时最容易导致失败的是签名文件和 SDK 路径。我在构建过程中先后踩过SDK 路径包含中文DevEco Studio 安装在中文目录下构建时某些原生工具链直接 GG。我的处理办法是把 SDK 和工程都放到全英文路径下整个世界清净了。签名文件过期调试签名有有效期过期后安装到设备上会直接失败报签名错误。重新生成一个 debug 签名替换即可。HAP 安装后白屏白屏基本是原生壳初始化 Flutter 引擎失败了去 DevEco Studio 里看 native crash 日志大概率是libflutter.so加载失败检查 abi 是否打全或者 Release 包有没有 strip 掉关键符号。另外构建时如果看到you are applying flutters main gradle plugin imperatively using the apply script这种日志这是 Flutter 和 OHOS 脚手架之间的兼容性警告不是致命错误但说明 Gradle 插件应用方式和预期不一致后续升级 Flutter 版本时需要重新处理。遇到后先确认一下当前工程是基于哪个 OHOS 适配版创建的然后再决定是否升级。5.5 组件化和复用从页面里抽离通用 widgets最后聊一个工程上的建议。TodoCard、SemanticTag、DueDatePicker 这三个组件我从一开始就是作为独立组件写的没有塞在页面文件里。这样做的直接好处是在第二周扩展“日历热力图”功能时直接复用了 SemanticTag没有动任何业务代码。Flutter 里的组件通信成本其实比很多人想象的高跨三四个层级往子组件传回调是一件很痛苦的事。Riverpod 在这里帮了大忙子组件不需要通过构造参数层层接收任务和回调直接在 build 里ref.watch(taskListProvider)就能拿到数据。你的项目如果也是这种层级多、状态共享密集的 UI强烈建议不要还停留在“构造参数 setState”阶段早点接一个全局状态管理库后面扩展功能会轻松非常多。结尾这个子系统完整实现完我再回头看最值钱的其实不是 UI 花了多少心思而是“可空截止日期”这个看似简单的需求背后牵出的整条设计链——数据模型怎么允许为空、语义层怎么把空值和真实时间统一建模、可视化层怎么把抽象语义转成用户能直觉理解的颜色和文案、排序逻辑怎么处理空值的边界。任何一环想得不够透最终呈现出来的就是一个要么崩溃、要么排序混乱的 TodoList。我在实际开发中还养成了一个习惯把时间语义计算和相对时间格式化做成纯函数并在test目录里补上用例。比如“逾期一天”“刚刚逾期 60 秒”“没有截止日期”“截止时间在当前时间后 59 秒”这些边界场景各跑一遍断言。这些用例看起来不起眼但在后续把 App 从中文环境改成英文环境、或者适配不同地区时区时帮了大忙。最后再分享一个小技巧这个项目上线后如果你想继续扩展可以在语义层加一个“智能提醒”的接口把TimeSemantic.urgent和overdue的任务通过通知推给用户数据层和 UI 层都不用大改。一个“可空截止日期 时间语义可视化”的 TodoList最有意思的扩展方向其实不是更多花哨的视图而是更聪明的“时间驱动行为”。这次实践就先写到这有具体问题的朋友可以直接照着我贴的代码跑一遍再回来检查你的排序和时区处理——这两个地方最容易出诡异 bug。