Xcodeproj解析

3,891 阅读6分钟

Xcodeproj

Xcodeproj 通过Ruby创建和修改Xcode项目结构。同样支持对 Xcode workspaces(.xcworkspace)、配置文件(.xcconfig)和 Xcode Scheme文件(.xcscheme)的修改

API Reference

1. 安装

sudo gem install xcodeproj

2. 使用

1. open

require 'xcodeproj'
project = Xcodeproj::Project.open("KBTEST/KBTEST.xcodeproj")
...
...
...
project.save

首先通过xcodeproj路径打开文件,内部会生成对应的Project类,所有信息都在此类中。若进行了修改,最后调用save方法保存,重新生成.pbxproj文件

2. 获取所有target

# target
project.targets.each do |target|
  puts target.name
end

3. 获取target的source files

# target source files
target = project.targets.first
files = target.source_build_phase.files.to_a.map do |pbx_build_file|
  pbx_build_file.file_ref.real_path.to_s

end.select do |path|
  path.end_with?(".m", ".mm", ".swift")

end.select do |path|
  File.exists?(path)
end

puts files

4. 获取target的build configuration

# target build setting
project.targets.each do |target|
  target.build_configurations.each do |config|
    if config.name == "Debug"
      puts config.build_settings
    end
  end
end

获取debug模式下的所有build_settings,可以自行进行修改配置或者新增配置

# 修改描述文件名称
config.build_settings["PROVISIONING_PROFILE_SPECIFIER"] = "xxProfileName"

# 新增配置
config.build_settings['MY_CUSTOM_FLAG'] ||= 'TRUE'

5. 引入类文件到工程中

# 添加文件
new_path = File.join("KBTEST","New")
group = project.main_group.find_subpath(new_path, true )
group.set_source_tree("<group>")
group.set_path("New")

file_ref = group.new_reference(File.join(project.project_dir, "/KBTEST/New/a.h"))
file_ref1 = group.new_reference(File.join(project.project_dir, "/KBTEST/New/a.m"))

target = project.targets.first
target.add_file_references([file_ref1])

project.save

新建一个名称为New的group,放在maingroup下

添加a.h、a.m文件到New group下

由于.m文件要进行编译,所以需要执行add_file_references ,添加到buildFile和sourcefiles。.h文件只用调用new_reference添加文件引用即可

源码分析

1. open

require 'xcodeproj'
project = Xcodeproj::Project.open("KBTEST/KBTEST.xcodeproj")
...
...
...
project.save

首先需要传入工程路径,调用Project类的open方法

open方法定义在lib/project.rb 文件中

def self.open(path)
  path = Pathname.pwd + path
  unless Pathname.new(path).exist?
    raise "[Xcodeproj] Unable to open `#{path}` because it doesn't exist."
  end
  project = new(path, true)
  project.send(:initialize_from_file)
  project
