Skip to content

Latest commit

 

History

History
1095 lines (870 loc) · 29.4 KB

3-5-UI模块.md

File metadata and controls

1095 lines (870 loc) · 29.4 KB

3-5. UI 模块 - UIModule

🎨 模块概述

一个游戏 70% 都是 UI,剩下 30% 才是 GamePlay。TEngine UI 模块是一套轻量级、高可拓展性的商业化 UI 框架,配合事件模块实现 MVE(Model - View - Event)事件驱动架构,能够提高至少一倍的开发效率。

✨ 核心优势

  1. 🚀 轻量级设计

    • UI 脚本为纯 C# 实现,完全脱离 Mono 的生命周期
    • 由 UIModule 的帧更新驱动并管理 UI 的生命周期
    • 零依赖,不强制绑定任何第三方 UI 框架
    • 极小的内存占用和性能开销
  2. 🔧 极高的可拓展性

    • 模块化架构:UIBase → UIWindow/UIWidget 分层设计
    • 代码自动生成:一键生成 UI 绑定代码,提升开发效率
    • 灵活的生命周期:可自定义 UI 的创建、刷新、更新、销毁流程
    • 事件驱动:与事件模块深度集成,支持 MVE 架构
  3. 💡 商业级开发流程

    • 完整的 UI 开发工作流
    • 支持同步/异步打开面板
    • 自动管理 UI 资源引用(与资源模块集成)
    • 支持 UI 层级管理和全屏面板
  4. 🎯 智能资源管理

    • 与资源模块深度集成
    • UI 销毁时自动释放资源引用
    • 支持 UIWidget 动态创建和销毁

🏗️ 架构设计

类层次结构

UIBase (UI基类)
├── UIWindow (UI窗口基类)
└── UIWidget (UI组件基类)

核心概念

  • UIBase:所有 UI 的基类,提供基础功能和生命周期管理
  • UIWindow:窗口级 UI,用于全屏或半屏面板
  • UIWidget:组件级 UI,用于可复用的 UI 组件

设计理念

  1. 纯 C# 实现:不依赖 MonoBehaviour,完全由框架管理生命周期
  2. 事件驱动:通过事件系统解耦 UI 逻辑和业务逻辑
  3. 资源自动化:UI 销毁时自动清理资源引用和事件监听

🛠️ 开发工作流

1. 前期配置

UI 命名规范

在 Scene 窗口下右键 ScriptGenerator 菜单下的 About 目录可以查看默认 UI 命名前缀规范:

image

默认命名规范(完整前缀表):

前缀 生成类型 示例节点名
m_go_ GameObject m_go_Effect
m_item_ UIWidget(子类) m_item_Slot
m_tf_ Transform m_tf_Container
m_rect_ RectTransform m_rect_Panel
m_text_ Text m_text_Title
m_richText_ RichTextItem m_richText_Desc
m_btn_ Button m_btn_Start
m_img_ Image m_img_Icon
m_rimg_ RawImage m_rimg_Avatar
m_scroll_ ScrollRect m_scroll_List
m_scrollBar_ Scrollbar m_scrollBar_Vert
m_input_ InputField m_input_Name
m_grid_ GridLayoutGroup m_grid_Items
m_hlay_ HorizontalLayoutGroup m_hlay_Tabs
m_vlay_ VerticalLayoutGroup m_vlay_List
m_slider_ Slider m_slider_Volume
m_toggle_ Toggle m_toggle_Sound
m_group_ ToggleGroup m_group_Tab
m_curve_ AnimationCurve m_curve_Anim
m_canvasGroup_ CanvasGroup m_canvasGroup_Fade
m_tmp_ TextMeshProUGUI m_tmp_Name
m_tmpInput_ TMP_InputField m_tmpInput_Search
m_tmpDropdown_ TMP_Dropdown m_tmpDropdown_Lang
m_canvas_ Canvas m_canvas_Overlay
m_dropdown_ Dropdown m_dropdown_Select

注意:前缀匹配按 ScriptGeneratorSetting.asset 中的顺序进行,长前缀优先匹配(如 m_scrollBar_ 在 m_scroll_ 之前,m_tmpInput_ 在 m_tmp_ 之前)。

重要提示:m_item 节点为特殊节点,表示是 UI 下的 UIWidget 组件,不会继续往下遍历生成 UI 代码。若需要这个 UIWidget 组件的代码,则在 m_item 节点右键生成该组件的 UI 脚本。

