移动端是 GraphQL 收益最显著的战场:网络不稳定、流量昂贵、内存有限、屏显信息密度大。GraphQL 的精确取数天然契合移动场景——按需请求、避免过度获取、单次往返。但移动端也有独特挑战:离线可用、缓存一致性、弱网超时、包体积与构建效率。本文将深入 Apollo iOS 与 Apollo Kotlin 两大客户端,讨论规范化缓存、离线持久化、弱网优化与代码生成,帮助你构建真正「离线优先」的移动 App。
一、移动端为什么需要 GraphQL
1.1 移动网络的三个铁律
移动网络与浏览器有本质差异,三个铁律决定技术选型:
- 往返成本高:每次 HTTP 请求都要经过基站协商与无线信道调度,弱网下 RTT 可达秒级。
- 带宽与流量受限:用户按流量计费,过度获取不仅慢,还直接烧钱。
- 连接不稳定:地铁、电梯、隧道随时断网,应用必须优雅降级。
REST 接口的「整包返回」在移动端尤其吃亏——详情页明明只需要标题,却要下载整个对象。GraphQL 按字段取数,把流量精确到字段级。
1.2 精确取数与单次往返
query ProductDetail($sku: String!) {
product(sku: $sku) {
sku
title
price {
amount
currency
}
stock { available }
# 不请求:description 大字段留给「展开」时再取
}
}
在 4G 弱网下,这条查询可能从「三次 REST 请求、每包 200KB」压缩为「一次请求、40KB」,首屏时间与流量消耗同时下降。
二、Apollo iOS 客户端架构
2.1 Apollo iOS 的组成
Apollo iOS(2024 年起是 v1.x 重构版)由三个核心包组成:
| 包 | 职责 |
|---|---|
Apollo | 请求执行、Normalized Cache、网络传输 |
ApolloAPI | 类型安全查询 API 与选择集代码生成 |
ApolloSQLite | SQLite 持久化存储,支撑离线缓存 |
安装示例:
# Podfile
pod 'Apollo', '~> 1.7'
pod 'ApolloSQLite', '~> 1.7'
2.2 客户端初始化与请求执行
import Apollo
let client = ApolloClient(
url: URL(string: "https://api.example.com/graphql")!,
store: ApolloStore(cache: InMemoryNormalizedCache())
)
let query = GraphQLQuery(ProductDetailQuery(sku: "SKU-1"))
let cancellable = client.fetch(query: query) { result in
switch result {
case .success(let graphQLResult):
print(graphQLResult.data?.product?.title ?? "无数据")
case .failure(let error):
print("请求失败:\(error)")
}
}
Apollo iOS 默认启用规范化缓存:每个对象按 key(通常基于 id)单独存储,多个查询引用同一对象时只缓存一份。
2.3 WatchQuery 与响应式 UI
移动端 UI 需要「数据变化自动刷新」。Apollo iOS 提供 watch(query:),当缓存中相关对象变化时自动重新发布结果:
let watcher = client.watch(query: ProductDetailQuery(sku: "SKU-1"))
.sink { result in
// 缓存更新时,SwiftUI 视图自动刷新
viewModel.products = result.data?.product
}
配合 @Published 与 SwiftUI,可以实现一套「缓存即状态」的数据流,无需手写状态管理。
三、Apollo Kotlin(Android)客户端
3.1 Apollo Kotlin 架构
Apollo Kotlin(原 Apollo Android)同样围绕规范化缓存构建,并提供协程优先的 API:
// build.gradle.kts
dependencies {
implementation("com.apollographql.apollo:apollo-runtime:4.0.0")
implementation("com.apollographql.apollo:apollo-normalized-cache-sqlite:4.0.0")
}
3.2 协程风格的请求执行
val apolloClient = ApolloClient.Builder()
.serverUrl("https://api.example.com/graphql")
.normalizedCache(NormalizedCacheFactory(SqliteNormalizedCacheFactory("apollo.db")))
.build()
val response = apolloClient.query(ProductDetailQuery(sku = "SKU-1"))
.await() // 挂起函数
val title = response.data?.product?.title
Apollo Kotlin 的 await() 基于协程,天然适配 Jetpack Compose 的异步模型。它还支持 query(…) 与 watch(…) 两种形态,前者一次性获取,后者订阅缓存变化。
3.3 并发与取消
移动端页面切换频繁,请求必须可取消。Apollo Kotlin 的协程 API 在作用域销毁时自动取消网络请求:
viewModelScope.launch {
val response = withContext(Dispatchers.IO) {
apolloClient.query(query).await()
}
_uiState.value = response.data?.product
}
四、离线持久化:从缓存到离线优先
4.1 SQLite 持久化缓存
内存缓存无法跨进程存活。Apollo iOS 与 Kotlin 均提供 SQLite 后端,把规范化缓存落盘,App 冷启动后依然可读:
// iOS:SQLite 持久化
let sqliteCache = try SQLiteNormalizedCache(fileURL: cacheURL)
let client = ApolloClient(
networkTransport: RequestChainNetworkTransport(...),
store: ApolloStore(cache: sqliteCache)
)
// Android:SQLite 持久化
val sqliteCache = SqliteNormalizedCacheFactory("apollo.db")
val store = ApolloStore(cache = sqliteCache)
持久化缓存的直接收益:App 启动后、网络就绪前,界面就能渲染上次的数据,这是「离线优先」的第一步。
4.2 网络优先 vs 缓存优先
Apollo 提供 FetchPolicy 控制请求与缓存的顺序:
| FetchPolicy | 行为 | 适用场景 |
|---|---|---|
fetchIgnoringCacheData | 忽略缓存,强制网络 | 实时性要求高的数据 |
returnCacheDataElseFetch | 先缓存后网络 | 默认策略,弱网友好 |
returnCacheDataDontFetch | 只用缓存,不发请求 | 离线模式 |
cacheAndNetwork | 两者同时,缓存先渲染 | 体验与新鲜度兼顾 |
let policy: CachePolicy = .returnCacheDataElseFetch
移动端生产推荐 cacheAndNetwork:缓存立即渲染(秒开),网络数据返回后再静默更新,让「快」与「新」兼得。
4.3 离线写操作与队列
只读离线还不够,用户可能离线也想发评论、下单。生产实践通常分两层:
- 乐观更新(Optimistic Response):提交 mutation 时立即用临时数据更新 UI,服务端返回后再校正。
- 离线队列(Offline Queue):mutation 失败(网络原因)时写入本地队列,网络恢复后按序重放。
let optimisticData = CreateCommentMutation.Data(comment: Comment(...))
client.perform(mutation: CreateCommentMutation(text: "好文"),
optimisticResult: optimisticData) { result in
// 成功则落库,失败则入队等待重放
}
注意离线队列要解决「幂等与冲突」:为每次 mutation 生成客户端 clientMutationId,服务端据此去重;重放时若对象已变更,需要冲突检测或版本比对。
五、弱网优化
5.1 超时与重试
弱网下默认超时往往过长(默认 60s 都不罕见),用户感知是「卡死」。应细分超时策略:
// iOS:自定义 URLSession 配置
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15 // 请求超时
config.timeoutIntervalForResource = 30 // 资源整体超时
config.waitsForConnectivity = true // 等待网络就绪
let client = ApolloClient(
networkTransport: RequestChainNetworkTransport(
session: URLSession(configuration: config),
url: URL(string: "https://api.example.com/graphql")!
),
store: ...
)
重试策略建议「有限次 + 指数退避」:最多 2~3 次,间隔 1s/2s/4s,且只在 URLError.networkConnectionLost 或超时这类可重试错误上重试,业务错误(4xx/GraphQL errors)绝不重试。
5.2 请求优先级
弱网下带宽有限,应保证关键请求(首屏数据)先于非关键请求(点赞数、广告)。Apollo Kotlin 通过 Request 的 HTTPRequest 提供优先级,或借用 URLSession 的 URLSessionTask.priority:
let task = client.fetch(query: HomeFeedQuery(), cachePolicy: .returnCacheDataElseFetch)
task.priority = URLSessionTask.highPriority // 首屏
Android 侧可在自定义 NetworkTransport 中为请求打标签,交由 OkHttp 的队列与调度策略管理。
5.3 流量削减
- @include/@skip 指令:按用户设置条件性请求字段。
- 持久化查询(APQ):只传哈希,服务端执行注册查询,节省上行流量。
- 压缩:服务端与 CDN 开启 gzip/brotli;GraphQL 响应 JSON 通常能压缩 70% 以上。
- 字段裁剪:在 Schema 与客户端查询层面剔除不必要的大字段(日志、正文)。
六、代码生成与类型安全
6.1 schema 驱动代码生成
Apollo 客户端的杀手级特性是从 Schema + 查询文件生成类型安全代码,编译期就能发现字段不存在、类型不匹配等问题。
iOS 侧(apollo-tooling / Swift Package 插件)读取 schema.graphqls 与 *.graphql 查询文件,生成 ProductDetailQuery.swift 等类型。Android 侧同理,Gradle 插件生成 Kotlin 数据类:
// 生成的类型示例
data class ProductDetailQuery(
val sku: String
) : Query<ProductDetailQuery.Data>
data class Data(
val product: Product? = null
)
6.2 查询文件组织
app/
├── graphql/
│ ├── schema.graphqls # 服务端 Schema(由 CI 拉取)
│ └── queries/
│ ├── ProductDetail.graphql
│ └── HomeFeed.graphql
ProductDetail.graphql:
query ProductDetail($sku: String!) {
product(sku: $sku) {
sku
title
price { amount currency }
stock { available }
}
}
生成的 Swift/Kotlin 类型与查询严格对应,字段增删在编译期即暴露,这比手写 DTO 安全得多。
6.3 包体积与构建效率
移动端对二进制体积敏感:
- 按需引用生成的模型,避免把整个 Schema 的所有类型都编译进产物。
- 使用代码生成缓存,Schema 未变化时跳过生成步骤。
- 多模块拆分,按功能页拆 GraphQL 模块,减少单模块改动面。
七、常见坑与最佳实践
7.1 规范化缓存的坑
- 缺少 id:没有
id字段的对象无法规范化,会退化为列表整体缓存。Schema 设计时应为实体提供稳定的id,并开启keyFields自定义。 - 列表合并:分页列表会与旧缓存合并,需理解
merge行为,必要时自定义字段合并策略。 - 失效时机:mutation 成功后要更新相关缓存,否则 UI 显示过期数据。可在 mutation 后
refetchQueries或手动写缓存。
7.2 弱网体验清单
- 首屏走
cacheAndNetwork,秒开优先。 - 非关键请求低优先级,错峰发送。
- 失败请求有限重试 + 指数退避,且只重试可重试错误。
- 网络状态监听:断网时切换
returnCacheDataDontFetch,展示离线提示。
7.3 离线与一致性的平衡
离线写操作是「最终一致」的:乐观 UI 立即生效,服务端确认后落库,冲突时以服务端为准并提示用户。对账务类操作(支付、下单)建议离线只做「草稿」,真正提交必须在线,避免资金类数据不一致。
八、一句话总结
移动端 GraphQL 的核心是「精确取数 + 规范化缓存 + 离线优先」:Apollo iOS/Kotlin 提供 SQLite 持久化与 cacheAndNetwork 策略让秒开成为默认,配合弱网超时、请求优先级与代码生成,构建出真正适合 App 场景的数据访问层。
FAQ
Q1: 移动端应该用 GraphQL 还是 REST?
A: 移动端恰恰是 GraphQL 优势最明显的场景:按字段取数节省流量、单次往返降低弱网 RTT、类型安全代码生成减少联调成本。但如果团队已有成熟的 REST 网关与缓存体系,且接口极少被多端复用,也不必强上 GraphQL。判断标准是「取数精细度需求」与「团队维护能力」。
Q2: Apollo iOS 与 Apollo Kotlin 的缓存可以跨端共享吗?
A: 不能直接共享——它们是各自实现的规范化缓存(SQLite 结构不同)。跨端一致性靠服务端保证:同一套 Schema、同一套 mutation 语义,客户端各自维护缓存。需要跨端实时同步时,使用 GraphQL Subscription 而非缓存共享。
Q3: 离线队列的 mutation 重放如何保证不重复扣款?
A: 每个离线 mutation 生成客户端唯一 clientMutationId,服务端按该 ID 幂等去重——同一 ID 的重复提交只执行一次。重放时服务端返回「已处理」结果,客户端不重复计费。对资金类操作,建议离线仅存草稿,在线提交。
Q4: 规范化缓存没有 id 的字段如何处理?
A: 未提供 id 的对象无法按对象规范化,会被当作列表的一部分整体缓存,更新时容易整体失效。解决方案:Schema 为实体提供稳定 id;Apollo 支持自定义 keyFields(如 keyFields: ["slug"]);对无法规范化的数据,接受「列表整体刷新」并控制刷新频率。
Q5: 移动端 GraphQL 如何做流量压缩?
A: 三层:传输层开启 gzip/brotli(响应通常可压缩 70%+);协议层使用 APQ 持久化查询(上行只传哈希);应用层用 @include/@skip 与字段裁剪避免请求不需要的大字段。
相关阅读
- GraphQL 客户端状态管理与缓存策略
- GraphQL 持久化查询与生产安全
- GraphQL 文件上传与流式传输:multipart、分片与 @defer/@stream 增量交付
- API 缓存与性能优化
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。