end
  1. 首先拼接绝对路径,根据路径判断文件是否存在

  2. 创建project对象,第一个参数为path,第二个参数skip_initialization = true,说明不需要从头进行初始化,因为接下来会根据path去创建初始化root_object对象

    def initialize(path, skip_initialization = false, object_version = Constants::DEFAULT_OBJECT_VERSION)
      @path = Pathname.new(path).expand_path
      @project_dir = @path.dirname
      @objects_by_uuid = {}
      @generated_uuids = []
      @available_uuids = []
      @dirty           = true
      unless skip_initialization.is_a?(TrueClass) || skip_initialization.is_a?(FalseClass)
        raise ArgumentError, '[Xcodeproj] Initialization parameter expected to ' \
          "be a boolean #{skip_initialization}"
      end
      unless skip_initialization
        initialize_from_scratch
        @object_version = object_version.to_s
        unless Constants::COMPATIBILITY_VERSION_BY_OBJECT_VERSION.key?(object_version)
          raise ArgumentError, "[Xcodeproj] Unable to find compatibility version string for object version `#{object_version}`."
        end
        root_object.compatibility_version = Constants::COMPATIBILITY_VERSION_BY_OBJECT_VERSION[object_version]
      end
    end
    
  3. 初始化root_object对象,对应的是PBXProject对象

    # Initializes the instance with the project stored in the `path` attribute.
        #
    def initialize_from_file
      pbxproj_path = path + 'project.pbxproj'
      plist = Plist.read_from_path(pbxproj_path.to_s)
      root_object.remove_referrer(self) if root_object
      @root_object     = new_from_plist(plist['rootObject'], plist['objects'], self)
      @archive_version = plist['archiveVersion']
      @object_version  = plist['objectVersion']
      @classes         = plist['classes'] || {}
      @dirty           = false
    
      unless root_object
        raise "[Xcodeproj] Unable to find a root object in #{pbxproj_path}."
      end
    
      if archive_version.to_i > Constants::LAST_KNOWN_ARCHIVE_VERSION
        raise '[Xcodeproj] Unknown archive version.'
      end
    
      if object_version.to_i > Constants::LAST_KNOWN_OBJECT_VERSION
        raise '[Xcodeproj] Unknown object version.'
      end
    
      # Projects can have product_ref_groups that are not listed in the main_groups["Products"]
      root_object.product_ref_group ||= root_object.main_group['Products'] || root_object.main_group.new_group('Products')
    end
    

    plist = Plist.read_from_path(pbxproj_path.to_s) 内部会通过 Nanaimo::Reader.new(contents).parse!.as_ruby 把pbx文件转换为ruby的hash字典类型

    然后通过 new_from_plist 方法生成root_object对象。这一步就把pbx文件所有内容都一一映射为root_object对象,从此对象中可以获取到任意想要的内容

    • build_configuration_list 对应的是 XCConfigurationList,内部包含的是所有的XCBuildConfiguration
    • main_group对应的是 PBXGroup,工程的主group
    • product_ref_group 对应的是包文件对应的group
    • simple_attributes_hash 对应的是一些其他的简单配置项
    • targets 对应的就是项目内所有的target,PBXNativeTarget
    • ...

至此,open的逻辑基本分析完毕。总结一下就是通过xcodeproj路径把pbx文件转换为ruby对应的对象。最后来看一下project类初始化完毕后的全部属性

2. 获取target

# target
project.targets.each do |target|
  puts target.name
end
  1. 调用project对象的targets方法,返回一个target抽象类数组

    # @return [ObjectList<AbstractTarget>] A list of all the targets in the
    #         project.
    #
    def targets
      root_object.targets
    end
    
  2. 遍历targets数组,这里只是打印出每一个target的name。可以根据自己的需求进行不同的操作

3. 获取target的souce files

# target source files
target = project.targets.first
files = target.source_build_phase.files.to_a.map do |pbx_build_file|
  pbx_build_file.file_ref.real_path.to_s

end.select do |path|
  path.end_with?(".m", ".mm", ".swift")

end.select do |path|
  File.exists?(path)
end

puts files

这里大体分为三步

  1. 首先通过 target.source_build_phase.files 获取此target下的build_file ,然后获取到build_file 的 file_ref。最后获取到file_ref的真实路径
  2. 只筛选出.m .mm .swift 结尾的文件,说明这是我们自己编写的代码文件
  3. 最后再根据路径判断是否是真实存在的文件

我们主要来分析第一步,如何获取到target下的build_file。了解pbx格式的就知道获取source files就是要获取PBXBuildPhase下的 PBXSourcesBuildPhase 段的内容,通过PBXSourcesBuildPhase下的files数组可以找到对应的PBXBuildFile,然后就可以找到PBXFileReference。

下图是ruby下的build_phase结构

target.source_build_phase 就是获取上图中build_phase对象

target.source_build_phase.files 是获取到 build_phase 对象中的files数组,对files数组进行遍历,其中每一个对象对PBXBuildFile

pbx_build_file.file_ref 就是获取上图中的 PBXFileReference 对象

最后调用 real_path.to_s 获取到真实路径转换为字符串形式

4. 获取target的build configuration

# target build setting
project.targets.each do |target|
  target.build_configurations.each do |config|
    if config.name == "Debug"
      puts config.build_settings
    end
  end
end

获取每一个target Debug环境下的buildSettings

调用 target.build_configurations ,获取到 XCConfigurationList,接着获取到 XCBuildConfiguration

