Fivee

导航

AIGC标识 游戏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控制的中枢系统

posted on 2026-11-04 15:02  Fivee  阅读(4)  评论(0)    收藏  举报