-
使用 gRPC 和 Swift 构建实时 App 及服务
使用 gRPC 在你的 Swift App 中及后端打造引人入胜的实时体验。gRPC 是一个开源的 RPC 框架,专为高性能、双向流式 API 设计。探索 gRPC Swift 软件包如何借助 Swift 并发提供现代且安全的运行时环境。了解整合的诸多工具如何简化你的工作流程,并帮助你轻松打造实时功能。
章节
- 0:00 - Introduction
- 1:39 - Meet gRPC
- 2:13 - App overview and demo setup
- 3:30 - Defining the ListRaces RPC
- 4:30 - Setting up Xcode to generate gRPC code
- 7:50 - Managing the gRPC client lifecycle
- 9:36 - Protobuf message format and binary efficiency
- 12:33 - Implementing a bidirectional streaming RPC
- 20:11 - Deploying the service
- 23:11 - Next steps
资源
- About gRPC
- gRPC Swift Extras
- gRPC Swift Protobuf
- gRPC Swift NIO Transport
- gRPC Swift
- Swift on Server
相关视频
WWDC25
WWDC24
WWDC23
-
搜索此视频…
你好 我是Swift Server团队的George 在本视频中 我将向你展示如何构建 实时体验 在你的App和服务中使用gRPC Swift 动态App体验通常依赖 从服务器获取数据 但与服务交互可能颇具挑战 手动编写网络代码 与服务交互可能非常耗时 从一些文档开始 花时间设计出色的API 最终得到一些看似可用的东西 但文档并不总是最新的 而且你可能在过程中犯了一些错误 结果可能不总是按预期运行 幸运的是 有更好的方法 许多服务API在规范中单独定义 它作为服务的可信来源 这让你能生成 与之交互所需的代码 节省时间并消除错误 这些优势适用于所有API 你需要交互的API 基于HTTP的API 优选方案是OpenAPI 它被广泛使用 在Swift中也有很好的支持 我的队友Si对此进行了介绍 在名为 "Meet Swift OpenAPI Generator"的专题中 我们来看看称为gRPC的替代方案 我将展示如何在App中使用它 发出简单请求 并使用流式RPC构建实时体验 接着 我们将了解 如何实现gRPC服务 并将其部署到云端 首先 我们来了解gRPC是什么
gRPC是一个用于 远程过程调用的框架 它是CNCF项目 也是被广泛采用的行业标准 与OpenAPI类似 你使用从规范生成的代码 让你能快速开始使用服务 但在gRPC中 API以 带有输入和输出的函数来定义 而非基于HTTP来定义
我们来看看实际运作方式 附近有一个新的卡丁车联赛即将开始 他们有一套系统追踪所有信息 从赛程到所有实时比赛数据 但他们需要一种方式 来提供这些信息 我一直在开发一款iOS App 通过gRPC服务与其后端集成 我在App中准备了几个视图 并填充了一些示例数据 可以列出即将到来的比赛 然后点击每条以获取更多信息 但如果能用gRPC 从服务器获取内容就更好了 返回赛程的函数 可称为list races 可以用请求的比赛数量来调用它 它将返回比赛列表 作为远程过程调用 请求消息由客户端发送到服务器 服务器执行函数 并将比赛列表作为响应返回 让我们将理论付诸实践 看看如何在App中使用gRPC Swift
首先定义服务API 然后向Xcode项目添加所需依赖项 配置gRPC构建插件 生成调用服务所需的代码 然后更新App以调用服务器 指定gRPC服务最常见的格式 称为Protocol Buffers 简称Protobuf
在.proto文件中 我将定义 一个名为ListRaces的RPC服务 它以ListRacesRequest作为输入 以ListRacesResponse作为输出 请求消息有一个名为limit的字段 它是一个整数 表示比赛数量的最大值 响应中包含的比赛数量 我给它设置了默认值100 消息中的每个字段 也被分配唯一的字段编号 响应消息包含一个重复的Race字段 Race被定义为单独的消息 包含名称等信息 位置和锦标赛名称作为字符串 圈数作为整数 以及开始时间作为时间戳 此类型是Protobuf知名类型 在其他地方定义 因此我需要导入其定义 定义好服务后 我可以切换 到Xcode添加gRPC依赖项 并配置代码生成器
首先 我需要向项目添加一些依赖项 我将导航到项目编辑器
然后选择 Package Dependencies选项卡 然后点击加号
首先 我将添加对 grpc-swift-nio-transport的依赖 它提供高性能网络代码 构建于开源SwiftNIO库之上
然后我将添加依赖项 对grpc-swift-protobuf 它提供构建插件 用于从proto文件生成gRPC代码
设置好依赖项后 我可以配置目标以使用构建插件 我将选择App Target 然后选择Build Phases选项卡 并展开名为 Run Build Tool Plug-ins的部分 然后点击加号图标 选择GRPCProtobufGenerator并点击Add
该插件扫描目标目录中的proto文件 并可通过JSON配置文件进行配置 现在我将它们添加到目标中
JSON文件配置要生成的代码 由于这是App 我只需要消息和客户端 不需要服务器代码 现在可以重新编译App以生成代码 作为安全措施 第一次使用时 系统会要求你信任该插件
一切设置完毕 现在可以调用服务了 我将打开RaceScheduleView
并导入所需模块
核心模块提供 常见的gRPC运行时组件 HTTP模块提供网络代码 SwiftProtobuf让我们能够 与Protobuf消息交互 接下来 我将向视图 添加task修饰符 在其中发出请求 在task内部 我将使用 withGRPCClient函数创建客户端 我将在do catch块中执行此操作 现在只打印错误 gRPC Swift让你 配置用于网络的实现 我将使用基于SwiftNIO的实现 连接到Mac上本地运行的服务器
传递给闭包的客户端只了解服务器 它对服务一无所知 这就是生成的代码发挥作用的地方 我将创建一个SwiftKart客户端
并用gRPC客户端初始化它
然后我将创建请求
调用list races RPC 并等待响应
最后 我将用新数据更新视图 通过将服务器响应映射 到视图使用的数据模型
就这样 我们已从 本地服务器获取了比赛日程 Finite Loops听起来很有趣 即将开始 在继续之前 我需要做一个重要的更改
目前 每次视图出现时 App都会创建新的gRPC客户端 这意味着每个视图 都需要建立与服务器的连接 增加了不必要的延迟 相反 App应该创建客户端 并在视图间共享 以便连接可以被复用
我可以通过App环境传播客户端 客户端也应该断开连接 当App进入后台时 以释放资源 在Xcode中 我将添加 之前编写的客户端管理器代码
然后打开App入口点 并创建管理器实例
我将通过environment修饰符 使其对子视图可用 当场景进入后台阶段时 也应该断开客户端连接 为此 我将创建一个 scene phase属性
然后监视其变化
我的管理器类延迟连接 被要求提供客户端时 因此不需要做任何事情 当场景进入活跃状态时 现在我将在RaceScheduleView 中使用该管理器 我将把它添加到视图中
并使其在预览中可用
最后用对管理器的调用 替换withGRPCClient调用
这就完成了App设置 通过代码与服务通信 由Protobuf中定义的 服务API生成 除服务API外 Protobuf还提供消息交换格式 SwiftProtobuf有一个代码生成器 让你能直接使用Swift类型 来表示你的消息 例如 我可以创建一条Race消息 并用相关信息填充字段 当gRPC在客户端和 服务器之间发送消息时 它将消息序列化为二进制表示 它使用唯一字段编号 而非名称来标识每个字段 因此 Protobuf消息 大约是等效JSON消息大小的一半 减少消息大小对移动App很有益 其中最小化数据传输有助于提升 网络调用的性能 这在网络条件差时尤为重要 这种效率对其他环境也很有益 例如服务间通信 以及进程间通信 例如Apple开源的 Containerization框架 它使用gRPC Swift 通过虚拟套接字进行通信 在宿主操作系统与 Linux虚拟机之间 gRPC Swift也是 云服务中的关键组件 例如Private Cloud Compute iCloud钥匙串和照片 以及SharePlay文件共享 但我们的应用不仅限于外部服务 gRPC深入我们的内部基础设施 例如操作系统构建和发布系统 gRPC的突出特性之一是 对流式传输的一流支持 许多RPC 例如list races 只需向服务器发送 单条请求消息 服务器以单条响应消息回复 这称为一元RPC 但RPC可以流式传输 请求和响应消息 这意味着还有三种 其他类型的RPC可探索 客户端流式RPC是指 客户端向服务器发送任意数量的消息 服务器以单条响应消息回复 想象每辆卡丁车将 遥测数据流式传输到服务器 在服务器流式RPC中 客户端发送单条请求消息 服务器以任意数量的响应消息回复 想想实时更新 例如实时文字解说 最后一种类型是双向流式传输 客户端和服务器 可以相互发送任意数量的消息 我想到了一个好办法 可以在App中使用它 来提供实时比赛更新 请求消息将告知服务器 客户端订阅了哪些类型的事件 响应消息将包含相关事件 当发生变化时 客户端可向服务器发送更多消息 关于他们感兴趣接收哪些事件
我的App一直向 Mac上运行的服务器发出请求 该服务器也是用Swift编写的 我们来看看 设置服务器非常简单 我创建一个服务器对象 用传输进行初始化 以及它应该提供的服务 要启动服务器 只需调用serve 服务只是一个类型 它实现由构建插件生成的协议 你可以看到我之前实现的 list races RPC 它是一个async函数 接收请求并返回响应 实现它只需查询数据库中的比赛 填充消息然后返回它 为了集成流式RPC 我将更新服务定义 然后切换到服务器并重新生成代码 以便实现新的RPC 完成后 我将更新App来调用它 首先向服务定义中添加 FollowRace RPC 由于RPC流式传输请求和响应消息 我需要在输入和输出前 添加stream关键字 然后需要定义消息 请求消息包含要关注的比赛名称 以及要订阅的事件类型列表 以enum表示 响应类型有一个oneof字段 就像带有关联值的Swift enum 消息可以保存每辆卡丁车的位置 或当前比赛名次 它们被定义为单独的消息 服务定义更新完毕后 我将切换到Xcode中的服务器 以便实现新的RPC 我将构建项目以重新生成代码
现在出现了构建错误 因为协议有新的要求 我尚未实现 因此我将填写一个桩代码
这与list races RPC有所不同 因为有流式传输 请求参数是请求消息的async序列 响应参数是一个对象 用于向客户端写入响应消息 我知道需要同时处理两个数据流 因此我需要一个task group
我需要等待第一条请求消息 以便了解要追踪的比赛名称 以及调用方感兴趣的事件 我将创建一个async迭代器 并等待第一条消息 我将把事件存储在 受mutex保护的集合中 因为两个不同的任务需要并发访问它 然后我将向task group添加一个任务 它调用实时比赛追踪器 传入要关注的比赛名称
这给了我一个事件的async序列 然后我可以过滤 仅包含客户端当前感兴趣的事件
我将迭代已过滤的事件
并创建一条空响应消息
然后 对事件进行switch并填充消息 首先 我将映射 追踪器中卡丁车位置的数组 到RPC使用的数据类型 然后对名次执行同样的操作
现在我将消息写入客户端
还有几件事需要完成 首先是继续处理请求消息 因为调用方可能会更改感兴趣的事件 我们将使用请求流的结束 作为客户端不再需要更多事件的信号 以便取消task group中运行的任务 停止发送回消息 最后我将重启服务器 以便客户端能调用新的RPC
服务中已实现该RPC 现在我可以更新App了 我将打开App的Xcode项目 并更新proto文件 以包含新的RPC和消息
我将构建项目以重新生成gRPC代码
然后导航到RaceInfoView 添加指向早先创建的 LiveStreamView的NavigationLink 然后打开直播视图
它显示一张地图 将绘制标注 表示比赛中每辆卡丁车的位置 还有一个工具栏按钮 可打开弹出页面 以显示实时排行榜 showLeaderboard属性追踪是否显示 视图已经具有属性 用于存储我感兴趣的各种状态 我只需要调用RPC 并连接从服务器接收到的数据 首先 我将添加之前使用的导入 然后通过environment注入客户端
像之前一样 我将创建一个task
并调用manager.withClient
然后我将创建一个kart客户端
并调用FollowRace RPC
它的结构与一元list races RPC不同 它有两个闭包 一个用于写入请求消息 另一个用于处理响应消息 每次showLeaderboard的值变化时 需要发送一条请求消息 我将使用AsyncStream随时间追踪它 并将其续接作为属性存储
当showLeaderboard发生变化时 我将向续接提供新值
我将在task中创建 AsyncStream及其续接
我需要向流提供 showLeaderboard的当前值 作为初始值 在RPC的第一个闭包中 我可以迭代流
并为每个值向服务器发送消息
如果正在显示排行榜 我将添加名次事件
然后将消息写入服务器
在响应闭包中 我将迭代消息并为每个事件 更新视图状态 我将使用辅助方法来处理事件
我将对事件进行switch 并将每个映射到视图使用的数据类型
我将从卡丁车位置开始 然后对名次执行同样的操作
最后 我将迭代响应消息 并为每个事件调用辅助方法
我们来看看效果
一场比赛即将在Apple Park开始 他们正向彩虹拱门方向行驶 现在看来他们正在向右转向鸭子池塘 Monty领先 Pepper和Bo紧随其后 很好 但我还没有达到我的目标 将信息提供给观众 因为服务在本地运行 我们将其部署到云端 以便所有使用App的人都可以访问 我使用Google Cloud Platform 托管我的服务 但你也可以使用其他平台 例如AWS或Fly.io 方法类似 但具体步骤会有所不同 大多数服务器运行Linux 这也是我今天部署的目标 我不需要修改任何代码 但我需要打包服务器可执行文件 连同其运行时依赖项打包成容器镜像 然后将镜像发布到 云提供商的镜像仓库 之后我将创建一个部署 最后更新App以指向已部署的服务 首先创建一个Containerfile 它描述了构建容器镜像所需的步骤 我将使用swift:latest 作为基础镜像 接下来 我将设置工作目录 并复制包清单和源文件 然后以Release模式构建服务器 并将其复制到已知位置 此时 镜像中 已有服务器可执行文件 但它还包含整个Swift工具链 我不需要所有这些来运行我的服务器 这会使镜像比所需的大得多
我将使用多阶段构建 并将二进制文件复制到 swift:slim运行时镜像中 最后 暴露端口 并将入口点设置为服务器
Containerfile就这样写完了 此时我会构建并发布镜像 到容器仓库 但这需要几分钟 因此我将使用之前发布的镜像 在终端中 我可以使用 gcloud run deploy命令
我将提供部署名称 镜像名称和地区 然后需要指定我的服务使用http2 并允许未经身份验证的请求
部署完成后 它会打印出服务的URL 更新客户端时会需要它 所以现在先复制
我将切换回App并打开 ClientManager 我将更新连接目标为服务的DNS名称 来自部署命令 然后通过更改传输安全选项启用TLS 从明文更改为TLS
我们来测试一下
看来我们赶上了 Finite Loops比赛的开始
Pepper的起步真是糟糕 Monty占据第一位 Mycroft和Kiko紧随其后
车手们正转向Infinite Loop园区
Pepper夺回了几个名次
看来这将是一场精彩的比赛
我向你展示了如何使用gRPC Swift 在App中构建出色的实时体验 以及它如何简化App到服务器的通信 从定义服务 生成代码 一直到实现并将服务部署到云端 而这只是开始 gRPC Swift内置了大量功能 帮助你将应用从原型推向生产环境 无论是与其他Swift包的集成 例如Swift OTel 或Swift service lifecycle 还是高级连接管理功能 如自定义传输和名称解析器 以及客户端负载均衡 你现在已准备好 在App中使用gRPC了 何不尝试构建 App与服务器交互的原型 看看gRPC Swift如何简化工作流程 或者尝试其中一个教程 以及GitHub上项目仓库中的示例 由于该项目是开源的 你也可以做出贡献 无论是提问 改善文档 还是提出和实现新功能 感谢观看 赛道上见
-
-
3:38 - ListRaces RPC definition
edition = "2024"; import "google/protobuf/timestamp.proto"; service SwiftKartService { rpc ListRaces(ListRacesRequest) returns (ListRacesResponse); } message ListRacesRequest { int32 limit = 1 [default = 100]; } message ListRacesResponse { repeated Race races = 1; } message Race { string name = 1; string location = 2; google.protobuf.Timestamp start_time = 3; int32 laps = 4; string championship = 5; } -
5:55 - grpc-swift-proto-generator-config.json
{ "generate": { "clients": true, "servers": false, "messages": true } } -
6:24 - Add gRPC imports
import GRPCCore import GRPCNIOTransportHTTP2 import SwiftProtobuf -
6:38 - Create a gRPC client connected to a local server
.task { do { try await withGRPCClient( transport: .http2NIOTS( address: .ipv4(host: "127.0.0.1", port: 8080), transportSecurity: .tls ) ) { client in <#code#> } } catch { print("gRPC error: \(error)") } } -
7:14 - Call the ListRaces RPC and update the view
.task { do { try await withGRPCClient( transport: .http2NIOTS( address: .ipv4(host: "127.0.0.1", port: 8080), transportSecurity: .tls ) ) { client in let kart = SwiftKartService.Client(wrapping: client) let request = ListRacesRequest() let response = try await kart.listRaces(request) self.races = response.races.map { race in RaceInfo( name: race.name, location: race.location, startTime: race.startTime.date, championship: race.championship, laps: Int(race.laps), drivers: race.drivers ) } } } catch { print("gRPC error: \(error)") } } -
8:30 - ClientManager.swift
import GRPCCore import GRPCNIOTransportHTTP2 import Synchronization import SwiftUI @Observable final class ClientManager: Sendable { fileprivate let state = Mutex(State.disconnected) static func makeTransport() throws -> HTTP2ClientTransport.TransportServices { try .http2NIOTS( target: .ipv4(address: "127.0.0.1", port: 8080), transportSecurity: .plaintext ) } func withClient( body: (_ client: GRPCClient<HTTP2ClientTransport.TransportServices>) async throws -> Void ) async throws { let client = try connectIfNecessary() try await body(client) } private func connectIfNecessary() throws -> GRPCClient<HTTP2ClientTransport.TransportServices> { try self.state.withLock { state in try state.connectIfNecessary() } } func disconnect() { let client = self.state.withLock { state in state.disconnect() } client?.beginGracefulShutdown() } } extension ClientManager { enum State { case connected(GRPCClient<HTTP2ClientTransport.TransportServices>, Task<Void, any Error>) case disconnected } } extension ClientManager.State { mutating func connectIfNecessary() throws -> GRPCClient<HTTP2ClientTransport.TransportServices> { switch self { case .connected(let client, _): return client case .disconnected: let client = try GRPCClient(transport: ClientManager.makeTransport()) let task = Task { try await client.runConnections() } self = .connected(client, task) return client } } mutating func disconnect() -> GRPCClient<HTTP2ClientTransport.TransportServices>? { switch self { case .connected(let client, _): self = .disconnected return client case .disconnected: return nil } } } -
8:39 - Propagate ClientManager to child views
import SwiftUI @main struct SwiftKartApp: App { let manager = ClientManager() var body: some Scene { WindowGroup { RaceScheduleView() .environment(manager) } } } -
8:52 - Disconnect ClientManager when the scene enters the background phase
import SwiftUI @main struct SwiftKartApp: App { let manager = ClientManager() @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { RaceScheduleView() .environment(manager) } .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .background : manager.disconnect() case .inactive, .active: break @unknown default: break } } } } -
9:12 - Inject ClientManager into the view via @Environment
@Environment(ClientManager.self) var manager -
9:21 - Replace withGRPCClient with manager.withClient
.task { do { try await manager.withClient { client in let kart = SwiftKartService.Client(wrapping: client) let request = ListRacesRequest() let response = try await kart.listRaces(request) self.races = response.races.map { race in RaceInfo( name: race.name, location: race.location, startTime: race.startTime.date, championship: race.championship, laps: Int(race.laps), drivers: race.drivers ) } } } catch { print("gRPC error: \(error)") } } -
9:41 - Using SwiftProtobuf
var race = Race() race.name = "Duck Pond Dash" race.location = "Apple Park, Cupertino" race.startTime = .init(roundingTimeIntervalSince1970: 1_781_198_600) race.laps = 6 race.championship = "Corporate Cup" race.drivers = ["Monty", "Pepper", "Mycroft", "Pancakes", "Duke", "Kiko", "Sissi", "Bo"] try race.serializedBytes() -
12:32 - Server
let server = GRPCServer( transport: .http2NIOPosix( address: .ipv4(host: "127.0.0.1", port: 8080), transportSecurity: .plaintext ), services: [Service()] ) try await server.serve() -
12:45 - Service
struct Service: SwiftKartService.SimpleServiceProtocol { private let database = RaceDB() func listRaces( request: ListRacesRequest, context: ServerContext ) async throws -> ListRacesResponse { var response = ListRacesResponse() response.races = await database.listRaces(atMost: request.limit) return response } } -
13:20 - swift_kart_service.proto
edition = "2024"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; service SwiftKartService { rpc ListRaces(ListRacesRequest) returns (ListRacesResponse); rpc FollowRace(stream FollowRaceRequest) returns (stream FollowRaceResponse); } message ListRacesRequest { int32 limit = 1 [default = 100]; } message ListRacesResponse { repeated Race races = 1; } message Race { string name = 1; string location = 2; google.protobuf.Timestamp start_time = 3; int32 laps = 4; string championship = 5; repeated string drivers = 6; } message FollowRaceRequest { string race_name = 1; repeated RaceEventType event_types = 2; } enum RaceEventType { RACE_EVENT_TYPE_UNSPECIFIED = 0; RACE_EVENT_TYPE_KART_LOCATIONS = 1; RACE_EVENT_TYPE_STANDINGS = 2; } message FollowRaceResponse { oneof event { KartLocations locations = 1; Standings standings = 2; } } message KartLocations { message Kart { int32 number = 1; double latitude = 2; double longitude = 3; google.protobuf.Timestamp recorded_at = 4; } repeated Kart karts = 1; } message Standings { message Entry { int32 kart_number = 1; google.protobuf.Duration gap_to_leader = 2; int32 position = 3; int32 lap = 4; } repeated Entry entries = 1; } -
14:16 - FollowRace stub
func followRace( request: RPCAsyncSequence<FollowRaceRequest, any Error>, response: RPCWriter<FollowRaceResponse>, context: ServerContext ) async throws { throw RPCError(code: .unimplemented, message: "FollowRace is unimplemented") } -
14:38 - Implement the FollowRace RPC
func followRace( request: RPCAsyncSequence<FollowRaceRequest, any Error>, response: RPCWriter<FollowRaceResponse>, context: ServerContext ) async throws { try await withThrowingTaskGroup { group in var iterator = request.makeAsyncIterator() guard let first = try await iterator.next() else { return } let eventTypes = Mutex(Set(first.eventTypes)) group.addTask { let events = tracker.events(forRace: first.raceName).filter { event in eventTypes.withLock { $0.contains(event.type) } } for await event in events { var message = FollowRaceResponse() switch event { case .locations(let locations): message.locations.karts = locations.map { location in var kart = KartLocations.Kart() kart.number = Int32(location.number) kart.latitude = location.latitude kart.longitude = location.longitude return kart } case .standings(let standings): message.standings.entries = standings.map { standing in var entry = Standings.Entry() entry.gapToLeader = .init(rounding: standing.delta, rule: .towardZero) entry.kartNumber = Int32(standing.kartNumber) entry.lap = Int32(standing.lap) entry.position = Int32(standing.position) return entry } } try await response.write(message) } } while let next = try await iterator.next() { eventTypes.withLock { $0 = Set(next.eventTypes) } } group.cancelAll() } } -
16:39 - swift_kart_service.proto
edition = "2024"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; service SwiftKartService { rpc ListRaces(ListRacesRequest) returns (ListRacesResponse); rpc FollowRace(stream FollowRaceRequest) returns (stream FollowRaceResponse); } message ListRacesRequest { int32 limit = 1 [default = 100]; } message ListRacesResponse { repeated Race races = 1; } message Race { string name = 1; string location = 2; google.protobuf.Timestamp start_time = 3; int32 laps = 4; string championship = 5; repeated string drivers = 6; } message FollowRaceRequest { string race_name = 1; repeated RaceEventType event_types = 2; } enum RaceEventType { RACE_EVENT_TYPE_UNSPECIFIED = 0; RACE_EVENT_TYPE_KART_LOCATIONS = 1; RACE_EVENT_TYPE_STANDINGS = 2; } message FollowRaceResponse { oneof event { KartLocations locations = 1; Standings standings = 2; } } message KartLocations { message Kart { int32 number = 1; double latitude = 2; double longitude = 3; google.protobuf.Timestamp recorded_at = 4; } repeated Kart karts = 1; } message Standings { message Entry { int32 kart_number = 1; google.protobuf.Duration gap_to_leader = 2; int32 position = 3; int32 lap = 4; } repeated Entry entries = 1; } -
16:40 - swift_kart_service.proto
edition = "2024"; import "google/protobuf/timestamp.proto"; service SwiftKartService { rpc ListRaces(ListRacesRequest) returns (ListRacesResponse); } message ListRacesRequest { int32 limit = 1 [default = 100]; } message ListRacesResponse { repeated Race races = 1; } message Race { string name = 1; string location = 2; google.protobuf.Timestamp start_time = 3; int32 laps = 4; string championship = 5; repeated string drivers = 6; } -
16:56 - Navigation link to LiveStreamView
NavigationLink(destination: LiveStreamView(race: race)) { Text("Live stream") } -
17:32 - Call the FollowRace RPC in the LiveStreamView
import SwiftUI import GRPCCore import GRPCNIOTransportHTTP2 import SwiftProtobuf struct LiveStreamView: View { private let race: RaceInfo @Environment(ClientManager.self) var manager @State private var tracking: KartTrackingViewModel @State private var standings: [StandingsEntry] = [] @State private var showLeaderboard = false @State private var continuation: AsyncStream<Bool>.Continuation? init(race: RaceInfo) { self.race = race self.tracking = KartTrackingViewModel(race: race) } var body: some View { VStack { KartTrackingMapView(viewModel: tracking) .ignoresSafeArea() .onAppear { tracking.start() } .onDisappear { tracking.stop() } } .onChange(of: showLeaderboard) { _, newValue in continuation?.yield(newValue) } .sheet(isPresented: $showLeaderboard) { LeaderboardView(race: race, standings: standings) .presentationDetents([.fraction(0.3), .medium, .large]) .presentationBackgroundInteraction(.enabled) } .toolbar { Toggle(isOn: $showLeaderboard) { Label("Leaderboard", systemImage: "list.number") } } .toolbarBackgroundVisibility(.visible, for: .navigationBar) .task { do { let (stream, continuation) = AsyncStream.makeStream(of: Bool.self) self.continuation = continuation continuation.yield(showLeaderboard) try await manager.withClient { client in let kart = SwiftKartService.Client(wrapping: client) try await kart.followRace { requestStream in for await showLeaderboard in stream { var message = FollowRaceRequest() message.raceName = race.name message.eventTypes = [.kartLocations] if showLeaderboard { message.eventTypes.append(.standings) } try await requestStream.write(message) } } onResponse: { responseStream in for try await message in responseStream.messages { if let event = message.event { await handleEvent(event) } } } } } catch { print("gRPC error: \(error)") } } } @MainActor private func handleEvent(_ event: FollowRaceResponse.OneOf_Event) { switch event { case .locations(let locations): self.tracking.updateKartCoordinates( locations.karts.map { TrackedKart(number: $0.number, latitude: $0.latitude, longitude: $0.longitude) } ) case .standings(let standings): self.standings = standings.entries.map { StandingsEntry( kartNumber: $0.kartNumber, secondsToLeader: $0.gapToLeader.timeInterval, position: $0.position, lap: $0.lap ) } } } } #Preview { NavigationStack { LiveStreamView(race: .example4) .environment(ClientManager()) } } -
20:55 - Containerfile
FROM swift:latest AS builder # Copy sources into /app WORKDIR /app COPY Package.swift Package.resolved . COPY Sources/ Sources/ # Build the server RUN swift build -c release --product server RUN cp "$(swift build -c release --show-bin-path)/server" /usr/bin/server # Copy the binary from the builder into a smaller runtime image. FROM swift:slim COPY --from=builder /usr/bin/server /usr/bin/server EXPOSE 8080 ENTRYPOINT ["/usr/bin/server"] -
21:56 - Deploy service
gcloud run deploy wwdc-demo-server \ --image us-central1-docker.pkg.dev/wwdc26/wwdc-demo-server/wwdc-demo-server:latest \ --region us-central1 \ --use-http2 \ --allow-unauthenticated -
22:22 - Target deployed service
static func makeTransport() throws -> HTTP2ClientTransport.TransportServices { try .http2NIOTS( target: .dns(host: "wwdc-demo-server-863666503339.us-central1.run.app"), transportSecurity: .tls ) }
-
-
- 0:00 - Introduction
Why hand-crafting networking code is error-prone, and how generating code from a service specification saves time and eliminates mistakes — setting up gRPC Swift as the approach for real-time experiences.
- 1:39 - Meet gRPC
gRPC is explained as a CNCF-standard remote procedure call framework that uses Protocol Buffers to define APIs as typed functions rather than HTTP endpoints.
- 2:13 - App overview and demo setup
A go-karting iOS app demo is introduced, showing how gRPC will replace static mock data with live server-fetched content.
- 3:30 - Defining the ListRaces RPC
The ListRaces RPC and its request/response messages are defined in a .proto file, covering fields, field numbers, types, and Protobuf Well Known Types.
- 4:30 - Setting up Xcode to generate gRPC code
The grpc-swift-nio-transport and grpc-swift-protobuf packages are added to the Xcode project, and the GRPCProtobufGenerator build plugin is configured to auto-generate Swift code from the proto file.
- 7:50 - Managing the gRPC client lifecycle
A shared ClientManager is introduced to reuse connections across views and disconnect the client when the app enters the background, reducing unnecessary latency.
- 9:36 - Protobuf message format and binary efficiency
The Protobuf binary serialization format is explained — using field numbers instead of names makes messages roughly half the size of equivalent JSON, benefiting mobile apps and service-to-service communication.
- 12:33 - Implementing a bidirectional streaming RPC
The FollowRace bidirectional streaming RPC is defined, implemented on the Swift server using async sequences and task groups, and wired up in the iOS app to stream live kart positions and standings.
- 20:11 - Deploying the service
The Swift server is containerised and deployed, then the app is updated to connect over TLS to the live production service.
- 23:11 - Next steps
Recap of the full gRPC workflow, with pointers to prototype your own integrations, explore the open-source GitHub repository, and contribute to the project.