
1. 从一个编译报错说起PCH到底是个什么东西如果你在Xcode里写过稍微上点规模的C或Objective-C项目大概率见过一个叫Prefix.pch的文件或者在某次清理工程之后突然冒出一堆找不到Foundation头文件的报错。我第一次遇到这个文件的时候心里想的是这玩意儿既不是.h也不是.m删了好像也能编译留着又不知道在干嘛。后来项目越做越大编译时间从几十秒涨到十几分钟我才真正开始认真研究PCH这个东西。PCH的全称是Precompiled Header中文叫预编译头文件。它的核心思路非常朴素把那些每个源文件都要包含、但几乎从不改动的头文件比如系统库、第三方库的公共头提前编译成一份二进制中间产物缓存起来。之后每次编译源文件时编译器直接读取这份缓存而不是重新解析一遍那些头文件的文本内容。这个机制解决的是一个非常现实的痛点。在一个典型的C工程里一个源文件可能间接包含几百甚至上千个头文件而每个头文件里又有大量的宏定义、模板、类型声明。编译器每次编译一个.cpp文件都要把这些内容从头到尾解析一遍。如果工程里有500个源文件那同一批头文件就被解析了500次。PCH做的事情就是解析一次缓存起来后面499次直接复用。适合关注这个话题的人其实很明确一是正在被编译速度折磨的C/Objective-C开发者二是刚接触Xcode、看到Build Setting里一堆选项不知道从哪下手的iOS新手三是在做跨平台项目、需要在不同构建系统之间迁移的工程师。不管你用的是Xcode、CMake还是手写MakefilePCH的思路都是通用的只是具体配置方式不同。接下来我会从原理、配置、实操、踩坑几个维度把PCH这件事彻底讲清楚。不是照本宣科翻译文档而是把我自己配PCH、调编译时间、处理各种诡异报错的经验完整地分享出来。2. PCH的工作原理与核心价值拆解2.1 编译器到底在重复做什么要理解PCH的价值得先知道编译器编译一个源文件时经历了什么。简单来说分几个阶段预处理、词法分析、语法分析、语义分析、代码生成、优化、链接。其中预处理阶段就是把#include的内容原地展开把所有宏替换掉。一个中等规模的C文件预处理之后的代码量可能是原始文件的几万倍。问题就出在这里。假设你的项目里有个Common.h里面包含了vector、string、map这些标准库头还有你自己写的一些工具类声明。这个Common.h被200个源文件包含。那么编译器就要把vector那几千行代码解析200遍。标准库头文件里还有大量的模板和SFINAE技巧解析起来相当耗时。我实测过一个项目单个源文件编译时间大约1.2秒其中光是解析标准库头文件就占了0.7秒左右。200个文件就是140秒纯粹浪费在重复解析上。PCH把这个过程压缩成一次后续每个文件只需要读取预编译好的二进制缓存解析时间可以降到接近零。2.2 PCH的缓存机制与失效条件PCH本质上是一个序列化后的编译器内部状态。编译器在预编译阶段把头文件解析完把符号表、宏定义、类型信息等内部数据结构序列化到磁盘上。后续编译时编译器直接反序列化这个状态跳过预处理和部分语法分析阶段。但这里有个关键点PCH的失效条件非常敏感。只要以下任何一项发生变化PCH就会失效编译器会重新生成PCH文件本身的内容变了哪怕只是加了个空行编译PCH时使用的编译器选项变了比如优化级别、宏定义、架构编译器版本变了依赖的头文件内容变了这意味着一件事PCH只适合放那些极其稳定的头文件。如果你把经常改动的业务头文件放进PCH那每次改动都会触发全量重新编译反而比不用PCH更慢。这是很多人用错PCH的第一个坑。2.3 为什么Xcode默认给你一个Prefix.pchXcode新建Objective-C项目时会自动生成一个项目名-Prefix.pch里面通常包含Availability.h、Foundation/Foundation.h、UIKit/UIKit.h这些。原因是iOS开发里几乎每个.m文件都要用到Foundation和UIKit把它们放进PCH能显著减少重复解析。但注意Xcode从某个版本开始新建Swift项目不再生成PCH了因为Swift有自己的模块系统Module不需要PCH这种机制。Swift的import本身就是基于预编译模块的比PCH更先进。所以如果你在做纯Swift项目PCH跟你没关系。但只要你项目里还有Objective-C或C代码PCH就仍然有意义。2.4 PCH与Module的区别和取舍这里必须提一下Clang的Module机制因为很多人会混淆。Module是把一个库的头文件集合打包成一个预编译模块用import Foundation;或者import Foundation来引用。它比PCH更细粒度可以按需加载而且不会像PCH那样全有或全无。那为什么还用PCH因为PCH配置简单一个文件搞定适合快速优化。而Module需要库本身支持要有module.modulemap对于老项目或者第三方库不一定能用。我的经验是新项目优先用Module老项目用PCH快速见效两者可以共存。对比维度PCHModule配置复杂度低一个文件加一个Build Setting中需要modulemap粒度全局所有文件共享按模块可单独加载失效影响全量重编译只影响依赖该模块的文件适用场景老项目快速优化新项目、库开发对第三方库支持只要头文件就能用需要库提供modulemap3. Xcode中PCH的完整配置实操3.1 创建PCH文件并接入工程第一步在Xcode里File New File选择Other分类下的PCH File命名建议用项目名-Prefix.pch跟Xcode默认风格保持一致。第二步打开项目的Build Settings搜索Prefix Header。找到Apple Clang - Language下面的Prefix Header选项填入PCH文件的路径。这里有个坑路径必须是相对于$(SRCROOT)的路径或者用$(SRCROOT)开头的绝对路径。比如你的PCH在项目根目录就填$(SRCROOT)/项目名-Prefix.pch。如果PCH在子目录里比如Support/下就填$(SRCROOT)/Support/项目名-Prefix.pch。第三步确保Precompile Prefix Header设置为YES。这个选项默认就是YES但如果你之前手动改过记得检查一下。设为YES时Xcode会生成.pch.gch或.pch.pch缓存文件设为NO时PCH文件会被当作普通头文件在每个源文件里展开那就完全失去意义了。3.2 PCH文件里该放什么、不该放什么这是最考验经验的部分。我的原则是只放那些全局稳定、高频使用、无副作用的头文件。适合放的系统框架的聚合头比如Foundation/Foundation.h、UIKit/UIKit.h标准库头比如vector、string、memory全局宏定义比如调试开关、日志宏全局常量声明注意是声明不是定义不适合放的任何业务逻辑头文件因为它们会频繁改动会引入命名空间污染的头文件有静态变量定义的头文件会导致重复定义依赖具体编译条件的头文件我见过有人在PCH里#import了自家项目的Model.h结果每次改数据模型都要全量重编译编译时间反而更长了。这就是典型的用错场景。3.3 用vim快速查看和编辑PCH虽然Xcode的编辑器很好用但有时候在终端里用vim改PCH更快尤其是你只想加一行宏定义的时候。我的习惯是# 进入项目根目录 cd /path/to/project # 用vim打开PCH vim MyProject-Prefix.pch在vim里PCH文件的内容结构通常是这样的#ifdef __OBJC__ #import Foundation/Foundation.h #import UIKit/UIKit.h #endif // 全局宏 #define DEBUG_LOG(...) NSLog(__VA_ARGS__)注意#ifdef __OBJC__这个包裹它的作用是让这些Objective-C的头文件只在编译.m或.mm文件时生效编译纯C或C文件时跳过。如果你的项目里混编了C这个包裹非常重要否则C文件会报找不到Objective-C头文件的错。用vim改完之后记得在Xcode里Product Clean Build Folder快捷键ShiftCmdK然后重新编译让PCH缓存重新生成。3.4 验证PCH是否真正生效配置完之后怎么确认PCH真的起作用了有几个方法。第一个方法看编译日志。在Xcode的Report Navigator里找到编译PCH的那一步应该能看到类似Precompile MyProject-Prefix.pch的记录。如果没看到说明配置没生效。第二个方法看DerivedData目录。PCH的缓存文件通常在~/Library/Developer/Xcode/DerivedData/项目名-xxx/Build/Intermediates.noindex/PrecompiledHeaders/下面文件名类似MyProject-Prefix.pch.gch。如果这个文件存在且时间戳是最近的说明PCH在工作。第三个方法最直接改一下PCH文件加个空行重新编译观察是不是触发了全量重编译。如果是说明PCH确实被所有文件依赖着。4. 编译时间优化的实测数据与调优策略4.1 一个真实项目的优化前后对比我拿一个中等规模的iOS项目做过实测。项目情况约320个源文件其中Objective-C约200个C约120个混编。优化前全量编译时间约8分40秒。配置PCH之后把Foundation、UIKit、以及几个稳定的第三方库头文件放进去全量编译时间降到约5分20秒节省了约38%。增量编译改一个业务文件的时间从约12秒降到约8秒。但这里有个反直觉的点PCH对增量编译的收益其实有限。因为增量编译本来就只编译改动的文件PCH省下的那点解析时间占比不大。PCH最大的价值在全量编译和CI构建上。如果你的CI每次都要全量编译PCH能省下大量时间。4.2 什么情况下PCH反而拖慢编译有三种情况PCH会帮倒忙。第一种是PCH文件本身频繁改动。每次改动都会触发全量重编译如果一天改十次PCH那还不如不用。第二种是PCH里放了太多东西。PCH越大生成缓存的时间越长而且缓存文件本身可能几十MB读写也有开销。我建议PCH控制在合理范围内不要什么都往里塞。第三种是项目源文件很少。如果只有十几个文件PCH的生成开销可能超过它节省的时间。一般来说源文件超过50个PCH才开始有明显收益。4.3 配合其他优化手段的组合拳PCH不是万能的它只是编译优化的一环。我通常会把PCH和以下手段配合使用开启Module在Build Settings里把Enable Modules (C and Objective-C)设为YES让系统框架走Module而不是PCH使用ccache在CI环境里用ccache缓存编译产物跨构建复用减少头文件依赖用前向声明forward declaration替代#import从根源上减少头文件解析量拆分大文件把几千行的巨型源文件拆成多个小文件让增量编译更高效这几招组合下来我把那个项目的CI构建时间从8分多压到了3分半左右。PCH贡献了其中大约三分之一。5. 常见问题与排查技巧实录5.1 PCH配置后报file not found这是最常见的问题九成是路径写错了。排查步骤检查Prefix Header的值是不是$(SRCROOT)/开头确认PCH文件真的在那个路径下用ls命令验证检查路径里有没有多余的空格或换行如果PCH在group里但不在磁盘对应位置Xcode的路径可能和你想的不一样我踩过的一个坑是PCH文件在Xcode的导航器里显示在根目录但实际磁盘上在子文件夹里。Xcode的group是逻辑分组不一定对应磁盘结构。一定要以磁盘实际路径为准。5.2 报duplicate symbol重复定义错误这个通常是因为PCH里放了变量定义而不是声明。比如你写了NSString *const kAppName MyApp;这个放在PCH里会被每个源文件都定义一次链接时就冲突了。正确做法是在PCH里只放extern NSString *const kAppName;然后在某一个.m文件里定义。或者用static限定但那样每个文件都会有一份副本浪费内存。5.3 混编项目里C文件报Objective-C头文件找不到这就是前面提到的#ifdef __OBJC__包裹的问题。如果你的PCH里有Objective-C的头文件但没有用#ifdef __OBJC__包起来编译.cpp文件时就会报错。正确的PCH结构应该是#ifdef __OBJC__ #import Foundation/Foundation.h #import UIKit/UIKit.h #endif // C/C通用的内容放在外面 #include vector #include string5.4 PCH缓存不更新导致改了宏没生效有时候你改了PCH里的宏定义但编译结果没变化。这是因为Xcode的增量编译没有检测到PCH变化。解决办法是手动Clean Build Folder或者删除DerivedData目录。我养成的习惯是每次改PCH之后第一件事就是ShiftCmdK清理然后再编译。虽然多花几秒但能避免很多为什么改了没用的困惑。5.5 常见问题速查表问题现象可能原因解决方法file not found路径错误用$(SRCROOT)开头验证磁盘路径duplicate symbolPCH里有变量定义改为extern声明C文件报ObjC头找不到缺少#ifdefOBJC用条件编译包裹改宏不生效缓存未更新Clean Build Folder编译反而变慢PCH内容频繁改动移出易变头文件PCH不生成Precompile设为NO改为YES5.6 几个容易被忽略的细节第一个细节PCH的路径在Xcode不同版本里可能表现不同。Xcode 14.2里Prefix Header选项的位置在Apple Clang - Language分组下但更早的版本可能在别的地方。如果找不到直接在Build Settings搜索框里搜prefix就能定位。第二个细节如果你用CocoaPods管理依赖Pods工程有自己的PCH。你改主工程的PCH不会影响Pods。如果想让Pods的头文件也进PCH需要在Podfile里配置post_install钩子或者手动在PCH里import。第三个细节用xcodebuild命令行构建时PCH的行为和Xcode GUI里一致但日志输出更详细。排查PCH问题时用xcodebuild -project xxx.xcodeproj -scheme xxx build能看到完整的预编译过程。6. 跨构建系统的PCH实践6.1 CMake里怎么配PCHCMake从3.16版本开始原生支持PCH用target_precompile_headers命令target_precompile_headers(MyTarget PRIVATE vector string memory Common.h )这个命令会自动处理PCH的生成和引用比手动配置省心很多。注意PRIVATE表示只对这个target生效如果多个target共享可以用PUBLIC或INTERFACE。CMake的PCH实现和Xcode略有不同它会在编译目录下生成.gch文件并在编译每个源文件时自动加上-include-pch参数。实测下来CMake的PCH在增量编译时的表现比Xcode更稳定因为它对失效条件的判断更精确。6.2 手写Makefile里的PCH如果不用CMake也不用Xcode手写Makefile的话PCH需要两步# 第一步生成PCH $(PCH_DIR)/prefix.h.gch: prefix.h $(CXX) $(CXXFLAGS) -x c-header $ -o $ # 第二步编译源文件时引用PCH %.o: %.cpp $(PCH_DIR)/prefix.h.gch $(CXX) $(CXXFLAGS) -include-pch $(PCH_DIR)/prefix.h.gch -c $ -o $关键点是-x c-header告诉编译器把输入当头文件处理-include-pch指定使用哪个PCH。注意PCH必须和编译源文件时用的编译器选项完全一致否则会报PCH file was compiled with different options。6.3 不同构建系统的PCH对比构建系统配置方式失效判断增量表现XcodeBuild Setting较宽松一般CMaketarget_precompile_headers精确好Makefile手动规则依赖Makefile取决于写法Bazelcc_library的pch属性精确好我的建议是能用CMake就用CMake它的PCH支持最省心。Xcode项目如果不想迁移就手动配。手写Makefile的话除非有特殊需求否则不建议自己造轮子。7. 我个人的PCH使用心得用了这么多年PCH我最大的体会是PCH是一把双刃剑用对了省时间用错了浪费时间。它不是什么高深技术但配置细节和适用场景的把握很考验经验。我现在维护的项目里PCH只放三样东西Foundation、UIKit、以及一个全局的日志宏。其他一概不放。业务头文件全部用前向声明和Module来处理。这样配置下来PCH文件半年都不用改一次缓存永远有效编译时间稳定。另外一个小技巧如果你的项目同时有Debug和Release配置可以给两个配置用不同的PCH。Debug配置的PCH里可以放一些调试用的宏和工具Release配置里去掉这样Release构建能更干净。在Xcode里通过Prefix Header的配置变量可以实现比如$(SRCROOT)/Prefix-$(CONFIGURATION).pch。最后说一个我踩过的大坑有一次我把PCH文件加到了git的.gitignore里结果团队里新来的同事拉代码后编译一直报错排查了半天才发现是PCH文件没被提交。PCH文件本身是源码的一部分必须提交到版本控制只有生成的.gch缓存文件才应该被忽略。这个教训让我在.gitignore里专门加了一行*.pch.gch来明确区分。