虚幻引擎应用实例分享(十):虚幻的断言

作者:Dluck
2023-10-20
2 1 0

前言

由 Epic Games 推出的虚幻系列引擎,因其高效、全能、易获取、所见即所得等特性受到广大游戏开发者欢迎,市面上也不乏从入门到深度分析的教程。本系列主要面向虚幻引擎的初学者以及有一定实践经验的虚幻引擎游戏开发者,分享能够立即运用在自己项目中的实践技巧。本教程综合了个人的学习笔记、官方文档以及个人心得,水平不足之处,望读者反馈和指正。

本文是 UE 应用实例分享系列专栏的第十篇。

断言:代码保护

无论是游戏开发还是任何其它软件开发,都存在一个铁律,即人都难免会犯错。因此,我们应该致力于处理错误,优化调试效率和方法,而不必苛求用户和开发人员不犯错误。

在游戏开发中,有许多方法可用于调试游戏程序并处理错误。例如:

  • 使用 try-catch 语句来捕获异常并进行处理;
  • 使用 if 语句来检测有问题的变量并进行处理;
  • 对功能函数进行单元测试以确保其正常运行(Web 开发中常用);
  • 使用断言检查变量是否符合条件;
  • 返回错误码以进行错误处理;
  • 通过屏幕或控制台输出警告信息。

以上这些都是保证游戏程序运行顺畅和稳定的有效方式。

在《游戏引擎架构》这本书中,程序错误被分为了两种:用户错误和程序员错误。其中,用户包括游戏开发团队中除程序员外的所有工作者,也包括使用正在开发的功能的程序员本身。许多时候,程序员会使用第三方工具、游戏引擎等,这时我们就是这些工具的用户。

游戏开发过程,通常伴随着混乱的迭代过程,正如《体验引擎》所指出的问题那样,我们基本不可能在项目初期就规划好所有的细节。面对一项变动非常大的开发流程时,最佳经验是,在提示错误的同时不阻碍其它功能的开发。如果导入了一个无效的资产,则整个游戏可能会崩溃,这将让团队付出巨大的代价!

由此,断言应运而生。在上述场景中,当断言检测到错误时,它会中止程序运行,若使用集成开发环境调试程序时,我们可以跳过这个错误并继续运行程序。Jason Gregory 将这称为 Bug 的地雷。

断言确实可以帮助我们及时发现逻辑中的错误,尤其是在变化繁多的开发阶段。

什么是断言

程序设计中的断言可以简单地理解为一种指令或语句,甚至可以被视为代码注释,其主要目的是在程序运行过程中检查特定条件是否为真。换句话说,断言是程序代码中的重要方法之一,用于对程序逻辑的正确性进行检查。一旦某个断言被认为是假的,意味着程序存在错误,此时需要进行排查和调试,以确保程序的正确性。

通常,断言语句被写在程序的关键、复杂或难以验证的部分,以帮助程序员进一步确保程序的正确性。这种方法可以让程序员及早发现代码中可能存在的错误,从而节约 Debug 时间,提高开发效率。同时,编写断言语句也是一种良好的编程习惯,尤其对保证代码质量至关重要。

使用虚幻引擎时,我们拥有三种族系的断言:CheckVerifyEnsure。为断言区分不同的族系,是因为不同类型的断言可能会有不同的执行情况。例如,是否执行断言语句中的代码;是否需要强制终止程序执行(还是只向用户报告一个错误信息)。在虚幻引擎中,大部分断言不会在发布版本中运行,因此断言通常是被用于开发(Development)和测试(Test)版本,对发布版本(Shipping)的性能几乎不会造成影响。


Check 族系

Check 族系的使用最接近 C++ 提供的基础的 assert 关键字用法。check 宏接受一个表达式,当表达式求出的值为 false 时会中断程序执行。

check 宏一般只会运行在 Debug、Development、Test、Shipping Editor 版本中(不包括 Shipping),但可以通过修改预编译宏 USE_CHECKS_IN_SHIPPING 1 使得 check 宏可以运行在所有版本当中。

以下是笔者认为最常见的用法:

  • check(Expression) 表达式为 false 会停止执行
  • checkf(Expression, FormattedText, ...) 同上,停止执行后会打印 FormattedText 中的内容

Check 族系的断言还提供了 Slow 后缀版本的宏,checkSlow 和 checkfSlow 分别用于代替不带 Slow 后缀的版本。Slow 后缀的断言仅在调试(Debug)版本中运行。

