星的天空的博客

种一颗树,最好的时间是十年前,其次是现在。

0%

iOS组件化避坑心得

为什么要组件化

组件化一般是把工程分层拆成不同的组件,以达到解耦,模块复用,便于单元测试,编译速度优化等效果,最终目的是为了提高开发质量和效率。当然,组件化是有一定成本的,在组件化之前要考虑清楚当前的项目情况是否适合组件化,收益能否覆盖开发成本。规模较小,模块没太多复用需求的项目,就没必要进行组件化。

组件化如何分层

组件化之前,先要对项目进行分层,以我们现在的项目为例:

分层后,一般使用Cocoapods来封装组件,通过路由来进行不同业务模块间的解耦调用。其中系统框架层公有pod库层是本来就有的。其它层的拆分逻辑分别是:

  • 自有公有组件层(公有pod):这一层的pod都是推到公有仓库,和业务是没有任何耦合的的,就是用于放开源的组件。
  • 私有组件层(私有pod):这一层的组件都有自己的私有git仓库,通过私有git仓库和私有索引库来进行管理,根据实际业务场景,这一层可能还会再进行分层。一般放一些项目中比较基础和通用的逻辑,例如配置信息管理,埋点管理,数据存储这些。
  • 本地组件层(本地pod):这一层放业务模块,是app呈现给用户的一个模块的整体封装,例如我的模块,首页模块这类。它的修改会非常频繁,日常的业务开发大部分都是在这一层,所以使用本地pod的方式,不会单独创建git仓库,与主工程共用一个git仓库进行管理。

    cocoapods只支持在Podfile中以path方式依赖pod,在podspec中是不支持的,所以本地pod最多只能有一层。(你也可以写插件支持多层)

这样分层后,当有新的业务需求时,我们只需要创建一个本地pod,写入pod依赖,就可以快速的进入业务开发状态,因为没有其它模块的干扰,编译和调试的速度会得到极大的提升,同时也避免了模块之间的耦合。

使用Cocoapods制作不同类型的组件

通过上述内容,可以知道有三种类型的组件:公有pod私有pod,本地pod。不管什么类型,都建议使用pod lib create PodName命令来创建组件,在它生成的组件模版基础上,可以很方便的进行开发。

公有Pod

公有pod是面向所有开发者的,需要尽量保证它的可用性和稳定性。如何发布公有pod这里不做说明,可以参考官方文档:Getting setup with Trunk。一些公司提供的商用公有pod,连pod验证命令pod lib lint都无法通过,这个是不应该的,最容易出现的问题就是编译错误,因为它会校验各个场景,而很多pod开发者却只保证自己的pod在常见场景可用。常见的报错有:

1
2
3
4
5
// 报错1: 
ld: symbol(s) not found for architecture i386

// 报错2:
building for iOS Simulator, but linking in object file built for iOS, file 'you path' for architecture arm64

这里的i38632位模拟器的架构,arm64m1机型上的模拟器架构,出现这种问题一般是pod中引入了lib或者framework静态库,而这些库不支持这些架构。
现在都2202年了,微信的最低支持版本都到了12.0,所以请大胆将pod库的deployment_target参数设置为12.0,这样就不会进行i386架构的验证。 而对arm64模拟器架构的支持,最好是将静态库重新打包成.xcframework格式并支持arm64模拟器架构,不然会导致m1设备的使用者只能以Rosetta模式在模拟器上运行项目。具体可以看我之前写的博文M1设备的Xcode编译问题深究 ,如这些静态库是第三方提供的,无法重新打包支持arm64模拟器架构,则可以进行如下设置避免报错:

1
2
3
4
# 因为依赖的静态库不支持模拟器arm64架构,设置当前这个pod不支持arm64, 以避免pod lib lint无法通过
s.pod_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' }
# 单纯设置pod_target_xcconfig只是设置当前这个pod不支持arm64, 这里把这些pod的上层设置为不支持arm64,兼容这种问题
s.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64' }

对于公有pod,负责任的做法是没有任何错误和警告后再进行发布,如果由于各种客观原因,实在无法去除警告,可以加入--allow-warnings参数来推送:pod trunk push [NAME.podspec] --allow-warnings。如果添加--skip-import-validation参数来逃避验证,则显得有些不负责任了。

另外,在CocoaPods1.8版本,将默认的spec repo设为了CDN源,以提高pod的速度。刚发布的公有pod版本,可能要几个小时后才能被同步到CDN源,导致刚发布时调用pod install --repo-update没法找到新发布的pod库,这时可以通过指定源来解决这个问题,样例如下:

1
pod '你的公有pod', :source => 'https://github.com/CocoaPods/Specs'

当然,CDN源同步后要记得改回。

