前段时间做了一些 iOS framework 开发的工作,总结一些作为上手资料的经验吧。
Libraries简介
是什么
官方frameworks、官方系统库、 三方frameworks、三方.a库、三方 workspace。
framework就是库文件 + 头文件 + 资源文件。
如何使用
引入方式、embed 的含义。
最后一列 embed 的含义是,是否将库嵌入到目标 bundle 中。
framework 项目 与 embed
对于一个 framework 项目,比如一个 framework 依赖其它的 framework。如果将静态库 framework 设置为 embed,xcode 会将 framework 当做动态库处理,将被依赖的静态库放到 bundle 的 frameworks 目录下。
如果设置为 not embed,则不会将三方依赖库放到输出包的 Framework 中。
无论用哪种方式,framework 在被其它项目引入时,仍然需要引入这个 framework 依赖的三方库。
app 项目 与 embed
对于一个 app,由于静态库在编译的过程中打包到应用可执行程序文件中了,而动态库是运行时链接。所以如果是引入动态库 framework,则需要设置为 embed,静态库则选择 not embed,否则会报错。
将动态库设置成 not embed 时,编译会成功,但是动态库不会被打包到应用 bundle 中,导致 app 运行时动态链接失败而报错:
将静态库设置成动态库,则应用编译可以成功,但是 iOS 在运行时 app 和动态库是都需要签名的,编译成功的应用在往设备上安装时会因库签名失败而报错,因为实际上库是一个静态库。
framework 开发
创建项目
可以创建 framework 项目或者 static library 项目,前者构建目标是 framework 库,而后者是 .a 库。
查找路径设置
编译的过程中,xcode 可能需要查找项目依赖的 framework、library、头文件,可以在 build settings 中搜索 search paths 来配置
配置路径时可以用一些环境变量,如项目根目录 $(PROJECT_DIR)
。
对外暴露 .h 文件
这是一个 framework 的编译结果:
可以看到库文件 FrameworkDev
,还有一个头文件目录 Headers。
默认情况下,代码中的头文件都不会包含到 Headers 目录中,如果外部项目依赖库中的头文件,则会因找不到头文件而无法编译。
暴露模块自身的头文件
选择头文件,将 target membership 选择为构建目标,并设置为 public,这样构建完毕后,这些头文件就会被包含到 Headers 目录中
暴露依赖模块的头文件
点加号,add other,选择文件,将依赖模块的头文件加到 build phases 中的 headers 的 public 区域即可。
有一些特殊场景比如希望做库合并时,可能需要暴露依赖库的头文件。
不对外暴露的 .h 文件
如果有两个头文件, A.h 和 B.h,A.h 希望对外暴露,而 B.h 不希望对外暴露,但是 A.h 中 import 了 B.h。
此时如果 B.h 没有被暴露出去,那么三方应用在编译时就会报错,找不到 B.h 而编译失败。
这种情况可以将头文件的依赖关系写在 .m 文件中,而不是.h 文件中。 在 .h 文件中,通过
import 语句写在 A.m 文件中,而不是 A.h 文件中。 同时在 A.h 中通过预声明 B.h 中被依赖的 interface 或 protocol,来保证依赖关系。示例:
B.h
1 | // interface 声明 @interface SRWebSocket @end ... // delegate 声明 @protocol SRWebSocketDelegate @end |
A.h
1 | // 预声明 B.h 中的依赖项 @protocol SRWebSocketDelegate; @class SRWebSocket; // 在 A.h 自身的声明中使用预声明的 B.h 成员 ... @interface IvhSDK : NSObject <SRWebSocketDelegate> { @private SRWebSocket* _ws; } ... |
A.m
1 | // 在 A.m 代码文件中真实引入 B.h 的内容 #import "B.h" @implementation IvhSDK ... @end |
不过这里有一个特殊情况,就是当 B.h 中的类需要作为父类时,通过 class 声明的类是没办法使用的。 我曾经折腾了一阵子,仍然没找到啥好的办法,只好把头文件暴露。
如何查看编译日志
xcode 中可以很方便的查看编译日志,如图所示,xcode将近期的每次编译记录都保存下来了,可以根据时间或者构建目标分组来查看。
右侧的记录,将编译过程中 xcode 做的每一件事情都按顺序记录下来了,排查问题时非常方便。
静态库 framework 和 动态库 framework
xcode 设置编译目标类型
在 xcode 的 build settings 的 linking 设置中,修改 Mach-O Type 类型,可以设置编译类型是动态库还是静态库。
通过 file 查看 framework 中的库二进制文件信息可以判断类型。
静态库 framework 和动态库 framework 在编译和链接的过程中有一些区别,链接的区别网上资料也比较多,参考 https://www.cnblogs.com/king-lps/p/7757919.html。
下面主要描述一下编译过程的区别在 xcode 中的表现。
静态库编译过程
- 预处理(Preprocessing):预处理器会处理源代码文件,包括宏替换和头文件展开等操作。
- 编译(Compilation):编译器会将预处理后的源代码编译成汇编代码。
- 汇编(Assembly):汇编器将汇编代码转换成目标代码,即一个个对象文件(Object File)。
- 归档(Archiving):Xcode 会使用 ar 工具将多个目标文件打包成一个静态库文件(.a文件)。
这里值的注意的是,没有链接的过程。也就是说,如果静态库 framework 依赖其它的静态 framework,并不会把其它静态库的内容包含到最终生成的 .a 文件中。三方开发者需要将静态库和静态库依赖的库全部加入项目依赖。
查看 xcode 的编译日志,发现一个地方很容易让人误解
可以发现创建静态库的过程是用的 libtool 工具,里边有个 LinkFileList 的参数指定,实际上这个参数指定是没有任何作用的,因为压根不会有链接过程。把日志里的命令复制出来,LinkFileList 参数修改掉随便指定个任意值,仍然可以成功执行。
用 nm 查看生成的静态库文件中的符号表,前面的 ‘U’ 代表找不到相关符号,也能说明并没有把依赖的三方静态库打包进去。
在应用中使用,如果不引入 framework 依赖的其它库,则会报错找不到符号。
动态库(或可执行程序)编译过程
- 预处理(Preprocessing):将源代码文件中的预处理指令(如 #include、#define 等)替换为实际的代码。这个步骤可以使用预处理器(Preprocessor)完成。
- 编译(Compiling):将预处理后的源代码文件编译成目标文件(Object file),目标文件包含了编译后的机器码(Machine code)和一些元数据(Metadata)。这个步骤可以使用编译器(Compiler)完成。
- 汇编(Assembling):将编译后的目标文件转换成机器码指令的二进制表示形式。这个步骤可以使用汇编器(Assembler)完成。
- 链接(Linking):将编译和汇编后的目标文件链接成最终的动态库(Framework)。在链接过程中,会解决符号依赖关系、地址重定向等问题。这个步骤可以使用链接器(Linker)完成。
- 代码签名(Code signing):如果启用了代码签名功能,编译后的动态库将会被签名,以保证代码的完整性和安全性。这个步骤可以使用代码签名工具完成
查看 xcode 的编译日志
查看符号表,编译动态库时,链接是会生效的。
静态库、动态库交叉依赖的场景
https://juejin.cn/post/6964993273244942343#heading-10 这篇文章做了一下动静态库交叉依赖的总结,看着比较清晰。
搬运一下结论:
- 静态库A依赖静态库B,使用时需要在Link Binary With Libraries引入静态库A、B;
- 静态库A依赖动态库B,使用时需要在Link Binary With Libraries引入静态库A和动态库B,并且在Embeded Binaries引入动态库B;
- 动态库A依赖静态库B,使用时需要在Link Binary With Libraries引入动态库A,并且在Embeded Binaries引入动态库A;
- 动态库A依赖动态库B,使用时需要在Link Binary With Libraries引入动态库A和B,并且在Embeded Binaries引入动态库A和B;
测试代码:点击访问
设置编译后置脚本
可以用编译后置脚本做一些事情,比如:
静态库合并
1 | TARGET_BUILD_DIR=${TARGET_BUILD_DIR} PROJECT_DIR=${PROJECT_DIR} libtool -static -o "${TARGET_BUILD_DIR}/FrameworkDev.framework/FrameworkDev" "${TARGET_BUILD_DIR}/FrameworkDev.framework/FrameworkDev" "${PROJECT_DIR}/ThirdParty/JSONModel.framework/JSONModel" "${PROJECT_DIR}/ThirdParty/SocketRocket.framework/SocketRocket" |
编译结果打包
1 | ARGET_BUILD_DIR=${TARGET_BUILD_DIR} PROJECT_DIR=${PROJECT_DIR} # 因为 JSONModel 需要在 header 中依赖,需要将其编译好的静态库一起打包到 pod 中 # 复制 JSONModel.framework 到 buid 目录 cp -r "${PROJECT_DIR}/ThirdParty/JSONModel.framework" "${TARGET_BUILD_DIR}/JSONModel.framework" \ && cp -r "${PROJECT_DIR}/ThirdParty/SocketRocket.framework" "${TARGET_BUILD_DIR}/SocketRocket.framework" \ && cd "${TARGET_BUILD_DIR}" \ && zip -r "FrameworkDev.zip" "FrameworkDev.framework" "JSONModel.framework" "SocketRocket.framework" |
cocoapods 包管理
cocoapods 是一个类似 npm、maven 等包管理工具,用它可以管理本地项目的依赖,避免在本地管理一大堆依赖模块;也可以将自己的模块通过 cocoapods 模块的形式发布,方便第三方引入。
将自己的库通过 cocoapods 发布
参考官方的 podspec 语法说明。
可以用 podspec 文件管理模块的依赖,并制定模块代码/库文件的地址。 然后将 podspec 发布到 cocoapods 官方平台,或者存放到自己的服务器,给三方开发者提供链接。
下面是一个示例:
1 | Pod::Spec.new do |s| |
这个 podspec 文件中,指定了模块的版本、依赖的系统Framework、系统lib、文件地址 source、头文件路径等信息。
source 属性可以指定一个 git 源码仓库地址、一个本地源码路径,或者一个可下载的代码或模块 zip 文件的url,三方开发者在使用时,cocoapods可以处理这几个类型的模块数据。
1 | s.source = { |
使用 git 指定地址时,可以将模块发布到 cocoapods 仓库中,完整的步骤为:
1 | # 创建 podspec pod spec create MyLibrary # 修改内容后,进行校验 pod lib lint MyLibrary.podspec # 注册 pod trunk register youremail@example.com # 发布 pod trunk push MyLibrary.podspec |
项目中使用 cocoapods 管理依赖库
首先在操作系统中安装 cocoapods1
sudo gem install cocoapods
然后通过 pod init
命令,或者手动创建 Podfile 文件,可以参考官方的 podfile语法
1 | # 指定平台及其版本号 platform :ios, '12.0' # 是否使用动态库 use_frameworks! # 指定源 source 'https://github.com/CocoaPods/Specs.git' # 指定全局配置 configurations = { 'Debug' => :debug, 'Release' => :release } # 指定多个目标 target 'MyApp' do # 指定依赖库及其版本号 pod 'Alamofire', '~> 5.0' pod 'SwiftyJSON', '~> 4.0' pod 'SDWebImage', '~> 5.0' # 指定依赖库及其特定的配置 pod 'Analytics', '~> 3.0', :configurations => ['Debug'] # 指定依赖库及其特定的 subspec pod 'Firebase/Core', '~> 8.0' # 指定依赖库及其特定的版本号和 subspec pod 'GoogleMaps', '~> 4.0', :subspecs => ['Maps', 'Places'], :git => 'https://github.com/googlemaps/google-maps-ios.git' # 指定依赖库及其特定的来源 pod 'MyLibrary', :git => 'https://github.com/MyOrganization/MyLibrary.git', :branch => 'develop' end # 指定单个目标 target 'MyAppTests' do # 测试相关依赖 pod 'Quick' pod 'Nimble' end |
除了官方 podspec 仓库和 git 仓库,还可以从三方地址获取 spec 文件
1 | pod 'JSONKit', :podspec => 'https://example.com/JSONKit.podspec' |
然后运行 pod install
,或者已经安装过了运行 pod update
,清理缓存 pod cache clean --all
来更新依赖包。
cocoapods 会在本地创建一个 pods 项目,和一个 workspace 文件。
项目的依赖都用 Pods 项目来管理,如果 podfile 中指定了 use_frameworks!
,则编译时会将依赖的源码格式的 pod 编译成动态库放到结果 bundle 中,否则编译成静态库。
这里实操的时候,有两个要注意的点:
- 使用 cocoapods 管理项目后,需要打开 workspace 工程,而不能直接打开原项目的工程文件了, 因为 workspace 中配置好了依赖关系。
- 打开 workspace 之前需要关掉原 proj 的 xcode 窗口,不然 workspace 中无法访问原 proj。
从项目中卸载 cocoapods
在终端中进入项目目录:
- 运行 pod deintegrate 命令以将 CocoaPods 从项目中卸载。
- 删除项目目录中的 Podfile 和 Podfile.lock 文件。
- 如果在 xcode 中打开了 MyApp.xcworkspace,则关闭它。
- 删除 MyApp.xcworkspace 文件。
其它特殊特性
framework项目依赖的头文件必须是在库中
一个很不好理解,但是确实存在的一个问题。
A.framework 依赖 B.framework,A.framework 通过静态库的形式发布,B.framework 通过源码的形式发布。
此时编译时会报错:Include of non-modular header inside framework module
参考这个讨论:https://stackoverflow.com/questions/27776497/include-of-non-modular-header-inside-framework-module。
解决的办法有三种:
- 通过修改 xcode 的设置来解决,不推荐
- 将对 .h 文件的 import 放到 .m 文件中,可以解决部分场景(如前面所说有时候无法做到)
- 将 B.framework 也打包成模块发布,可以解决
future
framework 单测方法
通过单测保证模块质量。
如何通过 cli 编译项目,搭建流水线
高效构建发布版本。
本文链接:https://www.zoucz.com/blog/2023/02/26/8adbf330-b5b2-11ed-9fa0-5dbc93f9d3ee/