ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UE5编辑器Slate自定义组件实战:SCompoundWidget构建工具面板

UE5编辑器Slate自定义组件实战:SCompoundWidget构建工具面板 又回到UE5编辑器自定义这个话题。前两篇我写过怎么挂菜单、怎么注册命令、怎么把自定义逻辑塞进现有按钮这些都属于把入口做出来。但做编辑器工具的人应该都有同感一旦功能复杂起来光有入口不够你需要真正能承载交互的面板。默认的Editor Utility WidgetUMG能解决一部分问题但经常会被它的控件树、变量绑定和刷新机制限制住尤其是想做一个性能可控、数据实时联动、布局能自己掌控的工具时直接上手写Slate反而更痛快。这篇文章我想聊的就是这个层面在UE5编辑器里用SCompoundWidget写自定义组件到底该怎么落地。我会从为什么选SCompoundWidget而不是SLeafWidget到完整实现一个可以塞进编辑器Tab的小组件再到数据联动和踩坑复盘一步步拆开讲。适合已经写过插件、对C不陌生、想摆脱UMG限制的开发者如果你是刚接触Slate也能从代码结构里直接抄作业因为这套组件骨架非常固定。1. 为什么自定义组件几乎都落在SCompoundWidget这条继承链上1.1 Slate控件树里的叶节点和容器节点要分清很多人第一次看Slate源码时会被SButton、STextBlock、SVerticalBox这一堆类名绕晕。其实它们的继承关系并不复杂SLeafWidget是叶节点它没有子槽位自己画自己SCompoundWidget是复合节点它内部有一个ChildSlot可以在上面再挂任意子控件树。你打开任何一个编辑器里的工具面板从属性面板到世界大纲视图基座绝大多数都是SCompoundWidget或其派生类。原因很朴素编辑器工具不是画一个按钮就完事而是要把按钮、列表、输入框组合成一个有交互逻辑的工作区。SCompoundWidget提供的ChildSlot正好解决了组合这件事。1.2 声明式语法和布局约束是这套东西的核心Slate最爽的一点是声明式构建。用SNew套布局写出来的代码和界面的结构是对应的不需要像UMG那样在编辑器里拖拖拽拽再绑定事件。它也不是基于重绘消息循环控件树的构建和布局传递都在构造阶段完成逻辑上清晰很多。SCompoundWidget只是基座真正决定控件长什么样的是你在ChildSlot里挂的布局组合。这一步的技术含量主要集中在两件事第一怎么组织控件的嵌套层次让信息展示顺序符合用户习惯第二怎么设置Slot的填充策略AutoHeight还是FillHeight让不同分辨率下不出现遮挡或空白。1.3 它和UMG编辑器控件的边界在哪我在前面的文章里反复强调过一句能快速原型验证用UMG要做深度编辑器集成用Slate。UMG的Editor Utility Widget在UE5里确实进步很大支持自定义函数、可以访问编辑器API但它的控件本质是运行时控件封装刷新和事件绑定往往要绕一层。而SCompoundWidget直接跑在Slate层你可以用TAttribute绑定任意数据、用事件委托和编辑器全局数据实时联动性能开销也可控。给个参考数值一个稍复杂的Editor Utility Widget面板一旦同时放超过二十个控件且频繁刷新编辑器界面就能感觉到明显的卡顿。同样场景换成纯Slate的SCompoundWidgetFPS基本不受影响。原因在于UMG的每次属性变化都可能触发整棵绑定树的状态检查而Slate可以做到只刷新局部。2. 动手写一个SCompoundWidget从类骨架到可见界面2.1 编译环境和模块依赖怎么配写Slate控件不一定要单独建一个插件在现成的编辑器模块里加代码也可以。但模块依赖必须配齐否则编译的时候头文件都找不到。在插件的.Build.cs里至少需要加上这几个模块PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, Slate, SlateCore, UnrealEd, EditorStyle });注意UE5.0之后EditorStyle模块还在但官方逐渐在推FAppStyle如果你使用的是UE5.1以上推荐直接引用FAppStyle去拿图标和颜色兼容性更好。如果只做纯工具面板不涉及资产编辑就无需引入UMG模块省得编译时间被拖长。2.2 头文件里先立规矩用宏声明出控件的对外参数SCompoundWidget和一个普通UObject类最大的不同是要写大量的宏来生成声明式语法。别嫌这些宏啰嗦它们是Slate能写出那种像搭积木一样的代码的关键底层支撑。下面是一个最简但完整的自定义组件声明#pragma once #include Widgets/SCompoundWidget.h #include AssetRegistry/AssetData.h class MYTOOL_API SMyAssetPanel : public SCompoundWidget { public: SLATE_BEGIN_ARGS(SMyAssetPanel) {} SLATE_ARGUMENT(TWeakObjectPtrUObject, SourceAsset) SLATE_ARGUMENT(FText, DisplayName) SLATE_EVENT(FSimpleDelegate, OnAssetChanged) SLATE_END_ARGS() void Construct(const FArguments InArgs); void UpdateAsset(TWeakObjectPtrUObject NewAsset); private: TSharedRefSTextBlock CreateInfoText() const; EVisibility GetEmptyHintVisibility() const; FReply HandleRefreshClicked(); TWeakObjectPtrUObject SourceAsset; FSimpleDelegate OnAssetChanged; TSharedPtrSTextBlock InfoTextBlock; };SLATE_ARGUMENT用来声明传入参数TWeakObjectPtr适合传UObject对象不会因为控件持有强引用导致资产无法卸载。SLATE_EVENT则是定义事件回调槽位外部可以通过绑定Lambda或成员函数来处理控件的通知。之所以把参数和事件都放在宏里是为了让调用方可以用命名参数的方式构造控件代码可读性会高很多。比如SNew(SMyAssetPanel) .SourceAsset(SelectedAsset) .DisplayName(FText::FromString(TEXT(当前选中))) .OnAssetChanged(this, FToolkit::HandleAssetChanged)2.3 Construct里的ChildSlot把控件树搭起来写Slate控件的核心其实就在Construct函数。它决定了这个组件第一次出现在编辑器里时长什么样。下面这段是一个能实际跑起来的面板布局包含了垂直盒、水平盒、文本、按钮void SMyAssetPanel::Construct(const FArguments InArgs) { SourceAsset InArgs._SourceAsset; OnAssetChanged InArgs._OnAssetChanged; ChildSlot [ SNew(SVerticalBox) SVerticalBox::Slot() .AutoHeight() .Padding(8.f, 4.f) [ SNew(STextBlock) .Text(InArgs._DisplayName) .Font(FCoreStyle::GetDefaultFontStyle(Bold, 12)) ] SVerticalBox::Slot() .AutoHeight() .Padding(8.f, 4.f) [ SNew(STextBlock) .Text(this, SMyAssetPanel::GetAssetNameText) .ColorAndOpacity(FSlateColor(FLinearColor(0.6f, 0.8f, 1.0f))) ] SVerticalBox::Slot() .FillHeight(1.f) .Padding(8.f, 4.f) [ SNew(SButton) .Text(FText::FromString(TEXT(刷新))) .OnClicked(this, SMyAssetPanel::HandleRefreshClicked) ] ]; }注意到我准备了GetAssetNameText和HandleRefreshClicked这两个私有函数在头文件里没有声明。这不严谨正确做法是在类里把GetAssetNameText声明为FText类型的成员函数不然编译过不去。这里展开代码是为了表达布局树写起来就是这样嵌套到你实际编码时把该声明的前置声明补齐即可。ChildSlot里可以放任意SWidget派生对象SVerticalBox、SHorizontalBox、SGridPanel、SSplitter、SScrollBox都是常用的布局容器。我通常的习惯是固定高度的信息行用AutoHeight扩展区域用FillHeight中间需要拖动分离的区域就用SSplitter。2.4 先别追求完美把它挂到编辑器窗口里看效果组件写完了看不到界面等于白写。我推荐最粗暴的方式在现有的Editor Tab上临时替换内容。如果你的插件已经注册了Tab直接在SpawnTab的返回体里用SNew(SMyAssetPanel)放到Tab内容槽TSharedRefSDockTab FMyToolkitTab::OnSpawnTab(const FSpawnTabArgs SpawnTabArgs) { return SNew(SDockTab) .TabRole(ETabRole::NomadTab) [ SNew(SMyAssetPanel) .DisplayName(FText::FromString(TEXT(我的资产面板))) ]; }编译启动编辑器后点击入口打开这个Tab如果能看到文本和按钮组件就算全链路打通了。一个容易忽略的细节是编辑器启动阶段如果频繁改Slate布局导致编译了缓存图形界面往往不会热更新全部模块建议彻底关闭编辑器重新编译后启动不然容易出现代码更新了但界面还是老样子的错觉。3. 数据联动让组件实时响应资产选择与属性变化3.1 事件挂接的正确姿势一个静态面板没有实际工程价值。真正有用的工具要能感知当前选了什么资产、属性改了什么然后实时更新界面。Slate里做这件事不靠Tick而是靠事件监听和TAttribute绑定。在Construct阶段可以挂上编辑器全局事件GEditor-GetEditorSubsystemUAssetEditorSubsystem() -OnAssetOpened.AddRaw(this, SMyAssetPanel::HandleAssetOpened); // 资产选择变化的监听推荐挂到内容浏览器底层 FEditorDelegates::OnAssetsPreDelete.AddSP(this, SMyAssetPanel::HandleAssetsPreDelete);注意这里用了AddRaw和AddSP本质分别是裸指针和共享指针的委托绑定。AddRaw要求你在析构时务必Unregister否则编辑器关闭Tab时回调会撞上一个已销毁的this直接崩。AddSP相对安全一些但前提是控件的生命周期在线程栈上确实受控。实际项目里我更推荐用OnAssetSelectionChanged之类的全局事件把数据推给控件。引擎层面确实有对应的委托比如UContentBrowserDataSubsystem里的相关回调不同UE5小版本的签名会有差异建议打开引擎源码查一下当前版本的精确声明。不要凭记忆写我被版本签名坑过不止一次。3.2 TAttribute让文本和可见性自己跟着数据走Slate里最优雅的联动方式是在构建时把某个属性的显示逻辑绑到一个函数上。不是每次数据变更都要手动SetText而是Slate在需要绘制或测量时自动调用你的函数去取当前值。这就把数据驱动界面落到了实时计算上。最常见的写法是.Text(this, SMyAssetPanel::GetAssetNameText) .Visibility(this, SMyAssetPanel::GetEmptyHintVisibility)GetAssetNameText返回FTextGetEmptyHintVisibility返回EVisibility。每次Slate检查控件状态时会把函数返回值和上一次对比不同就触发局部更新。这个机制在控件数量不大时非常高效且省去很多手动刷新的代码。用这个思路把资产名、路径、三角形数量、材质数量这些信息展示出去面板看起来就活了。但要留意不要在Get函数里做重型I/O比如读磁盘、反射序列化。因为Slate在一次界面更新周期里可能多次调用这些函数重逻辑会导致编辑器卡顿。3.3 属性修改后如何触发局部刷新不光要展示数据通常还要让用户改数据比如改资产的名字、改一个boolean属性。改完后界面不能傻掉。Slate没有内建的属性变化自动通知机制你需要自己补一条链路。通用的做法是在控件里调用产业对象的Modify修改属性后调用PostEditChangeProperty然后让控件主动刷新自己的TextBlock。这里贴一段我在工具里常用的刷新操作void SMyAssetPanel::HandleAttributeChanged(FName PropertyName) { if (UObject* Asset SourceAsset.Get()) { Asset-Modify(); FPropertyChangedEvent ChangeEvent( FindFPropertyFProperty(Asset-GetClass(), PropertyName), EPropertyChangeType::ValueSet ); Asset-PostEditChangeProperty(ChangeEvent); // 刷新文本 if (InfoTextBlock.IsValid()) { InfoTextBlock-SetText(GetAssetNameText()); } } }这里的关键在于Modify和PostEditChangeProperty必须成对出现。前者让编辑器能记录Undo快照后者是属性系统的标准通知出口漏掉PostEditChangeProperty即便界面刷新了依赖属性变化的其他系统比如场景里的Actor不会同步更新。4. 只讲操作不提坑就是在耍流氓我踩过的Slate崩溃和坑4.1 生命周期不归你管引用更不能乱存SWidget是基于共享指针管理的对象你在回调里捕获this就要负责解开。最常见的崩溃来自于Tab被关闭控件被销毁但某个全局委托还持有指向这个控件的裸指针下次事件触发时就踩野指针。我在一个批量重命名工具里遇到过保存场景时直接崩溃查了半天才发现是OnAssetsPreDelete里AddRaw绑定了面板而面板在关闭Tab时没解绑。当时排查方式也简单崩溃堆栈里看到的是SWidget相关调用但往上翻一层发现是全局委托在驱动它。这个坑的解法不是记得解绑这种口号而是设计层面规避尽量让你的组件用AddSP绑定自己而不是AddRaw。SDockTab持有的是TSharedRef面板的存活周期随Tab走SP委托在Tab销毁时会自动失效。4.2 不要在Tick里做反射或路径查询有人写工具图省事直接在Tick里GetAllObjectsOfClass、做路径解析取资产。这种行为在调试期好像没事等项目资产超过几百个编辑器帧率会明显下跌后台还会伴随大量ShaderCompileWorker进程的CPU占用。从UE5项目实际表现看编辑器卡顿经常不是单一原因。Slate控件自身在Tick里做重运算只是其一另一类你控制不了的是引擎在编辑器界面触发内容变化后自动重建渲染资源比如材质编译。遇到后台ShaderCompilerWorker突然报fatal error第一步不要怀疑你的Slate逻辑先检查最近是不是改了海量材质或加了高复杂度节点。自定义组件背锅的案例十有八九是混淆了界面卡顿和渲染资源编译卡顿。我的经验准则Tick里只做轻量逻辑比如读取TWeakObjectPtr判断对象是否有效、更新TextBlock的文本。一切遍历资产、搜索路径、访问磁盘相关的事情都放到按钮点击事件或延迟定时器里去做。4.3 布局SNew嵌套过深时的括号地狱Slate代码写多了很容掉进一种局面布局嵌套六层结尾的方括号和右括号多到怀疑人生。编译报错时定位困难有时还会因为少写一个中括号导致控件树结构错乱界面显示不是你想的样子。对付这个问题我的笨办法是先搭外层SVerticalBox编译运行确认能显示再往里面加一行布局。一次加一小块每次改动范围都能控制住出了错也不会全盘重来。4.4 控件可见性和空数据要走到前头空状态处理是很多新手写面板时最容易遗忘的。一开始数据都是满的觉得展示层没问题等用户切换到一个空资产目录面板就露出一大片空白或崩溃。所以要养成一个习惯所有依赖外部数据的TextBlock都要配套一个GetXxxVisibility函数。判断逻辑非常简单资产无效就返回EVisibility::Collapsed让整个文字区域隐藏同时显示一个请选择资产的提示文本。这不多费几行代码但能让工具质量上一个台阶。真实商业项目里编辑器的空状态体验也是被重点评审的因为这直接关系到用户信不信任这个工具。4.5 FText和FString混用时的编码坑还有个小坑不常提但遇上了很烦从资产路径格式化出来的FString直接塞给Text属性有时显示出来的是乱码或问号。Slate的文本系统在底层按FText语义处理本地化你如果只是临时调试可以用FText::FromString但如果要长期维护建议为可本地化的展示文本建立FText ID。编辑器工具通常不需要多语言但路径中含中文和特殊字符时FText::FromString配合标准路径转换函数能规避大部分乱码问题。5. 一个完整的小案例做一个当前资产信息面板5.1 功能和设计讲了那么多理论不如来一个可以直接跑的小工具。目标面板实现三个功能显示当前内容浏览器选中资产的名称、显示资产路径和类名、提供一个复制路径按钮。整体就一个文件类加一个Tab注册硬件成本很低。这个面板的交互链路是内容浏览器选中资产 - 编辑器全局选择事件触发 - 面板更新三个TextBlock - 点击按钮把路径复制到剪贴板。不看代码只听描述会以为要写不少消息实际实现却非常清爽。5.2 核心实现类定义沿用第二节的骨架新增三个TextBlock成员和两个处理函数。关键在Construct里创建分区布局void SAssetInfoPanel::Construct(const FArguments InArgs) { AssetNameText SNew(STextBlock) .Text(FText::FromString(TEXT(未选择资产))) .Font(FCoreStyle::GetDefaultFontStyle(Bold, 14)); AssetPathText SNew(STextBlock) .Text(FText::GetEmpty()) .ColorAndOpacity(FSlateColor(FLinearColor(0.4f, 0.4f, 0.4f))); ChildSlot [ SNew(SVerticalBox) SVerticalBox::Slot() .AutoHeight() .Padding(8.f, 4.f) [ AssetNameText.ToSharedRef() ] SVerticalBox::Slot() .AutoHeight() .Padding(8.f, 4.f) [ AssetPathText.ToSharedRef() ] SVerticalBox::Slot() .AutoHeight() .Padding(8.f, 4.f) [ SNew(SButton) .Text(FText::FromString(TEXT(复制路径))) .OnClicked(this, SAssetInfoPanel::HandleCopyPathClicked) ] ]; // 全局选中事件驱动面板刷新 FEditorDelegates::OnSelectedAssetsChanged.AddSP( this, SAssetInfoPanel::HandleSelectionChanged); }AssetNameText和AssetPathText为什么用TSharedPtr而不是普通成员因为需要在事件处理函数里调用它们的SetText如果只在构建时使用一次用局部值绑定到Text属性也可以但既然要动态更新就得持有可访问的指针。5.3 更新逻辑处理选中变化的回调要尽量简洁避免在Slate更新周期里做耗时操作void SAssetInfoPanel::HandleSelectionChanged() { TArrayFAssetData SelectedAssets; GEditor-GetContentBrowserSelections(SelectedAssets); if (SelectedAssets.Num() 0 || SelectedAssets[0].IsValid() false) { AssetNameText-SetText(FText::FromString(TEXT(未选择资产))); AssetPathText-SetText(FText::GetEmpty()); return; } const FAssetData Asset SelectedAssets[0]; AssetNameText-SetText(FText::FromName(Asset.AssetName)); AssetPathText-SetText(FText::FromString(Asset.GetSoftObjectPath().ToString())); }这段代码里最值得注意的就是GetContentBrowserSelections它返回的是当前内容浏览器里选中的资产列表。新版UE5里也可以走FContentBrowserModule的GetSelectedAssets回调监听选中变化但全局委托更省事不用引一堆内容浏览器私有头文件。5.4 复制到剪贴板时的平台差异HandleCopyPathClicked的写法反映了编辑器插件中跨平台的小问题FReply SAssetInfoPanel::HandleCopyPathClicked() { const FString AssetPath AssetPathText-GetText().ToString(); if (!AssetPath.IsEmpty()) { FPlatformApplicationMisc::ClipboardCopy(*AssetPath); } return FReply::Handled(); }如果是旧代码有些人会把FWindowsPlatformMisc或FPlatformMisc直接用进ClipboardCopy不同平台实现不一致。FPlatformApplicationMisc是统一抽象层UE5里用这个最稳妥。编辑器插件如果不考虑跨平台早晚会被用户在同一项目里用Mac配Win的同事教育。5.5 延伸思路这个面板虽然简单但扩展性很好。往里加一个显示选中资产类型的文本、加一个打开资产所在目录的按钮、甚至加一个右键菜单和面板联动都是顺着同一套事件驱动模式在延伸。你真正吃透了SCompoundWidget的套路后面写批量贴图压缩工具、写数据表格校验器都是同一套工序。我在实际项目里还做过一个更复杂的版本给资产加了标签管理用户按住Ctrl点资产时面板能高亮显示多个资产的关键信息。实现上并没有特别大的技术含量核心还是把选中状态通过事件推到控件里再通过TAttribute通知界面。UI写明白之后工具的价值立刻就体现出来。6. 最后补充几个能帮你少绕路的小习惯做UE5编辑器Slate开发这么长时间有几个习惯我觉得比记住API更有用。第一个是勤用FAppStyle拿图标。编辑器原生的很多图标资源路径直接通过FAppStyle::Get().GetBrush(Icons.Folder)这种写法就能取到不需要自己导资源。如果你自己做了个小图标想放进工具面板也建议把它挂到内容浏览器插件目录下用FSlateBrush动态创建避免硬编码路径。第二个是定期在构造后调用Invalidate(EInvalidateWidgetReason::Layout)。Slate在很多情况下会自动失效布局但如果你在某个子组件里动态增删Slot界面可能不会立刻重排。手动调用一次Invalidate能把不少界面变灰但是位置没更新的诡异问题消掉。第三个是遇到编辑器崩溃先看最后一条Log。Slate相关崩溃的日志通常会在Output Log里留下控件名或委托名。有一次我找出一个工具崩溃就是靠Log里出现了面板类名顺藤摸瓜发现是某个回调里连续两次进入HandleSelectionChanged第二次拿到的选中对象已经无效导致空指针访问。第四个是能编译过就尽可能早编译。Slate代码的宏非常多写一半再编译很容易出错一大堆定位困难。增量编译其实不慢我通常每加两个控件结构就编译一次把错误隔离在最小范围内。写到这里SCompoundWidget的基本用法和实战套路已经比较完整。如果你之前一直在UMG编辑器工具里挣扎不妨把下一个工具拿出来试试写Slate版。第一次可能会觉得代码量大、宏又多又不直观但写完两三个控件之后你就会发现这套东西的掌控感和灵活度是一般的可视化编辑器给不了的。
返回列表