Cloud Music Swift Compilation Module Practice

Image from: unsplash.com
Author of this article: Glacier

background

After years of iterations, Cloud Music iOS App has accumulated a large amount of Objective-C (hereinafter referred to as OC) code, and has completed the shelling of the main project. The relationship between the components of each layer is as follows:

The scene of mixing after componentization mainly focuses on mixing within the Framework and between Frameworks. The cost of mixing within the Framework is relatively low, and the focus is mainly on the mixing between Frameworks.

The innovative business integrated in cloud music has been put into use because it relies on less historical basic libraries. The master station business has not been invested for a long time. The main reason is that a large number of OC business basic libraries and public basic libraries do not support Swift mixing. The prerequisite for OC component libraries to participate in mixing is to complete the Module.

The above are the stages of our mixed compilation plan. This article mainly introduces the analysis and practice of the Module stage in the process of supporting cloud music Swift mixing.

What are Modules

As early as 2012, Apple proposed the concept of Modules (before Swift was released). Module is an abstract description of components, including component interfaces and implementations. Its core purpose is to solve the scalability and stability problems of C-series languages.

The Cocoa framework has supported Module for a long time, and it is forward compatible. Because of its compatibility, pure Objective-C development may not have a strong perception of it.

AFramework.framework
├─ Headers
├─ Info.plist
├─ Modules
│    └─ module.modulemap
└─ AFramework
复制代码

Moduleized OC binary Framework components have a .modulemapformat , which describes the ability of the component to be exposed to the outside world. When the referenced component contains a modulemap, the Clang compiler will search for the header file, compile the Module, and cache the compilation result.

The Clang compiler requires that the Objective-C components referenced by Swift must support the Module attribute. We call the process of OC components supporting Module as Moduleization.

How to enable Modules

Xcode Project Target 支持在 「Building Settings -> Defines Module」设置 Module 开关。

如果使用 CocoaPods 组件集成,支持如下几种方式进行 Module 化:

  1. 在 Podfile 添加 use_modular_headers! 为所有 pod 开启 Module;
  2. 在 Podfile 为每个 pod 单独设置 :modular_headers => true
  3. 在 pod 的 podspec 文件中设置 s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
  4. 在 Podfile 使用 use_frameworks! :linkage => :static

前三种方式在编译产物是 .a 静态库时生效,如果使用了 use_framework!,源码编译产物是 Framework,默认就会包含 modulemap。

Module 化现状分析

云音乐工程使用 CocoaPods 集成依赖库,几乎所有库已经完成 Framework 静态化,而大部分静态库都是在未打开 Module 下的编译产物。

那么要让 OC 静态库支持 Module,直观的方案是,直接打开 Module 化开关,重新构建 Framework 静态库,让产物包含 modulemap。

然而直接打开开关,组件大概率会编译失败。原因主要有两点:

  1. 组件的 Module 具有依赖传递性,当前组件打开 Module 编译,要求它所有的依赖库,都已经完成 Module 化。在云音乐庞大的组件体系里面,即使理清其中的依赖关系,用自动化的方式自下而上构建,成功的可能性也极低。
  2. 历史代码存在不少引用方式不规范,宏定义「奇淫技巧」,以及 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_filesswift_version,pod install 时 Cocopods 会自动生成对应的 modulemap 文件。

第二种,采用 CocoaPods 插件,在 pre_install 阶段,设置pod_target.should_build,让 CocoPods 自动生成 modulemap。

方案二的成本相对较低,最终我们采用了方案二。

总结

Objective-C component library Moduleization is the basis for supporting Swift mixing. The core of Moduleization is to provide modulemap files. To generate modulemaps, components need to be compiled with Module enabled. Various unknown problems may be encountered during this process.

The problems encountered by Cloud Music in the governance process are relatively convergent. They mainly focus on changes in the module compilation method, resulting in the loss of some context information. Some problems can be solved through automated solutions, while some problems still require manual verification.

planning outlook

Module components are degraded. After the moduleization is completed, it is necessary to prevent further deterioration. We enable the module in the local source code development stage to expose problems as early as possible. Disallow public headers from implicitly relying on the PCH, and restrict new components from using the PCH.

Objective-C interface compatibility transformation. Converting the OC interface to Swift may have some security and usability problems, and even some APIs cannot realize automatic bridging, which needs to be modified.

Normalize header file references. It is also a relatively common example that the header file is not standardized, which leads to the failure of Module compilation. By verifying the header file reference method of the newly added code in the CI stage, non-standard code integration is avoided.

References:

clang.llvm.org/docs/Module…

llvm.org/devmtg/2012…

developer.apple.com/documentati…

developer.apple.com/documentati…

tech.meituan.com/2021/02/25/…

This article was published by the NetEase Cloud Music Technology Team. Any form of reprinting without authorization is prohibited. We recruit various technical positions all year round. If you are going to change jobs and you happen to like cloud music, then join us at grp.music-fe(at)corp.netease.com!

Guess you like

Origin juejin.im/post/7207269389474037817