本文目录

PodSpec 文件管理

引子

本文是 Core 的最后一篇,它与另外两篇文章「Podfile 解析逻辑」和「PodSpec 文件分析」共同支撑起 CocoaPods 世界的骨架。CocoaPods-Core 这个库之所以被命名为 Core 就是因为它包含了 Podfile -> Spec Repo -> PodSpec 这条完整的链路,将散落各地的依赖库连接起来并基于此骨架不断地完善功能。从提供各种便利的命令行工具,到依赖库与主项目的自动集成,再到提供多样的 Xcode 编译配置、单元测试、资源管理等等,最终形成了我们所见的 CocoaPods。

今天我们就来聊聊 Spec Repo 这个 PodSpec 的聚合仓库以及它的演变与问题。

Source

作为 PodSpec 的聚合仓库,Spec Repo 记录着所有 pod 所发布的不同版本的 PodSpec 文件。该仓库对应到 Core 的数据结构为 Source,即为今天的主角。

整个 Source 的结构比较简单,它基本是围绕着 Git 来做文章,主要是对 PodSpec 文件进行各种查找更新操作。结构如下:

 1# 用于检查 spec 是否符合当前 Source 要求
 2require 'cocoapods-core/source/acceptor'
 3# 记录本地 source 的集合
 4require 'cocoapods-core/source/aggregate'
 5# 用于校验 source 的错误和警告
 6require 'cocoapods-core/source/health_reporter'
 7# source 管理器
 8require 'cocoapods-core/source/manager'
 9# source 元数据
10require 'cocoapods-core/source/metadata'
11
12module Pod
13  class Source
14    # 仓库默认的 Git 分支
15    DEFAULT_SPECS_BRANCH = 'master'.freeze
16    # 记录仓库的元数据
17    attr_reader :metadata
18    # 记录仓库的本地地址
19    attr_reader :repo
20    # repo 仓库地址 ~/.cocoapods/repos/{repo_name}
21    def initialize(repo)
22      @repo = Pathname(repo).expand_path
23      @versions_by_name = {}
24      refresh_metadata
25    end
26    # 读取 Git 仓库中的 remote url 或 .git 目录
27    def url
28      @url ||= begin
29        remote = repo_git(%w(config --get remote.origin.url))
30        if !remote.empty?
31          remote
32        elsif (repo + '.git').exist?
33          "file://#{repo}/.git"
34        end
35      end
36    end
37
38    def type
39      git? ? 'git' : 'file system'
40    end
41    # ...
42  end
43end

Source 还有两个子类 CDNSourceTrunkSource,TrunkSouce 是 CocoaPods 的默认仓库。在版本 1.7.2 之前 Master Repo 的 URL 指向为 Github 的 Specs 仓库,这也是造成我们每次 pod installpod update 慢的原因之一。它不仅保存了近 10 年来 PodSpec 文件同时还包括 Git 记录,再加上墙的原因,每次更新都非常痛苦。而在 1.7.2 之后 CocoaPods 的默认 Source 终于改为了 CDN 指向,同时支持按需下载,缓解了 pod 更新和磁盘占用过大问题。

Source 的依赖关系如下:

01-Source

回到 Source 来看其如何初始化的,可以看到其构造函数 #initialize(repo) 将传入的 repo 地址保存后,直接调用了 #refresh_metadata 来完成元数据的加载:

1def refresh_metadata
2  @metadata = Metadata.from_file(metadata_path)
3end
4
5def metadata_path
6  repo + 'CocoaPods-version.yml'
7end

Metadata

Metadata 是保存在 repo 目录下,名为 CocoaPods-version.yml 的文件,用于记录该 Source 所支持的 CocoaPods 的版本以及仓库的分片规则

 1autoload :Digest, 'digest/md5'
 2require 'active_support/hash_with_indifferent_access'
 3require 'active_support/core_ext/hash/indifferent_access'
 4
 5module Pod
 6  class Source
 7    class Metadata
 8      # 最低可支持的 CocoaPods 版本,对应字段 `min`
 9      attr_reader :minimum_cocoapods_version
10      # 最高可支持的 CocoaPods 版本,对应字段 `max`
11      attr_reader :maximum_cocoapods_version
12      # 最新 CocoaPods 版本,对应字段 `last`
13      attr_reader :latest_cocoapods_version
14      # 规定截取的关键字段的前缀长度和数量
15      attr_reader :prefix_lengths
16      # 可兼容的 CocoaPods 最新版本
17      attr_reader :last_compatible_versions
18      # ...
19    end
20  end
21end