自定义配置

有自定义需求可以在 TEngineSetting 下进行自定义:

image

2. UI 编排

遵守前期默认配置或者自定义配置进行 UI 编排:

image

3. 代码生成

在 UI 的根节点右键 ScriptGenerator 生成 UI 代码到剪贴板上:

image

提示:使用 UniTask 的生成代码可以做异步事件流驱动的 UI。

4. 创建 UI 脚本

自行创建 UI 脚本到需要的目录下并复制生成的 UI 代码。


📖 使用示例

基础示例:打开 UI 面板

// 同步打开面板
GameModule.UI.ShowUI<GameMainUI>(userData);

// 异步打开面板(推荐)
await GameModule.UI.ShowUIAsync<GameMainUI>(userData);

完整示例:UIWindow

namespace GameLogic
{
    /// <summary>
    /// BattleMainUI 面板
    /// <remarks>
    /// UIWindow 需要标记 Window 特性:
    /// - UILayer:UI 层级(可以自行定义)
    /// - fullScreen:是否为全屏面板(全屏面板会停止和隐藏这个面板堆栈后面的面板)
    /// </remarks>
    /// </summary>
    [Window(UILayer.Bottom, fullScreen: true)]
    class BattleMainUI : UIWindow
    {
        private TouchMove m_touchView;
        
        #region 脚本工具生成的代码
        private RectTransform m_rectContainer;
        private GameObject m_itemTouch;
        private Button m_btnLeaveBattle;
        private GameObject m_goTopInfo;
        private Button m_btnPause;
        
        public override void ScriptGenerator()
        {
            m_rectContainer = FindChildComponent<RectTransform>("m_rectContainer");
            m_itemTouch = FindChild("m_rectContainer/m_itemTouch").gameObject;
            m_btnLeaveBattle = FindChildComponent<Button>("m_btnLeaveBattle");
            m_goTopInfo = FindChild("m_goTopInfo").gameObject;
            m_btnPause = FindChildComponent<Button>("m_goTopInfo/m_btnPause");
            
            m_btnLeaveBattle.onClick.AddListener(OnClickLeaveBattleBtn);
            m_btnPause.onClick.AddListener(OnClickPauseBtn);
        }
        #endregion

        #region 事件处理

        private void OnClickPauseBtn()
        {
            BattleSys.Instance.Pause = !BattleSys.Instance.Pause;
        }

        private void OnClickLeaveBattleBtn()
        {
            BattleSys.Instance.StopBattle(isBattleEnd: false, isWin: false);
        }
        
        #endregion

        /// <summary>
        /// 注册事件
        /// 通过 AddUIEvent 注册的事件会自动绑定 UI 生命周期,面板销毁时自动移除监听
        /// </summary>
        public override void RegisterEvent()
        {
            AddUIEvent(ActorLogicEventDefined.OnMainPlayerBagDataChange, RefreshUI);
        }

        /// <summary>
        /// 绑定 UI 成员属性
        /// 特殊的 m_item 节点需要创建对应的 UIWidget 脚本
        /// </summary>
        public override void BindMemberProperty()
        {
            // 方式1:通过 GameObject 创建 Widget
            m_touchView = CreateWidget<TouchMove>(m_itemTouch);
            
            // 方式2:通过路径创建 Widget
            // m_touchView = CreateWidget<TouchMove>("m_rectContainer/m_itemTouch");
            
            // 方式3:通过资源路径异步创建 Widget
            // m_touchView = await CreateWidgetByPathAsync<TouchMove>(m_rectContainer, "UI/TouchMove");
        }

        /// <summary>
        /// UI 刷新
        /// </summary>
        public override void OnRefresh()
        {
            // 刷新 UI 数据
        }
    }
}

UIWidget 示例

namespace GameLogic
{
    /// <summary>
    /// 移动操作 UIWidget
    /// </summary>
    class TouchMove : UIWidget, IUICtrlMove
    {
        public override void BindMemberProperty()
        {
            // 绑定 Widget 成员
        }

        /// <summary>
        /// UI 更新
        /// 注意:只有在重写了此方法才会驱动这个 Widget 或面板的 Update
        /// </summary>
        public override void OnUpdate()
        {
            TProfiler.BeginSample("CheckMoveTouchFinger");
            CheckMoveTouchFinger();
            TProfiler.EndSample();

            TProfiler.BeginSample("UpdateTouchMovePos");
            UpdateTouchMovePos();
            TProfiler.EndSample();

            TProfiler.BeginSample("UpdateKeyMove");
            UpdateKeyMove();
            TProfiler.EndSample();
        }
    }
}

