
说实话我最早接触“UE5 使用Slate制作对话窗口”这个话题时第一反应是“没必要吧UMG拖拖控件不就行了”。但真正在项目里被UMG的加载耗时、焦点管理、动画控制的灵活性逼到墙角之后我才认真把Slate捡起来研究了一遍。这篇文章不聊UMG怎么用也不做那种“官方文档翻译”而是直接从一个可运行的对话窗口实例出发讲清楚Slate方案里最核心的布局思路、数据流、输入焦点和打字机效果是怎么实现的以及在真实项目里最容易踩的那些坑。适合已经写过一些C和UMG、想进一步深入引擎UI层的开发者。1. 为什么用Slate做对话窗口先搞清楚UMG和Slate的边界1.1 UMG是Slate的封装但不是万能解药很多人写过UMG但未必清楚UMG的本质。UMG里那个面板、按钮、文本块最终都会被转换成Slate控件节点。也就是说Slate是UE整个UI系统的底层实现UMG只是它上面一层面向设计师和蓝图用户的“积木”。我们平时用UMG节省了时间代价是失去了一部分控制力。对话窗口这个场景恰恰对控制力有要求。对话文本要频繁刷新、选项中要动态添加按钮、打字机效果要逐字出现、焦点还要在多个按钮之间来回切换。如果用UMG这些功能也能做但你会发现在UMG里做“动态添加N个选项按钮”这种操作VisualTree的刷新、焦点导航、动画时序都容易出现莫名其妙的延迟或Bug。说到底UMG的定位是快速搭建静态或弱动态界面而不是做这种高频动态更新的交互组件。Slate的优势正好体现在这里。它在代码里直接声明控件树没有中间层的转换损耗而且每一个控件的创建、布局、绘制、输入响应都可以由代码精确控制。对于对话窗口这种结构相对固定、但内容高度动态的UISlate是更底层的正解。提示如果你的团队里有大量纯蓝图开发人员并且对话窗口需要快速迭代样式那UMG依然是效率更高的选择。Slate方案更适合那些对性能、交互有硬要求或者本身就在做工具链、Editor扩展的团队。1.2 Slate对话窗口的适用边界与性能收益我的建议是不要为了“炫技”而全项目改用Slate。对话窗口这种体量的UI用Slate在运行时的优势主要体现在创建和销毁的GC压力小因为Slate控件不继承UObject不会产生大量的UObject垃圾回收标注。控件树完全由C持有布局计算在加载时一次性完成不受UMG的WidgetBlueprint重新编译和反射开销影响。输入事件的响应路径更短焦点管理更“硬核”不再依赖UMG内部的Navigation逻辑。但代价也很明显编辑器里看不到界面预览调试全靠日志和运行效果开发效率会比UMG低一截。所以我的结论是——如果你想做的是策略游戏里那种高频率出现、带选项分支、还涉及大量文本刷新的对话系统Slate是值得投入的如果只是过场剧情里偶尔弹一句话UMG更快没必要增加维护成本。2. 对话数据模型怎么设计才靠谱2.1 用数据驱动代替硬编码节点与选项结构做对话窗口的第一步不是写界面而是先把对话数据模型定死。我在项目里试过直接在代码里写死对话内容结果需求一变就要改C、等编译非常痛苦。后来统一改成数据驱动对话内容全部放到DataTable或DataAsset里运行时代码只负责读取和展示。就对话系统而言最经典的模型是“节点-选项”结构。每个节点代表一次发言节点之间通过选项或顺序关系连接。下面这组结构是我在实际项目里验证过的既简单又够用USTRUCT(BlueprintType) struct FDialogueOption { GENERATED_BODY() // 选项文本例如“我同意”“再想想” UPROPERTY(EditAnywhere) FText OptionText; // 选择后跳转到的下一个对话节点ID UPROPERTY(EditAnywhere) FName NextNodeId; // 可选的跳转后触发事件名方便外部蓝图监听 UPROPERTY(EditAnywhere) TArrayFName OnSelectEvents; }; USTRUCT(BlueprintType) struct FDialogueNode { GENERATED_BODY() // 说话人名字例如“队长” UPROPERTY(EditAnywhere) FText SpeakerName; // 说话人头像软引用不会在加载时阻塞主线程 UPROPERTY(EditAnywhere) TSoftObjectPtrUTexture2D SpeakerPortrait; // 对话正文 UPROPERTY(EditAnywhere) FText DialogueText; // 选项列表为空表示该节点没有分支播放完即结束 UPROPERTY(EditAnywhere) TArrayFDialogueOption Options; }; UCLASS() class UDialogueAsset : public UDataAsset { GENERATED_BODY() public: // 对话节点表Key就是节点ID UPROPERTY(EditAnywhere) TMapFName, FDialogueNode DialogueNodes; };用DataAsset而不是DataTable的原因是DataTable对行式数据的表现力更好而对话节点之间是有层级和关联的用Map管理节点ID更顺手。DataAsset里还能直接引用纹理、声音等资源编辑体验更直观。2.2 运行时上下文别把状态堆在控件里面数据模型定了之后最忌讳的是把“当前正在显示哪个节点”这个状态直接塞进Slate控件里。Slate控件的生命周期和推入Viewport的时机并不总是可控的如果状态放在控件内部窗口一关状态就丢了下次打开还得重新找位置。我的做法是单独写一个UDialogueContext这样的UObject负责持有当前节点ID、已显示字符数、是否处于打字状态等运行时数据。Slate控件只负责渲染这个上下文里的数据以及把玩家的操作回传给上下文。这样后续如果要加自动播放、历史记录、存档读档都只改这个上下文类UI层几乎不用动。UCLASS(BlueprintType) class UDialogueContext : public UObject { GENERATED_BODY() public: void StartDialogue(UDialogueAsset* InAsset, FName StartNodeId); void SelectOption(int32 OptionIndex); UPROPERTY(VisibleInstanceOnly) UDialogueAsset* CurrentAsset; UPROPERTY(VisibleInstanceOnly) FName CurrentNodeId; UPROPERTY(VisibleInstanceOnly) int32 DisplayedCharCount; UPROPERTY(VisibleInstanceOnly) bool bIsTyping; };这个设计的好处是对话上下文可以脱离UI独立测试。你可以写一个纯逻辑的单元测试传入DataAsset走到某个节点调用SelectOption(0)然后直接断言下一个节点ID。UI那块出问题时业务逻辑不受影响。3. Slate对话窗口的布局与实现流程3.1 控件树结构先画布局再写代码动手写代码之前一定要先把控件树在脑子里画出来。对话窗口的典型布局从上到下大概是标题栏可选、角色信息区头像名字、对话正文区、选项区。如果对话内容比较长还会加滚动框如果选项很多还要考虑翻页。我的方案是用SConstraintCanvas做背景层然后叠一个SVerticalBox作为主内容层。选择ConstraintCanvas是因为它可以在不同分辨率下方便地对齐到底部或顶部而SVerticalBox则适合处理纵向排列的内容。这两者组合起来基本能覆盖对话窗口绝大部分布局需求。class SDialogueWidget : public SCompoundWidget { public: SLATE_BEGIN_ARGS(SDialogueWidget) {} SLATE_END_ARGS() void Construct(const FArguments InArgs); void SetDialogueContext(UDialogueContext* InContext); private: TSharedPtrSImage PortraitImage; TSharedPtrSTextBlock SpeakerNameText; TSharedPtrSTextBlock DialogueText; TSharedPtrSVerticalBox OptionContainer; TWeakObjectPtrUDialogueContext Context; };3.2 构造函数里的关键细节样式、字体、可见性Construct函数是Slate控件的核心所有子控件都在这里声明并绑定数据。这里有几个细节特别值得注意。首先是字体。Slate默认字体不支持中文不处理的话对话文本就是一片方块。我一般在项目里用一个独立的中文字体资源比如打包进项目的Noto Sans SC或者思源黑体通过FSlateFontInfo指定。别用系统路径去Load Windows字体发布时那路径根本不存在。void SDialogueWidget::Construct(const FArguments InArgs) { // 中文字体一定要提前准备好否则文本渲染全变方块 FSlateFontInfo DialogueFont FAppStyle::GetFontStyle(TEXT(NormalFont)); DialogueFont.Size 22; // 头像图片占位先给一个灰色块 FLinearColor PlaceholderColor(0.2f, 0.2f, 0.2f, 1.0f); ChildSlot [ SNew(SConstraintCanvas) SConstraintCanvas::Slot() .Offset(FMargin(0, 0, 0, 0)) .Anchor(FAnchor(0.5f, 0.5f)) .AutoSize(true) [ SNew(SBorder) .BorderBackgroundColor(FLinearColor(0, 0, 0, 0.8f)) .Padding(FMargin(30, 20, 30, 20)) [ SNew(SVerticalBox) SVerticalBox::Slot() .AutoHeight() .Padding(0, 0, 0, 12) [ // 说话人名字 SAssignNew(SpeakerNameText, STextBlock) .Font(DialogueFont) .ColorAndOpacity(FLinearColor(1, 0.85f, 0.3f, 1)) ] SVerticalBox::Slot() .AutoHeight() .Padding(0, 0, 0, 12) [ // 头像区 SNew(SBox) .WidthOverride(100) .HeightOverride(100) [ SAssignNew(PortraitImage, SImage) .ColorAndOpacity(PlaceholderColor) ] ] SVerticalBox::Slot() .AutoHeight() .Padding(0, 0, 0, 12) [ // 对话正文 SAssignNew(DialogueText, STextBlock) .Font(DialogueFont) .AutoWrapText(true) .ColorAndOpacity(FLinearColor(1, 1, 1, 1)) ] SVerticalBox::Slot() .AutoHeight() [ // 选项容器动态往里塞按钮 SAssignNew(OptionContainer, SVerticalBox) ] ] ] ]; }代码里的SConstraintCanvas用Anchor控制位置AutoSize让面板根据内容自适应大小。实际项目中窗口大小最好固定比如宽度800高度500这样文本换行和按钮排列的结果是可预期的。3.3 把Slate控件挂到视口AddViewportWidgetContent的坑控件写完之后需要在游戏运行时的某个时机把它推入视口。常用的API是GEngine-GameViewport-AddViewportWidgetContent这是把Slate挂到游戏画面上的标准方法。void ADialogueTriggerActor::ShowDialogue() { if (!DialogueWidget.IsValid()) { SAssignNew(DialogueWidget, SDialogueWidget); } DialogueWidget-SetDialogueContext(DialogueContext); GEngine-GetGamePlayer(GetWorld(), 0)-ViewportClient-AddViewportWidgetContent( SNew(SWeakWidget).PossiblyNullContent(DialogueWidget.ToSharedRef()) ); // 必须要切输入模式否则玩家点击UI时角色也会跟着移动 FInputModeGameAndUI Mode; Mode.SetHideCursorDuringCapture(false); GetWorld()-GetFirstPlayerController()-SetInputMode(Mode); GetWorld()-GetFirstPlayerController()-bShowMouseCursor true; }这里有两个坑。第一直接用AddViewportWidgetContent(DialogueWidget.ToSharedRef())在某些引擎版本里会出问题最好是包一层SWeakWidget避免控件所有权被容器接管后外部指针失效导致的崩溃。第二挂载后千万别忘记切换输入模式。很多新手做完UI发现点击没反应或者角色乱走基本都是没设置FInputModeGameAndUI。4. 打字机效果、文本刷新与动态选项4.1 打字机效果ActiveTimer比Tick更优雅对话窗口的灵魂是打字机效果。Slate里实现打字机效果有两种方式在控件的Tick里做或者用RegisterActiveTimer。Tick的问题是即使对话完全不显示控件只要在视口里就会每帧执行浪费性能。ActiveTimer则是在你主动注册后才开始计时还支持返回EActiveTimerReturnType::Stop来终止非常贴合打字机这种“播完即停”的场景。void SDialogueWidget::StartTypewriter(float InCharPerSecond) { if (!Context.IsValid()) { return; } Context-DisplayedCharCount 0; Context-bIsTyping true; // 注册一个每帧执行的ActiveTimer RegisterActiveTimer(0.0f, FWidgetActiveTimerDelegate::CreateSP( this, SDialogueWidget::HandleTypewriterTimer, InCharPerSecond)); } EActiveTimerReturnType SDialogueWidget::HandleTypewriterTimer( double InCurrentTime, float InDeltaTime, float InCharPerSecond) { if (!Context.IsValid()) { return EActiveTimerReturnType::Stop; } FDialogueNode* Node Context-GetCurrentNode(); if (!Node) { return EActiveTimerReturnType::Stop; } FString FullText Node-DialogueText.ToString(); Context-DisplayedCharCount FMath::Min( Context-DisplayedCharCount FMath::CeilToInt(InCharPerSecond * InDeltaTime), FullText.Len()); DialogueText-SetText(FText::FromString(FullText.Left(Context-DisplayedCharCount))); if (Context-DisplayedCharCount FullText.Len()) { Context-bIsTyping false; ShowOptionsIfNeeded(); return EActiveTimerReturnType::Stop; } return EActiveTimerReturnType::Continue; }提示FString的Left()是按TCHAR个数截取的中文字符在UE的FString里也算一个TCHAR所以中文文本直接用这种方法没问题。但如果你用到了带组合标记的复杂字符例如某些生僻字或Emoji字符计数会有偏差实际项目中这种情况极少一般不用特判。4.2 动态选项按钮每次重新生成最省心当对话节点有选项时需要在打字机效果结束后把FDialogueOption里的内容生成成按钮显示出来。我之前试过维护一堆按钮的数组翻来覆去地SetVisibility代码很绕。后来发现最简单的方案就是每次进入选项阶段先把OptionContainer清空再AddSlot()添加新按钮。void SDialogueWidget::ShowOptionsIfNeeded() { OptionContainer-ClearChildren(); FDialogueNode* Node Context-GetCurrentNode(); if (!Node || Node-Options.Num() 0) { return; } for (int32 i 0; i Node-Options.Num(); i) { FDialogueOption Option Node-Options[i]; OptionContainer-AddSlot() .AutoHeight() .Padding(FMargin(0, 4, 0, 4)) [ SNew(SButton) .OnClicked(this, SDialogueWidget::HandleOptionClicked, i) .ContentPadding(FMargin(20, 6, 20, 6)) [ SNew(STextBlock) .Text(Option.OptionText) .Font(GetDefaultFont()) ] ]; } }这种“全清全建”的方式在选项数量不超过四五个时性能完全够用。如果以后对话系统要支持几十个选项的列表那才需要考虑SListView的虚拟化但游戏对话很少有这样的需求保持简单是第一原则。4.3 选项点击后的跳转与事件通知点击选项后的逻辑不只是切换节点。很多时候选择某个选项后要通知任务系统、好感度系统或者播放特定音效。所以我一般在HandleOptionClicked里分两步走先把OnSelectEvents抛给蓝图再根据NextNodeId切换到下一个节点。FReply SDialogueWidget::HandleOptionClicked(int32 OptionIndex) { FDialogueNode* Node Context-GetCurrentNode(); if (!Node || OptionIndex Node-Options.Num()) { return FReply::Handled(); } FDialogueOption SelectedOption Node-Options[OptionIndex]; // 通知外部系统 for (const FName EventName : SelectedOption.OnSelectEvents) { TriggerDialogueEvent(EventName); } // 如果设置了下一个节点就跳过去否则结束对话 if (!SelectedOption.NextNodeId.IsNone()) { Context-GoToNode(SelectedOption.NextNodeId); RefreshDialogueContent(); } else { CloseDialogue(); } return FReply::Handled(); }这里的TriggerDialogueEvent在项目里可以是一个委托也可以是直接GetWorld()-GetGameInstance()上挂一个事件分发器。不要试图让对话UI直接引用任务系统耦合会变得很重后续改一处崩三处。5. 焦点管理、键盘导航与手柄支持5.1 为什么Slate的焦点管理比UMG更“听话”对话窗口的交互不只有鼠标点击。PC端玩家可能会用键盘选选项主机玩家会用手柄。UMG在这方面的Navigation是自动的但有时候你发现手柄导航到了UI外面或者键盘按下没反应调试起来很费劲。Slate的焦点管理虽然是手动为主但胜在可控。关键API是FSlateApplication::Get().SetKeyboardFocus以及控件上的SupportsKeyboardFocus方法。SButton默认是支持键盘焦点的所以只要把焦点设置到第一个选项按钮上玩家用上下方向键就能在按钮之间切换。void SDialogueWidget::SetFocusToFirstOption() { if (OptionContainer-GetChildren()-Num() 0) { return; } // OptionContainer是SVerticalBox获取第一个child作为焦点目标 TSharedRefSWidget FirstChild OptionContainer-GetChildren()-GetChildAt(0); FSlateApplication::Get().SetKeyboardFocus(FirstChild, EFocusCause::SetDirectly); }但直接调用一次SetKeyboardFocus还不够。如果对话窗口在运行过程中被其他UI抢了焦点玩家按方向键就没反应了。我的做法是重写OnKeyDown在窗口级别先把上、下方向键拦截下来手动移动选中索引而不是完全依赖Slate的Navigation规则。这样无论是键盘、手柄还是鼠标悬停选中状态始终由同一个变量控制不会出现三种输入方式状态不一致的Bug。5.2 输入模式的切换UIOnly与GameAndUI怎么选对话窗口通常有两种输入模式一种是把控制权完全交给UI角色不能动鼠标显示另一种是UI和游戏并存比如你可以一边走一边和NPC对话虽然这很少见。前者用FInputModeUIOnly后者用FInputModeGameAndUI。我踩过的一个坑是用FInputModeUIOnly时如果某个选项按钮触发了音效播放而音效播放内部又弹出了新的UI窗口新的窗口拿不到焦点因为输入模式还是UIOnly但焦点在旧窗口上。解决办法是在打开对话窗口时统一调用一个ResetDialogueFocus的辅助函数它会等待一帧再设置焦点确保控件树已经刷新完成。void ADialogueTriggerActor::ResetDialogueFocus() { // 延迟一帧再设置焦点避免控件还没有完成布局 FTSTicker::GetCoreTicker().AddTicker( FTickerDelegate::CreateLambda( [this](float DeltaTime) { if (DialogueWidget.IsValid()) { DialogueWidget-SetFocusToFirstOption(); } return false; // 只执行一次 }), 0.1f ); }注意不要试图在Construct里立刻调用SetKeyboardFocus那会儿控件还没有被挂到Viewport上焦点设置会被系统丢弃。延迟一帧或者手动在OnArrangeChildren之后设置才是可靠做法。6. 动画、音效与细节体验把对话窗口做出“活”的感觉6.1 入场与退场动画FCurveSequence的用法Slate做UI动画最常用的工具是FCurveSequence原理就是一条从0到1的时间曲线你拿这个数值去驱动不同控件的透明度、位置或缩放。对话窗口入场时我会让它从底部滑入同时透明度从0变到1。void SDialogueWidget::PlayIntroAnimation() { IntroCurve FCurveSequence(); IntroCurve.AddCurve(0.0f, 0.3f, ECurveEaseFunction::CubicOut); IntroCurve.Play(this-AsShared()); } void SDialogueWidget::Tick(const FGeometry AllottedGeometry, double InCurrentTime, float InDeltaTime) { SCompoundWidget::Tick(AllottedGeometry, InCurrentTime, InDeltaTime); if (IntroCurve.IsPlaying()) { float Alpha IntroCurve.GetLerp(); // 把整个面板向下偏移60像素并修改透明度 TranslateTransform FSlateRenderTransform( FVector2D(0, (1.0f - Alpha) * 60.0f)); SetColorAndOpacity(FLinearColor(1, 1, 1, Alpha)); } }这里要注意SetColorAndOpacity是SWidget提供的方法影响当前控件及其所有子控件的整体透明度。如果你只想让背景变透明而文字不透明就需要在Construct里给不同的子控件单独绑定透明度属性不能图省事全部用统一的颜色叠加。动画过程中如果玩家快速连按跳过键要记得先判断IntroCurve是否还在播放播放时不允许跳转否则动画和内容刷新会打架。6.2 中文文本渲染与样式定制别让美观度拖了后腿Slate对话窗口的美观程度主要取决于你的字体和边框样式。默认的FAppStyle字体比较细在电视或低分辨率屏幕上会很模糊。我的经验是准备一套“对话字号正文灰白说话人高亮金”的字体组合。// 对话正文大号、浅灰白 FSlateFontInfo DialogueTextFont FAppStyle::GetFontStyle(TEXT(NormalFont)); DialogueTextFont.Size 22; DialogueTextFont.LetterSpacing 1.0f; // 说话人稍大、亮黄色用于区分 FSlateFontInfo SpeakerNameFont FAppStyle::GetFontStyle(TEXT(BoldFont)); SpeakerNameFont.Size 26;如果你用的是一个自定义风格的RPG建议不要用引擎默认的SBorder背景而是用自己的九宫格边框贴图。Slate里用FSlateBrush设定边框资源再设置SetBorderBackgroundColor控制颜色整体风格就能和游戏美术拉齐。6.3 音效的接入Slate控件也能播放声音对话窗口的音效通常分三类打字音效、选项出现音效、确认选择音效。打字音效不能用UGameplayStatics::PlaySound2D每帧直接播那样会声音叠成噪音。正确做法是在打字机效果里每累计显示固定字符数比如每3个字符就触发一次短促的滴答声并且给同一个声音随机加一点Pitch偏移避免听感单调。Slate层面有专门的音效接口FSlateApplication::Get().PlaySound(SoundPtr)。它会作为UI音效播放跟随Slate的音频系统不会干扰3D音效的混音逻辑。选项确认音效可以在HandleOptionClicked里调用这样无论玩家用鼠标还是手柄选择都会走同一条路径。7. 常见问题与排查技巧把Slate对话窗口放进真项目后7.1 Slate中文显示为方块这个问题基本每个人都会遇到。原因很简单默认的FAppStyle字体内部没有中文字符。排查方法也很直接看日志里有没有Font Cache相关的警告或者直接用FSlateFontInfo换一个你确定的资源路径。我项目中用的方案是在工程的Content目录下放一个打包进游戏的.ttf字体文件比如NotoSansSC-Regular.otf然后用FSlateFontInfo加载FSlateFontInfo ChineseFont FSlateFontInfo(FPaths::ProjectContentDir() / TEXT(UI/Fonts/NotoSansSC-Regular.otf), 22);注意只要项目里存在任何一个中文字体资源UMG里也同样能用。这个坑不分UMG和Slate只要你做中文本地化提前规划字体资产是必须的。7.2 对话窗口打开时角色还是会移动或攻击这是典型的输入模式没切好。只调用AddViewportWidgetContent是不够的必须用FInputModeGameAndUI或FInputModeUIOnly接管输入。但还要注意如果你的角色控制是在Pawn的SetupPlayerInputComponent里绑定的Action或Axis那么输入模式切换后这些绑定可能依然会生效。我的解法是在对话打开时给Pawn设置一个bIsInDialogue标记然后在Pawn的移动函数里判断这个标记如果为真就直接返回。这样比单纯依赖输入模式更稳妥因为你永远不知道哪个输入事件是绕过FInputModeUIOnly直接触发的。7.3 Slate控件打开后位置不对或拉伸变形Slate布局和UMG最大的不同是UMG控件依赖Canvas/Anchor/Alignment而Slate控件直接受SConstraintCanvas或者SOverlay的锚点影响。如果你把整个窗口直接塞进SConstraintCanvas而没有设置Anchor窗口会默认放在左上角而且不会居中。排查技巧临时给外层SBorder加一个明显颜色的背景比如红色或者绿色然后看它实际占据的区域。如果背景铺满全屏说明你的布局被某个Slot的Fill属性拉伸了如果只有一小块说明是AutoSize起作用但Anchor设置不对。 SConstraintCanvas::Slot() .Offset(FMargin(0, 0, 0, 0)) .Anchor(FAnchor(0.5f, 0.5f)) .Alignment(FVector2D(0.5f, 0.5f)) .AutoSize(true)这个组合的含义是把子控件中心点放到画布中心并让控件按内容自动大小排列。加上.AutoSize(true)后背景就不会莫名其妙拉伸到全屏了。7.4 焦点丢失手柄和键盘突然操作不了选项最常见的原因是对话窗口打开时SetKeyboardFocus设置到了窗口本身或某个不响应导航的控件上。我给每个选项按钮添加了OnHovered和键盘事件响应但第一次打开时总是无法高亮第一项。后来我发现需要把SetKeyboardFocus的调用放到AddViewportWidgetContent之后一帧并且在按钮上显式调用SetUserFocus而不是SetKeyboardFocus。两者差别在于SetKeyboardFocus是全局的而SetUserFocus会跟随当前用户。多手柄游戏中不同玩家操作自己的手柄时焦点不能互相干扰一定要用SetUserFocus。7.5 性能问题对话窗口很多时会不会卡如果同一时间屏幕上有大量Slate控件性能确实会下降。但对话窗口通常只需要一两个实例真正的性能隐患是打字机效果每帧刷新STextBlock导致文本重新布局。这个问题在长对话中尤其明显。优化思路有两个一是降低刷新频率比如每0.05秒刷新一次文本而不是每帧刷新二是用STextBlock的SetText(FText::FromString(...))代替SetText(FText::AsCultureInvariant(...))。前者会更快一些因为不会走Localization查找流程。长对话建议每帧最多刷两个字符这样肉眼效果和每帧刷一个字符差不太多但性能开销能少一半。8. 扩展思考从对话窗口到通用对话框架对话窗口做出来后稍微改造一下就能变成通用对话框架。我目前就在往这个方向做把SDialogueWidget抽成支持多主题的控件通过Style参数传入不同美术资源。以后做NPC对话、任务说明、设置引导都可以复用同一个类。项目里现在已经支持的扩展点包括对话节点支持图文混排正文下方增加SImage插槽。支持多个说话人按节点动态切换名字颜色。对话结束后支持自动关闭也可以强制等待玩家确认。支持从存档里恢复对话进度因为运行时状态全部放在UDialogueContext里序列化起来很容易。最近我还尝试给对话窗口加触屏支持毕竟现在移动端和主机端都有触摸操作的需求。Slate对触屏事件的支持虽然不如UMG那么直观但通过在OnTouchStarted和OnTouchMoved里做手势映射也能实现点击选项、上下滑动滚动等基本操作。如果未来要做多端发售的游戏这块是值得提前投入的。最后还是那句老话UI技术选型没有银弹UMG和Slate各有各的生态位。我的建议是如果团队里没有至少一个熟练的C开发者Slate方案不要碰如果已经决定用了并且在代码结构上先理清数据模型那Slate带给你对界面的控制力真的比UMG高不止一个档次。