先说结论:这个标题看着像是一个简单的 TodoList 教程,但往深了挖,它其实是一条从“能跑”到“能用”再到“好维护”的完整演进路线。尤其当“Flutter”和“OpenHarmony”这两个词凑在一起的时候,核心难点压根不在业务逻辑,而在跨端适配和工程架构上。换句话说,列表增删改查只是载体,三层 Tab 怎么设计、数据模型怎么解耦,才是这个项目真正值钱的地方。
我在实际动手改造这个项目的过程中,踩了不少坑,也总结了一套从零搭到产品级的思路。这篇文章不打算罗列 API 文档,而是把“为什么这么做”“当时遇到了什么”“最后怎么解决的”讲清楚,内容适合已经在 OpenHarmony 设备上跑过 Flutter 项目、但又觉得工程结构越写越乱的开发者;当然,如果你刚把 Flutter 环境装好,正准备找一个小而完整的项目练手,这篇也能帮你少走几个弯路。
1. 内容整体设计与思路拆解
1.1 一个 TodoList 为什么要谈“产品化演进”
很多开发者对 TodoList 的印象停留在“CRUD 练手项目”,这没有错,但仅限于把功能跑通的那个层面。一旦你把“运行在 OpenHarmony 设备上”作为硬性条件,事情就变了:设备屏幕尺寸不固定、系统级返回手势与 Flutter 路由冲突、输入法弹出时布局需要自适应、后台切换后状态恢复、还有原生插件调用链路的兼容性……这些都不是在 Android/iOS 模拟器上写几行 Dart 就能解决的。
所以我把这次项目的目标拆成了三个递进层次:
- 功能层:Todo 的新增、编辑、删除、状态切换(已完成/未完成)、按优先级筛选。这是地基。
- 架构层:三层 Tab 导航体系 + 数据模型与 UI 解耦。这是主体工程。
- 产品层:持久化存储、按需刷新、主题适配、异常兜底。这是让项目从“demo”变成“产品”的关键。
标题里的“产品化演进”指的就是第三层。如果只是把官方 counter demo 改成 TodoList,那不算演进;真正的演进是你在写完第一版能跑的代码之后,跳出来重新审视整个工程,发现“这里耦合了”“那里每次改需求都牵一发动全身”,然后做结构调整。这篇文章的主体部分,会完整演示这个审视和调整的过程。
1.2 三层 Tab 架构的含义与选型理由
标题里的“三层 Tab”并不是指三个 Tab 页签那么简单。我最终落地的是这样一套分层结构:
- 底层系统 Tab(底部导航):承载“今日待办”“全部任务”“我的”三个一级页面。这一层解决的是产品最粗粒度的信息分组。
- 中层业务 Tab(顶部筛选):在“全部任务”页面内,提供“进行中/已完成/已过期”三个筛选 Tab。这一层解决的是同一数据类型下的状态分类。
- 内层内容 Tab(嵌套视图):每个业务 Tab 下再根据紧急程度、创建时间等维度做动态分组。
这个结构最容易被新手问倒的问题是:为什么不用一个 BottomNavigationBar 加一个 TabBar 直接堆上去?
我的答案是:一旦你打算做产品化,任何 UI 结构都必须对应明确的数据边界和刷新粒度。 底部导航切换时,我们希望每个一级页面保持自己的滚动位置和过滤状态;顶部筛选切换时,我们希望数据列表能够独立刷新,而不是整个页面重建;内层内容分组又希望基于同一份内存数据做不同维度的排序。这三层各有各的关注点,混在一起写很快就会变成一团乱麻——setState 到处飞、页面互相干扰、状态不知道被谁改掉了。
因此,我采用的方案是:底层系统 Tab 由根组件管理,使用 IndexedStack 保持各页面状态;中层业务 Tab 由各页面内部管理,使用 TabController 联动 TabBarView;内层内容分组则交给数据层根据过滤条件动态计算。 这样每一层只需要关注自己那一层的事。
1.3 数据模型解耦:从“代码搬运”到“设计分层”
如果只讲 UI,那这篇博文和普通教程没什么区别。但这个项目里数据模型解耦的篇幅,可能比重构 UI 还要多,因为我在第一个版本里吃够了耦合的苦头。
第一版代码是这样的逻辑:TodoItem 直接塞在 Widget 里,状态管理用全局变量,增删改查直接操作 List,然后 setState 整页刷新。跑起来没问题,但一旦需要加“本地持久化”“启动时恢复上次未完成列表”“按优先级排序”这些产品级需求,立刻崩:
- 数据变更和 UI 刷新搅在一起,没法单独测试数据逻辑。
- 存储方案从内存换成数据库或者文件,界面代码要跟着大改。
- 多个页面共享数据时,根本不知道谁改了什么。
所以在第二版重构时,我把数据层独立出来,做了下面这个分层:
| 层级 | 职责 | 对应目录 |
|---|---|---|
| 数据模型层 | 定义 TodoItem、TodoStatus、优先级等纯数据类型,负责 JSON 序列化 | lib/models |
| 数据源层 | 封装本地存储、未来可扩展远端同步,对外暴露增删改查接口 | lib/data |
| 仓库层(Repository) | 业务逻辑层,负责数据校验、排序、过滤 | lib/repository |
| 状态管理层 | 通过 Provider/Riverpod 对外暴露状态,通知 UI 刷新 | lib/state |
| UI 层 | 只负责渲染和用户交互事件,不直接操作数据 | lib/pages |
这个分层最大的好处是:UI 层完全不知道数据到底存在内存里还是文件里、数据库里。 某个列表项被勾选之后,UI 只是调用了 viewModel.toggle(item.id),至于这个 toggle 背后是改内存数组、写数据库、还是同步到远端,UI 不需要关心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 三层 Tab 的工程级实现方式
这一部分直接上代码。先看根组件怎么管理底部导航。核心诉求是:切换底部 Tab 时,页面状态不丢,同时不要一次性把所有页面都构建出来(虽然 IndexedStack 会全构建,但对这个项目规模可接受)。
dart复制// lib/root_page.dart
import 'package:flutter/material.dart';
import 'pages/today_page.dart';
import 'pages/all_tasks_page.dart';
import 'pages/profile_page.dart';
class RootPage extends StatefulWidget {
const RootPage({super.key});
@override
State<RootPage> createState() => _RootPageState();
}
class _RootPageState extends State<RootPage> {
int _currentIndex = 0;
static const _pages = [
TodayPage(),
AllTasksPage(),
ProfilePage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
// IndexedStack 的关键作用:保留每个页面自身的状态。
// 例如你在“全部任务”页滚动到第 40 条,切到“今日待办”再切回来,
// 滚动位置依旧不变。
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() => _currentIndex = index);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.today_outlined),
selectedIcon: Icon(Icons.today),
label: '今日',
),
NavigationDestination(
icon: Icon(Icons.checklist_outlined),
selectedIcon: Icon(Icons.checklist),
label: '全部',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
),
],
),
);
}
}
注意,这里我刻意用了 Material 3 的 NavigationBar 而不是旧的 BottomNavigationBar。原因是 OpenHarmony 上 Flutter 的 Material 3 渲染引擎适配得更好,水波纹、选中态、颜色过渡的帧率表现明显比旧组件平滑。我实测过在 rk3568 设备上,快速切换底部 Tab 时 NavigationBar 的动画没有丢帧情况,而旧组件在相同负载下偶尔有掉帧。
再来看“全部任务”页面的中层 Tab:
dart复制// lib/pages/all_tasks_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/todo_view_model.dart';
import '../widgets/todo_list_view.dart';
class AllTasksPage extends StatefulWidget {
const AllTasksPage({super.key});
@override
State<AllTasksPage> createState() => _AllTasksPageState();
}
class _AllTasksPageState extends State<AllTasksPage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
// 页面初始化时从仓库加载数据,触发状态更新
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<TodoViewModel>().loadAll();
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(
