图片来自:unsplash.com
本文作者:冰川

布景

云音乐 iOS App 阅历多年的迭代,积累了很多的 Objective-C(以下简称 OC) 代码,目前现已完结主工程壳化,各层组件联系如下:

云音乐 Swift 混编 Module 化实践

组件化后混编的场景首要会集在 Framework 内混编和 Framework 之间混编,Framework 内的混编本钱较低,重头首要在 Framework 间的混编。

在云音乐中集成的立异事务,由于依托的前史根底库较少,现已投入运用 Swift。主站事务迟迟没有投入,首要原因是涉及到很多的 OC 事务根底库和公共根底库不支撑 Swift 混编,OC 组件库参加混编的条件是要完结 Module 化。

云音乐 Swift 混编 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 编译,并将编译成果缓存。

云音乐 Swift 混编 Module 化实践

Clang 编译器要求 Swift 引证的 Objective-C 组件必须支撑 Module 特性。咱们把 OC 组件支撑 Module 的过程,称为 Module 化。

如何敞开 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 目录。

云音乐 Swift 混编 Module 化实践

主动塞 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 组件库 Module 化是支撑 Swift 混编的根底,Module 化的核心是供给 modulemap 文件,要生成 modulemap,组件需翻开 Module 编译,这个过程中可能会遇到各种不知道问题。

云音乐在管理过程中遇到的问题相对比较收敛,首要会集在 Module 编译方法的变化,导致一些上下文信息丢失,一部分问题能够经过主动化的计划处理,而有些问题依然需求进行人工验证。

规划展望

Module 组件防劣化。 在 Module 化完结后,需防止再次劣化,咱们在本地源码开发阶段敞开 Module,尽可能早的露出问题。针对 PCH 制止揭露的头文件对它隐式依托,并约束新组件运用 PCH。

Objective-C 接口兼容性改造。 OC 接口转成 Swift 可能会存在一些安全性和易用性问题,乃至有些 API 无法完成主动桥接,都需求进行改造。

标准化头文件引证。 头文件不标准问题,导致 Module 编译失效,也是比较常见的比如。经过在 CI 阶段对新增代码的头文件引证方法进行校验,防止不标准的代码合入。

参考资料:

clang.llvm.org/docs/Module…

llvm.org/devmtg/2012…

developer.apple.com/documentati…

developer.apple.com/documentati…

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

本文发布自网易云音乐技能团队,文章未经授权制止任何形式的转载。咱们终年接收各类技能岗位,假如你准备换作业,又恰好喜欢云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!