5. Podspec 文件分析

2021年11月23日 阅读数:3
这篇文章主要向大家介绍5. Podspec 文件分析,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。
本文做者:Edmond  
校对:冬瓜

CocoaPods 历险记 这个专题是 Edmond 和 冬瓜 共同撰写,对于 iOS / macOS 工程中版本管理工具 CocoaPods 的实现细节、原理、源码、实践与经验的分享记录,旨在帮助你们可以更加了解这个依赖管理工具,而不只局限于 pod installpod updatehtml

本文知识目录

引子

在上文 Podfile 解析逻辑」 中(建议先阅读这篇文章),咱们以 Xcode 工程结构做为切入点介绍了 Podfile 背后对应的数据结构,剖析了 Podfile 文件是如何解析与加载,并最终 "入侵" 项目影响其工程结构的。今天咱们来聊一聊 CocoaPods-Core[2] 中的另外一个重要文件 --- Podspec 以及它所撑起的 CocoaPods 世界。ios

一个 Pod 的建立和发布离不开 .podspec 文件,它能够很简单也能复杂,如 QMUIKit[3](后续介绍)。git

今天咱们就直奔主题,来分析 Podspec 文件。github

Podspec

Podspec 是用于 描述一个 Pod 库的源代码和资源将如何被打包编译成连接库或 framework 的文件 ,而 Podspec 中的这些描述内容最终将映会映射到 Specification 类中(如下简称 Spec)。web

如今让咱们来从新认识 Podspec
npm

Podspec 初探

Podspec 支持的文件格式为 .podspec.json 两种,而 .podspec 本质是 Ruby 文件。json

问题来了,为何是 JSON 格式而不像 Podfile 同样支持 YAML 呢?swift

笔者的理解:因为 Podspec 文件会满世界跑,它可能存在于 CocoaPods 的 CDN Service[4]Speces Repo[5] 或者大家的私有 Specs Repo 上,所以采用  JSON 的文件在网络传输中会更友好。而 Podfile 更多的场景是用于序列化,它须要在项目中生成一份经依赖仲裁后的 Podfile 快照,用于后续的对比。数组

Podspec

Pod::Spec.new do |spec|
  spec.name         = 'Reachability'
  spec.version      = '3.1.0'
  spec.license      = { :type => 'BSD' }
  spec.homepage     = 'https://github.com/tonymillion/Reachability'
  spec.authors      = { 'Tony Million' => 'tonymillion@gmail.com' }
  spec.summary      = 'ARC and GCD Compatible Reachability Class for iOS and OS X.'
  spec.source       = { :git => 'https://github.com/tonymillion/Reachability.git':tag => "v#{spec.version}" }
  spec.source_files = 'Reachability.{h,m}'
  spec.framework    = 'SystemConfiguration'
end

上面这份 Reachability.podspec 配置,基本经过命令行 pod lib create NAME 就能帮咱们完成。除此以外咱们能作的更多,好比,默认状况下 CococaPods 会为每一个 Pod framework 生成一个对应的 modulemap 文件,它将包含 Podspec 中指定的公共 headers。若是须要自定义引入的 header 文件,仅需配置 moduel_map 便可完成。微信

下面是进阶版配置:

Pod::Spec.new do |spec|
  spec.name         = 'Reachability'
  # 省略与前面相同部分的配置 ...
  
  spec.module_name   = 'Rich'
  spec.swift_version = '4.0'

  spec.ios.deployment_target  = '9.0'
  spec.osx.deployment_target  = '10.10'

  spec.source_files       = 'Reachability/common/*.swift'
  spec.ios.source_files   = 'Reachability/ios/*.swift''Reachability/extensions/*.swift'
  spec.osx.source_files   = 'Reachability/osx/*.swift'

  spec.framework      = 'SystemConfiguration'
  spec.ios.framework  = 'UIKit'
  spec.osx.framework  = 'AppKit'

  spec.dependency 'SomeOtherPod'
end

像 👆 咱们为不一样的系统指定了不一样的源码和依赖等,固然可配置的不仅这些。

Podspec 支持的完整配置分类以下:

想了解更多的配置选项:传送门[6]

