商户助手iOS提升编译速度实践
发布于 4 年前 作者 minglei 5200 次浏览 来自 分享

一、前言

微盟商户助手的组件化开发已有三年有余,随着业务的不断扩增,组件也越来越多,从原来的账号、零售、美业、餐厅、客来店几条业务线扩展至酒店、旅游、会务等十几条业务线。组件越来越多,代码量也越来越大,伴随而来的便是编译和打包时间越来越长,如果把DerivedData删除,无缓存全量编译竟然达到了令人无法忍受的45分钟左右(以我的Mac2015 8 GB 1867 MHz DDR3为参考),极大影响了团队小伙伴的开发效率,因而缩短编译和打包时间迫在眉睫。

那么如何缩短编译时间呢,我们知道iOS主要有以下三种方式:
1.优化工程配置
如Debug时Build Active Architecture Only设为NO,Debug Information Format改为DWARF,使用PCH等
2.使用第三方工具
如缓存插件CCache、分布式编译工具distcc等
3.组件二进制化
其中第三点的效果最立竿见影,原因在于二进制已经是编译好后的产物,在iOS中形式为Framework,本文重点针对这点讲述我是如何在商户助手App中进行组件二进制化的。

文章将从以下三个方面进行介绍
一、组件集成方式
二、组件打包
三、源码和二进制之间切换

二、组件集成方式

在商户助手App中组件的引入方式有两种:
1.手动创建Framework,在主工程中的pofile文件中对声明此target第三方依赖;
2.通过cocopods自动管理,在podspec文件中声明第三方依赖。
那么这两种方式有什么区别呢,见下表

手动创建的Framework cocopods自动管理
主工程中依赖方式 在Link Bianary With Libraries手动添加 podfile中 pod ‘组件名称’, :path =>‘路径’
第三方依赖 在主工程中的pofile显示声明 在podspec文件中声明
目录结构 以xcodeproj为根目录,源码和xib并列显示,结构清晰 统一在Development Pods中管理,源码和xib资源分开目录显示,开发时需要搜索
pod install 后 在主工程和project.pbxproj都会写入pods相关信息 只会在主工程中写入pods相关信息
工程配置 Build Setting和Build Phase按需配置 podspec中填写source_files、resources、prefix_header_file、frameworks、libraries等信息

通过上述比较,可见各有优劣,至于选择哪种还得根据实际项目情况而来,从个人而言推荐后者,原因后文会谈及。

两种不同的组件方式,打包的方式自然也就不一样。

三、组件打包

3.1 脚本打包

手动创建的Framework,打包步骤如下:
1.配置打包环境:Mach-O Type设为Static Library;暴露所需头文件;Build Active Architecture Only设为NO等。
2.分别在模拟器和真机下编译。
3.通过lipo命令打出模拟器和真机的合并包。

如果每个组件的打包都按部就班使用上述步骤完成,必定影响工作效率。好在有脚本的帮助,把这些步骤写在脚本中,按下回车就可以解放你的双手。

#!/bin/sh

# 存放所以二进制文件的基地址
BASEBUILD_DIR="${SOURCECODE_DIR}/DerivedData/Build/Products"

# 支持真机的二进制文件目录
IPHONE_DEVICE_BUILD_DIR="${BASEBUILD_DIR}/iphoneos"

# 支持模拟器的二进制文件目录
IPHONE_SIMULATOR_BUILD_DIR="${BASEBUILD_DIR}/iphonesimulator"

# 合并后的二进制文件目录
UNIVERSAL_OUTPUT_FOLDER="${BASEBUILD_DIR}/Combine_Products"

# 最终更新组件的路径
TARGET_FRAMEWORK_DIR="$4/${TARGET_NAME}/Frameworks"

# 最终更新资源的路径
TARGET_ASSETS_DIR="$4/${TARGET_NAME}/Assets"

