Apollo Kotlin 的 GraphQL 客户端,缓存策略配置
Apollo Kotlin 的 GraphQL 客户端,缓存策略配置
线上环境出现过这样一种情况:用户在个人资料页修改了昵称,返回列表页后,下拉刷新,列表里旧昵称闪了一下,紧接着变成了新昵称。这个“闪一下”的过程在弱网环境下尤为明显,甚至偶尔会出现新昵称刷新完成后,再次滑动列表又回退到旧昵称。排查到最后,问题不在后端,也不在 UI 层的 Diff 计算,而是出在前端 Apollo Kotlin 的 Normalized Cache 策略配置上。Apollo Kotlin 作为 Android 生态里 GraphQL 客户端的事实标准,它的缓存系统远比“把 JSON 存进 SQLite”复杂得多,配置不当带来的隐患往往不是崩溃,而是这种难以复现的数据一致性问题。
从 OkHttp Cache 到 Normalized Cache 的跨越
很多 Android 项目早期接入 GraphQL 时,会本能地复用现有的 HTTP 缓存层。既然底层走的还是 HTTP,那直接用 OkHttp 的 CacheControl 似乎就能解决问题。这种做法在只读场景下确实能跑通,但一旦涉及数据更新,OkHttp 缓存完全无法处理 GraphQL 的语义。GraphQL 的请求体是 POST,OkHttp 默认不对 POST 响应做磁盘缓存;即便强行配置,同一个逻辑对象在列表查询和详情查询里分别属于不同的 HTTP 响应,OkHttp 会把它们当成两滩互不相识的 JSON 字符串。
Apollo Kotlin 提供的 Normalized Cache 解决的是对象级缓存。它会把 GraphQL 响应解析成对象图,根据类型和主键拆分成独立的记录(Record),存放在内存和持久化存储中。列表查询返回的 User 对象和详情查询返回的 User 对象,只要主键一致,在缓存层就是同一条记录。这意味着更新其中一处,另一处会自动感知变化。Apollo Kotlin 目前最新稳定版本是 4.x 系列,GitHub 仓库位于 apollographql/apollo-kotlin,采用 MIT 协议开源,不收取授权费用。这个库在 Maven Central 上发布,接入成本仅限于 Gradle 依赖和代码生成配置,没有商业版功能限制。
Normalized Cache 分为两级:MemoryCache 和 SqlNormalizedCache。MemoryCache 基于 LRU 策略,默认把最近使用的对象保留在内存中;SqlNormalizedCache 则依赖 AndroidX 的 SQLite 实现,把记录持久化到磁盘。实际配置时,通常会通过 ChainedCache 把两者串起来,读的时候先读内存,写的时候先写内存再写数据库。这个设计听起来直观,但第一个坑往往就出在这里——如果两级缓存的淘汰策略不一致,或者写入顺序没处理好,就会出现内存和磁盘数据不同步,导致前面提到的“回退到旧数据”现象。
Cache Key 的生成逻辑与自定义陷阱
Normalized Cache 的核心假设是:每个 GraphQL 对象都应该有一个全局唯一的缓存键(Cache Key)。Apollo Kotlin 的默认策略非常直接:它会在响应对象里找名为 id 的字段,如果找到了,就把键生成为 TypeName:id。比如 User:10086。这个设计来自 GraphQL 社区普遍遵循的 Global Object Identification 规范,也就是 Relay 的 id 字段约定。
问题是,很多后端 schema 并没有严格遵循这个约定。有的团队用 uid 作为主键,有的用 userId,还有的在不同查询里返回的对象根本没有唯一标识。一旦 Apollo Kotlin 找不到 id 字段,它会回退到路径缓存(Path Cache),也就是用查询路径本身作为键,例如 ROOT_QUERY.users.0。这种回退机制是静默的,不会抛出异常,也不会在日志里打印警告。后果是,列表查询里的第一个 User 和详情查询里的 User 会被当成两个完全不同的记录,缓存命中率暴跌,数据一致性更是无从谈起。
在 Apollo Kotlin 4.x 中,自定义 Cache Key 需要通过 ApolloClient.Builder 设置 cacheKeyGenerator。这是一个函数式接口,接收 GraphQL 对象的类型名和字段 map,返回 CacheKey?。如果你的后端返回的是 uuid 而不是 id,就必须在这里做显式映射。我见过有项目因为遗漏了这个配置,导致 MemoryCache 在滑动长列表时不断膨胀,因为每个列表项都被当成了独立记录,LRU 淘汰完全失效,最终触发 OOM。
还有一个更隐蔽的场景:union 和 interface 类型。如果后端返回的 SearchResult 是一个 union,里面可能是 User 或 Post,而这两者在各自的类型里都有 id,但 Apollo Kotlin 默认生成的键是 User:10086 和 Post:10086,恰好碰撞。虽然这种概率不高,但在大规模数据下几乎必然发生。正确的做法是在 cacheKeyGenerator 里把类型名和前缀结合,确保跨类型的唯一性。这个配置点藏在 ApolloClient 的构建流程里,文档里只有一小段说明,很容易在 POC 阶段被忽略,带到线上才暴露。
FetchPolicy 的真实行为:不只是“读缓存还是读网络”
Apollo Kotlin 提供了几种内置的 FetchPolicy:CacheFirst、NetworkFirst、CacheOnly、NetworkOnly,以及最容易被误用的 CacheAndNetwork。这些策略的命名给人一种错觉,以为它们只是控制“先读哪边”,但实际上它们决定了响应流的完整生命周期。
CacheFirst 会阻塞查询,直到从 MemoryCache 或 SqlNormalizedCache 里读出数据。如果缓存命中,它会立即返回数据,同时可以选择是否发起后台网络请求。在 4.x 的 API 里,CacheFirst 默认不会发后台请求,除非你显式配置 refetchPolicy。这意味着如果你指望 CacheFirst 自动刷新数据,那就会失望。很多开发者误以为用了 CacheFirst 就既能秒开页面又能保证数据新鲜,实际上它更像是一个保守的降级策略,适合对实时性要求不高的页面。
CacheAndNetwork 则完全不同。它会立即从缓存读取并 emit 一次,然后无论缓存是否命中,都会发起网络请求。网络响应回来后,如果数据与缓存不一致,会再次 emit。这个策略在 UI 层会表现为两次连续的 State 更新。如果你的 UI 层没有处理好这种中间态,比如在下拉刷新动画没结束时收到第二次更新,就会出现闪烁。RxJava 或 Kotlin Flow 的收集器必须设计为能处理多次发射,否则 DiffUtil 可能会算出错误的列表变更动画。
NetworkFirst 的行为是优先请求网络,但如果网络失败,会降级读取缓存。这个策略在离线场景下很有用,但要注意 Apollo Kotlin 的默认超时和重试逻辑。如果网络请求挂起而不是立即失败,NetworkFirst 会在整个超时期间阻塞,不会回退到缓存。对于移动端来说,弱网环境下“假连接”状态很常见,TCP 连接建立成功但数据传输停滞,这时 NetworkFirst 的体验反而不如 CacheFirst。我个人在大多数场景下会避免使用 NetworkFirst,除非有明确的离线降级需求。
这些策略的配置不是在某个全局位置一次性设定,而是每次 ApolloCall 都可以单独指定。这种灵活性是必要的,因为不同页面的一致性要求不同。比如消息列表可以用 CacheAndNetwork,而支付结果页必须用 NetworkOnly。但这也意味着团队需要建立明确的策略使用规范,否则随着项目膨胀,各处策略不一致会导致调试缓存问题变成噩梦。
写入路径:乐观更新与持久化的时序问题
读策略只是缓存的一半,另一半是写入。Apollo Kotlin 的 Mutation 执行后,返回的数据会自动写入 Normalized Cache,这是默认行为。但 UI 通常希望获得即时反馈,比如点赞按钮点击后立即变红,而不是等几百毫秒的网络往返。Apollo Kotlin 支持乐观更新(Optimistic Updates),在 4.x 中的 API 是通过 optimisticData 参数传入一个假想的响应对象。
乐观更新的实现机制是在内存缓存里临时插入一条记录,同时给这个记录打上事务标记。当真实网络响应回来后,Apollo Kotlin 会原子性地替换掉这条乐观记录。如果网络请求失败,库会自动回滚到之前的状态。这个回滚机制依赖于内存缓存的版本快照,也就是说,如果乐观更新期间发生了其他查询的缓存写入,回滚的边界可能会变得模糊。
一个常见的坑是:在乐观更新后、网络响应前,用户进行了下拉刷新。如果下拉刷新触发的 Query 也命中了同一条缓存记录,它读到的可能是乐观数据。这时候如果网络请求最终失败,回滚操作会让已经展示在下拉刷新结果里的数据突然变回去,用户会看到“点赞数先涨后跌”。这个问题没有完美的框架级解决方案,Apollo Kotlin 的文档里建议通过 UI 层的 Watcher 配合 FetchPolicy 来规避,但实际操作中需要非常小心地设计乐观数据的范围。
持久化缓存的写入时序是另一个痛点。MemoryCache 的写入是同步的,而 SqlNormalizedCache 的写入默认是异步的。如果你在一个 Mutation 成功后立即 finish() Activity,应用进程可能在 SQLite 写入完成前被杀死,导致下次冷启动时缓存里没有这条更新。Apollo Kotlin 没有提供内置的“等待持久化完成”的 API,你需要自己通过协程或回调来确保时序。这个细节在官方文档里被一句话带过,但在高并发写操作场景下非常致命。
SQLite 并发与 Watcher 的内存管理
提到 SqlNormalizedCache,就绕不开它的并发模型。Apollo Kotlin 的 SQLite 缓存底层基于 androidx.sqlite,在 4.x 中默认使用框架提供的线程池进行磁盘 IO。这意味着同一个 ApolloClient 实例内的多个 Query 可能会并发读写数据库。虽然 Record 级别的写入有事务保护,但 SQLite 的 WAL 模式在低端机上仍然可能出现阻塞。
一个实际遇到过的性能问题:在列表页同时发起三个并行 Query,分别获取用户资料、未读数和推荐内容。这三个 Query 的缓存写入操作会竞争数据库连接。如果其中某个 Query 返回的数据量特别大(比如推荐内容包含大量嵌套对象),它会长时间持有写入锁,导致另外两个 Query 的缓存更新被阻塞。在 UI 层的表现就是,三个接口明明都返回了 200,但有一个卡片迟迟不显示,直到大数据量的那个写入完成。
解决这个问题通常需要把大查询拆分成独立请求并设置不同的 ApolloClient 实例,或者干脆对某些大查询禁用持久化缓存,只保留内存缓存。Apollo Kotlin 允许在单个 Query 级别通过 httpFetchPolicy 和 doNotStore 标志来控制缓存行为,但这个 API 的命名在 3.x 到 4.x 的迁移中发生过变化,老项目升级时容易遗漏。
Watcher 是 Apollo Kotlin 提供的响应式查询机制,相当于对某个 Query 的缓存记录建立订阅。当相关缓存记录被更新时,Watcher 会自动重新执行查询并通知 UI。这个机制在实现聊天列表或通知中心时非常方便,但它对生命周期管理的要求很高。Watcher 默认持有 CoroutineScope,如果在 Fragment onDestroyView 里没有及时取消,会导致内存泄漏。更严重的是,如果 Watcher 的数量过多,每个 Watcher 都会在缓存更新时触发一次重新查询,造成读放大。在 4.x 中,watch() 返回的 Flow 需要显式收集,这个设计比 2.x 的回调接口更清晰,但也要求开发者对 Kotlin 协程的生命周期有足够把控。
从 2.x 到 4.x:API 断裂与迁移成本
Apollo Kotlin 的版本历史对缓存配置的影响不容小觑。2.x 时代还叫 Apollo Android,包名是 com.apollographql.apollo,缓存配置通过 NormalizedCacheFactory 完成。3.x 进行了彻底的重构,包名改为 com.apollographql.apollo3,缓存键解析器从 CacheKeyResolver 变成了 CacheKeyGenerator,SqlNormalizedCacheFactory 的构造函数参数也发生了变化。4.x 又做了一次包名统一,改回 com.apollographql.apollo,但内部 API 与 3.x 并不完全兼容。
这种断裂给老项目带来了真实的迁移成本。比如在 2.x 中,自定义 Cache Key 需要实现两个方法:fromFieldRecordSet 和 fromFieldArguments;到了 4.x,这被简化为单一的 CacheKeyGenerator 函数接口。很多在 2.x 时代写好的缓存逻辑无法直接复用,必须重写。更麻烦的是,3.x 引入的 apollo-runtime 模块拆分让依赖管理变得复杂,如果你同时使用了 Apollo 的 Compose 扩展或 IDL 生成插件,版本号必须严格对齐,否则会出现运行时 NoSuchMethodError。
4.x 在缓存方面的一个重要变更是移除了内置的 LruNormalizedCache 的某些构造器,转而要求通过 MemoryCacheFactory 显式配置最大字节数。2.x 里那种直接 new LruNormalizedCacheFactory(10 __PLACEHOLDER_ITALIC_0__ 1024) 的写法在 4.x 里不复存在,取而代之的是 MemoryCacheFactory(maxSizeBytes = 10 __PLACEHOLDER_ITALIC_1__ 1024)。这种 API 风格的变化虽然更符合 Kotlin 的命名习惯,但对于维护多年的老代码库来说,意味着一次全局的搜索替换和回归测试。
我个人不太认同社区里那种“紧跟最新版本”的盲目主张。如果你的项目当前在 2.x 或 3.x 上跑得很稳,缓存策略已经过线上验证,仅仅为了新语法而升级到 4.x 的性价比并不高。Apollo Kotlin 4.x 确实在编译器插件和响应式流支持上做了改进,但缓存核心机制并没有翻天覆地的变化。迁移决策应该基于具体痛点,比如是否受困于 3.x 的某个已知 Bug,或者是否需要 4.x 引入的 @catch 错误处理语义。
局限与替代方案
Apollo Kotlin 的 Normalized Cache 并不是银弹。它的设计深度绑定了 GraphQL 的类型系统和查询结构,这意味着它无法缓存非 GraphQL 的数据源,也不能轻易地与 REST 接口混用。对于还在渐进式迁移、后端同时提供 REST 和 GraphQL 的项目,维护两套缓存层(Apollo Normalized Cache + Room/Realm)会增加心智负担。
另一个局限是缓存淘汰的粒度。Apollo Kotlin 目前不支持按时间 TTL 淘汰缓存记录,除非你自己在 CacheKeyGenerator 里注入时间戳并定期清理。社区里有开发者提过 Feature Request,希望内置类似 OkHttp 的 max-age 支持,但官方目前的立场是:缓存新鲜度应该由 FetchPolicy 控制,而不是被动淘汰。这个设计理念在数据实时性要求高的场景下没问题,但对于内容类应用(比如新闻列表),没有 TTL 会导致用户长期看到旧数据,除非每次进页面都强制走网络。
在极端性能敏感的场景下,比如需要缓存百万级聊天记录的即时通讯应用,SqlNormalizedCache 的 SQLite 写入吞吐量可能成为瓶颈。这时候可能需要考虑绕过 Apollo 的缓存层,自己用 Room 或对象存储管理本地数据,把 Apollo Client 当作纯网络层使用。当然,这种方案放弃了 Normalized Cache 的数据一致性保证,需要在应用层手动处理 Mutation 后的缓存同步。
如果对 Apollo Kotlin 的缓存机制感到过于沉重,也可以看看其他方向。比如 GitHub 上的graphql-java-generator或者更轻量的 KGraphQL,但这些项目主要是服务端实现,客户端缓存仍需要自行搭建。在 Android 端,Apollo Kotlin 依然是生态最完善的选择,只是需要承认它的学习曲线和配置复杂度。免费的 MIT 协议和活跃的维护团队(Apollo Graph Inc. 赞助)保证了长期可用性,但不会因为开源就自动解决你的架构问题。
缓存策略的最终配置方案,取决于你的数据更新频率、一致性要求和团队对 GraphQL 的理解深度。没有万能模板,只有对 CacheFirst、CacheAndNetwork 和 NormalizedCache 内部机制的足够了解,才能在代码审查时一眼看出那个会导致线上数据闪烁的隐患配置。