这里以笔者 💻 环境中 Master 仓库下的 CocoaPods-version.yml 文件内容为例:

1---
2min: 1.0.0
3last: 1.10.0.beta.1
4prefix_lengths:
5- 1
6- 1
7- 1

最低支持版本为 1.0.0,最新可用版本为 1.10.0.beta.1,以及最后这个 prefix_lengths[1, 1, 1] 的数组。那么这个 prefix_lengths 的作用是什么呢 ?

要回答这个问题,我们先来看一张 Spec Repo 的目录结构图:

02-trunk-folder

再 🤔 另外一个问题,为什么 CocoaPods 生成的目录结构是这样 ?

其实在 2016 年 CocoaPods Spec 仓库下的所有文件都在同级目录,不像现在这样做了分片。这个是为了解决当时用户的吐槽:Github 下载慢,最终解决方案的结果就如你所见:将 Git 仓库进行了分片

那么问题来了,为什么分片能够提升 Github 下载速度?

很重要的一点是 CocoaPods 的 Spec Repo 本质上是 Git 仓库,而 Git 在做变更管理的时候,会记录目录的变更,每个子目录都会对应一个 Git model。而当目录中的文件数量过多的时候,Git 要找出对应的变更就变得十分困难。有兴趣的同学可以查看官方说明

另外再补充一点,在 Linux 中最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。也就是说就算不用 Git 来管理 Specs 仓库,当目录下存在数以万计的文件时,如何高效查找目标文件也是需要考虑的问题。

Tips:关于文件系统层次结构有兴趣的同学可以查看FHS 标准,以及知乎这篇:传送门

回到 CocoaPods,如何对 Master 仓库目录进行分片就涉及到 Metadata 类中的关键方法:

 1def path_fragment(pod_name, version = nil)
 2  prefixes = if prefix_lengths.empty?
 3               []
 4             else
 5               hashed = Digest::MD5.hexdigest(pod_name)
 6               prefix_lengths.map do |length|
 7                 hashed.slice!(0, length)
 8               end
 9             end
10  prefixes.concat([pod_name, version]).compact
11end

#path_fragment 会依据 pod_name 和 version 来生成 pod 对应的索引目录:

  1. 首先对 pod_name 进行 MD5 计算获取摘要;
  2. 遍历 prefix_lengths 对生成的摘要不断截取指定的长度作为文件索引。

AFNetworking 为例:

1$ Digest::MD5.hexdigest('AFNetworking')
2"a75d452377f3996bdc4b623a5df25820"

由于我们的 prefix_lengths[1, 1, 1] 数组,那么它将会从左到右依次截取出一个字母,即: a75 ,这三个字母作为索引目录,它正好符合我们 👆 目录结构图中 AFNetworking 的所在位置。

Versions

要找到 Podfile 中限定版本号范围的 PodSpec 文件还需要需要最后一步,获取当前已发布的 Versions 列表,并通过比较 Version 得出最终所需的 PodSpec 文件。

在上一步已通过 metadatapod_name 计算出 pod 所在目录,接着就是找到 pod 目录下的 Versions 列表:

03-versons-folder

获取 Versions:

 1def versions(name)
 2  return nil unless specs_dir
 3  raise ArgumentError, 'No name' unless name
 4  pod_dir = pod_path(name)
 5  return unless pod_dir.exist?
 6  @versions_by_name[name] ||= pod_dir.children.map do |v|
 7    basename = v.basename.to_s
 8    begin
 9      Version.new(basename) if v.directory? && basename[0, 1] != '.'
10    rescue ArgumentError
11    raise Informative, 'An unexpected version directory ...'
12    end
13  end.compact.sort.reverse
14end

该方法重点在于将 pod_dir 下的每个目录都转换成为了 Version 类型,并在最后进行了 sort 排序。

#versions 方法主要在 pod search 命令中被调用,后续会介绍。

来搂一眼 Version 类:

1class Version < Pod::Vendor::Gem::Version
2  METADATA_PATTERN = '(\+[0-9a-zA-Z\-\.]+)'
3  VERSION_PATTERN = "[0-9]+(\\.[0-9a-zA-Z\\-]+)*#{METADATA_PATTERN}?"
4  # ...
5end

