某社团的负责人找到我,说想给社团做一个内部管理工具,最扎心的一句话是:“现在派任务还在微信群接龙,谁做了、做到哪一步、什么时候截止,都得翻聊天记录。”于是我用 Flutter for OpenHarmony 做了这套社团管理App,第一个落地模块就是任务列表。这篇就把任务列表从数据模型、界面渲染、本地持久化到真机调试的完整实现过程整理出来,环境怎么配、列表怎么写、哪些地方容易踩坑,都会讲到。
如果你打算在 OpenHarmony 设备上跑 Flutter,或者正在做一个轻量级的管理类App,又或者单纯想看看跨平台框架适配新系统的真实手感,这篇都能给你一份可以直接抄作业的参考。整个模块本身不大,但覆盖面很典型:增删改查、筛选排序、状态管理、本地存储、列表优化,跑通一遍之后,后面加成员管理、活动日历基本就是换皮。
1. 为什么是 Flutter + OpenHarmony:这个App的技术账怎么算
1.1 需求背景:任务分配还在微信群接龙
社团实际运营里,任务管理是个高频又混乱的事。社长在群里发一个公告,下面跟一串“收到”“报名”“我来负责”,真正执行得怎么样没人跟踪。新干事不知道前任做过什么,活动复盘全靠记忆。所以需求方想要的是一个手机上好用的任务看板:能发布任务、指定负责人、设定截止时间、按状态筛选、能改能删,最好还能离线用,因为社团活动经常在操场、校外场地这类网络不稳定的地方。
第一版我不打算做完整的组织架构、权限角色、消息推送,这些对一个小型社团来说太重了。先把任务列表这个最小闭环做扎实:本地保存不依赖服务器,打开就能看,操作完自动落盘。用户量就是几十个人,任务量一天几十条,这个规模对本地存储毫无压力,也让我能把精力放在交互和稳定性上。
1.2 原生写过,为什么还是选了Flutter
我也认真对比过OpenHarmony原生开发和Flutter跨平台这两条路。原生开发写ArkTS,性能确实直接,系统能力调用也最方便,但团队里只有我一个人熟悉ArkTS,其他人更熟Flutter和Dart。如果后面还要出一个管理端、或者想让同样一套代码跑在Android备用机上,原生方案就意味着要维护两套代码。
| 对比维度 | OpenHarmony原生开发 | Flutter跨平台 |
|---|---|---|
| 开发语言 | ArkTS | Dart |
| UI一致性与多端复用 | 每端单独写一套 | 一套代码多端运行 |
| 团队上手成本 | 需要重新学一套生态 | 会Flutter基本零成本 |
| 系统能力调用 | 直接调用,最全 | 依赖插件适配,有滞后 |
| 包体与性能 | 原生最优 | 略大,但列表场景足够流畅 |
Flutter 的渲染层是自绘的,不依赖系统组件,所以在 OpenHarmony 上能拿到和 Android 几乎一致的外观,这对跨端一致性很有价值。还有团队技能红利:主力同学写Flutter已经两三年,这个项目又不用碰太深的系统API,主要就是列表、表单、本地存储。选Flutter能省一半工期,完全值得。
1.3 为什么把任务列表作为第一个模块
任务列表是整个App里最“五脏俱全”的一个模块。发布任务是创建,列表是查询,左滑删除、状态切换是更新,还有筛选、排序、空状态处理。这些基础能力几乎覆盖了后续所有页面要用到的技术点,而且它有明确的验收标准:发一条任务,能在列表里看到并筛选,重启App后数据还在。
把这个模块跑通的价值在于,它先验证了Flutter在OpenHarmony上的完整链路:创建工程、真机连接、热重载、本地存储、列表性能。这个链路一旦打通,后面再做社团成员通讯录、活动日历,就是往这套骨架上填内容。所以项目第一阶段我砍掉了所有花哨功能,只专心做任务列表。
2. 任务列表的需求拆解与数据模型设计
2.1 任务字段:比普通待办App多出来了什么
普通待办App通常只要“标题+日期+完成勾选”,但社团任务是协作型任务,信息维度明显要多。我在第一版数据模型里定了这几个核心字段。
enum TaskStatus { pending, inProgress, completed } enum TaskPriority { low, medium, high } class Task { final String id; final String title; final String description; final String assignee; // 负责人,社团场景里比“我”更重要 final TaskPriority priority; final TaskStatus status; final DateTime deadline; final DateTime createdAt; Task({ required this.id, required this.title, this.description = '', this.assignee = '', this.priority = TaskPriority.medium, this.status = TaskStatus.pending, required this.deadline, required this.createdAt, }); Task copyWith({ String? title, String? description, String? assignee, TaskPriority? priority, TaskStatus? status, DateTime? deadline, }) { return Task( id: id, title: title ?? this.title, description: description ?? this.description, assignee: assignee ?? this.assignee, priority: priority ?? this.priority, status: status ?? this.status, deadline: deadline ?? this.deadline, createdAt: createdAt, ); } Map<String, dynamic> toJson() { return { 'id': id, 'title': title, 'description': description, 'assignee': assignee, 'priority': priority.name, 'status': status.name, 'deadline': deadline.toIso8601String(), 'createdAt': createdAt.toIso8601String(), }; } factory Task.fromJson(Map<String, dynamic> json) { return Task( id: json['id'] as String, title: json['title'] as String, description: (json['description'] ?? '') as String, assignee: (json['assignee'] ?? '') as String, priority: TaskPriority.values.byName(json['priority'] as String), status: TaskStatus.values.byName(json['status'] as String), deadline: DateTime.parse(json['deadline'] as String), createdAt: DateTime.parse(json['createdAt'] as String), ); } }assignee 字段是最重要的业务差异化点。个人待办里“负责人”就是我自己,但社团任务里的负责人可以是某一届的干事,也可以是临时来帮忙的同学,所以它必须是一个可写可改的文本字段,不能写死在UI里。deadline 用 DateTime 而不是字符串,是为了后面能做“按截止时间排序”和“逾期标红”。
2.2 状态流转与排序规则:把逻辑收进模型层
任务状态我初始定义为三态:待办、进行中、已完成。三态已经能覆盖社团场景绝大多数情况,再加“已取消”这种状态属于过度设计,会让筛选器和状态标签复杂一倍。状态流转规则也很简单:创建后是待办,接单后转进行中,完成后转已完成。不允许从已完成跳回待办这种反向操作,但允许从已完成改回进行中,因为真实场景里确实有“任务验收不通过要返工”的情况。
排序规则是页面体验的关键。我给列表定义了两个默认维度:未完成的任务一定排在已完成前面;同状态内按截止时间升序。这样打开App第一眼看到的是“今天要完成什么”,而不是按创建时间倒序堆出来的历史。排序逻辑放在模型层的 visibleTasks 里统一处理,不散落在各个Widget里,后面凡是能看到任务列表的页面都能复用同一套排序。
List<Task> get visibleTasks { final filtered = _tasks.where((t) => t.status == _filter).toList(); filtered.sort((a, b) => a.deadline.compareTo(b.deadline)); return filtered; }2.3 存储选型:为什么先不碰数据库
第一版存储我用的是 SharedPreferences。理由很简单:数据量上限就是几百条,JSON序列化整个列表也就几十KB,每次增删改都整体写一遍,对现代手机来说这点IO开销完全可以忽略。如果一上来就引入数据库框架,反而要处理建表、迁移、异步查询等问题,对一个几十人用的工具App来说是杀鸡用牛刀。
当然SharedPreferences也有自己的边界。如果后面要做全文搜索、按多个条件组合筛选、或者统计任务完成率,就应该迁到 sqlite 或其他数据库方案。到时候只需要把 TaskRepository 的实现换掉,接口保持 fetchAll/insert/update/delete 不变,UI层完全不用动。这也是为什么我坚持所有存储操作都走 Repository 封装,而不是在页面里直接写 SharedPreferences 调用。
3. Flutter跑在OpenHarmony上:环境配置和工程适配
3.1 SDK分支与版本匹配:这一步最容易被老教程坑到
Flutter for OpenHarmony 的工程组织方式和常规Flutter项目不太一样,它的SDK不是通过flutter官方下载渠道直接拿到的,通常是从社区维护的 flutter_flutter 仓库拉取 OpenHarmony 适配分支。这里最需要注意的一点是:分支版本必须和 OpenHarmony SDK 版本对得上。
我最初在网上翻到一篇比较老的教程,照它配完后怎么都构建不成功,最后发现是分支和本机的OpenHarmony版本差了整整一代。所以配置前先确认两件事:第一,你用的OpenHarmony SDK/真机系统是哪个API版本;第二,flutter_flutter 仓库里哪个分支对应这个API版本。建议直接用仓库里维护状态比较新的主适配分支,而不是某些博客里写死的旧分支。
3.2 创建工程并检查设备
环境配置好之后,创建工程和常规Flutter项目没太大区别,核心命令就这一条:
flutter create --platforms ohos --org com.example club_task_app cd club_task_app flutter doctor flutter devices--platforms ohos是关键参数,它告诉工具链生成OpenHarmony平台的原生壳工程。生成的项目结构里会多出一个ohos/目录,里面是标准的OpenHarmony工程布局,用 DevEco Studio 打开这个目录就可以编辑原生侧代码、做签名配置和打包hap包。日常我会用自己熟悉的编辑器写Dart代码,只有需要改原生壳工程或签名时才打开DevEco Studio。
设备检测这步如果flutter devices看不到设备,先回系统设置里确认开发者模式已打开、USB调试已授权。第一次连接时手机屏幕会弹出授权框,记得点允许,许多人在这里漏看了一眼,后面一直报设备离线。
3.3 签名与第一次运行
OpenHarmony真机调试需要签名。自动签名流程在DevEco Studio里做:打开ohos/目录,进入项目配置的签名相关页面,登录开发者账号后让它自动生成本地调试证书即可。有一点要特别注意:签名配置属于本地文件,不要提交到git仓库,团队协作时每个人用自己的签名,否则会互相覆盖。
签名配好后,回到命令行执行flutter run。第一次运行会触发完整的hap包构建,耗时比普通Android项目长不少,属于正常现象。这个过程中最直观的感受是Flutter侧代码的改动依然可以热重载,但原生壳工程的改动不能,具体坑后面专门讲。
4. 任务列表界面的完整实现
4.1 状态管理选型:ChangeNotifier 这套够用且省心
任务列表涉及多个页面共享同一份数据:列表页要看任务,详情页要改状态,添加页要插入新任务。如果都用 setState 自己存一份,页面切换回来数据就不同步。我选了 ChangeNotifier + Provider 这套组合,没有上更重的状态管理库,原因很直接:项目规模小,状态树简单,ChangeNotifier 的 notifyListeners 机制已经足够完成任务发布和页面刷新,而且团队里每个人都熟,不需要额外学习成本。
class TaskListModel extends ChangeNotifier { final TaskRepository _repo; List<Task> _tasks = []; TaskStatus _filter = TaskStatus.pending; TaskListModel(this._repo); List<Task> get visibleTasks { final filtered = _tasks.where((t) => t.status == _filter).toList(); filtered.sort((a, b) => a.deadline.compareTo(b.deadline)); return filtered; } bool get hasFilteredTasks => visibleTasks.isNotEmpty; Future<void> load() async { _tasks = await _repo.fetchAll(); notifyListeners(); } Future<void> add(Task task) async { await _repo.insert(task); _tasks.add(task); notifyListeners(); } Future<void> update(Task task) async { await _repo.update(task); final index = _tasks.indexWhere((t) => t.id == task.id); if (index >= 0) _tasks[index] = task; notifyListeners(); } Future<void> delete(String id) async { await _repo.delete(id); _tasks.removeWhere((t) => t.id == id); notifyListeners(); } void changeFilter(TaskStatus status) { if (_filter == status) return; _filter = status; notifyListeners(); } }模型层把筛选、排序、增删改全部收拢,UI层只负责“调用模型的方法”和“根据模型的状态去渲染”,职责边界非常清楚。后面哪怕要把任务列表搬进一个 tab 页面、或者加一个统计页,都不用改动现有模型的方法,直接复用即可。
4.2 列表页骨架:筛选、滑动删除、刷新一个不少
列表页的主体结构我用三块拼成:顶部用 SegmentedButton 做状态筛选,中间是 ListView.builder 渲染任务卡片,右下角放一个 FloatingActionButton 用来发起新任务。之所以选 SegmentedButton 而不是 TabBar,是因为它更适合做“筛选当前列表”而不是“切换页面”,视觉上更轻,代码也更短。
Scaffold( appBar: AppBar(title: const Text('任务列表')), body: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: SegmentedButton<TaskStatus>( segments: const [ ButtonSegment(value: TaskStatus.pending, label: Text('待办')), ButtonSegment(value: TaskStatus.inProgress, label: Text('进行中')), ButtonSegment(value: TaskStatus.completed, label: Text('已完成')), ], selected: {model.currentFilter}, onSelectionChanged: (selection) { model.changeFilter(selection.first); }, ), ), Expanded( child: Consumer<TaskListModel>( builder: (context, model, child) { final tasks = model.visibleTasks; if (tasks.isEmpty) { return const _EmptyPlaceholder(); } return RefreshIndicator( onRefresh: model.load, child: ListView.builder( itemCount: tasks.length, itemBuilder: (context, index) { final task = tasks[index]; return Dismissible( key: ValueKey(task.id), direction: DismissDirection.endToStart, onDismissed: (_) => model.delete(task.id), background: Container( color: Theme.of(context).colorScheme.errorContainer, alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 24), child: const Icon(Icons.delete_outline), ), child: _TaskCard(task: task), ); }, ), ); }, ), ), ], ), floatingActionButton: FloatingActionButton( onPressed: () => _openEditor(context, model: model), child: const Icon(Icons.add), ), )这里有两个容易被忽略的细节。第一,ListView 必须放在 Expanded 里,否则在 Column 下会报 unbounded height 的异常,这个坑后面会专门展开。第二,我用了 Consumer 而不是在 initState 里监听模型,这样当某个操作只改了某个任务时,Flutter 会精准重建依赖该数据的Widget,而不是整页刷新。
4.3 任务卡片设计:信息密度和视觉优先级
单条任务卡片需要同时呈现标题、负责人、截止时间、状态、优先级五个信息,如果全部平铺,视觉上就是一坨字。我的做法是做一个层次化的卡片:左侧用 4px 宽的色条表示优先级,标题加粗,副标题只放“负责人和截止时间”,右上角放一个轻量的状态标签。
优先级颜色我用语义色而不是彩虹色:高优先级用红色系、中优先级用橙色系、低优先级用绿色系。这个约定和大多数人的直觉一致,不需要额外图例。色条比整卡变色克制得多,长时间盯列表不会觉得吵。状态标签我直接用枚举的文案,主要靠文字区分,不再叠加额外图标,因为列表已经够多视觉元素了。
class _TaskCard extends StatelessWidget { final Task task; const _TaskCard({required this.task}); Color _priorityColor(TaskPriority priority) { switch (priority) { case TaskPriority.high: return Colors.red.shade400; case TaskPriority.medium: return Colors.orange.shade400; case TaskPriority.low: return Colors.green.shade400; } } @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), clipBehavior: Clip.antiAlias, child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container(width: 4, color: _priorityColor(task.priority)), Expanded( child: ListTile( title: Text(task.title, style: textTheme.titleMedium), subtitle: Text( '负责人:${task.assignee} 截止:${_formatDate(task.deadline)}', style: textTheme.bodySmall, ), trailing: _StatusChip(status: task.status), onTap: () => _openDetail(context, task: task), ), ), ], ), ), ); } }4.4 添加任务表单:字段校验和默认值
点右下角加号进入添加页。我没有用底部弹窗,因为任务表单字段多,还有日期选择器,底部弹窗在窄屏上会被键盘顶得很难受,独立页面更稳妥。表单字段按重要程度排:标题必填,负责人必填,截止时间默认是今天加三天,描述选填,优先级默认中。
标题和负责人用了必填校验,空值提交时在输入框下方展示错误文案,这是手机上成本最低的防错手段。截止时间用的是 showDatePicker,一方面能避免手输格式错误,另一方面Flutter的日期选择器自带防未来日期误选等能力。提交按钮点击后直接调用model.add(task),然后Navigator.pop回列表页,新任务立刻出现在当前筛选状态对应的列表里。
有个体验细节值得说:如果当前筛选在“进行中”,添加的新任务状态是“待办”,提交后列表里看不到新任务,用户会以为没保存成功。我在提交成功后加了提示气泡,并提示用户新任务在“待办”列表下,避免这种困惑。
5. 数据持久化与页面间状态共享
5.1 Repository 封装:UI层不碰Json
UI层如果直接操作 SharedPreferences 和 jsonEncode,代码会散落得到处都是,而且序列化逻辑只要改一处字段,就得把所有调用点翻一遍。所以我单独抽了一个 TaskRepository,所有存储细节都装在里面,对外只暴露语义化方法:fetchAll、insert、update、delete。
class TaskRepository { static const _storageKey = 'club_task_list_v1'; Future<List<Task>> fetchAll() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_storageKey); if (raw == null || raw.isEmpty) return []; final list = jsonDecode(raw) as List<dynamic>; return list .map((item) => Task.fromJson(item as Map<String, dynamic>)) .toList(); } Future<void> _persist(List<Task> tasks) async { final prefs = await SharedPreferences.getInstance(); final payload = jsonEncode(tasks.map((t) => t.toJson()).toList()); await prefs.setString(_storageKey, payload); } Future<void> insert(Task task) async { final tasks = await fetchAll(); tasks.add(task); await _persist(tasks); } Future<void> update(Task task) async { final tasks = await fetchAll(); final index = tasks.indexWhere((t) => t.id == task.id); if (index >= 0) { tasks[index] = task; await _persist(tasks); } } Future<void> delete(String id) async { final tasks = await fetchAll(); tasks.removeWhere((t) => t.id == id); await _persist(tasks); } }这里我采用“每次操作都先读全量再改再写回”的策略。数据量小的时候这种写法最简单,不会出现并发写覆盖的问题,而且代码一眼就能看懂。等数据量真的大到需要数据库时,这个Repository接口层也能平滑迁移。
5.2 详情页的数据流:传id还是传对象
从列表卡片点进详情页时,我选择传任务id,而不是传整个Task对象。原因是详情页里可能会修改状态、改负责人、调整截止时间,这些修改必须回写到模型层。如果只传对象,详情页本地改完之后列表页不知道、模型层也不知道,就会出现详情页和列表页状态不一致的经典Bug。
传id的流程是:详情页通过id从模型层的完整任务列表里查找当前Task,所有修改都调用model.update(task)统一更新。详情页里的界面状态由传入的任务和模型共同驱动,任务完成状态切换后,列表页和筛选结果在同一次notifyListeners里一起刷新,不会出现任何一方的残留旧数据。
5.3 启动加载与首帧体验
App启动时,数据加载放在 main 函数里触发,而不是放在某个页面的 initState 里。原因有两个:其一,确保任何页面打开时模型里已经有数据;其二,把加载操作和UI解耦,避免页面重建时重复发起加载。SharedPreferences 的读取非常快,首屏基本感知不到加载过程,所以我直接同步调用 load,不用额外做启动闪屏遮罩。
void main() { final repo = TaskRepository(); final model = TaskListModel(repo)..load(); runApp( ChangeNotifierProvider( create: (_) => model, child: const ClubApp(), ), ); }如果后面接入远程服务器,需要网络加载,把这个..load()改成异步等待再进主页面即可,模型层和UI层不需要动。这个启动顺序的好处就是把“数据从哪里来”和“数据怎么展示”完全分开,哪怕数据源从本地换成服务器,也只是换个Repository实现。
6. 真机调试中躲不开的四个坑与排查思路
6.1 改了原生壳工程,热重载却不生效
第一次改ohos/目录下的权限声明或入口文件时,我差点以为Flutter在OpenHarmony上不支持热重载。后来排查发现,热重载只作用于Dart层,原生壳工程里任何改动都必须重新构建hap包才能生效。具体现象就是:你改了一个原生插件配置,然后保存dart文件触发hot reload,App里毫无变化,因为原生侧没重新编译。
排查链路也不复杂:先确认自己改的是不是ohos/目录下的内容,如果是,就直接停止当前的 flutter run,重新整机构建。后来我养成了一个习惯:凡是涉及原生插件的变更,先判断它是否需要动原生侧代码,需要的话就直接走完整构建,不在热重载上浪费时间。
6.2 Column 里套 ListView 导致的溢出崩溃
列表页最初我图省事,把SegmentedButton和ListView直接放在Column里,没加Expanded,运行后立刻报了一屏的布局异常。这类问题的本质是ListView这类可滚动组件在垂直方向上的高度是无限延伸的,Column在计算子组件高度时拿到一个无穷大的约束,直接炸掉。
解决办法就是用 Expanded 把ListView“锁”在剩余空间里,这也是列表页代码里那个Expanded的真正含义。以后只要遇到类似布局,只要记住一个原则:可滚动组件出现在Column或Row里时,外面必须包Expanded或指定高度,就不会再在这个问题上栽跟头。
6.3 第三方插件报MissingPluginException
项目里需要读取设备信息,我从pub.dev找了一个在Android上表现很好的插件,结果在OpenHarmony真机上直接报 MissingPluginException。原因很直白:OpenHarmony的Flutter插件生态和Android不是一回事,很多插件的OpenHarmony端实现并没有同步存在,或者只在某个特定适配分支里。
排查思路是先看插件仓库里有没有对应OpenHarmony的实现目录,没有的话就找两个方向替代:一是用纯Dart实现的插件,因为纯Dart层不依赖平台通道,天然兼容;二是查插件说明里有没有标注OpenHarmony支持,或看有没有独立的适配分支。后来我把设备信息获取改成了纯Dart实现加少量原生侧自己写的方法,问题才彻底解决。这个教训在选型阶段就值得注意:优先选纯Dart实现或明确支持OpenHarmony的库,别在集成到一半才发现平台通道不通。
6.4 列表滑动掉帧:优化渲染粒度
任务数量加到几十条后,列表在真机上偶尔出现滑动掉帧。这不是Flutter on OpenHarmony本身的性能问题,而是我代码里重建粒度太粗。列表项里有优先级色条、状态标签、日期文本,每次筛选状态变化或任意任务更新,整列都跟着重建,加上卡片里有一些没有缓存的包装组件,成本就上去了。
优化我做了三件事。第一,确保列表项用ListView.builder而不是一次生成全部Widget,只构建屏幕内可见的子项。第二,把任务卡片做成独立的StatelessWidget,并尽量减少卡内不必要的不透明度、阴影等绘制操作。第三,在可能频繁变化的区域外面加 RepaintBoundary,把局部重绘隔离到小范围。这三板斧下去,滑动恢复到了跟Android端差不多的流畅度。列表性能问题大多数时候不是框架不行,而是代码没有给框架偷懒的机会。
把这个任务列表模块从环境配置到真机跑通,整个过程最有价值的体会是:不要一上来就堆重武器,数据库、网络层、状态管理全家桶看着专业,但一个只有几十人用的社团工具根本用不上那么复杂的架构。把任务列表这个最小闭环做扎实,数据模型定清楚,存储接口封装好,后面加功能只是往骨架上填肉。
如果你也想在OpenHarmony设备上跑Flutter,建议同样从任务列表这样的小模块切入,而不是直接挑战复杂页面。环境适配和插件生态的问题比较前置,但一旦跨过去,后面就是常规Flutter开发体验。下一步这个App可以考虑接一个轻量后端做多人同步、加一个社团成员通讯录、或者把任务列表导出成活动复盘报告,方向很多,但核心都是先把列表这一层踩踏实。