AlicizaX/Client/Assets/Books/Framework/Runtime/UI.md
2026-04-01 13:20:06 +08:00

14 KiB
Raw Blame History

UI

模块概述

UI 模块负责窗口创建、资源定位、层级管理、显示/关闭、缓存、过渡动画与界面绑定代码协作。

本模块里最容易误解的一点是:

  • UIHolderObjectBase(通常简称 UIHolder)不是推荐手写的业务类
  • 项目中的大多数 UIHolder 都应通过 UI 绑定工具自动生成
  • 业务代码通常只编写 UIWindow<T>UIWidget<T>UITabWindow<T> 这类逻辑类

换句话说:

  • UIHolder 负责“控件引用和预制体桥接”
  • UI 逻辑类负责“界面行为和生命周期”

快速开始

  1. 在场景中挂载 UIComponent
  2. UIComponent.uiRoot 指定 UI 根预制体
  3. UISettingEditorWindow 中配置 UI 生成规则
  4. 选中 UI 预制体根节点,执行 GameObject/UI生成绑定
  5. 让生成器自动生成 UIHolder 类并挂回预制体
  6. 在逻辑类中使用 UIWindow<T>UIWidget<T>,其中 T 就是生成出来的 UIHolder 类型

架构说明

UIComponent
  └─ UIService
      ├─ UIBase
      ├─ UIWindow<T>
      ├─ UIWidget<T>
      ├─ UITabWindow<T>
      ├─ UIHolderObjectBase
      ├─ UIMetaRegistry
      ├─ UIResRegistry
      └─ UIHolderFactory

角色分工

  • UIBaseUI 逻辑生命周期基类
  • UIWindow<T>:顶层窗口逻辑
  • UIWidget<T>:挂在窗口或其他 Widget 下的子部件逻辑
  • UITabWindow<T>:支持 Tab 页懒加载与切换的窗口基类
  • UIHolderObjectBase:预制体绑定脚本基类,负责暴露控件引用、RectTransform、转场播放器
  • UIHolderFactory:根据注册信息加载预制体并创建对应 Holder

UIBaseUIHolderObjectBaseUIWidget<T> 的关系

UI 预制体
  └─ 挂载生成的 XXXHolder : UIHolderObjectBase
         ↑
UIWidget<XXXHolder> / UIWindow<XXXHolder>
         ↑
    通过泛型参数 T 访问 baseui

说明:

  • UIHolderObjectBase 挂在预制体上,持有控件引用
  • UIWindow<T> / UIWidget<T> 的泛型参数 T 指向该 Holder 类型
  • 在逻辑类内部可以通过 baseui 访问生成好的控件字段

UIHolder 的作用

UIHolderObjectBase 的职责不是写业务逻辑,而是充当:

  • 控件引用容器:保存按钮、文本、图片、节点等引用
  • 预制体桥接层:让 UI 逻辑层不直接依赖层级查找
  • 生命周期事件承载层:暴露 OnWindowInitEventOnWindowAfterShowEvent 等事件
  • 转场入口:自动寻找并驱动 IUITransitionPlayer

因此推荐做法是:

  • 业务不手写具体 XXXHolder
  • 由 UI 绑定工具从预制体结构自动生成

UIHolder 自动生成工作流

1. 配置生成规则

先在编辑器中配置 UIGenerateConfiguration,核心配置包括:

  • UIPrefabRootPathUI 预制体根目录
  • GenerateHolderCodePath:生成代码输出目录
  • NameSpace:生成类所在命名空间
  • LoadTypeResourcesAssetBundle

这些配置通常通过 UISettingEditorWindow 维护。

2. 按命名规则搭建 UI 预制体

生成器会扫描 UI 节点名和组件类型。例如默认规则会识别:

  • Btn → 按钮组件
  • TextTextMeshProUGUI
  • ImgImage
  • TfTransform
  • RectRectTransform

例如:

  • Btn#Close@
  • Text#Title@
  • Img#Icon@

生成器会根据这些节点名推断字段名和字段类型。