该 Version 继承于 Gem::Version 并对其进行了扩展,实现了语义化版本号的标准,sort 排序也是基于语义化的版本来比较的,这里我们稍微展开一下。

Semantic Versioning

语义化版本号(Semantic Versioning 简称:SemVer)绝对是依赖管理工具绕不开的坎。语义化的版本就是让版本号更具语义化,可以传达出关于软件本身的一些重要信息而不只是简单的一串数字。 我们每次对 Pod 依赖进行更新,最后最重要的一步就是更新正确的版本号,一旦发布出去,再要更改就比较麻烦了。

SemVer 是由 Tom Preston-Werner 发起的一个关于软件版本号的命名规范,该作者为 Gravatars 创办者同时也是 GitHub 联合创始人。

那什么是语义化版本号有什么特别呢 ?我们以 AFNetworking 的 release tag 示例:

13.0.0
23.0.0-beta.1
33.0.0-beta.2
43.0.0-beta.3
53.0.1

这些 tags 并非随意递增的,它们背后正是遵循了语义化版本的标准。

基本规则

  • 软件的版本通常由三位组成,如:X.Y.Z。
  • 版本是严格递增的,
  • 在发布重要版本时,可以发布 alpha, rc 等先行版本,
  • alpha 和 rc 等修饰版本的关键字后面可以带上次数和 meta 信息,

版本格式:

主版本号.次版本号.修订号

版本号递增规则如下:

1| Code status        | Stage                  | Example version |
2| ------------------ | ---------------------- | --------------- |
3| 新品首发             |1.0.0 开始           | 1.0.0           |
4| 向后兼容的 BugFix    | 增加补丁号 Z             | 1.0.1           |
5| 向后兼容的 Feature   | 增加次版本号 Y           | 1.1.0           |
6| 向后不兼容的改动      | 增加主版本号 X           | 2.0.0           |
7| 重要版本的预览版      | 补丁号后添加 alpha, rc   | 2.1.0-rc.0      |

关于 CocoaPods 的 Version 使用描述,传送门

CDNSource

CocoaPods 在 1.7.2 版本正式将 Master 仓库托管到 Netlify 的 CDN 上,当时关于如何支持这一特性的文章和说明铺天盖地,这里还是推荐大家看官方说明。另外,当时感受是似乎国内的部分 iOS 同学都炸了,各种标题党:什么最完美的升级等等。

所以这里明确一下,对于 CocoaPods 的 Master 仓库支持了 CDN 的行为,仅解决了两个问题:

  1. 利用 CDN 节点的全球化部署解决内容分发慢,提高 Specs 资源的下载速度。
  2. 通过 Specs 按需下载摆脱了原有 Git Repo 模式下本地仓库的磁盘占用过大,操作卡的问题。

然而,仅仅对 PodSpec 增加了 CDN 根本没能解决 GFW 导致的 Github 源码校验、更新、下载慢的问题。 只能说路漫漫其修远兮。

PS:作为 iOS 工程师,就经常被前端同学 😒 。你看这 CocoaPods 也太垃圾了吧!!!一旦删掉 Pods 目录重新 install 就卡半天,缓存基本不生效,哪像 npm 多快 balabala …

先来看 CDNSource 结构:

 1require 'cocoapods-core/source'
 2# ...
 3module Pod
 4  class CDNSource < Source
 5    def initialize(repo)
 6      # 标记是否正在同步文件
 7      @check_existing_files_for_update = false
 8      # 记录时间用于对比下载文件的新旧程度,以确认是否需要更新保存所下的资源
 9      @startup_time = Time.new
10      # 缓存查询过的 PodSpec 资源
11      @version_arrays_by_fragment_by_name = {}
12      super(repo)
13    end
14
15    def url
16      @url ||= File.read(repo.join('.url')).chomp.chomp('/') + '/'
17    end
18
19    def type
20      'CDN'
21    end
22    # ...
23  end
24end

Source 类是基于 Github Repo 来同步更新 PodSpec,而 CDNSource 则是基于 CDN 服务所返回的 Response,因此将 Source 类的大部分方法重写了一个遍,具体会在 SourceManager 一节来展开。

