美图秀秀 iOS 客户端二进制之路

1,944 阅读28分钟

一、前言

美图秀秀 iOS App 自2011年2月上线,业务一直在高速发展迭代,项目代码也在一直的增长,工程环境复杂,且一直处于多种语言混编状态,包含 OC、Swift、C++、C 等,目前代码量除了底层库已超 250W 行,依赖 pod 库达到了300个,代码编译的速度持续劣化,一次全量编译需要花费很长时间( M1:10+min,MacBook Pro(16-inch, 2019):20+min ),每当需要重新编译时,比如:拉取新代码、pod install/update 、合并分支(祈祷别有冲突吧)等,再编译、验证,期间有可能会多次遇到编译问题,这种等待的煎熬简直不能忍(多么痛的领悟)!

2021年底随着各业务组件抽离的逐步落地,为了提高项目编译速度,提升多业务线研发效率,二进制化成为了我们项目编译优化的必经之路。

历时7个月,从前期调研、确认方案、开发插件、处理编译问题、反复发现及修改完善插件、项目组件集成、脚手架开发、制定新的开发流程及规范、CI集成、主项目集成、到最后全团队推动使用二进制。终于全链路打通了秀秀 iOS 工程的二进制化。

截止11月底,我们的整体工程的编译速度已经提升了85%左右,目前还有很多遗留在壳工程的代码在持续下沉,预期全部下沉后整体编译时间能够提升90%以上。

二、二进制方案

二进制制作方案

在开始开发之前,我们对业内主流的二进制制作方案进行了调研,目前主流的方案有2种:

  • 基于podspec制作二进制
  • 基于壳工程制作二进制

基于podspec制作二进制

基于podspec制作二进制是指,根据podspec生成Podfile,然后执行pod install生成Xcode工程,最后用xcodebuild构建出最终的二进制产物,此过程跟pod spec/lib lint类似

  • 优点:
    • 支持单个Pod库独立制作
    • 源码和二进制版本号一一对应
    • 不依赖壳工程,只要podspec能够lint通过就可以编译制作
    • 制作时机比较明确(跟随组件发版节奏)
  • 缺点:
    • 需要保证podspec能够lint通过
    • 当依赖多个Pod时,需要在podspec中明确版本,当壳工程依赖版本比较复杂时,较难维护

基于壳工程制作二进制

CocoaPods在安装依赖库的时候,会自动生成对应的projecttarget,我们可以在壳工程内,利用xcodebuild对各个Pod库对应的target进行编译构建出最终的二进制产物,只要壳工程能编译通过即可制作成功

  • 优点:
    • 不需要保证podspec能够lint通过
    • 可以一次完成所有库的二进制制作
    • Pod库版本依赖清晰明确
  • 缺点:
    • 制作时机不明确,需要根据实际情况进行调整(目前我们使用的是定时任务触发)
    • 全量制作,耗时较长
    • 会存在部分库制作失败的问题,需要单独排查

制作方案选择

虽然基于podspec的方案更加“标准化”,更加符合CocoaPods的设计理念,但由于秀秀工程依赖库较多,依赖关系复杂,所以各Pod库内的podspec基本都没有指定依赖库版本号,导致无法通过lint,制作二进制的成功率很低,而壳工程是开发者平时开发使用的工程,都能保证编译通过,制作二进制的成功率高很多,所以最终选择了基于壳工程制作二进制的方案

二进制产物形式

动态库 VS 静态库

众所周知,iOS平台库的形式有2种:动态库静态库,动态库在App启动的时候需要通过dyld动态加载,系统动态库由于缓存的原因,加载速度很快,而自定义动态库没有缓存,如果数量过多,会导致启动速度变慢,而静态库是合并到最终的可执行文件中,对启动速度影响较小,所以这里我们选择静态库

.a VS framework

静态库的组织形式有2种,一种是library也就是.a,还有一种是framework。秀秀工程是OCSwift混编项目,Swift调用OC需要Clang Module的支持,而framework天然支持Clang Module,而且framework文件组织方式更加规范,再加上苹果不止一次的推荐使用framework,所以最终我们选择了static framework这种二进制产物形式

二进制产物存储方式

通常二进制产物存放方式有2种,目前我们使用的是第二种:

  • 组件所在git仓库
  • 静态文件服务器

相较于git仓库,静态文件服务器有如下优势:

  • 接口访问,易于扩展和自动化处理
  • 源码和二进制分离,依赖二进制时只需要下载二进制包,比git clone
  • 不会增加git仓库大小,这点也涉及到源码下载速度

二进制podspec管理方式

关于二进制podspec的管理,目前业内主要有3种方式:

  • 单私有源单版本:在不更改私有源和组件版本的前提下,通过动态变更源码podspec,达到管理二进制podspec的目的
  • 单私有源双版本:在不更改私有源的前提下,通过变更组件版本(如:版本号加-binary),达到管理二进制podspec的目的
  • 双私有源单版本:在不更改组件版本的前提下,通过变更组件的私有源,达到管理二进制podspec的目的

以上这3种方案主要是针对基于podspec制作二进制提供的管理方式,通过上面的分析,秀秀工程选择的是基于壳工程制作二进制的方案,由于subspec的原因,同一个版本的Pod库集成进壳工程时的代码可能会不同,这就导致最终制作出的二进制包不同,但是版本号却是一样的,所以基于壳工程制作二进制的方案会出现源码版本号和二进制版本号不能一一对应的问题,一个源码版本可能对应多个二进制版本,所以上面3种方案都不太能解决我们的问题,最终我们使用了了双私有源多版本的管理方式

  • 双私有源:这里的“双”并不是2个,而是表达源码和二进制私有源要分开存放,秀秀项目有好几个团队,每个团队都有自己的源码私有源,所以这里的意思是,多个源码私有源,单个二进制私有源
  • 多版本:一个源码版本对应多个二进制版本,如下图所示,AFNetworking4.0.1版本就对应多个二进制版本

16655661391437.jpg

关于版本号的生成规则,下面会有介绍

三、二进制插件开发

根据秀秀团队日常开发情况,提出以下二进制功能需求点:

  • 无侵入:对现有业务无影响
  • pod install/update无感知
  • 基于壳工程制作二进制
  • 组件级别的源码 / 二进制切换能力
  • 支持Clang Module, OC/Swift混编
  • 环境配置:可灵活配置configuration、上传二进制包地址、下载地址、二进制源等等
  • 源码白名单
  • 无二进制时自动切换到源码
  • 支持断点调试时切换源码
  • 接近原生CocoaPods的使用体验,完美利用CocoaPods缓存能力

为了满足以上需求,参考cocoapods-bincocoapods-imy-bin,我们开发了cocoapods-meitu-bin插件,主要功能如下图:

16679766262514.jpg

二进制制作

制作

cocoapods-meitu-bin制作二进制比较简单,进入Podfile所在目录,执行pod bin build-all即可,根据需要添加相应的option选项,支持的option选项如下:

选项含义
--clean全部二进制包制作完成后删除编译临时目录
--clean-single每制作完一个二进制包就删除该编译临时目录
--repo-update更新Podfile中指定的repo仓库
--full-build是否全量打包
--skip-simulator跳过模拟器编译
--configuration=configName在构建每个目标时使用configName指定构建配置,如:'Debug'、'Release'等

原理

基于壳工程制作二进制主要流程如下:

  • 判断配置文件BinConfig.yaml是否存在
    • 存在,读取配置信息
    • 不存在,跳过
  • 判断是否需要更新Podfile中的spec仓库
    • 需要,更新
    • 不需要,跳过
  • 判断配置文件中是否存在pre_build
    • 存在,执行
    • 不存在,跳过
  • 执行依赖分析,获取各Pod库对应的target
  • 遍历所有的pod targets制作二进制包
  • 判断配置文件中是否存在post_build
    • 存在,执行
    • 不存在,跳过
  • 清理制作二进制包时的临时文件

