
前两篇笔记里我已经把 Flutter 的基础跑通了环境怎么搭、Widget 怎么套、布局怎么写、几个常用组件怎么用。但真到自己动手做一个完整的小应用时我发现事情没这么简单。比如列表页的数据从哪来详情页的参数怎么传收藏按钮和列表条目怎么保持同步暗黑模式怎么一键切换——这些问题全都是基础教程里顺带提了一嘴、但没人认真讲透的部分。这篇“Flutter 入门笔记 三”我把它定位成从“能看”到“能用”的转折点。我会用一个小待办应用当贯穿案例把状态管理、网络请求、路由传参、主题切换这几块拼到一起。读完你大概能明白一个真实 App 的日常开发到底是怎么串起来的。1. 状态到底该归谁管从 setState 到状态提升1.1 当两个 Widget 需要同步时setState 就不够用了很多初学者接触的第一个交互写法是 setState我也是这么过来的。点击按钮改一个 boolUI 跟着变感觉特别直观。但很快会遇到一个尴尬场景页面上半部分是一个 ListView下半部分是一个悬浮按钮按钮上显示当前有多少条待办。当列表里新增一条数据时按钮上的数字也得跟着变。如果这两个区域是独立的 Widget而且各自管理自己的数据你很快会发现它们完全没法同步。A Widget 的 setState 只会触发 A 的 buildB 的 build 根本不会被调用。强制用 GlobalKey 之类的东西去跨组件操作短期能跑长期必然是灾难代码会越来越拧巴。我在这个阶段踩过一次坑把一份列表数据存在页面 A又复制了一份到页面 B两个页面来回跳数据各自维护最后新增的待办在 A 能看到切到 B 就消失了。排查了半天根因就是“同一份数据有两个来源”。这里有一条很朴素的原则一份数据只存在一个地方。1.2 谁用谁拥有数据往需要它的地方提升既然状态不能各管各的那应该放哪答案是放到需要这份数据的所有 Widget 的“共同父级”上然后通过构造函数或参数往下传。这个过程叫状态提升。比如上面那个场景列表和计数按钮都依赖同一份待办数组那就把这份数组放到它们的父页面里再分别传给子组件。父页面调用 setState 时整个子树都会重建列表和按钮自然拿到新数据。但也别一提提升就把所有状态全塞到根组件那样同样会很难受。我有一段时间就矫枉过正把所有页面属性全部提升到主页面里结果一个页面收了十几个参数改一个字段得翻好几层可维护性反而更差。对状态存放位置我一般按这个顺序判断某个状态只被一个 Widget 自己使用那就留在它内部用 setState 即可。某个状态被兄弟 Widget 共享就提升到它们共同的父级。某个状态被完全无关的页面共享比如登录用户、购物车、全局配置提升已经不管用了得用全局可访问的“状态容器”。表格总结一下使用范围推荐方案原因单个 Widget 内部setState作用域最小重建成本可控兄弟 Widget 同步状态提升到公共父级一份数据一个来源跨页面/跨路由共享InheritedWidget 或 Provider避免层层传参到了第三种情况就是下一节要讲的 Content。2. 用 ChangeNotifier 自己搭一套轻量状态管理2.1 ChangeNotifier 和 notifyListeners 的魔力Flutter 自带了一个叫 ChangeNotifier 的类它在 flutter/foundation.dart 里不需要引第三方库。它的用法很简单把数据放在一个类里数据发生变化时调用 notifyListeners()所有监听它的地方就会被通知去重新构建。这个思路和 setState 很像但它有个关键区别setState 是 Widget 自己通知自己ChangeNotifier 是任何一个对象都能通知多个外部监听者。也就是说数据容器可以彻底独立于 Widget 树谁要谁去订阅。我建议初学者先自己手写一遍这个机制不要一上来就直接用 Provider 包。手写一遍你才知道 Provider 底层帮你干了什么。2.2 一个最小可用的 TodoModel下面我用一个待办列表来演示。先定义一个 TodoItem 模型// todo_item.dart class TodoItem { TodoItem({ required this.id, required this.title, this.done false, }); final int id; String title; bool done; }再定义一个继承 ChangeNotifier 的 TodoModel// todo_model.dart import package:flutter/foundation.dart; import todo_item.dart; class TodoModel extends ChangeNotifier { final ListTodoItem _items []; ListTodoItem get items List.unmodifiable(_items); void addTodo(String title) { final item TodoItem(id: _items.length 1, title: title); _items.add(item); notifyListeners(); } void toggleTodo(int id) { final index _items.indexWhere((item) item.id id); if (index 0) { _items[index].done !_items[index].done; notifyListeners(); } } void removeTodo(int id) { _items.removeWhere((item) item.id id); notifyListeners(); } }注意两个细节。第一getter 里我用 List.unmodifiable 包了一层外部拿到的只是只读视图没法绕过方法直接改内部列表能避免很多脏数据问题。第二每次修改后都要 notifyListeners()漏掉的话界面不会更新这是最常见的“代码改了 UI 不动”的根源。2.3 把模型挂进 Widget 树ChangeNotifier 本身只是一个监听器它怎么和 Widget 树扯上关系最朴素的方案是 AnimatedBuilder它能监听某个 Listenable监听触发后重建指定子 Widget。不过现在 Flutter 生态里最主流的做法是引 Provider 包。它是官方推荐的轻量封装用法很清晰。先在 pubspec.yaml 里加依赖flutter pub add provider然后在 main.dart 里挂载模型import package:flutter/material.dart; import package:provider/provider.dart; import todo_model.dart; void main() { runApp( ChangeNotifierProvider( create: (_) TodoModel(), child: const MyApp(), ), ); }ChangeNotifierProvider 做了两件事创建一个 TodoModel 实例并把它放进 Widget 树上方的 InheritedWidget 机制里。子树里任何地方都能通过 Provider 拿到这个实例不需要一层层构造函数传参。2.4 消费端应该用 Provider.of 还是 Consumer在业务代码里读取模型有两种常见写法。第一种final todoModel context.readTodoModel();这种写法适合在事件回调里读取数据比如按钮的 onPressed因为它不会建立监听关系只是拿一次。第二种final todoModel context.watchTodoModel();这种写法会让当前 Widget 在模型变化时重新 build。还有更精细的写法用 Consumer 包住局部区域ConsumerTodoModel( builder: (context, model, child) { return Text(${model.items.length} 条待办); }, )Consumer 的好处是只有它包住的局部会重建其他部分不会。比如列表很长你只想让计数文本更新用 Consumer 包住那一个 Text 就行比整页 setState 高效得多。这里有个经验不要在 build 方法里直接实例化一个 ChangeNotifier也尽量不要把 Provider.of 写在 build 里却不加 listen 参数那会让每次 build 都重新订阅容易引发没必要的重建和奇怪的 warning。我后来回头看学状态管理最值得理解的其实是“谁通知谁重建”而不是记住某个库的 API。Provider、Riverpod、Bloc 这些库本质上都是在帮我们解决“数据变化后哪些 Widget 要精确更新”这个问题。3. 网络数据进页面完整的请求、解析、加载与错误态3.1 API 请求和 JSON 模型解析状态管理解决的是“数据在内存里怎么流动”那数据最初从哪来当然是网络。Flutter 里做网络请求入门阶段用 http 包最直接。flutter pub add http我来写一个简单的 API 层。假设接口是 https://api.example.com/todos返回一个 JSON 数组// todo_api.dart import dart:convert; import package:http/http.dart as http; import todo_item.dart; class TodoApi { static FutureListTodoItem fetchTodos() async { final resp await http .get(Uri.parse(https://api.example.com/todos)) .timeout(const Duration(seconds: 10)); if (resp.statusCode ! 200) { throw Exception(请求失败状态码${resp.statusCode}); } // 注意这里一定要用 bodyBytes而不是 body final decoded utf8.decode(resp.bodyBytes); final list jsonDecode(decoded) as Listdynamic; return list .map((item) TodoItem.fromJson(item as MapString, dynamic)) .toList(); } }我在给 TodoItem 补个 fromJsonfactory TodoItem.fromJson(MapString, dynamic json) { return TodoItem( id: json[id] as int, title: json[title] as String, done: json[done] as bool? ?? false, ); }这里有个非常容易踩的坑很多接口返回的内容带中文直接 resp.body 在某些字符集下会乱码因为 http 包默认按 latin1 解析的。正确做法是用 utf8.decode(resp.bodyBytes)。我见到很多新手在这卡半天以为是接口问题其实只是解码方式不对。3.2 FutureBuilder 有坑不要在 build 里提交请求加载数据最常用的组件是 FutureBuilder。但我得先提醒一个坑Future 一定要存在 StatefulWidget 的成员变量里在 initState 里初始化而不是直接写在 build 里。否则 build 方法每次重建都会重新发起一次网络请求页面闪一下加载框然后数据刷新体验很糟糕。我的写法是class TodoListPage extends StatefulWidget { const TodoListPage({super.key}); override StateTodoListPage createState() _TodoListPageState(); } class _TodoListPageState extends StateTodoListPage { late FutureListTodoItem _future; override void initState() { super.initState(); _future TodoApi.fetchTodos(); } void _refresh() { setState(() { _future TodoApi.fetchTodos(); }); } override Widget build(BuildContext context) { return FutureBuilderListTodoItem( future: _future, builder: (context, snapshot) { if (snapshot.connectionState ! ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return _ErrorView(message: snapshot.error.toString(), onRetry: _refresh); } final items snapshot.data ?? []; if (items.isEmpty) { return const _EmptyView(); } return ListView.separated( itemCount: items.length, separatorBuilder: (_, __) const Divider(height: 1), itemBuilder: (context, index) { final item items[index]; return ListTile( title: Text(item.title), trailing: item.done ? const Icon(Icons.check_circle, color: Colors.green) : const Icon(Icons.circle_outlined), onTap: () { context.readTodoModel().toggleTodo(item.id); }, ); }, ); }, ); } }注意我判断完成状态用的是 connectionState而不是snapshot.hasData。原因很简单只要请求没结束connectionState 就是 waiting这时候显示加载中肯定没错但如果是错误结束call snapshot.hasData 可能返回 false你无法判断是还在加载还是出错。所以直接对 connectionState 做区分最清晰。3.3 加载、错误、空状态三件套不能少一个功能完整的页面至少得有四种状态加载中、数据正常、数据为空、请求失败。很多新手只写前两种结果用户断网后看到的就是一个永远转圈的白屏体验极差。我在上面的 _ErrorView 里放了重试按钮其实就是一个 Column 加一个 MaterialButtonclass _ErrorView extends StatelessWidget { const _ErrorView({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_off, size: 48, color: Colors.grey), const SizedBox(height: 12), Text(message, textAlign: TextAlign.center), const SizedBox(height: 12), FilledButton(onPressed: onRetry, child: const Text(重新加载)), ], ), ), ); } }这里我把加载、错误、空状态拆成独立 Widget虽然暂时只有几十行代码但它养成的是一个好习惯UI 和数据状态分离。后面接口多了这套模式可以直接复制到其他页面。关于超时我用的是 .timeout(Duration(seconds: 10))。超时后 Future 会抛 TimeoutException自然进入错误分支用户体验是能感知的“加载失败可重试”而不是无限转圈。这个 10 秒是我根据自己的网络环境调出来的你可以按实际场景调。还有一个细节页面销毁后网络回调还在执行怎么办FutureBuilder 本身不会在页面销毁后直接崩溃但如果你在异步回调里调用了 setState就会遇到“setState called after dispose”的报错。入门阶段最简单的规避办法是不要在 await 之后无脑 setState用 StatefulWidget 里的 mounted 判断一下或者干脆只依赖 FutureBuilder 的结果不手动 setState。等以后接触更多异步场景再系统地学生命周期管理。4. 页面跳转的参数传递从直接 push 到统一路由4.1 最基础的跨页面传参方式Flutter 页面跳转的基础是 Navigator。最直观的写法直接 push 一个页面并传参Navigator.of(context).push( MaterialPageRoute( builder: (context) TodoDetailPage(item: item), ), );TodoDetailPage 在构造函数里接收一个 TodoItem 对象。这个方式非常直白目的页面需要什么参数构造函数就写什么参数类型安全也有保证。我建议刚入门时优先用这个写法不要一上来就设计复杂的路由体系先把“跳转”这件事跑通。从详情页返回结果也很常用。比如详情页里有一个“删除”按钮删除后希望列表页刷新Navigator.of(context).pop(true);列表页这边接收final result await Navigator.of(context).pushbool( MaterialPageRoute( builder: (context) TodoDetailPage(item: item), ), ); if (result true) { // 刷新列表 }这里用到了 Dart 的异步方法调用await 一个 Navigator.push返回值就是 pop 传入的值。这个模式在处理“编辑后返回刷新”时非常顺手。4.2 命名路由的诱惑和陷阱很多教程会介绍命名路由也就是在 MaterialApp 里配置 routes 表然后用 Navigator.pushNamed(context, /detail) 跳转。坦白说这个方案在小项目里初期很香写起来短一眼知道去哪。但用几次你就会发现动态参数很难在静态路由表里表达。比如你需要传一个对象 detail在 routes 表里你只能写成routes: { /detail: (context) TodoDetailPage(), }对象参数完全没地方放最终只能用 arguments 塞进去然后在页面里自己解析final args ModalRoute.of(context)?.settings.arguments as TodoItem;这样做能跑但页面和路由表之间缺少一个明确的参数签名尤其在页面多、参数复杂时非常容易传错类型运行期才发现。我个人的建议是命名路由表适合那些没有参数的静态页面比如设置页、关于页凡是需要带业务参数的页面直接用构造函数传参或者用 onGenerateRoute 统一处理。4.3 用 onGenerateRoute 收拢页面构造逻辑当项目页面多到一定程度我倾向于把路由切换逻辑统一放到 onGenerateRoute 里这是一个回调函数每次 pushNamed 时会经过它MaterialApp( onGenerateRoute: (settings) { switch (settings.name) { case /detail: final item settings.arguments as TodoItem; return MaterialPageRoute( builder: (context) TodoDetailPage(item: item), ); default: return MaterialPageRoute( builder: (context) const NotFoundPage(), ); } }, )这样做的价值是所有页面构造逻辑集中在一个文件里参数解析、类型校验一目了然。如果构造失败还能兜底一个 404 页面。养成这个习惯以后你会发现自己很少再为路由参数抓狂。还有一点不要在业务代码里用全局的 GlobalKey 访问 Navigator然后强行做全局跳转。那套写法在特殊场景下有用比如从通知栏跳转到某个页面但绝大多数逻辑里都应该优先用 BuildContext 驱动 Navigator。滥用全局导航会破坏页面之间的参数依赖也让你难以追踪跳转来源。5. 主题与暗黑模式配置一次全局生效5.1 别再把颜色写死在每个文件里新手最常见的样式问题是到处用 Color(0xFF…)、TextStyle(fontSize: 16)。单个页面还好两个页面以上就会出现风格不统一的问题按钮 A 的蓝色和按钮 B 的蓝色可能不是同一个蓝。正确的思路是颜色、字体、间距这些全局样式一律放进 ThemeData所有组件从主题里读取。在 MaterialApp 里这样配置MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF3F51B5), ), ), home: const TodoListPage(), )ColorScheme.fromSeed 会根据你给的一个种子色自动生成一整组协调的前景色、背景色、主色调、辅助色。这样你不用自己逐个定义生成的配色也基本不会难看。在组件里读主题的标准姿势是通过 BuildContextfinal scheme Theme.of(context).colorScheme; Container( color: scheme.primaryContainer, child: Text( 待办事项, style: TextStyle(color: scheme.onPrimaryContainer), ), )唯一需要记住的点是Theme.of(context) 必须在有 BuildContext 的地方调用。如果纯粹是一个工具函数你得把主题对象传进去不要自己 new 一个 ThemeData 到处传。5.2 亮色和暗色主题一起配现在应用基本都要支持暗黑模式。MaterialApp 里配置 darkTheme 就行MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF3F51B5)), ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF3F51B5), brightness: Brightness.dark, ), ), themeMode: ThemeMode.system, )themeMode 支持 system、light、dark 三种值。选 system应用就会跟着系统的亮暗设置走这是最省事也最尊重用户习惯的做法。在具体页面里通过 state 切换模式也很简单把上面这些配置封装成一个 StatefulWidget点击按钮时更新 themeMode 状态就行。这里要提一个我在暗黑模式下踩过的坑如果组件里的颜色写死成白色背景、黑色文字暗色模式下就很难看。所以前面说的“颜色尽量从 colorScheme 读”不只是为了统一更是为了让暗色主题自动生效。凡是用了 scheme.background、scheme.onSurface 这些变量切到暗黑模式时都会自动跟着变不需要你重写一遍样式。5.3 针对控件的局部主题覆盖有时候你想给某一个控件单独定制颜色不全局改。可以用控件自带的 style 参数比如 FilledButton.styleFromFilledButton.styleFrom( backgroundColor: scheme.tertiary, foregroundColor: scheme.onTertiary, )这属于局部覆盖不影响其他按钮。要区分清楚“全局主题”和“局部覆盖”边界全局统一的东西放 ThemeData个别地方特殊处理用 styleFrom。这个边界一开始没建立起来项目后期就会被各路花式写死颜色折腾到怀疑人生。我自己的体验是主题配置越早做越好。哪怕只有一个页面我也建议在项目初始化时就把 theme、darkTheme、themeMode 三项写好后面加页面只是填内容不会为了样式返工。到这里状态管理、网络请求、路由、主题这几块都有了一个能跑通完整流程的待办应用已经成形。你可以拿这个结构去套别的场景把 TodoItem 换成你业务的实体把 TodoApi 换成真实接口把路由表按你的页面改一改基本就是一套可复用的骨架。再往后的进阶方向比如更复杂的异步状态、数据库缓存、测试都是在这个骨架上逐个填肉不必急着一步到位。