把 TodoList 从“本地数组 + setState”一路演进到能跑在 OpenHarmony 真机上的产品级应用,这个过程中踩过的坑、推倒重来的设计,比我预想的多得多。如果你正打算把 Flutter 业务迁移到 OpenHarmony,或者正在苦恼“多 Tab 页面该怎么组织、数据层怎么拆才能不越改越乱”,这篇文章就是为你准备的。它不只是一份 TodoList 教程,而是把三层 Tab 架构、数据模型解耦和 OpenHarmony 设备适配这三个关键点串成一条完整演进的路线。
先说结论:Flutter 跑上 OpenHarmony 不是最难的,难的是跑起来之后,如何保证页面结构不塌、数据流不糊、换一块 rk3568 开发板还能正常触摸和显示。这篇文章从我做 TodoList 产品化改造的实际过程出发,把这些硬骨头一个个拆开讲透。
1. 为什么“能运行的 TodoList”离“产品化”还很远
很多人拿到一个 TodoList 的示例工程,跑通之后就以为任务完成了。但 Demo 和产品之间隔着的不是 UI 精细度,而是几个特别容易被忽略的维度。
1.1 功能之外:产品化意味着这些非功能指标
TodoList 的增删改查,任何一个 Flutter 初学者三天就能写完。但进入产品化阶段,问题就变了:
- 应用升级后,用户本地的几百条待办数据不能丢,这涉及数据迁移和持久化方案。
- 用户从“今日”Tab 切到“全部”Tab,再切回来,列表滚动位置和筛选状态不能重置,这涉及页面状态保持。
- 应用不能只跑在自己电脑的模拟器里,还要能跑在 rk3568 开发板、不同屏幕比例的手机、甚至 x86 架构的 OpenHarmony 设备上,这涉及设备适配。
- 程序不能一崩溃就白屏,数据层要能自愈,至少能保证本地缓存不损坏。
这些非功能需求,恰恰是示例工程里完全没有的。我重构 TodoList 的第一件事,就是把“功能清单”扔到一边,先列“演进目标”。我当时的演进目标有三条:页面结构从单页变成三层 Tab;数据模型从内存数组变成可持久化、可同步的 Repository;运行平台从 Android 扩展到 OpenHarmony 真机。
这个顺序很重要。如果先做 OpenHarmony 适配,再改架构,你会发现自己在一个不熟悉的平台上同时调试架构问题和平台问题,排错成本直接翻倍。我的建议是先在原平台把架构演进完成,确认逻辑没问题,再切到 OpenHarmony 做适配验证。
1.2 从玩具级到产品级的演进主线
玩具级代码有一个典型特征:所有东西都堆在 Widget 里。我最初的原型也是如此,TodoList 的数据直接是 List<TodoItem>,add、delete 方法直接写在页面 State 里,状态靠 setState 硬刷。这种写法在 50 行代码时很爽,但一旦引入三层 Tab,问题就暴露了:多个 Tab 页面要共享同一个数据源,一个页面改了数据,其他页面必须同步刷新。如果数据还散落在各自页面的 State 里,你只能在各个页面之间来回传回调,传到最后自己都分不清哪个回调是干嘛的。
所以我把演进分成了两条主线同步推进:
- 结构线:由“单页列表”演进为“RootTab + ModuleTab + ContentTab”三层 Tab 架构。每一层只负责自己那一级的导航职责,不跨层操作。
- 数据线:由“页面本地 List”演进为“Repository 数据仓库 + 本地数据库 + 可选同步通道”。所有页面都不再直接持有数据,而是通过统一的仓库接口读取、修改。
这两条线不是独立的。三层 Tab 架构天然需要多个页面共享同一份数据,而数据模型解耦后的 Repository 正好为这种共享提供了唯一的出口。这也是标题里把“三层 Tab 架构”和“数据模型解耦”并列的原因——它们是互相成就的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三层 Tab 架构在 TodoList 中的职责切割
Tab 导航是移动应用最常见的交互范式,但 “Tab” 这个词在不同语境下指的东西完全不一样。产品化演进的第一步,就是把混乱的 Tab 概念拆成三个清晰的层级。
2.1 三层到底怎么分层:RootTab、ModuleTab、ContentTab
我最终采用的分层方式是:
- RootTab(根级 Tab):应用启动后底部的几个主入口,TodoList 里我放了“今日”“全部”“已完成”三个。这里决定的是“用户当前处于哪一个功能域”。
- ModuleTab(模块级 Tab):某个功能域内部的子视图切换。在“全部”这个 RootTab 里,我又分出了“按日期分组”“按优先级分组”两个 ModuleTab,解决的是“同一份数据用什么视角看”。
- ContentTab(内容级 Tab):最内层的内容切换。比如点击某条待办进入编辑页,里面有“详情”“动态”“关联”三个内容标签,它们共享同一条 TodoItem 数据,只是展示维度不同。
直接看代码更清楚。我定义了一个通用的 TabItem 模型:
dart复制class TabItem {
final String id;
final String title;
final WidgetBuilder builder;
const TabItem({
required this.id,
required this.title,
required this.builder,
});
}
然后 RootTab 和 ModuleTab 都基于它构建:
dart复制class RootTabPage extends StatelessWidget {
final List<TabItem> tabs;
final int currentIndex;
final ValueChanged<int> onIndexChanged;
const RootTabPage({
super.key,
required this.tabs,
required this.currentIndex,
required this.onIndexChanged,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: currentIndex,
children: [for (final tab in tabs) tab.builder(context)],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onIndexChanged,
items: [
for (final tab in tabs)
BottomNavigationBarItem(icon: Icon(iconFor(tab.id)), label: tab.title),
],
),
);
}
}
之所以把 currentIndex 和 onIndexChanged 作为参数暴露出来,而不是在 RootTabPage 内部管理 State,是为了让 Tab 状态可以提升到更上层的“页面控制器”里统一管理。这在后面做状态持久化、深链定位时非常有用。
2.2 Tab 状态保持与页面恢复的落地细节
三层 Tab 架构最容易翻车的地方,不是切 Tab 本身,而是切完再切回来时,页面状态丢了。Flutter 里常见的做法是 IndexedStack 或者 AutomaticKeepAliveClientMixin,但两种方案在多层 Tab 叠加时有本质区别。
我实测下来的经验:如果只有两层 Tab,用 IndexedStack 最省事,它会一次性把子树都 build 出来,切换时只是改变显示索引,页面状态天然保留。但到了三层 Tab,问题来了——如果所有层级都用 IndexedStack,最内层不可见的页面也会被 build 和缓存,内存占用会叠出一个不小的量级。TodoList 这种轻应用还好,换成图片较多的业务就会出现明显的卡顿。
我的处理方式是分层选择策略:
| 层级 | 状态保持方案 | 理由 |
|---|---|---|
| RootTab | IndexedStack | 页面数量固定且不超过5个,缓存收益高 |
| ModuleTab | PageStorage + KeepAlive | 子项较多时按需构建,但要保留滚动位置 |
| ContentTab | 局部 State + 数据驱动 | 内容区共用同一数据模型,不需要全量缓存 |
ModuleTab 这层我踩过一个坑:用 TabBarView 配合 AutomaticKeepAliveClientMixin 时,子页面必须正确调用 super.build(context),并且在 wantKeepAlive 返回 true,否则一旦滑出可视区域,State 就被销毁了。另外,TabBarView 的页面是通过 PageView 机制滑动的,如果 ModuleTab 的内容不是左右滑动切换,而是点击 Tab 头部切换,那完全可以不用 TabBarView,直接用 AnimatedSwitcher 配合数据刷新,状态由上层 Repository 统一恢复。
2.3 三层 Tab 之间的通信边界
三层 Tab 页面之间必然要通信,比如“今日”页签需要监听“全部”页签里新增的数据变动。如果让每个页面自己去监听数据层,很容易写出“广播风暴”式的代码。
我给三层 Tab 定的通信规则只有一条:任何 Tab 页面都不直接调用其他 Tab 页面的内部方法,统一通过上层状态管理对象来中转。 具体落地时,我没有引入重量级状态管理框架,而是用 Flutter 自带的 ChangeNotifier + InheritedNotifier 做了一个轻量的页面控制器。
这个控制器的职责是:持有当前 RootTab 索引、ModuleTab 索引、ContentTab 索引,并提供切换方法。每个 Tab 页面的 build 方法只依赖控制器里的“自己关心的那部分状态”,不会因为其他 Tab 的切换而重建。
举个例子,编辑页的 ContentTab 切换“详情”和“动态”时,左侧的“今日”列表完全不应该重建。所以我把 ContentTab 的当前索引放在了一个独立的 ValueNotifier<int> 里,而不是放在统一的页面控制器里。这个细节看似微小,但在真机上对比过性能后,你会发现它避免了大量无谓的 widget rebuild。
3. 数据模型解耦:TodoList 的数据层是怎么重构的
标题里“数据模型解耦”这六个字,可能是整个项目里最容易被误解的部分。很多人以为解耦就是“把 Model 类单独拎出来放一个文件”,实际上这离真正的解耦还差得远。
3.1 把“Model”从“页面临时变量”里救出来
在最早的版本里,我的 TodoItem 只是一个数据结构:
dart复制class TodoItem {
final String id;
final String title;
final bool completed;
final DateTime createdAt;
}
这个类本身没有做错什么,但它的使用方式有问题——页面的 State 里直接维护了一个 List<TodoItem>,增删改查的逻辑也写在页面里。这导致 Model 和 View 高度耦合:一旦你想在另一个页面复用同一个数据源,只能把 List 传来传去。
参考 ER 图建模的思路,我给 TodoItem 重新梳理了实体与关系:
- 实体:TodoItem(待办事项)
- 属性:id、title、description、completed、priority、dueDate、createdAt、updatedAt、deleted、serverId
- 关系:一个 TodoItem 关联若干条 Tag;一个 Tag 关联若干条 TodoItem
这个建模过程看似小题大做,但正是它让我意识到:原来的 Model 缺少 updatedAt 和 deleted 这两个字段。少了它们,后续做本地数据库和后端同步时,根本无法判断哪条数据是新的、哪条数据删除了。所以数据模型解耦的第一步,是先把数据模型本身设计完整,而不是急着拆文件。
3.2 Repository 与本地数据库的选型
解耦的关键一步,是引入 Repository 模式。所有页面不再关心数据是从内存来的、从 SQLite 来的、还是从服务端来的,它们只面对一个统一的接口:
dart复制abstract class TodoRepository {
Stream<List<TodoItem>> watchAll();
Future<List<TodoItem>> loadAll();
Future<void> add(TodoItem item);
Future<void> update(TodoItem item);
Future<void> delete(String id);
Future<void> toggle(String id, bool completed);
}
接口定义完之后,我再为它实现不同的数据源。内存数据源用于测试和快速原型,本地数据库数据源用于正式产品。
本地数据库的选型,我纠结了一段时间。Flutter 生态里常见的有 sqflite、drift、hive、isar。结合要跑在 OpenHarmony 上的这个大前提,我的评估维度有三个:纯 Dart 程度、原生依赖复杂度、查询能力。
| 方案 | 纯 Dart 程度 | 原生依赖 | 查询能力 | OpenHarmony 适配难度 |
|---|---|---|---|---|
| sqflite | 依赖 sqlite3 原生库 | 高 | 强,原生 SQL | 中等,需要确认 sqlite3 在 OpenHarmony 上的可用性 |
| drift | 基于 sqlite,生成代码 | 高 | 强,类型安全 SQL | 中等偏高 |
| hive | 纯 Dart | 低 | 弱,仅 Key-Value | 低,容易适配 |
| isar | 原生二进制 | 高 | 中强 | 高,官方未声明支持 OpenHarmony |
我最终选择了 drift,理由是 TodoList 虽然简单,但产品化之后必然要面临按日期、优先级、关键词筛选的查询需求,用类型安全的 SQL 比手写 Key-Value 过滤要可靠得多。而且 drift 底层是 sqlite3,OpenHarmony 的内核支持 sqlite3 的移植,只要动态库能编译过去,就能跑。
如果你只是做轻量级的设置项存储,那用 hive 就够了,没必要上 drift。这也是数据模型解耦的一个附带好处——因为依赖注入的存在,我可以在不同场景下替换底层实现,页面代码完全无感。
3.3 本地库 + 后端同步的增量策略
“flutter 做本地数据库+后端同步”是开发社区里的高频问题。TodoList 虽然可以不做同步,但既然做产品化演进,我干脆把同步接口也留了出来。这里我采用的策略是**“本地为主,增量拉取,软删除标记”**:
- 所有写操作先写本地数据库,成功后立即刷新 UI。
- 每一条记录都有
updatedAt字段,同步时以上一次同步时间点为基准,拉取增量数据。 - 删除不直接物理删除,而是把
deleted字段置为 true。这样同步时可以安全地把删除操作传给服务端。
同步方向是双向的,冲突解决策略我用的是“最后写入者胜利”,以 updatedAt 为判断依据。这个策略简单,对 TodoList 这种低并发场景足够。如果你的业务需要多人协作编辑同一条数据,那就得引入更复杂的 OT 或 CRDT 算法,但那已经是另一个项目的复杂度了。
代码上,Repository 的具体实现类可以这样组织:
dart复制class LocalTodoRepository implements TodoRepository {
final TodoDao _dao;
LocalTodoRepository(this._dao);
@override
Stream<List<TodoItem>> watchAll() => _dao.watchAll();
@override
Future<void> add(TodoItem item) => _dao.insert(item);
}
class SyncingTodoRepository implements TodoRepository {
final TodoRepository _local;
final TodoRemoteDataSource _remote;
SyncingTodoRepository(this._local, this._remote);
@override
Future<void> add(TodoItem item) async {
await _local.add(item);
await _remote.push(item); // 失败时记录待同步队列
}
}
页面层永远只认 TodoRepository 这个抽象类型,构造时注入 SyncingTodoRepository 还是 LocalTodoRepository,页面根本不知道。
3.4 一个容易踩的坑:状态对象和实体对象混在一起
解耦过程中我犯过一个典型的错误:把 isLoading、isEditing 这类 UI 状态直接写进了 TodoItem 实体类里。这会导致一个后果:列表页面在刷新数据时,编辑页面的 UI 状态也被重建了。
正确的做法是把“数据实体”和“页面状态”严格分开。数据实体只描述业务数据,页面状态放在独立的 TodoListViewModel 里:
dart复制class TodoListViewModel extends ChangeNotifier {
TodoListViewModel(this._repository);
final TodoRepository _repository;
List<TodoItem> _items = [];
bool _isLoading = false;
List<TodoItem> get items => _items;
bool get isLoading => _isLoading;
Future<void> refresh() async {
_isLoading = true;
notifyListeners();
_items = await _repository.loadAll();
_isLoading = false;
notifyListeners();
}
}
这样做的好处是,TodoItem 可以安全地跨页面传递,不会被 UI 层的临时状态污染;ViewModel 则负责把数据转换成页面需要的样子。这个分离在数据量小的时候看不出优势,但一旦引入筛选、排序、分组,优势立刻就出来了。
4. OpenHarmony 设备适配:从编译通过到真正跑顺
如果说前三章是纯 Flutter 工程层面的架构演进,那这一章就是跨平台适配的真实战场。Flutter 在 OpenHarmony 上的适配状态,比很多人想象的要成熟,但也没有成熟到“一把梭”的程度。
4.1 Flutter for OpenHarmony 的工程分支与版本锁定
Flutter for OpenHarmony 并不是 Flutter 官方主线直接支持的,而是由 OpenHarmony 社区基于 Flutter 分支维护的一套适配方案。使用时要特别注意:你不能直接用 flutter.dev 官方下载的 SDK 去编 OpenHarmony 目标,必须切换到社区维护的 OpenHarmony 分支,并配套对应的 engine 版本。
这个环节最大的坑是版本错位。社区分支通常锁定某个 Flutter 版本,你要是按照 flutter upgrade 的习惯把 SDK 升到最新,很可能连 ohos 这个 target 都识别不到。所以我的建议是:
- 严格按照社区分支的 README 指引安装指定版本。
- 锁死
pubspec.yaml里的依赖版本,不要随意flutter pub upgrade。 - 用
flutter doctor检查 OHOS 环境是否就绪,重点看 DevEco Studio 的 SDK 路径是否被正确识别。
热词里提到的“flutter各个版本不对导致依赖包下不下来”,十有八九是版本漂移导致 pub 解析失败。我自己的做法是维护一个 .metadata 文件记录 flutter 版本和 engine 版本,换机器时直接照单恢复,能省掉很多无意义的排查时间。
4.2 rk3568 设备树选择问题到底该怎么面对
适配过程中,很多朋友在社区里问“OpenHarmony 的 rk3568 有许多设备树到底咋选”。这是个系统级问题,但它实实在在地影响了 Flutter 应用开发者的日常调试。
设备树(Device Tree,dts/dtb)是 Linux 内核用来描述硬件拓扑的一种机制。同一颗 rk3568 芯片会被用在各种开发板上,板子上的 DDR 型号、HDMI 接口、MIPI 屏幕、触摸 IC、WiFi 模组都不同,所以需要不同的设备树来告诉内核“这块板子上有哪些设备、怎么初始化”。
选择设备树的正确思路,不是按芯片型号去选,而是按板卡型号去选。拿到开发板后,第一件事是找板子丝印上的型号和厂家提供的固件包。固件包里一般会有现成的 dtb 文件,优先用厂家配套的,而不是自己去编译一个。如果你自己移植系统,需要确认几件事:
- HDMI 或 MIPI 屏幕能否点亮,看
dmesg | grep panel有没有正确探测到显示面板。 - 触摸是否生效,看 input 节点是否注册成功。
- GPU 是否启用,OpenHarmony 的图形栈依赖 GPU 节点,如果 dtb 里 GPU 的 status 是 disabled,Flutter 的渲染帧率会非常难看。
实际踩坑经验:有一块板子 Flutter 应用能跑起来,但点击没有任何反应,排查到最后发现是触摸屏的 I2C 引脚在 dtb 里没有正确配置。这种情况从应用层是永远查不出结果的,一定要回到内核日志去定位。
4.3 能力插件补齐:图库、支付等原生能力对接
纯 Dart 的 UI 层在 OpenHarmony 上迁移相对顺利,但凡是依赖原生能力的插件,比如图库选择、支付拉起、微信登录,都得重新接一遍。社区里也常见“flutter兼容鸿蒙拉起iap支付”“flutter如何调用鸿蒙的图库”这类求助。
以图库选择为例,Flutter 侧的 image_picker 在 OpenHarmony 上默认不可用,需要在 OpenHarmony 工程侧自行实现。实现路径并不复杂,核心是通过 MethodChannel 把 Flutter 的调用转发到 OpenHarmony 的系统选择器上。
Dart 侧注册通道:
dart复制class OhosImagePicker {
static const MethodChannel _channel = MethodChannel('com.example.todo/picker');
static Future<String?> pickImage() async {
final String? path =
await _channel.invokeMethod<String>('pickImage');
return path;
}
}
OpenHarmony 工程侧收到 pickImage 方法调用后,通过 photoAccessHelper 提供的 PhotoViewPicker 拉起系统相册,选中后把图片的 uri 或文件路径返回给 Flutter 侧。注意返回的 uri 可能带有 file://media/ 前缀,Dart 侧需要做一次路径转换。
对于支付这类能力,逻辑同理,但要先确认支付通道归属于哪个生态。不同渠道的支付 SDK 接入方式、回调解密逻辑完全不一样。我的建议是先梳理出支付流程的状态机:发起支付、等待回调、校验结果、返回 UI,然后在原生侧实现整个流程,只把最终支付结果通过 Channel 返回给 Flutter。不要指望 Flutter 侧直接控制支付回调,那会把业务逻辑和平台逻辑搅在一起。
4.4 插件不兼容时的降级方案
不是所有插件都能幸运地在 OpenHarmony 上找到对应实现。遇到不兼容的插件,我的降级排查顺序是:
- 先查插件是否纯 Dart 实现,如果依赖原生代码,大概率需要适配。
- 去 OpenHarmony 社区搜有没有人做过适配,有就用社区方案。
- 如果没人适配,优先寻找功能对标的替代插件。
- 最后一个办法是自己写 Platform Channel 适配层,封装原插件接口。
这个过程中最忌惮的事情是“硬怼”。曾经为了把一个依赖原生 SQLite 的插件编译通过,我花了一个晚上去调 CMake 和 NDK 交叉编译参数,最后证明这条路完全没必要。换用 drift 后,靠它上层用 Dart 方言处理 SQL,底层由 sqlite3 动态库支撑,半小时就跑通了。
5. 产品化收尾:性能、包体与安全加固
产品化不只是功能能跑,还要跑得流畅、包体能接受、代码不至于被轻易扒光。这几个维度在 TodoList 产品化中同样不可跳过。
5.1 build 与依赖版本治理
社区里大量“flutter 依赖包下不下来”“flutter 版本和 WPF 对比性能”的讨论,其实都指向同一个问题:构建环境不够干净。我的治理经验是这样:
- 用 pubspec.lock 锁死依赖树的精确版本。
- 在 CI/CD 环境里固定 Flutter SDK 版本,不要用
latest。 - OpenHarmony 构建时尽量使用国内可访问的镜像加速 pub 包下载,但要注意镜像同步的延迟,镜像状态不一致会导致依赖解析失败。
Release 构建时,要对 build.gradle 或 OpenHarmony 对应的构建脚本做裁剪,只保留目标 ABI。比如主要跑在 rk3568 上,就只编 arm64-v8a,可以显著减少包体大小。Flutter 的打包产物中,libflutter.so 和 libapp.so 占大头,ABI 裁剪是最直接的瘦身手段。
5.2 列表性能与内存细节
TodoList 场景虽小,但数据量一旦上千条,列表性能问题就暴露了。我的优化主要有三个:
- 使用
ListView.builder而不是ListView(children: ...),避免一次性构建所有子项。 - 每个列表项的
build方法尽量只依赖自身的数据,不要监听整个列表的变化。否则一条数据更新,全部列表项都会重建。 - 图片、图标资源用常量引用,避免在 build 方法里创建新对象。
这里有个容易被忽略的点:Tab 页面使用 IndexedStack 时,不可见的 Tab 页面依然会占用内存。如果你有大量图片,建议对不可见页面做“暂停加载”处理,而不是一味依赖 IndexedStack 的缓存。
5.3 反编译风险与加固手段
热词里有“反编译flutter”这个词,说明大家关心代码安全。Flutter 的 Dart 代码在 Release 模式下默认是 AOT 编译成机器码的,直接反编译回 Dart 源代码非常困难,但并不是无迹可寻。assets 目录里的资源、配置文件、本地数据库文件是很容易被提取的。如果你的应用里存在敏感信息,比如服务器地址、API Key,切记不要硬编码在 Dart 代码里,也不要放在 assets 中明文存储。
我做了两个加固动作:
- 敏感配置放在原生环境变量或加密存储中,通过 Channel 读取。
- 对 Dart 侧的敏感字符串做混淆或拆分散列,避免静态分析直接暴露。
同时要注意,OpenHarmony 真机上调试时默认可能开 debug 模式,发布产品前务必用 release 模式打包,并确认没有把 debug 用的日志输出带入生产包。
6. 重做一次我会怎么避坑:验证顺序与关键决策
最后聊一个更实际的话题:如果让我重新把这套 TodoList 产品化演进再做一遍,哪些顺序和决策会调整。
6.1 最小化迁移路径
第一次踩坑让我明白,不要试图在一个大版本里同时完成架构改造和平台迁移。最小化迁移路径应该是:
- 在原平台完成三层 Tab 架构改造,UI 行为保持原样。
- 引入 Repository 层,把数据流统一到仓库接口,但底层仍然用内存实现。
- 替换数据源为本地数据库,跑通数据持久化。
- 再切到 OpenHarmony 目标平台,验证编译、运行、设备适配。
每一步都有明确的验证标准,出问题能快速定位到是架构问题还是平台问题。
6.2 需要尽早确认的数据迁移策略
数据迁移是我最早忽略的问题。一开始直接在本地数据库里建表,后来加字段、改类型,旧版本用户升级后直接崩溃。如果你打算做得更稳,从第一版数据库设计时就要引入 schema 版本管理。drift 内置了 schema migration 机制,至少有两条铁律:
- 不要在生产库上执行破坏性的
DROP TABLE或ALTER TABLE,要用 migration 脚本做增量变更。 - 在修改表格结构之前,先返回当前 schema 版本并从备份恢复测试。
6.3 最后的个人经验
踩完这一圈坑,我最大的体会是:架构设计不是为了好看,而是为了让你在引入新平台、新能力时,不被旧的耦合代码拖死。三层 Tab 架构让页面职责清晰,Repository 让数据源可替换,版本管理让构建环境可复现。这三件事单独拆开都不复杂,但组合在一起,才让 TodoList 这个“玩具”真正具备了产品化的骨架。
如果你也在做类似的项目,我建议你先从数据层解耦开始动手,因为数据层的改动影响面最大,越早稳定,后续优化越轻松。至于三层 Tab,可以在数据结构稳定之后再逐步调整,不用急于一步到位。