# @return [ObjectList<XCBuildConfiguration>] the build
#         configurations of the target.
#
def build_configurations
  build_configuration_list.build_configurations
end

遍历中config此时就是 XCBuildConfiguration 类型,通过XCBuildConfiguration中的属性build_settings获取到所有配置

# @return [Hash] the build settings to use for building the target.
#
attribute :build_settings, Hash, {}

5. 引入类文件到工程中

在KBTEST下新建New,添加a.h,a.m到New group下,代码如下:

# 添加文件
new_path = File.join("KBTEST","New")
group = project.main_group.find_subpath(new_path, true )
group.set_source_tree("<group>")
group.set_path("New")

file_ref = group.new_reference(File.join(project.project_dir, "/KBTEST/New/a.h"))
file_ref1 = group.new_reference(File.join(project.project_dir, "/KBTEST/New/a.m"))

target = project.targets.first
target.add_file_references([file_ref1])


project.save

首先,我们在xcode下主动新建一个group,命名为New1。 在New1下新建new1.h 、new1.m文件。然后观察pbxproj文件的变化

  1. 在PBXGroup下新增了New1对应的模块,并在 KBTEST( mainGroup)children下新增了对 New1的引用。

  1. 在 PBXFileReference 段下,新增了两项,代表了 new1.h、new1.m。所有添加的文件都会进入到此模块中

  1. 由于.m文件要参与编译,所以在 PBXBuildFile 下会新增一项new1.m,具体引用的是 PBXFileReference 下new1.m文件

  1. 在 PBXSourcesBuildPhase 下会新增一项new1.m,这里new1.m引用的是上面 PBXBuildFile中new1.m文件。(PBXSourcesBuildPhase对应的是xcode中 Build Phase下的 Compile Sources)

