在虚幻引擎C++(UEC++)开发中,你是否曾为调试信息输出而烦恼?是直接在屏幕上打印字符串,还是使用更专业的日志系统?面对复杂的游戏逻辑和偶现的Bug,如何快速定位问题,而不是在茫茫代码中大海捞针?本文将为你彻底梳理UEC++中的两种核心日志机制:UE_LOG和GEngine->AddOnScreenDebugMessage。从零开始,手把手带你掌握它们的使用方法、适用场景、高级技巧以及生产环境中的最佳实践,让你在虚幻引擎C++的调试之路上,从入门到精通。
1. 背景与核心概念:为什么需要日志?
在软件开发中,日志(Log)是记录程序运行时状态、事件和错误信息的关键工具。它就像程序的“黑匣子”,当程序出现异常或行为不符合预期时,日志是开发者进行问题诊断和回溯的第一手资料。
在虚幻引擎(Unreal Engine)的C++开发中,日志系统尤为重要。游戏运行时状态复杂,涉及渲染、物理、AI、网络等多个子系统同步工作。一个简单的逻辑错误可能导致角色无法移动、资源加载失败或游戏崩溃。如果没有有效的日志输出,定位这些问题将如同盲人摸象。
虚幻引擎内置了一套强大且灵活的日志系统,主要为我们提供了两种面向开发者的输出方式:
UE_LOG: 控制台/文件日志- 本质: 将格式化后的字符串输出到引擎的日志系统。这些日志默认会显示在编辑器的“输出日志”(Output Log)窗口,并可以写入到磁盘文件(如
Saved/Logs/目录下的.log文件)中。 - 特点: 信息持久化,可以记录大量历史数据,支持分类(Category)和分级(Verbosity),适合记录程序运行的全流程、关键事件、警告和错误。它是事后分析和长期监控的首选。
- 本质: 将格式化后的字符串输出到引擎的日志系统。这些日志默认会显示在编辑器的“输出日志”(Output Log)窗口,并可以写入到磁盘文件(如
GEngine->AddOnScreenDebugMessage: 屏幕调试信息- 本质: 在游戏运行时的屏幕上(Game Viewport)直接绘制一段文本信息。
- 特点: 信息实时、直观,会随着游戏帧的刷新而显示或消失。它非常适合实时监控变量的变化、验证代码执行路径、或者给测试人员提供临时的状态提示。但信息是临时的,一旦消失便无法追溯。
简单来说,UE_LOG是写给“未来”的自己或同事看的诊断报告,而屏幕信息是给“现在”的自己看的实时仪表盘。两者相辅相成,共同构成了UEC++调试的基石。
2. 环境准备与版本说明
在开始实践之前,请确保你的开发环境已就绪。
- 操作系统: Windows 10/11 或 macOS。本文示例以Windows为主,但核心逻辑跨平台通用。
- 虚幻引擎版本:UE 5.0 及以上。本文示例代码在 UE 5.2 和 UE 5.3 中测试通过。UE4的大部分API也兼容,但建议使用较新版本以获得更好的开发体验。
- 开发环境:
- Visual Studio 2022(Windows) 或Xcode(macOS): 用于C++代码编译和调试。
- Visual Studio Code: 可选,用于代码编辑,需安装C++和Unreal Engine相关插件。
- 项目类型: 一个已创建的C++ 项目(如 Third Person Template)。纯蓝图项目无法直接使用C++日志API。
- 前置知识: 基础的C++语法,了解如何在虚幻编辑器中创建C++类并编译项目。
重要提示: 虚幻引擎版本迭代较快,但核心日志API保持高度稳定。如果你的项目使用的是特定版本(如UE 4.27),本文的绝大多数代码依然适用,但部分宏或头文件路径可能需要微调。请以你实际引擎版本的官方文档为最终参考。
3. 核心语法与原理拆解
3.1 UE_LOG: 强大的控制台日志系统
UE_LOG是一个功能强大的宏,其核心设计基于日志类别(Log Category)和详细级别(Log Verbosity)。
基本语法:
UE_LOG(LogCategory, Verbosity, Format, ...)LogCategory: 日志类别,用于对日志进行分类过滤。例如LogTemp是一个通用的临时类别。Verbosity: 日志详细级别,决定日志的重要性。级别从高到低常见的有:Fatal: 致命错误。打印日志后通常会调用abort终止程序。Error: 错误。用红色显示,表示操作失败。Warning: 警告。用黄色显示,表示可能存在问题的非致命情况。Display: 显示。用白色显示,重要的常规信息。Log: 日志。用灰色显示,一般的调试信息。Verbose: 详细。更细致的调试信息。VeryVerbose: 非常详细。最细致的调试信息,可能影响性能。
Format: 格式化字符串,类似printf。支持%s(FString, FName, FText需转换),%d,%f,%lld等。...: 可变参数,对应格式化字符串中的占位符。
为什么需要类别和级别?在一个大型项目中,所有模块都往一个日志流里写信息会很快导致信息过载。通过类别,你可以只查看LogAI、LogPhysics或LogNet相关的日志。通过级别,你可以在开发时打开Verbose级别进行深度调试,而在发布或测试时只显示Warning及以上级别的重要信息,避免控制台被刷屏。
3.2 GEngine->AddOnScreenDebugMessage: 直观的屏幕输出
这是一个通过全局引擎指针GEngine调用的方法,用于在屏幕上绘制2D文本。
基本语法:
if (GEngine) { GEngine->AddOnScreenDebugMessage( Key, // int32: 消息的唯一键值,用于后续更新或移除该消息。使用-1则每次创建新消息。 TimeToDisplay, // float: 消息在屏幕上显示的持续时间(秒)。 Color, // FColor: 消息的颜色。 Message, // const FString&: 要显示的字符串信息。 bNewerOnTop, // bool: 如果为true,新消息将显示在旧消息上方。 Scale // const FVector2D&: 文本的缩放比例。 ); }- Key 的作用: 这是屏幕消息与
UE_LOG最大的不同之一。如果你使用一个非负的Key(如1),并且多次调用此函数,它会更新Key对应的那条消息,而不是创建一条新的。这非常适合用于持续显示某个变量的实时值(如角色血量、帧率)。 - 性能注意: 每帧在屏幕上绘制大量文本会影响性能。应避免在
Tick函数中无节制地调用,尤其是显示大量不同信息时。
4. 完整实战案例:在角色类中集成日志
让我们通过一个具体的例子,在一个第三人称模板的角色(Character)类中,综合使用两种日志方法。
4.1 创建或打开项目
- 打开虚幻引擎,使用第三人称游戏(C++)模板创建一个新项目,命名为
LogDemo。 - 创建完成后,引擎会自动生成一个C++角色类,通常名为
ALogDemoCharacter。在内容浏览器中右键点击它,选择“在文件资源管理器中显示”,然后用Visual Studio打开解决方案文件(.sln)。
4.2 编写核心日志代码
打开LogDemoCharacter.h文件,在类声明中添加一个用于测试的变量和一个函数声明。
// 文件路径: Source/LogDemo/LogDemoCharacter.h #pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "LogDemoCharacter.generated.h" UCLASS(config=Game) class ALogDemoCharacter : public ACharacter { GENERATED_BODY() public: ALogDemoCharacter(); protected: virtual void BeginPlay() override; virtual void Tick(float DeltaTime) override; // 新增:一个测试变量 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "LogDemo") float TestHealth; // 新增:一个测试函数,用于触发日志 UFUNCTION(BlueprintCallable, Category = "LogDemo") void TakeDamage(float DamageAmount); };接下来,打开LogDemoCharacter.cpp文件,实现日志功能。
// 文件路径: Source/LogDemo/LogDemoCharacter.cpp #include "LogDemoCharacter.h" #include "Engine/Engine.h" // 需要包含此头文件以使用GEngine #include "DrawDebugHelpers.h" // 可选,用于绘制调试图形 // 定义自定义日志类别。通常放在.cpp文件顶部,.h文件中用extern声明。 // 这里我们在.cpp中定义并使用它。 DEFINE_LOG_CATEGORY_STATIC(LogMyGameCharacter, Log, All); ALogDemoCharacter::ALogDemoCharacter() { // 设置默认值 TestHealth = 100.0f; } void ALogDemoCharacter::BeginPlay() { Super::BeginPlay(); // 示例1:使用默认的临时日志类别,记录游戏开始。 UE_LOG(LogTemp, Display, TEXT("角色 %s 开始游戏!"), *GetName()); // 示例2:使用自定义的日志类别。 UE_LOG(LogMyGameCharacter, Log, TEXT("BeginPlay called. Initial Health: %f"), TestHealth); // 示例3:在屏幕上显示一条欢迎信息(显示5秒)。 if (GEngine) { GEngine->AddOnScreenDebugMessage( -1, // 键值-1,每次创建新消息 5.0f, // 显示5秒 FColor::Green, // 绿色 TEXT("欢迎来到LogDemo游戏!"), false, // 不置顶 FVector2D(1.0f, 1.0f) // 正常缩放 ); } } void ALogDemoCharacter::Tick(float DeltaTime) { Super::Tick(DeltaTime); // 示例4:在Tick中持续更新屏幕上的健康值信息。 // 使用固定的Key(如1),这样它只会更新同一条消息,而不是每帧创建新行。 if (GEngine) { FString HealthMessage = FString::Printf(TEXT("当前健康值: %.1f"), TestHealth); GEngine->AddOnScreenDebugMessage( 1, // 固定键值1,用于更新健康值显示 0.0f, // 显示时间为0,表示持续显示直到被更新或手动清除 FColor::Yellow, // 黄色 HealthMessage, false, FVector2D(1.0f, 1.0f) ); } // 示例5:模拟一个条件,触发警告日志。 if (TestHealth < 30.0f && TestHealth > 0.0f) { // 使用自定义类别记录警告,避免在Tick中重复打印。 // 这里用一个静态变量控制打印频率。 static float LastWarningTime = -1.0f; if (GetWorld()->GetTimeSeconds() - LastWarningTime > 2.0f) // 每2秒打印一次警告 { UE_LOG(LogMyGameCharacter, Warning, TEXT("警告!角色 %s 健康值过低: %f"), *GetName(), TestHealth); LastWarningTime = GetWorld()->GetTimeSeconds(); } // 在屏幕上用红色显示警告(使用另一个固定Key) if (GEngine) { GEngine->AddOnScreenDebugMessage( 2, 1.0f, // 显示1秒,会闪烁 FColor::Red, TEXT("警告:生命值低!"), true, // 置顶显示 FVector2D(1.2f, 1.2f) // 稍微放大 ); } } } void ALogDemoCharacter::TakeDamage(float DamageAmount) { // 示例6:在函数中记录错误和逻辑。 if (DamageAmount <= 0.0f) { UE_LOG(LogMyGameCharacter, Error, TEXT("TakeDamage 被调用,但伤害值无效: %f"), DamageAmount); return; } float OldHealth = TestHealth; TestHealth -= DamageAmount; TestHealth = FMath::Max(TestHealth, 0.0f); // 确保不低于0 UE_LOG(LogMyGameCharacter, Display, TEXT("角色受到伤害。旧健康值: %f, 伤害: %f, 新健康值: %f"), OldHealth, DamageAmount, TestHealth); // 示例7:健康值归零时,记录致命错误(模拟)。 if (TestHealth <= 0.0f) { UE_LOG(LogMyGameCharacter, Fatal, TEXT("角色 %s 已死亡!"), *GetName()); // 注意:Fatal级别日志会终止程序。实际游戏中应使用其他逻辑,如播放死亡动画、销毁角色等。 // 这里为了演示,我们注释掉Fatal,改用Error。 // UE_LOG(LogMyGameCharacter, Error, TEXT("角色 %s 已死亡!"), *GetName()); } }4.3 编译与运行验证
- 在Visual Studio中编译整个解决方案(通常按F5或选择“生成 -> 生成解决方案”)。
- 编译成功后,返回虚幻编辑器,它会自动检测到更改并重新加载模块。
- 点击编辑器工具栏上的“播放”按钮,在编辑器视口中运行游戏。
观察结果:
输出日志窗口: 在编辑器底部,找到“输出日志”(Output Log)标签页。你应该能看到类似以下的输出:
LogTemp: Display: 角色 LogDemoCharacter_C_0 开始游戏! LogMyGameCharacter: Log: BeginPlay called. Initial Health: 100.000000 LogMyGameCharacter: Display: 角色受到伤害。旧健康值: 100.000000, 伤害: 20.000000, 新健康值: 80.000000你可以尝试在“输出日志”窗口的过滤框中输入
LogMyGameCharacter来只查看自定义类别的日志,或者输入Warning来只查看警告。游戏视口: 在运行的游戏中,你应该能看到:
- 屏幕上方有绿色的“欢迎来到LogDemo游戏!”字样,5秒后消失。
- 屏幕某处(通常是左上角)持续显示黄色的“当前健康值: 100.0”。
- 如果你在角色蓝图中调用
TakeDamage函数(例如绑定到一个按键),让健康值低于30,屏幕会间歇性出现红色的“警告:生命值低!”字样,并且“输出日志”中会每2秒出现一次警告日志。
4.4 在蓝图中调用C++函数
为了测试TakeDamage函数,我们可以在角色蓝图中添加一个简单的触发:
- 在内容浏览器中,找到并打开你的角色蓝图(通常是
BP_LogDemoCharacter)。 - 在事件图表中,右键搜索“键盘事件”,例如添加一个“F键按下”事件。
- 从事件节点拖出引线,搜索“Take Damage”(你定义的函数),并连接。
- 在
DamageAmount引脚上输入一个值,比如20.0。 - 运行游戏,按下F键,观察控制台日志和屏幕健康值的变化。
5. 常见问题与排查思路
在使用UEC++日志时,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
编译错误:UE_LOG未定义 | 没有包含必要的头文件。 | 确保源文件顶部包含了#include "CoreMinimal.h"。对于自定义日志类别,需要#include “YourModuleName.h”或在.cpp中定义。 |
UE_LOG输出中文或特殊字符乱码 | UE_LOG的格式化字符串必须使用TEXT()宏包裹,且源文件编码需为UTF-8。 | 1. 检查所有字符串字面量是否用TEXT(“你的内容”)包裹。2. 确保你的C++源文件保存为UTF-8 with BOM或UTF-8编码(在VS中可通过“文件 -> 高级保存选项”设置)。 |
GEngine为空指针,导致崩溃 | 在构造函数或某些非常早的初始化阶段,GEngine可能还未初始化。 | 在使用GEngine前,必须进行空指针检查:if (GEngine) { ... }。最佳实践是在BeginPlay()或Tick()等游戏循环开始后使用。 |
| 屏幕调试信息不显示 | 1.GEngine为空。2. 显示时间( TimeToDisplay)设置为0,但Key不是固定值,导致信息被瞬间覆盖。3. 信息被其他UI元素遮挡。 4. 在编辑器模式而非“独立游戏”或“模拟”模式下运行。 | 1. 检查if (GEngine)。2. 对于持续显示的信息,使用固定的、非负的Key(如 1)。3. 尝试调整信息位置(可通过修改 GEngine->AddOnScreenDebugMessage的后续参数,但更复杂的位置控制需要Slate)。4. 确保在正确的视口模式下运行。 |
| 输出日志窗口被大量信息刷屏 | 在Tick中使用了Verbose或Log级别且未加限制的UE_LOG。 | 1.提升日志级别:在编辑器“输出日志”窗口,点击“日志”下拉菜单,选择只显示Warning及以上级别。2.使用自定义日志类别并过滤。 3.优化代码:避免在每帧都执行的函数中打印低级别日志,或使用静态变量、时间间隔来控制打印频率(如示例5所示)。 |
| 自定义日志类别不起作用 | DEFINE_LOG_CATEGORY_STATIC宏放在了头文件(.h)中,导致多重定义。 | 正确做法:在.cpp文件中使用DEFINE_LOG_CATEGORY_STATIC定义,在对应的.h文件中使用DECLARE_LOG_CATEGORY_EXTERN声明。对于模块级类别,通常在ModuleName.cpp中定义,在ModuleName.h中声明。 |
| 发布的游戏中仍然有大量日志输出,影响性能 | 默认的日志级别在开发(Development)和发布(Shipping)配置下不同,但可能仍有残留。 | 1. 使用#if !UE_BUILD_SHIPPING宏来包裹调试用的日志和屏幕信息代码,确保它们在发布版本中不被编译。2. 在项目设置中配置最终的日志详细级别。 |
6. 最佳实践与工程建议
将日志用好,能极大提升开发效率和项目可维护性。
6.1 日志策略与规范
合理使用日志级别:
Fatal/Error: 仅用于不可恢复的错误或断言失败。Warning: 用于可能有问题但程序仍可继续运行的情况(如加载资源失败使用默认值)。Display: 用于记录重要的业务流程节点(如关卡加载完成、玩家登录)。Log/Verbose: 用于详细的调试信息,务必用#if !UE_BUILD_SHIPPING包裹或通过运行时配置控制。
创建有意义的日志类别:
- 不要滥用
LogTemp。为你的游戏模块、子系统创建专用的日志类别,如LogMyGameAI、LogMyGameInventory、LogMyGameNetwork。 - 这能让你在调试时快速过滤出相关领域的日志。
- 不要滥用
格式化字符串要清晰:
- 在日志中包含足够的上下文信息,如对象名称(
*GetName())、函数名(FUNCTION_FNAME)、时间戳(FDateTime::Now())。 - 示例:
UE_LOG(LogInventory, Display, TEXT("[%s] 玩家 %s 获得了物品 %s (ID: %d)"), *FDateTime::Now().ToString(), *PlayerName, *ItemName, ItemId);
- 在日志中包含足够的上下文信息,如对象名称(
6.2 屏幕信息使用准则
区分用途:
- 临时调试: 使用Key为
-1,显示几秒后自动消失。适合一次性验证。 - 持续监控: 使用固定的Key(如
1,2,3...)来更新显示变量值。确保在对象销毁或不再需要时,考虑调用GEngine->RemoveOnScreenDebugMessage(Key)进行清理。 - 分层显示: 利用
bNewerOnTop和不同的Key区域来组织信息(如系统信息在顶部,角色状态在中部,调试命令在底部)。
- 临时调试: 使用Key为
性能考量:
- 绝对避免在每帧的
Tick中创建大量新的屏幕消息(Key为-1)。这会导致严重的性能下降和视觉混乱。 - 对于需要每帧更新的信息,必须使用固定Key的更新模式。
- 绝对避免在每帧的
6.3 高级技巧与集成
使用
ensure和check:check(Expression): 如果表达式为假,在开发配置下会触发断言并中断调试器。用于捕捉绝不应该发生的错误。ensure(Expression): 如果表达式为假,会记录一次警告(带调用堆栈),但程序继续执行。用于捕捉可能发生但不一定致命的错误。ensure在第一次触发时会记录,后续相同位置触发会静默,避免日志刷屏。- 它们比单纯的
UE_LOG(Error)更强大,能自动捕获文件和行号。
将日志输出到文件:
- 虚幻引擎默认就会将日志写入
Saved/Logs/YourProjectName.log。 - 你可以通过命令行参数
-log来运行游戏,确保日志被记录。 - 在代码中,你也可以使用
FPlatformMisc::LowLevelOutputDebugString或自定义文件流来记录日志,但通常引擎自带的日志系统已足够强大。
- 虚幻引擎默认就会将日志写入
在蓝图中查看C++日志:
- 在蓝图中,你可以使用“Print String”节点输出到屏幕(相当于简易的屏幕信息),但其输出也会显示在“输出日志”中,类别为
LogBlueprintUserMessages。这是一个连接蓝图和C++调试的桥梁。
- 在蓝图中,你可以使用“Print String”节点输出到屏幕(相当于简易的屏幕信息),但其输出也会显示在“输出日志”中,类别为
掌握UE_LOG和GEngine->AddOnScreenDebugMessage是每一位UEC++开发者必备的调试技能。从今天起,在你的代码中 strategically 地加入日志,而不是仅靠“猜”和“断点”。当你习惯用日志描绘程序的运行轨迹后,你会发现解决Bug的速度有了质的飞跃。试着为你的下一个功能模块定义一个专属的日志类别,并制定简单的日志级别规范,从项目初期就建立起良好的调试习惯。