抖音研发效能建设 - CocoaPods 优化实践(1)

  1. 依赖组件多,User 工程复杂度,导致 Pod Install 后 Xcode 工程索引慢,卡顿严重

  2. 依赖组件多,工程构建出现不符合预期的失败问题,比如 Arguments Too Long

  3. 研发流程上,有部分研发同学本地误清理了 CocoaPods 缓存,导致工程编译或者链接失败

  4. 组件拆分后,新添加文件必须 Pod Install 后才可以被其他组件访问,这拖慢了研发效率

我们开始尝试在 0 侵入、不影响现有研发流程的前提下,改造 CocoaPods 做来解决我们遇到的问题,并且取得了一些收益。在介绍我们的优化前,我们会先对 CocoaPods 做一些介绍, 我们以 CocoaPods 1.7.5 为例来做说明依赖管理的核心流程「Pod Install」

Pod Install


我们以一个 MVP 工程「iOSPlayground」为例子来说明,iOSPlayground 工程是怎么组织的:

| iOSPlayground.xcodeproj | 壳工程,包含 App Target:iOSPlayground |

| — | — |

| iOSPlayground | 壳工程文件目录,包含资源、代码、Info.plist |

| Podfile | 声明 User Target 的依赖 |

| Gemfile | 声明 CocoaPods 的版本,这里是 1.7.5 |

我们在 Podfile 中为 Target「iOSPlayground」引入 SDWebImage 以及 SDWebImage 的两个 Coder,并声明这些组件的版本约束

platform :ios, ‘11.0’

project ‘iOSPlayground.xcodeproj’

target ‘iOSPlayground’ do

pod ‘SDWebImage’, ‘~> 5.6.0’

pod ‘SDWebImageLottieCoder’, ‘~> 0.1.0’

pod ‘SDWebImageWebPCoder’, ‘~> 0.6.1’

end

然后执行 Pod install 命令 bundle exec pod install,CocoaPods 开始为你构建多依赖的开发环境;整个 Pod Install 流程最核心的就是 ::Pod::Installer 类,Pod Install 命令会初始化并配置 Installer,然后执行 install! 流程,install! 流程主要包括 6 个环节

def install!

prepare

resolve_dependencies # 依赖决议

download_dependencies # 依赖下载

validate_targets # Pods 校验

generate_pods_project # Pods Project 生成

if installation_options.integrate_targets?

integrate_user_project # User Project 整合

else

UI.p ‘Skipping User Project Integration’

end

perform_post_install_actions # 收尾

end

下面会对这 5 个流程做一些简单分析,为了简单起见,我们会忽略一些细节。

准备阶段

这个流程主要是在 Pod Install 前做一些环境检查,并且初始化 Pod Install 的执行环境。

依赖分析

这个流程的主要目标是分析决议出所有依赖的版本,这里的依赖包括 Podfile 中引入的依赖,以及依赖本身引入的依赖,为 Downloader 和 Generator 流程做准备。

这个过程的核心是构建 Molinillo 决议的环境:准备好 Specs 仓库,分析 Podfile 和 Podfile.lock,然后进行 Molinillo 决议,决议过程是基于 DAG(有向无环图)的,可以参考下图,按照最优顺序依次进行决议直到最后决议出所有节点上依赖的版本和来源。

iOSPlayground 工程最后决议出的依赖列表是:

基于最后决议的结果我们就可以获取 Specifications、生成 Aggregate Targets 和 Pod Targets。

Aggregate Targets:

Pod Targets:

| Version | 一般是用点分割的可以比较的序列,组件会以版本的形式对外发布 |

| — | — |

| Requirement | 一个或者多个版本限制的组合 |

| Source | Specs 仓库,组件发版的位置,用于管理多个组件多个版本的一组描述文件 |

| Dependency | User Target 的依赖或者依赖的依赖,由依赖名称、版本、约束、来源构成 |

| Podfile | Ruby DSL 文件,用于描述 Xcode 工程中 Targets 的依赖列表 |

| Podfile.lock | YAML 文件,Pod Install 后生成的依赖决议结果文件 |

| Podspec | Ruby DSL 文件,用于描述 Pod,包括名称、版本、子组件、依赖列表等 |

| Pod Target | 一个组件对应一个 Pod Target |

| Aggregate Target | 用来聚合一组 Pod Target,User Target 会依赖对应的 Aggragate Target |

