从 pbxproj 到 xcproj:Xcode 工程配置迎来 JSON 格式

最近一直在等待 Xcode 27.1 的到来,以便在 iPhone Duo 的模拟器中测试一下应用的适配情况。有意思的是,27.1 没等到,苹果率先发布了 27.2 beta。尽管 iPhone Duo 模拟器依然缺席,但新增的 project.xcproj 令人眼前一亮。它替代的是 .xcodeproj 包里的那份构建图 project.pbxproj,而不是整个工程包。它有哪些亮点,解决了什么问题,又有哪些事情没有改变,本文将对此进行探讨。

xcodeproj 与 pbxproj

Xcode 会为每个项目自动创建一个 .xcodeproj 文件,那么它由什么组成,又承担什么作用呢?

.xcodeproj 看起来像一个文件,其实是一个包。它保存的是 Xcode 如何理解这份工程:有哪些 target、文件如何进入构建、使用什么设置进行编译,以及用户在开发过程中的部分状态信息,例如断点、折叠、光标、窗口和上次打开的文件等。

一份常见的单工程档案大致是这样:

Text
MyApp.xcodeproj/
├── project.pbxproj                 # 工程描述(Xcode 27.2 起也可以是 project.xcproj)
├── project.xcworkspace/            # 单工程也会带一个隐式 workspace
│   ├── contents.xcworkspacedata
│   ├── xcshareddata/
│   └── xcuserdata/
├── xcshareddata/
│   ├── xcschemes/                  # 共享 scheme:Run / Test / Profile / Archive
│   └── swiftpm/
│       └── Package.resolved        # SPM 依赖锁版本
└── xcuserdata/
    └── <用户名>.xcuserdatad/
        ├── xcdebugger/
        │   └── Breakpoints_v2.xcbkptlist   # 这个工程里的断点
        ├── xcschemes/                      # 仅自己可见的 scheme
        └── UserInterfaceState.xcuserstate  # 折叠、光标、窗口、上次打开的文件

源码、.xcconfigInfo.plist 都不在这个包里,xcuserdata 在实践中通常也会被添加到 .gitignore 中。

在这个包中,最关键的是 project.pbxproj(Xcode 27.2 起也可以是 project.xcproj)。它是真正描述工程构建关系的文件。

pbxproj:颇具时代感的声明结构

project.pbxproj 是 Xcode Project 中的构建图,包括:工程有哪些 target;工程导航中的文件树;文件如何进入构建(Compile Sources、Copy Bundle Resources 等);Debug / Release 以及 build settings(这部分可以外置到 .xcconfig);依赖以及工程级元数据。

它使用的格式可以追溯到 NeXTSTEP 时代,不是 XML plist,也不是 JSON,而是 NeXT 留下的 OpenStep / ASCII plist:大括号、分号、isa,再配上一堆 24 位十六进制 ID。三十多年后的今天,一份典型的 pbxproj 依然是这样的:

Text
// !$*UTF8*$!
{
    archiveVersion = 1;
    objectVersion = 90;
    objects = {
        A1B2C3D4E5F6789012345678 /* ContentView.swift */ = {
            isa = PBXFileReference;
            lastKnownFileType = sourcecode.swift;
            path = ContentView.swift;
            sourceTree = "<group>";
        };
        ...
    };
    rootObject = 000000000000000000000000 /* Project object */;
}

objects 是一张巨大的扁平字典。文件、group、target、build phase、build file、configuration list,全部以 ID 为键平铺在一起,再通过 ID 互相引用。

这种结构面对多人协作时非常不友好,会变成仓库里最难合并的文本之一。原因很简单:它既是共享真相,又用一种对协作不友好的方式编码这份真相。一次很小的共享改动,可能会在很多不相邻的位置留下痕迹。

而且,在这个格式中,一些配置使用有序数组保存,但其中的顺序对编译往往没有实际语义。两个人分别添加两个文件,即使操作完全不冲突,也可能同时修改同一段列表,Git 就会报告冲突。看起来像是“工程坏了”,实际上可能只是两个无关文件被插进了同一个集合。

Xcode 团队近年来对 Folder 的引导,便是缓解上述问题的途径之一。旧的 Group 会把每个文件写进 pbxproj;Xcode 16 起引入的 Folder 则主要记录目录关系,打开工程时与磁盘内容保持同步,日常增删源码几乎不再需要修改工程文件。把源码目录换成 Folder,工程文件的 diff 和冲突都会明显减少。

到了 Agent 时代,pbxproj 的弊端被进一步放大。大量 ID 承担着对象身份和交叉引用的职责,Agent 要修改工程时,往往需要先在扁平对象表里找到正确的对象,再同步修改多个相互引用的位置,还要尽量保证序列化结果符合 Xcode 的习惯。时间不是花在“理解工程模型”上,而是花在“模仿 Xcode 的文本格式”上。

xcproj 的变化

作为 project.pbxproj 的替代品,苹果给 project.xcproj 的定位很克制:让 diff 更容易理解、减少合并冲突,并尽可能降低迁移成本。同一套工程模型,只是换了一种表达方式。它不是新的工程 DSL,也不是 Tuist Project.swift 这类工程清单的替代品。

也就是说,project.xcproj 并不会对 .xcodeproj 包带来根本变化,只是替换了其中构建图的表达方式。scheme、Package.resolvedxcuserdata 都不在这次变化的范围内。旧工程不会被自动转换,Xcode 27.2 同时支持新旧格式。

Xcode 27.2 中创建新项目时,默认已经采用 project.xcproj。旧工程可以在 File inspector → Project Format 中选择 JSON,或使用命令行进行转换:

Bash
xcodebuild -project MyApp.xcodeproj -convert-project "Xcode Project"

不过,xcproj 并不是简单地把 pbxproj 原样翻译成 JSON,而是重新调整了工程模型的表达方式:

  • 从扁平 ID 表变成更接近 UI 结构的树:不再把所有内容都丢进 objects,而是按照 files / targets / build-settings 等结构展开,让 diff 更接近实际的界面操作;
  • 调整了关系的描述方向:例如文件可以通过 target-membership 声明自己属于哪些 target,添加一个文件不再需要同时修改多个相互引用的位置;
  • ID 并没有消失,但交叉引用会更多地使用名称和路径,日常 diff 中不再充斥大量难以理解的 ID。

差别可以直观地感受一下。同一个 ContentView.swift,在 pbxproj 中需要在文件引用、build file、group 的 children、Sources build phase 四处各留一笔;而在 xcproj 中显式引用文件时,大概是这样:

JSON
{
  "kind": "group",
  "path": "Sources",
  "children": [
    { "path": "ContentView.swift", "target-membership": [ "MyApp/compile-sources" ] },
    { "path": "MyFramework.h", "target-membership": [ { "build-phase": "MyKit/headers", "header-role": "public" } ] }
  ]
}

几处细节值得留意:ContentView.swift 没有写 kind,因为默认就是 file reference;普通编译文件的 membership 可以只是一根字符串,MyApp/compile-sources 是「target 名 + build phase 种类」的紧凑引用,不再需要 UUID;只有携带额外属性时才展开成对象——把头文件标成 public,不必再去改某个 PBXBuildFile.settings.ATTRIBUTES

真正重要的并不是“终于用了 JSON”,而是这些结构变化让工程文件的文本表达更接近它所描述的工程语义。

与此同时,苹果还发布了开源项目 xcode-project-format,提供与新工程格式相关的模型和工具。Xcode 27.2 的 /usr/bin 中也包含同名项目提供的 xcprojformatter。它负责校验并按照规范重新格式化已经采用 xcproj 的工程文件,并不是 pbxprojxcproj 的转换器。对 CI 来说,可以把它理解为工程配置文件对应的 swift-format

对其他方案的影响

就像上文所说,xcproj 只是更换了包中那份构建图的表达方式,并不改变 .xcodeproj 的基本角色,也不会取代 Tuist、XcodeGen 这类工程清单。对现有工作流来说,变化并不大:生成器继续生成工程,修改工程的工具继续修改工程,只需要根据自身需求逐步适配新的格式。

方案真相在哪主要解决什么换 xcproj 之后
手维护 pbxprojXcode 工程—(默认状态)直接受益,可以优先考虑迁移
xcconfig设置文件设置复用继续使用,与 xcproj 互补
Synchronized folderXcode 工程减少增删文件产生的 diff继续发挥作用,并融入新的工程表达方式
XcodeGenYAML用清单生成工程可逐步适配 xcproj,清单本身的价值仍在
TuistSwift manifest生成 + 模块化 + 缓存 + 约束当前工作流不变,等待工具链进一步适配
BazelBUILDhermetic build / 远程缓存Xcode 工程生成层可适配,构建逻辑不变
SwiftPMPackage.swift用包模型描述 Swift Package基本无关
工程修改类工具还是 Xcode 工程在已有工程上改依赖、加配置需逐个确认支持情况,切换前务必验证

对于独立开发者或仍然直接维护 .xcodeproj 的小团队,收益最直接。已经使用其他工程生成或构建工具的团队,则可以等待相应工具完成适配,原有开发逻辑并不会因此发生根本变化。

进步,但还不是革命

pbxproj 的问题已经被开发者抱怨了很多年。社区给出的一种解决方式,是使用生成器,把 Xcode 工程本身变成“编译产物”。这很有效,但某种程度上,也把一个本该属于 IDE 的责任转移到了工具作者身上。

Xcode 27.2 选择在这个时间点重写工程格式,显然不只是为了 Git。苹果把介绍新格式的文章放在 Coding Intelligence 下。新的格式所改善的,恰好也是当前 Coding Agent 操作 Xcode 工程时最明显的一类痛点:不必再为了改动一处配置,去揣摩一份三十年前的序列化习惯。

对还在手动维护 .xcodeproj 的开发者来说,这是可喜的进步,但还谈不上革命。

社区真正期待了很多年的,其实是一种类似 Package.swift 的工程清单:它本来就是给人写、给人读,也给人 review 的,而不是把 IDE 内部数据库换一种更现代的编码方式。

swift package generate-xcodeproj 被废弃时,就有人提出过几乎相同的诉求:工程配置应该 easily readable and code reviewable。但苹果当时给出的方向不同——Xcode 专用的信息不应该进入一个面向跨平台 Swift Package 的清单。

苹果其实也曾展示过另一种可能。Swift Playgrounds 使用 .swiftpm 配合 AppleProductTypes.iOSApplication,已经可以描述一个能够在 iPad 上开发并最终上架的 App。但相关配置顶部又明确标注着自动生成、不要手动修改。至少到目前为止,苹果似乎仍然更倾向于让工具维护工程描述,而不是把它变成开发者直接编写的公开接口。

所以,xcproj 更像是把旧轨道修得能够跑 Agent,而不是换了一条轨道。

Folder 让文件系统重新成为源码的真相,JSON 则让剩下的构建图变得更加可读。这两步之后,再设想一份真正面向开发者、可以手写和 review 的工程清单,至少已经不像过去那么遥远。

至于苹果是否愿意继续向前走,就只能继续等待了。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