图片来自:https://unsplash.com
本文作者:冰川
背景
云音乐 iOS App 经验多年的迭代,积攒了大量的 Objective-C(以下简称 OC)代码,目前曾经实现主工程壳化,各层组件关系如下:
组件化后混编的场景次要集中在 Framework 内混编和 Framework 之间混编,Framework 内的混编老本较低,重头次要在 Framework 间的混编。
在云音乐中集成的翻新业务,因为依赖的历史根底库较少,曾经投入使用 Swift。主站业务迟迟没有投入,次要起因是波及到大量的 OC 业务根底库和公共根底库不反对 Swift 混编,OC 组件库参加混编的前提是要实现 Module 化。
以上是咱们实现混编打算的几个阶段,本文次要介绍在反对云音乐 Swift 混编过程中,Module 化阶段的剖析与实际。
什么是 Modules
早在 2012 苹果就提出了 Modules 的概念(比 Swift 公布还要早),Module 是组件的形象形容,蕴含组件接口以及实现。它的外围目标是为了解决 C 系语言的扩展性和稳定性问题。
Cocoa 框架很早就反对了 Module,并且前向兼容,正因为它的兼容性,纯 Objective-C 开发对它的感知可能不强。
AFramework.framework
├─ Headers
├─ Info.plist
├─ Modules
│ └─ module.modulemap
└─ AFramework
Module 化的 OC 二进制 Framework 组件,在 Modules 目录下存在一个 .modulemap
格局的文件,它形容了组件对外裸露的能力。当援用的组件蕴含 modulemap,Clang 编译器会从中查找头文件,进行 Module 编译,并将编译后果缓存。
Clang 编译器要求 Swift 援用的 Objective-C 组件必须反对 Module 个性。咱们把 OC 组件反对 Module 的过程,称为 Module 化。
如何开启 Modules
Xcode Project Target 反对在「Building Settings -> Defines Module」设置 Module 开关。
如果应用 CocoaPods 组件集成,反对如下几种形式进行 Module 化:
- 在 Podfile 增加
use_modular_headers!
为所有 pod 开启 Module; - 在 Podfile 为每个 pod 独自设置
:modular_headers => true
; - 在 pod 的 podspec 文件中设置
s.pod_target_xcconfig = {'DEFINES_MODULE' => 'YES'}
; - 在 Podfile 应用
use_frameworks! :linkage => :static
。
前三种形式在编译产物是 .a
动态库时失效,如果应用了 use_framework!
,源码编译产物是 Framework,默认就会蕴含 modulemap。
Module 化现状剖析
云音乐工程应用 CocoaPods 集成依赖库,简直所有库曾经实现 Framework 动态化,而大部分动态库都是在未关上 Module 下的编译产物。
那么要让 OC 动态库反对 Module,直观的计划是,间接关上 Module 化开关,从新构建 Framework 动态库,让产物蕴含 modulemap。
然而间接关上开关,组件大概率会编译失败。起因次要有两点:
- 组件的 Module 具备依赖传递性,以后组件关上 Module 编译,要求它所有的依赖库,都曾经实现 Module 化。在云音乐宏大的组件体系外面,即便理清其中的依赖关系,用自动化的形式自下而上构建,胜利的可能性也极低。
- 历史代码存在不少援用形式不标准,宏定义「奇淫技巧」,以及 PCH 隐式依赖等问题,这些问题导致组件库自身无奈失常 Module 编译。
Module 化计划
目前云音乐的二进制组件次要分为三种类型:
- Module Framework
- 非 Module Framework
.a
动态库
Module Framework 是在 Defines Module 关上时的编译产物,这种类型没有革新老本,只须要在 CI 阶段,将不同架构的 Framework 封装成 XCFramework 压缩并上传到服务器。
对于非 Module Framework 咱们尝试了一种老本比拟低的计划,在组件库 Module 敞开的条件下,先将其编译成动态库,再用脚本自动化生成对应的 modulemap 文件,放到 Famework/Modules 目录。
被动塞 modulemap 的计划之所以可行和 Clang Module 的编译原理无关。当应用 #import <NMSetting/NMAppSetting.h>
援用依赖时, Clang 首先会去 NMSetting.framework 的 Header 目录下查找对应的头文件是否存在,而后在 Modules 目录下查找 modulemap 文件。
modulemap 中蕴含的 umbrella header 对应的是组件公开头文件的汇合。如果援用的头文件能找到,Clang 就会应用 Module 编译。
// NMSetting.framework/Modules/NMSetting.modulemap
framework module NMSetting {
umbrella header "NMSetting-umbrella.h"
export *
module * {export *}
}
Clang 并不关怀 modulemap 起源,只会依照固定的门路去查找它是否存在。所以采纳被动增加 modulemap 的形式,能达到「坑骗」编译器的目标。
这种形式的益处是,只有以后组件被援用时能失常 Module 编译即可,不须要思考它依赖组件的 Module 编译是否有问题。毛病是不彻底,假如动态库组件公开头文件,存在不合乎 Module 标准的状况,即便有 modulemap,编译时仍然会抛出谬误:
Could not build moudle 'xxx'.
对于未知的 Module 编译问题,只能拉对应的源码针对性的解决。
以下是咱们遇到的一些比拟典型的 Module 问题,以及对应的解决思路。
Module 化问题
宏定义找不到
在应用 OC 开发时,习惯于在 .h
文件定义一些宏,不便内部拜访,然而 Swift 不反对定义宏,在援用 OC 的宏定义时,会将其转为全局常量。不过转换能力比拟无限,仅反对根本的字面量值,以及根本运算符表达式。
例如:
#define MAX_RESOLUTION 1268
#define HALF_RESOLUTION (MAX_RESOLUTION / 2)
转换为:
let MAX_RESOLUTION = 1268
let IS_HIGH_RES = 634
宏定义的内容如果蕴含 OC 的语法实现,那么这个宏对 Swift 是不可见的。如果要反对 Swift 拜访,须要对宏进行包装。
// Constant.h
#define PIC_SIZE CGSizeMake(60, 60)
+ (CGSize)picSize;
// Constant.m
+ (CGSize)picSize {return PIC_SIZE;}
以上的宏问题还算比拟直观,在云音乐组件中,还存在一些应用 #include
预处理指令,来应用宏的场景。
C 系语言传统的 #include
援用是基于文本替换的形式实现的,利用这个个性可能屏蔽宏的实现细节。
// A.h
#define NM_DEFINES_KEY(key, des) FOUNDATION_EXTERN NSString *const key;
#include "ItemList.h"
#undef C
// ItemList.h
NM_DEFINES_KEY(AKey, @"a key")
NM_DEFINES_KEY(BKey, @"b key")
在非 Clang Module 下编译,上述代码可能失常工作,然而在关上 Module 之后,宏定义 NM_DEFINES_KEY
就找不到了。
这是因为 Module 编译时,#include
不再是简略的文本替换模式,而是与 module 建设链接关系。
上面是一个开启 Module 编译的例子,main.m 文件的预处理后果,共只有几行代码。
// main.m preprocess result.
#pragma clang module import UIKit /* clang -E: implicit import for #import <UIKit/UIKit.h> */
# 10 "/Users/jxf/Documents/Workspace/Demo/ModuleDemo/ModuleDemo/main.m" 2
int main(int argc, char * argv[]) {NSString * appDelegateClassName;}
如果未开启 Module,UIKit 的所有头文件都会被复制进来,代码量将达到数万行。
正因为这种差别,Module 编译时 #include "ItemList.h"
不会将内容复制到 A.h 文件,就会导致无法访问到它的宏定义。
Module 提供了相应的解决方案,就是自定义 modulemap。后面曾经介绍,默认状况下 modulemap 的格局为:
framework module FrameworkName {
umbrella header "FrameworkName-umbrella.h"
export *
module * {export *}
}
FrameworkName-umbrella.h 蕴含以后组件对外裸露的所有头文件,该文件会在应用 CocoaPods 集成时同步生成。咱们能够应用 textual header
要害申明头文件,这样该头文件在被导入时,会降级为文本替换的模式。
framework module FrameworkName {
umbrella header "FrameworkName-umbrella.h"
textual header "ItemList.h"
export *
module * {export *}
}
自定义 modulemap 还有一些额定的配置,须要本人生成组件公开的头文件汇合 umbrella.h,并在 podspec 指定该 modulemap,。
s.module_map = "#{s.name}.modulemap"
在咱们 CI 打包流程中,如果检测到组件自定义了 modulemap 就会应用自定义的文件,不再主动塞入模版化的 modulemap。
如果 ItemList.h 不须要对外裸露,还有一种更简略的计划,间接在 podspec 将其申明为公有,这样在动态库 Headers 目录下就不会导出,也就不会呈现 Module 编译问题。
头文件缺失
云音乐业务根底库默认会应用 PCH(Precompiled Headers)文件,它的益处次要有两点,一是能肯定水平上进步编译效率,二是为以后组件库提供对立内部依赖,这种依赖关系是隐式的,PCH 曾经增加的依赖,组件内应用时不须要再手动 import。
这种形式的确能提供便利性,随着业务的疾速迭代,大家也都适应了不引头文件的习惯,然而依附隐式依赖关系,为 Module 编译留下了隐患。
看个具体的例子:
// <B/NMEventModel.h>
#import <UIKit/UIKit.h>
@interface NMEventModel : NSObject
@property (nullable, nonatomic, strong) NMEvent *event;
@end
B 组件中的 NMEventModel
援用了 NMEvent
,它来自另一个组件库 A,A 曾经在 B.pch 中 import,所以在 B 组件源码编译时能通过隐式依赖找到 NMEvent
。
当 C 组件同时援用 A 组件和 B 组件的动态库时,因为 B 组件动态化后曾经没有 PCH,失常来说拜访 NMEventModel.h 应该编译报找不到 NMEvent
才对,而实际上在非 Module 编译时是不会有问题的。
// C/Header.h
#import <A/NMEvent.h>
#import <B/NMEventModel.h>
这是因为在非 Module 环境下 #import <A/NMEvent.h>
会把 NMEvent
的定义复制到以后文件,为 NMEventModel.h
编译提供了上下文环境。
然而当开启 Module 编译时,会报 B 组件是非 Module 的谬误(Module 依赖传递性),谬误起因是 NMEventModel.h 头文件找不到 NMEvent
类。
其实还是后面介绍的 Clang Module import 机制扭转的起因,开启 Module 后,会应用独立的上下文编译 B 组件的 NMEventModel.h,短少了 NMEvent
上下文。
要解决该场景下的问题,比拟粗犷的形式是,在 Module 编译上下文中注入它的 PCH 依赖。然而对于二进制组件来说,它曾经没有 PCH 了,如果显式地裸露 PCH,仅仅是为了头文件的 Module 编译,会导致依赖关系进一步好转。
咱们对这种状况做了针对性的治理,补充缺失的头文件依赖,历史库解决完一波后,默认都开启 Module 编译,如果开发过程中,使用不当编译器会及时反馈。对于新组件库减少 PCH 卡口限度。
.a 动态库
Module 化的要害是须要有 modulemap 文件,而历史的二方、三方库,有些是 .a
的动态库。
.a
文件只是可执行文件的汇合,不蕴含资源文件,针对这种状况须要应用 Framework 进行二次封装。
次要有两种计划:
第一种,在 .a
文件目录注入一个空的 .swift
文件,并在 podspec 指定 source_files
和 swift_version
,pod install 时 Cocopods 会主动生成对应的 modulemap 文件。
第二种,采纳 CocoaPods 插件,在 pre_install
阶段,设置pod_target.should_build
,让 CocoPods 主动生成 modulemap。
计划二的老本绝对较低,最终咱们采纳了计划二。
总结
Objective-C 组件库 Module 化是反对 Swift 混编的根底,Module 化的外围是提供 modulemap 文件,要生成 modulemap,组件需关上 Module 编译,这个过程中可能会遇到各种未知问题。
云音乐在治理过程中遇到的问题绝对比拟收敛,次要集中在 Module 编译形式的变动,导致一些上下文信息失落,一部分问题可能通过自动化的计划解决,而有些问题依然须要进行人工验证。
布局瞻望
Module 组件防劣化。 在 Module 化实现后,需避免再次劣化,咱们在本地源码开发阶段开启 Module,尽可能早的裸露问题。针对 PCH 禁止公开的头文件对它隐式依赖,并限度新组件应用 PCH。
Objective-C 接口兼容性革新。 OC 接口转成 Swift 可能会存在一些安全性和易用性问题,甚至有些 API 无奈实现主动桥接,都须要进行革新。
规范化头文件援用。 头文件不标准问题,导致 Module 编译生效,也是比拟常见的例子。通过在 CI 阶段对新增代码的头文件援用形式进行校验,防止不标准的代码合入。
参考资料:
https://clang.llvm.org/docs/Modules.html#id12
https://llvm.org/devmtg/2012-11/Gregor-Modules.pdf
https://developer.apple.com/documentation/swift/using-importe…
https://developer.apple.com/documentation/swift/importing-obj…
https://tech.meituan.com/2021/02/25/swift-objective-c.html
本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!