通过上述步骤我们来分析一下代码的每一步都做了什么?

  1. 在KBTEST(mainGroup)下新建New(Group),设置source_tree和path,设置方式可以参考KBTEST,source_tree为<group>

    new_path = File.join("KBTEST","New")
    group = project.main_group.find_subpath(new_path, true )
    group.set_source_tree("<group>")
    group.set_path("New")
    
    5E785D438559401E397AB673 /* New */ = {
      isa = PBXGroup;
      children = (
        41B83A50825CA76C4A7D57D7 /* a.h */,
        BBCB1AF3369FC78AEC34D2F2 /* a.m */,
      );
      name = New;
      path = New;
      sourceTree = "<group>";
    };
    
  2. 添加a.h、a.m的文件引用。path路径为绝对路径

    file_ref = group.new_reference(File.join(project.project_dir, "/KBTEST/New/a.h"))
    file_ref1 = group.new_reference(File.join(project.project_dir, "/KBTEST/New/a.m"))
    
    /* Begin PBXFileReference section */
    
      41B83A50825CA76C4A7D57D7 /* a.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = a.h; sourceTree = "<group>"; };
      BBCB1AF3369FC78AEC34D2F2 /* a.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = a.m; sourceTree = "<group>"; };
    
    /* End PBXFileReference section */
    

    new_file_reference定义在file_references_factory.rb文件中

    def new_file_reference(group, path, source_tree)
      path = Pathname.new(path)
      ref = group.project.new(PBXFileReference)
      group.children << ref
      GroupableHelper.set_path_with_source_tree(ref, path, source_tree)
      ref.set_last_known_file_type
      ref
    end
    

    从上述代码中可以看出首先会创建 PBXFileReference文件,然后加入到group的children下,最后返回PBXFileReference引用对象

  3. 添加.m文件到目标target的PBXBuildFile和PBXSourcesBuildPhase下,参与编译。

    target = project.targets.first
    target.add_file_references([file_ref1])
    

    这里选择的是第一个target。调用add_file_references()方法

    # Adds source files to the target.
    #
    # @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.
    #
    # @yield_param [PBXBuildFile] each created build file.
    #
    # @return [Array<PBXBuildFile>] the created build files.
    #
    def add_file_references(file_references, compiler_flags = {})
      file_references.map do |file|
        extension = File.extname(file.path).downcase
        header_extensions = Constants::HEADER_FILES_EXTENSIONS
        is_header_phase = header_extensions.include?(extension)
        phase = is_header_phase ? headers_build_phase : source_build_phase
    
        unless build_file = phase.build_file(file)
          build_file = project.new(PBXBuildFile)
          build_file.file_ref = file
          phase.files << build_file
        end
    
        if compiler_flags && !compiler_flags.empty? && !is_header_phase
          (build_file.settings ||= {}).merge!('COMPILER_FLAGS' => compiler_flags) do |_, old, new|
            [old, new].compact.join(' ')
          end
        end
    
        yield build_file if block_given?
    
        build_file
      end
    end
    
    • 第一步首先根据文件名称判断类型是 PBXHeadersBuildPhase 或者 PBXSourcesBuildPhase

      extension = File.extname(file.path).downcase
      header_extensions = Constants::HEADER_FILES_EXTENSIONS
      is_header_phase = header_extensions.include?(extension)
      phase = is_header_phase ? headers_build_phase : source_build_phase
      
      # Finds or creates the source build phase of the target.
      #
      # @note   A target should have only one source build phase.
      #
      # @return [PBXSourcesBuildPhase] the source build phase.
      #
      def source_build_phase
        find_or_create_build_phase_by_class(PBXSourcesBuildPhase)
      end
      
      
      # @!group Internal Helpers
      #--------------------------------------#
      
      # Find or create a build phase by a given class
      #
      # @param [Class] phase_class the class of the build phase to find or create.
      #
      # @return [AbstractBuildPhase] the build phase whose class match the given phase_class.
      #
      def find_or_create_build_phase_by_class(phase_class)
        @phases ||= {}
        unless phase_class < AbstractBuildPhase
          raise ArgumentError, "#{phase_class} must be a subclass of #{AbstractBuildPhase.class}"
        end
        @phases[phase_class] ||= build_phases.find { |bp| bp.class == phase_class } ||
          project.new(phase_class).tap { |bp| build_phases << bp }
      end
      

      这里的目的是找到或者新创建 PBXSourcesBuildPhase,然后返回

    • 第二步根据phase查找是否内部有对应的file。如果未找到,则新建PBXBuildFile,并添加到phase的files内部

      unless build_file = phase.build_file(file)
        build_file = project.new(PBXBuildFile)
        build_file.file_ref = file
        phase.files << build_file
      end
      
      
      # @return [PBXBuildFile] the first build file associated with the given
      #         file reference if one exists.
      #
      def build_file(file_ref)
        (file_ref.referrers & files).first
      end
      
    • 最后一步处理外部的块调用yield传参也为build_file。返回PBXBuildFile

      yield build_file if block_given?
      
      build_file
      
  4. 保存上述对工程进行的修改

    使用初始化期间提供的path或参数传入的path(xcodeproj文件),以xcodeproj格式序列化项目。 如果提供了path,则依赖于项目根的文件引用不会自动更新,因此客户端负责在保存之前执行任何所需的修改。

    # Serializes the project in the xcodeproj format using the path provided
    # during initialization or the given path (`xcodeproj` file). If a path is
    # provided file references depending on the root of the project are not
    # updated automatically, thus clients are responsible to perform any needed
    # modification before saving.
    #
    # @param  [String, Pathname] path
    #         The optional path where the project should be saved.
    #
    # @example Saving a project
    #   project.save
    #   project.save
    #
    # @return [void]
    #
    def save(save_path = nil)
      save_path ||= path
      @dirty = false if save_path == path
      FileUtils.mkdir_p(save_path)
      file = File.join(save_path, 'project.pbxproj')
      Atomos.atomic_write(file) do |f|
        Nanaimo::Writer::PBXProjWriter.new(to_ascii_plist, :pretty => true, :output => f, :strict => false).write
      end
    end
    

    主要是调用Nanaimo::Writer::PBXProjWriter.new(to_ascii_plist, :pretty => true, :output => f, :strict => false).write 重新写pbxproj文件

    Nanaimo 是一个实现ASCII Plist序列化和反序列化的简单库,完全使用原生Ruby代码(并且没有依赖关系)。它还提供了对序列化Xcode projects(带注释)和XML plist的现成支持。在open和save中都使用了此库来操作pbxproj文件

参考资料