3. 选中 UI 预制体根节点

支持两种常见操作方式:

  • 在 Project 中选中 prefab 资源
  • 或在 Prefab Mode 中编辑当前预制体

4. 执行绑定工具

菜单入口:

  • GameObject/UI生成绑定

执行后生成器会:

  1. 读取当前 UI 生成配置
  2. 校验预制体路径是否位于配置的 UI 根目录
  3. 扫描可绑定节点
  4. 生成 XXXHolder 代码文件
  5. 脚本编译后自动把生成的 XXXHolder 挂到目标预制体根节点
  6. 自动回填对应字段引用

这意味着:

  • 正常情况下不需要手动创建 Holder 脚本
  • 也不需要手工把字段拖到 Inspector

5. 生成代码的结果

生成的 UIHolder 类本质上:

  • 继承自 UIHolderObjectBase
  • 带有 UIResAttribute
  • 包含自动生成的控件字段

形态类似:

using AlicizaX.UI.Runtime;

[UIRes(InventoryItemHolder.ResTag, EUIResLoadType.AssetBundle)]
public class InventoryItemHolder : UIHolderObjectBase
{
    public const string ResTag = "UI/Inventory/InventoryItem.prefab";

    [UnityEngine.SerializeField] private UnityEngine.UI.Button uiBtnClose;
    [UnityEngine.SerializeField] private TMPro.TextMeshProUGUI uiTextTitle;

    public UnityEngine.UI.Button BtnClose => uiBtnClose;
    public TMPro.TextMeshProUGUI TextTitle => uiTextTitle;
}

上面是示意结构;实际字段名由生成规则决定。

UIWidget<T> 中如何引用生成的 UIHolder

关键点:

  • T 就是生成工具输出的 Holder 类型
  • 逻辑类不需要自己声明控件字段
  • 通过 baseui 访问自动生成的 Holder 成员

例如:

using AlicizaX.UI.Runtime;
using UnityEngine;

public sealed class InventoryItemWidget : UIWidget<InventoryItemHolder>
{
    protected override void OnInitialize()
    {
        baseui.BtnClose.onClick.AddListener(OnClickClose);
    }

    protected override void OnOpen()
    {
        baseui.TextTitle.text = "Potion";
    }

    private void OnClickClose()
    {
        Close();
        Destroy();
    }
}

这里的含义是:

  • InventoryItemHolder 由 UI 绑定工具生成
  • InventoryItemWidget 是手写业务逻辑
  • UIWidget<InventoryItemHolder> 把逻辑和绑定类关联起来

核心类与接口

IUIService

负责:

  • 初始化 UI 根节点
  • 打开/关闭窗口
  • 查询已打开窗口
  • 获取层级根节点
  • 注入 ITimerService

UIBase

关键生命周期:

  • OnInitialize()
  • OnOpen()
  • OnClose()
  • OnDestroy()
  • OnUpdate()

以及对应异步版本:

  • OnInitializeAsync()
  • OnOpenAsync()
  • OnCloseAsync()

并提供:

  • CreateWidgetAsync<T>()
  • CreateWidgetSync<T>()
  • RemoveWidget(UIBase widget)

UIWindow<T>

适合顶层窗口,通常用于:

  • 主界面
  • 设置页
  • 背包页
  • 弹窗

常用能力:

  • CloseSelf()
  • 强制关闭
  • 打开后顶层排序与层级遮挡处理

UIWidget<T>

适合子部件,通常用于:

  • 列表项
  • 面板块
  • 详情条目
  • 页签子页面

公开方法:

  • Open(params object[] userDatas)
  • Close()
  • Destroy()

UITabWindow<T>

用于页签式窗口,支持:

  • 预注册 Tab
  • 按需懒加载
  • SwitchTab(int index, params object[] userDatas)

UIHolderObjectBase

核心成员:

  • Target
  • RectTransform
  • Visible
  • OnWindowInitEvent
  • OnWindowBeforeShowEvent
  • OnWindowAfterShowEvent
  • OnWindowBeforeClosedEvent
  • OnWindowAfterClosedEvent
  • OnWindowDestroyEvent