Convention Over Configuration

说到配置,不得不提一下 CoC 约定大于配置。约定大于配置算是在软件工程较早出现的概念的了,大意是:为了简单起见,咱们的代码须要按照必定的约定来编写(如代码放在什么目录,用什么文件名,用什么类名等)。这样既简化了配置文件,同时也下降了学习成本。

约定大于配置能够说是经过 Ruby on Rails[7] 发扬光大的。尽管它一直饱受争议,可是主流语言的依赖管理工具,如 Mavennpm 等都遵循 CoC 进行不断演进的,由于 CoC 可以有效帮助开发者减轻选择的痛感,减小无心义的选择。一些新的语言也吸取了这个思想,好比 Go 语言。若是用 C/C++ 可能须要定义复杂的 Makefile 来定义编译的规则,以及如何运行测试用例,而在 Go 中这些都是约定好的。

举个 🌰 :Podfile 中是能够指定 pod library 所连接的 Xcode project,不过大多状况下无需配置,CocoaPods 会自动查找 Podfile 所在的同级目录下所对应的工程文件 .project

Spec 的核心数据结构

Specification

在数据结构上 SpecificationTargetDefinition[8] 是相似的,同为多叉树结构。简化后的 Spec 的类以下:

require 'active_support/core_ext/string/strip.rb'
# 记录对应 platform 上 Spec 的其余 pod 依赖
require 'cocoapods-core/specification/consumer'
# 解析 DSL
require 'cocoapods-core/specification/dsl'
# 校验 Spec 的正确性,并抛出对应的错误和警告
require 'cocoapods-core/specification/linter'
# 用于解析 DSL 内容包含的配置信息
require 'cocoapods-core/specification/root_attribute_accessors'
# 记录一个 Pod 全部依赖的 Spec 来源信息
require 'cocoapods-core/specification/set'
# json 格式数据解析
require 'cocoapods-core/specification/json'

module Pod
  class Specification
    include Pod::Specification::DSL
    include Pod::Specification::DSL::Deprecations
    include Pod::Specification::RootAttributesAccessors
    include Pod::Specification::JSONSupport
 
    # `subspec` 的父节点
    attr_reader :parent
    # `Spec` 的惟一 id,由 name + version 的 hash 构成
    attr_reader :hash_value
    # 记录 `Spec` 的配置信息 
    attr_accessor :attributes_hash
    # `Spec` 包含的 `subspec`
    attr_accessor :subspecs
     
    # 递归调用获取 Specification 的根节点
    def root
      parent ? parent.root : self
    end
     
  def hash
    if @hash_value.nil?
       @hash_value = (name.hash * 53) ^ version.hash
  end
      @hash_value
    end
     
    # ...
  end
end

Specification 一样用 map attributes_hash 来记录配置信息。

注意,这里的 parent 是为 subspec 保留的,用于指向其父节点的 Spec

Subspecs

乍一听 Subspec 这个概念彷佛有一些抽象,不过当你理解了上面的描述,就能明白什么是 Subspec 了。咱们知道在 Xcode 项目中,target 做为最小的可编译单元,它编译后的产物为连接库或 framework。而在 CocoaPods 的世界里这些 targets 则是由 Spec 文件来描述的,它还能拆分红一个或者多个 Subspec,咱们暂且把它称为 Spec子模块,子模块也是用 Specification 类来描述的。

子模块能够单独做为依赖被引入到项目中。它有几个特色:

  • 未指定 default_subspec 的状况下, Spec 的所有子模块都将做为依赖被引入;
  • 子模块会主动继承其父节点 Spec 中定义的 attributes_hash
  • 子模块能够指定本身的源代码、资源文件、编译配置、依赖等;
  • 同一 Spec 内部的子模块是能够有依赖关系的;
  • 每一个子模块在 pod push 的时候是须要被 lint 经过的;

光听总结彷佛仍是云里雾里,祭出 QMUI 让你们感觉一下:

Pod::Spec.new do |s|
  s.name             = "QMUIKit"
  s.version          = "4.2.1"
  # ...
  s.subspec 'QMUICore' do |ss|
    ss.source_files = 'QMUIKit/QMUIKit.h''QMUIKit/QMUICore''QMUIKit/UIKitExtensions'
    ss.dependency 'QMUIKit/QMUIWeakObjectContainer'
    ss.dependency 'QMUIKit/QMUILog'
  end

  s.subspec 'QMUIWeakObjectContainer' do |ss|
    ss.source_files = 'QMUIKit/QMUIComponents/QMUIWeakObjectContainer.{h,m}'
  end

  s.subspec 'QMUILog' do |ss|
    ss.source_files = 'QMUIKit/QMUIComponents/QMUILog/*.{h,m}'
  end

  s.subspec 'QMUIComponents' do |ss|
    ss.dependency 'QMUIKit/QMUICore'
     
    ss.subspec 'QMUIButton' do |sss|
      sss.source_files = 'QMUIKit/QMUIComponents/QMUIButton/QMUIButton.{h,m}'
    end
    # 此处省略 59 个 Components
  end
  # ...
end

不吹不黑,QMUI 是笔者见过国内开源做品中代码注释很是详尽且提供完整 Demo 的项目之一。

整个 QMUIKit 的 Spec 文件中,总共定义了 64subspec 子模块,同时这些子模块之间还作了分层。好比 QMUICore:

另外补充一点,CocoaPods 支持了不一样类型的 SubSpec

# lib/cocoapods-core/specification/dsl/attribute_support.rb

SUPPORTED_SPEC_TYPES = [:library:app:test].freeze

:app:test 用于在项目中集成单元测试代码的 Subspec

Podspec From JSON

有了上文 Podfile 的了解,此次咱们对 Podspec 的文件加载会更加轻车熟路。首先是由 #from_file 方法进行文件路径和内容编码格式的检查,将加载的内容转入 #from_string

def self.from_file(path, subspec_name = nil)
  path = Pathname.new(path)
  unless path.exist?
    raise Informative, "No Podspec exists at path `#{path}`."
  end

  string = File.open(path, 'r:utf-8', &:read)
  # Work around for Rubinius incomplete encoding in 1.9 mode
  if string.respond_to?(:encoding) && string.encoding.name != 'UTF-8'
    string.encode!('UTF-8')
  end

  from_string(string, path, subspec_name)
end

def self.from_string(spec_contents, path, subspec_name = nil)
  path = Pathname.new(path).expand_path
  spec = nil
  case path.extname
  when '.podspec'
    Dir.chdir(path.parent.directory? ? path.parent : Dir.pwd) do
      spec = ::Pod._eval_Podspec(spec_contents, path)
      unless spec.is_a?(Specification)
        raise Informative, "Invalid Podspec file at path `#{path}`."
      end
    end
  when '.json'
    spec = Specification.from_json(spec_contents)
  else
    raise Informative, "Unsupported specification format `#{path.extname}` for spec at `#{path}`."
  end

  spec.defined_in_file = path
  spec.subspec_by_name(subspec_name, true)
end

接着根据文件类型为 .podspec.json 分别采用不一样的解析方式。在  JSONSupport 模块内将 #from_json 的逻辑拆成了两部分:

# `lib/cocoapods-core/specification/json.rb`
module Pod
  class Specification
    module JSONSupport
    # ①
    def self.from_json(json)
      require 'json'
      hash = JSON.parse(json)
      from_hash(hash)
    end
    # ②
    def self.from_hash(hash, parent = niltest_specification: falseapp_specification: false)
      attributes_hash = hash.dup
      spec = Spec.new(parent, nil, test_specification, :app_specification => app_specification)
      subspecs = attributes_hash.delete('subspecs')
      testspecs = attributes_hash.delete('testspecs')
      appspecs = attributes_hash.delete('appspecs')
  
      ## backwards compatibility with 1.3.0
      spec.test_specification = !attributes_hash['test_type'].nil?
  
      spec.attributes_hash = attributes_hash
      spec.subspecs.concat(subspecs_from_hash(spec, subspecs, falsefalse))
      spec.subspecs.concat(subspecs_from_hash(spec, testspecs, truefalse))
      spec.subspecs.concat(subspecs_from_hash(spec, appspecs, falsetrue))
  
      spec
    end
    # ③
    def self.subspecs_from_hash(spec, subspecs, test_specification, app_specification)
      return [] if subspecs.nil?
      subspecs.map do |s_hash|
        Specification.from_hash(s_hash, spec,
                                :test_specification => test_specification,
                                :app_specification => app_specification)
      end
    end
  end