其中,最关键的步骤是依赖分析(analyse)编译所有pod targets(build_pod_targets) ,下面会重点介绍这两步

16656467107696.jpg

# 读取配置文件
read_config
# 更新repo仓库
repo_update
# 执行pre_build命令
pre_build
# 分析依赖
@analyze_result = analyse
# 删除编译产物
clean_build_pods
# 编译所有pod_targets
results = build_pod_targets
# 执行post_build命令
post_build(results)
# 删除编译产物
clean_build_pods if @clean
依赖分析(analyse)

因为我们是基于壳工程来制作的二进制包,所以需要获取所有Pod库对应的projecttarget,另外我们还需要根据源码podspec去生成二进制podspec,所以我们需要依赖分析来帮我们完成这些工作

依赖分析不需要我们自己做,CocoaPods已经提供了Pod::Installer::Analyzer类来帮我们完成

编译所有pod targets(build_pod_targets)

从依赖分析中我们可以获取所有的pod targets,然后遍历该数组,对每一个pod target执行如下操作:

  • 判断是否是:path引入,如果是,跳过,进入下一个target
  • 判断是否是external source引入,如果是,跳过,进入下一个target
  • 判断是否已经是二进制了,如果是,跳过,进入下一个target
  • 构建二进制产物
  • 压缩并上传二进制产物
  • 生成二进制podspec并上传

流程图如下:

16657308433740.jpg

构建二进制产物

构建二进制产物分为2部分:

  • 分别构建模拟器和真机产物
  • 按照framework目录结构构造最终的二进制产物

第一步比较简单,核心就是通过xcodebuild编译各个pod target,主要代码如下:

# 构建
def build
    UI.info "编译`#{@pod_target}`".yellow
    dir = result_product_dir
    FileUtils.rm_rf(dir) if File.exist?(dir)
    # 编译模拟器
    unless @skip_simulator
      result = build_pod_target
      return false unless result
    end
    # 编译真机
    build_pod_target(false)
end

# 构建单个pod target
def build_pod_target(simulator = true)
    ... ...
    command = <<-BUILD
xcodebuild GCC_PREPROCESSOR_DEFINITIONS='$(inherited)' \
GCC_WARN_INHIBIT_ALL_WARNINGS=YES \
-sdk #{sdk} \
ARCHS=#{archs} \
CONFIGURATION_TEMP_DIR=#{temp_dir} \
BUILD_ROOT=#{product_dir} \
BUILD_DIR=#{product_dir} \
clean build \
-configuration #{@configuration} \
-target #{@pod_target} \
-project #{project}
    BUILD
    `#{command}`
    ... ...
end

第二步需要按照2种情况进行处理:

  • framework(.framework)
  • static library(.a)

首先解释一下为什么要按照2种情况进行处理?我们在执行pod install的时候,CocoaPods会按照Podfilepodspec中的设置,来决定每一个Pod库类型,不同的Pod库类型决定了通过xcodebuild构建出来的产物形式,以秀秀工程为例,既有static library,也有static framework,还有dynamic framework,因为我们是基于壳工程制作二进制,所以需要分别处理这2种情况

static frameworkdynamic framework文件结构相同,可以当成一种情况进行处理

target "MTXX" do
    # 默认全部是static framework
    use_frameworks! :linkage => :static
    ... ...
end

# dynamic framework
def pod_names_for_dynamic_framework
    return [
    'OptimizedSQLCipher',
    '...'
    ]
end

# static library
def pod_names_for_static_library
    return [
    'CocoaLumberjack',
    '...',
    ]
end

pre_install do |installer|
  installer.pod_targets.each do |pod|
    if pod_names_for_dynamic_framework.include?(pod.name)
      build_type = Pod::BuildType.dynamic_framework
      pod.instance_variable_set(:@build_type, build_type)
    end
    if pod_names_for_static_library.include?(pod.name)
      build_type = Pod::BuildType.static_library
      pod.instance_variable_set(:@build_type, build_type)
    end
  end
end

