iOS工程化「七」头文件-pch、module、hmap

1,020 阅读6分钟
头文件

image.png 创建一个项目,默认生成ViewController文件,里面有个#import,那么这个#import到底干了什么? 包含头文件的方式有两种:#import#include,都把.h文件直接拷贝一份到你引用的源码里面。 编译的时候,编译器会帮我们设置头文件,也就是header search path,里面是一组目录,这里就是项目要使用的头文件的目录。当import或者include的时候,会把后面的内容和一组目录拼接到一起组成完整路径,然后把完整路径的内容拷贝到当前.m文件里面。 区别: import:编译时保存一个字典,每次引入头文件的时候,都会先从字典里面看有没有,如果没有,从路径引入,如果有了就不会再引入了。 include:每次include就会拷贝一份,无论重复与否。 include = import + pragma once

源码是如何变成二进制的?

image.png 源码 -> 词法语法分析 -> 生成IR(Bitcode) -> MIR -> 汇编 -> 二进制(.o) clang:前端,分析词法语法 opt:进行path优化 llc:汇编化

image.png

image.png

头文件的拷贝就在source code -> Lexer中间的阶段,也就是预处理阶段。 xcode中,有个功能就是预处理:

image.png

点击就可以看到预处理就会把.h拷贝到.m中:

xcode的编译单元是.m,每个.m生成一个.o最后再链接一起生成可执行文件。

image.png

这里面有个问题,如果我们有100个文件都使用了ViewController,那么这100个文件都要#import "ViewController".h文件就要拷贝100次,要进行100次的词法语法分析。重复进行的100次操作就会占编译时间,那能不能只进行一次呢?

头文件演变最早是.h,后来发现多文件导入同一个头文件的话,操作会有些冗余,演变出来pch文件->预编译头文件。 pch的作用是先把.h文件生成ast语法树,用的时候把生成好的ast复制过去。 pch头文件预编译,会在编译产物目录生成一个.gch的文件,这个文件就是ast产物的文件,这个就是bitcode二进制文件。 bitcode有两种格式:字节流(二进制)格式和LLVM IR格式。

LLVM IR包含两种格式:Bitcode(.bc)Text Format(.ll)。 两种格式可以互相转换:

llvm-dis:.bc -> .ll
llvm-asm:.ll -> .bc

两种文件都是全量无损文件,可在此阶段进行优化。如果需要做优化,或者修改bitcode,或者编写pass,都需要熟悉IR。

pch文件

pch只是解决了生成到ast的流程,之后复制,生成bitcode生成汇编还是要做重复操作。它的可见性是全局的,不需要导入,你有多少文件,这些文件都会把pch引入进来,有些本身不需要的它也会引用,造成另一种浪费。它是全局可见性,不需要导入。

为了更好理解pch,我们创建个工程,添加PrefixHeader文件,要使这个文件生效,需要在Build Settings里面设置对应环境变量。搜索prefix

image.png

找到这两个环境变量名称,创建Config.xcconfig,设置对应环境变量:

GCC_PRECOMPILE_PREFIX_HEADER = YES
GCC_PREFIX_HEADER = "${SRCROOT}/pchTest/PrefixHeader.pch"

projectinfo中设置config,进行编译:

image.png

可以看到虽然提示build successed了,但是上面还是有个报错:

image.png

说是找不到这个文件,这个报错是因为pch这个参数的传递,不能使用SRCROOT,根本识别不出来,第二个是不能加双引号,修改如下:

GCC_PRECOMPILE_PREFIX_HEADER = YES
GCC_PREFIX_HEADER = pchTest/PrefixHeader.pch

验证可见性:在pch文件定义一个函数,这个函数就是全局函数:

void sj_test() {

}

如果在其他文件也定义这个函数,就会报错:

image.png

全局函数只有一个,酒泉不重新定义这个函数,多个文件都引入pch,编译的时候,也会报错,这个报错阶段是在链接阶段:

image.png

pch优点:提前生成ast的二进制,提升编译效率 pch缺点:可见性是全局可见,如果pch过大,会导致编译变慢

module

那能不能只有需要的才引入,不需要的不引入呢? module就可以实现这个功能,描述了一组预编译头文件,你哪需要就导入到哪,其他的也不会多拷贝。

module是怎么管理一组头文件的呢,首先关键字module声明一个模块,如果是framework模块可以在前面加framework

// 定义一个模块的名称Zoo
framework module Zoo {

}

在模块中可以接着定义子模块,模块中头文件用header声明:

module Zoo {
    module Sj1 {
        header "sj_1.h"
    }
    module Sj2 {
        header "sj_2.h"
    }
}

如果sj_1.h中引入了其他的module,如果想把使用的模块也暴露出来,使用export *进行导出。

module Sj1 {
   header "sj.h"
   export *
   module * { export * }
}

module * { export * }的作用是把sj.h里面所有引用的头文件,会把.h去掉,只取前面的名称,通过通配符给定义成子模块。

我们可以使用子模块定义,也可以用一个头文件进行映射,用一个头文件使用umbrella,使用一个头文件去映射多个头文件。

