☰
Flutter实战:从零搭建一个真正可用的待办事项App
2026/10/17 18:21:43 网站建设 项目流程

Flutter 项目实战 1:从零搭一个真正能用的待办事项 App

Flutter 这套跨端框架,最近两年在开发者圈子里讨论度一直不低。我自己也是在几个中大型项目里踩过不少坑之后,才敢说对它算是有了一点手感。如果你正准备上手 Flutter,又不想只停留在组件 Demo 的阶段,这个“项目实战 1”的系列文章就是为你准备的。第一篇我会抛开抽象的框架介绍,直接带你从零搭一个“能装进手机、能日常使用”的待办事项 App,覆盖页面搭建、交互状态、本地持久化、生命周期处理这些绕不开的核心基本功。项目体量不大,但所有模块都是真实业务里会遇到的形态,适合刚学完 Dart 语法、想通过完整项目把知识串起来的人,也适合写过几个页面但没系统梳理过数据流向的 Flutter 开发者。

1. 项目整体设计与思路拆解

1.1 为什么第一个项目选“待办事项”

几乎每个平台语言的实战入门都绕不开 Todo App,这句话听起来像套话,但实际带着新同学做项目时,我依然强烈推荐它。原因很简单:它麻雀虽小,五脏俱全。一个合格的待办事项应用至少需要“输入新任务”“展示任务列表”“标记完成状态”“删除任务”“数据重启不丢失”这五个基础能力,而这五个能力恰好对应着 Flutter 开发里最重要的五块拼图——Widget 布局、状态管理、列表渲染、交互事件、本地存储。

换一个更直白的说法:你写一个只会展示静态页面的 Demo,学到的是 Flutter 的“表面”;而待办事项这个看似简单的项目,逼着你去处理“数据从哪里来、到哪里去、如何变化、如何保存”,这才是 Flutter 开发真正的“主干”。而且它的复杂度边界很清晰——功能太少学不到东西,功能太多新手容易迷失在架构里。我见过很多同学一上来就仿写大型商城应用,最后连路由嵌套和状态管理都分不清楚,反而挫败感极强。所以这次我们就把范围锁死在一个单页面内,把深度做足。

1.2 技术选型背后的权衡

先说状态管理,这是每个 Flutter 新人都绕不开的决策点。方案很多:原生 setState、Provider、Riverpod、Bloc、GetX……各有拥趸,也各有争议。在这个项目里我选择使用最基础的StatefulWidget + setState,理由很直接:setState是 Flutter 状态管理的根基,所有高级方案最终都是对它的封装和增强。你把这个基础打牢了,后面学 Provider 时能一眼看穿它的本质,不会被各种概念唬住。

再说持久化方案,可选的有shared_preferences、sqflite、hive这样几类。shared_preferences本质是一个键值对存储库,类似 Web 端的 localStorage,适合保存“小体积、非结构化”的数据;sqflite是 SQLite 的 Flutter 封装,适合结构化查询;hive是高性能 NoSQL 方案。考虑我们的任务列表数据量很小、结构固定、按序展示,shared_preferences是最务实的选择——代码量少、理解成本低、无原生依赖带来的额外编译负担。有人会担心它性能不够,实际上对于几百条文本任务来说根本感觉不到差别,等数据量真的大了再迁移到 SQLite 也不迟。

1.3 功能边界与页面结构

正式开始前,先把功能边界用一句话划清楚:本次只做“单列表的增、删、改、存”,不做分类、不做提醒、不做多列表切换、不引入后端。这不是偷懒,而是刻意控制复杂度。一个项目如果同时引入后端接口、登录鉴权、消息推送,新手会分不清“这是 Flutter 的问题”还是“这是业务逻辑的问题”,排错成本会成倍上升。

页面结构上,我采用单页面布局:顶部是标题栏和任务输入区,下方是任务列表区。整个 App 只有一个页面,但内部包含两个核心 Widget:任务输入框和任务列表。这样做的好处是,后续无论你想加底部导航、还是加详情页,都能在这个基础上自然延伸,而不会推翻重来。

2. 环境准备与工程初始化

2.1 Flutter 环境搭建的关键细节

