클라우드 뮤직 스위프트 컴파일 모듈 실습

이미지 출처: unsplash.com
이 기사 작성자: Glacier

배경

Cloud Music iOS App은 수년간의 반복 끝에 많은 양의 Objective-C(이하 OC) 코드를 축적하고 메인 프로젝트의 쉘링을 완료했습니다.각 레이어의 구성 요소 간의 관계는 다음과 같습니다.

구성 요소화 후 혼합 장면은 주로 프레임워크 내 혼합과 프레임워크 간 혼합에 중점을 둡니다.프레임워크 내 혼합 비용은 상대적으로 낮고 주요 초점은 프레임워크 간 혼합입니다.

클라우드 음악에 통합된 혁신적인 비즈니스는 덜 역사적인 기본 라이브러리에 의존하기 때문에 사용되었습니다. 마스터 스테이션 사업은 오랫동안 투자되지 않았습니다.주된 이유는 많은 OC 비즈니스 기본 라이브러리와 공용 기본 라이브러리가 Swift 믹싱을 지원하지 않기 때문입니다.OC 구성 요소 라이브러리가 믹싱에 참여하기 위한 전제 조건은 기준 치수.

위의 내용은 당사의 믹싱 컴필레이션 계획 단계이며, 본 글은 주로 클라우드 음악 Swift 믹싱 지원 과정에서 Module 단계의 분석 및 실습을 소개합니다.

모듈이란?

Apple은 2012년 초에 Swift가 출시되기 전에 모듈 개념을 제안했습니다.모듈은 구성 요소 인터페이스 및 구현을 포함한 구성 요소에 대한 추상적인 설명입니다. 핵심 목적은 C 시리즈 언어의 확장성 및 안정성 문제를 해결하는 것입니다.

Cocoa 프레임워크는 Module을 오랫동안 지원해 왔으며 앞으로 호환됩니다. 호환성 때문에 순수한 Objective-C 개발에서는 이에 대한 인식이 강하지 않을 수 있습니다.

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

모듈화된 OC 바이너리 프레임워크 구성 요소는 Modules 디렉토리에 .modulemap형식 , 이는 구성 요소가 외부 세계에 노출되는 기능을 설명합니다. 참조된 구성요소에 modulemap이 포함된 경우 Clang 컴파일러는 헤더 파일을 검색하고 모듈을 컴파일하며 컴파일 결과를 캐시합니다.

Clang 컴파일러는 Swift가 참조하는 Objective-C 구성요소가 Module 특성을 지원해야 한다고 요구합니다. Module을 지원하는 OC 구성 요소의 프로세스를 Moduleization이라고 합니다.

모듈 활성화 방법

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 컴포넌트 라이브러리 모듈화는 스위프트 믹싱을 지원하기 위한 기반입니다.

거버넌스 프로세스에서 Cloud Music이 직면한 문제는 상대적으로 수렴적이며 주로 모듈 편집 방법의 변경에 중점을 두어 일부 컨텍스트 정보가 손실됩니다.일부 문제는 자동화된 솔루션을 통해 해결할 수 있지만 일부 문제는 여전히 수동 확인이 필요합니다. .

계획 전망

모듈 구성 요소의 성능이 저하되었습니다. 모듈화 완료 후 추가 열화 방지가 필요하며, 로컬 소스 코드 개발 단계의 모듈을 최대한 조기에 노출할 수 있도록 합니다. 공용 헤더가 암시적으로 PCH에 의존하는 것을 허용하지 않고 새 구성 요소가 PCH를 사용하지 못하도록 제한합니다.

Objective-C 인터페이스 호환성 변환. OC 인터페이스를 Swift로 변환하면 보안 및 사용성 문제가 발생할 수 있으며 일부 API도 자동 브리징을 구현하지 못하므로 수정해야 합니다.

헤더 파일 참조를 정규화합니다. 또한 헤더 파일이 표준화되지 않아 모듈 컴파일 실패로 이어지는 비교적 일반적인 예입니다. CI 단계에서 새로 추가된 코드의 헤더 파일 참조 방식을 검증하여 비표준 코드 통합을 방지합니다.

참조:

clang.llvm.org/docs/Module…

llvm.org/devmtg/2012…

developer.apple.com/documentati…

developer.apple.com/documentati…

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

이 기사는 NetEase 클라우드 음악 기술 팀에서 게시했으며 승인 없이 모든 형태의 전재를 금지합니다. 연중무휴로 다양한 기술직을 모집하고 있습니다. 이직을 준비 중이시거나 클라우드 음악을 좋아하신다면 grp.music-fe(at)corp.netease.com에서 함께하세요!

Supongo que te gusta

Origin juejin.im/post/7207269389474037817
Recomendado
Clasificación