🎨 UIWidget 设计与拓展

设计理念

UIWidget 是 TEngine UI 框架中的核心组件设计,采用组合优于继承的设计理念,通过 Widget 的组合来构建复杂的 UI 界面。

核心优势

  1. 🔧 高度可复用

    • Widget 可以在多个 Window 中复用
    • 独立的生命周期管理
    • 可独立测试和维护
  2. 📦 模块化设计

    • 每个 Widget 职责单一
    • 通过组合构建复杂界面
    • 易于扩展和维护
  3. 🎯 灵活的生命周期

    • 支持动态创建和销毁
    • 支持嵌套 Widget
    • 自动管理资源引用

层次结构设计

TEngine UI 框架支持多层次的 Widget 嵌套结构:

UIWindow (窗口)
  ├── UIPage (页面 Widget)
  │   ├── TabButtonWidget (页签按钮 Widget)
  │   ├── ContentWidget (内容 Widget)
  │   │   └── ItemWidget (列表项 Widget)
  │   └── ...
  └── ...

完整示例:背包界面

下面通过一个完整的背包界面示例,展示如何使用 UIWidget 构建复杂的 UI 界面。

界面结构图

BagWindow (UIWindow - 背包窗口)
│
├── m_goTopBar (顶部栏)
│   ├── m_btnClose (关闭按钮)
│   └── m_textTitle (标题文本)
│
├── m_goTabBar (页签栏)
│   ├── m_itemTabWeapon (TabButtonWidget - 武器页签)
│   ├── m_itemTabArmor (TabButtonWidget - 防具页签)
│   ├── m_itemTabConsumable (TabButtonWidget - 消耗品页签)
│   └── m_itemTabMaterial (TabButtonWidget - 材料页签)
│
└── m_goContent (内容区域)
    └── m_itemPageContainer (页面容器)
        ├── WeaponPage (UIPage Widget - 武器页面)
        │   └── m_itemScrollView (滚动视图)
        │       └── m_itemList (列表容器)
        │           ├── BagItemWidget (Item Widget - 背包物品)
        │           ├── BagItemWidget
        │           └── ...
        ├── ArmorPage (UIPage Widget - 防具页面)
        ├── ConsumablePage (UIPage Widget - 消耗品页面)
        └── MaterialPage (UIPage Widget - 材料页面)

1. BagWindow - 背包窗口(UIWindow)

namespace GameLogic
{
    /// <summary>
    /// 背包窗口
    /// </summary>
    [Window(UILayer.UI)]
    class BagWindow : UIWindow
    {
        // 页签按钮 Widget 列表
        private List<TabButtonWidget> _tabButtons = new List<TabButtonWidget>();
        
        // 页面 Widget 列表
        private List<UIPage> _pages = new List<UIPage>();
        
        // 当前选中的页签索引
        private int _currentTabIndex = 0;
        
        #region 脚本工具生成的代码
        private GameObject m_goTopBar;
        private Button m_btnClose;
        private Text m_textTitle;
        private GameObject m_goTabBar;
        private GameObject m_itemTabWeapon;
        private GameObject m_itemTabArmor;
        private GameObject m_itemTabConsumable;
        private GameObject m_itemTabMaterial;
        private GameObject m_goContent;
        private Transform m_itemPageContainer;
        
        public override void ScriptGenerator()
        {
            m_goTopBar = FindChild("m_goTopBar").gameObject;
            m_btnClose = FindChildComponent<Button>("m_goTopBar/m_btnClose");
            m_textTitle = FindChildComponent<Text>("m_goTopBar/m_textTitle");
            m_goTabBar = FindChild("m_goTabBar").gameObject;
            m_itemTabWeapon = FindChild("m_goTabBar/m_itemTabWeapon").gameObject;
            m_itemTabArmor = FindChild("m_goTabBar/m_itemTabArmor").gameObject;
            m_itemTabConsumable = FindChild("m_goTabBar/m_itemTabConsumable").gameObject;
            m_itemTabMaterial = FindChild("m_goTabBar/m_itemTabMaterial").gameObject;
            m_goContent = FindChild("m_goContent").gameObject;
            m_itemPageContainer = FindChild("m_goContent/m_itemPageContainer");
            
            m_btnClose.onClick.AddListener(OnClickCloseBtn);
        }
        #endregion

