如何用 // @Snapshotable 标记配置 gstack iOS QA 的状态快照:只暴露 Agent 需要的字段并让 token 留在快照外
2026/9/9 16:29:35 网站建设 项目流程

如何用 // @Snapshotable 标记配置 gstack iOS QA 的状态快照:只暴露 Agent 需要的字段并让 token 留在快照外

【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack

如果你已经把 gstack 的 iOS QA 能力装进了一个 Swift 应用(DebugBridge 已安装、真机通过 USB 连接),下一步要解决的是:Agent 通过GET /state/snapshot读取应用状态时,到底哪些字段会暴露出去。gstack 的答案是按字段显式标记——只有属性正上方带有独立// @Snapshotable注释的字段才会进入快照,未标记的字段(典型如 auth token)默认不会出现在快照 JSON 里,也不会进入/ios-fix记录的回放 fixture。

本文的操作路径:在@Observable状态类上标记字段 → 重新生成 accessor → Debug 构建部署到真机 → 启动 daemon 后请求/state/snapshot核对快照内容。适用于已按 docs/howto-ios-testing-with-gstack.md 完成安装的环境:macOS + Xcode 16.0+(xcrun devicectl --version可执行)、iOS 16+ 真机且已开启 Developer Mode、gstack 已安装(gstack-ios-qa-regengstack-ios-qa-daemon在 PATH 上)、Bun 在 PATH 上。

标记字段:独立注释而不是属性包装器

标记方式是写在属性正上方的独立注释行// @Snapshotable。它刻意设计成注释而非 property wrapper,因此能和 Observation 的@Observable宏共存,不影响现有代码编译。@Observable类里没写这个注释的属性永远不会出现在快照里——这正是让 token 留在快照外的机制:

@Observable final class AppState { // @Snapshotable var username: String = "" var authToken: String = "" // never exported }

上面是 docs/howto-ios-testing-with-gstack.md 给出的官方示例:username被标记、进入快照;authToken未标记、不导出。注意第二行行尾的// never exported只是普通行尾注释,不会把authToken标记进快照——生成器的词法扫描只认“单独占一行的// @Snapshotable”,字符串、块注释、行尾注释和说明性文字里的@Snapshotable都不生效(实现见 ios-qa/scripts/gen-accessors.ts 的maskSwiftSource)。

仓库里的测试 fixture 展示了一个更完整的真实状态类,标记了BoolStringIntString?四类字段,同时保留一个未标记的字典字段并注释说明它“不应通过 /state/snapshot 泄漏”,可参考 test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift:

@Observable final class FixtureAppState { // @Snapshotable var isLoggedIn: Bool = false // @Snapshotable var username: String = "" // @Snapshotable var tapCounter: Int = 0 // @Snapshotable var nickname: String? = nil /// Not snapshotted — ephemeral cache that should never leak via /state/snapshot. var ephemeralCache: [String: String] = [:] init() {} }

哪些字段允许标记:生成器的约束

不是任何属性都能标记。生成器按固定契约校验,违反任一条会停止生成并报出源码诊断(见下一节的诊断信息),而不会产出“编译后或在快照时才报错”的 Swift 代码:

  • 必须属于文件级@Observable类;嵌套在其他类型内部的 observable 类不受支持,需要把类型移到文件作用域。
  • 必须是可写的实例varletstatic/class属性、计算属性都不行。
  • 必须带显式类型标注(如var count: Int = 0,不能靠初始值推断)。
  • setter 必须是 internal 或 public;privatefileprivateprivate(set)fileprivate(set)会被拒绝。
  • 类型必须是 JSON 原生标量(StringBool、有符号/无符号整型宽度、FloatDoubleCGFloat)、数组、String 键字典,以及这些类型的 Optional 组合。不支持自定义类型、隐式解包 Optional(T!,需用T?)、嵌套 Optional、非 String 键的字典。
  • 快照 key(即字段名)必须在所有 observable 类之间唯一。

ObservableObject@StateObject等其他观察模型不会产生 accessor,生成器目前只支持文件级@Observable类(见 ios-qa/SKILL.md Phase 1)。

重新生成 accessor 并查看诊断

标记(或移动标记)之后,跑一次确定性的生成命令:

gstack-ios-qa-regen \ --app-source "$PWD/Sources/YourApp" \ --bridge-dir "$PWD/DebugBridge"

--app-source传你的应用源码目录(上面是文档示例路径,按实际仓库调整),--bridge-dir是本地的DebugBridgeSwift 包目录。命令会把规范模板复制到本地包、生成DebugBridgeGenerated/StateAccessor.swift、并写入DebugBridgeGenerated/.gstack-version。源码不变时是字节稳定的缓存命中;它同时会移除旧版扁平布局里的遗留生成文件,避免过期的 bridge 源文件遮蔽新包。如果gstack-ios-qa-regen不在 PATH,ios-sync/SKILL.md 中使用的是完整路径~/.claude/skills/gstack/bin/gstack-ios-qa-regen

每个被标记字段会在生成的 accessor 里以字段名作为 key 注册 read/write(生成逻辑见 ios-qa/templates/StateAccessor.swift.template),所以快照 JSON 的 key 就是字段名。标记写法不合法时,生成会以非零退出并打印诊断,常见诊断及其含义(均为 ios-qa/scripts/gen-accessors.ts 中的原文):

诊断(节选)说明
@Snapshotable field 'x' must be declared var, not let字段是let,改成可写var
@Snapshotable field 'x' cannot be private, fileprivate, private(set), or fileprivate(set)setter 不可见,放宽为 internal/public
@Snapshotable field 'x' must be an instance property用了static/class属性
@Snapshotable field 'x' requires an explicit type annotation缺少显式类型
@Snapshotable field 'x' cannot use an implicitly unwrapped Optional; use T? insteadT!改成T?
@Snapshotable field 'x' cannot use a nested Optional type去掉双层 Optional
must use String keys for snapshot dictionaries字典 key 必须是String
uses unsupported snapshot type '...'; use JSON scalar, array, or String-keyed dictionary types类型不在 JSON 原生集合内
snapshot key 'x' is declared by both A and B; keys must be unique across @Observable types跨类重名,改名

处理方式按 ios-sync/SKILL.md 的失败模式表:要么把声明改到契约内,要么直接删掉该字段的// @Snapshotable标记(未标记即不导出)。

构建、部署到真机并启动 daemon

生成成功后,用 Debug 配置构建并安装(-configuration Debug是必须条件——DebugBridge*目标带.when(configuration: .debug)约束,Release 构建拒绝链接 bridge,这也是快照能力只存在于调试构建的原因):

xcodebuild \ -scheme YourAppScheme \ -configuration Debug \ -destination 'generic/platform=iOS' \ -derivedDataPath /tmp/build \ -allowProvisioningUpdates -allowProvisioningDeviceRegistration \ CODE_SIGN_STYLE=Automatic \ DEVELOPMENT_TEAM=YOUR_TEAM_ID \ build

YourAppSchemeYOUR_TEAM_ID替换为你自己的 scheme 名和 Apple 开发者 team ID(在 Xcode → Settings → Accounts → team 列表里查,是 team ID 不是证书 ID)。然后安装并启动:

UDID=$(xcrun devicectl list devices 2>/dev/null | awk 'NR>2 && $0!="" {print $(NF-2); exit}') xcrun devicectl device install app --device "$UDID" /tmp/build/Build/Products/Debug-iphoneos/YourApp.app xcrun devicectl device process launch --device "$UDID" --terminate-existing your.bundle.id

YourApp.appyour.bundle.id换成实际应用名和 bundle id。手机锁屏会得到FBSOpenApplicationServiceErrorDomain error 1 — Locked,解锁重试;首次安装需要在手机上点 Trust,之后重跑。

确认应用源码里已按文档完成@main接线:DebugBridgeUIWiring.installAll()先于 StateServer 启动执行,随后DebugBridgeManager.shared.start(appState: appState, register: AppStateAccessor.register),并把生成器发现的实际类型替换掉示例中的appState/AppStateAccessor(完整代码见 docs/howto-ios-testing-with-gstack.md Step 1)。

最后启动 Mac 侧 daemon:

gstack-ios-qa-daemon

daemon 在两个 loopback listener 都绑定后打印READY: port=<n> pid=<pid>,默认端口 9099。同一时刻只会有一个 daemon:它对~/.gstack/ios-qa-daemon.pid持有排他 flock,第二次启动会发现已有实例的端口并直接接入。daemon 启动后会调用POST /auth/rotate把启动 token 换成内存中的新值,约 5 秒后 os_log 里抓到的 boot token 就失效了。

验证:/state/snapshot 只含标记字段

HTTP 面在http://127.0.0.1:9099(或[::1]:9099)。先用无需鉴权的版本探针确认 daemon 活着:

curl -s http://127.0.0.1:9099/healthz

然后请求快照端点:

curl -s http://127.0.0.1:9099/state/snapshot \ -H "Authorization: Bearer TOKEN"

TOKEN是当前有效的 bearer token:读端点(/screenshot/elementsGET /state/*)只需要 bearer,写类请求才额外需要X-Session-Id。本地 USB 模式下,跑/ios-qa技能流会自动完成隧道引导、token 轮换和端点调用;如果你手动驱动 HTTP,token 以 daemon 轮换后的当前值为准。

判断结果:

  • 返回 JSON 的 key 与标记字段一一对应(fixture 场景下就是isLoggedInusernametapCounternickname),authTokenephemeralCache这类未标记字段不在返回里;
  • 按 ios-sync/SKILL.md Phase 4 的验证序列,GET /state/snapshot会返回新的 accessor schema hash——如果你在 accessor 列表里新增过标记字段而 schema hash 没有变化,说明新字段没有独立// @Snapshotable注释,codegen 正确地排除了未标记状态;
  • swift buildxcodebuild -scheme <SchemeName>成功、应用重启后 daemon 连接并完成 token 轮换,是同一次验证里的前置确认项。

后续维护与限制

  • 以后新增@Observable类/字段、或把// @Snapshotable移到别的字段,重跑gstack-ios-qa-regen(或/ios-sync)即可;生成器的复合缓存 key 会判断是否真的需要重新生成。
  • 生成器默认排除DebugBridgeGenerated目录和StateAccessor.swift自身;如果诊断显示扫描到了生成的 bridge 源码,把--app-source收窄到应用源码目录。
  • 恢复语义与标记直接相关:POST /state/restore采用两阶段提交——先对完整输入做全量校验,通过后才在 MainActor 上应用赋值,非法输入不会留下部分恢复的状态。所以标记字段的类型契约不只是读取约束,也是回放 fixture 时的校验边界。
  • 发版前记得跑/ios-clean:它移除DebugBridgeSPM 依赖并清理@main里的#if DEBUG接线。即使忘了清理,Release 构建也因.when(configuration: .debug)条件不会链接 bridge。

参考文档:docs/howto-ios-testing-with-gstack.md、ios-qa/SKILL.md、ios-sync/SKILL.md。

【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询