如果你已经装好了 Flutter 环境,这一节可以快速扫读;如果你还在环境配置阶段,这几个细节值得留意。Flutter 的安装本身不复杂,无非是下载 SDK 压缩包、解压、配置 PATH、运行flutter doctor检查依赖。但实际给团队新同学调环境时,最多的问题是版本混乱——flutter命令可以使用,但运行时提示“Dart SDK 版本不兼容”,十有八九是 Flutter 和 Dart 不是配套版本。

我的建议是:不要单独去下载 Dart SDK,Flutter SDK 内部捆绑了匹配版本的 Dart,你只要保证 Flutter 这一条链路的版本统一即可。另一个实际经验是,国内开发者务必在安装后第一时间配置镜像源(使用官方公开的可信镜像即可),否则执行flutter pub get拉取依赖时会卡在连接环节。配置位置在环境变量里,设置PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL两个变量,之后再重启终端。

环境是否就绪,用一条命令自检:

flutter doctor -v

这条命令会列出 Flutter、Dart、Android Toolchain、连接设备等各模块的健康状态。不要忽略最后那串提示,比如“Android license status unknown”这类问题如果不处理,后面打包安装到真机时还会回来找你。我第一次带项目时就是跳过这一步直接跑模拟器,结果编译通过却装不上真机,回头排查半天才发现是许可未接受。

2.2 用命令行创建项目并理解工程骨架

创建项目的标准命令是:

flutter create todo_app

项目名必须是小写字母加下划线的组合,不要用大写,也不要叫app这类太通用的名字,因为后面包名和目录名都会基于这个名字生成。进入项目目录后,默认模板给你搭好了一个计数器的 Demo,运行flutter run就能看到效果。

我建议新手做的第一件事不是急着删代码,而是先把lib/main.dart读一遍,找到三个东西:main函数、runApp调用、MyApp这个根 Widget。这三个节点是 Flutter 应用的启动链路——main是程序入口,runApp把根 Widget 挂载到 Widget 树,MyApp配置应用级信息(比如主题和首页)。在这个项目里,我们的入口文件长这样:

import 'package:flutter/material.dart'; import 'screens/home_screen.dart'; void main() { runApp(const TodoApp()); } class TodoApp extends StatelessWidget { const TodoApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '待办事项', theme: ThemeData( primarySwatch: Colors.indigo, useMaterial3: true, ), home: const HomeScreen(), ); } }

这里把页面逻辑抽到了screens/home_screen.dart,文件结构上提前做了分层。很多新手习惯把所有代码都堆在main.dart,项目初期还无所谓,一旦页面多起来,整个文件上千行,改个样式都要翻半天。我的习惯是:main.dart只放入口和全局配置,页面、组件、模型各归各的文件,这会让你后续迭代的舒适度提升一个量级。

3. 核心页面与列表交互实现

3.1 数据模型设计与内存态列表

写页面之前,先定义数据模型。这一步很多人会忽略,觉得“我就一个字符串列表而已”,但当任务需要同时存储标题和完成状态时,没有模型就会陷入到处用两个平行 List 的窘境,状态一变就数据错位。定义模型的方法很简单:

class TodoItem { final String id; String title; bool isDone; TodoItem({ required this.id, required this.title, this.isDone = false, }); }

三个字段各有用处:id用来做列表项的唯一标识,删除和更新时靠它定位数据,而不是靠标题文本匹配(标题可能重复,这是新手很容易踩的坑);title是任务内容;isDone记录完成状态。

接着在HomeScreen这个StatefulWidget里维护一个List<TodoItem> _todoList,作为当前页面的“内存态数据”。页面每一次重新渲染,都是基于这个列表的真实状态来绘制,而不是直接操作 Widget。这里体现的思维是:状态是唯一的数据源,UI 只是状态的一种投影。你只要保证状态正确,UI 就会正确,不需要手动去“改控件上的字”。

3.2 输入区与新增任务逻辑

页面顶部的输入区,用一个TextField加一个“新增”按钮实现。这里有几个技巧可以省掉很多麻烦。第一个是把TextField包在一个TextEditingController上,controller 负责读取和清空输入内容;第二个是用onSubmitted回调捕捉键盘“完成”按钮,这样用户输完任务直接点键盘右下角的完成键就能提交,体验贴近原生应用;第三个是提交时做空字符串过滤,避免用户误触导致列表出现一堆空任务。

核心逻辑代码:

void _addTodo() { final text = _controller.text.trim(); if (text.isEmpty) { // 提示用户输入内容,而不是静默失败 ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('请先输入任务内容')), ); return; } setState(() { _todoList.insert( 0, TodoItem( id: DateTime.now().millisecondsSinceEpoch.toString(), title: text, ), ); }); _controller.clear(); FocusScope.of(context).unfocus(); // 收起键盘 }

这里我把新任务insert到列表头部而不是用add追加到尾部,是刻意为之——待办事项的优先级通常高于已完成事项,新任务放顶部符合大多数人的使用直觉。同时,id直接用时间戳生成的方案虽然简单,但已能保证单机环境下不重复,对于本项目足够了。

3.3 列表项的三种状态处理

列表区的核心是ListView.builder。为什么用builder而不是直接塞一个Column?因为ListView.builder是懒加载的,屏幕上看不到的那些列表项不会被构建,数据量变大时性能和内存占用会明显优于一次性全部渲染的方式。这也是真实项目中列表类的首选。

每个列表项的布局我拆成了三块:最左边一个Checkbox切换完成状态,中间是任务标题文本,最右边是一个删除按钮。删除操作要用Dismissible包一层,这样用户既能通过滑动删除,也能通过按钮删除,交互双保险。Dismissible需要传入唯一的key,这里必须使用ValueKey(item.id),否则列表复用时会出现删除错行或者动画错乱的诡异 Bug。

标记完成与删除的代码:

void _toggleTodo(String id) { setState(() { final index = _todoList.indexWhere((item) => item.id == id); if (index != -1) { _todoList[index].isDone = !_todoList[index].isDone; } }); } void _deleteTodo(String id) { setState(() { _todoList.removeWhere((item) => item.id == id); }); }

两个操作都用id定位,而不是用列表索引。这个习惯能帮你避免一个非常隐蔽的问题:当你同时执行“删除某一行”和“更新某一行”的操作时,索引可能因为之前的删除动作而发生偏移,最终更新错对象。用唯一标识去定位数据,等于是给数据上了一道保险,这在异步场景下尤其重要。

3.4 列表 UI 细节:完成态样式与空状态

列表项文本在完成状态下应该有明显的变化,我的处理是“删除线 + 灰色”:标题文本在isDone为真时加TextDecoration.lineThrough,并降低字色透明度。这既是视觉反馈,也是无障碍考量——弱化视力的用户不应该只靠勾选框的颜色变化来识别状态,文字本身的样式变化同样重要。

另一个容易忽视的点是空状态。当列表没有任何任务时,不能只呈现一片空白,要给用户一个引导提示,比如居中显示“还没有任务,添加一条吧”。实现方式是在ListView.builder的外层判断列表长度,列表为空时渲染一个提示 Widget,否则渲染列表。这个细节看着小,却是区分“能跑的 Demo”和“能用的应用”的分界线之一。

4. 数据持久化与状态管理进阶

4.1 为什么必须做持久化

如果应用退出后数据全部丢失,那它只能算一个演示壳子,离“能用”还差得远。所谓“持久化”,本质上就是把内存中的数据变成磁盘上的文件或记录,让它们在应用进程结束后仍然存在。待办事项的数据是用户的核心资产,必须做持久化。

shared_preferences的使用方式很清晰:通过一个全局单例实例,调用getString、setString这类方法读写键值对。我们需要考虑的是“存什么格式”。直接存字符串列表是最简单的,但那样做无法保存任务的isDone状态,所以标准做法是先把TodoItem对象序列化成 JSON 字符串,再把整个 JSON 数组编码成一个字符串存入。

4.2 序列化与反序列化的完整链路

给TodoItem加上两个方法,一个用于“转出去”,一个用于“转回来”:

class TodoItem { // ...已有字段 Map<String, dynamic> toJson() { return { 'id': id, 'title': title, 'isDone': isDone, }; } factory TodoItem.fromJson(Map<String, dynamic> json) { return TodoItem( id: json['id'] as String, title: json['title'] as String, isDone: json['isDone'] as bool? ?? false, ); } }

在HomeScreen中新增两个方法:

Future<void> _loadTodos() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('todo_list'); if (raw == null) return; final List<dynamic> decoded = jsonDecode(raw); setState(() { _todoList = decoded .map((item) => TodoItem.fromJson(item as Map<String, dynamic>)) .toList(); }); } Future<void> _saveTodos() async { final prefs = await SharedPreferences.getInstance(); final encoded = jsonEncode( _todoList.map((item) => item.toJson()).toList(), ); await prefs.setString('todo_list', encoded); }

特别是在_saveTodos里,我没有在每次_addTodo、_toggleTodo、_deleteTodo的方法体里重复写保存逻辑,而是在每个操作后统一调用_saveTodos()。这样保存动作始终跟着数据变化走,不需要人工记住每个角落,后续如果新增“编辑任务”功能,也只要加一句调用即可。

4.3 状态管理的分阶段演进思路

有同学会问:如果项目后期复杂度上来了,这个setState的写法是不是就要推翻重来?这里可以提前交个底:setState的问题是“状态提升困难”——当多个页面需要共享同一份待办数据时,每个页面都要各自维护一套逻辑,数据的同步就成了负担。到时我们需要把状态提升到全局层,引入全局状态容器,让任意页面都能读取和修改任务列表,页面通过绑定方式响应数据变化。

但注意,这个“进化”不是重写,而是重构。数据模型TodoItem、序列化方法、页面布局代码都可以原样保留,变的只是“状态放在哪里”以及“如何触发重建”。所以在这个项目里老老实实把setState用透,你会发现后面切换到任何状态管理方案时都顺理成章,因为你已经理解了状态变更是如何驱动 UI 刷新的,那些高级方案不过是帮你管理“变更通知”的机制而已。

5. 生命周期与体验打磨

5.1 initState 与页面初始化

很多新手写持久化时,会把加载数据的代码直接丢进build方法里。这是一个很伤的错误:build方法在应用运行期间会被反复调用(任何setState、任何父级重建都会触发它),如果每次都从磁盘读取数据并setState,轻则白白浪费 IO,重则陷入“读取 -> 重建 -> 再读取”的循环。

正确的做法是在initState里加载:

@override void initState() { super.initState(); _loadTodos(); }

initState在 State 对象被插入 Widget 树时只执行一次,天然适合做初始化工作。另外,_loadTodos内部是异步操作,而initState本身不等待异步完成,所以你会看到列表先以空状态渲染,数据读完后立刻触发setState刷新。这个“先空后满”的过程在本地读取速度很快,用户几乎感知不到,但代码逻辑上必须清晰。

5.2 键盘遮挡输入的解决思路

移动端开发绕不开一个体验细节:弹出的软键盘会遮挡输入框和下方的列表内容。Flutter 里的标准解法是给Scaffold设置resizeToAvoidBottomInset: true(默认就是 true),这样键盘弹出时页面会自动缩小,避免输入框被挡住。

但光有这个还不够。当用户点击列表下方的某个任务时,如果键盘还开着,画面会显得非常拥挤。实际体验中,我会在列表滚动时收起键盘。有一个很轻量的做法:

ListView.builder( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, itemBuilder: ..., )

keyboardDismissBehavior设为onDrag,用户一滑动列表键盘就自动收起,既清爽又符合直觉。这个参数很多人不知道,但加上的那一刻你就能感觉到应用“顺手”了许多。

5.3 数据丢失的防线:什么时候该立即保存

关于保存时机的选择,有个经验原则:即时保存但不频繁保存。本项目的场景是每次增删改都只有一条数据变化,整体量级很小,即时保存没有问题。如果未来任务列表非常庞大,比如上千条,每次都序列化整个数组就会有一点卡顿感,到时可以引入“防抖”技巧——用户停止操作 300 毫秒后再写盘,同时页面正常响应。这里不展开实现,但先埋一个概念,后面遇到再优化。

另一个实际教训是,SharedPreferences的写入是异步的,有人图省事故意不await。在绝大多数场景下,应用都不会因为“没等写入完成”而崩溃,但如果任务刚添加完马上杀进程,有一定概率写入还没落盘。所以我的规范是:所有写盘操作都老老实实await,用async方法包裹,不追求微秒级性能,换的是数据安全的确定性。

6. 常见问题与排查技巧实录

6.1 问题速查表

我把带新同学做这个项目时,最常碰到的报错和现象整理成了一个表,方便你直接对号入座。

问题现象直接原因解决思路
运行flutter run提示设备无法连接模拟器未启动或 USB 调试未打开先flutter devices查看可用设备,确认后重新运行
输入中文时拼音字母先冒出,选字后消失Flutter 默认的文本输入处理在部分输入法下体验不佳升级 Flutter 版本,或在TextField上设置合适的inputFormatters,但多数情况等待官方修复即可
Dismissible删除后列表数据错乱key设置不当,或删除回调里仍然嵌套了其他异步状态更新确认每个列表项key使用唯一 id
热重载后界面没变化State被保留,代码改动只影响后续重建;或改动只涉及main()之前的代码使用Hot Restart(小写键盘r),或检查是否修改的是根级数据结构
读取 JSON 时类型转换崩溃旧版本数据缺少某个字段,或字段类型与预期不符fromJson中给可空字段加??兜底,并在解析前打印原始数据
键盘弹出后按钮被遮挡部分机型或窗口尺寸下Scaffold未正确调整检查resizeToAvoidBottomInset是否为true

6.2 热重载与热重启:该用哪个别搞混

Flutter 的热重载(Hot Reload)和热重启(Hot Restart)是两个容易混淆的概念,实际体验差距也很大。热重载会在保留应用当前状态的基础上,把修改过的代码重新编译并注入到运行中的 Dart 虚拟机里。这意味着你正在调试一个表单,改了某个颜色或布局样式,热重载后你输入的文本、列表滚动位置基本都能保留,所以它是“调样式”场景下的利器。

但热重载无法处理以下情况:修改了main()里的初始化逻辑、修改了全局变量或静态变量的初始化、修改了enum定义、修改了native层代码等。遇到这类改动,界面往往“无动于衷”或运行报错。此时你需要的不是反复点击热重载,而是按一次热重启(快捷键Shift + R或R在控制台输入对应命令)。热重启会重新执行main(),并清空所有 State,代价是调试中的临时状态会丢失。我的习惯是:UI 微调用热重载,逻辑结构调整用热重启,别在一个方向上死磕。

6.3 一组有效排查套路:从日志定位状态问题

遇到“列表不更新”这类问题,不要急着改代码。先看现象发生在哪个环节:是数据没有进入_todoList,还是进入了但setState没有生效,还是build之后渲染结果不对。定位手段很简单——在关键节点打印日志:

void _addTodo() { // ... print('新增任务: ${item.id}, 当前列表长度: ${_todoList.length}'); }

如果点击新增后,日志显示列表长度在涨,但界面没有变化,那就是setState没有触发重建,检查setState是否在写错作用域,或者你在一个Builder/FutureBuilder的子上下文里改了外部状态。如果列表长度根本没涨,说明逻辑在赋值之前就跳走了,检查trim()之后的条件判断和 controller 是否已经正确释放。这种“分环节打印”的定位方式,看起来笨,但在 Flutter 这种 UI 驱动框架里往往比凭直觉改代码快得多,也锻炼人系统化思考的能力。

7. 后续可以怎么扩展

这个项目做到这里已经是一个完整可用的应用了。如果你学有余力,有几个自然的演进方向可以选择一个去实践。第一个方向是增加“编辑已有任务”的功能,点击列表项弹出对话框修改标题,这需要你处理“弹窗 -> 修改数据 -> 刷新列表 -> 保存”的完整链路,和之前新增任务逻辑形成很好的对照。第二个方向是引入Provider全局状态管理,将_todoList提升到全局层,然后所有页面统一读写,扩展性会明显增强。第三个方向是增加分类筛选,比如“全部 / 待完成 / 已完成”三个 Tab,这会迫使你学习TabBar与列表过滤的结合方式,也是页面间数据交互的初步尝试。第四个方向是接入系统提醒和通知,把待办事项做成真正有“待办感”的应用,这一步会让你真正接触 Flutter 与原生能力交互的边界。

我个人在实际项目里反复体会过一件事:新手阶段最怕的不是代码写不出来,而是不知道从哪里练起。待办事项这个项目如果认真做完,并且把“状态 -> 数据 -> 持久化”这条链路理解透,你的 Flutter 水平会有一个非常扎实的起步。“项目实战 1”这个系列的目的就在这里,先夯实一个点,再谈覆盖一个面。把基础打牢,后面的每一步都会越走越快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询