UIHolderFactory

UIHolderFactory 是 UI 资源实例化与 Holder 绑定的桥梁,作用是:

  • 根据 UIResRegistry 中登记的资源信息定位 UI 预制体
  • 调用 IResourceServiceResources 加载 UI 资源
  • 实例化预制体并获取对应的 UIHolderObjectBase
  • 把生成的 Holder 绑定到 UIWindow<T> / UIWidget<T> 对应的逻辑实例上

你通常不会在业务层频繁直接调用它,因为:

  • 打开窗口时,UIService 会在内部调用 UIHolderFactory
  • 创建 Widget 时,UIBase.CreateWidgetAsync<T>() / CreateWidgetSync<T>() 也会在内部调用它

可以把它理解为:

UI 逻辑类
  -> UIService / UIBase
      -> UIHolderFactory
          -> 加载预制体
          -> 找到生成的 XXXHolder
          -> 绑定到 UIWindow<T> / UIWidget<T>

典型作用场景

  1. ShowUI<InventoryWindow>()

    • UIService 找到 InventoryWindow 对应的元数据
    • UIHolderFactory 根据 InventoryWindowHolderUIResAttribute 加载预制体
    • 创建并返回 InventoryWindowHolder
    • 把 Holder 绑定给 InventoryWindow
  2. CreateWidgetAsync<InventoryItemWidget>(parent)

    • UIBase 创建 InventoryItemWidget 的元数据
    • UIHolderFactory 加载 InventoryItemHolder 对应的 Widget 预制体
    • 把 Holder 绑定给 InventoryItemWidget

直接调用示例

虽然业务层通常不需要直接调用,但在工具代码、调试代码或特殊预加载场景下,可以这样使用:

using AlicizaX.UI.Runtime;
using Cysharp.Threading.Tasks;
using UnityEngine;

public sealed class UIHolderFactoryExample : MonoBehaviour
{
    [SerializeField] private Transform previewRoot;

    private async UniTaskVoid Start()
    {
        InventoryItemHolder holder = await UIHolderFactory.CreateUIHolderAsync<InventoryItemHolder>(previewRoot);
        if (holder != null)
        {
            holder.TextName.text = "Preview Item";
            holder.TextCount.text = "99";
        }
    }
}

同步版本示例:

using AlicizaX.UI.Runtime;
using UnityEngine;

public sealed class UIHolderFactorySyncExample : MonoBehaviour
{
    [SerializeField] private Transform previewRoot;

    private void Start()
    {
        InventoryItemHolder holder = UIHolderFactory.CreateUIHolderSync<InventoryItemHolder>(previewRoot);
        if (holder != null)
        {
            holder.TextName.text = "Sync Preview";
            holder.TextCount.text = "1";
        }
    }
}

注意事项

  • T 必须是正确的生成型 UIHolder,且继承自 UIHolderObjectBase
  • 对应 Holder 需要已具备 UIResAttribute,通常由绑定工具自动生成
  • 如果资源路径错误、预制体未挂对应 HolderUIHolderFactory 绑定会失败
  • 正常业务打开窗口和创建 Widget 时,优先走 GameApp.UI.ShowUI<T>()CreateWidgetAsync<T>(),不建议绕过框架直接大量使用工厂

API 参考

IUIService.Initialize(Transform root, bool isOrthographic)

  • 必填参数:root
  • 必填参数:isOrthographic
  • 返回值:无
  • 说明:初始化 UI 根、Canvas、Camera 与各层级节点

UniTask<T> ShowUI<T>(params object[] userDatas) where T : UIBase

  • 可选参数:userDatas
  • 返回值:UniTask<T>
  • 泛型约束:T : UIBase
  • 说明:异步打开窗口
  • 推荐:默认优先使用该方法

T ShowUISync<T>(params object[] userDatas) where T : UIBase

  • 返回值:T
  • 说明:同步打开窗口
  • 注意:仅在资源已就绪时使用

