前段时间做了一些 iOS framework 开发的工作,总结一些作为上手资料的经验吧。

Libraries简介

是什么

官方frameworks、官方系统库、 三方frameworks、三方.a库、三方 workspace。
framework就是库文件 + 头文件 + 资源文件。

如何使用

引入方式、embed 的含义。
image.png

image.png

最后一列 embed 的含义是,是否将库嵌入到目标 bundle 中。

framework 项目 与 embed

对于一个 framework 项目,比如一个 framework 依赖其它的 framework。如果将静态库 framework 设置为 embed,xcode 会将 framework 当做动态库处理,将被依赖的静态库放到 bundle 的 frameworks 目录下。
image.png
如果设置为 not embed,则不会将三方依赖库放到输出包的 Framework 中。

无论用哪种方式,framework 在被其它项目引入时,仍然需要引入这个 framework 依赖的三方库。

app 项目 与 embed

对于一个 app,由于静态库在编译的过程中打包到应用可执行程序文件中了,而动态库是运行时链接。所以如果是引入动态库 framework,则需要设置为 embed,静态库则选择 not embed,否则会报错。

将动态库设置成 not embed 时,编译会成功,但是动态库不会被打包到应用 bundle 中,导致 app 运行时动态链接失败而报错:
image.png

将静态库设置成动态库,则应用编译可以成功,但是 iOS 在运行时 app 和动态库是都需要签名的,编译成功的应用在往设备上安装时会因库签名失败而报错,因为实际上库是一个静态库。

image.png

framework 开发

创建项目

image.png

可以创建 framework 项目或者 static library 项目,前者构建目标是 framework 库,而后者是 .a 库。

查找路径设置

编译的过程中,xcode 可能需要查找项目依赖的 framework、library、头文件,可以在 build settings 中搜索 search paths 来配置

image.png

配置路径时可以用一些环境变量,如项目根目录 $(PROJECT_DIR)

对外暴露 .h 文件

这是一个 framework 的编译结果:
image.png

可以看到库文件 FrameworkDev,还有一个头文件目录 Headers。
默认情况下,代码中的头文件都不会包含到 Headers 目录中,如果外部项目依赖库中的头文件,则会因找不到头文件而无法编译。

暴露模块自身的头文件

image.png

选择头文件,将 target membership 选择为构建目标,并设置为 public,这样构建完毕后,这些头文件就会被包含到 Headers 目录中

暴露依赖模块的头文件

image.png

点加号,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 声明的类是没办法使用的。 我曾经折腾了一阵子,仍然没找到啥好的办法,只好把头文件暴露。

如何查看编译日志

image.png

xcode 中可以很方便的查看编译日志,如图所示,xcode将近期的每次编译记录都保存下来了,可以根据时间或者构建目标分组来查看。

右侧的记录,将编译过程中 xcode 做的每一件事情都按顺序记录下来了,排查问题时非常方便。

静态库 framework 和 动态库 framework

xcode 设置编译目标类型

在 xcode 的 build settings 的 linking 设置中,修改 Mach-O Type 类型,可以设置编译类型是动态库还是静态库。

image.png

通过 file 查看 framework 中的库二进制文件信息可以判断类型。

image.png

image.png

静态库 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 的编译日志,发现一个地方很容易让人误解

image.png

可以发现创建静态库的过程是用的 libtool 工具,里边有个 LinkFileList 的参数指定,实际上这个参数指定是没有任何作用的,因为压根不会有链接过程。把日志里的命令复制出来,LinkFileList 参数修改掉随便指定个任意值,仍然可以成功执行。

用 nm 查看生成的静态库文件中的符号表,前面的 ‘U’ 代表找不到相关符号,也能说明并没有把依赖的三方静态库打包进去。

image.png

在应用中使用,如果不引入 framework 依赖的其它库,则会报错找不到符号。

image.png

动态库(或可执行程序)编译过程

  • 预处理(Preprocessing):将源代码文件中的预处理指令(如 #include、#define 等)替换为实际的代码。这个步骤可以使用预处理器(Preprocessor)完成。
  • 编译(Compiling):将预处理后的源代码文件编译成目标文件(Object file),目标文件包含了编译后的机器码(Machine code)和一些元数据(Metadata)。这个步骤可以使用编译器(Compiler)完成。
  • 汇编(Assembling):将编译后的目标文件转换成机器码指令的二进制表示形式。这个步骤可以使用汇编器(Assembler)完成。
  • 链接(Linking):将编译和汇编后的目标文件链接成最终的动态库(Framework)。在链接过程中,会解决符号依赖关系、地址重定向等问题。这个步骤可以使用链接器(Linker)完成。
  • 代码签名(Code signing):如果启用了代码签名功能,编译后的动态库将会被签名,以保证代码的完整性和安全性。这个步骤可以使用代码签名工具完成

查看 xcode 的编译日志

image.png

查看符号表,编译动态库时,链接是会生效的。

image.png

静态库、动态库交叉依赖的场景

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;

测试代码:点击访问

设置编译后置脚本

image.png

可以用编译后置脚本做一些事情,比如:

静态库合并

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Pod::Spec.new do |s|
s.name = 'SGPlayer'
s.version = '2.0.1'
s.summary = 'SGPlayer'
s.ios.framework = ['AVFoundation', 'AudioToolbox', 'VideoToolbox']
s.library = 'iconv', 'bz2', 'z'
s.homepage = 'https://github.com/libobjc/SGPlayer'
# s.screenshots = ''
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = 'czzou@tencent.com'
s.source = { :http => 'https://xxxxx/SGPlayer/2.0.1/SGPlayer.framework.zip' }
# s.social_media_url = ''

s.ios.deployment_target = '11.0'
s.static_framework = true
s.vendored_frameworks = 'SGPlayer.framework'

s.public_header_files = 'SGPlayer.framework/Headers/*.h'

end

这个 podspec 文件中,指定了模块的版本、依赖的系统Framework、系统lib、文件地址 source、头文件路径等信息。

source 属性可以指定一个 git 源码仓库地址、一个本地源码路径,或者一个可下载的代码或模块 zip 文件的url,三方开发者在使用时,cocoapods可以处理这几个类型的模块数据。

1
2
3
4
5
6
7
8
9
10
11
12
s.source = {
:git => 'https://github.com/username/repo.git',
:tag => '1.0.0'
}
s.source = {
:path => '~/Documents/MyProject'
}
s.source = {
:http => 'https://example.com/myproject.tar.gz',
# 可选,用于完整性校验
:sha256 => '4bfe565adceec28a74e9c71a9a0f0a8e2cbbe4c14a4f4d45c96ad54a98e9d7bb'
}

使用 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 管理依赖库

首先在操作系统中安装 cocoapods

1
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 来更新依赖包。

image.png

cocoapods 会在本地创建一个 pods 项目,和一个 workspace 文件。

项目的依赖都用 Pods 项目来管理,如果 podfile 中指定了 use_frameworks!,则编译时会将依赖的源码格式的 pod 编译成动态库放到结果 bundle 中,否则编译成静态库。

这里实操的时候,有两个要注意的点:

  1. 使用 cocoapods 管理项目后,需要打开 workspace 工程,而不能直接打开原项目的工程文件了, 因为 workspace 中配置好了依赖关系。
  2. 打开 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 编译项目,搭建流水线

高效构建发布版本。

☞ 参与评论