| $HOME/.cocoapods/repos/ | 本地存储需要使用的 Specs 仓库 |

依赖下载

这个流程的目标是下载依赖,下载前会根据依赖分析的结果 specifications 和 sandbox_state 生成需要下载的 Pods 列表,然后串行下载所有依赖。这里只描述 Cache 开启的情况,具体流程可以参考下图:

CocoaPods 会根据 Pod 来源选择合适的下载器,如果是 HTTP 地址,使用 CURL 进行下载;如果是 Git 地址,使用 Git 进行拉取;CocoaPods 也支持 SVN/HG/SCP 等方式。

iOSPlayground 工程的下载流程:

| $HOME/Library/Caches/CocoaPods/ | Pod 本地缓存目录,用存储下载到本地的 Pod,避免二次下载 |

| — | — |

Pods 校验

这个阶段主要是检查 Pod 描述文件 Speification、Pod Targets 和 Aggregate Targets 配置是否正确,从而保证 Pod Install 后可以进行正确构建,一般包括四个流程的检查。

Pods 工程生成

这个流程的目标是生成 Pods 工程,根据依赖决议的结果 Pod Targets 和 Aggregate Targets,生成 Pods 工程,并生成工程中 Pod Targets 和 Aggregate Targets 对应的 Native Targets。

CocoaPods 提供两种 Project 的生成策略:Single Project Generator 和 Multiple Project Generator,Single Project Generator 是指只生成 Pods/Pods.xcodeproj,Native Pod Target 属于 Pods.xcodeproj;Multiple Project 是 CocoaPods 1.7.0 引入的新功能,不只会生成 Pods/Pods.xcodeproj,并且会为每一个 Pod 单独生成 Xcode Project,Pod Native Target 属于独立的 Pod Xcode Project,Pod Xcode Project 是 Pods.xcodeproj 的子工程,相比 Single Project Generator,会有性能优势。这里我们以 Single Project 为例,来说明 Pods.xcodeproj 生成的一般流程。

iOSPlayground 工程在 Single Project Generator 下生成的工程结构:

| Pods/ | 沙盒目录 |

| — | — |

| Pods/Pods.xcodeproj | Pod Target、Aggregate Target 的容器工程 |

| Pods/Manifest.lock | Podfile.lock 的备份,项目构建前会和 Podfile.lock 比较,以判断当前的沙盒和工程对应 |

| Pods/Headers/ | 管理 Pod 头文件的目录,支持基于 HEADER_SEARCH_PATHS 的头文件检索 |

| Pods/Target Support Files/ | CocoaPods 为 Pod Target、Aggregate Target 生成的文件,包括:xcconfig、modulemap、resouce copy script、framework copy scrpt 等 |

User 工程整合

这个流程的目标是将 Pods.xcodeproj 整合到 User.xcodeproj 上,将 User Target 整合到 CocoaPods 的依赖环境中,从而在后续的构建流程生效:

User Target 的整合一般包括 Xcconfig 整合、Target 整合、动态库整合和资源整合等:

| User.xcodeproj | 壳工程,用于生成 App 等产品,名字一般自定义 |

| — | — |

| User Target | 壳工程中用于生成指定产品的 Target |

| User.xcworkspace | CocoaPods 生成,合并 User.xcodeproj 和 Pods/Pods.xcodeproj |

User 工程构建

Pod Install 执行完成后,就将 User Target 整合到了 CocoaPods 环境中。User Target 依赖 Aggregate Target,Aggregate Target 依赖所有 Pod Targets,Pod Targets 按照 Pod 描述文件(Podspec)中的依赖关系进行依赖,这些依赖关系保证了编译顺序

iOSPlayground 工程中 User Target: iOSPlayground 依赖了 Aggregate Target 的产物 libPods-iOSPlayground.a

iOSPlayground 工程中 Aggregate Target: Pod-iOSPlayground 依赖了了所有 Pod Targets

编译完成后,就开始进行链接、资源整合、动态库整合、APP 签名等操作,直到最后生成完整 APP。Xcode 提供了 Build Phases 方便我们查看和编辑构建流程配置,同时我们也可以通过构建日志查看整个 APP 的构建流程:

如何评估