void CloseUI<T>(bool force = false) where T : UIBase

  • 可选参数:force
  • 返回值:无
  • 说明:关闭指定窗口

CreateWidgetAsync<T>(Transform parent, bool visible = true) where T : UIBase

  • 必填参数:parent
  • 可选参数:visible
  • 返回值:UniTask<T>
  • 泛型约束:T : UIBase
  • 说明:从父 UI 创建 Widget

RemoveWidget(UIBase widget)

  • 必填参数:widget
  • 返回值:UniTask
  • 说明:从父 UI 中移除并销毁 Widget

完整使用示例

示例 1窗口逻辑 + 自动生成 Holder

using AlicizaX.UI.Runtime;
using UnityEngine;

[Window(UILayer.UI, fullScreen: true, cacheTime: 10)]
public sealed class InventoryWindow : UIWindow<InventoryWindowHolder>
{
    protected override async Cysharp.Threading.Tasks.UniTask OnInitializeAsync()
    {
        baseui.BtnClose.onClick.AddListener(CloseSelf);

        InventoryItemWidget item = await CreateWidgetAsync<InventoryItemWidget>(baseui.TfContent, false);
        item.Open("Potion", 10);
    }

    protected override void OnOpen()
    {
        baseui.TextTitle.text = "Inventory";
    }
}

说明:

  • InventoryWindowHolder 推荐由 UI 绑定工具生成
  • InventoryWindow 由业务手写

示例 2Widget 使用生成的 Holder

using AlicizaX.UI.Runtime;
using UnityEngine;

public sealed class InventoryItemWidget : UIWidget<InventoryItemHolder>
{
    private string _itemName;
    private int _count;

    protected override void OnInitialize()
    {
        baseui.BtnUse.onClick.AddListener(OnClickUse);
    }

    protected override void OnOpen()
    {
        _itemName = (string)UserDatas[0];
        _count = (int)UserDatas[1];

        baseui.TextName.text = _itemName;
        baseui.TextCount.text = _count.ToString();
    }

    protected override void OnClose()
    {
        baseui.TextName.text = string.Empty;
        baseui.TextCount.text = string.Empty;
    }

    private void OnClickUse()
    {
        Debug.Log($"Use item: {_itemName}");
        Close();
        Destroy();
    }
}

示例 3TabWindow

using AlicizaX.UI.Runtime;

[Window(UILayer.UI, fullScreen: true)]
public sealed class SettingWindow : UITabWindow<SettingWindowHolder>
{
    protected override void OnInitialize()
    {
        InitTabVirtuallyView<GraphicsTabWidget>(baseui.TfTabRoot);
        InitTabVirtuallyView<AudioTabWidget>(baseui.TfTabRoot);

        baseui.BtnGraphics.onClick.AddListener(() => SwitchTab(0));
        baseui.BtnAudio.onClick.AddListener(() => SwitchTab(1));
    }

    protected override void OnOpen()
    {
        SwitchTab(0);
    }
}

最佳实践

  • 不要手写大多数 UIHolder,优先使用自动生成
  • 窗口逻辑类只处理状态和行为,控件引用统一放进 Holder
  • OnInitialize 做一次性事件绑定,OnOpen 做参数刷新
  • 默认使用异步打开,避免首帧阻塞
  • 列表项和重复块优先拆成 UIWidget<T>

常见错误

手工编写 UIHolder 导致与生成器冲突

  • 现象:字段名、命名空间或资源路径不一致
  • 规避:把 Holder 视为生成产物,由工具维护

OnOpen 中重复注册按钮事件

  • 风险:窗口每次打开都会重复绑定
  • 正确做法:放到 OnInitialize

UIWidget<T> 当顶层窗口直接 ShowUI

  • UIWidget<T> 应由父 UIBase 通过 CreateWidgetAsync<T>()CreateWidgetSync<T>() 创建

性能注意事项

  • 首次打开大窗口优先预热资源或异步显示
  • 使用自动生成 Holder 可以避免大量运行时查找和手工拖引用错误
  • 高频销毁/重建的块状内容优先用 UIWidget<T>