#创建输出目录,并删除之前的framework文件
rm -rf "${IPHONE_SIMULATOR_BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${TARGET_NAME}.framework" "${IPHONE_DEVICE_BUILD_DIR}/${CONFIGURATION}-iphoneos/${TARGET_NAME}.framework"
rm -rf "${IPHONE_SIMULATOR_BUILD_DIR}/${CONFIGURATION}-iphonesimulator/libPods-${TARGET_NAME}.a" "${IPHONE_DEVICE_BUILD_DIR}/${CONFIGURATION}-iphoneos/libPods-${TARGET_NAME}.a"

mkdir -p "${UNIVERSAL_OUTPUT_FOLDER}"
rm -rf "${UNIVERSAL_OUTPUT_FOLDER}/${TARGET_NAME}.framework"

#分别编译模拟器和真机的Framework
buildFramework()
{
PODS_PROJECT="${SOURCECODE_DIR}/XYMainApp/Pods/Pods.xcodeproj"

PROJECT_DIR="${SOURCECODE_DIR}/$1/$1.xcodeproj"

echo "target路径 $1/$1"

echo "===== 正在构建Pods-$1====="
echo "===== 模拟器 ====="
xcodebuild -project ${PODS_PROJECT} -target "Pods-$1" ONLY_ACTIVE_ARCH=NO -configuration "${CONFIGURATION}" -sdk iphonesimulator BUILD_DIR="${IPHONE_SIMULATOR_BUILD_DIR}" BUILD_ROOT="${IPHONE_SIMULATOR_BUILD_DIR}" ARCHS='x86_64 i386' VALID_ARCHS='x86_64 i386' build -UseModernBuildSystem=NO

echo "===== 真机 ====="
xcodebuild -project ${PODS_PROJECT} -target "Pods-$1" ONLY_ACTIVE_ARCH=NO -configuration "${CONFIGURATION}" -sdk iphoneos BUILD_DIR="${IPHONE_DEVICE_BUILD_DIR}"  BUILD_ROOT="${IPHONE_DEVICE_BUILD_DIR}" ARCHS='armv7 armv7s arm4' VALID_ARCHS='armv7 armv7s arm64' build -UseModernBuildSystem=NO

echo "===== 正在构建$1====="
echo "===== 模拟器 ====="
xcodebuild -project "${PROJECT_DIR}" -target "$1" ONLY_ACTIVE_ARCH=NO -configuration "${CONFIGURATION}" -sdk iphonesimulator BUILD_DIR="${IPHONE_SIMULATOR_BUILD_DIR}" BUILD_ROOT="${IPHONE_SIMULATOR_BUILD_DIR}" ARCHS='x86_64 i386' VALID_ARCHS='x86_64 i386' build  -UseModernBuildSystem=NO

echo "===== 真机 ====="
xcodebuild -project "${PROJECT_DIR}" -target "$1" ONLY_ACTIVE_ARCH=NO -configuration "${CONFIGURATION}" -sdk iphoneos BUILD_DIR="${IPHONE_DEVICE_BUILD_DIR}"  BUILD_ROOT="${IPHONE_DEVICE_BUILD_DIR}" ARCHS='armv7 armv7s arm64' VALID_ARCHS='armv7 armv7s arm64' build -UseModernBuildSystem=NO

#拷贝framework到univer目录
echo "拷贝framework到univer目录"
echo "${IPHONE_DEVICE_BUILD_DIR}/${CONFIGURATION}-iphoneos/$1.framework"
echo "拷贝framework到univer目录"
cp -R "${IPHONE_DEVICE_BUILD_DIR}/${CONFIGURATION}-iphoneos/$1.framework" "${UNIVERSAL_OUTPUT_FOLDER}"

#合并framework,输出最终的framework到build目录
echo "合并framework,输出最终的framework到build目录"
lipo -create "${IPHONE_DEVICE_BUILD_DIR}/${CONFIGURATION}-iphoneos/$1.framework/$1" "${IPHONE_SIMULATOR_BUILD_DIR}/${CONFIGURATION}-iphonesimulator/$1.framework/$1" -output "${UNIVERSAL_OUTPUT_FOLDER}/$1.framework/$1"

#删除编译之后生成的无关的配置文件
dir_path="${UNIVERSAL_OUTPUT_FOLDER}/$1.framework/"
for file in ls $dir_path
do
if [[ ${file} =~ ".xcconfig" ]]
then
rm -f "${dir_path}/${file}"
fi
done

#清除组件build文件
if [ -d "${SOURCECODE_DIR}/${1}/build" ]
then
rm -rf "${SOURCECODE_DIR}/${1}/build"
fi
}