私有Pod

私有pod一般通过私有repo来进行管理,这样才方便做版本管理和使用缓存。私有repo创建命令是pod repo add REPO_NAME SOURCE_URL,其中SOURCE_URL就是私有repo的git地址。创建私有repo后,通过pod repo push REPO_NAME SPEC_NAME.podspec命令来发布私有pod私有repo。需要注意的是私有pod公有pod的发布命令并不一样,分别是:

1
2
3
4
# 公有pod发布命令
pod trunk push SPEC_NAME.podspec
# 私有pod发布命令
pod repo push REPO_NAME SPEC_NAME.podspec

发布后,需要在Podfile中加入私有repo源,才能找到私有pod并安装成功。

1
2
3
4
5
# --- Podfile文件 --
# 指定私有source
source 'https://youhost.com/YouPrivateRepo.git'
# 指定公有source,
source 'https://github.com/CocoaPods/Specs.git'

这些固定流程不做过多说明,具体内容可以查看官方文档:Private Pods

如果私有pod中依赖了非公有源的pod,在pod lib lint时会出现这类报错:

1
ERROR | [iOS] unknown: Encountered an unknown error (Unable to find a specification for `AlicloudHTTPDNS` depended upon by `DTHttpDns`

这时根据提示调用pod repo update后也是无效的,这类问题可以通过指定sources来解决,以如上报错为例,它依赖的AlicloudHTTPDNS是阿里的源:https://github.com/aliyun/aliyun-specs.git,设置sources后则可以验证通过:

1
2
# 可以通过逗号分隔,添加多个不同的source。(--sources='a_source,b_source')
pod lib lint DTHttpDns.podspec --allow-warnings --sources='https://github.com/aliyun/aliyun-specs.git'

pod repo push也可能会出现上述报错,但是它的逻辑稍有不同,它会从你本地的repo列表中去查找pod依赖,找到则不会报错。而pod lib lint在没有指定sources时,只会从默认的源去找。

本地Pod

本地pod与主工程一起被同一个git仓库管理,不需要单独进行版本管理,也不需要push,而是在Podfile中直接以path的方式进行引入,样例如下:

1
2
# --- Podfile文件中 --
pod '你的本地pod', :path => '../本地pod路径/你的本地pod目录名'

Pod组件中使用资源的坑

在pod中,经常会出现需要使用图片,xib,json文件等资源的场景,建议使用resource_bundles来配置使用这些资源,以名为DTVideo的pod库为例:

1
2
3
4
# -- DTVideo.podspec 文件中--
s.resource_bundles = {
'DTVideoAssets' => ['DTVideo/{Assets,Classes}/**/*.{xib,xcassets}']
}

这样配置后,cocoapods会自动把这些资源打包成一个名叫DTVideoAssetsbundle文件,在pod中使用这些资源的方式会发生一些改变。假如这个pod中有一个类VideoPlayListCell.swift,那么我们可以创建辅助方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct DTVideoCommon {
static func assetsBundle() -> Bundle? {
let myBundle = Bundle(for: VideoPlayListCell.self)
let path = myBundle.path(forResource: "DTVideoAssets", ofType: "bundle")
guard let path = path else {
return nil
}
let assetsBundle = Bundle.init(path: path)
return assetsBundle
}

static func imageWith(named name: String) -> UIImage? {
let assetsBundle = assetsBundle()
let image = UIImage.init(named: name, in: assetsBundle, compatibleWith: nil)
return image
}
}

加载图片时:

1
2
let image = DTVideoCommon.imageWith(named: "video_play_max_nor")
imageView.image = image

使用xib时:

1
2
let cellNib = UINib.init(nibName: cellIdentifier, bundle: DTVideoCommon.assetsBundle())
tableView.register(cellNib, forCellReuseIdentifier: cellIdentifier)

xib文件中设置Module名的坑

xib文件中有Module设置,如果是在工程中创建的,那么它默认是勾选上inherit Module From Target,当将这个文件移动到pod中时,它的Module名就被设置成了默认名,即bundle名,这样会导致创建这个cell的时候报错:

1
Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<UITableViewCell 0x7fd3eba23d60> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key you_error_key.'

错误设置图示如下:

正确的设置是不要勾选inherit Module From Target,并且在Module栏输入正确的Module名

当然你也可以空着,不输入Module名,但是这样需要修改VideoPlayListCell的类名:

1
2
3
4
@objc(VideoPlayListCell) // Module栏空着情况下,必须添加这行
class VideoPlayListCell: UITableViewCell {
// ... 代码省略
}

如果不使用@objc(VideoPlayListCell)修改类名,那么会出现上面一样的报错,所以还是建议输入正确的Module名

关于module

cocoapods现在默认会开启pod库的module选项,所以没有必要在podspec中配置:'DEFINES_MODULE' => 'YES'

module对头文件的引入和混编做了很大的优化,在引入其它组件的时候,建议以@import或者#import <A/A.h>的方式引入,其中#import <A/A.h>会最终转换为@import的方式。

现在使用pod库,以#import "XXX"#import <XXX>的方式引入,Xcode都没有补全提示了,只有@import方式有。

如果想对module做更多的了解,建议看这篇博文:从预编译的角度理解Swift与Objective-C及混编机制,这篇博文写的非常好,内容也很长,阅读完需要一两个小时。后面的内容很多是对篇博文的部分总结和回顾,再次感谢这篇博文的作者。

pod库中swift与Objective-C的混编问题

SwiftObjective-C文件同时在一个App或者Unit Test类型的Target中,不同类型文件的API寻找机制如下:

这个和pod没有关联,是主工程的混编用法。

SwiftObjective-C文件在不同Target中,例如不同Framework中,不同类型文件的API寻找机制如下:

图示的module.modulemap指的是需要import不同frameworkmodule,例如Apod中的swift文件需要引用Bpod中的.h/.m文件,需要:import B

SwiftObjective-C文件同时在一个Target中,例如同一Framework中,不同类型文件的API寻找机制如下:

这代表在同一个pod库中,不需要做任何处理,swift就可以直接调用库中的oc代码。但是oc想调用库中的swift代码,则需要导入固定格式的头文件,如:#import <DTVideo/DTVideo-Swift.h>,其中DTVideo就是这个pod库的module名。另外还有两个点需要注意:

  • swift中必须是声明为public或者open权限的才能被同一个pod中的oc代码或者外部调用。
  • 如果想在pod外部创建它某个swift类子类,那么这个swift类必须声明为open权限

pod中swift使用struct的坑

struct默认生成的初始化方法是internal级别的,例如:

1
2
3
4
5
6
7
public struct TestStruct {
let key: String
}

func test() {
TestStruct(key: "aaaa")
}

它可以在pod里面调用,但是在pod外部被调用则会报错:'TestStruct' initializer is inaccessible due to 'internal' protection level
需要手动创建它的public init方法。如:

1
2
3
4
5
6
7
public struct TestStruct {
let key: String
// 需要手动添加public init方法
public init(key: String) {
self.key= key
}
}

设置pod库的Public HeadersPrivete Headers

构建产物为Framework的情况下

  • 根据podspec里的public_header_files字段的内容,将相应头文件设置为Public类型,并放在Headers中。
  • 根据podspec里的private_header_files字段的内容,将相应文件设置为Private类型,并放在PrivateHeader中。
  • 将其余未描述的头文件设置为Project类型,且不放入最终的产物中。

如果podspec里未标注PublicPrivate的时候,会将所有文件设置为Public类型,并放在Header中。

构建产物为Static Library的情况下

不论podspec里如何设置public_header_filesprivate_header_files,相应的头文件都会被设置为Project类型。

  • Pods/Headers/Public中会保存所有被声明为public_header_files的头文件。
  • Pods/Headers/Private中会保存所有头文件,不论是public_header_files或者 private_header_files描述到,还是那些未被描述的,这个目录下是当前组件的所有头文件全集。

关于路由

现在常见的路由方案有:URLRouteProtocol-ClassTarget-Action等。个人偏爱URLRoute,主要有两方面的原因:

  • 第一是通用。URLRoute可以多端统一,例如运营配置一条推送,点击打开某个页面,对于运营方来说,统一配置固定规则的URL就可以了,每个端都一致。H5页面要打开某个Native页面,或者外部唤起,也是统一用URLRoute就可以了,不需要区分平台做不同操作。
  • 第二是没法避开。例如URL SchemeUniversal Links这类,还是需要使用到URL

Protocol-ClassTarget-Action这些方案,没法避免硬编码,只能说是URLRoute的一种补充。URLRoute本质上就是约定一个各端通用的协议,在各端内部对协议进行正确的解析和逻辑处理。封装好了之后,不管是外部还是内部的调用者,不需要关心任何细节和区分平台,只需要传入协议就可以。综上所属,推荐使用URLRoute

关于脚本

组件化后会多出很多重复简单的操作,例如一个私有pod的新版本发布,需要的流程有:git commit -> 打tag -> pod验证 -> pod发布,这些都是可以通过编写脚本简化操作的,建议在组件化过程中多做这方面的工作。

后文

现在市面上的组件化方案很多,各大公司各种高大上的落地方案。我在小公司的业务间隙,抽时间写的这篇简单的避坑心得,是对自己实践的整理和归纳,希望能帮到你。

参考资料