一条没部署的记录,让一台 iPhone 静默了 18 天
「物之森」4.0 测试期间,我的一台 iPhone 有 18 天没把任何改动传到 iCloud,同步日志却一条错误都没有。原因只是一个新模型没进 CloudKit 正式环境。修它只要一次部署,找到它却绕了好几圈。这篇记下排查的过程,和之后补上的几道防线。
Hi,我是风森。
「物之森」4.0 上线前的测试里,我的一台测试 iPhone 有 18 天没把任何改动上传到 iCloud。而 App 里的同步日志,一条错误都没有。
最后查下来,原因是 4.0 新增的一个数据模型,从来没进过 CloudKit 正式环境(Production)的 schema。服务器拒收了这一条记录,Core Data 就把整批上传、连同这次打开期间的全部同步都停掉了。
修好它只需要一次 schema 部署,真正花时间的是找到它。这篇想把排查的过程,和之后加上的几道防线都记下来。
先说说「物之森」的同步是怎么搭的
「物之森」的数据用 SwiftData 存在本机。打开 iCloud 同步后,由 SwiftData 背后的 NSPersistentCloudKitContainer 镜像到用户自己的 CloudKit 私有数据库。
CloudKit 有两个环境,这次的问题全藏在它们的差别里:
- Development:从 Xcode 直接运行的 Debug 构建连这里。这里的 schema 是「现用现建」的:某种记录第一次上传时,CloudKit 按它带的字段自动建出 record type 和字段。没有值的字段,不会被建出来。
- Production:TestFlight 和 App Store 版本连这里。这里从不自动建任何东西,只认在控制台里用「Deploy Schema Changes」从 Development 部署过来的结构。
SwiftData 的每个模型,在 CloudKit 里对应一个 CD_ 开头的 record type,比如 SDItem 对应 CD_SDItem。而且一旦上了 Production,字段就既不能改、也不能删。
4.0 加了不少新模型。其中一个是 SDBasketSharedItem:把共享林地里的物品放进篮子时,就会产生一条。它平时用得很少——这一点后面很重要。
两台设备不同步,日志却干干净净
发现问题时,我正在测 4.0 新做的珍藏匣原件同步,用一台 iPhone 和一台 iPad 来回加东西。结果 iPhone 上加的东西始终到不了 iPad,CloudKit 控制台里也看不到任何变化。第一反应是:新功能出问题了。
奇怪的是,App 隐藏诊断菜单里的同步日志干干净净。两台设备是同一个 build、同一个 Apple ID、同一个订阅,环境也都是 Production。平时会先怀疑的那些东西,一项项都对得上。
排查:五步,两次看错方向
现在回头看,真正的线索其实来得很早,只是我一开始没把它当回事。
- 对比两台设备的诊断报告。 差别在一个很不起眼的字段:iPad 的「上次上传」是几分钟前,iPhone 的却是 10 月 13 日。也就是说,iPhone 从那天起就再没成功上传过。它只在启动时下载一次,之后 iPad 上传了好几次,它一次也没收到。
- 彻底退出,重开 App。 日志里终于出现一条上传失败:
CKErrorDomain 2(partialFailure,部分记录被拒)。之前日志之所以干净,是因为失败只发生在重新启动、重新尝试上传的那一刻。可这个错误只有域和代码,没说是哪条记录、为什么被拒——Core Data 交给 App 的事件里,每条记录的细节已经被丢掉了。 - 去 CloudKit 控制台查 Logs。 按 UTC 时间找到那次请求,显示为
RecordDelete、BAD_REQUEST。我一度以为是某个删除操作被拒,甚至推翻了自己「缺 schema」的第一反应。这是第一次看错:那一批里其实有 25 条记录,控制台只给了一个笼统的分类。 - 连上 Mac,用「控制台」App 看设备日志。 Core Data 的错误都在,关键内容却全是
<private>。iOS 写日志时就会隐去非调试进程的私有参数,所以「让 App 自己去读日志」这条路也走不通。我差点为此多出一个 build,这是第二次看错。 - 从 Xcode 运行,但连 Production。 Xcode 控制台能显示完整的错误,可普通的 Debug 构建连的是 Development,等于把这台设备的数据切到了另一个环境。办法是在 entitlements 里临时加一行,让 Debug 构建也连 Production,看完立刻删掉:
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
这一次,终于看到了服务器的原话:
"Invalid Arguments" (12/2006); server message = "Cannot create new type CD_SDBasketSharedItem in production schema"
... 24 "Batch Request Failed" CKErrors omitted ...
根因:Production 只认部署过的结构
那台 iPhone 在 10 月 13 日前后,测试过「把共享林地的物品放进篮子」,于是产生了第一条 SDBasketSharedItem。可这个模型从没被任何 Debug 构建上传过,Development 里自然没有 CD_SDBasketSharedItem,之前的部署也就没带上它。于是,Production 拒收。
那为什么一条记录,能卡住整台设备?
- 上传是整批原子的。 一条被拒,同批其余 24 条也跟着失败(
Batch Request Failed)。 - Core Data 把这个错误判为无法自动恢复。 它会重置内部状态,然后在这次打开期间拒绝执行一切请求,上传和下载都停。
- 下次打开,同一批又被拒。 如此循环,App 每次收到的都只有一句
CKErrorDomain 2。
所以 iPad 一切正常,因为它从没产生过这种记录;而 iPhone 从 10 月 13 日起,所有改动都只留在了本机。
我以前的习惯是「新增模型后,先在开发环境存一条数据再部署」。这个办法,恰恰抓不到这种用得少的模型,也抓不到平时一直为空的字段。
修复:让 Development 一次建齐,再部署
最快的做法,看起来是直接在控制台手动建这个类型。但我没有这么做:Production 的字段一经部署就不能修改或删除,建错一个就是永久的。事后去看 Production 里的 CD_SDBasketSharedItem,Core Data 还自己加了 CD_moveReceipt(BYTES)、CD_moveReceipt_ckAsset、CD_spaceKey_ckAsset 这类字段,手建几乎不可能写对。
Apple 给 SwiftData 的做法是 initializeCloudKitSchema():用 SwiftData 模型生成 Core Data 模型,再为每个模型上传一条所有字段都有值的样本记录,让 Development 一次建齐。我把它做成了只在 Debug 构建里生效的小工具,用一个临时的空数据库运行,不碰任何真实数据:
let model = NSManagedObjectModel.makeManagedObjectModel(for: AppSchema.models)!
let description = NSPersistentStoreDescription(url: throwawayStoreURL)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: containerID)
description.shouldAddStoreAsynchronously = false
let container = NSPersistentCloudKitContainer(name: "Store", managedObjectModel: model)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in /* 处理错误 */ }
try container.initializeCloudKitSchema()
本来打算在模拟器里跑它,可 Xcode 27 的模拟器窗口(DeviceHub)在我这台 macOS 测试版上一打开就崩溃,没法在模拟器里登录 iCloud。最后换了个思路:把 App 作为 iPad App 直接跑在 Mac 上。Mac 本来就登录着 iCloud,带启动参数运行一次,大约 20 秒建完,立刻退出,全程不打开森林数据库。
接着在 CloudKit 控制台部署到 Production,再用 Apple 的 cktool 导出两边的 schema 核对:39 个 record type、466 个字段,完全一致。
那台 iPhone 装回 TestFlight 版本后,积压了 18 天的改动,一口气全部传到了 iPad。看到那一刻,心里的石头总算落了地。
怎样不让它再发生
Production 缺了什么,只有等某个用户真正用到那个字段时才会暴露,而且暴露的方式是「静默停传」。所以防线必须放在发布之前,并且不能靠我自己记得。
- 发布前,一条命令建齐 Development。
./scripts/cloudkit_schema.sh create会构建一个 Mac 版 Debug App,带启动参数运行一次initializeCloudKitSchema(),用的是 Mac 的 iCloud 账户。不需要模拟器或真机,有构建缓存时一分钟左右。 - 在控制台部署。 Apple 没有提供部署到 Production 的命令行,这一步只能保留为手动。
- 上传前自动核对。
check用cktool导出 Development 和 Production 的 schema,逐个比较 record type 和字段,Production 缺什么就列出来。上传脚本在归档前会自动跑gate:模型文件有改动就先create,再check,不齐就停止上传。(cktool需要一次性配置 CloudKit 管理令牌。) - App 内诊断,直接看到问题。 隐藏诊断菜单新增了 Uploads、Downloads、Setup 三行,持久记录最近一次成功,以及「从哪天开始失败」,例如
Failing since 10月13日 · CKErrorDomain 2。上传持续失败时,下方还会出现提示。另外加了一个「Check the CloudKit schema」,在 TestFlight 版本里直接去问 Production:App 用到的每个 record type 是不是都在。 - 把规则写进开发文档。 原来的规则是「新增模型后,在开发环境存一条数据再部署」,这正是这次漏掉的原因。现在改成:每次发布前跑脚本 → 部署 → 核对。
除此之外,每次上线前我依然会手动测一遍两台设备之间的 iCloud 同步。自动检查管的是「结构齐不齐」,手动测试管的是「用起来对不对」,两者缺一不可。
下次能快很多的几件事
回头看,这次的时间大部分花在了「看错地方」,而不是修复本身。下面几条,是我留给下一次的自己的:
- 先看「最近一次成功上传」,而不是日志里有没有错误。 日志会被清空、有条数上限,而一台卡住的设备在两次启动之间,根本不会产生日志。
- 固定排查顺序。 诊断里的 Uploads → Check the CloudKit schema → 必要时用「临时 Production entitlement + Xcode 运行」看服务器原话。前两步在手机上几分钟就够,第三步才需要连电脑。
- 提前知道哪些路走不通。 Core Data 同步事件里的 error 只有域和代码;设备日志里的细节是
<private>,App 自己读日志也一样;CloudKit 控制台的 Logs 只给请求级的笼统分类。提前知道这些,能省下一次次「再出一个 build 试试看」。 - schema 相关的工作不需要设备。 iPad App 能直接跑在 Apple 芯片的 Mac 上,还自带 Mac 的 iCloud 账户。用它来跑一次性的开发工具,比模拟器(要登录、要窗口)和真机(要开发者模式、要解锁)都省事得多。
- 能自动的交给脚本,不能自动的就让脚本停下来提醒。 发布流程里能检查的都交给上传脚本;控制台部署做不到自动,那就让脚本停下来,把缺什么列清楚。
如果你也在用 SwiftData / Core Data + CloudKit,这几条或许能帮你少走点弯路:
- Production 只有部署过的结构;Development 只有真正上传过、且字段有值的部分。每次改模型,先用
initializeCloudKitSchema()建齐再部署。 - 不要在 CloudKit 控制台手建 Core Data 的类型:Production 的字段不能改,也不能删。
- 一条被拒的记录会让整批失败,Core Data 还会在这次打开期间停掉全部同步。
- 判断同步是否健康,看「最近一次成功上传」,不看日志里有没有错误。
- 想看 Production 的完整错误:临时加上
icloud-container-environment = Production,从 Xcode 运行,看完就删,千万别提交。 - 一台上传失败的设备,未上传的改动只存在它自己身上:先备份;不要重置、不要删 App、不要「从 iCloud 重新下载」。
- TestFlight 和 App Store 连 Production,Xcode 运行连 Development。数据重要的设备,不要装 Debug 构建。
写在最后
还好,这次没有任何用户受影响:问题出在我的测试机上,4.0 也还在 TestFlight 测试阶段。但如果它跟着正式版一起发出去,用户看到的只会是「同步好像不太灵了」——App 里没有任何提示,我也收不到任何反馈。
CloudKit 和 Core Data 把大部分复杂度都藏了起来,平时这很省心;代价是出问题时,它们也一样安静。
对一个人开发的 App 来说,用户托付给「物之森」的,是他们一件件物品背后的记录和回忆。我能做的,就是把发布前的检查尽量自动化,再在 App 里留一扇能看清真实状态的窗。这样哪怕森林里真的起了雾,我也能第一时间看见。