Xcode中project.pbxproj合并冲突的解决

引言

Xcode的工程文件是 工程名.xcodeproj,而它其实是个package目录,通过显示包内容,可以查看到它内部主要有project.pbxprojxcuserdata。其中,xcuserdata 一般是跟用户相关的一些设置,如断点 记录等,一般不用放到版本管理中。而project.pbxproj 是工程描述文件,描述了工程里的源码文件、schema设置等。它的格式是文本类型的plist(Info.plist是binary plist),里面是一个一个的object,具体的各种object定义可以参见文末给出的链接。

project.pbxproj 的合并历来都是代码版本管理的噩梦。特别是当代码框架进行重构时,纯手工合并,简直就是不要不要的。如下面是两个工程文件的diff,大家感受下:

处理前的工程文件对比

眼一花,基本上就合并出错了,轻则工程少文件,重则把语法玩坏了,Xcode直接打不开了。

分析

pbxproj文件简要说明

pbxproj是个plist文件,plist的格式跟json的差不多,就是一个个对象,对象是个字典,可以关联一些字段和它的值。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中根据uuid和对象的关联,就可以唯一标识这个对象,方便对象的相互引用。如,通过uuid,PBXFileReference 类型的对象可以被PBXBuildFilePBXGroup对象引用,PBXBuildFile 对象可以被PBXSourcesBuildPhase 对象引用。

这里对一些常用的类型,进行简要说明:

  • PBXFileReference

PBXFileReference用来跟踪工程中使用的外部文件(对应到磁盘),包括源文件、头文件、资源文件、库、生成的应用文件等,它会被PBXGroup、PBXBuildFile等调用,如:

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会有对应的PBXBuildFile,它会被PBXSourcesBuildPhase或PBXResourcesBuildPhase调用
,这里一般不会有.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

对应工程中的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 */      

常见的冲突

根据我的多次合并经验,发现pbxproj文件冲突,主要是在跟文件相关的object的合并上。跟文件相关的object,主要就是上面具体描述的那几种类型:

  • PBXFileReference
  • PBXBuildFile
  • PBXSourcesBuildPhase
  • PBXResourcesBuildPhase
  • PBXGroup

造成冲突的原因主要有:

  • 位置变化

一般来说,除了PBXGroup 中文件是按实际的位置(比如在Xcode中的某个group中,把文件拉到前面的位置,那么它在pbxproj中的位置就在前面),其他的几个基本上跟文件的创建时间有关系,后面创建的文件,对应产生的PBXBuildFile 等对象就排在后面。

但是,文件一多,再通过多人操作,PBXBuildFile 等对象的顺序往往就没规律了。如本文开头所举的示例中,虽然大多数object相同,但是由于它们在两边的位置不同,导致diff时比较困难。

  • 文件重命名,导致文件名不同

在Xcode中对文件重命名后,相关的uuid并不会变化。只是对应的注释中的文件名发生变化。

  • 移动文件,导致uuid变化

这里说的移动,指的是删除文件,并重新添加到工程。如项目重构时,可能要建立子目录,并把相应文件删除,并重新添加。移动文件后,对应的uuid肯定变了,但是注释中的文件名还是一样的。

  • 新增文件

新增文件,会在PBXBuildFile 等分区中添加相应的对象。

解决

根据上面的分析,如果我们把容易造成冲突的对象进行重新排序,并把两边相同的对象放前面,然后是重命名或移动了的对象,最后是两边各自新增的对象,那么,后面再合并时,就要直观很多。

所以,解决方法是使用脚本,把两个pbxproj文件进行上述的处理生成两个新的文件,然后再使用比较工具对两个新文件进行比较合并。

regex come to rescure

刚开始,考虑用plist的语法去解析,但是这样解析后再写回,会把文件中的注释搞没了。想起使用了无数次的正则表达式,最终考虑使用正则表达式来处理。

考虑到我们工程一般很少用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结构层级中多了一层,所以需要多一层正则去匹配处理。

完整的代码见pbMerge.py,python正则表达式的使用,可以参考我之前写的python正则表达式

经过脚本的处理后,本文开头的例子就变成这样,已经十分好合并了:

预合并后工程文件的比较

结论

本文使用半自动方法,来对project.pbxproj文件的冲突进行解决。通过对该文件的预合并,使后面手动合并时更直观,同时极大地减少了工程文件合并出错,导致工程无法打开的问题。

参考

A brief look at the Xcode project format
Xcode Project File Format
http://www.zhihu.com/question/19763504/answer/14091247

推荐阅读更多精彩内容