module Zoo {
    umbrella header "sj.h"
    export *
}

module导入头文件有个好处就是如果我们使用@import Zoo引入,里面的头文件你使用哪个才会引入哪个,如果没使用,它也不会导入。

优化要点:

  1. 将App代码尽量分散到组件中,减少pch使用。分散到组件中我们就可以使用module
  2. 尽量使用framework,会自动生成module
  3. .a开启module,需要我们自己手动配置
  4. 对遗漏的无法正常使用module映射的引入hmap

xcode是默认开启module功能,Build Setting里面有个Other C Flags编译C或者OC的时候,给clang传递参数使用;同理Other C++ Flags就是编译C++clang传递参数使用。

config文件中配置OTHER_CFLAGSOTHER_CFLAGS = -fmodule-map-file="${SRCROOT}/pchTest/sj/module.modulemap"。 这里面有个点,就是module文件需要在同一个文件夹中,否则会有问题。 配置完之后,就可以使用@import进行模块导入:

#import "ViewController.h"
@import Zoo;

@interface ViewController ()
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    sj_1 *s = [sj_1 new];
}

@end

现在有个问题,在ViewController中使用#import "sj_1.h"导入和在sj_1.m中使用#import "sj_1.h"导入是不是一样的呢? 使用预编译,可以发现在ViewController中使用的直接复制.h文件,而sj_1.m中使用module的方式引入。

在导入头文件的时候,实际上是循环搜索,第一次循环的流程如下:

  1. 编译文件同级目录去查找头文件信息,有没有和它同名的.h文件,有没有modulemap文件,如果有modulemap文件会找引入的头文件是不是模块里面的文件,如果是就会用module方式引入;如果不是就用它的同名.h文件,如果都没有,下一步
  2. 看是不是framework
  3. 查找headermap文件,看里面有没有这个路径,如果有就导入 如果循环没有,会根据header search paths去继续按上面步骤寻找。

预编译完在xcode中找到编译命令:

image.png

可以看到编译命令使用了hmap,挂载hmap有两种方式: -I:挂载system header和user header 都可以,习惯叫system -iquote:挂载user header

把整个编译命令复制到终端执行,打开产物目录,可以看到编译结果,如果把hmap相关命令删除,编译就会报错,因为找不到对应头文件。需要我们写HEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj",上面说了是循环搜索,所以我们要把搜索头文件路径写上去。重新编译,复制编译命令,删除hmap相关参数,在终端执行就会执行成功,此时看导出文件是以模块方式导入。证明了我们上面说的流程。

Realm

我们看下Realm是怎么写的。在podspec文件中s.module_map = 'Realm/Realm.modulemap'设置modulemap,找到对应文件。

可以看到,里面有explicit,这个参数的含义是显式定义子模块。为什么用通配符了还要用这个方式定义子模块,因为有的头文件可能没有放到散头文件中,导入主模块的时候并不会把私有模块引入进去。

Swift Module

创建一个SwiftStatic Library工程,修改里面代码进行编译:

import Foundation
@objc
open class SwitfLibraryModule: NSObject {

}

如果我们要把这个静态库提供给别人使用,首先要有SwitfLibraryModule-Swift文件,这个在中间产物目录。在Build->Products里面看到一个.a库和swiftmodule文件,我们也需要把swiftmodule文件提供出去。

现在我们想在OC中用module方式访问swiftLibrary,其实就是以module方式访问-Swift.h文件。

创建SwitfLibraryModule.modulemap文件:

module SwiftLibrary.Swift {
    header "SwitfLibraryModule-Swift.h"
    requires objc
}

requires指定编译的语言,就是OC可以使用Swift不能使用。

config中挂载modulemap-fmodule-map-file="${SRCROOT}/Product/SwitfLibraryModule.modulemap"

ViewController中导入@import SwiftLibrary.Swift;进行编译,发现报错说找不到这个modulemodule定义中的.是给主module定义子module。这种写法是定义子模块,但是主模块还没定义,所以我们添加代码:

module SwiftLibrary {
    
}

所以现在定义子module就有两种方式了。为什么这次我们放在外面定义子module-Swift.h是系统自动生成的。比如framework是自带module的,写在外面就方便系统给加上swift module,系统会自动把子module放到frameworkmodulemap里面。

现在再进行编译就会编译成功了。

那现在再创建一个swift文件,导入import SwiftLibrary,会发现报错,因为swift头文件是通过swiftmodule来使用的。所以要告诉sweift编译器去哪个地方找swiftmoduleSWIFT_INCLUDE_PATHS就是这个参数,在config中设置这个环境变量:SWIFT_INCLUDE_PATHS = "${SRCROOT}/Product"。再进行编译就会成功。

这里还有个注意点,就是我们写swift module文件的时候,主module的名字要和module文件名字一致,否则OC引用module没问题,swift引用module就会报错~!

我们在swift文件中定义一个OC的类:

@objc
class Brid: SwitfLibraryModule {

}

进行编译,发现链接时候报错。因为没有把库文件真身导入进来,把.a库直接拖到Frameworks里面:

image.png

再进行编译就可以成功了。 如果是swift库提供给OC使用,环境变量要声明OTHER_SWIFT_FLAGS = -Xclang -fmodule-map-file="${SRCROOT}/pchTest/sj/module.modulemap"

hmap

hmap实际上是hashmap,通过key找到头文件的完整路径。 hmap文件怎么查看,推荐工具:DunmHeaderMapWriteHeaderMap,下载链接:[hmap]github.com/Cat1237/hma…

我们找一个hmap文件测试一下:

image.png

通过json文件生成hmap,创建一个json文件,里面代码:

{
    "sj_1.h": [
        "sj/",
        "sj_1.h"
    ]
}

安装mapfile,在终端中执行:gem install cocoapods-mapfile,cd到json文件目录,生成hmap文件:hmapfile writer --json-path=./header.json

可以看到里面都是以key-value的形式存储头文件路径。 我们可以通过hmap进行module映射,来优化编译。hmap是由xcode生成,这个过程是耗时的,我们可以把这个过程节省下来,优化编译。

下面通过hmap来让头文件通过module方式导入:

  1. config中挂载我们生成的hmapHEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj/header.hmap"。预编译看下编译命令,挂载上了我们的hmap,但是预编译并不是以module方式导入

image.png

hmap相关编译命令如下:

-iquote /Users/shangjie/Library/Developer/Xcode/DerivedData/pchTest-dwkyfwjqgxhryaghltjrurbtppdk/Build/Intermediates.noindex/pchTest.build/Debug-iphonesimulator/pchTest.build/pchTest-generated-files.hmap 

-I/Users/shangjie/Library/Developer/Xcode/DerivedData/pchTest-dwkyfwjqgxhryaghltjrurbtppdk/Build/Intermediates.noindex/pchTest.build/Debug-iphonesimulator/pchTest.build/pchTest-own-target-headers.hmap 

-I/Users/shangjie/Library/Developer/Xcode/DerivedData/pchTest-dwkyfwjqgxhryaghltjrurbtppdk/Build/Intermediates.noindex/pchTest.build/Debug-iphonesimulator/pchTest.build/pchTest-all-target-headers.hmap 

-iquote /Users/shangjie/Library/Developer/Xcode/DerivedData/pchTest-dwkyfwjqgxhryaghltjrurbtppdk/Build/Intermediates.noindex/pchTest.build/Debug-iphonesimulator/pchTest.build/pchTest-project-headers.hmap 

-I/Users/shangjie/Library/Developer/Xcode/DerivedData/pchTest-dwkyfwjqgxhryaghltjrurbtppdk/Build/Products/Debug-iphonesimulator/include 

-I/Users/shangjie/Desktop/工程化/代码/pchTest_hmaptest/pchTest/sj/header.hmap

pchTest-generated-files.hmap 永远都是空的,暂时没用到
pchTest-own-target-headers.hmap 当前编译target所包含的头文件,这里面只能包含publicprivate的头文件
pchTest-all-target-headers.hmap 当前project包含所有target头文件,也是只有publicprivate的头文件
pchTest-project-headers.hmap 真正所有的project头文件

image.png

image.png

我们可以发现两个hmap都把sj_1.h映射成pchTest/sj_1.h,那生效的是哪个呢,生效的只有一个,就是上面的,编译是一个命令一个命令执行,谁在上面就用谁的。

  1. 关闭hmapUSE_HEADERMAP = NO 编译还是报错,因为我们hmap只是把头文件sj_1.h==>sj/sj_1.h,并没有映射到头文件的完整路径,系统是再通过一个hmap映射到完整路径。
  2. 再创建一个json文件:
{
    "sj/sj_1.h": [
        "/Users/shangjie/Desktop/工程化/代码/pchTest_hmaptest/pchTest/sj/",
        "sj_1.h"
    ]
}

生成hmap,在config挂载新的hmapHEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj/header.hmap" "${SRCROOT}/pchTest/sj/header_1.hmap",再编译就会成功了。

如果我这么写编译成功:

HEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj/header_1.hmap"
USER_HEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj/header.hmap"

这么写编译失败:

HEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj/header.hmap"
USER_HEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj/header_1.hmap"

说明编译器传参-iquote在前,-I在后。

  1. 使用module方式导入,就需要modulemap文件,设置HEADER_SEARCH_PATHS = "${SRCROOT}/pchTest/sj" "${SRCROOT}/pchTest/sj/header.hmap"即可!
vfs

vfs:虚拟文件系统,主要是给framework使用。 编译.m代码时候,会先去当前目录找头文件,但是App有个奇怪的点,我们把一个头文件设置成public,默认是project的。

image.png

产物会多出来头文件目录:

image.png

产物目录中会有一个all-product-headers.yaml的文件就是vfs文件,他做的虚拟文件映射,里面内容:

image.png

这个就是把包里面头文件的路径映射到工程里头文件路径,防止你导入的时候会重复定义。 如果把hmap关闭,那vfs文件就不会生成,需要我们自己手动生成。