Baton:为 SwiftUI 和 Compose 带来 Relay 风格的 GraphQL 客户端
Baton – Relay for SwiftUI and Compose
Baton 将 Relay 的设计带到 SwiftUI 和 Compose,每个视图旁声明 GraphQL fragment,编译器在构建时把整屏 fragment 聚合成一个请求并按 schema 校验,运行时把响应归一化为 UI 框架可直接观察的记录,仅重渲染字段发生变化的视图。
Baton 将 Relay 带到了 SwiftUI 和 Compose。 每个视图旁边都有一个片段,每个屏幕一个请求,第一帧中就有缓存数据,并且只在字段发生变化的地方重新渲染。刚接触 GraphQL?为什么选择 GraphQL 回答了移动端工程师最先问的问题。
在 Swift 中,UIKit 和 AppKit 也受支持:控制器持有与视图相同的句柄(配方)。
视图在其 body 旁边声明它读取的 GraphQL 片段。编译器将屏幕的片段聚合为一个操作,在构建时根据 schema 验证所有内容,并为每个片段生成一个小型类型化透镜。运行时将响应规范化为 UI 框架自身观察的记录。
Baton 与 Relay 对齐,而非 Apollo(但与 Apollo 后端配合良好):相同的指令、相同的约定、相同的编译器血统。在 Web 上运行 Relay 并在原生端运行 Baton 的团队使用同一种语言。Relay 的设计是 React 的而非 GraphQL 的(快照、重读、通过抛出 Promise 挂起),Baton 将其排除在外:SwiftUI 的 Observation 和 Compose 的快照状态已经知道哪个视图读取了哪个字段。
设计在 docs/vision.md 中。它假设的约束在 docs/principles/ 中。词汇表在 docs/terminology.md 中。与其他原生客户端的比较在末尾,详细内容在 docs/comparison.md 中。
在你的应用上试试。 了解 Baton 能为你的应用做什么的最快方法是让你的编码代理在一个分支上集成它,测量重要屏幕前后的表现,并反馈结果。无论遇到什么障碍——阻碍、缺失的功能或令人失望的数字——都属于 issue。这是你让应用更快、GraphQL 更易用的机会,并在 Baton 还年轻时塑造它。代理的第一页是 docs/recipes/agents.md:读什么、屏幕的形状、永远不要写什么,以及审查清单。
感觉
左边是 SwiftUI,右边是 Compose,基于 GitHub 的 GraphQL API,如两个 GitHub 示例(Swift、Kotlin)所示:相同的文档、相同的透镜和相同的阶段,用 Kotlin 的话说。两个 API 在 1.0 之前仍会变动。
渲染它的视图旁边有一个片段,屏幕有一个查询。行声明它读取的字段;屏幕通过其变量命名仓库,并将行的片段展开到其开放问题中。编译器组装操作,行读取它声明的内容,仅此而已:
| SwiftUI | Compose |
|---|---|
struct IssueRow: View { @Fragment(""" fragment IssueRow_issue on Issue { number title author { login } } """) var issue: IssueRow_issue var body: some View { VStack(alignment: .leading) { Text("#\(issue.number) \(issue.title)") Text(issue.author?.login ?? "ghost") .foregroundStyle(.secondary) } } } struct IssuesScreen: View { @Query(""" query IssuesQuery($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { nameWithOwner issues(first: 20, states: OPEN) { nodes { id ...IssueRow_issue } } } } """) var issues: IssuesQuery var body: some View { switch issues.phase { case .ready(let data): if let repo = data.repository { List { ForEach(repo.issues.nodes ?? .empty) { IssueRow(issue: $0.issueRow) } } .navigationTitle(repo.nameWithOwner) } case .loading: ProgressView() case .failed(let error): ErrorView(error) { issues.retry() } } } } |
@Fragment($$""" fragment IssueRow_issue on Issue { number title author { login } } """) @Composable fun IssueRow(issue: IssueRow_issue) { Column { Text("#${issue.number} ${issue.title}") Text(issue.author?.login ?: "ghost") } } @Query($$""" query IssuesQuery($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { nameWithOwner issues(first: 20, states: OPEN) { nodes { id ...IssueRow_issue } } } } """) @Composable fun IssuesScreen(owner: String, name: String) { val query = rememberQuery( IssuesQuery(owner = owner, name = name), ) when (val phase = query.phase) { is Phase.Ready -> phase.data.repository?.let { repo -> LazyColumn { item { Text(repo.nameWithOwner) } items(repo.issues.nodes.orEmpty()) { IssueRow(it.issueRow) } } } Phase.Loading -> CircularProgressIndicator() is Phase.Failed -> ErrorView(phase.error) { query.retry() } } } |
分页的列表就是一个 Relay 连接,它位于自己的 fragment 中,屏幕的查询会用它来替代原本的 issues 字段。fragment 负责分页,store 负责合并后的各页数据,而视图像读取其他任何字段一样读取它们:
| SwiftUI | Compose |
|---|---|
struct IssueList: View { @Fragment(""" fragment IssueList_repository on Repository @refetchable( queryName: "IssueListPaginationQuery" ) @argumentDefinitions( count: {type: "Int", defaultValue: 20} cursor: {type: "String"} ) { issues( first: $count after: $cursor states: OPEN ) @connection(key: "IssueList_issues") { edges { node { id ...IssueRow_issue } } } } """) var repository: IssueList_repository var body: some View { let issues = repository.issues ForEach(issues.nodes) { issue in IssueRow(issue: issue.issueRow) } if issues.hasNext { ProgressView().task { try? await issues.loadNext() } } } } |
@Fragment($$""" fragment IssueList_repository on Repository @refetchable( queryName: "IssueListPaginationQuery" ) @argumentDefinitions( count: {type: "Int", defaultValue: 20} cursor: {type: "String"} ) { issues( first: $count after: $cursor states: OPEN ) @connection(key: "IssueList_issues") { edges { node { id ...IssueRow_issue } } } } """) @Composable fun IssueList(repository: IssueList_repository) { val issues = repository.issues LazyColumn { items(issues.nodes) { IssueRow(it.issueRow) } if (issues.hasNext) item { CircularProgressIndicator() LaunchedEffect(issues) { runCatching { issues.loadNext() } } } } } |
Kotlin 文档是一个 $$"""…""" 字符串,因为 $ 在普通文档中会开启模板,而 GraphQL 的变量需要它。视图标记位于 composable 上,而 composable 会记住它所展示的 operation 值。
它是什么,以及将会是什么
- 每个视图一个 fragment。 GraphQL 就写在 Swift 文件里,紧挨着读取它的视图,作为一个完整、有效的文档。父级将它的 fragment 作为记录引用和上下文传给子级;子级无法读取它未声明的任何内容。
- 每个屏幕一个请求。 编译器从展开到其中的 fragment 组装出 operation,并在 Relay 的
persistConfig下,组装出服务器注册它所用的 id。没有人手写屏幕的查询,也不会出现瀑布式请求。 - 首帧中的缓存数据。 读取在主 actor 上是同步的;一个 handle 在第一个 body 运行之前就会对 store 进行解析,而在一次 launch 之后,store 会从磁盘上的镜像读取该 handle 所需的内容。解码和规范化响应以及镜像的写入都在其之外运行;检查和垃圾回收则留在其上,在基准测试机器上各自耗时不到一帧的三分之一,而乐观响应也在其上被规范化,因此它的层会在调用当轮就显示出来。
- 只有发生变化的视图才会重新渲染。 记录是可观察对象;读取了
user.name的 body 会在该记录的那个字段发生变化时失效,且不会在其他任何时候失效。对基准测试夹具进行一次未变化的 refetch 在主 actor 上耗时不到 200 µs,且不涉及任何视图;提交全部 899 条记录大约耗时一毫秒。 - 诚实的数据。 可空性就是 schema 所声明的;
@required和@catch的工作方式与 Relay 中相同,只是用 Swift 的术语(一个可选 lens、一个Result、一个get throws);字段错误在缓存后依然存在;陈旧性是视图可以读取的一个阶段。 - 一切都小。 生成的代码是每个字段一行再加上数据表。运行时依赖 Foundation、Observation 以及系统自带的 SQLite。编译器是一个预构建的二进制文件,由 Swift 和 Kotlin 运行时共享。
- 声明式,一直深入到写入。视图需要什么、列表如何分页、乐观响应显示什么以及变更如何编辑列表,全都是视图旁边 GraphQL 文本中的指令和值;运行时没有任何接线。这也正是 Baton 对语言模型编写的代码来说成本低廉的原因:一个文件承载整个契约,构建时逐字符对照 schema 检查,错误的字段是编译错误而不是运行时意外。
它不会是什么
- 不是运行时 GraphQL 客户端:每个操作在构建时已知。没有查询构建器,没有字符串查询,应用中没有 GraphQL 解析器。
- 不是模型层:除了 store 本身,没有任何解码出来的东西需要持有、变更或持久化。
- 不能通过策略对象配置:身份是编译进来的 schema 配置;行为是句柄上的指令或值。
- 不是离线同步引擎、本地状态框架、服务器,也不是 React Native 或 Web 客户端。
- 不是两个平台共用一个运行时:Swift 和 Kotlin 运行时各自独立且符合各自语言习惯;编译器、产物格式、词汇表和一致性测试夹具是共享的。
由人编写,或由模型编写
语言模型编写的屏幕与人编写的屏幕形状相同,并经过相同的检查。视图旁边的片段是完整、有效的 GraphQL;schema 决定存在什么;生成的 lens 决定主体可以读取什么;指令决定数据如何流动(@connection、@appendEdge、@required、@catch)。没有需要配置的缓存策略,没有需要教导的规范化器,也没有需要编写的更新函数,因此生成器无需知道任何不在它面前文件中的东西。词汇表是封闭且简短的(docs/terminology.md),能放进一个提示词中;编译器的诊断会指向 GraphQL 文本中出错的那个字符,这是模型最擅长迭代的反馈。结果是代码生成快、易于审查,且很难在不知不觉中出错。智能体首先读取、并在交回更改前检查的是 docs/recipes/agents.md。
状态
0.15.0(Sushka)。在 Swift 上,读取、写入、列表、错误和持久化贯穿每一层,并有测试和基准支撑这些说法:第一个主体中的缓存数据、一个字段变化只重新渲染一行、乐观响应立即显示并在失败时回滚、连接在 store 中合并其分页、通过 Relay 的指令读取字段错误、延迟片段、订阅,以及一个比进程存活更久的 store。Kotlin 运行时运行在 JVM 和 Android 上,在 spec/ 下遵循相同的编译器和相同的夹具:规范的每个用例和每个脚本都经过编译器生成的代码,背后有 Compose for Desktop 示例、Android 示例以及与 Apollo Kotlin 的正面比较。从 0.13.0 起,它以 com.shergin.baton:baton 的形式发布在 Maven Central 上,并带有一个 Gradle 插件 com.shergin.baton,用于运行编译器(配方)。API 在 1.0 之前会自由破坏;每个版本都在 CHANGELOG.md 中。
与你的服务器配合使用
Baton 对服务器的要求不超出 GraphQL 规范。查询或变更都是标准的 GraphQL-over-HTTP 请求,因此 Apollo Server、Apollo Router、GraphQL Yoga、Hive Gateway、Hasura 以及任何其他符合规范的服务器都能原样应答,无需插件或适配器。把编译器指向 schema,无论它来自哪里,然后构建。@defer、订阅和持久化操作遵循各服务器自己的约定,联邦图看起来和任何其他服务器一样。命运服务器的 GraphQL 模板原样应答,包括它的 posts 连接、node(id:) 和变更;它的实时订阅通过 graphql-sse 到达,但携带一个 JSON 标量,因此它们不更新任何记录。一个进程持有一个 schema 家族:两个环境可以共享它,而一个进程中的两个 schema 不得给两个类型同一个名字。
Baton 力求在每个后端自身特性符合设计且对客户端有意义的地方支持它们。如果你的后端做了 Baton 尚未支持的事情,请开一个 issue。
Caton
Caton 是演示应用:一个用于 GitHub 通知的 macOS 菜单栏收件箱,旨在在真实产品中展示这个框架。它把每条通知与其拉取请求或 issue 的实时状态关联起来,菜单栏中的数字就是等待你处理的事项数量。
名字
在接力赛中,接力棒是真正被传递的东西。在这里,它是屏幕交给每个视图的数据:恰好是视图所请求的内容,别无其他。在俄语中,同一个词 батон 是一条面包,这就是为什么符号是 🥖 而标志是一条面包。
用数字说话
与维护中的原生客户端进行两场正面较量,使用相同数据:Rick and Morty 页面,686 KB 和 899 条记录。同一台机器,同一天,Apollo 按其文档所述配置,两个测试框架都在本仓库中。每个数字都在 BENCHMARKS.md 中,附有设备、操作系统和日期。
Swift 对比 Apollo iOS 2.4,Apple M1 Pro,2026 年 10 月 2 日:
| Baton | Apollo iOS | 更快 | |
|---|---|---|---|
| 响应进入存储 | 3.4 ms | 318 ms | 94× |
| 再次处理相同负载 | 165 µs | 3.99 ms | 24× |
| 数据已在存储中的屏幕 | 第一个主体已有数据,经过 115 µs 检查 | 228 ms 重建查询 |
Kotlin 对比 Apollo Kotlin 5.2,Google Pixel 9 运行 Android 17,发布构建,2026 年 10 月 9 日:
| Baton | Apollo Kotlin | 更快 | |
|---|---|---|---|
| 响应进入存储 | 5.3 ms | 44.7 ms | 8.4× |
| 再次处理相同负载 | 0.30 ms | 1.1 ms | 3.8× |
| 数据已在存储中的屏幕 | 0.75 ms 检查 | 25 ms 重建查询 | 33× |
差异在于 Apollo 从字节构建并在每次读取时重建的模型树。Baton 不物化视图未读取的任何内容:在手机上,整个响应在不到一个 120 Hz 帧的时间内进入存储,其中 1.0 ms 在主线程上,屏幕从那里读取其字段。从最后一个字节到屏幕可渲染的列表,Baton 用时 5.3 ms;Apollo Kotlin 在响应发出前进行默认缓存写入用时 44.7 ms,或者将写入推迟后首次渲染约 12 ms,之后还有 32 ms 的工作。
同样测得:乐观写入会立即显示,整个周期耗时 0.4 毫秒;四十二页的滚动在接近五兆字节时趋于平稳,因为存储会释放不再被任何视图持有的内容;在图像已打开的情况下启动,会在任何请求之前用 1.78 毫秒读回页面;在 Pixel 9 上端到端测试,示例在冷启动且存储为空时 255 毫秒后显示其列表,在图像上启动后 238 毫秒显示,而 Apollo Kotlin 对应版本分别需要 327 毫秒和 281 毫秒;在 JVM 上,Kotlin 运行时在 1.1 毫秒内摄取同一页面。
与其他原生客户端相比
两场正面交锋见数据说话:Swift 对 Apollo iOS 在 M1 Pro 上,Kotlin 对 Apollo Kotlin 在 Pixel 9 上,每次都是同一个 686 KB、899 条记录的 Rick and Morty 页面。Baton 0.6.0 在同一台 Mac 上重新测量(未重新运行 Apollo),写入存储耗时 4.1 毫秒,未更改负载低于 200 微秒,每个字段 28 纳秒;形态没有变化。Apollo 对相同数据的响应为 849 KB,因为其规范化器在每个对象上都要求 __typename。
Apollo 将差异用于构建操作模型,一次从响应构建,再次从存储构建。写入存储本身速度相同,在那场正面交锋中分别为 1.2 毫秒和 1.4 毫秒。Baton 保留记录,UI 框架观察它,第一个主体已经拥有缓存数据。更改的字段会重新渲染读取它的视图。
Apollo Kotlin 在相同数据上比 Apollo iOS 快得多,其缓存合并耗时在 Baton 提交的两倍以内:存储不是其时间消耗所在。时间花在从字节构建模型树并对其进行规范化上,在 Pixel 9 上占其 44.7 毫秒中的 30 毫秒,以及每次读取时重建该树,在那里占 25 毫秒。一旦树存在,字段就是属性加载,2 纳秒对 Baton 的 47 纳秒透镜读取;一个读取二十行所有字段的屏幕在 0.75 毫秒检查后向 Baton 支付约 7.5 微秒。Apollo Kotlin 还将 id 为 null 的十一个位置键为一个记录,因此它持有 889 条记录,而 Baton 持有 899 条。
Apollo iOS 和 Apollo Kotlin 是两个维护中的原生客户端,它们共享该设计。每个都生成一个形状与操作匹配的模型,在其后保留一个字符串键的记录存储,并在该查询读取的任何字段更改时重新执行整个查询。视图无法在其出现的帧上读取该存储:Apollo iOS 的读取是 async,而 Apollo Kotlin 的读取被记录为保持在主线程之外。
| Baton | Apollo iOS 2.4 | Apollo Kotlin 5.2 | |
|---|---|---|---|
| GraphQL | 在视图中,主体旁边 | 一个 .graphql 文件,你编写屏幕查询 |
一个 .graphql 文件,跨模块合并 |
| 子视图接收 | 一个透镜:它声明的字段 | 父字典的快照 | 父级也可读取的嵌套模型 |
| 热缓存,第一帧 | 数据 | 加载中。读取是 async |
加载中。读取保持在主线程之外 |
| 一个字段更改 | 读取它的视图 | 整个查询,重建为新树 | 整个查询,重建为新树 |
| 字节写入存储 | 在 M1 Pro 上 4.1 毫秒(Swift);在 Pixel 9 上 5.3 毫秒(Kotlin) | 318 毫秒 | 在同一 Pixel 9 上 44.7 毫秒;在 JVM 上 8.8 毫秒 |
| 读回 | 每个字段 28 纳秒,视图主体中每个字段 0.54 微秒(Swift);每个字段 47 纳秒(Kotlin,Pixel 9) | 重建 228 毫秒,然后每个字段 296 纳秒 | 在 Pixel 9 上重建 25 毫秒,然后每个字段 2 纳秒 |
| 滚动时内存 | 趋于平稳。42 页保持在 +5 MB 附近 | 保留每条记录。无驱逐 | 你调用 GC。存在 TTL 和修剪 |
| 列表 | 页面在存储中合并,每页一次更新 | 分页器中每页一个观察者,连接在一起 | 页面在存储中合并 |
| 乐观的写入 | 类型化响应,经 rebase,循环耗时 0.4 ms | 一个单独的可变模型,由你写入缓存 | 可选启用。随后 watcher 会重新运行查询 |
| UI 绑定 | @Fragment 和 @Query,分别用于 SwiftUI 和 Compose |
无。教程中会复制到 view model | 实验性的 Compose 辅助工具,最后发布于 2024 年 |
| 磁盘上 | 系统 SQLite,一条二进制行一条记录 | SQLite,每条记录一个 JSON 字符串 | 二进制 SQLite,前面带内存层 |
更详细的对比,包括各种方案、更小的 Swift 客户端、
来源以及 Apollo 领先之处,见
docs/comparison.md。
许可证
根据 MIT 或 Apache-2.0 许可授权, 由你选择。
来源:Hacker News · github.com