我们需要建立一些数据指标来进行衡量我们的优化结果,CocoaPods 内置了 ruby-prof(https://ruby-prof.github.io/) 工具。ruby-prof 是一个 Ruby 程序性能分析工具,可以用于测量程序耗时、对象分配以及内存占用等多种数据指标,提供了 TXT、HTML、CallGrind 三种格式。首先安装 ruby-prof,然后设置环境变量 COCOAPODS_PROFILE 为性能测试文件的地址,Pod Install 执行完成后会输出性能指标文件

ruby-perf 提供的数据是我们进行 CocoaPods 效能优化的重要参考,结合这部分数据我们可以很方便地分析方法堆栈的耗时以及其他性能指标。

但是 Ruby-perf 工具是 Ruby 方法级别,难以细粒度地查看实际 Pod Instal 过程中各个具体流程的耗时,可以作为数据参考,但是难以作为效率优化结果的标准。同时我们也需要一套体系来衡量 Pod Install 各个流程的耗时,基于这个诉求,我们自研了 CocoaPods 的 Profiler,并且在远端搭建了数据监控体系:

  1. Profiler 可以在本地打印各阶段耗时,也可以下钻到详细的流程

install! consume : 5.376132s prepare consume : 0.002049s resolve_dependencies consume : 4.065177s download_dependencies consume : 0.001196s validate_targets consume : 0.037846s generate_pods_project consume : 0.697412s integrate_user_project consume : 0.009258s

  1. Profiler 会把数据上传到平台,方便进行数据可视化

Profiler 除了上传 Pod Install 各个耗时指标以外,也会上传失败情况和错误日志,这些数据会被用于衡量稳定性优化的效果。

优化实践


对 Pod Install 的执行流程有了一定的了解后,基于 Ruby 语言的提供的动态性,我们开始尝试在 0 侵入、不影响现有研发流程的前提下,改造 CocoaPods 做来解决我们遇到的问题,并且取得了一些收益。

Source 更新

按需更新

我们知道 CocoaPods 在进行依赖版本决议的时候,会从本地 Source 仓库(一般是多个 Git 仓库)中查找符合版本约束的 Podspecs,如果本地仓库中没有符合要求的,决议会失败。仓库中没有 Podspec 分为几种情况:

  1. 本地 Source 仓库没有更新,和远程 Source 仓库不同步

  2. 远程 Source 仓库没有发布符合版本约束的 Podspec

原因 2 是符合预期的;原因 1 是因为研发同学没有主动更新本地 source repo 仓库,可以在 pod install 后添加 --repo-update 参数来强制更新本地仓库,但是每次都加上这个参数会导致 Pod Install 执行效率下降,尤其是对包含多个 source repo 的工程。

UI.p ‘Updating local specs repositories’ do

analyzer.update_repositories

end if repo_update?

怎么做可以避免这个问题,同时保证研发效率?

  1. 不主动更新仓库,如果找不到 Podspec,再自动更新仓库

  2. 不更新所有仓库,按需更新部分仓库

  3. 如果有新增组件,找不到 Podspec 后,自动更新所有仓库

  4. 如果部分更新后依然失败,自动更新所有仓库;这种情况出现在隐式依赖新增的情况

仓库按需更新,是指基于 Podfile.lock 查找哪些依赖的版本不在所属的仓库内,标记该依赖所属的仓库为需要更新,循环执行,检查所有依赖,获取到所有需要更新的仓库,更新所有标记为需要更新的仓库。

这样研发同学不需要关心本地 Source 仓库是否更新,仓库会按照最佳方式自动和远程同步。

更新同步

在仓库更新流程中也会出现并发问题,比如在抖音的 CI 环境上构建任务是并发执行的,在某些情况下多个任务会同时更新本地 source 仓库,Git 仓库会通过锁同步机制强制并发更新失败,这就导致了 CI 任务难以并发执行。如何解决并发导致的失败问题?

  1. 最简单的方式就是避免并发,一个机器同时只能执行一个任务,但是这会导致 CI 执行效率下降。

  2. 不同任务间进行 source 仓库隔离,CocoaPods 默认提供了这种机制,可以通过环境变量 CP_REPOS_DIR 的设置来自定义 source 仓库的根目录,但是 source 仓库隔离后,会导致同一个仓库占用多份磁盘,同时在需要更新的场景下,需要更新两次,这会影响到 CI 执行效率。

方案 1 和方案 2 一定程度保证了任务的稳定性,但是影响了研发效率,更好的方式是只在需要同步的地方串行,不需要同步的地方并发执行。一个自然而然的想法就是使用锁,不同 CocoaPods 任务是不同的 Ruby 进程,在进程间做同步可以使用文件锁。通过文件锁机制,我们保证了只有一个任务在更新仓库。

CocoaPods 仓库更新流程流程遇到的问题,本质是由于使用了本地的 Git 仓库来管理导致,在 CocoaPods 1.9.0 + ,引入 CDN Source 的概念,抖音也在尝试向 CDN Source 做迁移。

依赖决议

简化决议

CocoaPods 的依赖版本决议流程是基于 Molinillo 的,Molinillo 是基于 DAG 来进行依赖解析的,通过构建图可以方便的进行依赖关系查找、依赖环查找、版本降级等。但是使用图来进行解析是有成本的,实际上大部分的本地依赖决议场景并不需要这么复杂,Podfile.lock 中的版本就是决议后的版本,大部分的研发流程直接使用 Podfile.lock 进行线性决议就可以,这可以大幅加快决议速度。

Specification 缓存

依赖分析流程中,CocoaPods 需要获取满足约束的 Specifications,1.7.5 上的流程是获取一个组件的所有版本的 Specifications 并缓存,然后从 Specifications 中筛选出满足约束的 Specifications。对于复杂的项目来说,往往对一个依赖的约束来自于多个组件,比如 A 依赖 F(>=0),B 依赖 F (>=0),在分析完 A 对 F 的依赖后,在处理 B 对 F 的依赖时,还是需要进行一次全量比较。通过优化 Specification 缓存层可以减少这部分耗时,直接返回。

module Pod::Resolver

def specifications_for_dependency(dependency, additional_requirements = [])

requirement = Requirement.new(dependency.requirement.as_list + additional_requirements.flat_map(&:as_list))

find_cached_set(dependency).

all_specifications(warn_for_multiple_pod_sources).

select { |s| requirement.satisfied_by? s.version }.

map { |s| s.subspec_by_name(dependency.name, false, true) }.

compact

end

end

module Pod::Specification::Set

def all_specifications(warn_for_multiple_pod_sources)

@all_specifications ||= begin

#…

end

end

end

优化后:

module Pod::Resolver

def specifications_for_dependency(dependency, additional_requirements = [])

requirement_list = dependency.requirement.as_list + additional_requirements.flat_map(&:as_list)

requirement_list.uniq!

requirement = Requirement.new(requirement_list)

find_cached_set(dependency).

all_specifications(warn_for_multiple_pod_sources, requirement) .

map { |s| s.subspec_by_name(dependency.name, false, true) }.

compact

end

end

module Pod::Specification::Set

def all_specifications(warn_for_multiple_pod_sources, requirement)

@all_specifications ||= {}

@all_specifications[requirement]  ||= begin

#…

end

end

end

CocoaPods 1.8.0 开始也引入了这个优化,但是 1.8.0 中并没有重载 Pod::Requirement 的 eql? 方法,这会导致使用 Pod::Requirement 对象做 Key 的情况下,没有办法命中缓存,导致缓存失效了,我们重载 eql? 生效决议缓存,加速了 Molinillo 决议流程,获得了很大的性能提升:

module Pod::Requirement

def eql?(other)

@requirements.eql? other.requirements

end

end

循环依赖发现

当出现循环依赖时,CocoaPods 会报错,但报错信息只有谁和谁之间存在循环依赖,比如:

There is a circular dependency between A/S1 and D/S1

随着工程的复杂度提高,对于复杂的循环依赖关系,比如 A/S1 -> B -> C-> D/S2 -> D/S1 -> A/S1, 基于上面的信息我们很难找到真正的链路,而且循环依赖往往不止一条,subspec、default spec 等设置也提高了问题定位的复杂度。我们优化了循环依赖的报错,当出现循环依赖的时候,比如 A 和 D 之间有环,我们会查找 A -> D/S1 之前所有的路径,并打印出来:

There is a circular dependency between A/S1 and D/S1 Possible Paths:A/S1 -> B -> C-> D/S2 -> D/S1 -> A/S1 A/S1 -> B -> C -> C2 -> D/S2 -> D/S1 -> A/S1 A/S1 -> B -> C -> C3 -> C2 -> D/S2 -> D/S1 -> A/S1

沙盒分析缓存

SandboxAnalyzer 主要用于分析沙盒,通过决议结果和沙盒内容判断哪些 Pods 需要删除哪些 Pods 需要重装,但是在分析过程中,存在大量的重复计算,我们缓存了 sandbox analyzer 计算的中间结果,使 sandbox analyzer 流程耗时减少 60%。

依赖下载

大型项目往往要引入几百个组件,一旦组件发布新版本或者没有命中缓存就会触发组件下载,依赖下载慢也成为大型项目反馈比较集中的问题。

依赖并发下载

CocoaPods 一个很明显的问题就是依赖是串行下载的,串行下载难以达到带宽峰值,而且下载过程除了网络访问,还会进行解压缩、文件准备等,这些过程中没有进行网络访问,如果把下载并行是可以提高依赖下载效率的。我们将抖音的下载过程优化为并发操作,下载流程总时间减少了 60%以上。

HTTP API 下载

CocoaPods 支持多种下载方式的,比如 Git、Http 等。一般组件以源码发布,会使用 Git 地址作为代码来源,但是 Git 下载是比 Http 下载慢的,一是 Git 下载需要做额外的处理和校验,速度和稳定性要低于 HTTP 下载,二是在组件是通过 Git 和 Commit 指明 source 发布的情况下,Git 下载页会克隆仓库的日志 GitLog, 对于开发比较频繁的项目,日志大小要远大于仓库实际大小,这会导致组件下载时间变长。我们基于 Gitlab API 将 Git 地址转化为 HTTP 地址进行下载,就可以加快这部分组件的下载速度了。

沙盒软连接

CocoaPods 在安装依赖的时候,会在沙箱 Pods 目录下查找对应依赖,如果对应依赖不存在,则会将缓存中的依赖文件拷贝到沙箱 Pods 目录下。对于本地有多个工程的情况,Pods 目录占用磁盘就会更多。同时,将缓存拷贝到沙箱也会耗时,对于抖音工程,如果所有的内容都要从缓存拷贝到沙箱,大概需要 60s 左右。我们使用软连接替换拷贝,直接通过链接缓存中的 Pod 内容来使用依赖,而不是将缓存拷贝到 Pods 沙箱目录中,从而减少这部分磁盘占用,同时减少拷贝的时间。

缓存有效检查

在抖音使用 CocoaPods 的过程中,尤其是 CI 并发环境,存在缓存中文件不全的情况,缺少部分文件或者整个文件夹,这会导致编译失败或者运行存在问题。CocoaPods 本身有保证 Pods 缓存有效的机制:

结尾

  • 腾讯T4级别Android架构技术脑图;查漏补缺,体系化深入学习提升

img

  • 一线互联网Android面试题含详解(初级到高级专题)

这些题目是今年群友去腾讯、百度、小米、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。并且大多数都整理了答案,熟悉这些知识点会大大增加通过前两轮技术面试的几率

img

有Android开发3-5年基础,希望突破瓶颈,成为架构师的小伙伴,可以关注我
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
目录下。对于本地有多个工程的情况,Pods 目录占用磁盘就会更多。同时,将缓存拷贝到沙箱也会耗时,对于抖音工程,如果所有的内容都要从缓存拷贝到沙箱,大概需要 60s 左右。我们使用软连接替换拷贝,直接通过链接缓存中的 Pod 内容来使用依赖,而不是将缓存拷贝到 Pods 沙箱目录中,从而减少这部分磁盘占用,同时减少拷贝的时间。

缓存有效检查

在抖音使用 CocoaPods 的过程中,尤其是 CI 并发环境,存在缓存中文件不全的情况,缺少部分文件或者整个文件夹,这会导致编译失败或者运行存在问题。CocoaPods 本身有保证 Pods 缓存有效的机制:

结尾

  • 腾讯T4级别Android架构技术脑图;查漏补缺,体系化深入学习提升

[外链图片转存中…(img-m2AKR45r-1715314202374)]

  • 一线互联网Android面试题含详解(初级到高级专题)

这些题目是今年群友去腾讯、百度、小米、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。并且大多数都整理了答案,熟悉这些知识点会大大增加通过前两轮技术面试的几率

[外链图片转存中…(img-3jso4sBx-1715314202377)]

有Android开发3-5年基础,希望突破瓶颈,成为架构师的小伙伴,可以关注我
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值