还有一些不是很常见的用法,这里搬运官方文档的解释:

  • checkCode(Code) 在运行一次的 do-while 循环结构中执行 Code;主要用于准备另一个 Check 所需的信息
  • checkNoEntry 若此行被 hit,则停止执行,类似于 check(false),但主要用于应不可到达的代码路径
  • checkNoReentry 若此行被 hit 超过一次,则停止执行
  • checkNoRecursion 若此行被 hit 超过一次而未离开作用域,则停止执行
  • unimplemented 若此行被 hit,则停止执行,类似于 check(false),但主要用于应被覆盖而不会被调用的虚拟函数


Verify 族系

Verify 族系的用法类似 Check 族系,不过在 check 宏不运行的版本当中,也会执行 verify 宏括号中包含的表达式。在 Shipping 版本的游戏当中,verify 虽然会忽略括号内的表达式的值,但依旧会执行当中的代码。

  • verfy(Expression) 表达式为 false 则停止执行
  • verify(Expression, FormattedText, ...) 同上,停止执行后输出 FormattedText 到日志

verify 同样也提供 Slow 版本,verify 仅在 Debug 版本中运行。


Ensure 族系

Ensure 族系的断言宏适合在非致命错误时使用。如果某个 ensure 断言条件未被满足,程序不会停止运行,但引擎会通知崩溃报告器(Crash Reporter)。为避免过多的崩溃通知,ensure 只会报告一次。当然,如果我们使用 Always 版本的宏,则每次命中 ensure 断言时都会通知崩溃报告器。

  • ensure(Expression) 首次为 false 时通知崩溃报告器
  • ensureMsgf(Expression, FormattedText, ...) 通知崩溃报告器的同时输出日志
  • ensureAlways(Expression) 每次为 false 时通知崩溃报告器
  • ensureAlwaysMsgf(Expression, FormattedText, ...) 通知崩溃报告器的同时输出日志

ensure 在 Debug、Development、Test、Shipping Editor 版本中运行,并且所有编译配置的版本中都会执行括号内的表达式代码。

使用场景

断言适合用于处理不确定会不会发生的严重错误,而不应用于捕捉用户操作导致的错误,例如检测打开的文件地址是否存在。我们应该采用更温和的方式来应对后面这种可预知的问题。

断言是一种不那么强制性的手段,它没有崩溃、错误会让程序直接中止那么严重,但在非调试情况下会让程序中止;断言不需要像使用 try-catch 或者错误返回码那样预设和处理可能的错误情况;断言并不适合处理所有情况的错误,但尽可能多地使用断言来检测可能发生错误的逻辑,可以帮助我们尽早发现潜在的问题,并适应未来不可预测的变化。

虚幻引擎的代码中经常会遇见断言,因为虚幻引擎提供多种多样的断言功能,既可处理严重错误,也可用于处理小型错误,类似于日志的功能。例如,在虚幻服务器的开发中,玩家最大人数可以通过配置表进行配置,在不知道将传入的最大玩家人数是多少的情况下,新增玩家可能会导致玩家人数超过预设限制,从而触发断言。在调试阶段,可以跳过断言并继续运行(方便调试),但如果部署在真正的服务器上,将导致崩溃并输出一段错误信息。

断言的特点

相比于其他各类调试和定位问题的手段,断言有如下特点:

  • 断言会强制中断程序运行。一出错,我们的游戏便会无情地终止,不会给予提示警告信息,或者使用别的什么错误处理机制来临时跳过错误。
  • 在开发时就及时发现问题。根据前一点,正在开发新功能时出现的错误便会在运行时命中断言检测,以便立刻进行修改和错误处理。在大型开发中,常常会同时推进多个功能,这些功能多数时候会相互依赖。而断言可以提前为正在开发的功能布局好“安全措施”,避免我们在测试时才发现问题。
  • 断言通常有额外开销。这是因为断言辅助调试和测试时,需要进行额外的检查来确定代码是否按照预期工作,并在断言失败时打印适当的错误信息。我们应该谨慎地使用断言,并避免在生产代码中使用它们,以便最大程度地提高程序的性能和效率。
  • 断言一般仅适用于开发和调试的版本。断言一般仅仅在开发阶段生效,配置为 Shipping 打包的版本并不会开启断言。如果要测试 Shipping 版本的游戏,比如测试 Gameplay 功能是否正常时,需要使用其它调试手段。