最后看一下 TrunkSource 类:

 1module Pod
 2  class TrunkSource < CDNSource
 3    # 新版落盘后仓库名称
 4    TRUNK_REPO_NAME = 'trunk'.freeze
 5
 6    TRUNK_REPO_URL = 'https://cdn.cocoapods.org/'.freeze
 7
 8    def url
 9      @url ||= TRUNK_REPO_URL
10      super
11    end
12  end
13end

核心就是重写了返回的 url,由于旧版 Spec 仓库名称为 master 为了加以区分,CDN 仓库则改名为 trunk

Source Manager

Manager 作为 source 的管理类,其主要任务为 source 的添加和获取,而对 PodSpec 文件的更新和查找行为则交由 source 各自实现。不过由于一个 pod 库可能对应多个不同的 source,这里又产生出 Aggregate 类来统一 PodSpec 的查询。

它们的关系如下:

04-manager

Manager 实现:

 1module Pod
 2  class Source
 3    class Manager
 4      attr_reader :repos_dir
 5
 6      def initialize(repos_dir)
 7        @repos_dir = Pathname(repos_dir).expand_path
 8      end
 9
10      def source_repos
11        return [] unless repos_dir.exist?
12        repos_dir.children.select(&:directory?).sort_by { |d| d.basename.to_s.downcase }
13      end
14
15      def aggregate
16        aggregate_with_repos(source_repos)
17      end
18
19      def aggregate_with_repos(repos)
20        sources = repos.map { |path| source_from_path(path) }
21        @aggregates_by_repos ||= {}
22        @aggregates_by_repos[repos] ||= Source::Aggregate.new(sources)
23      end
24
25      def all
26        aggregate.sources
27      end
28      # ...
29    end
30  end
31end

Manager 类的初始化仅需要传入当前 repos 目录,即 ~/.cocoapods/repos,而 Aggregate 的生成则保存 repos_dir 了目录下的 Source,用于后续处理。

先看 Source 的生成,在 #source_from_path 中:

 1def source_from_path(path)
 2  @sources_by_path ||= Hash.new do |hash, key|
 3    hash[key] = case
 4                when key.basename.to_s == Pod::TrunkSource::TRUNK_REPO_NAME
 5                  TrunkSource.new(key)
 6                when (key + '.url').exist?
 7                  CDNSource.new(key)
 8                else
 9                  Source.new(key)
10                end
11  end
12  @sources_by_path[path]
13end

repos_dir 下的目录名称来区分类型,而 CDNSource 则需要确保其目录下存在名为 .url 的文件。同时会对生成的 source 进行缓存。

最后看 Aggregate 结构,核心就两个 search 方法:

 1module Pod
 2  class Source
 3    class Aggregate
 4      attr_reader :sources
 5
 6      def initialize(sources)
 7        raise "Cannot initialize an aggregate with a nil source: (#{sources})" if sources.include?(nil)
 8        @sources = sources
 9      end
10      # 查询依赖对应的 specs
11      def search(dependency) ... end
12       
13      # 查询某个 pod 以发布的 specs
14      def search_by_name(query, full_text_search = false) ... end
15        
16      # ...
17  end
18end

Source 源起

本节我们来谈谈 source 是如何添加到 repo_dir 目录下的。

由前面的介绍可知,每个 source 中自带 url,在 Source 类中 url 读取自 Git 仓库的 remote.origin.url 或本地 .git 目录,而在 CDNSource 中 url 则是读取自当前目录下的 .url 文件所保存的 URL 地址。

那 CDNSource 的 .url 文件是在什么时候被写入的呢 ?

这需要从 Podfile 说起。很多老项目的 Podfile 开头部分大都会有一行或多行 source 命令:

1source 'https://github.com/CocoaPods/Specs.git'
2source 'https://github.com/artsy/Specs.git'

用于指定项目中 PodSpec 的查找源,这些指定源最终会保存在 ~/.cocoapods/repos 目录下的仓库。

当敲下 pod install 命令后,在 #resolve_dependencies 阶段的依赖分析中将同时完成 sources 的初始化。

01-pod-install

 1# lib/cocoapods/installer/analyzer.rb
 2
 3def sources
 4  @sources ||= begin
 5    # 省略获取 podfile、plugins、dependencies 的 source url ...
 6    sources = ...
 7     
 8    result = sources.uniq.map do |source_url|
 9      sources_manager.find_or_create_source_with_url(source_url)
