移动端 GraphQL:Apollo iOS/Android、离线持久化与弱网优化

移动端 GraphQL 深度实践:Apollo iOS 与 Apollo Android/Kotlin 客户端、缓存规范化、离线持久化、弱网与请求优先级、代码生成与类型安全,帮助移动团队在 App 场景充分发挥 GraphQL 优势。

移动端是 GraphQL 收益最显著的战场:网络不稳定、流量昂贵、内存有限、屏显信息密度大。GraphQL 的精确取数天然契合移动场景——按需请求、避免过度获取、单次往返。但移动端也有独特挑战:离线可用、缓存一致性、弱网超时、包体积与构建效率。本文将深入 Apollo iOS 与 Apollo Kotlin 两大客户端,讨论规范化缓存、离线持久化、弱网优化与代码生成,帮助你构建真正「离线优先」的移动 App。

一、移动端为什么需要 GraphQL

1.1 移动网络的三个铁律

移动网络与浏览器有本质差异,三个铁律决定技术选型:

  1. 往返成本高:每次 HTTP 请求都要经过基站协商与无线信道调度,弱网下 RTT 可达秒级。
  2. 带宽与流量受限:用户按流量计费,过度获取不仅慢,还直接烧钱。
  3. 连接不稳定:地铁、电梯、隧道随时断网,应用必须优雅降级。

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 与选择集代码生成
ApolloSQLiteSQLite 持久化存储,支撑离线缓存

安装示例:

# 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 离线写操作与队列

只读离线还不够,用户可能离线也想发评论、下单。生产实践通常分两层:

  1. 乐观更新(Optimistic Response):提交 mutation 时立即用临时数据更新 UI,服务端返回后再校正。
  2. 离线队列(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 弱网体验清单

  1. 首屏走 cacheAndNetwork,秒开优先。
  2. 非关键请求低优先级,错峰发送。
  3. 失败请求有限重试 + 指数退避,且只重试可重试错误。
  4. 网络状态监听:断网时切换 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」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. GraphQL 限流与成本控制:查询成本分析、复杂度限制与按量计费
  3. GraphQL 边缘缓存与 CDN:POST 缓存、边缘执行与缓存键设计