iOS 使用 cocoapods 组件化心得记录
背景
2020年2月10日,在疫情的影响之下,公司只能远程开工,这天算是上班的第一天,上午召开了公司全体员工远程会议,宣贯了一些通知和公司计划。结论是:公司之前做的支付产品“ViPay”暂时停止步伐,转而进军其他领域,从宣贯至今,风头时而有变化,一会儿裁员一会又是做外包的,最后确切消息是要做一个超级 App,有点类似国内的支付宝和美团,所以之前我加了几百个小时班优化的客户端就这样夭折了,但是组件要抽出来做他用,还要为后期做容器接入第三方做准备,折腾了这么多天,记录一些小心得。
简介
组件化的方案采用的是用 cocoapods 管理,搭建私有库,根据职责区分区分模块,因为项目还没真正开始,是正准备阶段,暂时是这么划分的:
HDKit
├── Chaos-specs 私有 cocoapods 仓库
├── HDCashierKit 收银台,因为此块需要单独给第三方
├── HDServiceKit 基础服务
├── HDUIKit UI 组件
├── HDVendorKit 对第三方库的二次加工或封装
└── xmind 各模块脑图
少啰嗦,先看东西
部分成果
四个库同时进行(因为相互间可能有依赖),为了能够快速验证一些想法和做法,我是这么放在一个 workspace 工作的
看看其中两个库的 podspec
HDUIKit
Pod::Spec.new do |s|
s.name = "HDUIKit"
s.version = "0.4.5"
s.summary = "混沌 iOS 项目组件库"
s.description = <<-DESC
HDUIKit 是一系列 iOS 组件的组成,用于快速在其他项目使用或者第三方接入
DESC
s.homepage = "https://git.vipaylife.com/vipay/HDUIKit"
s.license = 'MIT'
s.author = {"VanJay" => "wangwanjie1993@gmail.com"}
s.source = {:git => "git@git.vipaylife.com:vipay/HDUIKit.git", :tag => s.version.to_s}
s.social_media_url = 'https://git.vipaylife.com/vipay/HDUIKit'
s.requires_arc = true
s.documentation_url = 'https://git.vipaylife.com/vipay/HDUIKit'
s.screenshot = 'https://xxx.png'
s.platform = :ios, '9.0'
s.frameworks = 'Foundation', 'UIKit', 'CoreGraphics'
s.source_files = 'HDUIKit/HDUIKit.h'
s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '-lObjC' }
s.subspec 'Core' do |ss|
ss.source_files = 'HDUIKit/HDUIKit.h', 'HDUIKit/Core','HDUIKit/UIKitExtensions', 'HDUIKit/UIKitExtensions/*/*'
ss.dependency 'HDUIKit/HDWeakObjectContainer'
ss.dependency 'HDUIKit/HDLog'
ss.dependency 'HDUIKit/HDRuntime'
ss.frameworks = 'AVFoundation'
end
s.subspec 'MainFrame' do |ss|
ss.source_files = 'HDUIKit/MainFrame'
ss.dependency 'HDUIKit/HDNavigationBar'
ss.dependency 'HDUIKit/HDAppTheme'
ss.dependency 'HDUIKit/UIKitExtensions/UIImage'
ss.resource_bundles = {'HDUIKitMainFrameResources' => ['HDUIKit/MainFrame/Resources/*.*']}
end
s.subspec 'HDNavigationBar' do |ss|
ss.source_files = 'HDUIKit/MainFrame/HDNavigationBar', 'HDUIKit/MainFrame/HDNavigationBar/*/*'
end
s.subspec 'HDRuntime' do |ss|
ss.source_files = 'HDUIKit/Core/Runtime','HDUIKit/UIKitExtensions/{NSMethodSignature}+HDUIKit.{h,m}'
ss.dependency 'HDUIKit/HDLog'
end
s.subspec 'MethodSwizzle' do |ss|
ss.source_files = 'HDUIKit/UIKitExtensions/NSObject/NSObject+HD_Swizzle.{h,m}'
end
s.subspec 'DispatchMainQueueSafe' do |ss|
ss.source_files = 'HDUIKit/DispatchMainQueueSafe'
end
s.subspec 'HDAppTheme' do |ss|
ss.source_files = 'HDUIKit/Theme'
ss.dependency 'HDUIKit/UIKitExtensions/UIColor'
end
s.subspec 'HDWeakObjectContainer' do |ss|
ss.source_files = 'HDUIKit/Components/HDWeakObjectContainer/HDWeakObjectContainer.{h,m}'
end
s.subspec 'WJFrameLayout' do |ss|
ss.source_files = 'HDUIKit/WJFrameLayout'
end
s.subspec 'WJFunctionThrottle' do |ss|
ss.source_files = 'HDUIKit/WJFunctionThrottle'
end
s.subspec 'HDLog' do |ss|
ss.source_files = 'HDUIKit/Components/HDLog'
end
s.subspec 'HDCodeGenerator' do |ss|
ss.source_files = 'HDUIKit/Vender/HDCodeGenerator', 'HDUIKit/Vender/HDCodeGenerator/*/*'
end
s.subspec 'UIKitExtensions' do |ss|
ss.source_files = 'HDUIKit/UIKitExtensions', 'HDUIKit/UIKitExtensions/*/*'
ss.dependency 'HDUIKit/Core'
ss.subspec 'UIView' do |sss|
sss.source_files = 'HDUIKit/UIKitExtensions/UIView'
end
ss.subspec 'NSString' do |sss|
sss.source_files = 'HDUIKit/UIKitExtensions/NSString'
end
ss.subspec 'UIColor' do |sss|
sss.source_files = 'HDUIKit/UIKitExtensions/UIColor'
sss.dependency 'HDUIKit/UIKitExtensions/NSString'
end
ss.subspec 'UIImage' do |sss|
sss.source_files = 'HDUIKit/UIKitExtensions/UIImage'
sss.dependency 'HDUIKit/UIKitExtensions/NSString'
end
ss.subspec 'UIButton' do |sss|
sss.source_files = 'HDUIKit/UIKitExtensions/UIButton'
end
end
s.subspec 'Components' do |ss|
ss.dependency 'HDUIKit/Core'
ss.dependency 'HDUIKit/HDAppTheme'
ss.subspec 'HDButton' do |sss|
sss.source_files = 'HDUIKit/Components/HDButton'
end
ss.subspec 'HDCyclePagerView' do |sss|
sss.source_files = 'HDUIKit/Components/HDCyclePagerView'
end
ss.subspec 'HDFloatLayoutView' do |sss|
sss.source_files = 'HDUIKit/Components/HDFloatLayoutView'
end
ss.subspec 'HDGridView' do |sss|
sss.source_files = 'HDUIKit/Components/HDGridView'
end
ss.subspec 'HDKeyBoard' do |sss|
sss.source_files = 'HDUIKit/Components/HDKeyBoard'
sss.dependency 'HDUIKit/Components/HDButton'
sss.dependency 'HDUIKit/WJFrameLayout'
end
ss.subspec 'HDRatingStarView' do |sss|
sss.source_files = 'HDUIKit/Components/HDRatingStarView'
end
ss.subspec 'HDTextView' do |sss|
sss.source_files = 'HDUIKit/Components/HDTextView'
sss.dependency 'HDUIKit/Components/MultipleDelegates'
end
ss.subspec 'HDTips' do |sss|
sss.source_files = 'HDUIKit/Components/HDTips'
sss.dependency 'HDUIKit/Components/ToastView'
sss.dependency 'HDUIKit/Components/ProgressView'
sss.resource_bundles = {'HDUIKitTipsResources' => ['HDUIKit/Components/HDTips/Resources/*.*']}
end
ss.subspec 'MultipleDelegates' do |sss|
sss.source_files = 'HDUIKit/Components/MultipleDelegates'
end
ss.subspec 'ProgressView' do |sss|
sss.source_files = 'HDUIKit/Components/ProgressView'
end
ss.subspec 'ToastView' do |sss|
sss.source_files = 'HDUIKit/Components/ToastView', 'HDUIKit/Components/HDVisualEffectView'
end
ss.subspec 'HDActionAlertView' do |sss|
sss.source_files = 'HDUIKit/Components/HDActionAlertView'
sss.dependency 'HDUIKit/DispatchMainQueueSafe'
end
ss.subspec 'HDAlertView' do |sss|
sss.source_files = 'HDUIKit/Components/HDAlertView'
sss.dependency 'HDUIKit/Components/HDActionAlertView'
sss.dependency 'HDUIKit/HDAppTheme'
sss.dependency 'HDUIKit/WJFrameLayout'
end
ss.subspec 'NAT' do |sss|
sss.source_files = 'HDUIKit/Components/NAT'
sss.dependency 'FFToast', '~> 1.2.0'
sss.dependency 'HDUIKit/Components/HDAlertView'
end
end
end
HDServiceKit
Pod::Spec.new do |s|
s.name = "HDServiceKit"
s.version = "0.4.3"
s.summary = "混沌 iOS 服务"
s.description = <<-DESC
HDServiceKit 是一系列服务以及能力,用于快速在其他项目使用或者第三方接入
DESC
s.homepage = "https://git.vipaylife.com/vipay/HDServiceKit"
s.license = 'MIT'
s.author = {"VanJay" => "wangwanjie1993@gmail.com"}
s.source = {:git => "git@git.vipaylife.com:vipay/HDServiceKit.git", :tag => s.version.to_s}
s.social_media_url = 'https://git.vipaylife.com/vipay/HDServiceKit'
s.requires_arc = true
s.documentation_url = 'https://git.vipaylife.com/vipay/HDServiceKit'
s.screenshot = 'https://xxx.png'
s.platform = :ios, '9.0'
s.frameworks = 'Foundation', 'UIKit'
s.source_files = 'HDServiceKit/HDServiceKit.h'
s.subspec 'HDCache' do |ss|
ss.libraries = 'pthread'
ss.source_files = 'HDServiceKit/HDCache', 'HDServiceKit/HDCache/*/*'
ss.dependency 'YYModel', '~> 1.0.4'
ss.dependency 'UICKeyChainStore', '~> 2.1.2'
end
s.subspec 'AntiCrash' do |ss|
ss.requires_arc = ['HDServiceKit/AntiCrash/NSObjectSafe.h']
ss.source_files = 'HDServiceKit/AntiCrash'
ss.dependency 'HDUIKit/MethodSwizzle'
end
s.subspec 'Location' do |ss|
ss.source_files = 'HDServiceKit/Location', 'HDServiceKit/Location/*/*'
ss.frameworks = 'CoreLocation', 'MapKit'
ss.dependency 'HDUIKit/HDLog'
end
s.subspec 'FileOperation' do |ss|
ss.source_files = 'HDServiceKit/FileOperation'
end
s.subspec 'HDReachability' do |ss|
ss.source_files = 'HDServiceKit/HDReachability'
end
s.subspec 'HDPodAsset' do |ss|
ss.source_files = 'HDServiceKit/HDPodAsset'
end
s.subspec 'HDWebViewHost' do |ss|
ss.dependency 'HDServiceKit/FileOperation'
ss.subspec 'Core' do |ss|
ss.libraries = 'xml2', 'z'
ss.frameworks = 'SafariServices', 'WebKit', 'MobileCoreServices'
ss.xcconfig = { "HEADER_SEARCH_PATHS" => ["$(SDKROOT)/usr/include/libxml2", "$(SDKROOT)/usr/include/libz"] }
ss.source_files = 'HDServiceKit/HDWebViewHost/Core', 'HDServiceKit/HDWebViewHost/Core/**/*.{h,m}'
ss.resource_bundles = {'HDWebViewHostCoreResources' => ['HDServiceKit/HDWebViewHost/Core/Resources/*.*']}
ss.dependency 'HDUIKit/MainFrame'
ss.dependency 'HDServiceKit/HDReachability'
ss.dependency 'HDUIKit/Components/HDTips'
end
ss.subspec 'RemoteDebug' do |ss|
ss.source_files = 'HDServiceKit/HDWebViewHost/RemoteDebug', 'HDServiceKit/HDWebViewHost/RemoteDebug/GCDWebServer/**/*'
ss.resource_bundles = {'HDWebViewHostRemoteDebugResources' => ['HDServiceKit/HDWebViewHost/RemoteDebug/src/*']}
ss.dependency 'HDServiceKit/HDWebViewHost/Core'
ss.dependency 'HDServiceKit/HDPodAsset'
end
ss.subspec 'Preloader' do |ss|
ss.source_files = 'HDServiceKit/HDWebViewHost/Preloader/*/*'
ss.resource_bundles = {'HDWebViewHostPreloaderResources' => ['HDServiceKit/HDWebViewHost/Preloader/html/*.*']}
ss.dependency 'HDServiceKit/HDWebViewHost/Core'
end
end
end
心得
- subspec 的资源文件最好自己管理,不然如果 subspec 在被别处单独引入的时候,资源文件不会被拉取
- 一些分类文件,内部调用错综复杂,如果要考虑划分很细到每个 subspec 将痛苦不堪,建议这些文件统一放在 Core ,必要引入,如果使用了分类,记得添加配置
s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '-lObjC' }
- cocoapods document 官方文档最新、最权威
提高效率
如果你没有自己一套的便利快捷的工作流,光是文件夹和工程间切换就足以让大部分人头痛,而且还容易出错,一向懒得我在经历了一上午的折磨后就着手写脚本解放双手了。
自动生成头文件
开发三方库时,往往会建立一个 import 了其他所有公用类的头文件,但是如果每增加一个功能或组件就要手动更新这个文件,实在太痛苦,可想而知,这是一项痛苦的工作,而且极度容易出错,所以我写了一个 python 脚本,在 xcode build 时自动触发,生成最新头文件,自动查找 podspec 文件,自动获取版本号,当然,也可以脱离 xcode build 执行,脚本内部做了简单判断,脚本内容如下:
HeaderFileGenerator.py
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import os
# 判断是否存在 podspec 文件
def isExistPodspec():
allFiles = os.listdir(os.getcwd())
isExist = False
for fileName in allFiles:
if '.podspec' in fileName:
isExist = True
# 记得跳出循环
break
else:
isExist = False
return isExist
# 自动获取 podspec 文件
def getPodspecFile():
listFile = os.listdir(os.getcwd())
# 文件后缀名,自动查找
file_extension = "podspec"
for fileName in listFile:
if fileName.endswith(file_extension):
return fileName
os.system("say 未找到" + file_extension + "文件")
return "not found"
# 自动获取版本号
def getPodspecVersion(podspecFileName):
podspec_path = os.getcwd() + '/' + podspecFileName
versionLine = ''
for line in open(podspec_path):
if 's.version' in line:
versionLine = line
break
version = ''
splitStr = versionLine.split("\"")
if len(splitStr) > 1:
version = splitStr[1]
return version
if not isExistPodspec():
# xcode build 触发
# 回到上级目录
os.chdir(os.path.abspath(os.path.dirname(os.getcwd())))
# podspec 文件
podspecFileName = getPodspecFile()
# 库名称
podName = podspecFileName.split(".")[0]
# 根目录
ROOT_DIR_PATH = os.getcwd() + '/' + podName
# 版本
version = getPodspecVersion(podspecFileName)
# 头文件名称
HeaderFileFullName = podName + '.h'
# 递归收集所有文件
def fileListForDir(dir):
fileList = []
for dir_path, subdir_list, file_list in os.walk(dir):
# 隐私头文件目录不导入
if dir_path.find('Private') > -1:
print('过滤隐私目录:'+dir_path)
continue
# 可以在这里设置过滤不相关目录
if not(dir_path.find(".git") > -1 or dir_path.find(".gitee") > -1 or dir_path.find(".svn") > -1 or dir_path.endswith('lproj') or dir_path.endswith('xcassets')):
for fname in file_list:
# if fname != HeaderFileFullName and (fname.lower().endswith(".h") or fname.lower().endswith(".c")):
if fname != HeaderFileFullName and (fname.lower().endswith(".h")):
full_path = os.path.join(dir_path, fname)
# 这是全路径
# fileList.append(full_path + fname)
fileList.append(fname)
return fileList
def main():
print('开始生成头文件')
print('根目录:' + ROOT_DIR_PATH)
print('版本号:' + version)
fileContent = '''//
// %s.h
// %s
//
// Created by VanJay on 2020/2/26.
// Copyright © 2020 VanJay. All rights reserved.
// This file is generated automatically.
#ifndef %s_h
#define %s_h
#import <UIKit/UIKit.h>
/// 版本号
static NSString * const %s_VERSION = @"%s";
''' % (podName, podName, podName, podName, podName, version)
# 文件名列表
fileList = fileListForDir(ROOT_DIR_PATH)
for filename in fileList:
fileContent += '''#if __has_include("%s")
#import "%s"
#endif
''' % (filename, filename)
# 拼接尾部
fileContent += '#endif /* %s_h */' % (podName)
# 写入文件
with open(ROOT_DIR_PATH + '/' + HeaderFileFullName, 'w') as file:
print("生成头文件成功")
file.write(fileContent)
if __name__ == "__main__":
main()
设置 Xcode build 触发
在工程设置 Build Phases
点击加号选择 New Run Script Phase
,重命名为Create Umbrella Header File
(可选),输入内容:
#!/bin/bash
# 生成头文件
python3 ../HeaderFileGenerator.py
脚本触发
也可在自动构建的脚本中触发,见下文
自动化发布
- 自动填写 commit log,自动打 tag,自动推送 tag 和对应分支代码到服务端
- 自动发布版本到 pod 私有库
- 自动打包成静态库
也可将自动自动更新头文件的脚本放置于此触发,内容如下:
publish.sh
#!/bin/bash
# 更新头文件
python3 ./HeaderFileGenerator.py
directory="$(pwd)"
# 文件后缀名,自动查找
file_extension="podspec"
podspec_path=`find $directory -name "*.$file_extension" -maxdepth 1 -print`
echo "podspec路径:$podspec_path"
podspec_name=$(basename $podspec_path)
echo "podspec名称:$podspec_name"
# 获取版本号
version=`grep -E "s.version |s.version=" $podspec_path | head -1 | sed 's/'s.version'//g' | sed 's/'='//g'| sed 's/'\"'//g'| sed 's/'\''//g' | sed 's/'[[:space:]]'//g'`
echo "podspec版本:$version"
echo "开始提交代码并打 tag:$version"
filename=$(echo $podspec_name | cut -d . -f1)
git rm -r --cached . -f
git add .
git commit -m "published $filename $version"
git push origin master
git tag -d $version
git push origin :refs/tags/$version
git tag -a $version -m "$version"
git push origin --tags
echo "提交及推送代码、tags 结束\n"
echo "开始发布 $filename 版本 $version 到 Chaos"
# 清除缓存
pod cache clean --all
pod repo push Chaos "${podspec_name}" --allow-warnings --verbose --sources=https://github.com/CocoaPods/Specs.git,,your_private_pod_address
echo "发布 $filename 版本 $version 到 Chaos 结束\n"
echo "开始打包 framework"
pod package ${podspec_name} --no-mangle --exclude-deps --force --spec-sources=https://github.com/CocoaPods/Specs.git,your_private_pod_address
echo "打包 framework 结束\n"
快速验证 podspec 有效性
pod 仓库组件慢慢多了,每次需要发版都需要验证是否有错,如果每次都直接 pod lint,pod 会将所有的 subspec 都验证一遍,过程十分漫长,所以在确定已经开发的 subspec 没有问题的话,lint 的时候只需要验证正在开发的 subspec 有没有问题,这点只是做个提示,能够提高效率、节约时间,命令如下:
pod lib lint --allow-warnings --verbose --sources=your_private_cocoapods_address,https://github.com/CocoaPods/Specs.git --subspec=subspec_name
专注业务
这样一来,就可以把精力集中在编码和业务上,省去的时间可以多倒几杯水,记得,多喝水。
每次写完新功能后,只需要执行:
➜ sh ./publish.sh
就完事了。
关于 bundle
如果使用 s.resource_bundles
让 pod 帮我们生成 bundle,注意
- 如果需要让所有 bundle 文件夹内文件在顶级目录(即同一级),匹配文件用
*.*
匹配:
s.resource_bundles = {'HDWebViewHostPreloaderResources' => ['HDServiceKit/HDWebViewHost/Preloader/html/*.*']}
- 如果需要让所有 bundle 文件夹内文件保持原来的层级结构,匹配文件时后缀名不要
.*
,正确使用如下:
s.resource_bundles = {'HDWebViewHostRemoteDebugResources' => ['HDServiceKit/HDWebViewHost/RemoteDebug/src/*']}
这里要注意如果末尾的 /*
没写将产生另一个结果,你从该 bundle 获取资源都要在路径后拼接一个 src
,所有记得写上,这样的话真实文件夹名称改变不会影响代码内获取路径。
获取 bundle
附上目前个人觉得比较可靠的获取 bundle 的方法,能够兼容 Podfile
内是否使用 use_frameworks!
两种情况
+ (NSBundle *)hd_WebViewHostCoreResources {
static NSBundle *resourceBundle = nil;
if (!resourceBundle) {
NSBundle *mainBundle = [NSBundle mainBundle];
NSString *resourcePath = [mainBundle pathForResource:@"Frameworks/HDServiceKit.framework/HDWebViewHostCoreResources" ofType:@"bundle"];
if (!resourcePath) {
resourcePath = [mainBundle pathForResource:@"HDWebViewHostCoreResources" ofType:@"bundle"];
}
resourceBundle = [NSBundle bundleWithPath:resourcePath] ?: mainBundle;
}
return resourceBundle;
}
PS
规范
项目记得使用 clang-format
自动规范代码格式,毕竟看着不规范的代码难受的是自己。
pod package
需要先安装 cocoapods
插件
sudo gem install cocoapods-packager
附录
build 时自动从环境变量获取版本号,兼容 Xcode 11+
import xml.etree.cElementTree as ET
# 自动获取版本号
def autoFetchVersionNumber():
internal_version = ''
# 从 Info.plist 中读取 HDServiceKit 的版本号,将其定义为一个 static const 常量以便代码里获取
infoFilePath = str(os.getenv('SRCROOT')) + \
'/%s/%s-Info.plist' % (HeaderFileName, HeaderFileName)
infoTree = ET.parse(infoFilePath)
infoDictList = list(infoTree.find('dict'))
# 从 info.plist 获取版本号
for index in range(len(infoDictList)):
element = infoDictList[index]
if element.text == 'CFBundleShortVersionString':
internal_version = infoDictList[index + 1].text
break
# Xcode 11
if internal_version.startswith('$'):
internal_version = str(os.getenv('MARKETING_VERSION'))
return internal_version