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

眼一花,基本上就合并出錯了,輕則工程少文件,重則把語法玩壞了,Xcode直接打不開了。
分析
pbxproj文件簡要說明
pbxproj是個plist文件,plist的格式跟json的差不多,就是一個個對象,對象是個字典,可以關(guān)聯(lián)一些字段和它的值。pbxproj的總體框架如下:
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 45;
objects = {
/* ... */
};
rootObject = 29B97313FDCFA39411CA2CEA /* Project object */;
}
其中objects就是主要的字段。它本身又是一個對象,里面包含了一個個的鍵值對。如下:
BF3014CF1C10632C0080D38E = {
isa = PBXGroup;
children = (
BF3014DA1C10632C0080D38E /* PBTest */,
BF3014F41C10632C0080D38E /* PBTestTests */,
BF3014FF1C10632D0080D38E /* PBTestUITests */,
BF3014D91C10632C0080D38E /* Products */,
);
sourceTree = "<group>";
};
這里的BF3014CF1C10632C0080D38E 是uuid,而后面又是對象。objects中的對象都有一個isa字段,表明了object的類型,而object的其他字段取決于object的類型。
objects中根據(jù)uuid和對象的關(guān)聯(lián),就可以唯一標識這個對象,方便對象的相互引用。如,通過uuid,PBXFileReference 類型的對象可以被PBXBuildFile和PBXGroup對象引用,PBXBuildFile 對象可以被PBXSourcesBuildPhase 對象引用。
這里對一些常用的類型,進行簡要說明:
- PBXFileReference
PBXFileReference用來跟蹤工程中使用的外部文件(對應(yīng)到磁盤),包括源文件、頭文件、資源文件、庫、生成的應(yīng)用文件等,它會被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會有對應(yīng)的PBXBuildFile,它會被PBXSourcesBuildPhase或PBXResourcesBuildPhase調(diào)用
,這里一般不會有.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
編譯過程,列出一些PBXBuildFile。如果有多個target,則會有多個source,如uitest、unit-test都會生成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
這個用來編譯資源文件,如:
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
對應(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中會把相同類型的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 */
常見的沖突
根據(jù)我的多次合并經(jīng)驗,發(fā)現(xiàn)pbxproj文件沖突,主要是在跟文件相關(guān)的object的合并上。跟文件相關(guān)的object,主要就是上面具體描述的那幾種類型:
- PBXFileReference
- PBXBuildFile
- PBXSourcesBuildPhase
- PBXResourcesBuildPhase
- PBXGroup
造成沖突的原因主要有:
- 位置變化
一般來說,除了PBXGroup 中文件是按實際的位置(比如在Xcode中的某個group中,把文件拉到前面的位置,那么它在pbxproj中的位置就在前面),其他的幾個基本上跟文件的創(chuàng)建時間有關(guān)系,后面創(chuàng)建的文件,對應(yīng)產(chǎn)生的PBXBuildFile 等對象就排在后面。
但是,文件一多,再通過多人操作,PBXBuildFile 等對象的順序往往就沒規(guī)律了。如本文開頭所舉的示例中,雖然大多數(shù)object相同,但是由于它們在兩邊的位置不同,導(dǎo)致diff時比較困難。
- 文件重命名,導(dǎo)致文件名不同
在Xcode中對文件重命名后,相關(guān)的uuid并不會變化。只是對應(yīng)的注釋中的文件名發(fā)生變化。
- 移動文件,導(dǎo)致uuid變化
這里說的移動,指的是刪除文件,并重新添加到工程。如項目重構(gòu)時,可能要建立子目錄,并把相應(yīng)文件刪除,并重新添加。移動文件后,對應(yīng)的uuid肯定變了,但是注釋中的文件名還是一樣的。
- 新增文件
新增文件,會在PBXBuildFile 等分區(qū)中添加相應(yīng)的對象。
解決
根據(jù)上面的分析,如果我們把容易造成沖突的對象進行重新排序,并把兩邊相同的對象放前面,然后是重命名或移動了的對象,最后是兩邊各自新增的對象,那么,后面再合并時,就要直觀很多。
所以,解決方法是使用腳本,把兩個pbxproj文件進行上述的處理生成兩個新的文件,然后再使用比較工具對兩個新文件進行比較合并。
regex come to rescure
剛開始,考慮用plist的語法去解析,但是這樣解析后再寫回,會把文件中的注釋搞沒了。想起使用了無數(shù)次的正則表達式,最終考慮使用正則表達式來處理。
考慮到我們工程一般很少用xib,所以PBXResourcesBuildPhase 就不做處理,PBXGroup 分組一般是每個人自己維護(如一個功能模塊一個group),所以也不處理。最終的處理分三步,
- 處理
PBXBuildFile section中的沖突 - 處理
PBXFileReference section中的沖突 - 處理
PBXSourcesBuildPhase section中的沖突
每一步的處理,都是先匹配出section,然后在section中查找所有的對象,并把這些對象進行重新排序,最后把排序后的對象寫回。
用來匹配section的正則表達式有:
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)
用來匹配section中對象的正則如下:
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
需要注意的是,對PBXSourcesBuildPhase的解析,由于PBXSourcesBuildPhase結(jié)構(gòu)層級中多了一層,所以需要多一層正則去匹配處理。
完整的代碼見pbMerge.py,python正則表達式的使用,可以參考我之前寫的python正則表達式。
經(jīng)過腳本的處理后,本文開頭的例子就變成這樣,已經(jīng)十分好合并了:

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