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' }

提高效率

如果你没有自己一套的便利快捷的工作流,光是文件夹和工程间切换就足以让大部分人头痛,而且还容易出错,一向懒得我在经历了一上午的折磨后就着手写脚本解放双手了。

自动生成头文件

开发三方库时,往往会建立一个 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