buildFramework ${TARGET_NAME}

#清除主工程build文件
if [ -d "${SOURCECODE_DIR}/XYMainApp/build" ]
then
rm -rf "${SOURCECODE_DIR}/XYMainApp/build"
fi

# 拷贝asset资源
ASSETS_DIR=$(find ${SOURCECODE_DIR}/${TARGET_NAME} -name ${TARGET_NAME}.xcassets)
mkdir -p "${TARGET_ASSETS_DIR}"
rm -rf "${TARGET_ASSETS_DIR}/${TARGET_NAME}.xcassets"
cp -R "${ASSETS_DIR}" "${TARGET_ASSETS_DIR}/${TARGET_NAME}.xcassets"

mkdir -p "${TARGET_FRAMEWORK_DIR}"
# 覆盖framework
rm -rf "${TARGET_FRAMEWORK_DIR}/${TARGET_NAME}.framework"
cp -R "${UNIVERSAL_OUTPUT_FOLDER}/${TARGET_NAME}.framework" "${TARGET_FRAMEWORK_DIR}/${TARGET_NAME}.framework"

# 打开合并后的文件夹
open "${TARGET_FRAMEWORK_DIR}"

这里值得一提的是每个组件都是一个target,执行pod install后,会生成对应的target,名称为‘target-库名’,所以需要先对其进行编译,否则打包会失败。
由于组件比较多,为了提高打包效率,写了个小工具,可以自由切换组件进行打包,它的本质还是执行以上脚本。

3.2 Cocopods-packager打包

cocoapds家族提供了可以将pod库打包成静态库的插件cocoapods-packager,它的安装十分简单,只需要执行gem install cocoapods-packager命令即可,使用它对项目中的组件进行打包的前提是必须配置好了podsepc文件,因为它是依据此文件来锁定源码、资源、系统和第三方依赖、环境配置等信息。
以助手App中的XYPublicClasses的podspec文件为例:

s.source           = { :git => '组件git地址'}
s.source_files = 'XYPublicClasses/Classes/**/*.{h,pch,m}'
s.public_header_files = 'XYPublicClasses/Classes/**/*.{h,pch}'
s.prefix_header_file = 'XYPublicClasses/Classes/Supporting Files/Config/XYPrefixHeader.pch'
s.resources = ['XYPublicClasses/Classes/**/*.{xib,storyboard,plist,png,bundle,wm}']
s.dependency 'WechatOpenSDK'
s.dependency 'AFNetworking'
s.dependency 'YYKit'
s.dependency 'Masonry'

确保配置完基本信息后,便是执行pod package命令进行打包

pod package  XYPublicClasses.podspec --no-mangle --force --exclude-deps  --spec-sources=依赖的私有库地址

几个参数的介绍

-no-mangle:即Do not mangle symbols of depedendant Pods。
            如果组件有其他依赖项,必须填写,否则会报Undefined symbols for architecture 错误
--force:强制覆盖之前打包好的Framework
--exclude-deps:不包含依赖的符号表。动态库不能加,但我们项目中使用的是静态库,需加上。
--spec-sources:依赖的私有库地址,注意官方源https://github.com/CocoaPods/Specs.git也需要加上
--subspecs:批量生成subspec的二进制库,每一个subspec的库名就是podspecName+subspecName

打包完成后,查看Framework的大小是否正常,我在刚开始尝试的时候,发现包的大小竟然只有1KB,反复查找原因发现是podspec没有配置正确,修改后打包成功。

