开篇:接口字段改了,客户端却没人发现
后端把一个字段从 nickname 改名成 nick_name,REST 接口的响应里少了这个字段,Flutter 端直到用户反馈头像旁空白才发现。这类问题在字符串拼装的 REST 客户端里几乎无法避免:字段名只存在于字符串中,编译器完全帮不上忙。
GraphQL 与 gRPC 都能通过 schema 或 proto 文件在编译期生成类型安全的客户端代码,把"字段名写错"从运行时错误变成编译错误。代价是引入代码生成流程与构建复杂度。Flutter 侧的常见组合是:GraphQL 用 graphql_flutter 或 ferry,gRPC 用 grpc 加 protobuf,代码生成统一交给 build_runner。本文沿着选型、GraphQL 接入、代码生成、gRPC 接入、缓存与流式、性能踩坑这条链路展开。
一、API 客户端方案全景与选型
1.1 REST、GraphQL 与 gRPC 的取舍
| 维度 | REST | GraphQL | gRPC |
|---|---|---|---|
| 传输格式 | JSON | JSON | Protobuf 二进制 |
| 类型安全 | 靠手写模型 | 靠代码生成 | 靠代码生成 |
| 请求粒度 | 端点固定 | 客户端声明字段 | 方法固定 |
| 流式支持 | 需 SSE 或 WebSocket | Subscription | 原生四种流 |
| 浏览器友好 | 好 | 好 | 需 gRPC-Web |
| 典型场景 | 通用接口 | 聚合多源数据 | 内部服务、实时 |
1.2 依赖与代码生成
# pubspec.yaml
dependencies:
graphql_flutter: ^5.1.2
ferry: ^0.16.1
grpc: ^4.0.1
protobuf: ^3.1.0
fixnum: ^1.1.0
dev_dependencies:
build_runner: ^2.4.12
ferry_generator: ^0.12.0
protoc_plugin: ^21.1.2
1.3 选型的核心问题
- 后端是否已经提供 GraphQL 或 gRPC?没有的话,先评估改造成本。
- 是否需要实时流?需要则 gRPC 的流式或 GraphQL 的 Subscription 更自然。
- 团队能否接受代码生成进入构建流程?不能接受就退回手写 REST 客户端。
一句话总结:类型安全不是免费的,它的代价是 schema 与代码生成的流程约束,先确认团队愿意接受这份约束。
二、GraphQL 客户端接入
2.1 初始化客户端
final httpLink = HttpLink('https://api.example.com/graphql');
final authLink = AuthLink(
getToken: () async => 'Bearer $accessToken',
);
final link = authLink.concat(httpLink);
final client = GraphQLClient(
link: link,
cache: GraphQLCache(store: InMemoryStore()),
);
在 Widget 树中通过 GraphQLProvider 注入客户端,下游用 Query、Mutation、Subscription 三个 Widget 消费。
GraphQLProvider(
client: client,
child: const MyApp(),
)
2.2 查询与变更
const fetchUser = gql(r'''
query FetchUser($id: ID!) {
user(id: $id) {
id
nickname
avatarUrl
}
}
''');
Query(
options: QueryOptions(
document: fetchUser,
variables: const {'id': '1024'},
),
builder: (result, {fetchMore, refetch}) {
if (result.isLoading) return const CircularProgressIndicator();
if (result.hasException) return Text(result.exception.toString());
final user = result.data?['user'];
return Text(user?['nickname'] ?? '');
},
)
2.3 订阅
const onMessage = gql(r'''
subscription OnMessage($roomId: ID!) {
messageAdded(roomId: $roomId) {
id
content
}
}
''');
Subscription(
options: SubscriptionOptions(
document: onMessage,
variables: const {'roomId': 'r1'},
),
builder: (result) {
if (result.isLoading) return const Text('连接中');
return Text(result.data?['messageAdded']?['content'] ?? '');
},
)
2.4 手写字符串的隐患
上面的写法虽然能跑,但 result.data?['user']?['nickname'] 依然是字符串取值,类型安全没有真正落地。要获得编译期检查,必须引入代码生成。
一句话总结:
graphql_flutter解决了请求与缓存,但要真正类型安全,还得靠ferry这类带代码生成的客户端。
三、代码生成与类型安全
3.1 用 ferry 生成类型化客户端
ferry 从 .graphql 文件与 schema 生成 Dart 类,查询返回的是强类型对象而非 Map。
# lib/graphql/fetch_user.graphql
query FetchUser($id: ID!) {
user(id: $id) {
id
nickname
}
}
# build.yaml
targets:
$default:
builders:
ferry_generator|graphql_builder:
options:
schema: myapp|lib/graphql/schema.graphql
ferry_generator|serializer_builder:
options:
schema: myapp|lib/graphql/schema.graphql
3.2 运行代码生成
# 生成 GraphQL 客户端代码
dart run build_runner build --delete-conflicting-outputs
# 开发期监听文件变化持续生成
dart run build_runner watch --delete-conflicting-outputs
3.3 使用生成的客户端
final req = GFetchUserReq((b) => b..vars.id = '1024');
final response = await client.request(req).first;
final user = response.data?.user;
debugPrint(user?.nickname ?? '');
此时 user?.nickname 是编译期可检查的字段,后端改名会在重新生成后立刻报编译错误。
3.4 代码生成的工程约束
- 生成的
.g.dart文件要提交或统一在 CI 生成,团队内必须一致。 - schema 变更后必须重新生成,建议在 CI 中加一步校验生成结果是否有未提交差异。
- 生成的代码不要手工修改,任何改动都会在下次生成时被覆盖。
一句话总结:代码生成把接口契约变成了编译期约束,代价是 schema 与生成物必须纳入版本管理与 CI 校验。
四、gRPC 客户端接入
4.1 proto 定义与生成
// protos/user.proto
syntax = "proto3";
package user;
message UserRequest {
string id = 1;
}
message UserReply {
string id = 1;
string nickname = 2;
}
service UserService {
rpc GetUser(UserRequest) returns (UserReply);
rpc WatchUsers(UserRequest) returns (stream UserReply);
}
# 生成 Dart 代码
protoc --dart_out=grpc:lib/src/generated \
-Iprotos protos/user.proto
生成后会得到 user.pb.dart、user.pbgrpc.dart 等文件,包含消息类与客户端桩代码。
4.2 建立连接与调用
final channel = ClientChannel(
'api.example.com',
port: 443,
options: const ChannelOptions(credentials: ChannelCredentials.secure()),
);
final stub = UserServiceClient(channel);
final reply = await stub.getUser(UserRequest(id: '1024'));
debugPrint(reply.nickname);
await channel.shutdown(); // 用完后关闭连接
4.3 流式调用
gRPC 支持四种调用模式,Flutter 端最常用的是服务端流(实时推送)与双向流(聊天)。
final stream = stub.watchUsers(UserRequest(id: '1024'));
await for (final reply in stream) {
debugPrint('收到更新:${reply.nickname}');
}
| 模式 | 请求 | 响应 | 典型场景 |
|---|---|---|---|
| 一元 | 单个 | 单个 | 常规查询 |
| 服务端流 | 单个 | 多个 | 实时推送 |
| 客户端流 | 多个 | 单个 | 批量上传 |
| 双向流 | 多个 | 多个 | 聊天、协作 |
4.4 元数据与拦截器
认证令牌通过 CallOptions 的 metadata 传递,重试、日志、超时则通过拦截器统一处理。
final options = CallOptions(
metadata: {'authorization': 'Bearer $token'},
timeout: const Duration(seconds: 10),
);
final reply = await stub.getUser(UserRequest(id: '1024'), options: options);
一句话总结:gRPC 的类型安全来自 proto 生成,实时能力来自流式调用,两者结合是内部服务通信的最优解。
五、缓存、重试与流式
5.1 GraphQL 缓存策略
GraphQL 的缓存分三层:内存归一化缓存(按对象 ID 存储)、网络缓存(HTTP 层)、持久化缓存(落盘)。归一化缓存能让"改了用户昵称,所有引用该用户的界面同步更新"。
| 缓存层 | 作用范围 | 失效时机 |
|---|---|---|
| 内存归一化 | 单次会话 | 应用重启 |
| HTTP 缓存 | 单次会话 | 响应过期 |
| 持久化缓存 | 跨启动 | 手动清除 |
5.2 重试与退避
网络请求必须带重试,但要区分可重试与不可重试:连接超时、5xx 可重试;参数错误、401 不可重试。重试要使用指数退避并加抖动,避免雪崩。
Future<T> retry<T>(Future<T> Function() task, {int maxAttempts = 3}) async {
var attempt = 0;
while (true) {
try {
return await task();
} catch (e) {
attempt++;
if (attempt >= maxAttempts) rethrow;
final delay = Duration(milliseconds: 200 * (1 << attempt));
await Future.delayed(delay);
}
}
}
5.3 连接生命周期与断线重连
gRPC 长连接会因网络切换、后台挂起而断开。应用回到前台时应重建 channel,流式订阅要能自动重连并补齐断线期间的数据。
5.4 错误映射
把传输层错误统一映射成业务可处理的类型,避免 UI 层到处判断 GrpcError 或 OperationException。
一句话总结:缓存决定"数据是否新鲜",重试决定"网络抖动是否致命",两者都要有明确的失效与退避规则。
六、性能与踩坑清单
6.1 常见踩坑清单
- 每次请求都新建
GraphQLClient:缓存完全失效,应全局单例。 - 每次调用都新建 gRPC channel:连接无法复用,开销巨大。
- 忘记
channel.shutdown():连接泄漏,长时运行后耗尽资源。 - 生成的代码手工修改:下次生成被覆盖,行为诡异。
- schema 变更后未重新生成:编译期检查形同虚设。
- 重试不区分错误类型:401 无限重试,触发风控。
- 订阅未在页面销毁时取消:回调触发到已卸载的 Widget。
- protobuf 生成代码未提交:CI 环境缺少依赖导致构建失败。
6.2 调试手段
# 校验 proto 是否能正常编译
protoc --descriptor_set_out=/dev/null -Iprotos protos/user.proto
# 查看 GraphQL schema 与本地 schema 是否一致
dart run build_runner build --delete-conflicting-outputs
6.3 构建体积与启动开销
代码生成会引入 protobuf、grpc 等依赖,包体积与启动时的类加载都会增加。如果只用到少量接口,可评估是否值得引入完整 gRPC 栈,或改用 gRPC-Web 或轻量 HTTP 加手写模型的折中方案。
一句话总结:类型安全客户端的性能问题集中在"连接与客户端是否复用"以及"生成物是否纳入构建流程"两点上。
FAQ
常见问题:GraphQL 客户端该选 graphql_flutter 还是 ferry?
答:需要快速上手、查询简单时用 graphql_flutter;需要真正的类型安全、复杂缓存策略、代码生成时用 ferry。两者可以共存,但建议统一,避免缓存与状态管理互相打架。
常见问题:代码生成的文件要提交到版本库吗?
答:两种做法都可行,但必须团队统一。提交的好处是 CI 不需要额外生成步骤、新成员拉代码即可编译;不提交则要在 CI 中固定生成步骤并校验无差异。混合做法最容易出问题。
常见问题:gRPC 在 Flutter Web 上能用吗?
答:浏览器不支持原生 gRPC,需要用 gRPC-Web 加代理。移动端可以直接使用原生 gRPC。若同时要支持 Web 与移动端,需评估是否统一改用 gRPC-Web。
常见问题:为什么 GraphQL 改了数据界面没刷新?
答:多半是归一化缓存命中导致的。缓存按对象 ID 存储,若变更后没有更新对应对象,界面仍读旧值。可通过 refetch、手动写缓存或调整缓存的 update 策略解决。
常见问题:gRPC 长连接经常断开怎么办?
答:网络切换、应用进入后台、服务端空闲回收都会断开长连接。应在应用回到前台时重建 channel,并为流式订阅实现自动重连与断线补偿,不要假设连接永远在线。
常见问题:重试会不会导致重复下单之类的副作用?
答:会。对非幂等的写操作重试必须谨慎,正确做法是服务端提供幂等键,客户端重试时携带同一幂等键,由服务端保证只生效一次。
常见问题:如何保证客户端与后端的接口契约同步?
答:把 schema 或 proto 文件作为唯一契约来源,纳入版本管理,并在 CI 中校验客户端生成结果与契约一致。契约变更时先改 schema,再重新生成客户端,让编译错误暴露所有受影响的位置。
相关阅读
- Flutter 异步与网络 — HTTP 客户端、重试与错误处理的基础设施
- Flutter 状态管理 — 服务端数据与本地状态的协同管理
- Flutter 架构模式 — 数据层与领域层的分层组织方式
- Flutter 流与响应式编程 — 订阅与流式数据在 UI 层的消费方式
- Flutter 平台通道 — 需要原生网络栈时的桥接方案
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。