游戏UI架构(二)
一生二,二别生了吧。
上篇讲了概念,本篇来写点具体的代码示例。
| 直接用引擎 | 框架自建 |
|---|---|
UUserWidget 与 NativeOnInitialized / NativeConstruct / NativeDestruct |
层级与栈的路由 |
UCanvasPanel + ZOrder 做层级 |
界面句柄表与模态追踪 |
USafeZone 做安全区 |
资源持有计数与常驻策略 |
FStreamableManager / FStreamableHandle 加载与释放 |
事件主题表与解绑凭据 |
CreateWidget 实例化 |
调用者信息透传(Logger 宏) |
UE_LOG + ELogVerbosity + 运行时分类开关 |
— |
FUserWidgetPool 复用控件 |
— |
TMulticastDelegate 收发事件 |
— |
判断标准有两条:引擎有没有现成的;这套东西是不是必须全项目共享同一份状态。前者有就用,后者才值得自建。中间那些「引擎有但不够好用」的,在外面包一层,别改里面的实现。
UIPanelBase —— 生命周期契约
定义:所有界面的基类,直接继承 UUserWidget。引擎已经定义了「一个控件从实例化到销毁会走哪些回调」,框架不另造一套,只把业务需要的钩子挂上去。
伪码:
STRUCT FUIMeta
Name : FName // 唯一标识
Layer : EUILayer
Mode : EUIStackMode
AssetBundle : FName
ResPaths : TArray<FSoftObjectPath>
bIgnoreSafeArea : bool // 全屏遮罩:挂层级根,不进安全区
bInBackStack : bool
bCache : bool // 关掉后是否保留实例
CLASS UUIPanelBase : UUserWidget // 基类就是引擎的 UUserWidget
状态:
Meta : FUIMeta // 类上声明:名字、层级、栈策略、资产路径
bLoaded : 是否已过 NativeOnInitialized
bVisible : 是否在视口里
OpenData : 打开时传入的 payload
CloseResult : 关闭时回传的 payload
Handles : TArray<FDelegateHandle> ← 关键字段
// ===== 框架内部调用,业务不要重写 =====
PROCEDURE LoadUI(Class, InData) -> UUIPanelBase:
// CreateWidget 由引擎完成:加载类、实例化控件树、注入 OwningPlayer
Widget = CreateWidget<UUIPanelBase>(OwningPlayer, Class)
IF Widget == NULL: RETURN NULL
Widget.OpenData = InData
Widget.bLoaded = TRUE // 引擎已调过 NativeOnInitialized
RETURN Widget
PROCEDURE Show(InData):
IF bLoaded == FALSE: 报错并 RETURN
OpenData = InData
bVisible = TRUE
SetVisibility(ESlateVisibility::SelfHitTestInvisible)
AddToViewport(Layer 的 ZOrder) // 引擎挂载,内部调 NativeConstruct
PROCEDURE Refresh(InData):
IF bVisible == FALSE: RETURN
NativeRefresh(InData) // 引擎没有这个回调,框架补的
PROCEDURE Hide():
IF bVisible == FALSE: RETURN
bVisible = FALSE
SetVisibility(ESlateVisibility::Collapsed)
RemoveFromParent() // 引擎移除,内部调 NativeDestruct
PROCEDURE Release():
RemoveAllBindings()
RemoveFromParent()
// UObject 交给 GC,不手动 delete
// ===== 业务覆写点:直接用引擎的 Native*,不另起名 =====
// 概念篇说的 onInit / onShow / onHide,就是下面这三个
VIRTUAL PROCEDURE NativeOnInitialized():
// 【实例级 · 1 次】绑事件、建结构、初始化静态部分
// 引擎在 Initialize 时调,早于第一次 Construct
VIRTUAL PROCEDURE NativeConstruct():
// 【显示级 · N 次】刷数据、播入场动画
// 引擎在每次加入视口时调
VIRTUAL PROCEDURE NativeDestruct():
// 【显示级 · N 次】停动画、清临时状态
// 引擎在每次移除时调,与 UObject 销毁无关
VIRTUAL PROCEDURE NativeRefresh(InData):
// 【显示级 · N 次】局部刷新
// ===== 辅助 =====
PROCEDURE Bind(Handle):
Handles.Add(Handle) // 记账,销毁时统一摘
PROCEDURE RemoveAllBindings():
FOR EACH H IN Handles:
逐个移除(H) // 单项失败不中断
Handles.Empty()
UIManager —— 界面调度中枢
定义:UI 层唯一的对外入口,负责创建、销毁、层级管理、栈管理、模态追踪。做成 UGameInstanceSubsystem,生命周期跟 GameInstance 一致,全项目一份。
业务侧永远不能自己 CreateWidget,只能经由框架入口打开:本篇的入口是 UUIManagerSubsystem::Open,接入系统篇后改由 UUIFacade::Open 代业务调用。
伪码:
STRUCT FUIPanelEntry
Class : TSubclassOf<UUIPanelBase>
Panel : UUIPanelBase*
Data : payload
CLASS UUIManagerSubsystem : UGameInstanceSubsystem
// 本篇按单域写:层级、栈、模态都由它自己持有
// 接多域后这些 map 搬进 FUIDomainRuntime,Open 多收一个域参数
状态:
LayerPanels : TMap<EUILayer, UCanvasPanel*> // 层级根,范围是整个物理屏
MountCanvases : TMap<EUILayer, UCanvasPanel*> // SafeZone 内的挂载点
LayerStacks : TMap<EUILayer, TArray<FUIPanelEntry>>
Instances : TMap<FName, UUIPanelBase*> // 存活实例索引
ModalStack : TArray<UUIPanelBase*>
BackStack : TArray<UUIPanelBase*>
PROCEDURE Initialize(Collection):
// 单域:一个全屏根,所有层级挂它下面;多域时父级换成各域的根
Root = CreateWidget<UUserWidget>(PC, UIRootClass)
Root->AddToViewport(0)
FOR EACH Layer IN 全部层级:
// 两个构建过程都在「屏幕适配」一节,这里只做登记
LayerPanels[Layer] = BuildLayerRoot(Root, Layer) // 物理屏范围
MountCanvases[Layer] = BuildSafeZone(LayerPanels[Layer]) // 安全区内
PROCEDURE Open(PanelClass, InData) -> UUIPanelBase*:
Meta = PanelClass 的 FUIMeta
// —— 第一步:查重复用 ——
IF Instances.Contains(Meta.Name):
Existing = Instances[Meta.Name]
Existing->Show(InData)
Existing->Refresh(InData)
MoveToTop(Meta.Layer, Existing)
EventBus()->Broadcast(EUIMessage::Opened, Meta.Name, InData)
RETURN Existing
// —— 第二步:同层互斥 ——
// 互斥检查放在加载之前:先关旧的、放掉旧资源,再加载新的
IF Meta.Mode == EUIStackMode::Exclusive:
FOR EACH Entry IN 副本(LayerStacks[Meta.Layer]):
CloseInternal(Entry.Panel, 空, 不移出栈)
// —— 第三步:加载与实例化 ——
Widget = LoadUI(PanelClass, InData) // 内部就是 CreateWidget
IF Meta.ResPaths.Num() > 0:
FUIResourceTracker::AcquireAll(Meta.AssetBundle, Meta.ResPaths)
Instances.Add(Meta.Name, Widget)
LayerStacks[Meta.Layer].Add({PanelClass, Widget, InData})
// —— 第四步:挂载 ——
// 全屏遮罩要盖住物理屏,挂 LayerPanel;普通界面挂 SafeZone 内的 Mount
Parent = Meta.bIgnoreSafeArea ? LayerPanels[Meta.Layer] : MountCanvases[Meta.Layer]
Parent->AddChild(Widget)
铺满父级(Widget) // 子界面只做这一件事
// —— 第五步:显示 ——
Widget->Show(InData)
// —— 第六步:登记 ——
IF Meta.Layer 落在弹出类层级:
ModalStack.Add(Widget)
IF Meta.bInBackStack:
BackStack.Add(Widget)
EventBus()->Broadcast(EUIMessage::Opened, Meta.Name, InData)
EventBus()->Broadcast(EUIMessage::Impression, Meta.Name) // 埋点:曝光
RETURN Widget
PROCEDURE Close(PanelClass, Result):
Widget = Instances.FindRef(PanelClass 的 Meta.Name)
IF Widget == NULL: RETURN
CloseInternal(Widget, Result, 移出栈)
PROCEDURE CloseInternal(Widget, Result, bRemoveFromStack):
Meta = Widget 所属类的 FUIMeta
Widget->CloseResult = Result
Widget->Hide() // 触发 NativeDestruct
ModalStack.Remove(Widget)
BackStack.Remove(Widget)
IF bRemoveFromStack:
LayerStacks[Meta.Layer].Remove(对应条目)
IF Meta.bCache:
// 缓存模式:控件留着,只是 Collapsed
IF NOT LayerStacks[Meta.Layer].Contains(Widget):
LayerStacks[Meta.Layer].Add({PanelClass, Widget})
ELSE:
Instances.Remove(Meta.Name)
Widget->Release() // 摘委托 + 移出视口,GC 回收
EventBus()->Broadcast(EUIMessage::Closed, Meta.Name, Result)
PROCEDURE Back() -> bool:
// 先退引导层,引导优先级最高
Guide = BackStack 中 layer == Guide 的
IF Guide.Num() > 0:
CloseInternal(Guide.Last(), 空, 移出栈)
RETURN TRUE
// 再退弹窗层
Popup = BackStack 中 layer == Popup 的
IF Popup.Num() > 0:
CloseInternal(Popup.Last(), 空, 移出栈)
RETURN TRUE
// 退无可退返回 false,交业务层决定,框架不擅自退出
RETURN FALSE
FUNCTION HasModal() -> bool:
RETURN ModalStack.Num() > 0
层级 Layer
枚举 EUILayer : uint8
Background = 0 // 全屏底图
Scene = 10 // 关卡内的固定 UI
Hud = 15 // 大世界 HUD 的槽位层,随情境换装
Main = 20 // 主菜单、背包、设置
Popup = 30 // 弹窗家族
Guide = 40 // 新手引导
Loading = 50 // 加载、转场
Debug = 90 // GM 面板
// 数值同时作为 UCanvasPanelSlot 的 ZOrder
// 所以视觉遮挡和输入优先级永远是同一个顺序
// 判层只问一句:这个界面出现时,底下的东西还能不能被点到
栈 Stack
枚举 EUIStackMode
Exclusive // 互斥:开新的,同层旧的全部关闭。主菜单、设置、背包
Stack // 栈:可叠加,Back 从栈顶退。弹窗家族
Single // 单例:同 Key 只留一个,重复打开只刷新。Loading、Toast
// 判据:这一层同一时刻该不该有两个界面活着
// 不该 → Exclusive;该 → Stack;重复触发不该叠 → Single
事件总线 —— 跨模块广播
定义:引擎的多播委托只能在两个已知对象之间直连,跨模块广播需要一层按主题注册的中转。做成 UGameInstanceSubsystem,底层仍然是引擎的 TMulticastDelegate。
伪码:
STRUCT FUIEventChannel
Handlers : 多播委托<Payload> // 底层是引擎的多播委托
CLASS UUIEventSubsystem : UGameInstanceSubsystem
状态:
Channels : TMap<FName, FUIEventChannel> // 每个主题一个多播委托
// 订阅:返回引擎原生的 FDelegateHandle,这就是解绑凭据
FUNCTION On(Topic, Handler) -> FDelegateHandle:
Ch = Channels.FindOrAdd(Topic)
Handle = Ch.Handlers.Add(Handler)
// Handle 无效说明签名不匹配,此处补一条日志
RETURN Handle
FUNCTION Broadcast(Topic, Payload):
Ch = Channels.Find(Topic)
IF Ch == NULL: RETURN
// 引擎同步遍历委托列表,这里先拷一份再发,回调里增删不影响本轮
// 异常逐个吞掉,一个监听器失败不中断其余
FOR EACH H IN 副本(Ch.Handlers):
安全调用(H, Payload)
PROCEDURE Clear(Owner):
FOR EACH Ch IN Channels:
Ch.Handlers.RemoveAll(Owner) // 引擎按对象批量解绑
// 订阅时用 AddUObject 绑定 Owner,解绑按 Owner 匹配
// 主题名统一走 FName 常量表,不散落在各处
常量 EUIMessage
Opened 界面已打开
Closed 界面已关闭
Impression 埋点:曝光
FlowChanged 流程切换
资源管理 —— 内存管理,资源加载释放
定义:引擎的 FStreamableManager 负责真正的加载与卸载,FStreamableHandle 就是引用凭据。框架在上面记一层「哪个界面占着哪些资源」,界面关闭时释放凭据,没人持有才真正卸载。加载入口统一,外部不允许直接 LoadObject。
伪码:
STRUCT FUIResEntry
Handle : TSharedPtr<FStreamableHandle>
Count : int32
bResident : bool
CLASS FUIResourceTracker // 纯静态工具,不是 UObject
状态:
Streamable : FStreamableManager& // 引擎的异步加载器
Entries : TMap<FString, FUIResEntry> // Key -> {Handle, Count, bResident}
FUNCTION Acquire(BundleName, Path, bResident = FALSE) -> UObject*:
Key = BundleName + ":" + Path
IF Entries.Contains(Key):
Entries[Key].Count++
IF bResident: Entries[Key].bResident = TRUE
RETURN Entries[Key].Handle->GetLoadedAsset()
// 引擎负责加载,Handle 本身就是一份引用
Handle = Streamable.RequestSyncLoad(路径) // 异步用 RequestAsyncLoad
IF NOT Handle.IsValid(): RETURN NULL
Entries.Add(Key, {Handle, 1, bResident})
RETURN Handle->GetLoadedAsset()
FUNCTION AcquireAll(BundleName, Paths):
FOR EACH P IN Paths: Acquire(BundleName, P)
FUNCTION Release(BundleName, Path):
Key = BundleName + ":" + Path
IF NOT Entries.Contains(Key): RETURN
Entries[Key].Count--
IF Entries[Key].Count <= 0 AND NOT Entries[Key].bResident:
// 计数归零即交还引擎,由引擎决定何时真正卸载
Entries[Key].Handle->ReleaseHandle()
Entries.Remove(Key)
FUNCTION ReleaseAll(BundleName, Paths):
FOR EACH P IN Paths: Release(BundleName, P)
// 场景切换兜底:白名单以外全部放手
PROCEDURE ReleaseAllExcept(BundleName, KeepPaths):
FOR EACH (Key, Entry) IN 副本(Entries):
IF NOT Key.StartsWith(BundleName + ":"): CONTINUE
IF Key 在白名单: CONTINUE
IF Entry.bResident: CONTINUE
Entry.Handle->ReleaseHandle()
Entries.Remove(Key)
FUNCTION Dump() -> FString
// 按 Key / Count / bResident 输出,用于核对持有关系/查泄露
// 用计数而非布尔标记:同一资产允许多个界面同时持有
Logger —— 引擎 log 的封装
定义:UE_LOG 已经有日志分类、verbosity 分级、运行时控制台开关;日志文件也是引擎自己写、崩溃时随报告一起带走。这些全部不重做,框架只补一样引擎没给的:调用者信息。
伪码:
// 引擎侧,全是配置,不写代码
// DEFINE_LOG_CATEGORY(LogUI) 开一个分类
// DefaultEngine.ini 配默认级别,发布版关掉 Verbose
// 控制台 log LogUI Verbose 线上临时打开,不用发版
// 控制台 log LogUI Warning 某模块刷屏时单独压掉
// Saved/Logs/*.log 引擎自己落盘,崩溃报告自动附带
// 所以不需要自己维护环形缓冲
宏 LOG_UI(Verbosity, Format, ...):
// UE_LOG 自己按分类和 verbosity 过滤
// 唯一要补的是调用者:__FILE__ / __LINE__ 是编译期常量,没有运行时开销
// __FILE__ 给的是完整路径,GetCleanFilename 只保留文件名
UE_LOG(LogUI, Verbosity,
TEXT("[%s:%d] ") Format,
*FPaths::GetCleanFilename(ANSI_TO_TCHAR(__FILE__)), __LINE__,
##__VA_ARGS__)
// 语法糖,省得每次写分类名
宏 LOG_UI_INFO(Format, ...) -> LOG_UI(Log, Format, ##__VA_ARGS__)
宏 LOG_UI_WARN(Format, ...) -> LOG_UI(Warning, Format, ##__VA_ARGS__)
宏 LOG_UI_ERROR(Format, ...) -> LOG_UI(Error, Format, ##__VA_ARGS__)
对象池 —— 高频 UI,共用对象
定义:给高频出现、生命周期很短的 UI 准备一批可复用控件。UMG 自带 FUserWidgetPool,ListView 内部用的就是它,直接包一层。
伪码:
CLASS FUIWidgetPool // 封装 UMG 自带的 FUserWidgetPool
状态:
Pool : FUserWidgetPool // 引擎管空闲控件
InUse : TSet<TWeakObjectPtr<UUserWidget>> // 引擎不记谁在用,自己记账
Class : TSubclassOf<UUserWidget>
Capacity : 32
FUNCTION Borrow() -> UUserWidget*:
Widget = Pool.GetOrCreateInstance(World, Class) // 池空则新建,惰性扩容不预热
IF Widget == NULL: RETURN NULL
InUse.Add(Widget)
Widget->SetVisibility(ESlateVisibility::SelfHitTestInvisible)
RETURN Widget
FUNCTION Release(Widget):
IF NOT InUse.Contains(Widget):
报错("归还了未借出的控件")
RETURN
InUse.Remove(Widget)
Widget->RemoveFromParent() // 先脱离父级,再决定去向
IF Pool.Num() >= Capacity:
Widget->ConditionalBeginDestroy() // 满了一律交给 GC
ELSE:
Pool.Release(Widget) // 引擎回收,下次 GetOrCreateInstance 复用
PROCEDURE Clear():
Pool.ReleaseAll() // 引擎一次清空
InUse.Empty()
// 引擎的 FUserWidgetPool 不设上限,Capacity 由这一层维护
// 界面退出时统一调 Clear()
屏幕适配 —— 设备差异
定义:不在每个界面上算安全区。引擎自带 USafeZone,它自己读设备 SafeArea 并收缩内容区;在每个 LayerRoot 里垫一个,所有子界面挂进去,只需要撑满即可。
伪码:
// 引擎的 USafeZone 已经处理了刘海、圆角、手势条
// 框架不算 insets,也不监听转屏,这些都是引擎的事
PROCEDURE BuildLayerRoot(Parent : UWidget*, Layer : EUILayer) -> UCanvasPanel*:
Panel = CreateWidget<UUserWidget>(PC, LayerRootClass)
Parent->AddChild(Panel) // 先入 CanvasPanel,才有槽
// 槽的 ZOrder 直接用枚举值,遮挡顺序和输入优先级天然一致
Cast<UCanvasPanelSlot>(Panel->Slot)->SetZOrder((int32)Layer)
RETURN Panel
PROCEDURE BuildSafeZone(LayerPanel : UCanvasPanel) -> UCanvasPanel*:
// 引擎自带的安全区容器,自己读设备 SafeArea
SafeZone = NewObject<USafeZone>()
// SafeZone 只有一个内容槽,多个界面没法直接塞,垫一层 Canvas 承接
Mount = NewObject<UCanvasPanel>()
SafeZone->AddChild(Mount)
LayerPanel->AddChild(SafeZone)
铺满父级(SafeZone)
铺满父级(Mount)
RETURN Mount // 所有普通界面挂这里
PROCEDURE 铺满父级(Widget : UUserWidget):
Slot = Cast<UCanvasPanelSlot>(Widget->Slot)
IF Slot == NULL: RETURN
// 用 anchors 拉满四边、offsets 归零,表达「撑满父级」
Slot->SetAnchors(FAnchors(0.f, 0.f, 1.f, 1.f))
Slot->SetOffsets(FMargin(0.f))
Slot->SetAlignment(FVector2D(0.f, 0.f))
资产管理 —— 基础设施
定义:什么尺寸,用什么压缩格式,图集如何打,九宫格怎么设置,在什么目录
这一节是规范,不是代码:
目录
Content/
UI/
Common/ 公共图,常驻,不随界面卸载
<模块>/ 按模块分,随该模块界面加载
Font/ 字体,按字号分资产
Audio/
BGM/ 长音频,流式
SFX/ 短音效,预加载
尺寸与压缩
| 用途 | 尺寸策略 | 处理 |
|---|---|---|
| 全屏底图 | 按最长边适配,不做等比铺满 | Texture Group 单独开,允许降采样 |
| 九宫格按钮 | 保留可拉伸边 | 禁止整体缩放 |
| 小图标 | 统一到 2 的幂 | 走 UI 图集,减少 draw call |
| 带透明大图 | 降低分辨率或分离 alpha | Compression Settings 选带 alpha 的格式 |
图集
- 同一个界面用到的图,打同一张图集
- 常驻图集控制在两张以内,减少材质切换
- 单张图集不超过设备纹理上限
- 图集不要跨模块混打
九宫格
UImage的Draw As设成Box,Margin四边按实际圆角像素填- 拉伸区必须是纯色或可平铺的纹理
命名
T_UI_<模块>_<用途>_<状态>
例:T_UI_Shop_BtnBuy_Normal
禁止中文、空格、大小写混用。命名规范不统一。
在拥有了这一切基础设施之后,就可以进入下一步,一个全UI控制的中枢系统
浙公网安备 33010602011771号