通过上述两种方法成功得到Framework后,那么问题来了:如何在项目中使用呢?接下来便是要讨论如何在源码和二进制之间快速切换。

四、源码和二进制切换

依赖于cocoapods方式切换的方案有两种:
方案一:在podspec中使用ENV环境变量,执行pod install时指定SOURCE的值来切换,但此方案的弊端是每次切换都需要pod cache clean清理缓存,后续再执行pod install时又需要再重新拉一遍代码,这一步的快慢取决于你配置的代理源和网络快慢了。

  if ENV['SOURCE']
    puts '配置源码、资源等信息'
  else
    puts '配置之前打包好二进制、资源等信息'
  end

方案二:通过subspec切换,和方案一比起来除了外层条件判断不一样,内层配置信息保持一致,好处就是不需要清理缓存,这也是我在项目当中使用的方案。

首先,需要改造一下上面所提到podspec文件配置,增加subspec来区分源码和二进制

  s.subspec 'Source' do |ss|
     ss.source_files = '路径'
     puts '配置源码、资源等信息'
  end
 
  s.subspec 'Framework' do |ss|
     ss.ios.vendored_framework = '二进制路径'
     puts '配置之前打包好二进制、资源等信息'
  end

其次,在podfile中引入,并执行pod install即可

pod '库名', :path =>'库路径',:subspecs => 'Source或者Framework'

至此新的问题产生了:
一、每个小伙伴切换源码和二进制的组件可能都不一样,如果都在主工程中pofile修改,执行install后,主工程势必冲突连连。好在主工程不承载业务代码,作为一个壳子代码已长期稳定,我的做法就是拷贝一份主工程,这样既避免了冲突问题,也加速了编译速度。
二、手动创建的Framework在podfile中原有的源码依赖是target ‘组件名’ do方式,如果要切换至二进制库,这种方式就得注释掉,来回切换显得比较麻烦,不如pods管理的库更加简洁纯粹,只需要改变Source或者Framework的变量即可。从这一点上来说,这也是我更推荐使用它的原因。

最后,编译整个项目,依然还是踩到几个坑,记录如下:
1.duplicate symbol
使用pod package打出来的静态库,需要加上参数–exclude-deps,不包含依赖的符号表。
2.图片显示异常问题
在podspec中资源引入的方式有两种:resource_bundles和resources。这两者的主要区别在于前者会把资源打入指定bundle中,可以减少命名冲突,但是代码引入时需要指定此bundle,而后者则直接把资源平铺在主bundle中,这样如果不同组件中有相同命名的图片则会导致显示异常问题。
在商户助手中,使用的是resources方式引入,因为不同组件要求资源必须指定前缀,从而避免命名冲突导致的异常问题,并且图片资源是放在xcassets中的,这样组件的assets和主工程的xcassets会合并成一个car文件,使用时直接指定名称即可,而不需要如resource_bundles方式必须指定自定义的bundle。

s.resources = ['path/Assets/*.xcassets']

3.没有权限访问.app
原因在于方式一打包出来的Framework中包含Info.plist,而资源的配置项是*.plist,导致编译时把主工程的Info.plist覆盖,因而无法打开app。
解决方法有两种:组件中Info.plist非必要,可以删除;或者在podspec使用exclude_files忽略此文件

s.exclude_files = 'path/Info.plist'

五、总结

执行完上述步骤后,项目的编译速度有了质的提高。

依赖方式 首次编译时长
全源码 45min
到店为源码,零售为库 7min
零售为源码,到店为库 10min

当然,提升编译速度是一场持久战,随着项目的迭代,仍然需要不断寻求最优解。

六、参考资源

Cocoapods-packager官方文档

Podfile语法参考(译)

1 回复

很有用的一篇文章,按照楼主的方式重新搞了下自己的项目的确编译速度提高了很多。但是感觉还不理想,是不是因为我项目里边的 XIB 文件太多造成的,请问下楼主 xib 有没有什么优化的方式

回到顶部