news 2026/10/17 11:40:44

Flutter入门实战:状态管理、网络请求、路由与主题切换全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter入门实战:状态管理、网络请求、路由与主题切换全解析

前两篇笔记里,我已经把 Flutter 的基础跑通了:环境怎么搭、Widget 怎么套、布局怎么写、几个常用组件怎么用。但真到自己动手做一个完整的小应用时,我发现事情没这么简单。比如列表页的数据从哪来,详情页的参数怎么传,收藏按钮和列表条目怎么保持同步,暗黑模式怎么一键切换——这些问题,全都是基础教程里顺带提了一嘴、但没人认真讲透的部分。

这篇“Flutter 入门笔记 三”,我把它定位成从“能看”到“能用”的转折点。我会用一个小待办应用当贯穿案例,把状态管理、网络请求、路由传参、主题切换这几块拼到一起。读完你大概能明白,一个真实 App 的日常开发到底是怎么串起来的。

1. 状态到底该归谁管:从 setState 到状态提升

1.1 当两个 Widget 需要同步时,setState 就不够用了

很多初学者接触的第一个交互写法是 setState,我也是这么过来的。点击按钮,改一个 bool,UI 跟着变,感觉特别直观。但很快会遇到一个尴尬场景:页面上半部分是一个 ListView,下半部分是一个悬浮按钮,按钮上显示当前有多少条待办。当列表里新增一条数据时,按钮上的数字也得跟着变。

如果这两个区域是独立的 Widget,而且各自管理自己的数据,你很快会发现它们完全没法同步。A Widget 的 setState 只会触发 A 的 build,B 的 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 List<TodoItem> _items = []; List<TodoItem> 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.read<TodoModel>();

这种写法适合在事件回调里读取数据,比如按钮的 onPressed,因为它不会建立监听关系,只是拿一次。

第二种:

final todoModel = context.watch<TodoModel>();

这种写法会让当前 Widget 在模型变化时重新 build。

还有更精细的写法,用 Consumer 包住局部区域:

Consumer<TodoModel>( 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 Future<List<TodoItem>> 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 List<dynamic>; return list .map((item) => TodoItem.fromJson(item as Map<String, dynamic>)) .toList(); } }

我在给 TodoItem 补个 fromJson:

factory TodoItem.fromJson(Map<String, 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 State<TodoListPage> createState() => _TodoListPageState(); } class _TodoListPageState extends State<TodoListPage> { late Future<List<TodoItem>> _future; @override void initState() { super.initState(); _future = TodoApi.fetchTodos(); } void _refresh() { setState(() { _future = TodoApi.fetchTodos(); }); } @override Widget build(BuildContext context) { return FutureBuilder<List<TodoItem>>( 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.read<TodoModel>().toggleTodo(item.id); }, ); }, ); }, ); } }

注意我判断完成状态用的是 connectionState,而不是snapshot.hasData。原因很简单:只要请求没结束,connectionState 就是 waiting,这时候显示加载中肯定没错;但如果是错误结束,call snapshot.hasData 可能返回 false,你无法判断是还在加载还是出错。所以直接对 connectionState 做区分最清晰。

3.3 加载、错误、空状态三件套不能少

一个功能完整的页面,至少得有四种状态:加载中、数据正常、数据为空、请求失败。很多新手只写前两种,结果用户断网后看到的就是一个永远转圈的白屏,体验极差。

我在上面的 _ErrorView 里放了重试按钮,其实就是一个 Column 加一个 MaterialButton:

class _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).push<bool>( 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 会根据你给的一个种子色,自动生成一整组协调的前景色、背景色、主色调、辅助色。这样你不用自己逐个定义,生成的配色也基本不会难看。

在组件里读主题的标准姿势是通过 BuildContext:

final 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.styleFrom:

FilledButton.styleFrom( backgroundColor: scheme.tertiary, foregroundColor: scheme.onTertiary, )

这属于局部覆盖,不影响其他按钮。要区分清楚“全局主题”和“局部覆盖”边界:全局统一的东西放 ThemeData,个别地方特殊处理用 styleFrom。这个边界一开始没建立起来,项目后期就会被各路花式写死颜色折腾到怀疑人生。

我自己的体验是,主题配置越早做越好。哪怕只有一个页面,我也建议在项目初始化时就把 theme、darkTheme、themeMode 三项写好,后面加页面只是填内容,不会为了样式返工。

到这里,状态管理、网络请求、路由、主题这几块都有了,一个能跑通完整流程的待办应用已经成形。你可以拿这个结构去套别的场景:把 TodoItem 换成你业务的实体,把 TodoApi 换成真实接口,把路由表按你的页面改一改,基本就是一套可复用的骨架。再往后的进阶方向,比如更复杂的异步状态、数据库缓存、测试,都是在这个骨架上逐个填肉,不必急着一步到位。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 11:39:35

LexiLaw中文法律大模型:本地部署与推理优化实战

简介&#xff1a;LexiLaw 是一套面向法律从业者、法学院学生及对法律知识有需求的普通用户的中文法律大模型资源&#xff0c;基于 ChatGLM-6B 架构在法律领域数据集上微调而成&#xff0c;可用于法律咨询、条款解读、案例解析与法规查询等场景&#xff0c;帮助读者理解大模型在…

作者头像 李华
网站建设 2026/10/17 11:39:19

Flutter跨端开发实战:在OpenHarmony上构建维修状态看板

1. 项目概述1.1 能做什么&#xff1a;维修车间里的“数字仪表盘”先说结论&#xff1a;这个项目&#xff0c;是给汽车维修门店/售后车间做的一个跨端“状态总览屏”。它解决的核心问题&#xff0c;是让前台接待、车间主管、维修技师都能在一块屏幕上随时掌握每辆在修车辆的实时…

作者头像 李华
网站建设 2026/10/17 11:39:19

PaddleOCR图片旋转90度识别失败?方向检测与校正实战

简介&#xff1a;本资源面向图像处理初学者与需要批量矫正图片方向的开发者&#xff0c;聚焦利用PaddleOCR底层能力解决图片旋转问题。包内共45个文件&#xff0c;以19个py脚本与17个pyc编译文件为主&#xff0c;另含3个onnx模型、2张jpg与1张png示例图、1个ttf字体及readme.md…

作者头像 李华
网站建设 2026/10/17 11:38:34

上下文模式实战:从参数爆炸到优雅的工程解耦

1. 从“上下文模式”说起&#xff1a;一个被低估的工程概念 第一次看到“context-mode”这个词&#xff0c;很多人会下意识觉得它是个抽象得没边的东西——上下文嘛&#xff0c;不就是个环境变量、一个配置项、一个框架里的参数&#xff1f;但真正在项目里踩过坑的人都明白&…

作者头像 李华
网站建设 2026/10/17 11:37:06

圆环卷收视觉检测:基于OpenCV的跑偏与卷径测量方案

1. 项目背景与需求拆解 1.1 为什么是"圆环卷收"——需求场景还原 先把这个词拆开看。"圆环"说的是被检测对象的几何形态&#xff0c;"卷收"说的是生产动作。合在一起&#xff0c;基本就是卷材类生产线上的一个经典视觉工位&#xff1a;收卷机把…

作者头像 李华
网站建设 2026/10/17 11:36:50

AI成本治理实战:用OpenTelemetry与多模型路由破解LLM隐性成本

标题里那场"隐性成本之战"&#xff0c;我这个季度深有体会。账单比上季度翻了一倍&#xff0c;查询量只涨了30%&#xff0c;排查到最后问题根本不在算力扩容&#xff0c;而是模型选错、重试风暴、上下文膨胀这些看不见的地方。后来我们做了三件事&#xff1a;把OpenT…

作者头像 李华