引言
Xcode的工程文件是 工程名.xcodeproj,而它其實(shí)是個(gè)package目錄,通過(guò)顯示包內(nèi)容,可以查看到它內(nèi)部主要有project.pbxproj 和 xcuserdata。其中,xcuserdata 一般是跟用戶相關(guān)的一些設(shè)置,如斷點(diǎn) 記錄等,一般不用放到版本管理中。而project.pbxproj 是工程描述文件,描述了工程里的源碼文件、schema設(shè)置等。它的格式是文本類型的plist(Info.plist是binary plist),里面是一個(gè)一個(gè)的object,具體的各種object定義可以參見(jiàn)文末給出的鏈接。
project.pbxproj 的合并歷來(lái)都是代碼版本管理的噩夢(mèng)。特別是當(dāng)代碼框架進(jìn)行重構(gòu)時(shí),純手工合并,簡(jiǎn)直就是不要不要的。如下面是兩個(gè)工程文件的diff,大家感受下:

眼一花,基本上就合并出錯(cuò)了,輕則工程少文件,重則把語(yǔ)法玩壞了,Xcode直接打不開(kāi)了。
分析
pbxproj文件簡(jiǎn)要說(shuō)明
pbxproj是個(gè)plist文件,plist的格式跟json的差不多,就是一個(gè)個(gè)對(duì)象,對(duì)象是個(gè)字典,可以關(guān)聯(lián)一些字段和它的值。pbxproj的總體框架如下:
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 45;
objects = {
/* ... */
};
rootObject = 29B97313FDCFA39411CA2CEA /* Project object */;
}
其中objects就是主要的字段。它本身又是一個(gè)對(duì)象,里面包含了一個(gè)個(gè)的鍵值對(duì)。如下:
BF3014CF1C10632C0080D38E = {
isa = PBXGroup;
children = (
BF3014DA1C10632C0080D38E /* PBTest */,
BF3014F41C10632C0080D38E /* PBTestTests */,
BF3014FF1C10632D0080D38E /* PBTestUITests */,
BF3014D91C10632C0080D38E /* Products */,
);
sourceTree = "<group>";
};
這里的BF3014CF1C10632C0080D38E 是uuid,而后面又是對(duì)象。objects中的對(duì)象都有一個(gè)isa字段,表明了object的類型,而object的其他字段取決于object的類型。
objects中根據(jù)uuid和對(duì)象的關(guān)聯(lián),就可以唯一標(biāo)識(shí)這個(gè)對(duì)象,方便對(duì)象的相互引用。如,通過(guò)uuid,PBXFileReference 類型的對(duì)象可以被PBXBuildFile和PBXGroup對(duì)象引用,PBXBuildFile 對(duì)象可以被PBXSourcesBuildPhase 對(duì)象引用。
這里對(duì)一些常用的類型,進(jìn)行簡(jiǎn)要說(shuō)明:
- PBXFileReference
PBXFileReference用來(lái)跟蹤工程中使用的外部文件(對(duì)應(yīng)到磁盤(pán)),包括源文件、頭文件、資源文件、庫(kù)、生成的應(yīng)用文件等,它會(huì)被PBXGroup、PBXBuildFile等調(diào)用,如:
BF30150E1C106FD70080D38E /* AAStable1ViewController.h */ = {
isa = PBXFileReference;
fileEncoding = 4;
lastKnownFileType = sourcecode.c.h;
path = AAStable1ViewController.h;
sourceTree = "<group>";
};
BF30150F1C106FD70080D38E /* AAStable1ViewController.m */ = {
isa = PBXFileReference;
fileEncoding = 4;
lastKnownFileType = sourcecode.c.objc;
path = AAStable1ViewController.m;
sourceTree = "<group>";
};
BF3014E51C10632C0080D38E /* Base */ = {
isa = PBXFileReference;
lastKnownFileType = file.storyboard;
name = Base; path = Base.lproj/Main.storyboard;
sourceTree = "<group>";
};
- PBXBuildFile
參與編譯的PBXFileReference會(huì)有對(duì)應(yīng)的PBXBuildFile,它會(huì)被PBXSourcesBuildPhase或PBXResourcesBuildPhase調(diào)用
,這里一般不會(huì)有.h文件,如
BF3015101C106FD70080D38E /* AAStable1ViewController.m in Sources */ = {
isa = PBXBuildFile;
fileRef = BF30150F1C106FD70080D38E /* AAStable1ViewController.m */;
settings = {ASSET_TAGS = (); };
};
BF3014E61C10632C0080D38E /* Main.storyboard in Resources */ = {
isa = PBXBuildFile;
fileRef = BF3014E41C10632C0080D38E /* Main.storyboard */;
};
- PBXSourcesBuildPhase
編譯過(guò)程,列出一些PBXBuildFile。如果有多個(gè)target,則會(huì)有多個(gè)source,如uitest、unit-test都會(huì)生成source,下面是主target的source,
BF3014D41C10632C0080D38E /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BF3015161C10700E0080D38E /* AAStable3ViewController.m in Sources */,
BF3015101C106FD70080D38E /* AAStable1ViewController.m in Sources */,
BF3015221C10707E0080D38E /* AAFileMayMoveViewController.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
- PBXResourcesBuildPhase
這個(gè)用來(lái)編譯資源文件,如:
BF3014D61C10632C0080D38E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BF3014EB1C10632C0080D38E /* LaunchScreen.storyboard in Resources */,
BF3014E81C10632C0080D38E /* Assets.xcassets in Resources */,
BF3014E61C10632C0080D38E /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
- PBXGroup
對(duì)應(yīng)工程中的group,如:
BF3014DA1C10632C0080D38E /* PBTest */ = {
isa = PBXGroup;
children = (
BF3014DE1C10632C0080D38E /* AppDelegate.h */,
BF3014DF1C10632C0080D38E /* AppDelegate.m */,
BF3014E41C10632C0080D38E /* Main.storyboard */,
BF3014E71C10632C0080D38E /* Assets.xcassets */,
BF3014E91C10632C0080D38E /* LaunchScreen.storyboard */,
BF3014EC1C10632C0080D38E /* Info.plist */,
BF3014DB1C10632C0080D38E /* Supporting Files */,
);
path = PBTest;
sourceTree = "<group>";
};
另外,pbxproj中會(huì)把相同類型的object放在一起,并在前后添加注釋,如:
/* Begin PBXBuildFile section */
BF3015101C106FD70080D38E /* AAStable1ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = BF30150F1C106FD70080D38E /* AAStable1ViewController.m */; settings = {ASSET_TAGS = (); }; };
BF3014E01C10632C0080D38E /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = BF3014DF1C10632C0080D38E /* AppDelegate.m */; };
BF3015131C106FF50080D38E /* AAStable2ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = BF3015121C106FF50080D38E /* AAStable2ViewController.m */; settings = {ASSET_TAGS = (); }; };
/* End PBXBuildFile section */
常見(jiàn)的沖突
根據(jù)我的多次合并經(jīng)驗(yàn),發(fā)現(xiàn)pbxproj文件沖突,主要是在跟文件相關(guān)的object的合并上。跟文件相關(guān)的object,主要就是上面具體描述的那幾種類型:
- PBXFileReference
- PBXBuildFile
- PBXSourcesBuildPhase
- PBXResourcesBuildPhase
- PBXGroup
造成沖突的原因主要有:
- 位置變化
一般來(lái)說(shuō),除了PBXGroup 中文件是按實(shí)際的位置(比如在Xcode中的某個(gè)group中,把文件拉到前面的位置,那么它在pbxproj中的位置就在前面),其他的幾個(gè)基本上跟文件的創(chuàng)建時(shí)間有關(guān)系,后面創(chuàng)建的文件,對(duì)應(yīng)產(chǎn)生的PBXBuildFile 等對(duì)象就排在后面。
但是,文件一多,再通過(guò)多人操作,PBXBuildFile 等對(duì)象的順序往往就沒(méi)規(guī)律了。如本文開(kāi)頭所舉的示例中,雖然大多數(shù)object相同,但是由于它們?cè)趦蛇叺奈恢貌煌?,?dǎo)致diff時(shí)比較困難。
- 文件重命名,導(dǎo)致文件名不同
在Xcode中對(duì)文件重命名后,相關(guān)的uuid并不會(huì)變化。只是對(duì)應(yīng)的注釋中的文件名發(fā)生變化。
- 移動(dòng)文件,導(dǎo)致uuid變化
這里說(shuō)的移動(dòng),指的是刪除文件,并重新添加到工程。如項(xiàng)目重構(gòu)時(shí),可能要建立子目錄,并把相應(yīng)文件刪除,并重新添加。移動(dòng)文件后,對(duì)應(yīng)的uuid肯定變了,但是注釋中的文件名還是一樣的。
- 新增文件
新增文件,會(huì)在PBXBuildFile 等分區(qū)中添加相應(yīng)的對(duì)象。
解決
根據(jù)上面的分析,如果我們把容易造成沖突的對(duì)象進(jìn)行重新排序,并把兩邊相同的對(duì)象放前面,然后是重命名或移動(dòng)了的對(duì)象,最后是兩邊各自新增的對(duì)象,那么,后面再合并時(shí),就要直觀很多。
所以,解決方法是使用腳本,把兩個(gè)pbxproj文件進(jìn)行上述的處理生成兩個(gè)新的文件,然后再使用比較工具對(duì)兩個(gè)新文件進(jìn)行比較合并。
regex come to rescure
剛開(kāi)始,考慮用plist的語(yǔ)法去解析,但是這樣解析后再寫(xiě)回,會(huì)把文件中的注釋搞沒(méi)了。想起使用了無(wú)數(shù)次的正則表達(dá)式,最終考慮使用正則表達(dá)式來(lái)處理。
考慮到我們工程一般很少用xib,所以PBXResourcesBuildPhase 就不做處理,PBXGroup 分組一般是每個(gè)人自己維護(hù)(如一個(gè)功能模塊一個(gè)group),所以也不處理。最終的處理分三步,
- 處理
PBXBuildFile section中的沖突 - 處理
PBXFileReference section中的沖突 - 處理
PBXSourcesBuildPhase section中的沖突
每一步的處理,都是先匹配出section,然后在section中查找所有的對(duì)象,并把這些對(duì)象進(jìn)行重新排序,最后把排序后的對(duì)象寫(xiě)回。
用來(lái)匹配section的正則表達(dá)式有:
gBuidFileSectionPattern = re.compile(r'''(?i)(.*/\* Begin PBXBuildFile section \*/\s+?)(.*?)(/\* End PBXBuildFile section \*/.*)''', re.S)
gFileReferenceSectionPattern = re.compile(r'''(?i)(.*/\* Begin PBXFileReference section \*/\s+?)(.*?)(/\* End PBXFileReference section \*/.*)''', re.S)
gSourceBuildPhaseSectionPattern = re.compile(r'''(?i)(.*/\* Begin PBXSourcesBuildPhase section \*/\s+?)(.*?)(/\* End PBXSourcesBuildPhase section \*/.*)''', re.S)
用來(lái)匹配section中對(duì)象的正則如下:
gBuidFilePattern = re.compile(r'''(?i)(^\s+(\w+) /\* (\S*)\s.*?$)''', re.S|re.M)
gFileReferencePattern = gBuidFilePattern
gSourceBuildPhaseSourcePattern = re.compile(r'''(^\s+(\w+?) /\* Sources \*/.*?$.*?^\s+files.*?$\n)(.*?)(^\s+\);.*?};\n)''', re.S|re.M)
gSourceBuildPhaseFilePattern = gBuidFilePattern
需要注意的是,對(duì)PBXSourcesBuildPhase的解析,由于PBXSourcesBuildPhase結(jié)構(gòu)層級(jí)中多了一層,所以需要多一層正則去匹配處理。
完整的代碼見(jiàn)pbMerge.py,python正則表達(dá)式的使用,可以參考我之前寫(xiě)的python正則表達(dá)式。
經(jīng)過(guò)腳本的處理后,本文開(kāi)頭的例子就變成這樣,已經(jīng)十分好合并了:

結(jié)論
本文使用半自動(dòng)方法,來(lái)對(duì)project.pbxproj文件的沖突進(jìn)行解決。通過(guò)對(duì)該文件的預(yù)合并,使后面手動(dòng)合并時(shí)更直觀,同時(shí)極大地減少了工程文件合并出錯(cuò),導(dǎo)致工程無(wú)法打開(kāi)的問(wèn)題。
參考
A brief look at the Xcode project format
Xcode Project File Format
http://www.zhihu.com/question/19763504/answer/14091247