end

这里的逻辑也是比较简单:

  • ① 将传入的字符串转换为 json;
  • ② 将转换后的 json 转换为 Spec 对象并将 json 转换为 attributes_hash,同时触发 ③;
  • ③ 经过 self.subspecs_from_hash 实现递归调用完成 subspecs 解析;

Tips: 方法 ② 里的 Spec 是对 Specification 的别名。

Podspec From Ruby

QMUIKit.podspec 的文件内容,你们是否注意到其开头的声明:

Pod::Spec.new do |s|
  s.name             = "QMUIKit"
  s.source_files     = 'QMUIKit/QMUIKit.h'
  # ...
end

发现没 .podspec 文件就是简单直接地声明了一个 Specifiction 对象,而后经过 block 块定制来完成配置。像 namesource_files 这些配置参数最终都会转换为方法调用并将值存入 attributes_hash 中。这些方法调用的实现方式分两种:

  1. 大部分配置是经过方法包装器 attributeroot_attribute 来动态添加的 setter 方法;
  2. 对于复杂逻辑的配置则直接方法声明,如 subspecdependency 方法等(后续介绍)。

attribute wrappter

# `lib/cocoapods-core/specification/dsl.rb`
module Pod
  class Specification
    module DSL
      extend Pod::Specification::DSL::AttributeSupport
      # Deprecations must be required after include AttributeSupport
      require 'cocoapods-core/specification/dsl/deprecations'

      attribute :name,
                :required => true,
                :inherited => false,
                :multi_platform => false

      root_attribute :version,
                      :required => true
      # ...
    end
  end
end

能够看出 name 和 version 的方法声明与普通的不太同样,其实 attributeroot_attribute 是经过 Ruby 的方法包装器来实现的,感兴趣的同窗看这里 「Python装饰器 与 Ruby实现[9]」。

Tips: Ruby 原生提供的属性访问器 --- attr_accessor 你们应该不陌生,就是经过包装器实现的。

这些装饰器所声明的方法会在其模块被加载时动态生成,来看其实现:

# `lib/cocoapods-core/specification/attribute_support.rb`
module Pod
  class Specification
    module DSL
      class << self
        attr_reader :attributes
      end

      module AttributeSupport
        def root_attribute(name, options = {})
          options[:root_only] = true
          options[:multi_platform] = false
          store_attribute(name, options)
        end

        def attribute(name, options = {})
          store_attribute(name, options)
        end

        def store_attribute(name, options)
          attr = Attribute.new(name, options)
          @attributes ||= {}
          @attributes[name] = attr
        end
      end
    end
  end
end

attributeroot_attribute 最终都走到了 store_attribute 保存在建立的 Attribute 对象内,并以配置的 Symbol 名称做为 KEY 存入 @attributes,用于生成最终的 attributes setter 方法。

最关键的一步,让咱们回到 specification 文件:

# `/lib/coocapods-core/specification`
module Pod
  class Specification
    # ...
    
    def store_attribute(name, value, platform_name = nil)
      name = name.to_s
      value = Specification.convert_keys_to_string(value) if value.is_a?(Hash)
      value = value.strip_heredoc.strip if value.respond_to?(:strip_heredoc)
      if platform_name
        platform_name = platform_name.to_s
        attributes_hash[platform_name] ||= {}
        attributes_hash[platform_name][name] = value
      else
        attributes_hash[name] = value
      end
    end

    DSL.attributes.values.each do |a|
      define_method(a.writer_name) do |value|
        store_attribute(a.name, value)
      end

      if a.writer_singular_form
        alias_method(a.writer_singular_form, a.writer_name)
      end
    end
  end
end

