Flutter 基础体系 · 第 12/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 网络与数据层:HTTP、序列化、取消、缓存、分页和离线

Flutter 应用的数据层通常位于三个边界之间:

  1. 远程边界:HTTP 请求、状态码、超时、重试和服务端协议。
  2. 进程边界:JSON 文本与 Dart 对象之间的序列化,以及异步任务的生命周期。
  3. 本地边界:内存、文件、数据库和离线状态。

一个可维护的数据流通常不是“页面直接发请求”,而是:

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 通常不会因为 404500 自动抛出异常。它会返回一个 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:ioHttpClient,但 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();

这段代码的关键因果关系

  1. Uri 负责构造 URL,不应通过字符串拼接用户输入。直接拼接可能产生错误的转义结果。
  2. Accept 声明客户端希望得到 JSON;它不保证服务端一定返回 JSON。
  3. timeout 会让等待该 Future 的调用尽快失败,但不等价于取消底层网络操作。后文会详细解释。
  4. 2xx 只表示 HTTP 层成功。返回 JSON 后,还需要检查 JSON 结构和业务字段。
  5. 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、枚举、UriBigInt 或自定义类。日期通常是 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:httpClient.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);
}

这里有两个独立保护:

  1. _generation 防止旧请求晚返回后覆盖新结果;
  2. _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 缓存的定义和三个问题

缓存是对数据的副本进行复用,以减少:

  • 网络延迟;
  • 网络流量;
  • 服务端请求;
  • 离线时的不可用时间。

任何缓存策略都必须回答三个问题:

  1. 数据是什么时候产生的?
  2. 数据在什么条件下仍可接受?
  3. 数据如何被验证或失效?

因此缓存条目不应只有一个 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

设缓存写入时间为 tst_s,当前时间为 tnt_n,有效时长为 TT。缓存新鲜条件是:

tnts<Tt_n - t_s < T

过期并不一定意味着不可展示。可以定义三段状态:

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 仍是预期值

否则会出现:

  1. page 2 请求发出;
  2. 用户下拉刷新;
  3. page 1 请求返回并重置列表;
  4. 旧 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
  ↓
反序列化为当前模型

迁移必须满足:

  1. 旧数据可以识别;
  2. 迁移尽量幂等,重复执行不会破坏数据;
  3. 迁移失败有恢复策略;
  4. 数据库升级和应用代码版本相互兼容。

缓存可以采用更激进的策略:发现版本不兼容时直接删除并重新请求。用户创建的本地业务数据则不能简单删除,必须迁移或保留备份。


九、离线写入和同步队列

离线读通常比离线写简单。离线写需要解决“本地已经成功,但服务器尚未成功”的中间状态。

一个待同步操作可以有这样的状态:

pending
  ├── 上传成功 → synced
  ├── 临时失败 → retryable
  ├── 鉴权失败 → blocked
  ├── 业务冲突 → conflict
  └── 永久参数错误 → failed

队列表可以包含:

operationId
entityType
entityId
operation
payload
createdAt
attemptCount
lastError
status

同步时必须考虑:

  • 同一个实体的操作顺序;
  • 重试退避;
  • 幂等键;
  • 服务端版本号;
  • 冲突解决;
  • 应用被杀死后的恢复;
  • 任务执行期间用户再次修改本地数据。

重试退避

临时错误的指数退避可以写成:

dn=min(dmax,d0×2n)+jitterd_n = \min(d_{\max}, d_0 \times 2^n) + jitter

其中:

  • d0d_0 是初始延迟;
  • nn 是已经失败的次数;
  • dmaxd_{\max} 是最大延迟;
  • jitter 是随机扰动,用于避免大量客户端同时重试。

例如 d0=1d_0=1 秒、最大 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。