10    end
11    unless plugin_sources.empty?
12      result.insert(0, *plugin_sources)
13      plugin_sources.each do |source|
14        sources_manager.add_source(source)
15      end
16    end
17    result
18  end
19end

获取 sources url 之后会通过 sources_manager 来完成 source 更新,逻辑在 CocoaPods 项目的 Manager 扩展中:

 1# lib/cocoapods/sources_manager.rb
 2
 3module Pod
 4  class Source
 5    class Manager
 6
 7      def find_or_create_source_with_url(url)
 8        source_with_url(url) || create_source_with_url(url)
 9      end
10
11      def create_source_with_url(url)
12        name = name_for_url(url)
13        is_cdn = cdn_url?(url)
14		  # ...
15        begin
16          if is_cdn
17            Command::Repo::AddCDN.parse([name, url]).run
18          else
19            Command::Repo::Add.parse([name, url]).run
20          end
21        rescue Informative => e
22          raise Informative, # ...
23        ensure
24          UI.title_level = previous_title_level
25        end
26        source = source_with_url(url)
27        raise "Unable to create a source with URL #{url}" unless source
28        source
29      end
30      # ...
31    end
32  end
33end

查找会先调用 #source_with_url 进行缓存查询,如未命中则会先下载 Source 仓库,结束后重刷 aggreate 以更新 source。

 1# lib/cocoapods-core/source/manager.rb
 2
 3def source_with_url(url)
 4  url = canonic_url(url)
 5  url = 'https://github.com/cocoapods/specs' if url =~ %r{github.com[:/]+cocoapods/specs}
 6  all.find do |source|
 7    source.url && canonic_url(source.url) == url
 8  end
 9end
10
11def canonic_url(url)
12  url.downcase.gsub(/\.git$/, '').gsub(%r{\/$}, '')
13end

另外,仓库的下载的则会通过 #cdn_url? 方法区分,最后的下载则 📦 在两个命令类中,概括如下:

  • Command::Repo::AddCDN:即 pod repo add-cdn 命令,仅有的操作是将 url 写入 .url 文件中。
  • Command::Repo::Add:即 pod repo add 命令,对于普通类型的 Source 仓库下载本质就是 git clone 操作。

简化后源的添加流程如下:

05-source-add

PodSpec 查询

同样在 #resolve_dependencies 的依赖仲裁阶段,当 Molinillo 依赖仲裁开始前,会触发缓存查询 #find_cached_set 并最终调用到 Aggregate 的 #search。完整调用栈放在 gist 上。

我们来看看 #search 入口:

1# lib/cocoapods-core/source/aggregate.rb
2
3def search(dependency)
4  found_sources = sources.select { |s| s.search(dependency) }
5  unless found_sources.empty?
6    Specification::Set.new(dependency.root_name, found_sources)
7  end
8end

Aggregate 先遍历当前 sources 并进行 dependency 查找。由于 Git 仓库保存了完整的 PodSpecs,只要能在分片目录下查询到对应文件即可,最终结果会塞入 Specification::Set 返回。

Specification::Set 记录了当前 pod 关联的 Source,一个 pod 可能存在与多个不同的 Spec 仓库 中。

CDN 仓库查询

CDNSource 重写了 #search 实现:

 1# lib/cocoapods-core/cdn_source.rb
 2
 3def search(query)
 4  unless specs_dir
 5    raise Informative, "Unable to find a source named: `#{name}`"
 6  end
 7  if query.is_a?(Dependency)
 8    query = query.root_name
 9  end
10
11  fragment = pod_shard_fragment(query)
12  ensure_versions_file_loaded(fragment)
13  version_arrays_by_name = @version_arrays_by_fragment_by_name[fragment] || {}
14   
15  found = version_arrays_by_name[query].nil? ? nil : query
16
17  if found
18    set = set(query)
19    set if set.specification_name == query
20  end
21end

逻辑两步走:

  1. 通过 #ensure_versions_file_loaded 检查 all_pods_versions 文件,如果不存在会进行下载操作。
  2. 如果当前 source 包含查询的 pod,会创建 Specification::Set 作为查询结果,并在 #specification_name 方法内完成 PodSpec 的检查和下载。

0x01 all_pods_versions 文件下载

依据前面提到的分片规则会将 pod 名称 MD5 分割后拼成 URL。

