尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

UE中高级实战:C++与蓝图混合编程、模块化架构与性能优化

UE中高级实战:C++与蓝图混合编程、模块化架构与性能优化 1. 从“能跑蓝图”到“能改引擎”UE实战到底在练什么很多人学Unreal Engine的路径都差不多先拖几个Actor进场景连几个蓝图节点让角色动起来再跟着教程做个第三人称射击或者跑酷Demo感觉“差不多会了”。但真到了项目里遇到性能瓶颈、打包报错、C和蓝图互相调不明白、多平台构建各种奇葩问题的时候才发现之前那点东西根本不够用。这篇内容就是写给已经过了“蓝图入门”阶段、准备往UE中高级方向走的开发者重点聊UE实战里那些真正卡人的环节以及引擎架构层面需要理解的核心机制。先把定位说清楚这里不教你怎么装引擎、怎么创建工程那些内容官方文档和入门视频已经讲烂了。我要聊的是UE的C与蓝图混合编程实战、引擎模块化架构的拆解思路、性能分析与调试的落地方法以及打包部署阶段的高频坑点。适合已经能独立完成小型UE项目、但遇到复杂需求就不知道从哪下手的开发者。如果你还在纠结“蓝图和C学哪个”那说明你还没到需要看这篇内容的阶段建议先把蓝图的基础节点过一遍再回来。UE这个引擎有个特点它的架构设计非常“重”模块化程度极高但这也意味着你如果不理解它的模块依赖关系改一个地方可能引发连锁反应。比如你动了一个Core模块的接口可能整个Editor都起不来。所以UE实战的核心不是“会写C”而是理解引擎的架构分层知道什么该改、什么不该改、什么应该通过插件扩展。这个思路会贯穿整篇内容。2. UE的模块化架构到底怎么分层2.1 引擎的物理分层与逻辑分层UE的源码目录结构其实已经暗示了它的架构分层。打开引擎源码根目录你会看到Engine/Source/Runtime、Engine/Source/Editor、Engine/Source/Developer这几个大目录。Runtime放的是运行时核心模块Editor放的是编辑器专用模块Developer放的是一些开发工具模块。这个划分不是随便定的它对应的是编译依赖的层级关系Runtime模块不能依赖Editor模块Editor模块可以依赖Runtime模块。具体到模块层面最底层的是Core它提供基础的数据结构、数学库、内存管理、字符串处理这些。往上是CoreUObject这是UE对象系统的核心UObject、反射、序列化、垃圾回收都在这一层。再往上是Engine包含Actor、Component、World、Level这些游戏逻辑的基础设施。然后是各种功能模块比如RenderCore、RHI、PhysicsCore、AudioMixer等等。理解这个分层的好处是当你要加一个新功能时你能快速判断它应该放在哪一层。比如你要做一个自定义的资源类型那它应该继承UObject放在CoreUObject之上的某个模块里。如果你要做一个渲染相关的扩展那可能需要动RenderCore或者写一个依赖RenderCore的插件。层级搞错了编译依赖就会出问题轻则编译报错重则打包失败。2.2 模块描述文件与依赖管理每个UE模块都有一个.Build.cs文件这是模块的构建描述文件。里面最关键的是PublicDependencyModuleNames和PrivateDependencyModuleNames这两个列表。Public依赖意味着依赖这个模块的其他模块也能访问到被依赖模块的公共头文件Private依赖则只在当前模块内部可见。我见过太多项目在这块出问题有人把所有依赖都写成Public结果模块之间的耦合越来越重编译时间越来越长最后改一个头文件触发全量重编。正确的做法是尽量用Private依赖只有确实需要在公共头文件里暴露的类型才用Public依赖。比如你的模块公共头文件里用到了UObject那CoreUObject必须是Public依赖但如果只是在cpp文件里用到了某个数学库那它应该是Private依赖。还有一个容易忽略的点循环依赖。UE的构建系统不允许模块之间循环依赖A依赖B、B又依赖A编译直接报错。遇到这种情况通常的解法是把公共部分抽到一个更底层的模块里或者用接口/委托来解耦。这个在大型项目里非常常见尤其是当多个团队各自开发模块时接口设计没做好就很容易出现循环依赖。2.3 插件系统的架构价值UE的插件系统是它架构设计里非常聪明的一环。插件可以独立编译、独立启用禁用而且插件可以依赖引擎模块但引擎模块不会依赖插件。这意味着你可以把项目里的自定义功能全部做成插件引擎源码保持干净升级引擎版本时冲突会少很多。我自己的习惯是任何非通用的功能都做成插件。比如项目里的战斗系统、任务系统、UI框架全部放在Plugins目录下。这样做有几个好处第一引擎升级时只需要处理插件和引擎接口的兼容问题不用去merge引擎源码的改动第二插件可以按需启用比如服务端构建时可以把编辑器相关的插件禁掉减少包体第三插件可以独立做单元测试测试代码也放在插件里不污染引擎目录。插件的.uplugin文件里可以配置模块、平台白名单、加载阶段这些信息。加载阶段这个参数很关键它决定了插件在引擎启动的哪个阶段被加载。比如PreDefault阶段加载的插件可以在引擎默认模块之前初始化适合做一些底层扩展PostEngineInit阶段加载的插件在引擎初始化完成后才加载适合做游戏逻辑相关的功能。选错了加载阶段可能会遇到插件里的对象还没注册就被引用的问题。3. C与蓝图混合编程的实战边界3.1 什么时候用C什么时候用蓝图这个问题被问得最多但答案其实不复杂性能敏感的逻辑、需要频繁调用的逻辑、需要暴露给多个系统复用的逻辑用C快速原型验证、UI交互逻辑、简单的场景触发逻辑用蓝图。但实际项目里这个边界往往更模糊因为还要考虑团队人员构成、迭代速度、调试便利性这些因素。我经历过的一个典型场景是角色移动逻辑。最初用蓝图做的CharacterMovement组件加上几个自定义的蓝图节点跑起来没问题。但后来加了网络同步、加了技能系统对移动的干预、加了动画状态机对移动状态的查询蓝图节点数量爆炸调试起来非常痛苦而且每次改一个参数都要重新编译蓝图迭代效率很低。后来把移动逻辑的核心部分用C重写蓝图只负责配置参数和做表现层的响应问题就解决了。所以我的经验是当一个蓝图逻辑的节点数超过50个或者它被超过3个其他蓝图引用或者它涉及网络同步就应该考虑用C重写。这个阈值不是绝对的但可以作为一个参考。3.2 UPROPERTY和UFUNCTION的正确用法C和蓝图交互的核心是UPROPERTY和UFUNCTION这两个宏。UPROPERTY让C成员变量暴露给蓝图和编辑器UFUNCTION让C函数可以被蓝图调用或者被蓝图重写。UPROPERTY的说明符很多常用的有EditAnywhere在编辑器中可编辑、BlueprintReadWrite蓝图可读写、VisibleAnywhere编辑器中可见但不可编辑、BlueprintReadOnly蓝图只读。这里有个坑BlueprintReadWrite的变量在蓝图中修改后C端的值会同步变化但如果你在C端直接修改这个变量蓝图端的缓存可能不会立即更新。这是因为蓝图的变量访问走的是UObject的属性系统而C直接访问内存。正确的做法是始终通过Set函数来修改变量或者在修改后调用MarkPackageDirty通知编辑器。UFUNCTION的说明符里BlueprintCallable让函数可以在蓝图中被调用BlueprintImplementableEvent让函数在C中声明、在蓝图中实现BlueprintNativeEvent让函数在C中有默认实现、同时允许蓝图重写。BlueprintNativeEvent需要配合_Implementation后缀的函数来实现默认逻辑这个命名规则是强制的写错了编译能过但运行时会找不到实现。3.3 蓝图原生类与C基类的继承关系UE里有一个很重要的概念叫“蓝图原生类”也就是继承自C类的蓝图类。这种继承关系让C提供基础框架蓝图做具体配置和扩展。比如你写一个AWeaponBase的C类定义好开火、换弹、瞄准的接口和默认实现然后创建BP_Rifle、BP_Shotgun这些蓝图子类在蓝图里配置具体的模型、动画、音效、数值参数。这种模式的好处是逻辑复用和配置分离。但要注意一点蓝图子类不能删除C父类里定义的组件。比如C父类里有一个USkeletalMeshComponent蓝图子类只能修改它的属性不能把它删掉换成另一个类型的组件。如果确实需要不同的组件结构那应该在C层面用继承或者组件组合来实现而不是指望蓝图去改结构。还有一个常见问题是构造函数的执行顺序。C类的构造函数在蓝图子类被创建时也会执行但蓝图子类的属性配置是在构造函数之后应用的。所以如果你在C构造函数里读取某个在蓝图中配置的属性读到的会是默认值而不是蓝图里配的值。正确的做法是在BeginPlay或者PostInitializeComponents里读取这些配置。4. 性能分析与调试的落地方法4.1 用Unreal Insights做帧级分析Unreal Insights是UE自带的性能分析工具比传统的Profiler好用很多。启动方式是在命令行加-tracehost127.0.0.1 -tracedefault参数然后打开Unreal Insights应用连接上去。它会记录每一帧的CPU和GPU耗时精确到每个函数、每个系统。我一般用Insights排查三类问题帧率突然下降、内存持续增长、加载时间过长。帧率问题看Timing视图找到耗时最高的那几个函数通常就是罪魁祸首。内存问题看Memory视图关注UObject的数量和总内存占用如果UObject数量持续增长不下降那大概率是某个地方持有引用没释放。加载问题看Loading视图能看到每个资源的加载耗时优化大资源或者做异步加载。有个细节要注意Insights的Trace数据量很大长时间录制会产生几个G的文件。所以一般只录制出问题的那几秒不要一直开着。另外Development构建和Shipping构建的性能特征可能差别很大最终性能评估一定要在Shipping构建下做。4.2 蓝图性能的常见陷阱蓝图的性能问题往往比C更隐蔽因为蓝图的执行开销不直观。几个常见的蓝图性能陷阱每帧执行的蓝图逻辑。比如在Event Tick里做复杂的计算或者频繁的Get/Set操作。蓝图的每个节点都有执行开销一个Tick里跑几十个节点开销就很可观了。解法是把不需要每帧执行的逻辑改成定时器或者事件驱动。蓝图中的ForEachLoop。蓝图里的循环节点在处理大量数据时性能很差因为每次迭代都有蓝图虚拟机的开销。如果循环次数超过几百次建议用C实现。蓝图中的字符串操作。蓝图的字符串拼接、比较、转换都很慢因为涉及到内存分配和编码转换。如果需要在UI上频繁更新文本建议在C里处理好再传给蓝图。蓝图中的Cast节点。Cast在蓝图里是相对昂贵的操作尤其是当Cast失败时。如果在一个Tick里做多次Cast性能影响会很明显。解法是用接口或者用IsA做类型判断。4.3 内存泄漏的排查思路UE的内存管理基于UObject的引用计数和垃圾回收。理论上只要没有强引用UObject就会被回收。但实际项目里内存泄漏还是很常见主要原因是意外的强引用。常见的强引用来源UPROPERTY标记的成员变量、TArray或TMap里存储的UObject指针、委托绑定、定时器回调、异步加载的回调。排查方法是打开Stat Memory看内存增长趋势然后用Obj List命令列出所有UObject按类名排序看哪个类的实例数量异常增长。还有一个工具是Reference Viewer在编辑器里右键任意UObject就能打开能看到这个对象被谁引用了。如果发现某个对象应该被回收但一直没回收用Reference Viewer查一下引用链通常就能找到问题。5. 打包部署阶段的高频坑点5.1 打包配置的常见错误UE的打包配置项很多DefaultEngine.ini、DefaultGame.ini、DefaultInput.ini这几个文件里的配置直接影响打包结果。几个容易出问题的点地图和资源列表。打包时需要在Project Settings Packaging Additional Asset Directories to Cook里指定需要额外打包的目录否则运行时动态加载的资源可能找不到。另外Maps to Cook列表要确认包含了所有需要的地图。Shader编译。打包时会编译所有用到的Shader如果项目里用了自定义Shader或者修改了引擎的Shader文件打包时间会很长。建议在打包前先做一次Compile All Shaders把Shader缓存建好后续打包会快很多。插件启用状态。打包时只启用需要的插件编辑器专用的插件比如各种Editor扩展、调试工具在打包版本里应该禁用否则会增加包体甚至导致打包失败。5.2 跨平台构建的注意事项UE支持Windows、Mac、Linux、Android、iOS等多个平台但跨平台构建的坑不少。Windows平台相对最稳因为引擎主要在Windows上开发。Android和iOS的构建问题最多主要是SDK版本、NDK版本、签名配置这些。Android构建要注意NDK版本必须和引擎要求的版本一致版本不匹配会导致编译失败或者运行时崩溃。另外Android的纹理压缩格式要选对不同GPU支持的格式不一样选错了会导致纹理显示异常或者性能下降。iOS构建要注意证书和Provisioning Profile的配置这个在Apple开发者后台配置好之后在UE的Project Settings里填对Team ID和Bundle Identifier。另外iOS对内存的限制比桌面平台严格很多在iOS上跑之前一定要用Stat Memory确认内存占用在安全范围内。5.3 打包后的运行时问题排查打包后的版本和编辑器里跑可能表现不一样常见的问题包括资源加载失败、蓝图逻辑不执行、性能下降、崩溃。资源加载失败通常是Cook阶段漏掉了资源检查Saved/Cooked目录下有没有对应的资源文件。蓝图逻辑不执行可能是蓝图被编译成了Development Only在Shipping构建里被剔除了检查蓝图的Compile Mode设置。性能下降可能是Shipping构建的优化选项和Development不同或者某些调试功能在Shipping里被禁用了。崩溃的话拿到Crash Log之后用UnrealEngine\Engine\Binaries\DotNET\CrashReportClient分析能看到调用栈和崩溃原因。6. 从项目实战中沉淀的架构经验6.1 用接口解耦模块依赖UE的接口UInterface是实现模块解耦的利器。比如你的战斗系统和UI系统需要交互但UI系统不应该直接依赖战斗系统的具体类。这时候定义一个ICombatInterface战斗系统实现这个接口UI系统只依赖接口不依赖具体实现。这样战斗系统换实现、UI系统换框架互相都不影响。接口的定义要注意UInterface本身不包含实现实现类需要继承IYourInterface。接口方法在C里是纯虚函数在蓝图里是BlueprintImplementableEvent或者BlueprintNativeEvent。调用接口方法时用Execute_YourFunction这个静态函数不要直接调用虚函数否则蓝图实现的版本不会被调用。6.2 用子系统管理全局状态UE的Subsystem系统是管理全局状态的好工具。UGameInstanceSubsystem、UWorldSubsystem、ULocalPlayerSubsystem分别对应不同的生命周期。比如游戏设置、存档管理适合放在GameInstanceSubsystem里关卡相关的逻辑适合放在WorldSubsystem里。Subsystem的好处是自动管理生命周期不需要手动创建和销毁而且可以通过GetSubsystem在任何地方获取。但要注意Subsystem的初始化顺序Initialize函数里不要依赖其他Subsystem已经初始化完成因为顺序是不确定的。如果确实有依赖关系用ShouldCreateSubsystem来控制创建条件或者在Initialize里做延迟初始化。6.3 用数据驱动替代硬编码UE的DataTable和DataAsset是数据驱动的核心工具。DataTable适合存储结构化的表格数据比如物品配置、技能配置、关卡配置。DataAsset适合存储单个复杂对象的数据比如角色配置、武器配置。数据驱动的好处是策划可以独立配置程序不需要改代码。但要注意DataTable的行结构体必须是FTableRowBase的子类而且结构体里的字段类型要有限制不是所有类型都能放在DataTable里。DataAsset更灵活可以嵌套引用其他DataAsset适合做复杂的配置树。我自己的项目里所有数值配置都放在DataTable里所有资源引用都放在DataAsset里。这样策划改数值不需要程序介入程序改逻辑也不需要策划重新配资源。这个分工在团队协作里非常重要能省掉大量沟通成本。6.4 版本管理与引擎升级策略UE项目的版本管理有几个特殊点二进制资源文件.uasset、.umap的diff和merge很困难所以一般建议对二进制资源做文件锁避免多人同时修改同一个资源。源码部分用Git或者Perforce管理但要注意.gitignore要配好Binaries、Intermediate、Saved这些目录不应该提交。引擎升级是个大工程尤其是当项目修改了引擎源码时。我的建议是尽量不要改引擎源码所有扩展都通过插件实现。如果确实需要改引擎把改动做成patch文件单独管理升级时重新应用patch。另外升级前一定要做完整的回归测试引擎版本升级带来的行为变化可能很隐蔽比如某个API的默认参数变了、某个组件的默认值变了这些都可能影响游戏逻辑。7. 几个实际踩过的坑与排查记录7.1 蓝图编译导致的打包失败有一次打包Android版本编辑器里跑得好好的打包一直报错说某个蓝图编译失败。查了半天发现是那个蓝图里用了一个只在编辑器下可用的节点打包时这个节点被剔除了导致蓝图编译不过。解法是把那个节点换成运行时也可用的替代方案或者用WITH_EDITOR宏把编辑器专用的逻辑包起来。这个坑的教训是打包前一定要在Development构建下完整跑一遍不要只在编辑器里测试。编辑器环境和运行时环境的差异比想象中大。7.2 异步加载导致的资源空引用项目里用了AsyncLoadAsset做异步加载大部分时候没问题但偶尔会崩溃说资源为空。排查后发现是异步加载的回调里没有检查加载结果直接用了返回的资源指针。异步加载可能失败资源不存在、路径错误、内存不足回调里必须检查返回值。另外异步加载的另一个坑是资源被GC回收。如果异步加载完成后没有及时把资源引用保存到UPROPERTY变量里下一帧GC就可能把它回收掉。解法是在回调里立即把资源赋值给一个UPROPERTY成员变量确保有强引用。7.3 网络同步中的角色权限问题做多人游戏时遇到一个经典问题客户端修改了角色状态但服务器不认可导致状态回滚。原因是客户端直接修改了Replicated变量但没有通过Server RPC通知服务器。UE的网络模型是服务器权威客户端只能通过RPC请求服务器修改状态服务器修改后再同步回客户端。正确的做法是客户端调用Server RPC在RPC的实现里修改Replicated变量然后UE会自动把修改同步到所有客户端。如果需要在客户端立即看到反馈比如开火特效可以用Multicast RPC或者本地预测但最终状态以服务器为准。7.4 常见问题速查表问题现象可能原因排查方法解决方案打包后资源加载失败Cook阶段漏资源检查Saved/Cooked目录在Packaging设置里添加资源目录蓝图逻辑在打包版不执行蓝图被标记为Development Only检查蓝图Compile Mode改为Default或Shipping运行时崩溃调用栈指向UObject对象被GC回收用Reference Viewer查引用链用UPROPERTY持有强引用网络同步状态不一致客户端直接修改Replicated变量检查变量修改位置通过Server RPC修改帧率突然下降某系统耗时突增用Unreal Insights看Timing优化耗时函数或改异步内存持续增长UObject泄漏用Obj List看实例数量排查强引用来源跨平台构建失败SDK/NDK版本不匹配检查引擎要求的版本安装对应版本SDK插件功能在打包版失效插件未启用或加载阶段错误检查uplugin配置调整加载阶段和启用状态8. 进阶方向从使用者到贡献者8.1 阅读引擎源码的方法UE的源码有上千万行不可能全部读完。有效的阅读方法是带着问题读。比如你想知道Actor的Tick是怎么被调用的那就从AActor::Tick开始往上追UWorld::Tick再往上追UEngine::Tick把调用链理清楚。这种问题驱动的阅读方式比从头到尾翻源码高效得多。另外要善用IDE的跳转功能。Visual Studio或者Rider都有很好的C索引能快速跳转到定义、查找引用。UE的源码里有很多宏和模板IDE可能解析不了这时候可以用CtrlShiftF全局搜索来辅助。8.2 给引擎提PR的流程如果你发现引擎有bug或者想加功能可以给UE的GitHub仓库提PR。流程是fork仓库、在本地修改、跑测试、提交PR、等Epic的审核。审核周期可能比较长而且Epic对引擎代码的质量要求很高代码风格、注释、测试都要符合规范。提PR之前建议先在论坛或者Issue里讨论一下确认这个改动是Epic愿意接受的。有些改动虽然合理但可能和引擎的长期规划冲突提了也不会被merge。另外PR的改动要尽量小一个PR只做一件事不要混多个不相关的修改。8.3 社区资源与学习路径UE的社区很活跃官方论坛、Discord、Reddit都有大量讨论。官方文档虽然有些地方更新不及时但基础概念和API说明还是最权威的。另外Epic的Learning Portal有很多免费教程从入门到高级都有。学习路径上我的建议是先广度后深度。先把UE的主要系统都过一遍知道每个系统大概能做什么、怎么用然后选一个方向深入。比如你对渲染感兴趣就深入学RenderCore、RHI、Shader编程对Gameplay感兴趣就深入学GameplayAbilitySystem、网络同步、AI。不要一开始就钻到一个很窄的方向里那样容易只见树木不见森林。最后分享一个我自己的习惯每学一个新系统就写一个最小可运行的Demo。比如学GAS就写一个最简单的技能释放Demo学网络同步就写一个最简单的多人移动Demo。这些Demo代码量不大但能帮你快速验证理解是否正确而且以后遇到相关问题可以回头参考。这个习惯坚持下来积累的Demo库就是你自己最好的参考手册。
返回列表