做Flutter做到一定阶段,你会发现一个绕不开的坎:页面搭好了,列表写出来了,但数据怎么进去?真实项目里不可能什么都写死在代码里,总得从接口拿数据。这篇就是来解决这个问题的——用 http 和 dio 这两个库里最常用的能力,把“请求列表数据 → 解析成模型 → 刷新页面的列表 UI”这条完整链路走一遍。
这篇文章适合刚看完基础 Widget 教程、想开始接触真实数据请求的零基础读者。不需要你懂太多后端知识,只要会看接口返回的 JSON 就够。我会把请求、解析、渲染、刷新的每一步拆开讲,顺带提几个自己实际用下来最容易出问题的地方。
1. 开工前的三件套:环境、依赖和测试接口
1.1 环境确认没有你想的那么复杂
Flutter 的网络请求不需要额外配置 SDK 或者安装什么特殊组件。Dart 自带的dart:io(移动端)和dart:html(Web 端)能力已经够底层网络通信用了,我们只需要在pubspec.yaml里添加请求库,然后把接口地址准备好。
先确认一下你的 Flutter 环境能跑起来。在终端执行flutter doctor,只要 Flutter 和 Dart 两项是正常的,就可以继续。版本方面,我用的 Flutter 3.x(Dart 3.x)完全没问题,这篇涉及到的语法都兼容。
有一点容易被忽略:如果之后你要在 Android 真机上请求http://开头的明文接口(不是https),需要在src/main/AndroidManifest.xml里补权限和应用配置。Debug 模式下 Flutter 模板会自动帮你加好android.permission.INTERNET权限,但 Release 包里的主AndroidManifest.xml默认没有,正式打包前要自己补上:
<uses-permission android:name="android.permission.INTERNET" />然后在<application>节点里根据情况加android:usesCleartextTraffic="true"。这个后面再细说,先跑起来再说。
1.2 添加 http 和 dio 依赖
打开pubspec.yaml,在dependencies下面加这两行:
dependencies: flutter: sdk: flutter http: ^1.2.0 dio: ^5.4.0保存后执行flutter pub get,搞定。为什么两个库都装?因为这篇标题里两个都要实战,而且它们适合的场景不太一样:http 轻量、API 直观,适合学习原理和写简单请求代码;dio 功能全面,拦截器、超时配置、取消请求都是内置的,正式项目里我更推荐它。先学会 http 再切 dio,你会很清楚 dio 帮你做了什么事。
1.3 选一个不用注册就能用的测试接口
自己写后端太麻烦,我推荐直接用 JSONPlaceholder 这套公共测试接口。它完全免费,不需要 token,返回的数据也是正经 JSON。比如https://jsonplaceholder.typicode.com/todos返回的就是一个待办事项数组,每个对象长这样:
{ "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }选这个接口有几个原因:第一,它返回的数据类型是数组,刚好对应列表页面;第二,字段不多,用来演示模型解析很合适;第三,它的服务器在国内访问速度还不错,不容易超时。
你可以在浏览器里直接打开这个地址看看返回内容,先对数据长什么样有个底,后面写模型类就知道每个字段对应什么类型了。
2. 用 http 包徒手做第一次网络请求
2.1 三段式:发请求、解 JSON、转模型
http 包的用法非常直白,核心就三个步骤。先用http.get(Uri.parse(...))拿到响应,然后jsonDecode把响应体字符串解析成 Dart 对象,最后把 Map 转成自己定义的模型类。
我建一个Todo模型,属性跟接口字段一一对应:
class Todo { final int userId; final int id; final String title; final bool completed; Todo({ required this.userId, required this.id, required this.title, required this.completed, }); factory Todo.fromJson(Map<String, dynamic> json) { return Todo( userId: json['userId'] as int, id: json['id'] as int, title: json['title'] as String, completed: json['completed'] as bool, ); } }写模型的建议是别偷懒用自动生成工具,至少前几次手写fromJson。这样你会对每个字段的类型转换有概念,后面遇到字段缺失、类型不匹配时才不会慌。
请求函数也不需要写到天上去了,一个函数足够:
Future<List<Todo>> fetchTodos() async { final Uri uri = Uri.parse('https://jsonplaceholder.typicode.com/todos'); final http.Response response = await http.get(uri); if (response.statusCode == 200) { final List<dynamic> data = jsonDecode(response.body); return data .map((item) => Todo.fromJson(item as Map<String, dynamic>)) .toList(); } else { throw Exception('请求失败,状态码:${response.statusCode}'); } }注意jsonDecode得到的List<dynamic>,里面每一项都是Map<String, dynamic>,所以要做一次强转。这一步对新手来说最容易报错,因为 Dart 的类型推导比较严格,as Map<String, dynamic>写漏了就要看一堆红色报错。
2.2 页面里调用,把列表塞进 ListView
模型建好了,请求函数写好了,接下来就是在 StatefulWidget 里把它用起来。核心思路是:进页面时触发请求,请求完成拿到数据后,调setState更新状态,让build重新执行。
class TodoListPage extends StatefulWidget { @override State<TodoListPage> createState() => _TodoListPageState(); } class _TodoListPageState extends State<TodoListPage> { List<Todo> _todos = []; bool _loading = true; String? _errorMessage; @override void initState() { super.initState(); _loadData(); } Future<void> _loadData() async { setState(() { _loading = true; _errorMessage = null; }); try { final List<Todo> todos = await fetchTodos(); if (!mounted) return; setState(() { _todos = todos; _loading = false; }); } catch (e) { if (!mounted) return; setState(() { _loading = false; _errorMessage = '加载失败,请稍后重试'; }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('网络请求列表')), body: _buildBody(), ); } Widget _buildBody() { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_errorMessage != null) { return Center(child: Text(_errorMessage!)); } return ListView.builder( itemCount: _todos.length, itemBuilder: (context, index) { final todo = _todos[index]; return ListTile( title: Text(todo.title), trailing: todo.completed ? const Icon(Icons.check_circle) : null, ); }, ); } }这里我用了三个状态变量:_todos存列表数据,_loading控制加载转圈,_errorMessage存错误提示。看起来比直接写一个FutureBuilder要啰嗦,但状态管理更直观,尤其对零基础读者来说,这三个变量背后的逻辑一清二楚。
2.3 为什么页面一直转圈?先理解 async 和 await
很多新手第一次跑上面的代码,会遇到页面一直转圈不显示数据的情况。原因大多出在异步时序上。
fetchTodos()是async函数,它返回的是一个Future<List<Todo>>,而不是直接返回List<Todo>。你有两种处理方式。第一种是在initState里调_loadData(),函数内部await完之后再setState,这是上面代码里的做法;第二种是在build里用FutureBuilder来监听 Future 的状态,这个我会在第 4 节专门讲。
如果你在initState里写了类似这样的代码,就会踩坑:
// 错误示范 @override void initState() { super.initState(); _todos = await fetchTodos(); // ❌ 编译都不会通过 }initState不是 async 函数,没法直接用await。所以正确做法是定义一个专门的方法,把异步操作包进去,然后setState通知界面刷新。这个思路贯穿所有异步加载场景,理解了就一通百通。
3. 引入 dio:聊聊它比 http 多出来的那几把刷子
3.1 全局配置:BaseOptions 和拦截器
http 包功能简单,但真实项目里你很快会遇到几个需求:每个请求都要拼接 baseUrl、每次都要统一设置超时时间和 Header、想打印请求日志方便调试、想给请求头里加 token。这些用 http 写会很啰嗦,每个请求都要重复传参数。dio 就是为这些场景设计的。
先创建一个公共的 dio 实例,通常我会放在一个单独的文件里,比如lib/service/http_client.dart:
final Dio dio = Dio( BaseOptions( baseUrl: 'https://jsonplaceholder.typicode.com', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), headers: { 'Content-Type': 'application/json', }, ), ); dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { // 这里可以统一加 token,比如从本地存储里读取 // options.headers['Authorization'] = 'Bearer xxx'; debugPrint('请求地址:${options.uri}'); handler.next(options); }, onResponse: (response, handler) { debugPrint('响应状态:${response.statusCode}'); handler.next(response); }, onError: (DioException error, handler) { debugPrint('请求出错:${error.message}'); handler.next(error); }, ), );BaseOptions里的配置是全局默认值,某个请求想临时覆盖,直接在请求方法的参数里传就行。拦截器的handler.next()表示放行,这个机制等下讲取消请求时还会提到。
3.2 用 dio 重写列表请求,代码短了一截
同样的 fetch 函数,dio 版本长这样:
Future<List<Todo>> fetchTodosWithDio() async { final Response<dynamic> response = await dio.get('/todos'); final List<dynamic> data = response.data as List<dynamic>; return data .map((item) => Todo.fromJson(item as Map<String, dynamic>)) .toList(); }对比一下就能发现,用 dio 不需要手动拼完整 URL 了,因为 baseUrl 已经在全局配置里;不需要自己调jsonDecode,因为 dio 会根据响应头自动把 JSON 解析成Map或List;错误处理也更舒服,dio 会抛出DioException,后面接住就行。
如果你是复用之前的_loadData方法,只需要把里面调用的fetchTodos()换成fetchTodosWithDio(),其他都不用改。这正好说明这一层的页面逻辑和请求实现是解耦的,你完全可以用一个ApiService类封装所有请求,页面里只调方法,不管底层用的是 http 还是 dio。
3.3 取消请求和并发请求:dio 的隐藏加分项
这一节内容稍微进阶一点,但说实话,正式项目里一定会用到。
先说说取消请求。页面 A 发起了一个网络请求,然后用户立刻返回上一页,这时候请求还在飞。如果响应回来之后你想用setState更新已经销毁的页面,Flutter 会直接报错:“setState() called after dispose()”。解决方案有两个方向:一是在页面销毁时做个mounted判断(我之前代码里写了if (!mounted) return;),二是主动取消请求。
dio 的CancelToken就是干这个的:
class _TodoListPageState extends State<TodoListPage> { final CancelToken _cancelToken = CancelToken(); Future<void> _loadData() async { try { final todos = await dio.get('/todos', cancelToken: _cancelToken); // ... } on DioException catch (e) { if (e.type == DioExceptionType.cancel) { // 请求被取消了,什么都不用做 } } } @override void dispose() { _cancelToken.cancel('页面销毁,取消请求'); super.dispose(); } }并发请求用 http 也能写,但是配合 dio 的全局配置会更顺手。比如你要同时请求两个接口,然后合并成一个列表展示:
final results = await Future.wait([ dio.get('/todos'), dio.get('/posts'), ]);两个请求并行发出,总耗时就只有最慢的那个接口的时长,而等待响应的代码仍然是一次性的。这是优化首屏加载时间非常实用的手段。
3.4 两个库到底怎么选?我总结一张表
| 维度 | http | dio |
|---|---|---|
| 包体积 | 更小,依赖少 | 略大,自带拦截器和 Cookie 管理 |
| API 直观程度 | 非常简单,适合初学者 | 中等,功能多但概念也多 |
| 全局配置 | 需要自己封装或每次传参 | 内置 BaseOptions |
| 拦截器 | 没有,需要自己写封装 | 有,非常方便 |
| 取消请求 | 需要用http.Client配合做,麻烦 | 内置 CancelToken |
| 文件上传/下载 | 要自己处理流,费劲 | 内置FormData和下载回调 |
| 适用项目 | Demo、脚本、临时工具 | 正式 App、中大型项目 |
我的建议是:一开始学习阶段用 http,因为每个环节都很透明,你能真切感受到“请求发出去、响应收回来”的过程;等你要开始做完整项目了,直接切 dio,不用纠结。
4. 数据回来了 UI 却没动?刷新机制一篇讲透
4.1 setState:最朴素的强制刷新
先说最基础的setState。它干的事情很直白:告诉 Flutter,“我这个 State 的数据变了,请你重新执行build方法”。
但有一个细节很多人没注意到:setState不是把整个页面所有 Widget 都重建一遍,Flutter 的框架会对比前后结构(这就是 diff 机制),只更新变化的部分。所以你在请求完成后:
setState(() { _todos = todos; _loading = false; });Flutter 会自动发现_todos引用变了,列表区域会重新渲染;如果_todos和_loading都没变,它就不会重建,这其实是一种性能保护。
我之前给_loadData方法里加了开头和结尾两个setState,看起来有点冗余。实际操作下来,尤其是加上下拉刷新功能后,这种写法的体验反而最好:先把列表隐藏起来显示 loading,等数据回来再整体替换,用户不会看到旧数据和新数据闪烁交错。
一个容易犯的错:在setState里直接对数组add是没有用的,必须创建一个新列表或者复制一份再改。比如:
// 错误示范(虽然能跑,但 UI 可能不会更新) _todos.add(newTodo); setState(() {}); // 正确做法 setState(() { _todos = [..._todos, newTodo]; });原因是 Flutter 判断列表是否变化,默认比对的是引用地址。你add之后原列表的引用没变,框架会认为没有更新。
4.2 FutureBuilder:把 Future 直接镶到 UI 上
FutureBuilder是另一种刷新 UI 的思路,适合那些不希望在 State 里维护一堆状态变量的场景。它的逻辑是:你给我一个Future,我根据这个 Future 当时的状态(等待中、有数据、报错)来渲染对应的 UI。
class TodoPage extends StatefulWidget { @override State<TodoPage> createState() => _TodoPageState(); } class _TodoPageState extends State<TodoPage> { late Future<List<Todo>> _future; @override void initState() { super.initState(); _future = fetchTodosWithDio(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('FutureBuilder 列表')), body: FutureBuilder<List<Todo>>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center(child: Text('错误:${snapshot.error}')); } final todos = snapshot.data ?? []; return ListView.builder( itemCount: todos.length, itemBuilder: (context, index) => ListTile( title: Text(todos[index].title), ), ); }, ), ); } }这里有个大坑:千万别在build方法里直接写FutureBuilder(future: fetchTodos(), ...)。因为build方法在每次刷新的时都会重新执行,fetchTodos()也会重新调用,导致同一个 Future 被不断重建,页面会陷入无限请求的死循环。正确做法是在initState里把 Future 存成变量,FutureBuilder引用同一个实例。
那FutureBuilder和setState选哪个?如果页面逻辑比较简单,一个 Future 管到底,用 FutureBuilder 很优雅;如果页面有多个异步过程、要缓存数据、要做局部刷新,老老实实用 State 加 setState,后期更可控。
4.3 RefreshIndicator:下拉刷新就是再走一遍请求
移动端列表有个标准交互:下拉刷新。Flutter 里用RefreshIndicator包住你的ListView就行,语义也非常清楚:用户下拉到触发位置,执行onRefresh回调。
Widget _buildList() { return RefreshIndicator( onRefresh: _loadData, child: ListView.builder( itemCount: _todos.length, itemBuilder: (context, index) => ListTile( title: Text(_todos[index].title), ), ), ); }_loadData本身就是Future<void>,所以可以直接传给onRefresh,RefreshIndicator 会在它完成之后收起加载动画。这里要注意一个细节:如果ListView内容不够长、撑不满屏幕,下拉刷新的手势会失灵。解决办法是给ListView加physics: const AlwaysScrollableScrollPhysics(),这样即使内容不满一屏,也允许用户下拉。
这是我实际使用中最容易忽略的一个点,很多人的列表明明有几十条数据,但换到空数据时就无法下拉刷新,问题就在这。
4.4 局部刷新:ValueNotifier 值不值得学
最后补充一个不算新但很实用的刷新机制:ValueNotifier和ValueListenableBuilder。setState是整块 State 重建,FutureBuilder是一个 Future 响应一个快照,而ValueListenableBuilder可以只监听一个对象的变化,实现局部 Widget 刷新。
比如你在列表页面顶部有个“加载中”状态栏,您只希望它变化,不希望整个列表重建:
final ValueNotifier<bool> _isLoading = ValueNotifier(false); // 在请求开始结束时修改 _isLoading.value ValueListenableBuilder<bool>( valueListenable: _isLoading, builder: (context, value, child) { return value ? const LinearProgressIndicator() : const SizedBox.shrink(); }, )这个做法的好处是刷新范围更小,性能更好。不过对于零基础读者来说,setState 和 FutureBuilder 已经覆盖绝大多数场景了,ValueNotifier 可以先了解,等真正遇到“全局 loading 控制”或者“列表某项局部更新”的时候再回来看,也来得及。
5. 请求过程中最容易踩的五个坑
5.1 异步回来页面已经没了:mounted 和 dispose 的配合
第 3.3 节里提过mounted判断,这里展开说一下。当页面被销毁后(用户返回了上一个页面),State 对象还在内存里等异步结果回来,这时如果调用setState,Flutter 会抛异常,严重时会导致崩溃或内存泄漏。
标准写法是每次异步await回来之后,马上判断 mounted:
final todos = await fetchTodosWithDio(); if (!mounted) return; setState(() { _todos = todos; _loading = false; });有两点补充。第一,mounted是 State 的属性,只能在 State 类里访问;如果你把请求逻辑抽到独立的控制器或 ViewModel 里,那就要用取消令牌或生命周期回调来配合。第二,不仅setState要判断,像ScaffoldMessenger.of(context)弹 SnackBar 这种依赖context的操作,也必须先检查 mounted,否则同样会有问题。
5.2 错误处理:网络异常不是程序员的错,但要学会兜底
零基础阶段容易把网络请求写得“只有成功没有失败”,但实际上弱网、断网、服务器 500 是每天都可能遇到的事。我强烈建议,所有网络请求方法,一律用 try-catch 包起来,并且在 UI 层给出明确的失败反馈。
dio 的DioException带有一个type属性,可以区分超时、连接错误、取消、响应错误等。这个在实际排查问题的时候很有用:
} on DioException catch (e) { switch (e.type) { case DioExceptionType.connectionTimeout: message = '连接超时,请检查网络'; break; case DioExceptionType.receiveTimeout: message = '响应超时,请稍后重试'; break; case DioExceptionType.connectionError: message = '无法连接服务器'; break; default: message = '请求失败:${e.message}'; } }UI 方面的兜底,至少要做到三件事:有加载失败提示、有重试按钮、有空白列表的占位图。比如我自己的列表页就到_errorMessage != null时展示一个居中的 Column,里面放一个“重试”按钮,点击后重新调_loadData()。这点细节会直接影响用户对你 App 的评价。
5.3 Android 明文 HTTP 被拦:开发到一半突然全部失败
如果你拿来练手的接口是http://而不是https://(比如连接本地后端服务),真机调试时会发现请求直接失败,控制台提示“Cleartext HTTP traffic not permitted”。
这是因为 Android 9 及以上默认禁止明文 HTTP 流量。解决方式有两种。
第一种,快速开发用,加全局开关,在 AndroidManifest 的<application>节点下配置:
<application android:usesCleartextTraffic="true" ... >第二种,正规一点,用网络安全配置只允许特定域名走明文。在res/xml/network_security_config.xml里写:
<network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">10.0.2.2</domain> <domain includeSubdomains="true">192.168.1.100</domain> </domain-config> </network-security-config>然后在 AndroidManifest 里引用:
<application android:networkSecurityConfig="@xml/network_security_config" ... >特别注意:如果你用的是 Android 模拟器访问宿主机,IP 是10.0.2.2,不是127.0.0.1。这个坑当年困了我一个下午。iOS 那边也有类似的NSAppTransportSecurity配置,跑真机调试局域网 HTTP 接口时也要接开 ATS,规则同理会限制明文流量。
5.4 JSON 字段类型对不上:后端返回字符串,模型却定义 int
这是“零基础”阶段最容易碰到的解析异常。比如接口字段id有时返回数字1,有时返回字符串"1",你用json['id'] as int强转,直接抛类型转换异常。
比较稳妥的做法是写一个小小的类型清洗逻辑:
int _toInt(dynamic value) { if (value is int) return value; if (value is String) return int.tryParse(value) ?? 0; return 0; }然后在模型解析里用_toInt(json['id'])代替直接强转。同理,布尔值有时后端会返回0、1或者字符串"true"、"false",也要自己写清洗转换。
这看起来和网络请求没直接关系,但它偏偏是最容易在“网络请求成功、解析时报错”的环节暴露的问题。我建议每个工程都放一个type_cast.dart之类的工具文件,把所有_toInt、_toString、_toBoolсобрать 在一起,各模型类共用。
5.5 热重载之后列表不刷新?原来状态还留在内存里
最后一个坑,不是代码逻辑问题,而是开发工具的使用习惯。很多人写完代码点一下热重载(Hot Reload),发现列表数据还是旧的,就以为哪里写错了。
热重载只会重新执行build相关的代码,不会销毁页面 State。也就是说,_todos、_loading这些变量值还保存在内存里,页面构建时拿到的还是旧状态。想让请求流程整个重新跑一遍,需要用热重启(Hot Restart),也就是那个蓝色的刷新按钮,或者直接重新启动应用。
这个小知识点是零基础学员问得最多的问题之一。我自己的习惯是:改了模型层、网络层这种跟数据生命周期相关的代码,一律热重启,别再点热重载了;只调 TextView、颜色、间距这些纯 UI 样式,才用热重载,响应更快。搞清楚这两块,开发效率会有非常直观的提升。
这一套流程走下来,基本上就具备独立完成“列表页请求接口 + 展示数据 + 下拉刷新”的能力了。你学完 http 再切到 dio,会觉得后者身上处处都是为真实项目设计的痕迹。等这些基础稳住了,下一步就可以去研究 dio 的拦截器怎么统一加 token、怎么处理 401 跳登录这些更接近业务层的逻辑,到时候你会发现,网络请求这东西,框架只帮你到“拿到数据”这一步,后面的数据管理、状态同步、用户提示,才是真正需要花心思打磨的工程点。我这里讲的坑,也都是自己一行一行试出来的,建议你边看边敲,踩到了再回来看对策,印象会深得多。