先说结论:这个标题看着像是一个简单的 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(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('全部任务'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '进行中'),
Tab(text: '已完成'),
Tab(text: '已过期'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
TodoListView(filter: TodoFilter.active),
TodoListView(filter: TodoFilter.completed),
TodoListView(filter: TodoFilter.overdue),
],
),
);
}
}
这里的精巧之处在于:TodoListView 本身不持有任何数据,它只是声明“我需要哪一类任务”。具体数据怎么获取、怎么过滤,由 ViewModel 根据传入的 TodoFilter 统一处理。
dart复制// lib/widgets/todo_list_view.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/todo_item.dart';
import '../state/todo_view_model.dart';
enum TodoFilter { active, completed, overdue }
class TodoListView extends StatelessWidget {
final TodoFilter filter;
const TodoListView({super.key, required this.filter});
@override
Widget build(BuildContext context) {
final viewModel = context.watch<TodoViewModel>();
List<TodoItem> items = viewModel.getFilteredTodos(filter);
if (items.isEmpty) {
return const Center(child: Text('这个分类下面还没有任务'));
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final todo = items[index];
return ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => viewModel.toggle(todo.id),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
subtitle: Text('截止:${todo.dueDate}'),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
// 跳转编辑页,实际项目中这里是 Navigator.push
} else if (value == 'delete') {
viewModel.delete(todo.id);
}
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'edit', child: Text('编辑')),
PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
);
},
);
}
}
这个设计有没有问题?有。它不够优雅的地方在于,三个 Tab 页面共享同一个 ListView Widget,但切换时没有动画。后续产品优化时,可以考虑用 AnimatedSwitcher 或 PageTransitionSwitcher 包裹一下。但现阶段,保持切换效率优先。
2.2 数据模型解耦的完整实现
先定义纯数据模型:
dart复制// lib/models/todo_item.dart
enum TodoPriority { low, medium, high }
class TodoItem {
final String id;
final String title;
final String description;
final DateTime createdAt;
final DateTime? dueDate;
final bool isCompleted;
final TodoPriority priority;
const TodoItem({
required this.id,
required this.title,
required this.description,
required this.createdAt,
this.dueDate,
this.isCompleted = false,
this.priority = TodoPriority.medium,
});
TodoItem copyWith({
String? title,
String? description,
DateTime? dueDate,
bool? isCompleted,
TodoPriority? priority,
}) {
return TodoItem(
id: id,
title: title ?? this.title,
description: description ?? this.description,
createdAt: createdAt,
dueDate: dueDate ?? this.dueDate,
isCompleted: isCompleted ?? this.isCompleted,
priority: priority ?? this.priority,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'createdAt': createdAt.toIso8601String(),
'dueDate': dueDate?.toIso8601String(),
'isCompleted': isCompleted,
'priority': priority.name,
};
}
factory TodoItem.fromJson(Map<String, dynamic> json) {
return TodoItem(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
dueDate: json['dueDate'] != null
? DateTime.parse(json['dueDate'] as String)
: null,
isCompleted: json['isCompleted'] as bool,
priority: TodoPriority.values.firstWhere(
(e) => e.name == json['priority'],
orElse: () => TodoPriority.medium,
),
);
}
}
这个模型的设计有几个关键点:
- 不可变性(immutable):所有字段都是 final,任何修改都通过
copyWith返回新实例。这是解耦的基础,它确保数据在跨层传递时不会被某个环节意外篡改。 - 显式序列化:
toJson/fromJson是显示器,不是 JSON 序列化库自动生成的。为什么不用json_serializable?因为在这个项目里,手动写能让你明确感知“这个字段在持久化时是什么格式”,尤其DateTime和enum,自动生成的代码不一定符合你的存储约定。 - 没有任何 UI 依赖:模型层不 import Flutter 的 material,这保证了它将来可以无缝迁移到其他 Dart 项目里复用,甚至可以写成纯 Dart package 做单元测试。
然后是数据源层。这个项目的存储方案我选的是 shared_preferences,因为它足够轻量。但如果待办数量上千,建议改成 sqflite 或 Hive,架构相同,只换数据源实现即可:
dart复制// lib/data/todo_local_data_source.dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/todo_item.dart';
class TodoLocalDataSource {
static const _storageKey = 'todo_items';
Future<List<TodoItem>> loadTodos() async {
final prefs = await SharedPreferences.getInstance();
final rawList = prefs.getString(_storageKey);
if (rawList == null) return [];
final List<dynamic> decoded = jsonDecode(rawList) as List<dynamic>;
return decoded
.map((json) => TodoItem.fromJson(json as Map<String, dynamic>))
.toList();
}
Future<void> saveTodos(List<TodoItem> todos) async {
final prefs = await SharedPreferences.getInstance();
final rawList = jsonEncode(todos.map((todo) => todo.toJson()).toList());
await prefs.setString(_storageKey, rawList);
}
}
Repository 层负责业务规则:
dart复制// lib/repository/todo_repository.dart
import '../data/todo_local_data_source.dart';
import '../models/todo_item.dart';
class TodoRepository {
final TodoLocalDataSource _localDataSource = TodoLocalDataSource();
Future<List<TodoItem>> fetchAll() => _localDataSource.loadTodos();
Future<void> addTodo(TodoItem item) async {
final todos = await _localDataSource.loadTodos();
todos.add(item);
await _localDataSource.saveTodos(todos);
}
Future<void> updateTodo(TodoItem updatedItem) async {
final todos = await _localDataSource.loadTodos();
final index = todos.indexWhere((todo) => todo.id == updatedItem.id);
if (index != -1) {
todos[index] = updatedItem;
await _localDataSource.saveTodos(todos);
}
}
Future<void> removeTodo(String id) async {
final todos = await _localDataSource.loadTodos();
todos.removeWhere((todo) => todo.id == id);
await _localDataSource.saveTodos(todos);
}
}
状态管理层,也就是衔接 UI 和仓库层的“胶水层”。这里我使用 Provider + ChangeNotifier:
dart复制// lib/state/todo_view_model.dart
import 'package:flutter/foundation.dart';
import '../models/todo_item.dart';
import '../repository/todo_repository.dart';
import '../widgets/todo_list_view.dart';
class TodoViewModel extends ChangeNotifier {
final TodoRepository _repository = TodoRepository();
List<TodoItem> _allTodos = [];
bool _isLoading = false;
String? _errorMessage;
List<TodoItem> get allTodos => _allTodos;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
Future<void> loadAll() async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_allTodos = await _repository.fetchAll();
} catch (e) {
_errorMessage = '加载失败:$e';
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> add(TodoItem item) async {
try {
await _repository.addTodo(item);
await loadAll();
} catch (e) {
_errorMessage = '添加失败:$e';
notifyListeners();
}
}
Future<void> toggle(String id) async {
final index = _allTodos.indexWhere((todo) => todo.id == id);
if (index == -1) return;
final updated = _allTodos[index].copyWith(
isCompleted: !_allTodos[index].isCompleted,
);
try {
await _repository.updateTodo(updated);
_allTodos[index] = updated;
notifyListeners();
} catch (e) {
_errorMessage = '更新失败:$e';
notifyListeners();
}
}
Future<void> delete(String id) async {
try {
await _repository.removeTodo(id);
_allTodos.removeWhere((todo) => todo.id == id);
notifyListeners();
} catch (e) {
_errorMessage = '删除失败:$e';
notifyListeners();
}
}
List<TodoItem> getFilteredTodos(TodoFilter filter) {
switch (filter) {
case TodoFilter.active:
return _allTodos.where((todo) => !todo.isCompleted).toList();
case TodoFilter.completed:
return _allTodos.where((todo) => todo.isCompleted).toList();
case TodoFilter.overdue:
return _allTodos
.where((todo) =>
!todo.isCompleted &&
todo.dueDate != null &&
todo.dueDate!.isBefore(DateTime.now()))
.toList();
}
}
}
这个 ViewModel 最关键的设计是:UI 层的 TodoListView 不知道自己处于哪个 Tab,每次 build 时通过 getFilteredTodos 获取对应列表。 这保证了数据变化的传递是单向的:UI 发出动作 → ViewModel 修改数据 → notifyListeners → UI 重新 build。
2.3 为什么不用全局 State、不用 setState 满天飞
我在刚开始做这个项目时,习惯性地把 TodoList 存在一个全局单例里,然后在每个 Widget 的 setState 里手动刷新。当时觉得“就几条数据,有什么大不了的”。后来增加功能时,三次差点把自己坑进去:
- 增加“今日待办”页面时,它要展示“截止日期是今天的任务”,得监听同一个数据源。
- 增加“已完成”统计卡片时,它要实时统计完成率。
- 增加“恢复上次打开状态”时,需要启动时异步加载数据,而页面早就在 build 了。
如果继续用全局变量加 setState,每个页面都要自己写一套“初始化时读数据、修改时写数据、销毁时保存数据”的逻辑,而且多个页面之间没法共享更新。用 Provider + ViewModel 之后,这种跨页面的状态同步变成了订阅关系:任意页面修改了数据,所有 context.watch 到 ViewModel 的页面自动刷新。这就是数据驱动 UI,而不是 UI 驱动数据。
3. 实操过程与核心环节实现
3.1 开发环境的基础问题与解决
在讲具体实现之前,必须先聊聊环境问题。因为在 OpenHarmony 设备上跑 Flutter,最大的门槛往往不是代码,而是开发环境。
我使用的设备是 rk3568 开发板,系统版本 OpenHarmony 4.0。这个板子在 OpenHarmony 社区讨论度很高,但一个绕不开的坑是:rk3568 有很多套设备树,不同开发板对应不同 dts,网上很多教程里给的路径都互不相同。
我的建议是:不要盲从网上文章,先去你开发板对应的官方内核仓库找 arch/arm64/boot/dts/rockchip/ 目录,对照你板子的型号(比如 RK3568 EVB、ROC-RK3568-PC、TB-RK3568X)选择同名的 dts 编译。如果你买的是整机开发套件,厂家一般会提供编译好的 boot.img 和 dtb,直接用比啥都稳。
Flutter SDK 方面,OpenHarmony 不能直接用 Google 官方 Flutter SDK,需要用 OpenHarmony 适配分支。目前社区维护的版本主要同步自 Flutter 3.7 和 3.13 左右(具体看发行时间),装完后在命令行输入 flutter doctor,如果显示 Flutter • Channel unknown • Framework unknown,而不是报错,基本说明装对了。
注意:切换 Flutter SDK 后,老项目经常出现依赖解析冲突。建议新建项目后,把
pubspec.yaml里所有依赖先注释掉,逐个放开,避免一次性引入一堆不兼容版本。
3.2 项目从零初始化的实操记录
项目初始化的命令和标准 Flutter 项目基本一致:
bash复制flutter create todo_openharmony
cd todo_openharmony
创建后,pubspec.yaml 添加依赖:
yaml复制dependencies:
flutter:
sdk: flutter
provider: ^6.0.5
shared_preferences: ^2.2.0
intl: ^0.18.0
这里需要解释一下为什么用 provider 而不是 riverpod 或 bloc。因为 OpenHarmony 适配版本的 Flutter 内置第三方包生态不完全兼容,provider 作为一个纯 Dart 状态管理库,几乎是零依赖,出问题的概率最小。不是 riverpod 不好,而是在这个场景下,稳定压倒一切。
目录结构我是这样组织的:
text复制lib/
├── main.dart
├── root_page.dart
├── models/
│ └── todo_item.dart
├── data/
│ └── todo_local_data_source.dart
├── repository/
│ └── todo_repository.dart
├── state/
│ └── todo_view_model.dart
├── pages/
│ ├── today_page.dart
│ ├── all_tasks_page.dart
│ └── profile_page.dart
└── widgets/
├── todo_list_view.dart
└── todo_edit_dialog.dart
这个结构不是越复杂越好,而是每个目录都有明确的边界。后面加功能时,新代码该放哪、不该动哪,一目了然。
3.3 核心页面与数据流的打通实现
main.dart 的注入逻辑:
dart复制// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'root_page.dart';
import 'state/todo_view_model.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const TodoApp());
}
class TodoApp extends StatelessWidget {
const TodoApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => TodoViewModel(),
child: MaterialApp(
title: 'OpenHarmony TodoList',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.teal,
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.teal,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const RootPage(),
),
);
}
}
这里有一个容易忽略的细节:WidgetsFlutterBinding.ensureInitialized() 在 OpenHarmony 上有时会有奇怪的时序问题。我遇到过一种情况,不写这行,异步加载数据时偶尔崩溃;写上之后,一切正常。原因大概率是原生通道初始化需要先绑定 Widgets 层。
新增待办的编辑对话框是纯 UI 组件,它收到用户输入后调用 viewModel.add(...),具体数据怎么存储,UI 完全不管:
dart复制// lib/widgets/todo_edit_dialog.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/todo_item.dart';
import '../state/todo_view_model.dart';
Future<void> showTodoEditDialog(BuildContext context, {TodoItem? existing}) async {
final titleController = TextEditingController(text: existing?.title ?? '');
final viewModel = context.read<TodoViewModel>();
await showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(existing == null ? '新增待办' : '编辑待办'),
content: TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: '任务标题',
hintText: '请输入任务内容',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
final title = titleController.text.trim();
if (title.isEmpty) return;
if (existing == null) {
viewModel.add(
TodoItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
description: '',
createdAt: DateTime.now(),
),
);
} else {
viewModel.toggle(existing.id);
}
Navigator.pop(context);
},
child: const Text('保存'),
),
],
);
},
);
}
“今日待办”页面则依赖同一 ViewModel 的过滤逻辑,实现跨页面的数据共享:
dart复制// lib/pages/today_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/todo_view_model.dart';
import '../widgets/todo_list_view.dart';
import '../widgets/todo_edit_dialog.dart';
class TodayPage extends StatelessWidget {
const TodayPage({super.key});
@override
Widget build(BuildContext context) {
final viewModel = context.watch<TodoViewModel>();
final todayTodos = viewModel.allTodos.where((todo) {
final now = DateTime.now();
return todo.createdAt.year == now.year &&
todo.createdAt.month == now.month &&
todo.createdAt.day == now.day;
}).toList();
return Scaffold(
appBar: AppBar(
title: const Text('今日待办'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => showTodoEditDialog(context),
tooltip: '新增任务',
),
],
),
body: todayTodos.isEmpty
? const Center(child: Text('今天还没有任务,点击右上角添加'))
: ListView.builder(
itemCount: todayTodos.length,
itemBuilder: (context, index) {
final todo = todayTodos[index];
return ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => viewModel.toggle(todo.id),
),
title: Text(todo.title),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => viewModel.delete(todo.id),
),
);
},
),
);
}
}
到这里,整个项目的骨架已经完整。接下来是最有价值的部分:实际运行中遇到的问题到底怎么排查。
4. 常见问题与排查技巧实录
4.1 热重载后界面不更新,按钮点了没反应
这个问题在 OpenHarmony 真机上非常典型。现象是:代码改完,flutter run 窗口按 R 热重载,日志显示重载成功,但设备界面没有变化;有时连按钮点击事件都不触发。
排查之后发现,根源是 OpenHarmony 的 Flutter 适配版对 DartVM 热重载的支持不完整,尤其是修改了 Model 类或 enum 定义之后,热重载往往失效。解决办法是:修改纯 Dart 数据模型层时,不要依赖热重载,直接热重启(大写的 R)。 如果是改 UI 布局,热重载通常没问题。
经验:给 OpenHarmony 开发 Flutter,建议把“热重载失效”当作预期行为,而不是 Bug。看到界面不对,先按
R热重启,别浪费时间反复按r。
4.2 Flutter 插件在 OpenHarmony 上缺失实现
这个坑出现的频率超高。shared_preferences 是官方插件,按理说应该没问题,但 OpenHarmony 适配版的插件注册机制和 Android 不同,有时会出现运行时提示 MissingPluginException。
我当时遇到的情况是 shared_preferences 在真机上直接抛异常。排查步骤:
- 先确认
pubspec.yaml里有没有shared_preferences的 OpenHarmony 平台实现包。部分插件需要额外引入shared_preferences_ohos之类的适配包。 - 清理构建缓存:执行
flutter clean,删除build目录和.dart_tool目录,重新拉依赖。 - 检查
ohos模块下的entry/src/main/配置,有些插件需要在module.json5里注册原生代码。
如果某个插件始终无法适配,优先考虑替换方案。比如 path_provider 在旧版本 OpenHarmony 上缺失实现时,我直接换成了读写应用的私有目录,用 dart:io 的 Directory.systemTemp 顶一阵。原则是:别跟插件死磕,换个能跑的方案先把功能落地。
4.3 一启动就白屏,日志没有任何报错
白屏问题排查起来最费神,因为 Flutter 层根本没有日志。我遇到过一次,最终定位是 OpenHarmony 上 Flutter 引擎和原生页面的生命周期冲突:应用启动时立即执行了异步数据加载,但原生窗口还没完全就绪,导致渲染管道被挂起。
解决办法是给 MaterialApp 加一个 builder:
dart复制builder: (context, child) {
return FutureBuilder(
future: Future<void>.delayed(const Duration(milliseconds: 300)),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox.shrink();
}
return child!;
},
);
}
这个 300ms 的延迟是为了错开原生窗口初始化与 Flutter 首帧渲染。实测下来,老设备上偶发白屏的几率大幅下降。当然这不算根治,但它是一个可接受的 trade-off:牺牲 300ms 启动时间,换来稳定的首帧显示。
4.4 rk3568 设备如何选对设备树
这个问题在标题热词里出现了,而且确实是个高频问题。再展开一次:
rk3568 是一颗芯片,但不同开发板的外设接口、引脚复用、内存规格不一样,所以 OpenHarmony 内核里为它准备了多套 dts。选错的表现可能是:串口无输出、HDMI 无信号、Wi-Fi 蓝牙不可用、触摸屏不响应。
我的实操建议:
- 查出你的开发板具体型号:看包装盒、PCB 丝印、厂家文档,比如“ROC-RK3568-PC”“RK3568 EVB1”“TB-RK3568X”等。
- 在内核源码里搜索型号名:
bash复制find kernel/linux/arch/arm64/boot/dts/rockchip/ -name "*rk3568*"
- 确认后,在编译内核时指定:
bash复制make ARCH=arm64 rockchip_defconfig
make ARCH=arm64 dtbs
如果你的开发套件厂商提供了编译好的 boot.img,就用厂商的。自己编译内核要处理交叉编译工具链、LTO、安全启动校验等一堆问题,对 Flutter 开发来说性价比不高。
4.5 Flutter 安装与配置阶段的高频报错
搜索热词里还有不少 Flutter 安装配置的问题,比如:
- CMake error at CMakeLists.txt:3 (project): generator Visual Studio 17:这是 Windows 上缺少 VS 的 C++ 桌面组件,或 CMake 生成器选错。解决方法是打开 Visual Studio Installer,勾选“使用 C++ 的桌面开发”。
PATH 需要新终端生效:环境变量改了之后,必须关掉旧终端重开,很多新手卡在这个提示上。- 依赖包下载不下来:不同 Flutter 版本对应的 package 版本差异很大,锁定版本号后重新
flutter pub get。 flutter run提示找不到设备:先用flutter devices确认设备列表,OpenHarmony 设备需要开启调试模式并安装 OpenHarmony SDK platform-tools。
这些其实和学习 Flutter 本身无关,但是任何写 Flutter 的人都会碰到,提前知道能省不少时间。
5. 未讲完的产品化细节与进一步扩展建议
5.1 从 Demo 到产品还需要补什么
架构解耦完成、功能能跑之后,我发现离“产品”还差不少东西。虽然不是这个项目的核心,但如果你想把它真正常态使用,下面几点值得补:
- 数据备份与迁移:
shared_preferences存储不适合长期使用,导出 JSON 文件到用户目录是个轻量方案。 - 通知提醒:OpenHarmony 上推送通知需要调用系统提醒能力,Flutter 侧要做 MethodChannel 封装,工作量不小。
- 无障碍支持:Checkbox 和 ListTile 的语义标签、屏幕阅读器的 focus 顺序,在鸿蒙平台上需要额外验证。
- 性能监控:列表超过 200 条后,
ListView.builder的回收机制是否依然流畅?快速滑动时是否有白块?这些需要压测。
5.2 事件上报与统计的思考
我做产品化时遇到过一个问题:用户到底怎么使用 TodoList?哪些 Tab 点击得最多、哪些任务被删除得最多、新增任务集中在什么时段?如果数据模型和 UI 耦合在一起,做统计就得改动每一个事件触发的页面;但解耦后,我只需要在 TodoViewModel 的 add、toggle、delete 方法里加一个上报点就行。
这就是标题里“数据模型解耦”在真实产品需求下的价值——不是为了解耦而解耦,而是为了后续扩展时尽量少动已有代码。
5.3 OpenHarmony 原生能力的调用体验
Flutter 调用 OpenHarmony 原生能力,本质上走的是 Platform Channel。但 OpenHarmony 的 API 风格和 Android 差别很大,例如拉起 IAP 支付、访问图库,都需要写 OpenHarmony 侧的 ArkTS 代码。
我在测试中遇到比较典型的例子是“分享待办到图库”:先用 Canvas 把 TodoListView 渲染成图片,然后通过 MethodChannel 调用 OpenHarmony 的媒体库接口保存。Flutter 侧的代码和 Android 一模一样,但 OpenHarmony 侧的实现要使用 @ohos.multimedia.mediaLibrary,不能直接用 Android 的 MediaStore。
这意味着:如果你只是把 Flutter 项目往 OpenHarmony 上跑,纯 UI 部分几乎没有改动;但一旦涉及系统能力,就绕不开 ArkTS 原生开发。 这是做产品化时一定要提前排期的技术债。
6. 一些真实的体会和避坑心得
写到最后,分享几个我在这个项目里最深的体会。第一条是:在 OpenHarmony 上做 Flutter 开发,心态要调整成“在陌生的环境里找稳定”。不要指望每个 API 行为都和 Android 完全一致,也不要把时间耗在纠结某个动画为什么稍微卡顿上。先把核心架构做好,把数据流理清,后续遇到兼容性问题都改得动;反过来,如果一开始就图省事,全部状态堆在页面里,后期任何适配问题都会变成一座山。
第二条是:数据模型解耦的收益是滞后体现的。你写第一版功能的时候,用全局变量加 setState 确实快,但那是一时的快。等你要加第二个页面、第三个筛选条件,或者突然需要持久化,那些“省掉”的抽象会加倍还给你的调试时间。这个项目里我第一版大概只花了两个小时写功能,第二版重构花了一整天,但从此以后加功能就顺畅多了。
第三条建议很具体:尽量把处理过的问题记录成速查表,比如设备树怎么选、插件缺失怎么办、白屏怎么绕。我在开发过程中整理了一个表格,贴在这里,希望对你有用:
| 问题 | 症状 | 解决思路 |
|---|---|---|
| 热重载失灵 | 界面不更新 | 改模型层后直接热重启,不要热重载 |
| MissingPluginException | 调用系统能力时崩溃 | 检查是否有 ohos 适配包,或换替代插件 |
| 启动白屏 | 无日志、无画面 | 延迟首帧渲染,错开窗口初始化 |
| rk3568 设备树选错 | 串口/屏幕/WiFi 异常 | 确认板卡型号,使用配套 dts 或厂家 boot.img |
| 依赖版本不一致 | pub get 失败或运行报错 | 锁定版本号,逐个放开依赖 |
| 文本输入法弹出遮挡 | 输入框被遮住 | Scaffold 设置 resizeToAvoidBottomInset: true |
这些心得不一定在每个设备上都完全适用,但思路应该是通用的:先定位问题发生的层次,是 Flutter 层、原生层还是系统层,然后逐层排查,不要一上来就重装环境。
最后再说一个小技巧:Todo 任务的排序我用到了 priority 字段,但 UI 上只展示了一个优先级图标,没有做拖拽排序。如果后续要做拖拽,建议在数据层加一个 sortOrder 字段,而不是靠列表的位置推断顺序。这个看起来是小事,等数据来源多了(比如从云端同步、从其他设备导入)就明白了——顺序必须显式存储,不能隐式依赖数组下标。这种细节,就是“产品化”和“demo”的区别。