具体应用

Check

断言最常见的用法是判断指针是否为空:有时候,我们需要处理为空的情况,而另一些时候,传入的指针为空会导致关键错误,使得后面的代码执行变得毫无意义。于是,我们使用一个 check 宏来处理指针为空的情况。

以下是一段来自 GameMode.cpp 中的代码,在寻找一位玩家是否在服务器中时,使用了一个 check 宏来判断需要查找的 PlayerController 是否有效。

bool AGameMode::FindInactivePlayer(APlayerController* PC)
{
    check(PC && PC->PlayerState);
    
    // Do something
}


Verify

关于 verify 的整体使用都不算多,因为多数时候我们会自定义检测函数用于检测具体的功能,而不是使用断言。以下例子来自于官方文档,因为无论什么版本都需要执行如下代码,所以使用 verify 宏来判断。断言最好不要执行具体的功能,以减少代码的复杂性。

// 这将设置 Mesh 的值,并预计为非空值。若之后 Mesh 的值为空,则停止程序。
// 使用 Verify 而非 Check,因为表达式存在副作用(设置网格体)。
verify((Mesh = GetRenderMesh()) != nullptr);


Ensure

虚幻引擎中最常用的断言非 ensure 莫属,因为 ensure 可以在非致命错误时使用。正如 ensure 的中文含义“确保”一般,我们可以用 ensure 去确认任何我们不太确定的地方。

假设开发的是一款网络游戏,需要访问某个 HTTP 接口。该接口返回了一段 Json 数据用于我们初始化角色信息,比如性别、年龄、身高、捏脸数据等,但是服务器会返回什么并不是游戏客户端可以确认的,假若我们的后端工程师没有好好地工作,返回的内容有错误,则我们可以用一个 ensure 宏来确保返回的 Json 是正确的:

const TSharedRef<TJsonReader<>> JsonReader = TJsonReaderFactory<>::Create(Response->GetContentAsString());
TSharedPtr<FJsonValue> RootJsonValue;
FJsonSerializer::Deserialize(JsonReader, RootJsonValue);
// 下面这行代码解析网络返回的 Json 文件,尝试反序列化,如果失败便会命中 ensure 断言。
ensure(Json_Serialization::Deserialize(*RootJsonValue, ResponseContent));

第二个例子中,我们用 ensureMsgf 宏检测一些不是特别严重的错误,比如调用了一个不该调用的函数。false 将确保断言执行就被命中,同时输出一段消息告诉我们该怎么解决这个问题。不同于 checkNoEntry,这样的用法并不会导致游戏程序被强行中止。

void Server::ServerFunction()
{
#if WITH_SERVER_CODE
    // ...
#else
    ensureMsgf(false, TEXT("This function is server call only. Please fix this call on client."));
#endif //WITH_SERVER_CODE
}

总结

相较于编程语言原生的断言功能,虚幻引擎提供了多种多样的断言供我们使用。虽然本文并未一一列举所有类型的断言用例,但了解每种类型断言的特点、熟悉其使用环境才能确切选择适合自己项目的工具。根据笔者的经验,ensure 无疑是最为常用的选择,它除了能够带输出消息外,还可以多次或单次提醒,适用于处理大部分非严重错误。而对于严重错误,check 是首选,check 可以选择是否输出 log,还包括 checkNoEntry 这种不带参数只要运行即中断的版本。相比之下,verify 的适用范围最小,和 check 相比,除了可以执行括号内的代码以外,并未有太多特点。

参考

[1]. 断言 | 虚幻引擎文档
[2]. 浅谈 断言(assert) - 知乎
[3]. 杰森·格雷戈瑞. 游戏引擎架构. 北京: 电子工业出版社. 2019.
[4]. 泰南·西尔维斯特. 体验引擎:游戏设计全景探秘. 电子工业出版社. 2015.


图片:如无特别说明,文中图片均为作者自制
*本文内容系作者独立观点,不代表 indienova 立场。未经授权允许,请勿转载。

近期点赞的会员

 分享这篇文章

Dluck 

Gamer / Developer 

您可能还会对这些文章感兴趣

参与此文章的讨论

暂无关于此文章的评论。

您需要登录或者注册后才能发表评论

登录/注册