AFNetworking 为例,经 #pod_shard_fragment 分割后获取的 fragment 为 [a, 7, 5],则拼接后的 URL 为 https://cdn.cocoapods.org/all_pods_versions_a_7_5.txt,下载后的内容大致如下:

1AFNetworking/0.10.0/0.10.1/.../4.0.1
2AppseeAnalytics/2.4.7/2.4.8/2.4.8.0/...
3DynamsoftBarcodeReader/7.1.0/...
4...

所包含的这些 pod 都是分片后得到的相同的地址,因此会保存在同一份 all_pods_versions 中。

 1def ensure_versions_file_loaded(fragment)
 2  return if !@version_arrays_by_fragment_by_name[fragment].nil? && !@check_existing_files_for_update
 3
 4  index_file_name = index_file_name_for_fragment(fragment)
 5  download_file(index_file_name)
 6  versions_raw = local_file(index_file_name, &:to_a).map(&:chomp)
 7  @version_arrays_by_fragment_by_name[fragment] = versions_raw.reduce({}) do |hash, row|
 8    row = row.split('/')
 9    pod = row.shift
10    versions = row
11
12    hash[pod] = versions
13    hash
14  end
15end
16
17def index_file_name_for_fragment(fragment)
18  fragment_joined = fragment.join('_')
19  fragment_joined = '_' + fragment_joined unless fragment.empty?
20  "all_pods_versions#{fragment_joined}.txt"
21end

另外每一份 pods_version 都会对应生成一个文件用于保存 ETag,具体会在下一节会介绍。

0x02 PodSpec 文件下载

#specification_name 将从 all_pods_versions 索引文件中找出该 pod 所发布的版本号,依次检查下载对应版本的 PodSpec.json 文件。

 1module Pod
 2  class Specification
 3    class Set
 4      attr_reader :name
 5      attr_reader :sources
 6      
 7      def specification_name
 8        versions_by_source.each do |source, versions|
 9          next unless version = versions.first
10          return source.specification(name, version).name
11        end
12        nil
13      end
14
15      def versions_by_source
16        @versions_by_source ||= sources.each_with_object({}) do |source, result|
17          result[source] = source.versions(name)
18        end
19      end
20      # ...
21    end
22  end
23end

绕了一圈后回到 Source 的 #versions 方法,由于 CDN Source 不会全量下载 pod 的 PodSpec 文件,在 #version 的检查过程会进行下载操作。

08-cdn-search

Pod Search 查询命令

CocoaPods 还提供了命令行工具 cocoapods-search 用于已发布的 PodSpec 查找:

1$ pod search `QUERY`

它提供了 Web 查询和本地查询。本地查询则不同于 #search,它需要调用 Aggregate 的 #search_by_name ,其实现同 #search 类似,最终也会走到 Source 的 #versions 方法。

07-pod-search

注意,Gti 仓库的 #search_by_name 查询仍旧为文件查找,不会调用其 #versions 方法。

Repo 更新

pod install 执行过程如果带上了 --repo-update 命令则在 #resolve_dependencies 阶段会触发 #update_repositories 更新 Spec 仓库:

 1# lib/cocoapods/installer/analyzer.rb
 2
 3def update_repositories
 4  sources.each do |source|
 5    if source.updateable?
 6      sources_manager.update(source.name, true)
 7    else
 8      UI.message "Skipping ..."
 9    end
10  end
11  @specs_updated = true
12end

不过 #update 的实现逻辑在 CocoaPods 项目的 Manager 扩展中:

 1# lib/cocoapods/sources_managers.rb
 2
 3def update(source_name = nil, show_output = false)
 4  if source_name
 5    sources = [updateable_source_named(source_name)]
 6  else
 7    sources = updateable_sources
 8  end
 9
10  changed_spec_paths = {}
11
12  # Do not perform an update if the repos dir has not been setup yet.
13  return unless repos_dir.exist?
14
15  File.open("#{repos_dir}/Spec_Lock", File::CREAT) do |f|
16    f.flock(File::LOCK_EX)
17    sources.each do |source|
18      UI.section "Updating spec repo `#{source.name}`" do
19        changed_source_paths = source.update(show_output)
20        changed_spec_paths[source] = changed_source_paths if changed_source_paths.count > 0
21        source.verify_compatibility!
22      end
23    end
24  end
25  update_search_index_if_needed_in_background(changed_spec_paths)
26end
  1. 获取指定名称的 source,对 aggregate 返回的全部 sources 进行 filter,如未指定则 sources 全量。

  2. 挨个调用 source.update(show_output),注意 Git 和 CDN 仓库的更新方式的不同。