        public override void BindMemberProperty()
        {
            // 创建页签按钮 Widget
            _tabButtons.Add(CreateWidget<TabButtonWidget>(m_itemTabWeapon));
            _tabButtons.Add(CreateWidget<TabButtonWidget>(m_itemTabArmor));
            _tabButtons.Add(CreateWidget<TabButtonWidget>(m_itemTabConsumable));
            _tabButtons.Add(CreateWidget<TabButtonWidget>(m_itemTabMaterial));
            
            // 设置页签按钮数据
            _tabButtons[0].SetData("武器", 0, OnTabClick);
            _tabButtons[1].SetData("防具", 1, OnTabClick);
            _tabButtons[2].SetData("消耗品", 2, OnTabClick);
            _tabButtons[3].SetData("材料", 3, OnTabClick);
            
            // 创建页面 Widget
            _pages.Add(CreateWidgetByType<WeaponPage>(m_itemPageContainer));
            _pages.Add(CreateWidgetByType<ArmorPage>(m_itemPageContainer));
            _pages.Add(CreateWidgetByType<ConsumablePage>(m_itemPageContainer));
            _pages.Add(CreateWidgetByType<MaterialPage>(m_itemPageContainer));
            
            // 默认显示第一个页面
            SwitchPage(0);
        }

        public override void RegisterEvent()
        {
            // 监听背包数据变化
            AddUIEvent(BagEventDefined.OnBagDataChange, OnBagDataChange);
        }

        private void OnClickCloseBtn()
        {
            GameModule.UI.CloseUI<BagWindow>();
        }

        private void OnTabClick(int tabIndex)
        {
            if (_currentTabIndex == tabIndex) return;
            
            // 更新页签状态
            _tabButtons[_currentTabIndex].SetSelected(false);
            _tabButtons[tabIndex].SetSelected(true);
            
            // 切换页面
            SwitchPage(tabIndex);
            
            _currentTabIndex = tabIndex;
        }

        private void SwitchPage(int pageIndex)
        {
            // 隐藏所有页面
            for (int i = 0; i < _pages.Count; i++)
            {
                _pages[i].Visible = (i == pageIndex);
            }
        }

        private void OnBagDataChange()
        {
            // 刷新当前页面
            if (_currentTabIndex < _pages.Count)
            {
                _pages[_currentTabIndex].Refresh();
            }
        }
    }
}

2. TabButtonWidget - 页签按钮 Widget

namespace GameLogic
{
    /// <summary>
    /// 页签按钮 Widget
    /// 可复用的页签按钮组件
    /// </summary>
    class TabButtonWidget : UIWidget
    {
        private int _tabIndex;
        private Action<int> _onClick;
        
        #region 脚本工具生成的代码
        private Button m_btnTab;
        private Text m_textTabName;
        private GameObject m_goSelected;
        
        public override void ScriptGenerator()
        {
            m_btnTab = FindChildComponent<Button>("m_btnTab");
            m_textTabName = FindChildComponent<Text>("m_btnTab/m_textTabName");
            m_goSelected = FindChild("m_btnTab/m_goSelected").gameObject;
            
            m_btnTab.onClick.AddListener(OnClickTab);
        }
        #endregion

        /// <summary>
        /// 设置页签数据
        /// </summary>
        public void SetData(string tabName, int tabIndex, Action<int> onClick)
        {
            m_textTabName.text = tabName;
            _tabIndex = tabIndex;
            _onClick = onClick;
        }

        /// <summary>
        /// 设置选中状态
        /// </summary>
        public void SetSelected(bool selected)
        {
            m_goSelected.SetActive(selected);
            // 可以添加更多选中状态的视觉效果
        }

        private void OnClickTab()
        {
            _onClick?.Invoke(_tabIndex);
        }
    }
}

3. UIPage - 页面基类 Widget

namespace GameLogic
{
    /// <summary>
    /// 页面基类 Widget
    /// 所有页面的基类,提供通用的页面功能
    /// </summary>
    abstract class UIPage : UIWidget
    {
        /// <summary>
        /// 刷新页面数据
        /// </summary>
        public virtual void Refresh()
        {
            // 子类实现具体的刷新逻辑
        }