Specification 类被加载时,会先遍历 DSL module 加载后所保存的 attributes,再经过 define_method 动态生成对应的配置方法。最终数据仍是保存在 attributes_hash 中。

Attribute

Attribute 是为了记录该配置的相关信息,例如,记录 Spec 是否为根节点、Spec 类型、所支持的 platforms、资源地址通配符等。

  1. root_attribute 包装的配置仅用于修饰 Spec 根节点,好比版本号 version 只能由 Spec 根节点来设置,另外还有 sourcestatic_frameworkmodule_name 等;
  2. attribute 包装的配置则不限是否为 Spec 根结点。咱们以 AFNetworking 的 source_files 为例:因为在 macOS 和 watchOS 上并无 UIKit framwork,所以它单独将 UIKit 的相关功能拆分到了 AFNetworking/UIKit 中;
Pod::Spec.new do |s|
  # ...
  s.subspec 'NSURLSession' do |ss|
  # ...
  end

  s.subspec 'UIKit' do |ss|
    ss.ios.deployment_target = '9.0'
    ss.tvos.deployment_target = '9.0'
    ss.dependency 'AFNetworking/NSURLSession'

    ss.source_files = 'UIKit+AFNetworking'
  end
end

#subspec

除了 attribute 装饰器声明的 setter 方法,还有几个自定义的方法是直接经过 eval 调用的。

def subspec(name, &block)
  subspec = Specification.new(self, name, &block)
  @subspecs << subspec
  subspec
end

def test_spec(name = 'Tests', &block)
  subspec = Specification.new(self, name, true, &block)
  @subspecs << subspec
  subspec
end

def app_spec(name = 'App', &block)
  appspec = Specification.new(self, name, :app_specification => true, &block)
  @subspecs << appspec
  appspec
end

这三种不一样类型的 Subspeceval 转换为对应的 Specification 对象,注意这里初始化后都将 parent 节点指向 self 同时存入 @subspecs 数组中,完成 SubSpec 依赖链的构造。

#dependency

对于其余 pod 依赖的添加咱们经过 dependency 方法来实现:

def dependency(*args)
  name, *version_requirements = args
  # dependency args 有效性校验 ...

  attributes_hash['dependencies'||= {}
  attributes_hash['dependencies'][name] = version_requirements

  unless whitelisted_configurations.nil?
    # configuration 白名单过滤和校验 ...

    attributes_hash['configuration_pod_whitelist'||= {}
    attributes_hash['configuration_pod_whitelist'][name] = whitelisted_configurations
  end
end

dependency 方法内部主要是对依赖有效性的校验,限于篇幅这里不列出实现,核心要点以下:

  1. 检查依赖循环,根据 Spec 名称判断 Spec 与自身, SpecSubSpec之间是否存在循环依赖;
  2. 检查依赖来源Podspec 中不支持 :git:path 形式的来源指定,如需设定可经过 Podfile 来修改;
  3. 检查 configuation 白名单,目前仅支持 Xcode 默认的 DebugRelease 的 configuration 配置;

建立并使用你的 Pod

最后一节来两个实践:建立 Pod 以及在项目中使用 SubSpecs

Pod 建立

pod 相关使用官方都提供了很详尽的都文档,本小节仅作介绍。

1. 建立 Pod

仅需一行命令完成 Pod 建立(文档[10]):

$ pod lib create `NAME`

以后每一步都会输出友好提示,按照提示选择便可。在添加完 source code 和 dependency 以后,你还能够在 CocoaPods 为你提供的 Example 项目中运行和调试代码。

准备就绪后,能够经过如下命令进行校验,检查 Pod 正确性:

$ pod lib lint `[Podspec_PATHS ...]`

2. 发布 Pod

校验经过后就能够将 Pod 发布了,你能够将 PodSepc 发布到  Master Repo 上,或者发布到内部的 Spec Repo 上。

CocoaPods Master Repo

若是发布的 CocoaPods 的主仓库,那么须要经过 CocoaPods 提供的 Trunk 命令:

$ pod trunk push `[NAME.podspec]`

不过使用前须要先经过邮箱注册,详情查看文档[11]

Private Spec Repo

对于发布到私有仓库的,可经过 CocoaPods 提供的 Repo 命令:

$ pod repo push `REPO_NAME` `SPEC_NAME.podspec`

文档详情 --- 传送门[12]

SubSpecs In Podfile

SubSpec 一节提到过,在 CocoaPods 中 SubSpec 是被做为单独的依赖来看待的,这里就借这个实操来证实一下。

在上文的实践中,咱们知道每个 Pod 库对应为 Xcode 项目中的一个个 target,那么当明确指定部分 SubSpec 时,它们也将被做为独立的 target 进行编译。不过这里须要明确一下使用场景:

1. Single Target

当主项目中仅有一个 target 或多个 target 引用了同一个 pod 库的多个不一样 SubSpec 时,生成的 target 只会有一个。咱们以 QMUIKit 为例,项目 Demo.project 下的 Podfile 配置以下:

target 'Demo' do
  pod 'QMUIKit/QMUIComponents/QMUILabel':path => '../QMUI_iOS'
  pod 'QMUIKit/QMUIComponents/QMUIButton':path => '../QMUI_iOS'
end

此时 Pods.project 下的 QMUIKit 的 target 名称为 QMUIKit

2. Multiple Target

若是咱们的主项目中存在多个 target 且使用同一个 pod 库的不一样 SubSpec 时,结果则有所不一样。

如今咱们在步骤 1 的基础上添加以下配置:

target 'Demo2' do
 pod 'QMUIKit/QMUIComponents/QMUILog':path => '../QMUI_iOS'
end

能够发现,CocoaPods 为每一个 tareget 对应的 SubSpec 依赖生成了不一样的 QMUIKit targets。

Tips: 当主工程 target 依赖的 Subspec 数量过多致使的名称超过 50 个字符,将会对 subspec 后缀作摘要处理做为惟一标识符。

总结

本文是 CocoaPods-Core 的第二篇,重点介绍了 Podspec 的类构成和解析实现,总结以下:

  1. 初探 Podspec 让咱们对其能力边界和配置分类有了更好的了解;
  2. 深刻 Podspec 咱们发现其数据结构同 Podfile 相似,都是根据依赖关系创建对应的树结构;
  3. Podspec 针对单个库的源码和资源提供了更精细化的管理, SubSpec 结构的推出让大型 library 的内部分层提供了很好的工具;
  4. 装饰器模式结合 Ruby 的动态特性,让 Podspec 的 DSL 特性的实现起来更加优雅;

知识点问题梳理

这里罗列了四个问题用来考察你是否已经掌握了这篇文章,若是没有建议你加入收藏 再次阅读:

  1. 说说 Podspec 所支持的配置有几类,分别具备哪些功能 ?
  2. PodspecSubSpec 之间有哪些关系 ?
  3. 说说 SubSpec 的特色以及做用 ?
  4. 谈谈 Podspec 中的 DSL 解析与 Podfile 的解析实现有哪些区别 ?

参考资料

[1]

Podfile 解析逻辑: /2020/09/16/cocoapods-story-4.html

[2]

CocoaPods-Core: https://link.zhihu.com/?target=https%3A//github.com/CocoaPods/Core

[3]

QMUIKit: https://github.com/Tencent/QMUI_iOS/blob/master/QMUIKit.podspec

[4]

CDN Service: https://cdn.cocoapods.org/

[5]

Speces Repo: https://github.com/CocoaPods/Specs

[6]

传送门: https://guides.cocoapods.org/syntax/Podspec.html

[7]

Ruby on Rails: https://www.wikiwand.com/en/Ruby_on_Rails

[8]

TargetDefinition: https://looseyi.github.io/post/sourcecode-cocoapods/04-cocoapods-podfile/#targetdefinition

[9]

Python装饰器 与 Ruby实现: https://github.com/mxchenxiaodong/haha_day/issues/3#

[10]

文档: https://guides.cocoapods.org/making/using-pod-lib-create.html

[11]

文档: https://guides.cocoapods.org/making/getting-setup-with-trunk.html

[12]

传送门: https://guides.cocoapods.org/making/private-cocoapods.html




本文分享自微信公众号 - 一瓜技术(tech_gua)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。