UE 的日志和断言系统看起来是基础设施里最不起眼的部分,但它决定了你排查问题时能看到什么、Shipping 包里保留什么。UE_LOG 的编译期裁剪、check/ensure/verify 的行为差异,都是"上线前必须想清楚"的设计决策。源码在 Runtime/Core/Public/Logging/(LogMacros.h、LogVerbosity.h)和 Runtime/Core/Public/Misc/AssertionMacros.h。
UE_LOG 的三层过滤:类别、级别、编译期裁剪
UE_LOG(LogTemp, Warning, TEXT("HP: %d"), HP) 这行代码背后有三层过滤,每一层在不同阶段生效:
第一层:日志类别(Category)。DECLARE_LOG_CATEGORY_EXTERN(LogMySystem, Log, All) 声明一个类别,第一个参数是名字,第二个是默认级别,第三个是编译期最高级别。类别是全局单例的 FLogCategoryBase,引擎启动时注册到 FLogManager。运行时用控制台命令 Log LogMySystem Verbose 可以动态调级别。
第二层:运行时级别(Verbosity)。ELogVerbosity 从低到高:Verbose < VeryVerbose < Log < Display < Warning < Error < Fatal。每次 UE_LOG 先查类别当前的级别设置,低于设定级别的消息直接跳过——跳过是运行时的,参数表达式不求值。这意味着 UE_LOG(..., TEXT("%s"), *ExpensiveToString()) 在级别不够时不会调 ExpensiveToString(),日志开销可控。
第三层:编译期裁剪。DECLARE_LOG_CATEGORY_EXTERN 的第三个参数(编译期最高级别)控制宏展开时是否生成代码:UE_LOG_ACTIVE(LogMySystem, Verbose) 这个内部宏在编译期判断"该类别的编译期上限是否 >= 本次调用的级别",不满足就整个 UE_LOG 展开为空语句,编译产物里连字符串都没有。Shipping 构建默认把 Log 级别以下的全部裁掉(UE_BUILD_SHIPPING 定义时 COMPILED_IN_MINIMUM_VERBOSITY 变化),所以 Shipping 包里 Verbose 日志是零开销——不是"运行时不打",是"二进制里不存在"。
这三层对应排查问题的三种手段:运行时级别不够 → 控制台调级别;类别没注册 → 查 DEFINE_LOG_CATEGORY 是不是只在 cpp 里写了没配合 DECLARE_LOG_CATEGORY_EXTERN;Shipping 里看不到 → 查编译期上限和 bDisableLoggingInShipping。
日志的输出链路:FOutputDevice 分发
一条日志通过级别检查后,进入 FMsg::Logf(Logging/LogMacros.h 组装后调到这里),然后分发给所有注册的 FOutputDevice:
UE_LOG → FMsg::Logf → FLogManager 按类别路由
├── FOutputDeviceFile → Saved/Logs/MyGame.log
├── FOutputDeviceConsole → 编辑器 Output Log 面板 / 终端
└── FOutputDeviceDebug → IDE 的调试输出窗口
FOutputDevice 是可注册的——你可以继承它写一个发网络日志的设备,挂到 FLogManager 上就能收到所有日志。远程诊断工具(UnrealInsights 的日志通道、自定义的日志上报)都是这么做的。
日志的格式化在 FMsg::Logf 里是延迟的:格式串和参数先打包,输出设备真正要写的时候才格式化。这解释了为什么高并发场景下日志不一定是瓶颈——格式化发生在输出线程,主线程只付打包成本。但如果输出设备是同步写文件且开了 flush,I/O 还是会卡住调用线程,bFlushOutputDevice 这类配置要按场景权衡。
check/ensure/verify:三档断言的行为边界
断言宏的行为差异是面试和代码审查的高频考点,也是实际 bug 的高发区。三档的核心区别在"失败后停不停"和"Shipping 里在不在":
check(Ptr != nullptr); // 失败 → 立即崩溃(触发 crash reporter)
ensure(Ptr != nullptr); // 失败 → 打日志 + 触发一次 crash reporter 上报,**继续执行**
verify(Ptr != nullptr); // Shipping 里表达式保留、检查消失
| 宏 | Debug | Development | Shipping | 失败后行为 |
|---|---|---|---|---|
check |
崩溃 | 崩溃 | 整个表达式消失 | 立即终止 |
ensure |
崩溃 | 上报后继续 | 消失(WITH_ENSURE 可保留) |
上报后继续执行 |
verify |
崩溃 | 崩溃 | 表达式保留,检查消失 | 立即终止(非 Shipping) |
checkSlow |
崩溃 | 消失 | 消失 | 仅 Debug |
check 在 Shipping 里消失是行为差异最大的坑:check(Func()) 在 Shipping 里 Func() 根本不会被调用。把有副作用的表达式塞进 check 是最经典的 Shipping-only bug——开发环境一切正常,打包后功能静默失效。正确写法是 verify:表达式保留,检查在 Shipping 里消失。
ensure 是"软断言":失败时走 crash reporter 上报(带调用栈和上下文)但不终止进程,函数继续执行。它适合"这个情况不该发生但发生了也不至于崩"的场景——数据异常、边界情况、外部输入不合预期。ensure 有频率控制:ensure(每次都报)、ensureOnce(只报一次)、ensureMsgf(带格式化消息)。引擎代码里大量 ensure 的存在说明 Epic 对"线上环境不该崩但要可观测"的态度。
checkSlow/verifySlow/ensureSlow 家族只在 Debug 配置生效,用于开销大的检查(遍历校验、深度一致性检查)——Development 里都不跑。
断言的实现:从宏到 Crash Reporter
check(expr) 展开后的核心是(AssertionMacros.h,简化):
#define check(expr) \
if (!(expr)) { \
FDebug::CheckVerifyFailed(#expr, __FILE__, __LINE__); \
/* 触发崩溃处理 */ \
}
FDebug::CheckVerifyFailed 会走平台的崩溃处理链:挂起所有线程 → 生成 minidump → 调 Crash Reporter(如果配置开启)→ 终止进程。日志里看到的 Assertion failed: Ptr != nullptr [File:...] [Line: ...] 就是这里打的。
ensure 的实现走另一条路:FDebug::EnsureFailed 收集调用栈和上下文,发给 Crash Reporter 的非致命通道,然后返回让代码继续跑。ensureOnce 用一个静态 bool 保证同一个代码位置只触发一次——这个静态变量是线程不安全的,多线程同时触发可能报多次,但行为上仍是"最多几次",不是"精确一次"。
实战:Shipping 包前的断言审计
打包 Shipping 前值得过一遍的检查清单:
- grep 所有
check(看参数有没有副作用:check(Initialize())、check(Queue.Dequeue(Item))这种,换成verify或把副作用提到 check 外面。 - 确认
ensure的处理路径:ensure失败后代码继续跑,后面的逻辑必须能容忍"ensure 的条件不满足"的状态。常见反模式:ensure(Ptr)之后直接用Ptr->,ensure 失败时Ptr是空,后面接着崩——ensure 失去了意义。 - 日志级别的 Shipping 裁剪:确认关键的错误日志是
Warning/Error级别,Log级别以下在 Shipping 里会被编译期裁掉,排查线上问题时别指望它们。 bDisableLoggingInShipping:BaseEngine.ini里这个开关默认 true,意味着 Shipping 连Error级别的文件日志都不写。需要线上日志的项目要自己打开并评估 I/O 开销。
结论
- 日志的开销控制靠三层过滤:编译期裁剪决定"Shipping 里有没有",运行时级别决定"打不打",输出设备决定"写到哪"。排查日志问题先确定卡在哪一层。
check里绝不放有副作用的表达式,有副作用的用verify;ensure后面的代码必须能容忍失败状态,否则 ensure 只是个延迟的 check。- Shipping 前做一次断言审计:grep 所有
check(的副作用、确认ensure后的容错路径、打开bDisableLoggingInShipping的例外清单。 - 日志分类按子系统建(
LogMyGameCombat、LogMyGameUI),别全堆LogTemp——运行时按类别调级别是线上排查的第一手段,全堆 Temp 等于放弃了这个能力。
基础设施到这里走完一半。最后两篇看运行时的动态面:模块加载(FModuleManager)和插件系统怎么把 DLL 挂进引擎。