        /// <summary>
        /// 页面显示时调用
        /// </summary>
        public override void OnSetVisible(bool visible)
        {
            base.OnSetVisible(visible);
            if (visible)
            {
                Refresh();
            }
        }
    }
}

4. WeaponPage - 武器页面 Widget

namespace GameLogic
{
    /// <summary>
    /// 武器页面 Widget
    /// 展示武器类型的背包物品
    /// </summary>
    class WeaponPage : UIPage
    {
        // 物品 Widget 列表
        private List<BagItemWidget> _itemWidgets = new List<BagItemWidget>();
        
        #region 脚本工具生成的代码
        private ScrollRect m_itemScrollView;
        private Transform m_itemList;
        
        public override void ScriptGenerator()
        {
            m_itemScrollView = FindChildComponent<ScrollRect>("m_itemScrollView");
            m_itemList = FindChild("m_itemScrollView/Viewport/Content/m_itemList");
        }
        #endregion

        public override void Refresh()
        {
            // 获取武器类型的物品数据
            var weaponItems = BagMgr.Instance.GetItemsByType(ItemType.Weapon);
            
            // 调整物品 Widget 数量
            AdjustIconNum(_itemWidgets, weaponItems.Count, m_itemList);
            
            // 更新每个物品 Widget 的数据
            for (int i = 0; i < weaponItems.Count; i++)
            {
                _itemWidgets[i].SetData(weaponItems[i]);
            }
        }
    }
}

5. BagItemWidget - 背包物品 Widget

namespace GameLogic
{
    /// <summary>
    /// 背包物品 Widget
    /// 可复用的物品展示组件
    /// </summary>
    class BagItemWidget : UIWidget
    {
        private ItemData _itemData;
        
        #region 脚本工具生成的代码
        private Image m_imgIcon;
        private Text m_textName;
        private Text m_textCount;
        private GameObject m_goQuality;
        private Button m_btnItem;
        
        public override void ScriptGenerator()
        {
            m_imgIcon = FindChildComponent<Image>("m_imgIcon");
            m_textName = FindChildComponent<Text>("m_textName");
            m_textCount = FindChildComponent<Text>("m_textCount");
            m_goQuality = FindChild("m_goQuality").gameObject;
            m_btnItem = FindChildComponent<Button>("m_btnItem");
            
            m_btnItem.onClick.AddListener(OnClickItem);
        }
        #endregion

        /// <summary>
        /// 设置物品数据
        /// </summary>
        public void SetData(ItemData itemData)
        {
            _itemData = itemData;
            
            if (itemData == null)
            {
                Visible = false;
                return;
            }
            
            Visible = true;
            
            // 设置物品图标(使用 SetSprite 自动管理资源)
            m_imgIcon.SetSprite($"UI/Icon/Item/{itemData.IconName}");
            
            // 设置物品名称
            m_textName.text = itemData.Name;
            
            // 设置数量
            m_textCount.text = itemData.Count > 1 ? itemData.Count.ToString() : "";
            
            // 设置品质显示
            SetQuality(itemData.Quality);
        }

        /// <summary>
        /// 设置品质显示
        /// </summary>
        private void SetQuality(int quality)
        {
            // 根据品质显示不同的边框颜色等
            // m_goQuality.GetComponent<Image>().color = GetQualityColor(quality);
        }

        private void OnClickItem()
        {
            if (_itemData != null)
            {
                // 发送物品点击事件
                GameEvent.Send(BagEventDefined.OnItemClick, _itemData);
            }
        }
    }
}

UIWidget 拓展场景

场景 1:列表项 Widget

列表项是 UIWidget 最常见的应用场景:

// 聊天消息列表项
class ChatMessageWidget : UIWidget
{
    public void SetData(ChatMessageData data) { }
}

// 排行榜列表项
class RankItemWidget : UIWidget
{
    public void SetData(RankData data) { }
}

// 商店商品列表项
class ShopItemWidget : UIWidget
{
    public void SetData(ShopItemData data) { }
}

场景 2:功能模块 Widget

将复杂功能封装成 Widget:

// 角色属性面板 Widget
class CharacterAttributeWidget : UIWidget
{
    public void RefreshAttribute(CharacterData data) { }
}

// 技能树 Widget
class SkillTreeWidget : UIWidget
{
    public void RefreshSkillTree(SkillTreeData data) { }
}

场景 3:动态内容 Widget

支持动态创建和销毁的 Widget:

// 动态创建提示 Widget
class TipWidget : UIWidget
{
    public static void ShowTip(string message, Vector2 position)
    {
        var tip = CreateWidgetByType<TipWidget>(UIModule.UIRoot);
        tip.SetMessage(message);
        tip.SetPosition(position);
        
        // 3秒后自动销毁
        UniTask.Delay(3000).ContinueWith(() => {
            tip.OnDestroy();
            Object.Destroy(tip.gameObject);
        }).Forget();
    }
}

UIWidget 设计模式

模式 1:数据驱动

// Widget 通过 SetData 方法接收数据,自动更新 UI
class ItemWidget : UIWidget
{
    public void SetData(ItemData data)
    {
        // 根据数据自动更新 UI
        UpdateUI(data);
    }
}

模式 2:事件驱动

// Widget 通过事件系统与外部通信
class ItemWidget : UIWidget
{
    public override void RegisterEvent()
    {
        // 监听物品数据变化
        AddUIEvent(ItemEventDefined.OnItemUpdate, OnItemUpdate);
    }
    
    private void OnItemUpdate(ItemData data)
    {
        if (data.ItemId == _itemData.ItemId)
        {
            SetData(data);
        }
    }
}

模式 3:组合模式

// 通过组合多个 Widget 构建复杂界面
class ComplexPage : UIPage
{
    private HeaderWidget _header;
    private ContentWidget _content;
    private FooterWidget _footer;
    
    public override void BindMemberProperty()
    {
        _header = CreateWidget<HeaderWidget>("m_itemHeader");
        _content = CreateWidget<ContentWidget>("m_itemContent");
        _footer = CreateWidget<FooterWidget>("m_itemFooter");
    }
}

UIWidget 最佳实践

1. 单一职责原则

每个 Widget 应该只负责一个明确的功能:

// ✅ 推荐:职责单一
class ItemIconWidget : UIWidget
{
    // 只负责显示物品图标
}

// ❌ 不推荐:职责过多
class ItemWidget : UIWidget
{
    // 同时负责显示图标、名称、数量、品质、操作按钮等
}

2. 数据与视图分离

Widget 只负责视图展示,数据由外部传入:

// ✅ 推荐:数据与视图分离
class ItemWidget : UIWidget
{
    public void SetData(ItemData data)
    {
        // 只负责根据数据更新视图
    }
}

// ❌ 不推荐:Widget 直接访问数据管理器
class ItemWidget : UIWidget
{
    public void Refresh()
    {
        var data = ItemMgr.Instance.GetItem(_itemId); // 不推荐
    }
}

3. 使用 SetData 模式

统一使用 SetData 方法更新 Widget:

// ✅ 推荐:统一的 SetData 接口
class ItemWidget : UIWidget
{
    public void SetData(ItemData data) { }
}

// ❌ 不推荐:多个更新方法
class ItemWidget : UIWidget
{
    public void SetIcon(string icon) { }
    public void SetName(string name) { }
    public void SetCount(int count) { }
}

4. 合理使用生命周期

根据需求重写生命周期方法:

class ItemWidget : UIWidget
{
    // 只在需要时重写 OnUpdate
    public override void OnUpdate()
    {
        // 实时更新倒计时等
        UpdateCountdown();
    }
    
    // 在 OnSetVisible 中处理显示/隐藏逻辑
    public override void OnSetVisible(bool visible)
    {
        base.OnSetVisible(visible);
        if (visible)
        {
            PlayShowAnimation();
        }
    }
}

🔧 核心 API

UIWindow 生命周期

/// <summary>
/// UI 创建时调用(资源加载完成后)
/// </summary>
protected virtual void OnCreate() { }

/// <summary>
/// UI 刷新时调用(每次打开时)
/// </summary>
protected virtual void OnRefresh() { }

/// <summary>
/// UI 更新时调用(每帧,需要重写才会调用)
/// </summary>
protected virtual void OnUpdate() { }

/// <summary>
/// UI 销毁时调用
/// </summary>
protected virtual void OnDestroy() { }

UIWidget 创建方式

// 方式1:通过 GameObject 创建(推荐,资源已存在)
T CreateWidget<T>(GameObject goRoot, bool visible = true) where T : UIWidget, new();

// 方式2:通过路径创建(同步)
T CreateWidgetByPath<T>(Transform parentTrans, string assetLocation, bool visible = true) where T : UIWidget, new();

