Project.pbxproj的剖析

project.pbxproj

  • 介绍

    project.pbxproj 文件被包含于 Xcode 工程文件 .xcodeproj 之中,存储着 Xcode 工程的各项配置参数。它本质上是一种旧风格的 Property List 文件,历史可追溯到 NeXT 的 OpenStep,其可读性不如 xml 和 json。Property List 在苹果家族的历史上存在三种格式:OpenStep,XML 和 Binary,*除了 OpenStep 被废弃不支持写入以外,其余格式都提供 API 支持读写。该文件以明确的编码信息开头,通常是UTF-8。这意味着该文件在其开始时不能承载BOM(Byte Ordering Mark:即字节顺序标记,它是插入到以UTF-8、UTF16或UTF-32编码Unicode文件开头的特殊标记,用来识别Unicode文件的编码类型。对于UTF-8来说,BOM并不是必须的,因为BOM用来标记多字节编码文件的编码类型和字节顺序(big-endian或little-endian),否则解析将失败。与 json 最明显的差别是:数组用小括号括起来并用逗号隔开元素;字典用大括号括起来并用分号隔开键值对,键值之间用等号连接;二进制数据用尖括号 <> 括起来:

    1
    2
    3
    4
    5
    6
    7
    数组:
    project.pbxproj => ( "1", "2", "3" )
    json => [ "1", "2", "3" ]
    字典:
    project.pbxproj => { "key" = "value"; ... }
    json => { "key" : "value", ... }

    对 Xcode 工程进行添加、删除文件或者配置编译参数等,实际上都是对 project.pbxproj文件的修改。使用 git diff 查看所做文件的修改,会发现唯一被修改的就是这个 project.pbxproj 文件,其他的文件并没有被修改。

  • 解析文件格式

    根元素总共有 5 个键值对,Key 分为:archiveVersionclassesobjectVersionobjectsrootObject。上图中所有的配置对象都在 objects 中,对象的Key 都为 UUID,对应的 Value 可当做是一个字典。

    这里的 key 是 UUID 作为交叉引用的索引,保证每个配置信息对象的唯一性。因为 UUID 根据主机Mac地址或(伪)随机数等加上时间戳生成(ps:UUID生成规则),避免了多人在同一时间段操作修改工程文件带来的问题。也就是说工程中每项配置对象都有个唯一的 UUID,然后其他配置对象想引用某个配置对象直接使用它的 UUID 即可。这就跟我们编程时使用指针指向某个对象的地址一样,其他对象的属性想引用它,只需要给属性传个指针地址就行了。

    文件的引用由 PBXBuildFile 中属性 fileRef 指向 PBXFileReference,它有一个path属性标明文件路径,但需要注意的是这里的 path 显示的路径可能不是项目目录下的全路径,只是显示文件名,要得到文件全路径,需要使用 PBXFileReference 的 UUID 索引到 PBXGroup、PBXReferenceProxy等中一层层递归向上搜索路径来拼接成全路径。

    objects 的键值对根据内容类型被分成了若干个 section,采用注释的方式分节也使得可读性更强。section 的数量跟工程有关,尤其是每个工程的 BuildPhase 和 Target 差别都很大,section 列表就是上图中objects后的分支结构,显示在 Xcode 中能看见所有的公共配置信息都存在于 project.pbxproj 中。主要包含跟文件相关的 BuildFile,Group 和 FileReference,跟编译相关的 BuildPhase 和 Build Configuration(List),以及一些列 Target 和 TargetDependency等。

    着重介绍几个属性:

  • PBXProject

    PBXProject标识着整个工程,由根元素的rootObject引入。

  • PBXBuildFile

    PBXBuildFile是文件类,被PBXBuildPhase等作为文件包含或被引用的资源。

  • PBXFileReference

    PBXFileReference用于跟踪项目引用的每一个外部文件,比如源代码文件、资源文件、库文件、生成目标文件等。

  • PBXGroup

    PBXGroup用于组文件,或者嵌套组, 是 Xcode 中用来组织文件的一种方式,它对文件系统没有任何影响,无论你创建或者删除一个 Group,都不会导致 folder 的增加或者移除。 当然如果在你删除时选择 Move to Trash 就是另外一说了。

    在 Group 中的文件的关系, 不会与 folder 中的有什么冲突, 它只是 Xcode 为你提供的一种分离关注的方式。Group 之间的关系, 也是在 project.pbxproj 中定义的,这个文件中包含了 Xcode 工程中所有 File 和 Group 的关系。

    Group 在我们的工程中就是黄色的文件夹,而 Folder 是蓝色的文件夹(一般在 Xcode 工程中, 我们不会使用 Folder)。

  • PBXNativeTarget

    PBXNativeTarget就是工程中的Target ,它指定了一个用于产品(product), 并且包含了从工程中的一些文件中构建产品的命令。这些命令使用构建设置(build settings)和构建阶段(build phases)的方式来组织, 可以在 Xcode 编辑器中改变这些设置。如果工程中有多个target,都会在这个section中有所体现。

  • PBXSourcesBuildPhase

    PBXSourcesBuildPhase用于构建阶段中编译源文件。

  • PBXResourcesBuildPhase

    PBXResourcesBuildPhase用于构建阶段需要复制的资源文件。

    文件格式具体分析详见 Xcode Project File Format

  • 操作 Property List 的途径

    plutil命令提供处理 Property list 文件的能力,将Property list 文件转成 XML 格式命令如下:

    1
    $ plutil -convert xml1 -s -r -o project.pbxproj.xml project.pbxproj

    在 Cocoa 的 NSPropertyListSerialization 也提供了类似的功能,更面向对象,其实 plutilNSPropertyListSerialization 底层都是调用 CoreFoundationCFPropertyList 相关的 API,所以功能类似。

    但两者在读入 project.pbxproj 文件时,字典中键值对的顺序会跟文件中原始的顺序不一致。这是因为字典为了实现快速查找会将 key 按序存储(比如字典序或用红黑树排序)。

    大多数操作 project.pbxproj 文件工具的原理,也就是利用 plutil 转成 json 或 xml 后进行处理。

  • 操作 project.pbxproj 文件

    Xcodeproj 是一个使用 Ruby 来创建和修改 Xcode 工程文件的工具. 属于Cocoapods的一个组件,Cocoapods 就是利用Xcodeproj组件,脚本化的管理任务和构造友好的Xcode库,它同时支持Xcode workspaces (.xcworkspace)configuration files (.xcconfig)Xcode Scheme files (.xcscheme)

    • 安装

      Xcodeproj 通过 RubyGems 安装,命令如下:

      1
      $ [sudo] gem install xcodeproj
    • 源码分析

      API文档地址,这里解析一下 Xcodeproj module 下 几个重要的类:

      • Project

        Xcodeproj 组件用这个 class 来抽象 Xcode project 文件,可用这个类来操作已存在的Xcode project 文件,甚至可以创建一个Xcode project 文件。调用 Project 的 API 返回 AbstractObject 的实例,这个 AbstractObject 的类就是用来包装 project.pbxproj 中的 objects,以此来描述 Xcode project 文件,进行操作。

      • Object

        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
        Object
        ├── AbstractObject
        │ ├── AbstractBuildPhase
        │ ├── PBXCopyFilesBuildPhase
        │ ├── PBXFrameworksBuildPhase
        │ ├── PBXHeadersBuildPhase
        │ ├── PBXResourcesBuildPhase
        │ ├── PBXRezBuildPhase
        │ ├── PBXShellScriptBuildPhase
        │ └── PBXSourcesBuildPhase
        │ ├── AbstractTarget
        │ ├── PBXAggregateTarget
        │ ├── PBXLegacyTarget
        │ └── PBXNativeTarget
        │ ├── AbstractObjectAttribute
        │ ├── PBXBuildFile
        │ ├── PBXBuildRule
        │ ├── PBXContainerItemProxy
        │ ├── PBXFileReference
        │ ├── PBXGroup
        │ ├── PBXVariantGroup
        │ └── XCVersionGroup
        │ ├── PBXProject
        │ ├── PBXReferenceProxy
        │ ├── PBXTargetDependency
        │ ├── XCBuildConfiguration
        │ └── XCConfigurationList

        上面就是Object module 中类的结构图,用 Object module 来抽象文件中的属性,其中的类名和 project.pbxproj 文件中属性名一样,不同在于添加了一些抽象类来管理相同类型的属性,这样设计使得操作属性非常方便,直接操作这些属性类就行。

      • UUIDGenerator

        在源工程 uuid_generator.rb 的文件中,类UUIDGenerator就是用来生成UUID,但与系统生成UUID不同的是,UUIDGenerator 利用文件路径,然后MD5之后来作为UUID。其中在方法 generate_paths 可以看出拼接文件路径的逻辑,有路径的属性就直接利用 path ,没有路径的就根据属性类型按照一定规则拼接路径,然后在方法uuid_for_path中MD5一下路径。

  • 实践

    • 创建 Xcodeproj 工程文件,并保存(这里需要注意的是不管是这里的创建工程,还是对工程的修改最后一定要使用save保存,才会执行对文件的工程的修改)

      1
      2
      # def new(klass)
      Xcodeproj::Project.new("./JCTest.xcodeproj").save
    • 打开 Xcodeproj 文件

      1
      2
      # def self.open(path)
      project=Xcodeproj::Project.open("./JCTest.xcodeproj")
    • 创建 Group 分组,名称为 JCTestGroup,对应的路径为./JCTestGroup

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      # def new_group(name, path = nil, source_tree = :group)
      @param source_tree : SOURCE_TREES_BY_KEY = {
      :absolute => '<absolute>',
      :group => '<group>',
      :project => 'SOURCE_ROOT',
      :built_products => 'BUILT_PRODUCTS_DIR',
      :developer_dir => 'DEVELOPER_DIR',
      :sdk_root => 'SDKROOT',
      }
      JCTestGroup=proj.main_group.new_group("JCTest","./JCTest")
    • Group 分组添加文件引用

      1
      2
      # def new_reference(path, source_tree = :group)
      ref=JCTestGroup.new_reference("JCTest.m")
    • 创建 Target

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      # def new_target(type, name, platform, deployment_target = nil, product_group = nil, language = nil)
      @param [Symbol] type : ":application",":framework",":dynamic_library",
      ":static_library"
      @param [String] name : the name of the target product
      @param [Symbol] platform : ":ios" or ":osx"
      @param [String] deployment_target : the deployment target for the platform
      @param [PBXGroup] product_group : the product group, where to add to a file
      reference of the created target.
      @param [Symbol] language : ":objc" or ":swift"
      @return [PBXNativeTarget] the target.
      target = project.new_target(:application,"JCTest",:ios)

      下面是 new_target 方法的内部实现,可以看到初始化 Target、Product、Build phases、Frameworks,那么如果需要对 Xcodeproj 工程文件进行修改,就可以直接操作这些对象得到想到的配置:

      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
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      def self.new_target(project, type, name, platform, deployment_target,
      product_group, language)
      # ----------------- Target -------------------
      # 新建 target 加入到 project.targets 中
      target = project.new(PBXNativeTarget)
      project.targets << target
      target.name = name
      target.product_name = name
      /* 设置 product_type 类型 ,使用 Constants 中的 PRODUCT_TYPE_UTI 获取:
      PRODUCT_TYPE_UTI = {
      :application => 'com.apple.product-type.application',
      :framework => 'com.apple.product-type.framework',
      :dynamic_library => 'com.apple.product-type.library.dynamic',
      :static_library => 'com.apple.product-type.library.static',
      :bundle => 'com.apple.product-type.bundle',
      :octest_bundle => 'com.apple.product-type.bundle',
      :unit_test_bundle => 'com.apple.product-type.bundle.unit-test',
      :ui_test_bundle => 'com.apple.product-type.bundle.ui-testing',
      :app_extension => 'com.apple.product-type.app-extension',
      :command_line_tool => 'com.apple.product-type.tool',
      :watch_app => 'com.apple.product-type.application.watchapp',
      :watch2_app => 'com.apple.product-type.application.watchapp2',
      :watch_extension => 'com.apple.product-type.watchkit-extension',
      :watch2_extension => 'com.apple.product-type.watchkit2-extension',
      :tv_extension => 'com.apple.product-type.tv-app-extension',
      :messages_application => 'com.apple.product-type.application.messages',
      :messages_extension => 'com.apple.product-type.app-extension.messages',
      :sticker_pack => 'com.apple.product-type.app-extension.messages-
      sticker-pack',
      :xpc_service => 'com.apple.product-type.xpc-service',
      }.freeze
      */
      target.product_type = Constants::PRODUCT_TYPE_UTI[type]
      # target 配置信息
      target.build_configuration_list = configuration_list(project, platform,
      deployment_target, type, language)
      # ----------------- Product -------------------
      product = product_group.new_product_ref_for_target(name, type)
      target.product_reference = product
      # ----------------- Build phases -------------------
      target.build_phases << project.new(PBXSourcesBuildPhase)
      target.build_phases << project.new(PBXFrameworksBuildPhase)
      # ----------------- Frameworks -------------------
      framework_name = (platform == :osx) ? 'Cocoa' : 'Foundation'
      target.add_system_framework(framework_name)
      target
      end
    • target 的配置信息 build_configuration_list,其实在创建 Target 的 new_target 方法中已经使用 configuration_list 方法初始化 build_configuration_list 配置信息,使用 set_setting 可以修改 buildSettings 的配置,这里的 key 与 buildSettings 的 key 是一致的:

      1
      2
      3
      # def set_setting(key, value)
      target.build_configuration_list.set_setting('INFOPLIST_FILE',
      "$(SRCROOT)/JCTest/Info.plist")

      看一下 configuration_list 内部实现,可以看出为 section XCBuildConfiguration 初始化 project.targets 中所有的 target 的 Release 和 Debug 的 buildSettings,使用 common_build_settings 方法设置在当前 platform 和 configuration 下的 buildSettings:

      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
      29
      30
      31
      # def self.configuration_list(project, platform = nil, deployment_target = nil, target_product_type = nil, language = nil)
      cl = project.new(XCConfigurationList)
      cl.default_configuration_is_visible = '0'
      cl.default_configuration_name = 'Release'
      release_conf = project.new(XCBuildConfiguration)
      release_conf.name = 'Release'
      release_conf.build_settings = common_build_settings(:release, platform,
      deployment_target, target_product_type, language)
      debug_conf = project.new(XCBuildConfiguration)
      debug_conf.name = 'Debug'
      debug_conf.build_settings = common_build_settings(:debug, platform,
      deployment_target, target_product_type, language)
      cl.build_configurations << release_conf
      cl.build_configurations << debug_conf
      project.build_configurations.each do |configuration|
      next if cl.build_configurations.map(&:name).include?(configuration.name)
      new_config = project.new(XCBuildConfiguration)
      new_config.name = configuration.name
      new_config.build_settings = common_build_settings(configuration.type,
      platform, deployment_target, target_product_type, language)
      cl.build_configurations << new_config
      end
      cl
      end
    • target添加相关的文件引用,这样编译的时候才能引用到:

      1
      2
      3
      4
      5
      6
      # def add_file_references(file_references, compiler_flags = {})
      @param [Array<PBXFileReference>] file_references : the files references of
      the source files that should be added to the target.
      @param [String] compiler_flags : the compiler flags for the source
      files
      target.add_file_references([ref],'-fno-objc-arc')
    • save 保存,对 project 的修改,只有调用 save 之后才会执行

      1
      project.save
    • 以下是利用 Xcodeproj 组件创建一个 Xcodeproj 工程的完整代码,但需要注意的是 Xcodeproj 创建的只是工程的各种配置以及文件的索引,但实体文件需要自己组织好工程目录,与填写在 Xcodeproj 组件的路径一致

      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
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      require 'xcodeproj'
      #创建 JCTest.xcodeproj工程文件,并保存
      Xcodeproj::Project.new("./JCTest.xcodeproj").save
      #打开创建的JCTest.xcodeproj文件
      proj=Xcodeproj::Project.open("./JCTest.xcodeproj")
      #创建一个分组,名称为JCTest,对应的路径为./JCTest
      JCTestGroup=proj.main_group.new_group("JCTest","./JCTest")
      #给JCTest分组添加文件引用
      JCTestGroup.new_reference("AppDelegate.h")
      ref1=JCTestGroup.new_reference("AppDelegate.m")
      ref2=JCTestGroup.new_reference("Assets.xcassets")
      JCTestGroup.new_reference("Base.lproj/LaunchScreen.storyboard")
      JCTestGroup.new_reference("Base.lproj/Main.storyboard")
      JCTestGroup.new_reference("ViewController.h")
      ref4=JCTestGroup.new_reference("ViewController.m")
      #在JCTest分组下创建一个名字为Supporting Files的子分组,并给该子分组添加main和info.plist文件引用
      supportingGroup=JCTestGroup.new_group("Supporting Files")
      ref3=supportingGroup.new_reference("main.m")
      supportingGroup.new_reference("Info.plist")
      #创建target,主要的参数 type: application :dynamic_library framework :static_library 意思大家都懂的
      #name:target名称
      #platform:平台 :ios或者:osx
      target = proj.new_target(:application,"JCTest",:ios)
      #添加target配置信息
      target.build_configuration_list.set_setting('INFOPLIST_FILE', "$(SRCROOT)/JCTest/Info.plist")
      #target添加相关的文件引用,这样编译的时候才能引用到
      target.add_file_references([ref1,ref2,ref3,ref4])
      testGroup=proj.main_group.new_group("JCTestTests","./JCTestTests")
      ref4=testGroup.new_reference("JCTestTests.m")
      supportingGroup=testGroup.new_group("Supporting Files")
      supportingGroup.new_reference("Info.plist")
      #创建test target
      testTarget = proj.new_target(:unit_test_bundle,"JCTestTests",:ios,nil,proj.products_group)
      testRefrence = testTarget.product_reference
      testRefrence.set_explicit_file_type('wrapper.cfbundle')
      testRefrence.name = "JCTestTests.xctest"
      testTarget.add_file_references([ref4])
      #保存
      proj.save