Git 仓库更新

Git 仓库更新本质就是 Git 操作,即 git pullgit checkout 命令:

 1def update(show_output)
 2  return [] if unchanged_github_repo?
 3  prev_commit_hash = git_commit_hash
 4  update_git_repo(show_output)
 5  @versions_by_name.clear
 6  refresh_metadata
 7  if version = metadata.last_compatible_version(Version.new(CORE_VERSION))
 8    tag = "v#{version}"
 9    CoreUI.warn "Using the ..."
10    repo_git(['checkout', tag])
11  end
12  diff_until_commit_hash(prev_commit_hash)
13end

#update_git_repo 就是 git fetch + git reset --hard [HEAD] 的结合体,更新后会进行 cocoapods 版本兼容检查,最终输出 diff 信息。

CDN 仓库更新

Git 仓库是可以通过 Commit 信息来进行增量更新,那以静态资源方式缓存的 CDN 仓库是如何更新数据的呢 ?

像浏览器或本地缓存本质是利用 ETag 来进行 Cache-Control,关于 CDN 缓存可以看这篇:传送门

而 ETag 就是一串字符,内容通常是数据的哈希值,由服务器返回。首次请求后会在本地缓存起来,并在后续的请求中携带上 ETag 来确定缓存是否需要更新。如果 ETag 值相同,说明资源未更改,服务器会返回 304(Not Modified)响应码。

Core 的实现也是如此,它会将各请求所对应的 ETag 以文件形式存储:

06-source-cdn

⚠️ 注意,在这个阶段 CDNSource 仅仅是更新当前目录下的索引文件,即 all_pods_versions_x_x_x.txt

 1def update(_show_output)
 2  @check_existing_files_for_update = true
 3  begin
 4    preheat_existing_files
 5  ensure
 6    @check_existing_files_for_update = false
 7  end
 8  []
 9end
10
11def preheat_existing_files
12  files_to_update = files_definitely_to_update + deprecated_local_podspecs - ['deprecated_podspecs.txt']
13
14  concurrent_requests_catching_errors do
15    loaders = files_to_update.map do |file|
16      download_file_async(file)
17    end
18    Promises.zip_futures_on(HYDRA_EXECUTOR, *loaders).wait!
19  end
20end

Pod Repo 更新命令

CocoaPods 对于 sources 仓库的更新也提供了命令行工具:

1    $ pod repo update `[NAME]`

其实现如下:

 1# lib/cocoapods/command/repo/update.rb
 2
 3module Pod
 4  class Command
 5    class Repo < Command
 6      class Update < Repo
 7        def run
 8          show_output = !config.silent?
 9          config.sources_manager.update(@name, show_output)
10          exclude_repos_dir_from_backup
11        end
12        # ...
13      end
14    end
15  end
16end

在命令初始化时会保存指定的 Source 仓库名称 @name,接着通过 Mixin 的 config 来获取 sources_manager 触发更新。

最后用一张图来收尾 CocoaPods Workflow:

09-cocoapods-flow

总结

最后一篇 Core 的分析文章,重点介绍了它是如何管理 PodSpec 仓库以及 PodSpec 文件的更新和查找,总结如下:

  1. 了解 Source Manager 的各种数据结构以及它们之间的相互关系,各个类之间居然都做到了权责分明。
  2. 通过对 Metadata 的分析了解了 Source 仓库的演变过程,并剖析了存在的问题。
  3. 掌握了如何利用 CDN 来改造原有的 Git 仓库,优化 PodSpec 下载速度。
  4. 发现原来 CLI 工具不仅仅可以提供给用户使用,内部调用也不是不可以。

知识点问题梳理

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

  1. PodSpecs 的聚合类有哪些,可以通过哪些手段来区分他们的类型 ?
  2. 说说你对 Aggregate 类的理解,以及它的主要作用 ?
  3. Source 类是如何更新 PodSpec
  4. Core 是如何对仓库进行分片的,它的分片方式是否支持配置 ?
  5. CDN 仓库是如何来更新 PodSpec 文件 ?