☰
Flutter网络请求实战:http与dio解析列表数据并刷新UI
2026/9/28 12:25:02 网站建设 项目流程

做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 两个库到底怎么选?我总结一张表

维度httpdio
包体积更小,依赖少略大,自带拦截器和 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 跳登录这些更接近业务层的逻辑,到时候你会发现,网络请求这东西,框架只帮你到“拿到数据”这一步,后面的数据管理、状态同步、用户提示,才是真正需要花心思打磨的工程点。我这里讲的坑,也都是自己一行一行试出来的,建议你边看边敲,踩到了再回来看对策,印象会深得多。

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

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

立即咨询