framework(.framework)的处理,因为我们选择的二进制产物形式是framework,所以如果Pod库类型是framework的话处理起来相对比较简单,主要流程如下:

  • 拷贝真机framework到目标产物目录
  • 判断是否有Swift代码
    • 有,拷贝模拟器swiftmodules到目标产物framework目录
    • 无,跳过
  • 合并模拟器真机二进制文件(lipo
  • 拷贝资源文件到resources(自定义)文件夹
  • 拷贝vendored_frameworksfwks(自定义)文件夹
  • 拷贝vendored_librarieslibs(自定义)文件夹

16657411703061.jpg

最终产物目录结构如下图所示:

16660849666711.jpg

这里有个问题,如何找到资源文件vendored_frameworksvendored_libraries呢?如果我们自己去处理也可以实现,但是会有点麻烦,幸运的是,CocoaPods在进行依赖分析的时候,已经帮我们分析出了这些文件,核心代码如下:

# lib/cocoapods/target/pod_target.rb
module Pod
    # 对应每个pod target
    class PodTarget < Target
        # @return [Array<Sandbox::FileAccessor>] the file accessors for the
        #         specifications of this target.
        #
        attr_reader :file_accessors
    end
end

# lib/cocoapods/sandbox/file_accessor.rb
module Pod
  class Sandbox
    class FileAccessor
        # @return [Array<Pathname>] the resources of the specification.
        #
        def resources
            paths_for_attribute(:resources, true)
        end
        
        # @return [Array<Pathname>] The paths of the framework bundles that come
        #         shipped with the Pod.
        #
        def vendored_frameworks
            paths_for_attribute(:vendored_frameworks, true)
        end
        
        # @return [Array<Pathname>] The paths of the library bundles that come
        #         shipped with the Pod.
        #
        def vendored_libraries
            paths_for_attribute(:vendored_libraries)
        end
    end
  end
end

.a的处理,相较于framework形式要稍微复杂一些,主要流程如下:

  • 创建framework文件夹,以AFNetworking为例,我们需要创建AFNetworking.framework的文件夹
  • 拷贝头文件(publicprivate
  • 生成umbrella headermodulemap,这一步为了支持Clang Module
  • 编译特殊的资源文件(下面会介绍)
  • 判断是否有Swift代码
    • 有,拷贝真机和模拟器swiftmodules到目标产物framework目录
    • 无,跳过
  • 合并真机模拟器二进制文件(lipo
  • 拷贝资源文件到resources(自定义)文件夹
  • 拷贝vendored_frameworksfwks(自定义)文件夹
  • 拷贝vendored_librarieslibs(自定义)文件夹

16659745601690.jpg

获取头文件资源文件vendored_frameworksvendored_libraries的方法跟上面相同,这里不多赘述,主要讲一下生成 umbrella header 和 modulemap以及编译特殊的资源文件这2步

  1. 生成umbrella headermodulemap

秀秀工程是SwiftOC混编的项目,Swift调用OC需要Clang Module的支持,所以需要生成umbrella headermodulemap,如果Pod库类型是framework,只要设置DEFINES_MODULEYES,使用xcodebuild构建该target时,会自动生成umbrella headermodulemap,而.a这种形式的类型则不会自动生成,需要我们自己手动去生成

  • umbrella header文件是该Pod库对外暴露的头文件的合集,以AFNetworking为例,内容如下:
#ifdef __OBJC__
#import <UIKit/UIKit.h>
#else
#ifndef FOUNDATION_EXPORT
#if defined(__cplusplus)
#define FOUNDATION_EXPORT extern "C"
#else
#define FOUNDATION_EXPORT extern
#endif
#endif
#endif

#import "AFNetworking.h"
#import "AFHTTPSessionManager.h"
#import "AFURLSessionManager.h"
#import "AFCompatibilityMacros.h"
#import "AFNetworkReachabilityManager.h"
#import "AFSecurityPolicy.h"
#import "AFURLRequestSerialization.h"
#import "AFURLResponseSerialization.h"
#import "AFAutoPurgingImageCache.h"
#import "AFImageDownloader.h"
#import "AFNetworkActivityIndicatorManager.h"
#import "UIActivityIndicatorView+AFNetworking.h"
#import "UIButton+AFNetworking.h"
#import "UIImage+AFNetworking.h"
#import "UIImageView+AFNetworking.h"
#import "UIKit+AFNetworking.h"
#import "UIProgressView+AFNetworking.h"
#import "UIRefreshControl+AFNetworking.h"
#import "WKWebView+AFNetworking.h"

FOUNDATION_EXPORT double AFNetworkingVersionNumber;
FOUNDATION_EXPORT const unsigned char AFNetworkingVersionString[];

我们可以通过上面file_accessors提供的public_headers方法获取所有公开头文件的路径及头文件名,按照umbrella header的文件格式写入即可,umbrella header的命名规则是pod_target_name-umbrella.h,以AFNetworking为例,umbrella headerAFNetworking-umbrella.h,关键代码如下:

umbrella_header = Pod::Generator::UmbrellaHeader.new(@pod_target)
# 需要导入的头文件
umbrella_header.imports = @file_accessors.flat_map(&:public_headers).compact.uniq.map { |header| header.basename }
result = umbrella_header.generate
File.open(umbrella_header_path, "w+") do |f|
  f.write(result)
end

Clang ModuleC++支持的不好,所以umbrella header中包含的头文件不要有C++代码,否则会有各种奇怪的编译报错

  • modulemap文件格式也是相对固定的,下面以AFNetworking(OC)SwiftyJSON(Swift)为例,内容如下:

AFNetworking:

framework module AFNetworking {
  umbrella header "AFNetworking-umbrella.h"

  export *
  module * { export * }
}

SwiftyJSON:

framework module SwiftyJSON {
  umbrella header "SwiftyJSON-umbrella.h"

  export *
  module * { export * }
}

module SwiftyJSON.Swift {
  header "SwiftyJSON-Swift.h"
  requires objc
}

可以看到,含有Swift代码的modulemap比没有Swift代码的多了下面一部分,这一部分主要是用做OC调用Swift的,不过格式都是相对固定的,我们只要按照这个格式写入modulemap文件即可

  1. 编译特殊的资源文件

特殊的资源文件包括如下几种:

  • storyboard / xib
  • xcdatamodeld / xcdatamodel
  • xcmappingmodel

以上几种文件有3点需要注意的:

  • 需要编译,编译后的产物后缀分别为:
    • storyboard -> storyboardc
    • xib -> nib
    • xcdatamodeld -> momd
    • xcdatamodel -> mom
    • xcmappingmodel -> cdm
  • 编译后的产物必须存放在framework根目录下,否则无法找到,这是framework自己的查找规则
  • Pod编译类型为framework的无需手动编译,用xcodebuild编译时会自动生成,如果编译类型是.a则需要手动编译

那如何编译这几种文件呢?我们可以参考CocoaPods的实现方式:当我们执行完pod install后,在Pods/Target Support Files/Pods-xxx目录下有一个Pods-xxx-resources.sh的脚本文件,这个文件是CocoaPods自动生成的,专门用来处理资源文件的,里面有关于所有资源文件的处理方式,主要代码如下:

install_resource()
{
    case $RESOURCE_PATH in
    *.storyboard)
        ibtool --reference-external-strings-file --errors --warnings --notices --minimum-deployment-target ${!DEPLOYMENT_TARGET_SETTING_NAME} --output-format human-readable-text --compile "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename \"$RESOURCE_PATH\" .storyboard`.storyboardc" "$RESOURCE_PATH" --sdk "${SDKROOT}" ${TARGET_DEVICE_ARGS};;
    *.xib)
        ibtool --reference-external-strings-file --errors --warnings --notices --minimum-deployment-target ${!DEPLOYMENT_TARGET_SETTING_NAME} --output-format human-readable-text --compile "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename \"$RESOURCE_PATH\" .xib`.nib" "$RESOURCE_PATH" --sdk "${SDKROOT}" ${TARGET_DEVICE_ARGS};;
    *.xcdatamodel)
        xcrun momc "$RESOURCE_PATH" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename "$RESOURCE_PATH" .xcdatamodel`.mom";;
    *.xcdatamodeld)
        xcrun momc "$RESOURCE_PATH" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename "$RESOURCE_PATH" .xcdatamodeld`.momd";;
    *.xcmappingmodel)
        xcrun mapc "$RESOURCE_PATH" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/`basename "$RESOURCE_PATH" .xcmappingmodel`.cdm";;
}

通过上面的代码,我们就可以编译这些特殊的资源文件,然后将编译产物拷贝到framework根目录。

至此,我们的二进制产物就已经制作完成了

压缩并上传二进制产物
  1. 压缩二进制产物
zip --symlinks -r #{output_library} #{input_library}
  1. 上传二进制产物
curl -F \"name=#{@pod_target.product_module_name}\" -F \"version=#{@version || @pod_target.root_spec.version}\" -F \"xcode_version=#{xcode_version}\" -F \"file=@#{zip_file}\" #{upload_url}
生成二进制podspec并上传
  1. 生成二进制podspec

二进制podspec的生成,主要是对源码podspec字段的增删和修改,主要流程如下:

  • 获取源码podspec并转成hash
  • 对源码podspec字段进行增删和修改
  • 处理subspec
  • 生成二进制podspec并转成json格式
  • json格式的二进制podspec写入文件

主要代码如下:

