先说个结论,免得你翻了半天不知道值不值得看:这个报错最后拆开,其实是三个不相干的坑叠在了一起。老版 gtest 在 Xcode 26 的 libc++ 里找不到<tr1/tuple>,CMake 缓存把libgtest.a编成了 x86_64,外加误把带main()的libgtest_main.a链接进了 UE 模块。如果你也在 UE5.6 + Xcode26 上做插件开发,想给插件的纯 C++ 逻辑补上 gtest 单测,这篇记录应该能让你少折腾一整个下午。
我的项目背景不复杂:一个资源批处理插件,负责导入检查、缩略图生成、命名规范校验这些事。逻辑层刻意不依赖 UE 的反射和 UObject,方便在纯 C++ 环境下做单元测试。团队里已经有几套 gtest 用例了,所以插件这边也顺手选了 gtest。但 UE5.6 在 macOS 上的默认工具链就是 Xcode 26,编译器一更新,老版本 gtest 的兼容问题马上就暴露出来了。下面按我实际踩坑的顺序写,每一步的报错、排查、修复都能直接复现。
1. 为什么偏偏 UE5.6 + Xcode 26 + gtest 这个组合容易翻车
1.1 需求背景与集成方式选型
我的插件模块分成两层:引擎相关部分放在Editor模块里,负责 UI 和编辑器扩展;核心算法和工具函数放在独立的运行时模块里,尽量不碰引擎数据类型。后者是我最想保护的部分,测试用例多、迭代快,必须有一套能快速运行的单元测试框架。
gtest 是 C++ 社区里最稳妥的选择,资料多、社区活跃、参数化测试和死亡测试都够用。问题从来不在 gtest 本身,而在怎么把它接进 UE 的构建体系。
1.2 为什么不直接用引擎自带的 GoogleTest 模块
UE 引擎源码里确实带了 GoogleTest,位置在Engine/Source/ThirdParty/GoogleTest。如果你装的是源码版引擎,能直接看到头文件和一部分构建脚本。但我最终还是放弃了它,原因有两个。
第一,引擎自带的 gtest 版本通常比较旧,和团队的用例风格不一定匹配,而且你没法轻易换版本——引擎目录是只读的,改坏了影响面太大。第二,引擎自带版本在模块化编译时和 UE 自己的宏、编译选项混在一起,出问题时很难判断是 gtest 的问题还是引擎 ThirdParty 集成的问题。
所以我选了最传统的做法:从 GitHub 拉一份 googletest 源码,放到插件自己的ThirdParty/GoogleTest目录下,用 CMake 独立编译出静态库,再通过Build.cs链接进插件。这样 gtest 版本的主动权完全在自己手里,出了任何编译问题也能单独排查,不会牵扯到整个 UE 工程的构建链。
1.3 整体集成路线
整个集成分四步:
- 下载 googletest 源码,放到
Plugins/MyPlugin/ThirdParty/GoogleTest/。 - 用 CMake 在 macOS 上编出
libgtest.a和libgmock.a,拷到lib/Mac/目录。 - 在插件的
Build.cs里加上头文件路径和静态库路径。 - 写一个独立的测试 runner,提供入口在编辑器里触发
RUN_ALL_TESTS()。
看起来挺常规的。但真正动手的时候,前三步每一步都给我甩了个编译报错,而且报错信息一个比一个隐蔽。
2. 第一道坎:'tr1/tuple' file not found 与 libc++ 的断代
2.1 报错现场
编译插件模块时,Xcode 的输出窗口里冒出来这样一段:
In file included from ThirdParty/GoogleTest/src/googletest/src/gtest-port.cc:38: ThirdParty/GoogleTest/src/googletest/include/gtest/internal/gtest-port.h:123:12: fatal error: 'tr1/tuple' file not found第一反应当然是 include 路径配错了。我检查了PublicSystemIncludePaths,确认gtest和gmock的头文件目录都在。再一看,<tr1/tuple>这个头文件根本不是 gtest 项目自带的,它应该是编译器工具链的系统头文件。也就是说,问题出在编译器能搜到哪些头文件。
2.2 排查:不是路径问题,是工具链里根本没有这个头
我先用预处理器确认了一遍,免得自己怀疑错方向。在终端里把 gtest 源码文件跑一遍,看它到底有没有可能找到<tr1/tuple>:
clang++ -std=c++17 -arch arm64 \ -isysroot $(xcrun --sdk macosx --show-sdk-path) \ -I ThirdParty/GoogleTest/include \ -dM -E ThirdParty/GoogleTest/src/googletest/src/gtest-port.cc | grep GTEST_HAS_TR1_TUPLE输出里能看到宏被默认开启:
#define GTEST_HAS_TR1_TUPLE 1然后我在 Xcode 26 的 SDK 目录里直接搜tr1:
find $(xcrun --sdk macosx --show-sdk-path) -path "*c++*tr1*" 2>/dev/null结果一条记录都没有。到这里才确认:这不是项目路径问题,是 Xcode 26 这套工具链里,tr1头文件已经彻底从 libc++ 中消失了。
2.3 根因:老 gtest 对 GNU 兼容编译器的判断太粗糙
老版本 gtest(我用的 1.8.1)在gtest-port.h里有一段比较"古老"的兼容逻辑。当年写这段代码时,GCC 4.x 配套的是 libstdc++,<tr1/tuple>是真实存在的。所以gtest-port.h只要看到编译器定义__GNUC__,就默认std::tr1::tuple可用,于是直接 include<tr1/tuple>。
问题在于 Apple Clang 虽然也叫 clang,但它为了兼容性,同样会定义__GNUC__。Xcode 老早切换到了 libc++,tr1目录在系统头文件里本来就名存实亡,Xcode 26 的新 SDK 更是干净利落地移除了这一套。于是老 gtest 的判断逻辑就彻底失效了:它以为存在于系统中的头文件,实际上一行都不剩。
2.4 两个修复方向,我分别试了一遍
临时方案是在编译 gtest 源码时,手动把GTEST_HAS_TR1_TUPLE定为 0:
cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_ARCHITECTURES=arm64 \ -DCMAKE_CXX_FLAGS="-DGTEST_HAS_TR1_TUPLE=0" \ -Dgtest_build_tests=OFF \ -Dgtest_build_samples=OFF这个宏一旦置 0,老 gtest 就不会再去 include<tr1/tuple>,而是走 C++11 的<tuple>路径。我的插件本来就是 C++17 工程,完全不在乎这层兼容。
但临时方案治标不治本。gtest 的旧源码里不止一处这种过时假设,后面很可能还会冒出别的兼容问题。所以我最终选择了升级 googletest 到 1.14 版本,新版源码已经完全删掉了 tr1 相关代码。这个决定在后面省了很多事。
这里有个我实际踩到的小坑:升级时只替换了include/gtest目录,忘了替换include/gmock目录,结果 gtest 新版头文件和 gmock 旧版头文件混在一起,编译时直接报出"类成员声明与实现不一致"这种莫名其妙的错误。所以换版本务必整个仓库一起换,别只换一半。
3. 第二道坎:CMake 缓存把 libgtest.a 编成了 x86_64
3.1 链接器无情的架构报错
gtest 源码本身编译通过之后,我把它接进 UE 的Build.cs里继续编译插件。这次链接阶段直接挂了:
ld: in '/Users/xx/Plugins/MyPlugin/ThirdParty/GoogleTest/lib/Mac/libgtest.a', building for macOS-arm64 but attempting to link with file built for macOS-x86_64 clang: error: linker command failed with exit code 1 (use -v to see invocation)看到这句话,我第一反应是:这台机器明明是 Apple Silicon,终端uname -m输出也是arm64,CMake 怎么可能编出 x86_64 的库?
3.2 用 lipo 和 nm 验证架构
先用lipo直接看静态库的实际架构:
lipo -info ThirdParty/GoogleTest/lib/Mac/libgtest.a输出让我有点意外:
Non-fat file: libgtest.a is architecture: x86_64再用nm看看库里的符号是否正常:
nm libgtest.a | grep "testing::UnitTest::GetInstance"符号是有的,但这段符号属于 x86_64 切片,对 arm64 的 UE 可执行文件来说毫无意义。
3.3 罪魁祸首:埋藏在 CMakeCache.txt 里的历史架构设置
排查 CMake 配置时,我在build/CMakeCache.txt里看到一行:
CMAKE_OSX_ARCHITECTURES:STRING=x86_64这是非常典型的历史遗留。很早之前我在一台 Intel Mac 上用同一个源码目录配过 gtest,后来把整个项目迁到了 Apple Silicon 机器上,为了方便直接用命令行继续编译。但 CMake 的缓存机制会优先信任已有缓存里的架构设置,不会每次都根据当前机器重新推断。于是 CMake 就照旧编出了 x86_64 的库。
这种情况在 Windows 上很少发生,因为 Windows 下架构切换没有 macOS 这么普遍;在 macOS 上却很容易踩中,尤其是多人协作项目里,源码目录或者build目录被提交到 Git,换机器后 CMake 缓存就成了隐形的定时炸弹。
3.4 正确的做法:显式指定架构,并清掉旧缓存
修复方式很简单,但有一个关键动作:先删掉build目录,让 CMake 重新生成缓存。命令行里显式指定目标架构:
rm -rf build cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_ARCHITECTURES=arm64 \ -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0 \ -Dgtest_build_tests=OFF \ -Dgtest_build_samples=OFF \ -Dgmock_build_tests=OFF cmake --build build -j8重点就是两个参数:CMAKE_OSX_ARCHITECTURES指定arm64,和CMAKE_OSX_DEPLOYMENT_TARGET指定 macOS 最低版本。UE5.6 在 Apple Silicon 上跑的是 arm64 的 UnrealEditor,插件最终链接进主程序时,所有静态库的切片必须和主程序架构一致。这里的CMAKE_OSX_DEPLOYMENT_TARGET也建议和 UE5.6 的DefaultTargetSettings对齐,省得以后出现 ABI 层面的诡异问题。
如果你需要在 Intel 和 Apple Silicon 之间分发同一个 gtest 库,也可以编两个切片的库再合并:
lipo -create libgtest_arm64.a libgtest_x86_64.a -output libgtest_universal.a但 UE 插件通常只需要本机架构,我最终只保留了 arm64 版本。fat 库虽然通用,却没来由地把二进制体积翻了一倍。
4. 第三道坎:静态库里的 main() 和你的 main() 撞了车
4.1 又一个新的链接错误
架构问题解决后,gtest 库能编过了,也进得了链接列表。但 UE 工程编译到最后一步,链接器又抛了一个新错误:
ld: duplicate symbol _main in .../GTestRunner.cpp.o and .../ThirdParty/GoogleTest/lib/Mac/libgtest_main.a(gtest_main.cc.o)哦,这个报错就非常直白了:链接器发现_main符号被定义了两次。一次来自我自己的GTestRunner.cpp,另一次来自libgtest_main.a里的gtest_main.cc。
4.2 为什么静态库里会冒出一个 main
googletest 的 CMake 默认会构建两个使用层级的库:一个是libgtest.a,只包含测试框架本身;另一个是libgtest_main.a,额外提供一个标准的main()函数,方便你写最小测试工程。它的设计初衷是好的:你只需要写测试用例,链接gtest_main之后就能直接生成可执行文件。
但 UE 有自己的入口和模块体系,插件的定位是动态库,不需要也无法拥有最终程序的main。我早期为了快速验证某个用例,把libgtest_main.a也顺手加到了Build.cs的库列表里,又保留了自己的 runner,于是两个main在最终链接阶段正面撞上。
确认一下库里的符号很简单:
nm libgtest_main.a | grep "_main$"输出:
0000000000000000 (__TEXT,__text) _main没跑,里面确实住着一个main。
4.3 修复:不让 gtest 来抢 UE 的入口
修复的方式取决于你想要哪种运行方式。
如果你想完全让 gtest 独立跑一个测试可执行文件,那libgtest_main.a是你最好的朋友,这种情况完全不需要 UE 参与,CMake 构建出来的测试 target 直接跑。
但如果你想在 UE5.6 的编辑器环境里跑测试,正确做法是:链接libgtest.a和libgmock.a,绝不链接libgtest_main.a,同时由你自己提供测试入口。我最终在 CMake 配置里顺手关掉了 gtest_main 的构建,从源头避免以后再手滑:
-Dgtest_build_main=OFF这样库里根本不会生成libgtest_main.a,链接阶段也不会再有第二个_main出现。
4.4 一个更容易踩的变体:把 gtest 源码直接拖进 UE 模块
还有一种做法是把 gtest 的.cc文件直接放进 UE 模块里编译,而不是链接静态库。如果你只是想把 gtest 用在独立测试 target 上,这样做的确方便;但一旦放进了 UE 模块,除了main()冲突,还会遇到 UE 宏和编译选项对第三方源码的干扰,排查起来比链接静态库复杂得多。
我的建议很明确:gtest 永远作为一个独立的第三方库编译好,UE 模块里只保留include路径和链接路径。这样 gtest 的编译参数是独立的,不会受 UE 的-Werror、异常设置、RTTI 开关这些选项影响,遇到问题也容易单独复现。
5. 跑通之后的完整工程结构与入口设计
5.1 最终目录形态
所有折腾结束后,我的 ThirdParty 目录长这样:
Plugins/MyPlugin/ThirdParty/GoogleTest/ ├── include/ │ ├── gtest/ │ └── gmock/ ├── lib/Mac/ │ ├── libgtest.a │ └── libgmock.a └── src/ # googletest 源码,只用于重新编译库,不参与 UE 模块编译src目录纯粹留作备份和二次编译,UE 模块构建时不会碰它。
5.2 Build.cs 的关键片段
UE 侧只需要把 include 和 library 加进去:
using System.IO; using UnrealBuildTool; public class MyPlugin : ModuleRules { public MyPlugin(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" }); string GoogleTestRoot = Path.Combine(ModuleDirectory, "../../ThirdParty/GoogleTest"); PublicSystemIncludePaths.Add(Path.Combine(GoogleTestRoot, "include")); if (Target.Platform == UnrealTargetPlatform.Mac) { PublicAdditionalLibraries.Add(Path.Combine(GoogleTestRoot, "lib/Mac/libgtest.a")); PublicAdditionalLibraries.Add(Path.Combine(GoogleTestRoot, "lib/Mac/libgmock.a")); } } }注意PublicSystemIncludePaths和PublicIncludePaths在 UE5.6 里有细微区别。前者是给系统级第三方头文件用的,比较推荐;后者在老项目里也用得多,但新增代码就别再用了。
5.3 测试入口:用控制台命令而不是 main
既然不链接gtest_main,就得自己提供一个触发入口。我把它放进了插件模块的StartupModule里,注册一条控制台命令:
#include "Modules/ModuleManager.h" #include "Misc/ConsoleCommand.h" #include "gtest/gtest.h" class FMyPluginModule : public IModuleInterface { public: virtual void StartupModule() override { GTestCommand = MakeUnique<FAutoConsoleCommand>( TEXT("MyPlugin.RunAllGTest"), TEXT("Run all registered GoogleTest cases."), FConsoleCommandWithArgsDelegate::CreateLambda( [](const TArray<FString>&) { int Argc = 1; char Arg0[] = "MyPlugin"; char* Argv[] = { Arg0, nullptr }; testing::InitGoogleTest(&Argc, Argv); const int Result = RUN_ALL_TESTS(); UE_LOG(LogTemp, Log, TEXT("GTEST_RESULT=%d"), Result); })); } virtual void ShutdownModule() override { GTestCommand.Reset(); } private: TUniquePtr<FAutoConsoleCommand> GTestCommand; }; IMPLEMENT_MODULE(FMyPluginModule, MyPlugin)在编辑器里,打开控制台输入MyPlugin.RunAllGTest,测试就会跑起来,结果统一打到LogTemp里。这个方案的好处是不需要单独的测试进程,也不依赖main,直接在编辑器环境里完成单元测试。
5.4 一个容易忽略的问题:测试库别进正式包
我这里把 gtest 链接放进了公共模块依赖,但实际生产项目建议给它单独做隔离。更稳妥的做法是建一个独立的测试 target 或者在Build.cs里判断Target.Configuration,只在Development或者DebugGame配置下启用 gtest。否则正式打包时,你会把 gtest 的符号和测试代码一起带进交付包,一个追求精简的线上包不该背这口锅。
6. 复盘:一张速查表和几条长记性的经验
6.1 排错速查表
把这次的三个报错整理成一张表,下次遇到能直接对号入座:
| 报错特征 | 根因方向 | 快速检查手段 | 修复建议 |
|---|---|---|---|
'tr1/tuple' file not found | 老 gtest 头文件与新版 libc++ 不兼容 | find $(xcrun --sdk macosx --show-sdk-path) -path "*tr1*" | 升级 googletest 到 1.14,或编译时定义GTEST_HAS_TR1_TUPLE=0 |
building for macOS-arm64 but attempting to link with file built for macOS-x86_64 | 第三方静态库架构与主程序不一致 | lipo -info libgtest.a | 清除 CMakeCache,显式指定-DCMAKE_OSX_ARCHITECTURES=arm64 |
duplicate symbol _main | 同时链接了gtest_main并自定义了入口 | nm libgtest_main.a | grep "_main$" | 不链接libgtest_main.a,或-Dgtest_build_main=OFF |
| 升级后出现"类成员声明不一致"类报错 | gtest 与 gmock 头文件版本混搭 | 对比include/gtest与include/gmock的版本 | 整个 googletest 仓库一起替换,不要只换一半 |
6.2 几条长记性的经验
第一,第三方库的编译要尽量脱离 UE 构建体系独立完成。gtest 这种开源库,在 CMake 环境下是非常成熟的项目,一旦和 UE 的构建规则搅在一起,报错信息会被各种宏展开和隐式路径污染,排查成本成倍上升。
第二,CMake 缓存是最容易忽视的"隐形设置"。换机器、换架构之后,先删掉build目录再说。很多诡异的问题不是代码写错了,而是缓存里存了上个环境的状态。
第三,链接告警一定别忽略。duplicate symbol这类错误,虽然名字叫重复符号,但不代表你的工程必须重构。先看一下符号来自哪个库,很多时候只是链接列表里多了一个不该出现的库。
第四,新版 gtest 的代价没有想象中大。我最初执着于在旧版本 1.8.1 上打补丁,后来换了 1.14 才发现,新版对 Xcode 26 的支持非常干净,代码里那些老旧的tr1兼容层早就被删掉了。如果你的项目不需要旧版 gtest 的特殊依赖,直接升级是最省时间的。
最后分享一个小的实操习惯:我每次在 UE 里集成第三方库,都会先用 CMake 单独编一个完全不依赖 UE 的最小可执行测试,用它跑一遍 gtest 自带的基础用例。这一步能确保库本身是健康的,之后再接入 UE 时,如果还有问题,问题几乎可以断定出在我的集成方式上,而不是出在库的编译上。这次整个排查能三小时收工,这个习惯帮了不少忙。