Post

XCode 虚拟目录转实体目录|解决专案结构混乱与 XcodeGen、Tuist 整合痛点

针对 XCode 早期虚拟目录造成专案结构混乱、难以使用 XcodeGen 与 Tuist,提供纯 Swift 开源工具 XCFolder,快速转换虚拟目录为实体目录,降低合并冲突风险,提升团队协作与 CI/CD 效率。

XCode 虚拟目录转实体目录|解决专案结构混乱与 XcodeGen、Tuist 整合痛点

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


XCode 虚拟目录万年问题探究与我的开源工具解决方案

Apple 开发者职业伤害之 Xcode 早期使用虚拟目录,导致目录结构混乱且难以整合 XcodeGen, Tuist 等现代工具。

Photo by [Saad Salim](https://unsplash.com/@saadx?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

Photo by Saad Salim

English version of this post:

Exploring the Long-Standing Issues of XCode Virtual Directories and My Open Source Tool Solution

背景

随著团队规模与专案的成长,XCode 专案档 ( .xcodeproj) 的体积会逐渐增大,依照专案复杂程度可以到十万行、甚至百万行,加上多人多条线并行开发,免不了会出现冲突问题; XcodeProj 档案一旦出现冲突就跟 Storyboard / .xib 的冲突一样难解 ,因也是纯描述档,很容易在解冲突时不小心把别人新增的档案移除或是别人移除的档案参照又跑回来。

另外的问题是随著模组化推进,在 XCode 专案档 ( .xcodeproj) 建立、管理模组的流程非常不友善,模组有变动一样只能从 XCode 专案档 的 Diff 中查看,整体不利于团队朝模组化迈进。

如果只是为了防止冲突可以简单地在 pre-commit 做 File Sorting ,现有的脚本 Github 上很多,可以直接参考设置。

XCFolder

长话短说 ,开发了一个工具可以帮你把 XCode 早期的虚拟目录转依照 XCode 里的目录结构换成实体目录。

下滑继续看故事…

现代化 Xcode Project 档案管理

具体思维同我们在多人开发时不鼓励使用 Storyboard or .xib 一样, 我们需要一个好维护、可迭代、可被 Code Review 的介面来管理「XCode 专案档」 ,目前市场主流有两套免费工具可以使用:

  • XCodeGen :老牌工具,使用 YAML 定义 XCode 专案内容,再透过它转换成 XCode 专案档 ( .xcodeproj)。 直接使用 YAML 定义结构、导入难度及学习成本较低、但是对模组化或是依赖管理功能较弱、YAML 动态设置弱。

  • Tuist :近几年出的新工具,使用 Swift DSL 定义 XCode 专案内容,再透过它转换成 XCode 专案档 ( .xcodeproj)。 更稳定多变,内建模组化及依赖管理功能,但学习与导入门槛较高。

不论是那一套我们的核心工作流程都会变成是:

  1. 建立 XCode 专案内容设定档 (XCodeGen project.yaml or Tuist Project.swift )

  2. 将 XCodeGen or Tuist 加入开发者与 CI/CD Server 环境中

  3. 使用 XCodeGen or Tuist 透过设定档产生 .xcodeproj XCode 专案档

  4. /*.xcodeproj 目录档案 加入 .gitignore

  5. 调整开发者流程,切换 Branch 时需要跑 XCodeGen or Tuist 透过设定档产生 .xcodeproj XCode 专案档

  6. 调整 CI/CD 流程,需要跑 XCodeGen or Tuist 透过设定档产生 .xcodeproj XCode 专案档

  7. 完成

.xcodeproj XCode 专案档是由 XCodeGen or Tuist 基于 YAML of Swift DSL 设定档产生的,同一份设定档 & 同个工具版本会产生相同结果;因此我们不需要再把 .xcodeproj XCode 专案档上到 Git 当中,就此可以保证以后不会再有 .xcodeproj 档案冲突;专案构建的改变、模组的新增改动我们都会回到定义的设定档中做调整,因为是 Yaml or Swift 写成的,我们可以很轻易地进行迭代跟 Code Review。

Tuist Swift 范例: Project.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import ProjectDescription

let project = Project(
    name: "MyApp",
    targets: [
        Target(
            name: "MyApp",
            platform: .iOS,
            product: .app,
            bundleId: "com.example.myapp",
            deploymentTarget: .iOS(targetVersion: "15.0", devices: [.iphone, .ipad]),
            infoPlist: .default,
            sources: ["Sources/**"],
            resources: ["Resources/**"],
            dependencies: []
        ),
        Target(
            name: "MyAppTests",
            platform: .iOS,
            product: .unitTests,
            bundleId: "com.example.myapp.tests",
            deploymentTarget: .iOS(targetVersion: "15.0", devices: [.iphone, .ipad]),
            infoPlist: .default,
            sources: ["Tests/**"],
            dependencies: [.target(name: "MyApp")]
        )
    ]
)

XCodeGen YAML 范例: project.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
name: MyApp
options:
  bundleIdPrefix: com.example
  deploymentTarget:
    iOS: '15.0'

targets:
  MyApp:
    type: application
    platform: iOS
    sources: [Sources]
    resources: [Resources]
    info:
      path: Info.plist
      properties:
        UILaunchScreen: {}
    dependencies:
      - framework: Vendor/SomeFramework.framework
      - sdk: UIKit.framework
      - package: Alamofire

  MyAppTests:
    type: bundle.unit-test
    platform: iOS
    sources: [Tests]
    dependencies:
      - target: MyApp

档案目录结构

XCodeGen or Tuist 会依照档案实际目录、位置产生 XCode 专案档 ( .xcodeproj) 的目录结构, 实际目录即为 XCode 专案档案目录

因此档案的实际目录位置就很重要,我们会直接用它来当 XCode 专案档案目录。

这在现代的 XCode / XCode 专案中,这两个目录的位置相等是再日常不过的事,但这个议题就是本文想要探究的项目。

早期 Xcode 使用虚拟目录

在早期的 XCode 中在档案目录上按右键「New Group」是不会建立实际目录的,档案会被放在专案根目录下并在 .xcodeproj XCode 专案档做 File Reference,因此目录只有在 XCode 专案档中可见,实际没有。

随著时代演变,Apple 在 XCode 上逐渐汰换了这个诡异的设计,后来的 XCode 从加上「New Group with Folder」到预设会建实体目录、不要建要选「New Group without Folder」在到 现在 (XCode 16) 只剩「New Group」+ 自动根据实体目录产生XCode 专案档案目录

虚拟目录的问题

  • 无法使用 XCodeGen or Tuist,因为都需要实体目录位置产生 XCode 专案档 ( .xcodeproj)。

  • Code Review 困难:在 Git Web GUI 上看不到目录结构,整个档案都是摊平的。

  • DevOps, 第三方工具整合困难:例如 Sentry, Github 可以依照目录派发警告或 Auto-Assigner,没有目录只有档案无法设定。

  • 专案目录结构极为复杂,一大堆档案摊平在根目录。

对于一个年代久远并且没有在早期意识到虚拟目录问题的专案,虚拟目录的档案有 3,000+ 个,手动对照搬移的话搬完应该就辞职去卖状元糕了,这真的可以称得上是「 Apple 开发者职业伤害 😔 」。

Xcode 专案虚拟目录 转 实体目录

基于上述种种原因,我们急迫的需要把 XCode 专案的虚拟目录转成实体目录,否则后续的专案现代化、更高效的开发流程都无法推进。

❌ Xcode 16 「Convert to folder」选项

XCode 16

XCode 16

去年 XCode 16 刚出时有关注到这个选单新选项,本来的期待是它可以自动帮我们把虚拟目录档案转换成实体目录。

但实际并不然,他需要你先把档案建好目录、放到对应的实体位置,点击「Convert to folder」后会帮你改成新的 XCode Project 目录设定方式「 PBXFileSystemSynchronizedRootGroup 」,说实话 对转换没有任何用,这比较是转换后可以升级成新的目录设定方式。

目录没有建立、档案没有放好,点「Convert to folder」就会报以下错误:

1
2
3
4
Missing Associated Folder
Each group must have an associated folder. The following groups are not associated with a folder:
• xxxx
Use the file inspector to associate a folder with each group, or delete the groups after moving their content to another group.

🫥 开源专案 venmo / synx

在 Github 搜寻许久只找到这个使用 Ruby 撰写的虚拟转实体目录开源专案工具,实际跑下来是有效果的但因年久失修 (~= 10 年未更新) 很多档案还是需要手动对应搬移,无法完全转换,因此放弃。

不过还是很感谢这个开源专案的启发,我才想说可以自己开发转换工具。

✅ 我的开源专案 ZhgChgLi / XCFolder

使用纯 Swift 开发的 Command Line Tools,基于 XcodeProj 解析 .xcodeproj XCode 专案档,读取所有档案取得所属目录、解析目录将虚拟目录转换成实体目录,将档案搬移到正确位置、最后再调整 .xcodeproj XCode 专案档目录设定,即可完成转换。

使用方式

1
2
3
git clone https://github.com/ZhgChgLi/XCFolder.git
cd ./XCFolder
swift run XCFolder YOUR_XCODEPROJ_FILE.xcodeproj ./Configuration.yaml

For Example:

1
swift run XCFolder ./TestProject/DCDeviceTest.xcodeproj ./Configuration.yaml

CI/CD 模式 ( Non Interactive Mode ):

1
swift run XCFolder YOUR_XCODEPROJ_FILE.xcodeproj ./Configuration.yaml --is-non-interactive-mode

Configuration.yaml 可以设定想要的执行参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 忽略的目录,不会解析转换
ignorePaths:
- "Pods"
- "Frameworks"
- "Products"

# 忽略的档案类型,不会被转换搬移
ignoreFileTypes:
- "wrapper.framework" # Frameworks
- "wrapper.pb-project" # Xcode project files
#- "wrapper.application" # Applications
#- "wrapper.cfbundle" # Bundles
#- "wrapper.plug-in" # Plug-ins
#- "wrapper.xpc-service" # XPC services
#- "wrapper.xctest" # XCTest bundles
#- "wrapper.app-extension" # App extensions

# 只建立目录、搬移档案,不调整 .xcodeproj XCode 专案档目录设定
moveFileOnly: false

# 优先使用 git mv 指令搬移档案
gitMove: true

⚠️ 执行前请注意:

  • 请确保 Git 无任何未 Commit 改变,因为怕脚本有误污染你的专案目录 (脚本执行会检查,若有未 Commit 改变会抛错误 ❌ Error: There are uncommitted changes in the repository )

  • 预设优先使用 git mv 指令搬移档案以确保 git file log 纪录完整,如果搬移失败或是非 Git 专案才会使用 FileSystem Move 搬移档案。

等待执行完成即可:

⚠️ 执行后请注意:

  • 请检查专案目录是否有遗漏(红色)的档案,数量少可以手动修正,数量多请确认 Configuration.yaml 里的 ignorePaths, ignoreFileTypes 设定是否正确,或是 建立一个 Issue 让我知道。

  • 检查 Build Setting 里的相关路径 e.g. LIBRARY_SEARCH_PATHS 是否需要手动更动路径

  • 建议 Clean & Build 看看

  • 如果懒得管现在的 .xcodeproj XCode 专案档,也可以直接开始使用 XCodeGen or Tuist 直接重新产生目录档案

修改脚本:

直接点击 ./Package.swift 就能开启专案调整脚本内容。

其他开发随记

  • 得力于 XcodeProj 我们可以很轻易的使用 Swift 物件方式存取 .xcodeproj XCode 专案档案内容

  • 同样使用 Clean Architecture 架构开发

  • PBXGroup 设定中若没有 path 只有 name 则为虚拟目录,反之则为实体目录

  • XCode 16 新的目录设定 PBXFileSystemSynchronizedRootGroup 只需宣告主目录就会自动从实体目录解析,不需要再把每个目录跟档案都宣告在 .xcodeproj XCode 专案档案内

  • 直接用 SPM (Package.swift) 方式开发 Command line tool 真的很方便!

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.