130 lines
4.1 KiB
Markdown
130 lines
4.1 KiB
Markdown
# 【Demo】Dio 网络请求(封装 + 三态 UI)
|
||
|
||
> 源文件:`lib/pages/demo/dio_demo.dart` | 分类:网络
|
||
> 一句话:GET/POST/报错一次跑通,学"统一请求入口 + 加载/成功/失败三态 UI"的页面范式。
|
||
|
||
---
|
||
|
||
## 一、这个 Demo 解决什么问题
|
||
|
||
新手第一次接接口时的完整范式:
|
||
1. 请求期间要显示 loading、按钮要禁用(防重复点击);
|
||
2. 错误要统一翻译成中文提示(封装层做,页面不碰底层细节);
|
||
3. **await 之后 setState 前必须判 mounted**(用户可能已切走页面)。
|
||
|
||
---
|
||
|
||
## 二、怎么运行 / 演示内容
|
||
|
||
```dart
|
||
runApp(const MaterialApp(home: DioDemoPage()));
|
||
```
|
||
|
||
4 个按钮:
|
||
- GET 单条:`PostApi.fetchPost(1)`;
|
||
- GET 列表:`PostApi.fetchPosts(userId: 1)`(jsonplaceholder mock);
|
||
- POST 新建:`PostApi.createPost(...)`;
|
||
- 故意报错:请求不存在的路径,看统一错误提示。
|
||
|
||
请求/响应日志在控制台打印(`DioClient` 拦截器里 debugPrint 完整 URL)。
|
||
|
||
---
|
||
|
||
## 三、核心知识点(新手向)
|
||
|
||
### 1. 项目的网络分层(重点!)
|
||
```
|
||
页面(本 Demo)→ PostApi(业务接口,只定义"有哪些接口")
|
||
→ DioClient(封装层:baseUrl/超时/拦截器/错误翻译)
|
||
→ Dio(底层库)
|
||
```
|
||
- `PostApi` 只关心业务:`fetchPost(1)`、`fetchPosts(userId: 1)`;
|
||
- `DioClient` 统一处理:日志、超时、把 `DioException` 翻译成中文 `ApiException`;
|
||
- 页面 **只 catch `ApiException`**,不用关心 Dio 的细节类型。
|
||
|
||
### 2. 统一请求入口 _run(页面范式)
|
||
```dart
|
||
Future<void> _run(Future<void> Function() action) async {
|
||
setState(() {
|
||
_loading = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
await action();
|
||
} on ApiException catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _error = '请求失败:${e.message}');
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _error = '未知异常:$e');
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false); // 无论成败都复位
|
||
}
|
||
}
|
||
```
|
||
- **`finally` 里复位 `_loading`**:成功/失败都要恢复,忘记写就会永远转圈;
|
||
- 每个具体请求只是往 `_run` 里传一个闭包,**loading 逻辑只写一遍**。
|
||
|
||
### 3. 三态 UI
|
||
```dart
|
||
// 加载中:转圈
|
||
if (_loading) Center(child: CircularProgressIndicator());
|
||
|
||
// 失败:红底错误卡
|
||
if (_error != null)
|
||
Container(
|
||
color: const Color(0xFFFDECEA),
|
||
child: Text(_error!, style: const TextStyle(color: Color(0xFFD32F2F))),
|
||
);
|
||
|
||
// 成功:结果卡(SelectableText 可复制)
|
||
Container(
|
||
child: SelectableText(_resultBody.isEmpty ? '(暂无数据)' : _resultBody),
|
||
)
|
||
```
|
||
|
||
### 4. 按钮防重
|
||
```dart
|
||
FilledButton.icon(
|
||
onPressed: _loading ? null : _fetchOne, // loading 时禁用
|
||
...
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
## 四、关键代码剖析
|
||
|
||
```dart
|
||
/// GET 单条
|
||
Future<void> _fetchOne() async {
|
||
await _run(() async {
|
||
final post = await PostApi.fetchPost(1);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_resultTitle = 'GET /posts/1 成功';
|
||
_resultBody = 'id: ${post.id}\nuserId: ${post.userId}\ntitle: ${post.title}';
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、踩坑记录
|
||
|
||
1. **await 之后 setState 前必须 `if (!mounted) return`** —— 异步期间页面可能已销毁,直接 setState 会报错;
|
||
2. **catch 顺序**:先 `on ApiException`(业务错误),再 `catch (e)`(兜底未知异常);
|
||
3. 别在页面里直接 catch `DioException` —— 封装层的翻译就白做了;
|
||
4. loading 复位放 `finally`,不是 try 里 —— 否则异常路径永远转圈;
|
||
5. 真实项目 URL 用相对路径(baseUrl 在 DioClient 统一配),别在页面写死域名。
|
||
|
||
---
|
||
|
||
## 六、扩展思考
|
||
|
||
- 生产级能力(取消/进度/重试)见 [dio_pro_demo.md](dio_pro_demo.md);
|
||
- 上传文件(FormData + onSendProgress)见 [oss_upload_demo.md](oss_upload_demo.md);
|
||
- 页面更进阶的写法:用 Riverpod `AsyncNotifierProvider` 管理请求状态,AsyncValue 自带 loading/error/data 三态(见 exam 架构文档);
|
||
- 错误重试策略:`DioClient` 已内置指数退避重试(只对网络层错误),开关见 dio_pro_demo。
|