Flutter 基础体系 · 第 12/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 网络与数据层:HTTP、序列化、取消、缓存、分页和离线
Flutter 应用的数据层通常位于三个边界之间:
- 远程边界:HTTP 请求、状态码、超时、重试和服务端协议。
- 进程边界:JSON 文本与 Dart 对象之间的序列化,以及异步任务的生命周期。
- 本地边界:内存、文件、数据库和离线状态。
一个可维护的数据流通常不是“页面直接发请求”,而是:
Widget
↓ 用户意图
Controller / ViewModel
↓
Repository
├── RemoteDataSource ── HTTP API
└── LocalDataSource ── 内存 / 文件 / SQLite / IndexedDB
Repository 的职责是把远程和本地数据组合成一个稳定的应用接口。页面不应该知道某个字段来自 JSON、缓存文件还是数据库,也不应该直接决定是否重试 HTTP 请求。
一、先定义数据层中的几个边界
1. HTTP 客户端不是 API 数据层
HTTP 客户端只负责传输:
- URL、请求方法、请求头和请求体;
- 响应状态码、响应头和响应体;
- 连接失败、超时和协议错误。
它不知道:
User如何从 JSON 构造;- 哪些状态码代表“未登录”;
- 哪些数据可以缓存;
- 第三页是否已经加载;
- 用户离线时是否应该展示旧数据。
因此下面两层应当分开:
HTTP response
↓ 检查状态码和响应头
JSON 文本
↓ jsonDecode
Map<String, dynamic> / List<dynamic>
↓ 显式映射
领域对象 User / Article / PageResult
2. 网络成功不等于业务成功
HTTP 层的成功通常表示请求得到了响应,不表示业务操作成功。
例如:
200 OK:服务端成功返回资源;201 Created:资源创建成功;204 No Content:操作成功但没有响应体;400 Bad Request:请求参数无效;401 Unauthorized:凭据缺失或无效;403 Forbidden:身份存在但没有权限;404 Not Found:资源不存在;409 Conflict:状态冲突;429 Too Many Requests:被限流;500–599:服务端或网关错误。
package:http 通常不会因为 404 或 500 自动抛出异常。它会返回一个 Response,由应用自己判断状态码。网络连接失败、DNS 失败、TLS 失败等情况才通常以异常形式出现。
3. 三类失败需要不同处理
请求失败
├── Transport failure:没有得到有效 HTTP 响应
│ ├── 无网络
│ ├── DNS 失败
│ ├── TLS 失败
│ └── 连接超时
├── HTTP failure:得到了响应,但状态码表示失败
│ ├── 401
│ ├── 404
│ └── 500
└── Decode / domain failure:响应存在,但格式或业务语义不符合预期
├── JSON 不是对象
├── 字段类型错误
└── 服务端返回了未知枚举值
如果把三类错误都转换成一个“请求失败”字符串,页面无法决定是否重试、是否跳转登录、是否展示缓存或是否报告协议错误。
二、HTTP:从请求生命周期到可运行实现
2.1 选择 HTTP 实现
Flutter 常见选择是 package:http:
dependencies:
flutter:
sdk: flutter
http: ^1.0.0
版本号应以项目实际依赖解析结果为准。package:http 提供跨平台的高层接口,Android、iOS、桌面和 Web 都可以使用,但底层实现和平台能力不同:
| 平台 | 常见底层差异 |
|---|---|
| Android / iOS / 桌面 | 通常基于 Dart IO 或平台适配实现,可使用 TCP/TLS 等能力 |
| Web | 受浏览器同源策略、CORS、浏览器缓存和 Fetch 能力限制 |
| 所有平台 | 具体代理、证书、Cookie、后台传输能力取决于实现和平台 |
如果需要连接池、代理、证书策略、底层 socket 或非常细致的超时控制,可以使用 dart:io 的 HttpClient,但 dart:io 不能在 Web 编译目标中使用。跨平台公共代码不应直接导入它。
2.2 一个完整的 HTTP 请求
下面的客户端实现了:
- 统一的基础 URL;
- JSON 请求头;
- 认证头;
- 状态码检查;
- 空响应处理;
- 网络异常转换;
- 可选超时。
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
sealed class ApiException implements Exception {
const ApiException(this.message);
final String message;
@override
String toString() => '$runtimeType: $message';
}
final class NetworkException extends ApiException {
const NetworkException(super.message, {this.cause});
final Object? cause;
}
final class HttpException extends ApiException {
const HttpException(
super.message, {
required this.statusCode,
this.body,
});
final int statusCode;
final String? body;
}
final class DecodeException extends ApiException {
const DecodeException(super.message, {this.cause});
final Object? cause;
}
final class ApiClient {
ApiClient({
http.Client? client,
required this.baseUri,
this.accessToken,
}) : _client = client ?? http.Client();
final http.Client _client;
final Uri baseUri;
final String? accessToken;
Future<dynamic> getJson(
String path, {
Map<String, String> queryParameters = const {},
Duration timeout = const Duration(seconds: 15),
}) async {
final uri = baseUri.replace(
path: '${baseUri.path}$path',
queryParameters: queryParameters.isEmpty ? null : queryParameters,
);
final headers = <String, String>{
'Accept': 'application/json',
if (accessToken != null)
'Authorization': 'Bearer $accessToken',
};
late http.Response response;
try {
response = await _client.get(uri, headers: headers).timeout(timeout);
} on TimeoutException catch (error) {
throw NetworkException('请求超时', cause: error);
} catch (error) {
throw NetworkException('网络请求失败', cause: error);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'HTTP 请求失败',
statusCode: response.statusCode,
body: response.body,
);
}
if (response.body.isEmpty) {
return null;
}
try {
return jsonDecode(response.body);
} on Object catch (error) {
throw DecodeException('响应不是合法 JSON', cause: error);
}
}
void close() {
_client.close();
}
}
调用示例:
final api = ApiClient(
baseUri: Uri.parse('https://api.example.com'),
);
final json = await api.getJson(
'/users',
queryParameters: {'page': '1'},
);
print(json);
api.close();
这段代码的关键因果关系
Uri负责构造 URL,不应通过字符串拼接用户输入。直接拼接可能产生错误的转义结果。Accept声明客户端希望得到 JSON;它不保证服务端一定返回 JSON。timeout会让等待该Future的调用尽快失败,但不等价于取消底层网络操作。后文会详细解释。2xx只表示 HTTP 层成功。返回 JSON 后,还需要检查 JSON 结构和业务字段。close()会关闭客户端。关闭后不要继续复用同一个http.Client。
http.Client 通常应由一个较长生命周期的 Repository 或数据层持有,而不是每次请求都创建并立即销毁。这样可以复用连接并统一配置。但如果必须单独取消某一个请求,又没有更细粒度的取消能力,可以为该请求创建独立客户端;代价是连接复用变差。
2.3 请求体、编码和幂等性
发送 JSON 时,必须同时设置内容类型并编码请求体:
final response = await client.post(
Uri.parse('https://api.example.com/articles'),
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json; charset=utf-8',
},
body: jsonEncode({
'title': '网络与数据层',
'published': true,
}),
);
常见错误是直接把 Map 传给 body,或者忘记 Content-Type。服务端可能把它当作表单、纯文本,最终得到 415 Unsupported Media Type 或错误字段。
重试前必须区分 HTTP 方法和业务语义:
GET通常是幂等的:重复执行通常不会改变资源;PUT设计上通常是幂等的;DELETE是否幂等取决于 API 语义;POST通常不是幂等的,重复创建可能得到两个订单或两条支付记录。
对于不能安全重复执行的操作,应使用服务端支持的幂等键,例如:
POST /payments
Idempotency-Key: 4f2b...
客户端随机生成幂等键还不够,服务端必须保存和解释它。客户端在 POST 超时后无法知道请求是否已经在服务端执行;直接重试可能重复扣款。
三、JSON 序列化:文本、动态值和领域对象
3.1 JSON 解码后的真实类型
jsonDecode 返回的是动态 JSON 值:
final value = jsonDecode('''
{
"id": 7,
"name": "Ada",
"tags": ["dart", "flutter"],
"active": true,
"profile": null
}
''');
其结构等价于:
Map<String, dynamic>
├── id -> int
├── name -> String
├── tags -> List<dynamic>
├── active -> bool
└── profile -> null
JSON 只有这些基本结构:
- 对象;
- 数组;
- 字符串;
- 数字;
- 布尔值;
null。
JSON 没有 Dart 的 DateTime、枚举、Uri、BigInt 或自定义类。日期通常是 ISO 8601 字符串,枚举通常是字符串或整数,需要显式转换。
3.2 显式序列化比到处使用 dynamic 更安全
class User {
const User({
required this.id,
required this.name,
required this.createdAt,
});
final int id;
final String name;
final DateTime createdAt;
factory User.fromJson(Object? value) {
if (value is! Map<String, dynamic>) {
throw const FormatException('User 必须是 JSON 对象');
}
final id = value['id'];
final name = value['name'];
final createdAt = value['createdAt'];
if (id is! int || name is! String || createdAt is! String) {
throw const FormatException('User 字段类型不正确');
}
final parsedDate = DateTime.tryParse(createdAt);
if (parsedDate == null) {
throw const FormatException('createdAt 不是合法日期');
}
return User(
id: id,
name: name,
createdAt: parsedDate,
);
}
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'createdAt': createdAt.toUtc().toIso8601String(),
};
}
使用:
final user = User.fromJson({
'id': 7,
'name': 'Ada',
'createdAt': '2024-01-01T12:00:00Z',
});
final encoded = jsonEncode(user.toJson());
print(encoded);
这里的完整转换链是:
JSON 字符串
→ jsonDecode
→ Object?
→ User.fromJson 校验字段
→ User
反向链路是:
User
→ toJson
→ Map<String, Object?>
→ jsonEncode
→ JSON 字符串
为什么要在边界处校验
如果直接这样写:
final name = json['name'] as String;
当服务端返回 null、数字或字段缺失时,会抛出类型转换异常。显式 is 检查可以把错误定位到具体模型和字段,而不是让错误在页面渲染阶段才出现。
字段是否可空必须表达业务语义:
final String? nickname;
表示“字段可以没有值”;它不应该被滥用来掩盖协议不稳定。必填字段缺失时,通常应报告协议错误,而不是静默替换成空字符串。
3.3 列表、分页对象和未知字段
服务端返回列表时,需要同时检查外层和每一项:
List<User> parseUsers(Object? value) {
if (value is! List) {
throw const FormatException('users 必须是数组');
}
return value.map(User.fromJson).toList(growable: false);
}
如果 API 的分页响应是:
{
"items": [
{
"id": 1,
"name": "Ada",
"createdAt": "2024-01-01T00:00:00Z"
}
],
"nextCursor": "abc"
}
可以定义:
class Page<T> {
const Page({
required this.items,
required this.nextCursor,
});
final List<T> items;
final String? nextCursor;
}
Page<User> parseUserPage(Object? value) {
if (value is! Map<String, dynamic>) {
throw const FormatException('分页响应必须是对象');
}
final items = value['items'];
final nextCursor = value['nextCursor'];
if (items is! List) {
throw const FormatException('items 必须是数组');
}
if (nextCursor != null && nextCursor is! String) {
throw const FormatException('nextCursor 必须是字符串或 null');
}
return Page(
items: items.map(User.fromJson).toList(growable: false),
nextCursor: nextCursor as String?,
);
}
未知字段通常可以忽略,这有利于服务端向前增加字段。但以下变化通常属于不兼容变化:
- 必填字段被删除;
- 字段类型改变;
- 枚举增加了客户端无法处理的关键状态;
- 分页游标语义改变。
对于枚举,安全做法是保留未知值:
enum ArticleStatus {
draft,
published,
unknown,
}
ArticleStatus parseArticleStatus(Object? value) {
return switch (value) {
'draft' => ArticleStatus.draft,
'published' => ArticleStatus.published,
_ => ArticleStatus.unknown,
};
}
如果未知状态会影响资金、权限或安全决策,则不能简单降级,应该中止并报告协议不兼容。
3.4 代码生成与手写序列化的边界
手写 fromJson 适合模型少、协议简单的项目;模型多时可使用代码生成工具,例如 json_serializable。代码生成减少重复代码,但不会替你决定:
- 日期格式;
- 缺省值;
- 版本迁移;
- 未知枚举的处理;
- 服务端字段是否满足业务不变量。
因此“生成了序列化代码”不等于“完成了协议设计”。生成代码仍应通过接口样例和异常响应测试。
大型 JSON 的解析是 CPU 工作。jsonDecode 本身是同步操作,响应很大时会阻塞 UI isolate。Flutter 的 compute 或自建 isolate 可以把解析移出 UI isolate:
import 'dart:convert';
import 'package:flutter/foundation.dart';
List<Map<String, dynamic>> decodeList(String text) {
final decoded = jsonDecode(text);
if (decoded is! List) {
throw const FormatException('根节点必须是数组');
}
return decoded
.map((item) => Map<String, dynamic>.from(item as Map))
.toList();
}
final rows = await compute(decodeList, response.body);
compute 适合把可发送的数据和顶层函数传给另一个 isolate。它不是网络取消机制,也不能让任意不可发送的对象跨 isolate 传递。小响应使用 isolate 反而有消息复制和调度成本,不能仅凭“异步”就认为必须使用 isolate。
四、取消:Future、HTTP 和过时结果不是同一个问题
4.1 Future 没有通用的取消协议
Dart 的 Future<T> 表示“将来产生一个结果”,但标准 Future 接口没有 cancel():
final future = loadUsers();
// future.cancel(); // 不存在
Future.timeout 也不是通用取消:
final result = await loadUsers().timeout(
const Duration(seconds: 3),
);
超时后,调用方不再等待这个 Future 的结果,但底层 loadUsers() 可能仍在执行。若它最终完成,网络、解析或副作用可能仍然发生。
这一区别非常重要:
停止等待结果 ≠ 停止底层工作
4.2 客户端关闭和单请求取消
package:http 的 Client.close() 是客户端级别的资源释放操作。它适合:
- 页面或 Repository 销毁;
- 应用退出某个数据层;
- 关闭一个专用客户端。
它不等价于所有实现上都提供的“只取消这一次请求”。如果用共享客户端调用 close(),其他请求也会受到影响。
一个简单的专用客户端取消方式是:
import 'package:http/http.dart' as http;
class CancellableRequest {
http.Client? _client;
Future<http.Response> get(Uri uri) {
final client = http.Client();
_client = client;
return client.get(uri).whenComplete(() {
if (identical(_client, client)) {
_client = null;
}
client.close();
});
}
void cancel() {
_client?.close();
_client = null;
}
}
这段代码表达的是“关闭这个请求使用的客户端”。但它有明显取舍:
- 每次请求独立创建客户端,连接复用较差;
- 关闭动作的底层中断时机取决于平台实现;
- Web 平台的 Fetch 请求受浏览器实现约束;
- 关闭后该 client 不能再次使用。
如果项目需要标准化的请求取消,应选择明确提供 cancellation token 或 abort controller 的 HTTP 库,并确认该能力在目标平台都实现,而不是仅根据某个平台的底层 API 推断跨平台行为。
4.3 UI 生命周期取消:防止过时结果覆盖新结果
搜索框是更常见的例子。用户依次输入:
d → da → dar → dart
请求可能按相反顺序返回。即使无法真正取消旧请求,也可以取消“旧结果对状态的写入资格”。
class SearchController {
SearchController(this.repository);
final SearchRepository repository;
int _generation = 0;
bool _disposed = false;
Future<void> search(String keyword) async {
final current = ++_generation;
try {
final result = await repository.search(keyword);
if (_disposed || current != _generation) {
return; // 结果已过时
}
// 只有最新请求可以更新 UI 状态。
print('展示 $keyword 的结果:${result.length} 条');
} catch (error) {
if (_disposed || current != _generation) {
return;
}
// 只处理当前请求的错误。
print('搜索失败:$error');
}
}
void dispose() {
_disposed = true;
++_generation;
}
}
abstract interface class SearchRepository {
Future<List<String>> search(String keyword);
}
这里有两个独立保护:
_generation防止旧请求晚返回后覆盖新结果;_disposed防止页面销毁后继续提交状态。
StatefulWidget 中还必须在异步返回后检查 mounted:
Future<void> load() async {
final data = await repository.load();
if (!mounted) {
return;
}
setState(() {
_data = data;
});
}
mounted 只解决 Widget 是否仍然存在,不解决多个请求之间的顺序竞争。因此它不能替代 generation token。
4.4 Isolate 任务也不自动可取消
网络请求和 isolate 计算都可能是长任务,但取消方式不同:
- 网络:关闭底层请求、客户端或平台 abort;
- isolate:停止发送结果,或在自己管理的 isolate 上调用
Isolate.kill; - 普通 Future:只能让调用方忽略结果,除非底层 API自行提供取消。
不要把“await 后不再使用结果”称作任务已经取消。这个表述会掩盖资源泄漏、无效流量和副作用重复的问题。
五、缓存:不仅是“把结果存起来”
5.1 缓存的定义和三个问题
缓存是对数据的副本进行复用,以减少:
- 网络延迟;
- 网络流量;
- 服务端请求;
- 离线时的不可用时间。
任何缓存策略都必须回答三个问题:
- 数据是什么时候产生的?
- 数据在什么条件下仍可接受?
- 数据如何被验证或失效?
因此缓存条目不应只有一个 JSON 字符串,还应记录元数据:
class CacheEntry {
const CacheEntry({
required this.body,
required this.savedAt,
this.etag,
});
final String body;
final DateTime savedAt;
final String? etag;
}
5.2 内存缓存、持久化缓存和 HTTP 缓存
内存缓存
特点:
- 速度快;
- 进程重启后消失;
- 占用 Dart heap;
- 适合当前页面或当前会话的数据。
典型结构:
final Map<Uri, CacheEntry> memoryCache = {};
内存缓存必须考虑容量,否则图片、列表和详情不断累积会导致内存压力。常见淘汰策略是 LRU,但 Flutter 的具体缓存行为还可能由图片缓存等组件单独管理。
持久化缓存
可以使用:
- Preferences:少量键值状态;
- 文件:JSON、文本、二进制;
- SQLite:查询、事务、关系和增量更新;
- 加密存储:凭据或敏感小数据。
缓存不是永久数据库。缓存损坏、版本不兼容和清理都是正常路径,读取失败时通常应删除损坏条目并回源,而不是让应用永久不可用。
HTTP 缓存
HTTP 协议有标准缓存语义,例如:
Cache-Control: max-age=60
ETag: "v123"
Last-Modified: Wed, 01 Jan 2025 00:00:00 GMT
Vary: Accept-Language
客户端在验证缓存时可以发送:
If-None-Match: "v123"
如果资源未改变,服务端返回:
304 Not Modified
304 没有新的资源体,客户端应继续使用本地缓存正文,同时更新缓存元数据。ETag 比单纯的时间戳更可靠,因为资源可能在相同秒内变化,或者服务器时钟与客户端不一致。
Vary 也不能忽略。例如响应依赖 Accept-Language,却用 URL 作为唯一缓存键,会把中文响应错误地提供给英文用户。
5.3 TTL、stale-while-revalidate 和 stale-if-error
设缓存写入时间为 ,当前时间为 ,有效时长为 。缓存新鲜条件是:
过期并不一定意味着不可展示。可以定义三段状态:
0 ───────── T ───────── T + S
fresh stale-but-usable
fresh:直接返回缓存;stale-but-usable:先展示缓存,同时后台刷新;- 超过
T + S:只能在没有网络时作为最后退路,或直接视为不可用。
stale-while-revalidate 的数据流:
sequenceDiagram
participant UI
participant Repo
participant Cache
participant API
UI->>Repo: 请求列表
Repo->>Cache: 读取条目
Cache-->>Repo: 旧数据
Repo-->>UI: 立即展示旧数据
Repo->>API: 后台请求 / If-None-Match
API-->>Repo: 200 新数据或 304
Repo->>Cache: 更新正文和元数据
Repo-->>UI: 发布刷新结果
这种策略的关键不是“永远先读缓存”,而是明确 UI 状态:
显示旧数据 + refreshing
显示新数据 + not refreshing
无缓存 + loading
有缓存但刷新失败 + stale/error
无缓存且刷新失败 + error
如果刷新失败但缓存尚未过期,通常可以继续展示缓存;如果缓存已经非常陈旧,应同时告诉用户数据可能不是最新的。错误状态不能简单覆盖掉已有可用数据。
5.4 一个可运行的内存缓存示例
class MemoryCache<T> {
MemoryCache(this.ttl);
final Duration ttl;
final Map<String, _Entry<T>> _entries = {};
T? readFresh(String key, {DateTime? now}) {
final entry = _entries[key];
if (entry == null) return null;
final current = now ?? DateTime.now();
final age = current.difference(entry.savedAt);
if (age >= ttl) {
return null;
}
return entry.value;
}
T? readStale(String key) => _entries[key]?.value;
void write(String key, T value, {DateTime? now}) {
_entries[key] = _Entry(
value: value,
savedAt: now ?? DateTime.now(),
);
}
void remove(String key) {
_entries.remove(key);
}
}
class _Entry<T> {
const _Entry({
required this.value,
required this.savedAt,
});
final T value;
final DateTime savedAt;
}
测试时注入 now 很重要。否则依赖真实时钟的测试会随时间变化,难以稳定验证边界:
final cache = MemoryCache<String>(
const Duration(minutes: 5),
);
final base = DateTime.utc(2025, 1, 1);
cache.write('a', 'old', now: base);
assert(cache.readFresh('a', now: base.add(const Duration(minutes: 4))) == 'old');
assert(cache.readFresh('a', now: base.add(const Duration(minutes: 5))) == null);
边界使用 >= 表示恰好到达 TTL 时已经过期。这个选择应在实现中固定,并在测试中明确。
六、分页:数据顺序、边界和并发
6.1 Offset 分页和 Cursor 分页
Offset 分页
请求形式:
GET /articles?page=3&pageSize=20
服务端根据偏移量取数据。优点是容易跳页,缺点是数据在翻页期间插入或删除时,页边界会漂移:
第一次请求 page=1:A B C
期间插入 X
第二次请求 page=2:C D E
C 重复出现。
Cursor 分页
请求形式:
GET /articles?limit=20
GET /articles?limit=20&after=eyJpZCI6MjB9
服务端返回:
{
"items": [...],
"nextCursor": "eyJpZCI6NDB9"
}
游标代表服务端定义的稳定位置,不应由客户端把它当作简单页码或自行解析。Cursor 对不断变化的时间线通常更稳定,但不适合任意跳到第 100 页,且游标可能过期或绑定筛选条件。
6.2 分页状态机
一个列表至少需要这些状态:
初始:
items = []
isInitialLoading = false
isLoadingNext = false
nextCursor = null
hasMore = true
error = null
刷新:
isInitialLoading = true
使用新查询条件
丢弃旧分页游标
加载下一页:
isLoadingNext = true
保留已有 items
使用当前 nextCursor
成功:
items = 已有 items + 新 items
nextCursor = 服务端 nextCursor
hasMore = nextCursor != null
isLoadingNext = false
失败:
保留已有 items
记录 nextPageError
isLoadingNext = false
刷新时不能继续使用旧查询条件的游标。例如搜索词从 flutter 变为 dart,旧游标只对 flutter 的结果集有意义。
6.3 防止重复加载和分页竞态
class ArticlePager {
ArticlePager(this.repository);
final ArticleRepository repository;
final List<Article> items = [];
String? _nextCursor;
bool _loading = false;
bool _hasMore = true;
Future<void> refresh() async {
if (_loading) return;
_loading = true;
try {
final page = await repository.fetchArticles(cursor: null);
items
..clear()
..addAll(page.items);
_nextCursor = page.nextCursor;
_hasMore = page.nextCursor != null;
} finally {
_loading = false;
}
}
Future<void> loadNext() async {
if (_loading || !_hasMore) return;
final cursor = _nextCursor;
_loading = true;
try {
final page = await repository.fetchArticles(cursor: cursor);
// 只有本次请求仍对应当前游标时才追加。
if (cursor != _nextCursor) {
return;
}
items.addAll(page.items);
_nextCursor = page.nextCursor;
_hasMore = page.nextCursor != null;
} finally {
_loading = false;
}
}
}
abstract interface class ArticleRepository {
Future<Page<Article>> fetchArticles({required String? cursor});
}
class Article {
const Article(this.id);
final int id;
}
实际项目中,refresh() 和 loadNext() 可能需要不同的锁,因为“刷新”和“加载下一页”不一定应该互相阻塞。更可靠的实现是为查询条件和请求代次建立快照:
请求开始时记录:
query = 当前搜索词
generation = 当前刷新代次
cursor = 当前游标
响应返回时检查:
generation 仍相同
query 仍相同
cursor 仍是预期值
否则会出现:
- page 2 请求发出;
- 用户下拉刷新;
- page 1 请求返回并重置列表;
- 旧 page 2 返回后追加到新列表。
这不是网络错误,而是状态一致性错误。
6.4 分页去重和排序
即使 API 声称分页稳定,客户端仍可能收到重复项,原因包括:
- 服务端数据在分页期间变化;
- 网络重试导致同一页被执行两次;
- 游标失效后服务端回退;
- 多个请求竞态。
如果资源有稳定主键,可去重:
void appendUnique<T>(
List<T> target,
Iterable<T> incoming,
Object Function(T item) keyOf,
) {
final keys = target.map(keyOf).toSet();
for (final item in incoming) {
if (keys.add(keyOf(item))) {
target.add(item);
}
}
}
去重不能替代正确分页。若相同 ID 的对象内容更新了,仅仅跳过新对象会保留旧版本;此时应按 ID 建立索引并替换已有对象,同时根据服务端排序规则重新组织列表。
七、离线:不是“请求失败后读一次本地”
7.1 离线优先的数据流
离线能力至少要定义以下策略:
读操作:
1. 读取本地快照
2. 先向 UI 展示本地数据
3. 尝试远程刷新
4. 成功则写回本地并发布新数据
5. 失败则保留本地数据并标记刷新失败
写操作:
1. 本地立即更新,或进入待同步队列
2. 网络可用时上传
3. 成功标记完成
4. 冲突或永久错误进入人工处理路径
因此离线不只是“判断网络连接”。网络连接状态只能说明某种网络接口是否可用,不能保证 DNS、TLS、服务端和鉴权都成功。真正的数据层应以请求结果作为最终事实。
7.2 Repository 的 stale-while-revalidate 实现
下面是一个简化的 Repository。它使用内存缓存演示流程,生产环境可以把 CacheStore 替换为文件或 SQLite。
class ArticleRepositoryImpl implements ArticleRepository {
ArticleRepositoryImpl({
required this.api,
required this.cache,
});
final ApiClient api;
final MemoryCache<List<Article>> cache;
@override
Future<List<Article>> fetchFirstPage({
bool allowStale = true,
}) async {
const key = 'articles:first-page';
final fresh = cache.readFresh(key);
if (fresh != null) {
return fresh;
}
final stale = allowStale ? cache.readStale(key) : null;
try {
final json = await api.getJson('/articles');
final page = parseArticlePage(json);
cache.write(key, page.items);
return page.items;
} on Object {
if (stale != null) {
return stale;
}
rethrow;
}
}
}
Page<Article> parseArticlePage(Object? value) {
if (value is! Map<String, dynamic>) {
throw const FormatException('分页响应必须是对象');
}
final rawItems = value['items'];
final rawCursor = value['nextCursor'];
if (rawItems is! List) {
throw const FormatException('items 必须是数组');
}
if (rawCursor != null && rawCursor is! String) {
throw const FormatException('nextCursor 必须是字符串或 null');
}
final articles = rawItems.map((item) {
if (item is! Map<String, dynamic>) {
throw const FormatException('文章项必须是对象');
}
final id = item['id'];
final title = item['title'];
if (id is! int || title is! String) {
throw const FormatException('文章字段类型错误');
}
return ArticleModel(id: id, title: title);
}).toList(growable: false);
return Page(
items: articles,
nextCursor: rawCursor as String?,
);
}
class ArticleModel extends Article {
const ArticleModel({
required super.id,
required this.title,
});
final String title;
}
这个实现有一个重要限制:如果缓存存在但已过期,它是在等待远程请求结束后才返回旧数据。真正的 stale-while-revalidate 通常需要把“立即发布缓存”和“后台刷新”拆成两个事件,适合使用 Stream、状态通知器或显式的 AsyncValue 状态,而不是只返回一个 Future。
7.3 用 Stream 表示持续的数据状态
Future 适合一次结果;Stream 适合多个时刻的状态变化:
sealed class LoadState<T> {
const LoadState();
}
class Loading<T> extends LoadState<T> {
const Loading();
}
class Data<T> extends LoadState<T> {
const Data(this.value, {this.isRefreshing = false});
final T value;
final bool isRefreshing;
}
class Failed<T> extends LoadState<T> {
const Failed(this.error, {this.previous});
final Object error;
final T? previous;
}
一个典型流程可以发布:
Loading
Data(oldItems, isRefreshing: true)
Data(newItems, isRefreshing: false)
或者:
Loading
Data(oldItems, isRefreshing: true)
Failed(networkError, previous: oldItems)
第二种状态比直接发布 Failed 更准确,因为用户仍然有可用数据。Flutter UI 可以同时显示列表、刷新指示器和“离线或刷新失败”提示。
八、本地存储:缓存、业务数据和敏感数据要分开
本地存储的选择不应只依据“哪个 API 最简单”。
| 数据 | 适合存储 |
|---|---|
| 主题、首次启动标记、小型设置 | Preferences |
| 一份完整 JSON 快照、导出文件 | 文件 |
| 可查询列表、关系、事务、分页数据 | SQLite |
| Token、密钥等敏感小数据 | 平台安全存储或加密存储 |
| 图片和大对象 | 文件系统,并在数据库中保存路径和元数据 |
平台差异
dart:io的文件和 socket API 不可直接用于 Web。- 移动端和桌面端可以使用 SQLite;Web 需要使用 IndexedDB 或支持 Web 的数据库方案。
- 浏览器的本地存储容量、清理策略和隐私模式行为由浏览器控制。
- 移动平台文件路径、应用沙盒和备份策略由平台决定。
- Preferences 适合小型键值,不适合存储不断增长的分页列表或需要事务一致性的业务数据。
缓存版本和迁移
持久化数据必须有版本:
{
"schemaVersion": 2,
"items": [...]
}
读取时:
版本 1
↓ migration 1 → 2
版本 2
↓
反序列化为当前模型
迁移必须满足:
- 旧数据可以识别;
- 迁移尽量幂等,重复执行不会破坏数据;
- 迁移失败有恢复策略;
- 数据库升级和应用代码版本相互兼容。
缓存可以采用更激进的策略:发现版本不兼容时直接删除并重新请求。用户创建的本地业务数据则不能简单删除,必须迁移或保留备份。
九、离线写入和同步队列
离线读通常比离线写简单。离线写需要解决“本地已经成功,但服务器尚未成功”的中间状态。
一个待同步操作可以有这样的状态:
pending
├── 上传成功 → synced
├── 临时失败 → retryable
├── 鉴权失败 → blocked
├── 业务冲突 → conflict
└── 永久参数错误 → failed
队列表可以包含:
operationId
entityType
entityId
operation
payload
createdAt
attemptCount
lastError
status
同步时必须考虑:
- 同一个实体的操作顺序;
- 重试退避;
- 幂等键;
- 服务端版本号;
- 冲突解决;
- 应用被杀死后的恢复;
- 任务执行期间用户再次修改本地数据。
重试退避
临时错误的指数退避可以写成:
其中:
- 是初始延迟;
- 是已经失败的次数;
- 是最大延迟;
jitter是随机扰动,用于避免大量客户端同时重试。
例如 秒、最大 30 秒时:
第 0 次:约 1 秒
第 1 次:约 2 秒
第 2 次:约 4 秒
第 3 次:约 8 秒
第 4 次:约 16 秒
第 5 次:约 30 秒
不应对所有失败都重试:
- 408、429、部分 5xx 可能适合重试;
- 401 应刷新凭据或重新登录;
- 403 通常不是重试能解决的;
- 400 参数错误不应重复发送;
- 非幂等写操作必须先确认幂等语义。
后台同步还受到 Android、iOS 和桌面系统调度限制。应用退到后台后,不能假设 Dart isolate 会无限期运行;需要使用平台提供的后台任务能力,并接受执行时机不确定。
十、认证、错误恢复和响应校验
10.1 401 刷新 Token 的并发问题
多个请求同时收到 401 时,不能让每个请求都执行一次刷新:
请求 A → 401 → 刷新
请求 B → 401 → 刷新
请求 C → 401 → 刷新
正确的协调方式是共享一个正在进行的刷新 Future:
class TokenRefresher {
Future<String>? _refreshing;
Future<String> refresh() {
return _refreshing ??= _doRefresh().whenComplete(() {
_refreshing = null;
});
}
Future<String> _doRefresh() async {
// 调用 refresh-token 接口并返回新 access token。
return 'new-token';
}
}
所有并发请求等待同一个 _refreshing。刷新失败后,原请求不能无限重放,应清理凭据并进入登录或鉴权失败状态。
重放请求还要检查它是否幂等。自动重放一个 GET 通常可接受;自动重放支付 POST 必须依赖幂等键和服务端保证。
10.2 错误对象应保留诊断信息
页面展示可以使用用户友好的消息,但底层错误应保留:
- 请求方法;
- URL;
- 状态码;
- request ID;
- 服务端错误码;
- 原始异常和堆栈;
- 是否使用了缓存;
- 是否已经重试。
日志不能记录:
- Authorization 头;
- access token;
- 密码;
- 完整的敏感请求体;
- 个人隐私数据。
生产诊断需要区分:
用户可见消息:网络连接失败,请稍后重试
内部原因:SocketException / status=503 / requestId=...
十一、Web、移动端、桌面端的差异
11.1 Web 的 CORS 和同源策略
Flutter Web 发出的请求由浏览器执行。若前端源是:
https://app.example.com
而 API 是:
https://api.example.com
这仍可能属于不同 origin,因为 origin 由协议、主机和端口共同决定。服务端必须通过 CORS 响应头允许该来源。
跨域请求可能先发送预检 OPTIONS,尤其在使用自定义头或非简单方法时。服务端若没有正确响应:
Access-Control-Allow-Origin
Access-Control-Allow-Headers
Access-Control-Allow-Methods
浏览器会阻止页面读取响应,即使服务器实际上处理了请求。
Flutter Web 不能因为移动端可以访问某 API,就推断浏览器也可以访问。dart:io 方案、任意 TCP 连接、部分证书控制和本地文件能力也不能直接移植到 Web。
11.2 Android 和 iOS 的网络配置
移动平台还涉及:
- Android Manifest 的网络权限;
- Android 的明文 HTTP 限制;
- iOS App Transport Security;
- TLS 证书链;
- 后台执行和系统挂起;
- 代理、VPN 和系统网络切换。
开发阶段临时允许明文 HTTP 可能帮助调试,但生产环境应使用 HTTPS。关闭证书校验或接受任意证书会使中间人攻击成为现实,不应作为“解决 TLS 错误”的常规方案。
11.3 桌面端
桌面应用通常比移动端拥有更宽松的文件和网络环境,但仍受操作系统权限、代理、证书和发行包配置影响。桌面端不能假设用户一定在线,也不能把访问本地路径的能力当作 Web 兼容能力。
十二、一个端到端的请求到 UI 流程
以“文章列表首次加载”为例:
页面创建
↓
Controller.load()
↓
Repository 读取本地缓存
├── 有新鲜缓存 → 发布 Data
└── 无新鲜缓存
↓
发布 Loading 或旧 Data + refreshing
↓
ApiClient 发 GET
├── 网络失败 → 旧缓存可用则保留,否则 Failed
├── 非 2xx → 根据状态码分类
├── JSON 无效 → Decode error
└── 解析成功 → 写本地缓存 → 发布 Data
可以用 Mermaid 表示主要成功路径:
flowchart TD
A[Widget / Controller] --> B[Repository]
B --> C{读取本地缓存}
C -->|新鲜| D[发布缓存数据]
C -->|没有或过期| E[发起 HTTP GET]
C -->|旧缓存| F[先发布旧数据和 refreshing]
F --> E
E --> G{响应状态}
G -->|网络异常| H{存在可用旧数据?}
H -->|是| I[保留旧数据并发布刷新错误]
H -->|否| J[发布错误]
G -->|非 2xx| J
G -->|2xx| K[JSON 解码和模型校验]
K -->|失败| J
K -->|成功| L[写入本地缓存]
L --> M[发布新数据]
关键点是:Repository 不是简单的“HTTP 包装器”,而是数据来源、缓存策略、错误分类和状态发布的组合边界。
十三、常见错误和诊断方法
1. 把 Future.timeout 当作取消
表现:页面显示超时,但服务端日志显示请求后来仍被处理;快速搜索产生大量无效请求。
诊断:
- 查看服务端请求日志时间;
- 为请求添加 request ID;
- 检查超时后底层客户端是否关闭;
- 检查是否使用了 generation token 防止过时结果写入。
修复:需要真正中断时使用底层支持的取消机制;不具备取消时至少丢弃过时结果并限制并发。
2. 只检查 statusCode == 200
表现:创建接口返回 201 却被当成失败,删除接口返回 204 时尝试解析空 JSON。
修复:按 2xx 范围判断,并单独处理无正文响应。
3. 直接把 jsonDecode 结果传给 UI
表现:页面到处出现 json['x'] as String,协议变化后异常定位困难。
修复:在数据边界构造强类型模型,集中处理日期、空值、枚举和字段错误。
4. 分页请求互相覆盖
表现:列表跳回第一页、重复项、第二页混入新搜索条件。
诊断:记录每个请求的 query、cursor、generation 和开始/结束时间。
修复:使用加载锁、游标快照、查询代次和按 ID 去重。
5. 缓存永久掩盖服务端错误
表现:用户一直看到旧数据,以为系统正常。
修复:缓存条目保存时间和版本;展示旧数据时明确刷新状态;超过最大陈旧时间后不要无条件当作最新数据。
6. 把网络可用性广播当成请求事实
表现:系统显示“在线”,但 DNS、认证或 API 网关仍失败。
修复:网络状态只用于提示或触发尝试,数据层必须根据实际请求结果更新状态。
7. 用 Preferences 存大列表
表现:读写变慢、数据损坏后整个列表不可用、无法查询单条记录。
修复:将结构化、增长性数据放入 SQLite 或适合平台的数据库;Preferences 只保存小型设置和标志。
十四、测试数据层时应验证什么
数据层测试不应只覆盖“200 返回正确对象”,还应覆盖完整故障路径:
HTTP 测试
- 2xx;
- 204 空正文;
- 400、401、403、404、409、429、500;
- 网络异常;
- 超时;
- 非 JSON 正文;
- 缺失字段和错误字段类型。
序列化测试
void main() {
test('User 可以往返序列化', () {
final input = <String, dynamic>{
'id': 1,
'name': 'Ada',
'createdAt': '2025-01-01T00:00:00Z',
};
final user = User.fromJson(input);
final output = user.toJson();
expect(output['id'], 1);
expect(output['name'], 'Ada');
});
test('缺失必填字段时报错', () {
expect(
() => User.fromJson({
'id': 1,
'createdAt': '2025-01-01T00:00:00Z',
}),
throwsFormatException,
);
});
}
并发测试
构造两个可控的 Future:
请求 A 先发出
请求 B 后发出
让 B 先完成
让 A 后完成
验证最终状态仍然是 B
这能验证 generation token,而不是依赖真实网络的偶然顺序。
缓存测试
至少验证:
写入时刻
TTL 前读取成功
恰好 TTL 时过期
读取旧缓存并刷新失败
缓存损坏后删除并回源
版本不兼容后的迁移或清理
离线测试
模拟:
本地有旧数据 + 网络失败
本地无数据 + 网络失败
本地有数据 + 远程返回新数据
离线写入 + 恢复网络 + 同步成功
同步冲突
重复同步同一 operation
只有把这些路径显式测试,离线和缓存才不是“网络失败时碰巧能工作”。
十五、最终的数据层边界
一个完整的数据层至少应明确以下契约:
HTTP 层:
请求如何构造?
哪些状态码如何分类?
超时是否只停止等待,还是能取消底层任务?
序列化层:
JSON 的根类型是什么?
必填字段、可空字段和未知枚举如何处理?
日期、金额和精度如何转换?
并发层:
页面销毁后谁阻止状态写入?
新请求如何淘汰旧请求?
分页和刷新如何互斥或协调?
缓存层:
缓存键包含哪些查询条件?
何时 fresh、何时 stale?
如何使用 ETag 或版本号验证?
数据损坏和 schema 升级如何恢复?
分页层:
使用 offset 还是 cursor?
next page 的结束条件是什么?
重复项和数据漂移如何处理?
离线层:
读请求是否允许旧数据?
写请求是否进入同步队列?
重试是否幂等?
冲突由谁解决?
当这些问题没有被定义时,代码即使能完成一次请求,也还不能称为稳定的数据层。HTTP 负责传输,序列化负责协议边界,取消负责任务生命周期,缓存负责时间与一致性,分页负责集合边界,离线负责在远程不可用时维持可解释的数据状态;它们必须在同一个状态模型中协作,而不能分别堆叠在页面代码里。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 状态管理:InheritedWidget、Provider、Riverpod、BLoC 和边界
- 下一篇:Flutter 本地存储:Preferences、文件、SQLite、加密和迁移
- 延伸:Dart 异步与并发:Future、Stream、Event Loop、Isolate 和取消
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论