// 方式3:通过路径创建(异步,推荐)
UniTask<T> CreateWidgetByPathAsync<T>(Transform parentTrans, string assetLocation, bool visible = true) where T : UIWidget, new();

// 方式4:通过类型创建(根据类型名加载资源)
T CreateWidgetByType<T>(Transform parentTrans, bool visible = true) where T : UIWidget, new();

事件注册

// 注册事件(自动绑定 UI 生命周期)
AddUIEvent(int eventType, Action handler);
AddUIEvent<T>(int eventType, Action<T> handler);
AddUIEvent<T, U>(int eventType, Action<T, U> handler);

// 移除所有事件(通常在 OnDestroy 中自动调用)
RemoveAllUIEvent();

UI 查找方法

// 查找子节点
Transform FindChild(string path);
Transform FindChild(Transform trans, string path);

// 查找组件
T FindChildComponent<T>(string path) where T : Component;
T FindChildComponent<T>(Transform trans, string path) where T : Component;

🎯 高级特性

1. UI 层级管理

UI 面板需要标记 Window 特性,以标识层级和是否全屏:

[Window(UILayer.Bottom, fullScreen: true)]
class BattleMainUI : UIWindow
{
    // 全屏面板会把下层面板的 Visible 设置为 false
}

image

2. 事件生命周期绑定

通过 AddUIEvent 注册的事件会自动绑定 UI 生命周期:

  • ✅ UI 创建时自动注册事件
  • ✅ UI 销毁时自动移除事件监听
  • ✅ 无需手动管理事件生命周期
public override void RegisterEvent()
{
    // 面板销毁时自动移除监听,无需手动 RemoveEventListener
    AddUIEvent(ActorLogicEventDefined.OnMainPlayerBagDataChange, RefreshUI);
}

3. 资源自动管理

UI 模块与资源模块深度集成:

  • ✅ UI 加载时自动管理资源引用
  • ✅ UI 销毁时自动释放资源
  • ✅ 支持 UIWidget 动态创建和销毁

4. 依赖注入支持

UI 模块支持依赖注入:

// 设置依赖注入器
UIBase.Injector = (uiBase) => {
    // 注入依赖
    if (uiBase is BattleMainUI battleUI)
    {
        battleUI.SetDependency(someService);
    }
};

5. 条件更新

UI 的 Update 方法只有在子类显式 override 时才会被框架调用,避免不必要的性能开销。这是通过基类的 _hasOverrideUpdate 字段实现的:

  • 基类 UIBase 默认将 _hasOverrideUpdate 设为 false,框架跳过未重写 OnUpdate 的面板
  • 子类一旦 override OnUpdate,框架自动识别并每帧驱动该面板的 Update
// ✅ 正确:重写 OnUpdate,框架会每帧调用
public override void OnUpdate()
{
    // 更新逻辑
}

// ✅ 正确:不需要每帧更新时,不重写 OnUpdate,节省性能开销
// (不要写空的 override OnUpdate,否则框架仍会每帧调用)

💡 最佳实践

1. 优先使用异步加载

// ✅ 推荐:异步加载,不阻塞主线程
await GameModule.UI.ShowUIAsync<GameMainUI>(userData);

// ❌ 不推荐:同步加载,可能造成卡顿
GameModule.UI.ShowUI<GameMainUI>(userData);

2. 使用 AddUIEvent 注册事件

// ✅ 推荐:自动管理事件生命周期
public override void RegisterEvent()
{
    AddUIEvent(EventDefined.SomeEvent, OnSomeEvent);
}

// ❌ 不推荐:需要手动管理
void OnCreate()
{
    GameEvent.AddEventListener(EventDefined.SomeEvent, OnSomeEvent);
}

void OnDestroy()
{
    GameEvent.RemoveEventListener(EventDefined.SomeEvent, OnSomeEvent);
}

3. UIWidget 复用

对于可复用的 UI 组件,使用 UIWidget:

// 创建多个相同的 Widget
List<ItemWidget> items = new List<ItemWidget>();
for (int i = 0; i < 10; i++)
{
    var item = CreateWidgetByType<ItemWidget>(m_itemContainer);
    items.Add(item);
}

4. 合理使用 Update

只有需要每帧更新的 UI 才重写 OnUpdate:

// ✅ 需要实时更新的 UI
public override void OnUpdate()
{
    UpdateTimer();
}

// ❌ 不需要每帧更新的 UI,不重写 OnUpdate

🔗 相关链接