# 创建二进制podspec
def create_binary_podspec
    UI.info "创建二进制podspec:`#{@pod_target}`".yellow
    spec = @pod_target.root_spec.to_hash
    root_dir = @pod_target.framework_name
    # 处理版本号
    spec['version'] = version
    # 处理source
    spec['source'] = source
    # 处理头文件
    spec['source_files'] = "#{root_dir}/Headers/*.h"
    spec['public_header_files'] = "#{root_dir}/Headers/*.h"
    spec['private_header_files'] = "#{root_dir}/PrivateHeaders/*.h"
    # 处理vendored_libraries和vendored_frameworks
    spec['vendored_libraries'] = "#{root_dir}/libs/*.a"
    spec['vendored_frameworks'] = %W[#{root_dir} #{root_dir}/fwks/*.framework]
    # 处理资源
    resources = %W[#{root_dir}/*.{#{special_resource_exts.join(',')}} #{root_dir}/resources/*]
    spec['resources'] = resources
    # 删除无用的字段
    delete_unused(spec)
    # 处理subspecs
    handle_subspecs(spec)
    # 生成二进制podspec
    bin_spec = Pod::Specification.from_hash(spec)
    bin_spec.description = <<-EOF
     「converted automatically by plugin cocoapods-meitu-bin @美图 - zys」
      #{bin_spec.description}
    EOF
    bin_spec
end

# podspec写入文件
def write_binary_podspec(spec)
    UI.info "写入podspec:`#{@pod_target}`".yellow
    podspec_dir = "#{Pathname.pwd}/build_pods/#{@pod_target}/Products/podspec"
    FileUtils.mkdir(podspec_dir) unless File.exist?(podspec_dir)
    file = "#{podspec_dir}/#{@pod_target.pod_name}.podspec.json"
    FileUtils.rm_rf(file) if File.exist?(file)
    
    File.open(file, "w+") do |f|
      f.write(spec.to_pretty_json)
    end
    file
end

二进制podspec主要字段如下:

  • version:二进制版本号,与源码版本号不一样,下面会有介绍
  • source:二进制文件下载地址,格式为{http: 二进制文件下载URL, type: 'zip'}
  • source_files:公开头文件目录,Headers/*.h
  • public_header_files:公开头文件目录,Headers/*.h
  • private_header_files:私有头文件目录,PrivateHeaders/*.h
  • vendored_libraries:依赖的librarylibs/*.a
  • vendored_frameworks:依赖的framework%W[#{root_dir} #{root_dir}/fwks/*.framework]
  • resources:资源文件,%W[#{root_dir}/*.{#{special_resource_exts.join(',')}} #{root_dir}/resources/*]

subspec采取递归方式处理,每个subspec依赖整个二进制文件

# 处理subspecs
def handle_subspecs(spec)
    spec['subspecs'].map do |subspec|
      # 处理单个subspec
      handle_single_subspec(subspec, spec)
      # 递归处理subspec
      recursive_handle_subspecs(subspec['subspecs'], spec)
    end if spec && spec['subspecs']
end

# 递归处理subspecs
def recursive_handle_subspecs(subspecs, spec)
    subspecs.map do |s|
      # 处理单个subspec
      handle_single_subspec(s, spec)
      # 递归处理
      recursive_handle_subspecs(s['subspecs'], spec)
    end if subspecs
end
  1. 上传二进制podspec

上传二进制podspec比较简单,使用CocoaPods定义好的Pod::Command::Repo::Push类进行上传

repo_name = Pod::Config.instance.sources_manager.binary_source.name
argvs = %W[#{repo_name} #{binary_podsepc_json} --skip-import-validation --use-libraries --allow-warnings --verbose]

begin
  push = Pod::Command::Repo::Push.new(CLAide::ARGV.new(argvs))
  push.validate!
  push.run
rescue Pod::StandardError => e
  UI.info "推送podspec:#{@pod_target} 失败,#{e.to_s}".red
end

需要注意的是,使用Pod::Command::Repo::Push类进行上传,默认会进行xcodebuild编译检验,此过程耗时且容易失败,所以cocoapods-meitu-bin插件进行了hook,去掉了相应的耗时校验,代码如下:

module Pod
  class Validator
    def perform_extensive_analysis(spec)
      return true
    end
  end
end

版本号

基于壳工程打包二进制这种方式,无法实现二进制和源码版本号一一对应,一个源码库可能对应多个二进制版本,这取决于subspec的数量以及壳工程如何引用该Pod,所以二进制版本号不能直接使用源码版本号,需要有所区分

如何进行版本号的区分呢?这里主要考虑如下几点:

  • subspec组合方式:不同subspec可以组合出不同的二进制
  • Xcode版本:不同Xcode版本的Swift编译器无法兼容
  • configuration:需要区分DebugRelease
  • dependency不同

基于上面这些考虑因素,我们决定将这几部分拼接成字符串,然后做MD5,取该MD5的前6位,再在前面加上bin组成版本号后缀,最终的二进制版本号形式为x.y.z.bin[md5前6位]

# 二进制版本号(x.y.z.bin[md5前6位])
def version(pod_name, original_version, specifications, configuration = 'Debug')
    # 有缓存从缓存中取,没有则新建
    if @specs_str_md5_hash[pod_name].nil?
      specs = specifications.map(&:name).select { |spec|
        spec.include?(pod_name) && !spec.include?('/Binary')
      }.sort!
      specs << xcode_version
      specs << (configuration.nil? ? 'Debug' : configuration)
      specs_str = specs.join('')
      specs_str_md5 = Digest::MD5.hexdigest(specs_str)[0,6]
      @specs_str_md5_hash[pod_name] = specs_str_md5
    else
      specs_str_md5 = @specs_str_md5_hash[pod_name]
    end
    "#{original_version}.bin#{specs_str_md5}"
end

二进制 / 源码切换

使用

Podfile中添加如下代码:

# 加载插件
plugin 'cocoapods-meitu-bin'
# 开启二进制
use_binaries!
# 源码白名单
set_use_source_pods ['AFNetworking']

原理

自定义DSL

Podfile实际上是一个ruby文件,所以自定义DSL就是自定义方法,代码如下:

module Pod
  class Podfile
    module DSL

      def use_binaries!(flag = true)
        set_internal_hash_value(USE_BINARIES, flag)
      end

      def set_use_source_pods(pods)
        hash_pods_use_source = get_internal_hash_value(USE_SOURCE_PODS) || []
        hash_pods_use_source += Array(pods)
        set_internal_hash_value(USE_SOURCE_PODS, hash_pods_use_source)
      end

    end
  end
end

从上面的代码我们可以看到,use_binaries!set_use_source_pods是把传递的参数存储到了一个全局的hash表中,这个全局的hash表叫做internal_hash,在pod install过程中会从该hash表中取出相应的值然后进行使用

无感知切换源码二进制

使用cocoapods-meitu-bin插件后,我们在执行pod install时就可以做到有二进制时用二进制,没有二进制时用源码,那我们是如何实现这个功能的呢?要解释这个问题,我们先来看一下pod install的执行流程,核心代码如下:

# lib/cocoapods/installer.rb

# 准备工作
prepare
# 依赖分析
resolve_dependencies
# 下载Pod库
download_dependencies
# 校验targets
validate_targets
if installation_options.skip_pods_project_generation?
  show_skip_pods_project_generation_message
else
  # 集成pod targets
  integrate
end
# 写入Podfile.lock
write_lockfiles
# 执行post_install
perform_post_install_actions

其中,resolve_dependencies是利用Molinillo算法来解析依赖,获取所有依赖库的podspec信息,我们不想干预该算法的流程,于是我们就在依赖分析完成后,获取所有解析到的podspec信息,根据版本号规则获取二进制版本号,然后在二进制podspec源中进行查找,看是否有对应的二进制版本,如果有,替换掉源码版本,如果没有,返回原来的源码版本

断点调试时二进制 / 源码映射

二进制可以加快编译速度,提高研发同学的开发效率。但如果想查看源码需要将组件添加到白名单,然后重新pod install、重新编译运行app,当研发同学只想在调试时查看一下源码,会很不方便,因此我们需要提供一种在调试过程中能够查看源码的能力

为了解决这个问题,我们可以利用LLDB提供的源码映射功能,由于LLDB是调试器,用于调试代码,所以这种方式只适用于调试过程中,不需要重新pod install和编译,在只想调试看一下该库内部逻辑的时候还是很方便的

源码映射原理

  1. 下载二进制对应版本的源码
  2. 利用LLDB提供的命令进行二进制 / 源码映射
下载源码

源码下载功能已经集成进了cocoapods-meitu-bin插件中,使用方式如下:

下面的命令需要在项目Podfile同级目录执行,如果使用MBox,需要在命令前加上mbox

# 下载源码(不需要指定版本号,会进行依赖分析获取版本号)
[mbox] pod bin source add AFNetworking
# 查看已经下载的源码
[mbox] pod bin source list [Pod_Name]
# 删除已经下载的源码
[mbox] pod bin source delete [Pod_Name] [--all]
# 查看帮助
[mbox] pod bin source --help

最终的源码下载到~/LLDB_Sources目录下

二进制 / 源码映射

二进制 / 源码映射功能需要使用LLDB提供的settings append target.source-map命令,为了方便大家使用,我们通过Python脚本将该命令进行了封装,并提供了更加简洁的LLDB命令:mtmap

脚本安装步骤及使用方法可以参见插件文档。

除了提供最基本的二进制 / 源码映射功能外,还提供了以下3个LLDB命令:

  • mtlist:列出已经下载的源码
  • mtshow:显示当前所有的映射
  • mtreset:将之前所有的映射清空

配置文件

为了让cocoapods-meitu-bin插件更加灵活,我们提供了2个配置文件:

  • .bin_dev.yml
  • BinConfig.yaml

.bin_dev.yml

配置文件.bin_dev.yml主要用来配置二进制文件上传/下载地址、二进制文件格式及podspec存储地址等信息,我们模仿Podfile的生成方式,提供了pod bin init命令来初始化该配置文件,该命令采用问答的方式,初始化成功后,该文件被存放在Podfile同级目录

该配置文件在二进制制作和下载时需要使用,所以需要加到git追踪中

之所以用隐藏文件,是因为该配置文件一旦生成,基本不会修改,即使修改也是一个人修改提交,其他人同步

配置文件.bin_dev.yml内容如下:

---
configuration_env: dev
# 二进制podspec仓库
binary_repo_url: git@techgit.meitu.com:mtbinaryspecs.git
# 二进制文件上传地址
binary_upload_url: http://xxx/file/upload.json
# 二进制文件下载地址前缀
binary_download_url: https://xiuxiu-xxx.com:443/ios/binary
# 二进制文件格式
download_file_type: zip

BinConfig.yaml

配置文件BinConfig.yaml主要用来配置制作和使用二进制时一些可选的配置项,该文件可以手动创建并放在Podfile同级目录

配置文件BinConfig.yaml是一些可选的配置项,每个人可能不同,所以不需要添加到git追踪中,如果没有配置需求,可以不配置

该文件主要内容分为2部分:

  • build_config:制作二进制时相关的配置
  • install_config:使用二进制时相关的配置

build_config包括4部分:

  • pre_build:制作二进制之前执行的命令
  • post_build:制作二进制之后执行的命令
  • black_list:黑名单(不制作二进制)
  • write_list:白名单(不管是否已经有二进制,都重新制作)

install_config包含2部分:

  • use_binary:是否开启二进制,与Podfile中的use_binaries!共同判断,两个值只要有一个是true,则开启二进制,否则不开启
  • black_list:黑名单,与Podfile中的set_use_source_pods合并为一个数组,该数组中的库不使用二进制

四、美图秀秀接入二进制实践

开发环境版本管理

使用 bundle 管理,通过Gemfile文件约束使用的CocoaPods版本和cocoapods-meitu-bin等插件版本,好处如下:

  • 开发环境的一致性,避免使用不同插件版本导致的问题
  • 插件版本更新的及时同步
  • CI打包机和GitLab Runner执行机器可通过读取项目Gemfile文件来同步CocoaPodscocoapods-meitu-bin版本

使用:只需在Gemfile所在目录执行bundle install/update即可。

开发流程更改

二进制链路实践过程中,除了链路开发外,秀秀的工程管理方式、开发模式、开发环境跟之前会有很大差异,更重要的一件事就是对新的开发流程的推进,我们花了很长的时间来一步一步的推动,从前期的所有仓库规范tag,到mbox小范围引入使用,再到最后的删掉远端Pods文件夹和Podfile.lock。客户端研发人员往往对这种变化难以从容面对,尤其是业务工作量比较大时,面对研发流程变化、新工具的学习和适应所带来的成本会倍感压力。一直在强调 “无感知” 正是为了更好的让研发人员完成过渡,但也仅仅是工具层面的无感知,其他流程需要依靠 “工具+文档+在线支持” 的紧密配合来保障整个研发流程的转换。

  • 多仓开发成为常态,因此引入mbox作为开发辅助工具 由于二进制化开发后,Pods 文件夹被从远端删除,那后续的多仓开发必须借由效率工具实现,Mbox便成为我们的首选工具。目前cocoapods-meitu-bin插件已对 Mbox 进行了支持。优势简单介绍下如下(具体参考官方Mbox介绍):

    • 沙盒隔离,方便多仓分支管理
    • Git批量管理:在 Workspace 内的所有仓库,都受 MBox 控制,可以快速查询和修改 Git 状态,依赖库越多效率越高
    • Workspace 级别的 Git Hooks
    • Feature 需求模型:不仅仅能够管理所有仓库的分支,还能保留未提交的改动,甚至不同 Feature 下进行仓库的差异化。
    • Feature 协作:提供 Feature 快速导出与导入,实现多人同步协作
    • 统一的依赖管理:支持 BundlerCocoaPods 两种依赖管理工具
    • 多容器切换,引入了 Container 概念,允许在同一个 Workspace 下有多个 App,这些 App 可能是同平台,也可能是跨平台的
  • ignorePods文件夹、 Podfile.lock文件

    未使用二进制之前,秀秀是带着Pods文件夹和Podfile.lock文件提交Git的,每次Podfile有变更都会产生修改记录,同时每次新增framework就会涉及到大文件提交,且在代码合并时podlock的冲突是常态,合并效率极低,再加上编译慢的问题会更加放大合并的低效。在使用二进制后会把所有源码组件都制作成xxxx.framework,且会自动切换源码和二进制,所以必须忽略Pods文件夹和Podfile.lock文件,不在Git提交,这也切合mbox的使用方式,也是CocoaPods推荐方式。

  • 需要频繁 pod install/update

    由于忽略Pods文件夹,所以每次拉取代码,切分支、查二进制源码、合并代码等都需要执行Pod 操作去更新相关组件。对pod install/update的速度就提出了要求,因此针对这一点也做了专项优化,后文会专门介绍。

  • 规范组件tag流程

    二进制组件的制作,Podfile组件依赖需要使用Release方式(pod 'AFNetworking','4.0.1')引入才可以制作,所以约定在每次发版打Appstore包前需要各业务组件提供Release版本,这就涉及到组件打tag并推送对应版本的Podspec文件到远端Spec仓库。

    为了规范该流程,提供cocoapods-tag一行命令实现Podspec文件的版本号修改、校验、打tag、推送Podspec文件到远端spec repo仓库

    • 规范打 tag 流程
    • 可跳过 lint
    • 可上传 spec

具体接入细节

  1. 使用 Bundler 作为 Gem 沙盒,通过Gemfile文件指定Cocoapodscocoapods-meitu-bin版本

    在项目目录新建Gemfile文件,指定Cocoapodscocoapods-meitu-bin版本如下:

    source "https://rubygems.org" 
    gem 'cocoapods-meitu-bin', '1.0.0'
    gem 'cocoapods', '1.10.2'
    

    执行 bundle install安装依赖环境

    $ bundle install
      Fetching gem metadata from https://rubygems.org/.......
      Resolving dependencies...
      Using httpclient 2.8.3
      Using bundler 2.3.16
      ....
      Using cocoapods 1.10.2
      Using cocoapods-meitu-bin 1.0.0
      Bundle complete! 2 Gemfile dependencies, 42 gems now installed.
      Use `bundle info [gemname]` to see where a bundled gem is installed.
    

    执行 pod plugins installed 可查看输出的插件版本与Gemfile指定的是否一致,一致表示更新成功,否则就需要排查未更新的原因

  2. 初始化cocoapods-meitu-bin环境配置

    执行 pod bin init来初始化二进制相关配置,各配置项说明如下:

    • configuration_env 指定编译环境,暂未使用,后期废弃
    • binary_repo_url 二进制Podspec私有源地址
    • binary_upload_url 二进制文件上传地址
    • binary_download_url 二进制文件下载地址,后面会依次传入Xcode版本、configuration、组件名称与组件版本
    • download_file_type 二进制文件类型
    $  pod bin init
    
       设置插件配置信息.
       所有的信息都会保存在 podfile同级目录/.bin_dev.yml 文件中.
       你可以在对应目录下手动添加编辑该文件.
       文件包含的配置信息样式如下:
       ---
       configuration_env: dev
       binary_repo_url: git@github.com:xxx/example-private-spec-bin.git
       binary_upload_url: http://localhost:8080/frameworks
       binary_download_url: http://localhost:8080/frameworks
       download_file_type: zip
       
       编译环境
       可选值:[ dev / debug_iphoneos / release_iphoneos ]
       旧值:dev
        >
       dev
       
       二进制podspec私有源地址
       旧值:git@github.com:xxx/example-private-spec-bin.git
        >
       git@github.com:xxx/example-private-spec-bin.git
       
       二进制文件上传地址
       旧值:http://localhost:8080/frameworks
        >
       http://localhost:8080/frameworks
       
       二进制文件下载地址,后面会依次传入Xcode版本、configuration、组件名称与组件版本
       旧值:http://localhost:8080/frameworks
        >
       http://localhost:8080/frameworks
       
       二进制文件类型
       可选值:[ zip / tgz / tar / tbz / txz / dmg ]
       旧值:zip
        >
       zip
    
       设置完成.
    
  3. Podfile使用cocoapods-meitu-bin插件和相关配置项说明

    #若没安装 cocoapods-meitu-bin插件 直接注释下面三行就行
    #加载插件
    plugin 'cocoapods-meitu-bin'
    
    #开启二进制 -- 添加以下代码开启二进制功能
    #(ENV["MEITU_USE_BINARIES"] != "false") 回来控制CI打包是否开启二进制
    use_binaries!(ENV["MEITU_USE_BINARIES"] != "false")#注释这行在 pod install 就全切源码
    
    #二进制包 configuration 设置,默认Debug模式,CI打包从ENV中取,本地调试想修改 参考 set_configuration 'Debug' 或 set_configuration 'Distribution' 然后再执行pod install 切换到对应configuration的二进制包
    set_configuration(ENV["MEITU_USE_CONFIGURATION"].nil? ? 'Debug' : ENV["MEITU_USE_CONFIGURATION"])
    
    #源码白名单,数组内的组件使用源码(针对要切到源码或者必须使用源码的相关pod组件)
    set_use_source_pods ['SQLiteRepair', 'OptimizedSQLCipher']
    
    platform :ios, '11.0'
    
    source 'https://github.com/CocoaPods/Specs.git'
    
    '''
    

    经过上面三步,通过配置use_binaries!方法的入参,再执行 pod install/update,即可 “无感知” 实现二进制与源码切换

    例如use_binaries!(true)时,即为从源码组件切换到二进制组件

    $  pod install
    当前configuration: `Debug`
    更新私有源仓库 meitu-mtbinaryspecs
    Analyzing dependencies
    Molinillo resolve耗时:4.5s
    Downloading dependencies
    Installing AFNetworking 4.0.1.bine6dc04 (was 4.0.1 and source changed to `git@techgit.xxx.com/binaryspecs.git` from `https://github.com/CocoaPods/Specs.git`)
    Installing Alamofire 5.4.4.binfe6ccf (was 5.4.4 and source changed to `git@techgit.xxx.com/binaryspecs.git` from `https://github.com/CocoaPods/Specs.git`)
    Installing MTDeviceInfo 1.3.8.binae71e2 (was 1.3.8 and source changed to `git@techgit.xxx.com/binaryspecs.git` from `git@techgit.xxx.com/specs.git`)
    ...
    Generating Pods project
    Pod installation complete! There are 198 dependencies from the Podfile and 295 total pods installed.
    
    pod_time_profiler: pod执行耗时:
    pod_time_profiler: ———————————————————————————————————————————————
    pod_time_profiler: |            Stage             |    Time(s)    |
    pod_time_profiler: ———————————————————————————————————————————————
    pod_time_profiler: |     resolve_dependencies     |     7.873     |
    pod_time_profiler: |    download_dependencies     |     9.472     |
    pod_time_profiler: |       validate_targets       |     0.597     |
    pod_time_profiler: |          integrate           |    15.342     |
    pod_time_profiler: |       write_lockfiles        |     0.336     |
    pod_time_profiler: | perform_post_install_actions |     1.980     |
    pod_time_profiler: ———————————————————————————————————————————————
    

五、二进制接入问题

1. 使用规范问题

Podfile 组件依赖规范

官方文档 我们先了解下Podfile的几种不同的组件依赖方式有何区别:

  • git-branch 引用方式: pod 'A', :git => 'xxx.git', :branch => 'master'

    pod install/update 操作会从git地址进行git clone,命令如下:

    # 根据branch获取最新的commit id
    $ /usr/bin/git ls-remote <url> <branch>
    # clone当前仓库
    $ /usr/bin/git clone <url> <target path> --template=
    # 切换到对应的commit id
    $ /usr/bin/git -C <target path> checkout --quiet <commit id>
    
  • git-commit 引用方式: pod 'A', :git => 'xxx.git', :commit => 'db61f7e'

    pod install/update 操作会从git地址进行git clone,命令如下:

    # clone当前仓库
    $ /usr/bin/git clone <url> <target path> --template=
    # 切换到对应的commit id
    $ /usr/bin/git -C <target path> checkout --quiet <commit id>
    
  • git-tag 引用方式: pod 'A', :git => 'xxx.git', :tag => '0.1.0'

    pod install/update 操作会从git地址进行git clone,命令如下:

    $ /usr/bin/git clone <url> <target path> --template= --single-branch --depth 1 --branch <tag>
    
  • Release 引用方式: pod 'A', '0.1.0'

    pod install/update 操作会从Podfile中的source来进行查找对应仓库版本的podspec文件,之后根据podspec文件进行git clone,命令如下(与git-tag方式命令一致):

    $ /usr/bin/git clone <url> <target path> --template= --single-branch --depth 1 --branch <tag>
    
  • path 引用方式: 直接依赖的本地路径,无下载操作

综上可以看出,只有 tagRelease这两种方式这样 Pod 内部 clone 会加上 -depth 1 参数,这个参数的官方解释为--depth <depth>: create a shallow clone of that depth,即只拉取最近这次的 commit,不会全量拉取整个仓库和 .git 快照文件了,可有效提高pod install/update过程中下载仓库的速度。

采用二进制开发流程时,必须在Podfile中对组件依赖使用Release方式,即pod 'AFNetworking','4.0.1'方式,原因如下:

  • 二进制组件制作、识别、使用都需要依赖指定tag版本

  • 二进制版本切换本身是source源的切换,需要从Podfile中的source来进行查找对应仓库版本,使用Release方式只需要我们控制仓库查找顺序即可拥有二进制源码切换的能力,且不影响CocoaPods的其他功能。

  • Podfile指定相关组件依赖版本,可以提高pod install依赖分析速度,便于版本依赖回溯追踪

Podspec规范

官方文档

  • 指定swift版本,如果不写默认指定swift 4.0 版本,目前基本相关swift组件都需要swift 5.0及以上版本,参考如下:

     s.swift_versions = ['5.0']
     s.swift_versions = ['5.0', '5.1', '5.2']
     s.swift_version = '5.0'
     s.swift_version = '5.0', '5.1'
    
  • s.sources.version 规范

    s.version          = 'xxx'
    s.source       = { :git => 'git@techgit.meitu.com:xxx/xxx', :tag => 'xxx' }
    

    在实际应用中,经常会因为提供不规范的组件版本号、源码仓库并未打tag就提供该tag版本组件、s.source未调整成ssh方式导致本地或CI打包机执行pod install/update 报错,需要注意一下几点:

    • s.version版本号需要符合Cocoapods 版本号命名规范,Cocoapods版本命名正则校验如下

      VERSION_PATTERN = '[0-9]+(?>\.[0-9a-zA-Z]+)*(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?' # :nodoc:
      ANCHORED_VERSION_PATTERN = /\A\s*(#{VERSION_PATTERN})?\s*\z/ # :nodoc:
      
       begin
        Version.new(basename)
      rescue ArgumentError
        raise Informative, 'An unexpected version directory ' \
         "`#{basename}` was encountered for the " \
         "`#{pod_dir}` Pod in the `#{name}` repository."
      end
      
    • s.versions.source 版本号一致且该tag实际存在

    • 秀秀CI打包机权限认证只支持ssh方式,所以s.source 需要指定为ssh

    • 组件资源管理方式,应使用s.resource_bundles,不使用s.resource

      s.ios.resource_bundle = { 'xxxResourceBundle' => 'xxx/xxx/Resources/*.{xcassets,png,...}' }
      或
      s.resource_bundles = {
      'xxxResourceBundle' => ['xxx/Resources/*.{xcassets,png,...}'],
      'xxxOtherResourcesBundle' => ['xxx/OtherResources/*.{xcassets,png,...}']}
      

    key-value可以避免相同名称资源的名称冲突,同时建议bundle的名称至少应该包括 Pod 库的名称,可以尽量减少同名冲突

组件引入规范

由于秀秀项目在Podfile中同时使用了use_modular_headers!use_frameworks! :linkage => :static,所有源码组件都会被开启Clang Module 支持, 生成 modulemap 文件,应使用如下所示组件引入方式:

  • @import xxx
  • import xxx
  • #import <xxx/xxx.h>
platform :ios, '11.0'
    
source 'https://github.com/CocoaPods/Specs.git'
    
inhibit_all_warnings!
use_modular_headers!
    
install! 'cocoapods', :deterministic_uuids => false, :generate_multiple_pod_projects => true
    
target 'MTXX' do
   use_frameworks! :linkage => :static
   pods_mtxx_binary()
   pods_for_debugs()
   # 壳工程
   third_pods_just_in_shell_project()    
end
######################### 
默认使用static_framework格式打包,特别需求在下面设置 
#########################
def pod_names_for_dynamic_framework
    return [
    'WCDBSwift',
    ...
    ]
end
    
def pod_names_for_static_library
    return [
    'CocoaLumberjack',
    ...
    ]
end
    
#、标注使用动态库Framework打包
hook_build_to_dynamic_pod_list = pod_names_for_dynamic_framework()
#、标注使用static_library打包
hook_build_to_library_pod_list = pod_names_for_static_library()
    
# CocoaPod hook 修改pod组件打包类型
pre_install do |installer|
  installer.pod_targets.each do |pod|
    if hook_build_to_dynamic_pod_list.include?(pod.name)
      build_type = Pod::BuildType.dynamic_framework
      pod.instance_variable_set(:@build_type, build_type)
    end
    if hook_build_to_library_pod_list.include?(pod.name)
      build_type = Pod::BuildType.static_library
      pod.instance_variable_set(:@build_type, build_type)
    end
  end
end

2. 实际方案接入的常见问题

  • 切换到二进制组件壳工程引用头文件报错问题

    由于秀秀壳工程还有部分代码未下沉和拆分到组件中,同时跟随版本在持续下沉和拆分到对应组件,由于之前都是在壳工程中,头文件引入方式还是 #import "xxx.h",且下沉到组件后使用源码组件也不会报错,当切换为二进制组件后壳工程在以#import "xxx.h"就会编译报错,所以需要调整为#import <xxx/xxx.h>或者@import xxx;

  • 混编组件组件内使用import "xxx-Swift.h"导致在秀秀使用该组件编译报错问题

    /Users/.../Desktop/mtxx_work/mtxx/MTXX/Pods/MeituGemSDK/MeituGemSDK/Classes/Base/UI/View/PageControl/MBCSlidePageControl.m:10:9: fatal error: 'MeituGemSDK-Swift.h' file not found
    #import "MeituGemSDK-Swift.h"
         ^~~~~~~~~~~~~~~~~~~~~
    1 error generated.
    

    由于在Podfile中设置了 use_frameworks! :linkage => :static,源码组件编译产物都会是static_framework,所以在组件内使用#import "xxx-Swift.h"就会导致编译报错,两种解决方案如下:

    1. 调整组件内引用#import "xxx-Swift.h"如下
    #if __has_include(<xxxx/xxx-Swift.h>)
    #import <xxx/xxx-Swift.h>
    #else
    #import "xxx-Swift.h"
    #endif
    
    1. Podfile通过pre_install设置该组件编译产物为static_library类型
  • 在支持 Clang Module组件使用了不支持Clang Module模块或组件,并在前者对外暴露的.h中使用#import <xxx/xxx.h>引用后者,就会导致编译报错

    报错信息如下:

    16661590285971.jpg

    可以从上图看到 oc_Swift_2 组件通过 s.dependency 'WechatOpenSDK', '~> 1.8.7'tx.vendored_frameworks = 'Sources/TencentOpenAPI.framework' 依赖组件WechatOpenSDK 和 组件内的TencentOpenAPI.framework, 且这俩都不支持Clang Module,在test2.h 使用#import <xxx/xxx.h>引入依赖,编译就会报错,两种解决方案如下:

    • 手动梳理头文件,把在test2.h使用#import <xxx/xxx.h>调整到test2.m引入依赖即可

    • 基于LLVM的配置 Build Setting — Apple LLVM 8.1 Language Modules — Allow Non-modular Includes In Framework Modules设置为YES,则可以在Framework中使用模块外的Include,不过这种过于粗暴

      在报错组件的Podspec添加s.pod_target_xcconfig设置

      s.pod_target_xcconfig = { 
      'OTHER_SWIFT_FLAGS' => '-Xcc -Wno-error=non-modular-include-in-framework-module',
      'CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES' => 'YES' 
      }
      

      或者在Podfile中修改

      post_install do |installer|
        installer.pod_target_subprojects.flat_map { |p| p.targets }.each do |target|
          if target.name == 'xxx'
             config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
             config.build_settings['OTHER_SWIFT_FLAGS'] = '$(inherited) -D COCOAPODS -suppress-warnings -Xcc -Wno-error=non-modular-include-in-framework-module'
          end
        end
      end
      

      这种具有依赖传递性,一个组件添加该设置项,所有依赖该组件的上层组件也需要添加,同时壳工程也需要添加该设置,只是作为过渡兼容方案,建议使用梳理头文件依赖,把不应该在.h中的头文件引入调整到.m

  • 因切换不同版本Xcode,二进制组件并未切换到该版本对应二进制组件导致的编译报错

    error:Module compiled with Swift 5.5.2 cannot be imported by the Swift 5.6 compiler: /Users/xxx/xxx/xxx/xxx..
    

    主要是因为切换Xcode版本,使用Swift编译器版本不一致导致,cocoapods-meitu-bin 针对不同的Xcode 版本制作不同的二进制组件,需要在切换Xcode版本且Command Line Tools中也切换对应版本,再执行 pod install 就会去更新相关组件版本

    16661607001633.jpg

  • 秀秀项目使用的底层组件Clang Module支持问题和过渡方案

    秀秀项目很多组件都是SwiftOC混编的和Swift组件,而底层提供的二进制组件大多不支持Clang Module,所以就会导致组件的Swift代码访问底层组件无法访问的问题,解决方案如下:

    • 使用桥接头文件的方式

      秀秀使用的过渡方案是在含Swift组件中新建个.h文件引入底层组件依赖,采用这种桥接的方式支持使用,但这种方式需要在不同组件中重复去新建.h文件桥接,同时也会因为这样使用导致一些编译问题不好排查

    • 推动底层二进制组件提供同学支持 Clang Module

      在实际推动中,因为底层组件中多使用C++代码,发现如果在对外暴露.h包含C++代码,在Swift组件使用就会编译报错,这也是导致底层同学一直未支持Clang Module的原因,针对该问题调整如下:

      • C++需要使用OC来做中转,同时要将创建的 .m 文件后缀改成 .mm ,这是告诉 XCode 编译该文件时要用到 C++ 代码,对外暴露OC.h文件即可
      • 不需要对外暴露且包含C++代码的.h,可以通过设置组件的Podsepcspec.private_header_files = 'Headers/Private/*.h' 或者 spec.project_header_files = 'Headers/Project/*.h'

3.pod install/update 速度过慢问题

由于各种历史原因,大部分内部远端库都含 Pods 文件夹及各种底层库的大文件,版本累积之后会导致仓库的 .git 超级大,导致pod install/updategit clonetag依赖仓库时会下载整个仓库,实际依赖文件只有 20M 但可能却要下载 7G 的仓库( 我们甚至还存在 20G 的仓库。。。)。二进制化之前由于主工程直接提交了 Pods 文件,每次一个仓库更新并不会影响太大,但在二进制化删除 Pods 文件后,频繁的 pod 操作会让人直接崩溃,数据量太大了。尤其是在本地没有pod cache的前提下,一次pod操作是非常耗费时间的。

为了加快pod速度,我们在cocoapods-meitu-bin中对各阶段耗时进行统计,同时也会提示并输出大于 500M 组件

针对pod速度优化,我们主要从下面几方面进行优化:

  • Podfile各组件都明确指定版本,避免Pod决策分析过多耗时
  • 拆分纯净源码库,规范Pod组件使用
  • 二进制化
    • 已经是二进制的库,迁移到二进制文件服务器
    • 使用二进制组件库,尽量不使用源码组件
  • 对暂时无法拆库的组件进行删除pods文件夹,移除历史无用大文件记录等
  • 提供并发下载支持,cocoapods-meitu-bin提供并发下载支持
  • 避免因多Cocoapods切换版本导致缓存被清(通过cocoapods-meitu-bin hook cocoapods 清理缓存方法实现切换Cocoapods不去执行移除Pod Cache
  • .git 优化 (由于影响过大,暂未执行)

基于上面优化后,在删除Podfile.lock,Pods文件夹及CocoaPods缓存后,前后对比整体提升 80% ,还有部分组件未优化,仍然后下降空间

16661667990142.png

4.M1下ruby环境和ffi依赖导致 pod install 报错

该问题多存在于非M1使用迁移助手迁移到M1 MAC导致

正常M1 mac 执行下面命令输出 arm64
$ uname -m
arm64
从非M1迁移的系统执行下面命令输出 
$ uname -m
x86_64

而部分相关依赖比如 Homebrew rvm ruby ffi等均都提供arm版本,且安装判断就是通过uname -m 获取系统类型, 因此安装的依赖不是arm版本,而是x86_64版本,导致出现各种奇奇怪怪的问题,比如 pod install 失败

16654793275205.jpg

16654793448595.jpg

针对这种问题,有两种方式处理:

  • 彻底给硬盘格式化并重装系统
  • 在用户和群组里新建个用户,再重新安装 cocoapods-meitu-bin插件,及相关环境配置。推荐这种方式,不需要借助额外的存储设备就可完成文件迁移

针对M1 MAC推荐使用ruby2.7.2版本

六、二进制CI集成

CI制作二进制包

制作二进制包主要通过CI提供Runner去制作二进制包,制作二进制包主要考虑以下几点:

  • 制作时机(通过 GitLab-CI/CD 流水线计划设定每天1224基于develop分支触发制作)
  • 制作二进制组件相关配置,比如多Xcode版本设置和Configuration配置(通过.gitlab-ci.yml配置多环境和多版本Xcode二进制组件制作流水线任务) 16654808333241.jpg
  • 执行完成后结果通知(企业微信机器人通知)

CI 打App包相关变动

  • 相比较之前CIApp包,需要执行pod install

    • pod install 慢问题,见上面Pod优化
    • 打包机缓存问题:多项目打包会导致缓存空间占用极大,一旦缓存占满就需要清理,会导致清理后 pod install 速度极慢
      • 方案1:扩容, 简单粗暴,保证大容量
      • 方案2:共享缓存方案,使多台打包机公用一套 pod 缓存,有效提升打包机群的打包缓存命中率,但这个方案是理想方案,实施侧由于各种原因还未能支持。
    • 打包机环境升级导致缓存被清理(CI调整使用不同版本cocoapods,执行pod操作会清pod cache,针对这点插件内已做了兼容优化处理)
  • bundle 规范环境 通过在项目新建Gemfile文件约束相关使用插件版本,CI执行前会读取Gemfile相关插件版本约束

    source "https://rubygems.org"
    
    gem 'cocoapods-meitu-bin''1.0.0'
    gem 'cocoapods', '1.10.2'
    
  • 基于二进制打包配置

    采用环境变量注入的方式,可以根据打包类型进行配置,灵活支撑各业务场景。

    通过CI环境变量注入 MEITU_USE_BINARIES 结合Podfileuse_binaries!(ENV["MEITU_USE_BINARIES"] != "false"),来控制CI是否在pod install使用二进制组件还是源码组件。

    本地开发因没有注入该环境变量pod install就正常走插件二进制流程,实现开发同学使用无感知,没有使用成本。

七、总结

cocoapods-meitu-bin 插件核心能力

  • 无侵入:对现有业务无影响。
  • pod install/update 无感知:利用双私有源、源码白名单等配置,通过 hook pod ,实现对二进制或源码的自动依赖。
  • 制作二进制包
    • 基于壳工程:依赖壳工程制作所有依赖库的二进制包,无需各依赖组件自动触发打包,一次性完成所有依赖 Pods 版本的制作。同时支持对单个组件基于壳工程依赖打包。
    • 基于 podspec: 通过单个 podspec 制作二进制包(待处理)
  • 支持 Clang Module, SwiftOC/Swift混编。
  • 环境配置:可灵活配置configuration、上传二进制包地址、下载地址、二进制源等等。
  • 自动生成二进制 podspec
  • 源码白名单
  • CocoaPods 优化:支持并发、优化依赖分析时长、仓库过大预警等。
  • 完美利用 CocoaPods 缓存能力。

二进制化链路上的其他功能建设

除了插件自身以外,在整个链路的开发过程中,遇到了很多的问题,也为此做了很多的脚手架与优化。简单介绍几个:

cocoapods-tag 插件/GUI工具:

一行命令实现打tag,提交远端及上传podspecspec仓库,命令及GUI均已支持,规范打 tag 效率提升 90% 以上。

引入字节 Mbox 的多仓开发管理工具,高效的提升多仓开发效率

代码仓库治理

通过迁移二进制仓库,拆库等方式,使组件库与Demo工程独立,从而根本上治理掉历史的大文件导致的.git过大问题。整个工程所需下载代码大小优化达 80% 以上。

总体效果

  1. 编译时长:80%+ ,随着业务的逐步下沉,依然还有很大优化空间。
  2. pod install/update速度优化 80% 以上。
  3. CI 打二进制包效率相比源码提升:60% 以上,由于 CI 两种方式打包都有 pod install 和 产物的处理环节,所以相比本地编译整体时长主要提升在 xcodebuild 部分。
  4. 更高效的多仓依赖管理,提升研发流程运转效率,git相关操作效率随同步开发仓库数量成倍提升,合并效率更是提升 90% 以上:由于二进制后 ignore 了 PodsPodfile.lock 文件,不同业务间基本不会有什么冲突,规避了之前 "解决冲突 --> pod install --> 编译 --> 修改 --> 可能重新 pod install --> 重新编译" 的低效问题。

结束语

移动端研发蓬勃发展了十余年,各个大型团队都建立起了一个个内部的独立而成熟工具链体系,且大部分属于定制化工具链路,这种体系是很难做到开源的,即使能够开源也只是其中某一种独立工具。所以对于我们这种项目超大,团队却并不太大的企业来说,更需要结合当前团队的研发流程、人员结构、技术特点,自由选择最适合的工具链组合,将各个工具的优势发挥至极致,有效的连接工具和开发者,博采众长,最大程度的提升研发效能。

基础建设目的是为业务做好支撑,提升效能,需要保持对 “不合理”、“重复劳动” 的敏感性。同时也是一项充满未知、磨炼自身的工作,能够打破自身技术牢笼,应用的技术栈会变得比较模糊,希望大家有机会也能把自身的经验和技术投入的更广阔的未来之中,移动端改变世界!

One More

cocoapods-meitu-bin插件已开源,希望能够更方便的提供给 iOS 开发者们更加方便的二进制研发工具。

参考资料

本文发布自美图秀秀技术团队,文章未经授权禁止任何形式的转载。