Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

UI Kit 快速入门

首先我们来进行 UI Kit 的快速入门

制作一个界面的,步骤如下:

  1. 准备
  2. 生成代码
  3. 逻辑编写
  4. 运行

1. 准备

  • 先创建一个场景 TestUIHomePanel。
    Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit
  • 删除 Hierarchy 其他的 GameObject。
  • 搜索 UIRoot.prefab,拖入 Hierarchy。

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

  • 在 UIRoot / Design GameObject 下创建 Panel ( 右击 Design -> UI -> Panel )。
  • 将该 Panel 改名为 UIHomePanel。
  • 添加按钮 BtnStart、BtnAbout。

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

  • 对 BtnStart、BtnAbout 添加 UIMark Component。
  • 将 UIHomePanel 做成 prefab,再进行 AssetBundle 标记。

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

2. 生成代码

  • 右击 UIHomePanel Prefab 选择 @UI Ki t- Create UI Code,生成 UI 脚本。

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

  • 此时,生成的脚本自动挂到了 UIHomePanel 上,并且进行 UIMark 标记的控件,自动绑定到 prefab 上,如图所示:

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

3. 逻辑编写

  • 打开 UIHomePanel ,在 ResgisterUIEvent 上编写 Button 事件绑定,编写后代码如下:
/* 2018.7 凉鞋的MacBook Pro (2) */

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using QFramework;

namespace QFramework.UIKitExample
{
    public class UIHomePanelData : UIPanelData
    {
        // TODO: Query Mgr's Data
    }

    public partial class UIHomePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIHomePanelData ?? new UIHomePanelData();
            //please add init code here
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            throw new System.NotImplementedException ();
        }

        protected override void RegisterUIEvent()
        {
            BtnStart.onClick.AddListener(() =>
            {
                Log.E("BtnStart Clicked !!!");
            });
            
            BtnAbout.onClick.AddListener(() =>
            {
                Log.E("BtnAbout Clicked !!!");
            });
        }

        protected override void OnShow()
        {
            base.OnShow();
        }

        protected override void OnHide()
        {
            base.OnHide();
        }

        protected override void OnClose()
        {
            base.OnClose();
        }

        void ShowLog(string content)
        {
            Debug.Log("[ UIHomePanel:]" + content);
        }
    }
}

4. 运行

  • 创建一个 空 GameObject,命名为 TestUIHomePanel,挂上 UIPanelTester 脚本。
  • UIPanelTester 脚本的 PanelName 填写为 UIHomePanel。

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

  • 运行!
  • 运行之后,点击对应的按钮则会有对应的输出。

UI Kit 的快速入门就介绍到这里。其中的 UI Kit 代码生成和 Res Kit 的 Simulation Mode 为日常开发节省了大量的时间。

UI Kit 层级管理

我们的 UIHomePanel 是怎么打开的呢?

答案在 UIPanelTester 中。代码如下:

namespace QFramework
{
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    [System.Serializable]
    public class UIPanelTesterInfo
    {
        /// <summary>
        /// 页面的名字
        /// </summary>
        public string PanelName;

        /// <summary>
        /// 层级名字
        /// </summary>
        public UILevel Level;
    }

    public class UIPanelTester : MonoBehaviour
    {
        /// <summary>
        /// 页面的名字
        /// </summary>
        public string PanelName;

        /// <summary>
        /// 层级名字
        /// </summary>
        public UILevel Level;

        [SerializeField] private List<UIPanelTesterInfo> mOtherPanels;

        private void Awake()
        {
            ResMgr.Init();
        }

        private IEnumerator Start()
        {
            yield return new WaitForSeconds(0.2f);
            
            UIMgr.OpenPanel(PanelName, Level);

            mOtherPanels.ForEach(panelTesterInfo => { UIMgr.OpenPanel(panelTesterInfo.PanelName, panelTesterInfo.Level); });
        }
    }
}

在 Start 方法中,有一句 UIMgr.OpenPanel ( PanelName, Level );

其中 PanelName 是我们填写的 UIHomePanel。Level 则是用来显示层级的枚举。

枚举定义如下:

    public enum UILevel
    {
        AlwayBottom        = -3, //如果不想区分太复杂那最底层的UI请使用这个
        Bg                 = -2, //背景层 UI
        AnimationUnder     = -1, //动画层
        Common             = 0, //普通层 UI
        AnimationOn        = 1, // 动画层
        PopUI              = 2, //弹出层 UI
        Guide              = 3, //新手引导层
        Const              = 4, //持续存在层 UI
        Toast              = 5, //对话框层 UI
        Forward            = 6, //最高UI层用来放置UI特效和模型
        AlwayTop           = 7, //如果不想区分太复杂那最上层的UI请使用这个
    }

默认为 UILevel.Common。

层级管理的核心实现比较简单,代码如下:

            switch (uiLevel)
            {
                case UILevel.Bg:
                    ui.Transform.SetParent(mBgTrans);
                    break;
                case UILevel.AnimationUnderPage:
                    ui.Transform.SetParent(mAnimationUnderTrans);
                    break;
                case UILevel.Common:
                    ui.Transform.SetParent(mCommonTrans);
                    break;
                case UILevel.AnimationOnPage:
                    ui.Transform.SetParent(mAnimationOnTrans);
                    break;
                case UILevel.PopUI:
                    ui.Transform.SetParent(mPopUITrans);
                    break;
                case UILevel.Const:
                    ui.Transform.SetParent(mConstTrans);
                    break;
                case UILevel.Toast:
                    ui.Transform.SetParent(mToastTrans);
                    break;
                case UILevel.Forward:
                    ui.Transform.SetParent(mForwardTrans);
                    break;
            }

这种是最通用最常见的层级管理方式。只要将 UIPanel 放到对应层级的 GameObject 下面就好了。

不过这种层级管理会有一点问题。当这个 UIRoot 只有一个 Canvas 的时候,页面的打开或关闭都会进行 Canvas 网格的重新排序,也就是网格重建,所以很多方案都建议,每个 UIPanel 挂一个 Canvas。然后根据 Canvas Sorting Order 进行层级管理。

使用 Canvas 的 Sorting Order 可以更精细地进行层级管理,而 UI Kit 目前的方式相对粗糙一些。

使用 Canvas 的方式是 UI Kit 的一个方向,等 QFramework 团队找到比较简洁的实现之前,UI Kit 还是默认使用当前方式。

UI Kit 最佳实践 (一) 界面跳转及数据展示

接下来,我们来制作两个界面。一个是 UILevelPanel,一个是 UIGamePanel。

UILevelPanel 功能定义:

  • 有若干个关卡,这里暂时定义为九个关卡。
  • 点击任意关卡则跳转到 UIGamePanel,并在 UIGamePanel 中显示当前关卡信息。比如,在 UILevelPanel 点击第一关,则打开的 UIGamePanel 则展示 第一关,同理第二关。

UIGamePanel 功能定义:

  • 游戏暂停按钮。
    关卡信息展示,这里只展示第几关就够了。

这里 UILevelPanel 和 UIGamePanel 的制作的详细过程和 UI Kit 快速入门类似。简单展示下两个页面的布局,和相关的逻辑代码。

UIGamePanel.prefab:

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

  • BtnPause 是一个 Button。
  • Level 是一个 Text,主要是用来表示第几关的。

其代码如下:

UIGamePanel.cs

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePanelData : UIPanelData
    {
        public int LevelIndex = 1;
    }

    public partial class UIGamePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePanelData ?? new UIGamePanelData();
            //please add init code here

            Level.text = "第" + mData.LevelIndex + "关";
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            throw new System.NotImplementedException ();
        }

        protected override void RegisterUIEvent()
        {
            BtnPause.onClick.AddListener(() =>
            {
                Log.E("BtnPause Clicked");
            });
        }
    }
}

在 InitUI 方法中,会接收到一个 UIData,然后转为 UIGamePanelData 后赋值给 MData。

那么这个 UIData 从哪里传过来呢?

答案是 UIMgr.Open 这个 API,这个 API 提供传入相应界面的 UIData。这部分在下一小结详细说。

现在重点是,在 UIMgr.Open 不传入 UIData 时,UIGamePanel, UIData 有可能是空的,那么当 UIData 为空时,则会默认创建一个 UIGamePanelData()。

默认创建一个 UIGamePanelData 这样做有什么好处呢?

答案是 方便测试,可以在 UIGamePanelData 里给数据设置一些默认值,这里 LevelIndex 默认是 1。当然这也不是本小节的重点,这个也下一小节详细说。

由于可以设置默认值,所以 TestUIGamePanel.scene 运行之后如下:

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

默认就是第一关。这样负责编写 UIGamePanel 和游戏战斗模块的同学们,主要改自己部分的 UIGamePanelData 就可以进行一些测试了,而不是从头开始运行游戏手点直到打开 UIGamePanel 才看到测试结果,重点是 UIGamePanel 不依赖任何其他数据,只是作为数据的一个展示而已。反正这不是本节重点。。。不好意思实在忍不住:)

接下来 UILevelPanel

UILevelPanel.prefab:

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

  • LevelProtoype 是一个 Button,代表关卡 item 的原型。
  • LevelsContainer 则是一个 普通的 GameObject,不过挂上了 GridLayoutGroup 了。
  • BtnBack 则是左上角的的返回按钮。

UILevelPanel 可能会复杂一点了,代码如下:

/* 2018.7 凉鞋的MacBook Pro (2) */

using UnityEngine.UI;

namespace QFramework.Example
{
    public class UILevelPanelData : UIPanelData
    {
        // TODO: Query Mgr's Data
    }

    public partial class UILevelPanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UILevelPanelData ?? new UILevelPanelData();
            //please add init code here

            for (var i = 0; i < 10; i++)
            {

                LevelPrototype.Instantiate()
                        .Parent(LevelsContainer)
                        .LocalScaleIdentity()
                        .LocalPositionIdentity()
                        .Show()
                        .ApplySelfTo(btnLevel=>
                        {
                            var levelIndex = i + 1;

                            btnLevel.onClick.AddListener(() =>
                            {
                                CloseSelf();
                                UIMgr.OpenPanel<UIGamePanel>(uiData: new UIGamePanelData()
                                {
                                    LevelIndex = levelIndex
                                });
                            });
                        })
                        .transform.Find("Text").GetComponent<Text>().text = "第" + (i + 1) + "关";
            }
            
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            throw new System.NotImplementedException ();
        }

        protected override void RegisterUIEvent()
        {
            BtnBack.onClick.AddListener(() =>
            {
                CloseSelf();
                UIMgr.OpenPanel<UIMainPanel>();
            });
        }
    }
}

核心代码:

for (var i = 0; i < 10; i++)
{

    LevelPrototype.Instantiate()     // var BtnLevel = GameObject.Instantiate(LevelPrototype)
            .Parent(LevelsContainer) // BtnLevel.transform.SetParent(LevelsContainer)
            .LocalScaleIdentity()    // BtnLevel.localScale = Vector3.one
            .LocalPositionIdentity() // BtnLevel.localPosition = Vector3.zero
            .Show()                 // BtnLevel.gameObject.SetActive(true)
            .ApplySelfTo(btnLevel=>  // 这里就是为了捕获上边的 BtnLevel 然后进行一些操作
            {
                var levelIndex = i + 1;

                btnLevel.onClick.AddListener(() =>
                {
                    CloseSelf();
                    UIMgr.OpenPanel<UIGamePanel>(uiData: new UIGamePanelData()
                    {
                        LevelIndex = levelIndex
                    });
                });
            })
            .transform.Find("Text").GetComponent<Text>().text = "第" + (i + 1) + "关";
}

请仔细月底以上注释部分。

总之,注释部分则是对链式支持的解释。不是本文重点。

重点是:

UIMgr.OpenPanel<UIGamePanel>(uiData: new UIGamePanelData()
{
    LevelIndex = levelIndex
});

打开 UIGamePanel 的时候,可以传入一个 Data 进去。而这个 Data 就是 UIGamePanel 中 InitUI 接收到的 uiData。非常简单。

运行结果如下:

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

功能完成。

UIPanel 之间的数据传递

由上一小节介绍的从 UILevelPanel 传数据到 UIGamePanel 这种传递数据的方式有什么好处呢?

首先在笔者的框架里,推荐将 UI 界面当做只是进行数据展示的工具。是一个 Input 和 Output 模块。输入数据,然后将数据展示到界面上。在笔者接触的很多项目中,在界面逻辑中添加了非常多数据访问相关的代码,各种 DataManager、ConfigManager 等等,这里是非常不推荐的。推荐所有要展示的初始数据,都是通过 uiData 传入进来,这要每个界面或模块都可以进行独立测试。

独立测试、团队成员之间互不干扰、并且可以自己写一些测试数据这仅仅是第一个好处,还不够。

第二个好处则是为了方便完成 UIPanelStack 功能,这个功能是主要完成一个 返回上一页这个功能。举个例子。

假如 UIGamePanel 可以从 UILevelPanel 打开,那么 UILevelPanel 打开的 UIGamePanel 返回上一页应该是 UILevelPanel。

同理,通过 UIOtherPanel 打开的 UIGamePanel 返回的上一个页面应该是 UIOtherPanel。

这个功能如果用一堆判断 + UIGamePanelData 来实现很容易,但是代码不是很优雅,假如 UIGamePanel 可以从十多个页面打开,那是不是要写十多个条件判断? 而且要在每个打开 UIGamePanel 的地方都要把 上一个页面信息记录到 UIGamePanelData 里。这样很麻烦。要完成这个最简单的还是一个 UIPanelStack,用一个堆栈来记录页面打开关闭的信息。

有了 UIPanelData 这个概念,那么很容易就可以完成这个功能。主要抽象出一个 UIPanelInfo 就好了,

namespace QFramework
{
    public class UIPanelInfo
    {
        public IUIData UIData;

        public UILevel Level;

        public string AssetBundleName;
        
        public string PanelName;
    }
}

这是第二个好处了,有了 UIPanelData 这个基础就可以做更多的事情。UIPanelStack 功能不是本小节重点,在接下来会详细进行介绍。

总结下来好处就是两点:

  • 独立测试、团队成员之间互不干扰、并且可以自己写一些测试数据。
  • 更容易实现 UIPanelStack。

不过,这个 UIPanelData 的设计还有改进的空间,如果将 UIPanelData 做成可序列化的,并且 mData 是一个可赋值的话,那么做界面测试的时候就不用更改代码了,只在 prefab 上就可以进行测试数据的修改了。可以节省大量的代码编译时间。这个功能在笔者自己的项目进行过实验,是可以实现的,等稳定了会在 UI Kit 未来的版本集成。

UIMgr/UIManager

UIMgr 本身是个 Manager,之前在 ResKit 有介绍过,只要叫 XXXMgr 的本身就是一个容器,是一个对外提供容器元素一系列操作的容器。而 UIMgr 则是 UI 的容器,里边维护了一个字典,Dictionary <string,IUIPanel>。所有的 UIMgr.Open/Close/Get 等,都是先进行一次字典查询,再进行相应操作的。这个不难理解,大部分市面上的方案和 UI Kit 类似,都比较成熟了,这里不值得浪费篇幅。

UIPanelStack 管理

UIPanelStack 也就是堆栈,一般只是用来完成返回上一页这个功能的。在没用堆栈管理页面信息之前,都是打开一个页面,然后在打开的页面上记录上一页面的信息,然后当页面点击返回时,再根据记录的上一个页面的信息打开上一个页面。这种实现在页面数量不多、跳转逻辑不复杂的情况下可以勉强应付。但是跳转逻辑比较复杂的情况下还是要实现一个,所以就添加了 UIPanelStack 这个功能。

起初的实现是所有的页面,每当关闭时页面信息全部压到 Stack 里。这里的页面信息定义如下:

    public class UIPanelInfo
    {
        public IUIData UIData;

        public UILevel Level;

        public string AssetBundleName;
        
        public string PanelName;
    }

简单介绍下:

  • UIData 是 在关闭时页面的数据快照
  • Level 则是关闭时所在的层级
  • AssetBundleName 则是所加载的 AB 名,如果没有传入则为空。
  • PanelName 则是打开页面时传入的页面名字。

后来发现笔者参与的项目不需要每个页面都压入栈中,只是少数的几个页面需要。

所以提供了两个手动的 API。

UIMgr.Push<T>/UIMgr.Push(string panelName);
UIMgr.Back(IUIPanel panel)/UIMgr.Back(string panelName);

Push 很容易理解,就是 UIPanel 的压栈操作。Back 则是,返回到最近 Push 进栈中的页面。

实现原理也很简单。

  • Push 时将传入的 panelName 对应的 Panel 关闭掉,生成 UIPanelInfo 后,将 UIPanelInfo 压入到栈中。
  • Back 则是将传入的 panelName 对应的 Panel 关闭掉,从 栈中弹出一个 UIPanelInfo,之后根据信息打开页面并传入 Info 中的数据。

核心代码:

UIManager.cs

        public void Push(IUIPanel view)
        {
            if (view != null)
            {
                mUIStack.Push(view.PanelInfo);
                view.Close();
                mAllUI.Remove(view.Transform.name);
            }
        }

        public void Back(string currentPanelName)
        {
            var previousPanelInfo = mUIStack.Pop();
            CloseUI(currentPanelName);
            OpenUI(previousPanelInfo.PanelName, previousPanelInfo.Level, previousPanelInfo.UIData,
                previousPanelInfo.AssetBundleName);
        }

关于 UIPanelStack 介绍到这里。

这个功能目前还比较简陋,在 QFramework 文档中还没有进行介绍。不过相比把所有的页面信息都进行压栈操作,按需手动的这种方式更合适一些。

小结 (一)

事实上,独立测试的界面完全替代了 QFramework 初期所提供的 QApp 模块化的方式。这个不理解没关系,每个界面的开发已经是模块化了,不过只是从业务进行横向的模块化。已经够用了,毕竟大部分的项目都是进行 UI 界面的制作和修改。其他一些战斗系统等等,比较大的模块,也多少会依赖于 UI 部分,那么这种的推荐用一个 UI 界面进行一个模块的入口。总之在业务逻辑以及界面角度来看,已经支持了模块化的架构。

UI Kit 最佳实践 (二) 暂停界面与通信

接下来我们接着进行 UI Kit 最佳实践,之前我们完成了 UILevelPanel 和 UIGamePanel。

而 UIGamePanel 中的暂停功能还没有完成。点击暂停功能则应该打开暂停界面。

所以我们先完成暂停界面的制作:

UIGamePausePanel.prefab:

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

  • Image: 是对话框的白色背景
  • BtnContinue: 是继续按钮
  • BtnReplay: 是重玩按钮
  • BtnMain: 是主页按钮

其代码如下:

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePausePanelData : UIPanelData
    {
        // TODO: Query Mgr's Data
    }

    public partial class UIGamePausePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePausePanelData ?? new UIGamePausePanelData();
            //please add init code here
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            throw new System.NotImplementedException ();
        }

        protected override void RegisterUIEvent()
        {
            BtnMain.onClick.AddListener(() =>
            {
                CloseSelf();
                UIMgr.ClosePanel<UIGamePanel>();
                UIMgr.OpenPanel<UIMainPanel>();
            });

            BtnReplay.onClick.AddListener(() =>
            {
                Log.E("BtnPlay Clicked");
            });

            BtnContinue.onClick.AddListener(() =>
            {
                CloseSelf();
            });
        }
    }
}

代码比较简单,主要是三个按钮的事件注册。

而 UIGamePanel.cs 进行暂停按钮的注册:

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePanelData : UIPanelData
    {
        public int LevelIndex = 1;
    }   

    public partial class UIGamePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePanelData ?? new UIGamePanelData();
            //please add init code here

            Level.text = "第" + mData.LevelIndex + "关";          
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
        }

        protected override void RegisterUIEvent()
        {
            BtnPause.onClick.AddListener(() =>
            {
                UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI);
            });
        }
    }
}

原来的 BtnPause 点击之后进行一些日志的输出,现在则改为了打开 UIGamePausePanel 页面。

这里层级为 UILevel.PopUI。

这样一个打开暂停页面的功能就完成了。

结果如下:

Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit

但是一般的暂停页面没有这么简单。

当打开暂停页面的时候,正在进行的游戏应该全部暂停。这里最容易的实现就是 Time.timeScale = 0.0f;

不过这不是重点,当继续游戏时候,还要通知 UIGamePanel 或者对应的 GameManager 进行暂停的恢复。

这就涉及到了对象之间通信的问题。

最简单的方式就是,在 UIGamePausePanel 中获取 UIGamePanel ,然后进行相应恢复操作。

UIGamePanel.cs 先增加几个方法:

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePanelData : UIPanelData
    {
        public int LevelIndex = 1;
    }   

    public partial class UIGamePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePanelData ?? new UIGamePanelData();
            //please add init code here

            Level.text = "第" + mData.LevelIndex + "关";
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
        }

        protected override void RegisterUIEvent()
        {
            BtnPause.onClick.AddListener(() =>
            {
                GamePause();
                UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI);
            });
        }


        void GameStart()
        {
            
        }

        void GamePause()
        {
            Log.E("GamePause");
        }

        public void GameResume()
        {
            Log.E("GameResume");
        }

        void GameOver()
        {
            
        }
        
    }
}

由于 GameResume 需要交给 UIGamePausePanel 调用。所以要做成 public 方法。

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePausePanelData : UIPanelData
    {
        // TODO: Query Mgr's Data
    }

    public partial class UIGamePausePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePausePanelData ?? new UIGamePausePanelData();
            //please add init code here
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            throw new System.NotImplementedException ();
        }

        protected override void RegisterUIEvent()
        {
            BtnMain.onClick.AddListener(() =>
            {
                CloseSelf();
                UIMgr.ClosePanel<UIGamePanel>();
                UIMgr.OpenPanel<UIMainPanel>();
            });

            BtnReplay.onClick.AddListener(() =>
            {
                Log.E("BtnPlay Clicked");
            });

            BtnContinue.onClick.AddListener(() =>
            {
                CloseSelf();
                UIMgr.GetPanel<UIGamePanel>().GameResume();
            });
        }
    }
}

在 BtnContinue 中获取,这是最简单的实现。

但是从子 Panel 对象获取 父 Panel 对象,不太优雅。

这里笔者介绍一个原则:

  • 自底向上,发消息或提供委托。
  • 自顶向下,直接缓存引用。
  • 同级之间,发消息。

应用到当前的例子,则为 子 Panel 应该对 父 Panel 提供委托或者发消息。

我们先实现提供委托的方式。

这个比较简单,由于 BtnContinue 其实是 public 的,所以在 UIGamePanel 打开 UIGamePausePanel 的同时就可以进行一个按钮点击事件的注册。

代码如下:

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePanelData : UIPanelData
    {
        public int LevelIndex = 1;
    }

    public partial class UIGamePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePanelData ?? new UIGamePanelData();
            //please add init code here

            Level.text = "第" + mData.LevelIndex + "关";          
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
        }

        protected override void RegisterUIEvent()
        {
            BtnPause.onClick.AddListener(() =>
            {
                GamePause();
                UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI).BtnContinue.onClick.AddListener(GameResume);
            });
        }

        void GameStart()
        {
            
        }

        void GamePause()
        {
            Log.E("GamePause");
        }

        void GameResume()
        {
            Log.E("GameResume");
        }

        void GameOver()
        {
            
        }
        
    }
}

代码很简单,这要在 BtnPause 按钮点击时,进行 BtnContinue 的点击事件注册。

Button 的 onClick 事件,不用担心会被覆盖,因为用的是 AddListener 这个 API,是增加,实质上类似于 += 操作。这样就可以把事件从外部注入到 UIGamePausePanel 中。

这也是比较方便的一种方式,尤其是在 Dialog 或者 Prompt 通用的时候,其标记过的控件都可以在外边访问。

这样有利有弊,有点不符合面向对象的设计原则,不过问题不大。

这样第二种方式就完成了。

第三种方式则是通过发送消息,UIGamePausePanel 可以把事件发送给 UIGamePanel。

首先要在 UIGamePanel 中进行事件的定义:

    public enum UIGamePanelEvent
    {
        Start = QMgrID.UI,
        GameResume,
        End
    }

事件的注册:

    public partial class UIGamePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePanelData ?? new UIGamePanelData();
            //please add init code here

            Level.text = "第" + mData.LevelIndex + "关";
            
            RegisterEvent(UIGamePanelEvent.GameResume);
        }
        
        ...
    }

事件的处理:

    public partial class UIGamePanel : UIPanel
    {
        ...
        
        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            if (eventId == (int) UIGamePanelEvent.GameResume)
            {
                GameResume();
            }
        }
        
        ...
    }

接收消息之后,马上调用 GameResume 方法就好了。

完整 UIGamePanel.cs 代码如下:

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePanelData : UIPanelData
    {
        public int LevelIndex = 1;
    }

    public enum UIGamePanelEvent
    {
        Start = QMgrID.UI,
        GameResume,
        End
    }
    

    public partial class UIGamePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePanelData ?? new UIGamePanelData();
            //please add init code here

            Level.text = "第" + mData.LevelIndex + "关";
            
            RegisterEvent(UIGamePanelEvent.GameResume);
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            if (eventId == (int) UIGamePanelEvent.GameResume)
            {
                GameResume();
            }
        }

        protected override void RegisterUIEvent()
        {
            BtnPause.onClick.AddListener(() =>
            {
                GamePause();
                UIMgr.OpenPanel<UIGamePausePanel>(UILevel.PopUI);
            });
        }

        void GameStart()
        {
            
        }

        void GamePause()
        {
            Log.E("GamePause");
        }

        void GameResume()
        {
            Log.E("GameResume");
        }

        void GameOver()
        {
            
        }
        
    }
}

接下来是在 UIGamePausePanel 中发送事件的部分。

非常简单,代码如下:

/* 2018.7 凉鞋的MacBook Pro (2) */

namespace QFramework.Example
{
    public class UIGamePausePanelData : UIPanelData
    {
        // TODO: Query Mgr's Data
    }

    public partial class UIGamePausePanel : UIPanel
    {
        protected override void InitUI(IUIData uiData = null)
        {
            mData = uiData as UIGamePausePanelData ?? new UIGamePausePanelData();
            //please add init code here
        }

        protected override void ProcessMsg (int eventId,QMsg msg)
        {
            throw new System.NotImplementedException ();
        }

        protected override void RegisterUIEvent()
        {
            BtnMain.onClick.AddListener(() =>
            {
                CloseSelf();
                UIMgr.ClosePanel<UIGamePanel>();
                UIMgr.OpenPanel<UIMainPanel>();
            });

            BtnReplay.onClick.AddListener(() =>
            {
                Log.E("BtnPlay Clicked");
            });

            BtnContinue.onClick.AddListener(() =>
            {
                SendEvent(UIGamePanelEvent.GameResume);
                CloseSelf();
            });
        }
    }
}

在 BtnContinue 按钮点击之后进行事件的发送就好了。

这就是事件机制的方式。

目前有三种方式:

  • 获取父 Panel 调用方法。
  • 对父 Panel 提供委托。
  • 发送消息。

这里笔者比较推荐使用第二种和第三种。

其中最推荐的还是,第二种,提供委托的方式。这样事件能够很好地进行接收,保持单向的引用,可以通过代码了解两者之间的关系,所以这通常是很紧实的设计。

而第三种虽然推荐,但是多少会有一些风险。可能造成消息满天飞的情况,万不得已的情况下慎用,唯一的好处就是松耦合。在跨模块之间通信,或者同级的对象之间建议用消息,这种情况已经是万不得已的情况下了。

而第一种方式呢,造成了双向引用,是完全违背常识的,所以不推荐,不过项目紧的时候先完成需求为先。

ok,三种方式的利弊介绍到这里。

观察者模式与事件机制

观察者模式是对象/模块之间解耦的利器

观察者模式是什么?笔者看了很多官方和书上定义后也是一头雾水,我们先抛弃 Observer/Subscriber 等技术概念,直接来段简单易读的代码更容易理解些。

    class Person
    {
        public string Name;
        
        public void Say(string msg)
        {
            Log.E ("Person:{0} Say:{1}", Name, msg);
        }

        public void ReceiveMsg(string msg)
        {
            Say (msg);
        }
    }

代码不难理解,主要是 ReceiveMsg 方法用来接收消息。

    class Me
    {
        List<Person> mContacts = new List<Person>();
        
        public void RegisterContact(Person person)
        {
            mContacts.Add(person);
        }
        
        public void SendMsgToContancts()
        {
            mContacts.ForEach(person => person.ReceiveMsg("Hi"));
        }
    }

Me 就是,也就是发送者,将消息发送出去。

  • mContacts,就是联系人的意思。
  • RegisterContact,需要在我的联系人名单里注册好 Person,Person 才能接收到我发出去的消息。
  • SendMsgToContacts,则是我向联系人发送消息。

测试代码:

    public class ObserverPatternExample : MonoBehaviour 
    {
        // Use this for initialization
        IEnumerator Start () 
        {
            Me me = new Me ();
            
            me.RegisterPerson(new Person (){ Name = "张三" });
            me.RegisterPerson(new Person (){ Name = "李四" });
            me.RegisterPerson(new Person (){ Name = "王五 "});

            yield return new WaitForSeconds (1.0f);

            me.SendMsgToContancts ();
        }
    }

输出结果为:

Person:张三 Say:Hi
Person:李四 Say:Hi
Person:王五 Say:Hi

这是一种一对多的消息广播。

当然多对一,多对多很容易实现,原理都是通过观察者模式。

有了以上例子为基础,再去看设计模式中的观察者模式就会更简单了。

观察者模式的经典实现,都有一个消息注册列表,一般是在,Subject 中,这个 Subject 对应的是 以上例子中的 Me,而 Observer 则对应的是 Person。

接下来介绍下观察者模式的应用,消息机制 QEventSystem。

QEventSystem 提供了如下的 API:

QEventSystem.RegisterEvent(eventKey,onEventCallback);
QEventSystem.UnRegisterEvent(eventKey,onEventCallback);
QEventSystem.SendEvent(eventKey,params object[] args);

很简单,就是注册,注销,和发送事件。

而 QEventSystem 本身就是一个消息注册列表,相当于上个例子中的 List<Person> 封装到了 QEventSystem。

所以如果将以上例子改成使用 QEventSystem,则代码会变成如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using QFramework;

namespace CourseExample
{
    class Person
    {
        public const int PersonEvent = 0;

        public string Name;

        public Person()
        {
            QEventSystem.RegisterEvent (PersonEvent, OnReceiveEvent);
        }

        void OnReceiveEvent(int eventKey,object[] args)
        {
            var receivedMsg = args [0] as string;
            Say (receivedMsg);
        }
        
        public void Say(string msg)
        {
            Log.E ("Person:{0} Say:{1}", Name, msg);
        }
    }

    class Me
    {
        public void SendMsgToContancts()
        {
            QEventSystem.SendEvent (Person.PersonEvent, "Hi");
        }
    }

    public class ObserverPatternExample : MonoBehaviour 
    {
        // Use this for initialization
        IEnumerator Start () 
        {
            Me me = new Me ();
            new Person (){ Name = "张三" };
            new Person (){ Name = "李四" };
            new Person (){ Name = "王五 "};

            yield return new WaitForSeconds (1.0f);

            me.SendMsgToContancts ();
        }
    }
}

QEventSystem 与观察者模式就介绍到这里。

中介者模式与事件机制

这里不多说,主要比较下观察者模式和中介者模式就理解了。

观察者 (observer)模式

  • 通过 订阅 - 发布 (subscribe-publish) 模型,消除组件之间双向依赖
  • 消息的 发布者 (subject) 不需要知道 观察者 (observer) 的存在
  • 两者只需要约定消息的格式(如何订阅、如何发布),就可以通信
  • 对应的是以上的不用 QEventSystem 的 Person/Me 的例子。

中介者 (mediator) 模式

  • 通过设置 消息中心 (message center),避免组件之间直接依赖
  • 所有的 协同者 (colleague) 只能通过 中介者 (mediator) 进行通信,
    而相互之间不知道彼此的存在
  • 当各个组件的消息出现循环时,消息中心可以消除组件之间的依赖混乱
  • 对应的是以上的使用 QEventSystem 的 Person/Me 的例子。

所以 QEventSystem 与其说是观察者模式,不如说是中介者模式的实现。

以上关于设计模式的描述,仅仅是表达个人的理解,方便初学者更容易理解。

小结 (二)

在这个小结简单了解了 观察者模式和中介者模式,以及 UI Kit 所支持的事件机制。

Manager Of Managers 简介

UI Kit 内置了一个 Manager Of Managers 的支持。

主要提供两个功能:

  • 模块化的脚本管理
  • 模块化的消息系统

而模块化的脚本管理,对我们来说不是很陌生。其中 UIMgr 与 UIPanel 则是其一种实现。

UIMgr 为脚本管理器,而 UIPanel 则是脚本单元。

对应的可以推测出, CharacterManager 则有对应的 Character,Enemy/Weapon Manager 有对应的 Weapon 和 Manager 脚本。这是在一个业务逻辑上划分模块的一种方式。不难理解。

UI Kit 提供了两个基类:

QMonoBehaviour 和 QMgrBehaviour:

  • QMonoBehaviour 则是脚本的基类,比如 UIPanel 则是继承了 QMonoBehaivour。
  • QMgrBhavriour 则是管理类的基类,比如 UIMgr 则是继承了 QMgrBehaviour。

不难理解,而 UIMgr 里边维护了一个关于 UIPanel 的容器。很好地表明了 QMgrBehaviour 与 QMonoBehaviour 之间的关系。

从而想实现其他模块也变得容易,比如 CharacterManager 只要继承 QMgrBehaviour 而 Character 脚本则继承 QMonoBehaviour 就可以实现角色模块了。

以上则是模块化的脚本管理的介绍。

另一个利器则是模块化的消息系统。

大家可能发下 UIGamePanel 与 UIGamePausePanel 实现的事件机制 与 后来的 QEventSystem 有一点区别。

在 UIGamePanelEvent 中,事件的定义如下:

    public enum UIGamePanelEvent
    {
        Start = QMgrID.UI,
        GameResume,
        End
    }

为什么 Start 要等于 QMgrID.UI 呢?

我们先来看看 QMgrID 是什么?

    public class QMsgSpan
    {
        public const int Count = 3000;
    }

    public partial class QMgrID
    {
        public const int Framework = 0;
        public const int UI = Framework + QMsgSpan.Count; // 3000
        public const int Audio = UI + QMsgSpan.Count; // 6000
        public const int Network = Audio + QMsgSpan.Count;
        public const int UIFilter = Network + QMsgSpan.Count;
        public const int Game = UIFilter + QMsgSpan.Count;
        public const int PCConnectMobile = Game + QMsgSpan.Count;
        public const int FrameworkEnded = PCConnectMobile + QMsgSpan.Count;
        public const int FrameworkMsgModuleCount = 7;
    }
}

是一个长度为 3000 的区间,那么从 3000 ~ 5999 则是 UI 模块的消息。

而 6000 ~ 8999 之间的则是 Audio 消息。不难理解。

这样就实现了一个模块化的消息频段。

UIGamePausePanel 发送消息的顺序为:

UIGamePausePanel -> UIMgr -> UIGamePanel。

这里还没有介绍 UIMgr 为什么可以中转消息。

我们看了 QMgrBehaviour 与 QMonoBehaivour 实现就知道了:

QMgrBehaviour.cs

/* Copyright (c) 2018.3 liangxie */

namespace QFramework 
{
    using System;
    using System.Collections.Generic;
    
    /// <summary>
    /// manager基类
    /// </summary>
    public abstract class QMgrBehaviour : QMonoBehaviour,IManager
    {
        private readonly QEventSystem mEventSystem = NonPublicObjectPool<QEventSystem>.Instance.Allocate();

        #region IManager
        public virtual void Init() {}
        #endregion

        protected int mMgrId = 0;

        protected abstract void SetupMgrId ();

        protected override void SetupMgr ()
        {
            mCurMgr = this;
        }

        protected QMgrBehaviour() 
        {
            SetupMgrId ();
        }

        public void RegisterEvents<T>(IEnumerable<T> eventIds,OnEvent process) where T: IConvertible
        {
            foreach (var eventId in eventIds)
            {
                RegisterEvent(eventId,process);
            }
        }

        public void RegisterEvent<T>(T msgId,OnEvent process) where T:IConvertible
        {
            mEventSystem.Register (msgId, process);
        }

        public void UnRegisterEvents(List<ushort> msgs,OnEvent process)
        {
            for (int i = 0;i < msgs.Count;i++)
            {
                UnRegistEvent(msgs[i],process);
            }
        }

        public void UnRegistEvent(int msgEvent,OnEvent process)
        {
            mEventSystem.UnRegister (msgEvent, process);
        }

        public override void SendMsg(QMsg msg)
        {
            if (msg.ManagerID == mMgrId)
            {
                Process(msg.EventID, msg);
            }
            else 
            {
                QMsgCenter.Instance.SendMsg (msg);
            }
        }

        public override void SendEvent<T>(T eventId)
        {
            SendMsg(QMsg.Allocate(eventId));
        }

        // 来了消息以后,通知整个消息链
        protected override void ProcessMsg(int eventId,QMsg msg)
        {
            mEventSystem.Send(msg.EventID,msg);
        }
    }
}

QMonoBehaviour.cs

/* Copyright (c) 2017 liangxie */

namespace QFramework 
{
    using UnityEngine;
    using System;
    using System.Collections.Generic;

    public abstract class QMonoBehaviour : MonoBehaviour
    {
        protected bool mReceiveMsgOnlyObjActive = true;
        
        public void Process (int eventId, params object[] param)  
        {
            if (mReceiveMsgOnlyObjActive && gameObject.activeInHierarchy || !mReceiveMsgOnlyObjActive)
            {
                QMsg msg = param[0] as QMsg;
                ProcessMsg(eventId, msg);
                msg.Processed = true;
                
                if (msg.ReuseAble)
                {
                    msg.Recycle2Cache();
                }
            }
        }

        protected virtual void ProcessMsg (int eventId,QMsg msg) {}

        protected abstract void SetupMgr ();
        
        private QMgrBehaviour mPrivateMgr = null;
        
        protected QMgrBehaviour mCurMgr 
        {
            get 
            {
                if (mPrivateMgr == null ) 
                {
                    SetupMgr ();
                }

                if (mPrivateMgr == null) 
                {
                    Debug.LogError ("not set mgr yet");
                }

                return mPrivateMgr;
            }

            set { mPrivateMgr = value; }
        }
            
        public virtual void Show()
        {
            gameObject.SetActive (true);

            OnShow ();
        }

        protected virtual void OnShow() {}

        public virtual void Hide()
        {
            OnHide ();

            gameObject.SetActive (false);
            Log.I("On Hide:{0}",name);
        }

        protected virtual void OnHide() {}

        protected void RegisterEvents<T>(params T[] eventIDs) where T : IConvertible
        {
            foreach (var eventId in eventIDs)
            {
                RegisterEvent(eventId);
            }
        }

        protected void RegisterEvent<T>(T eventId) where T : IConvertible
        {
            mEventIds.Add(eventId.ToUInt16(null));
            mCurMgr.RegisterEvent(eventId, Process);
        }
        
        protected void UnRegisterEvent<T>(T eventId) where T : IConvertible
        {
            mEventIds.Remove(eventId.ToUInt16(null));
            mCurMgr.UnRegistEvent(eventId.ToInt32(null), Process);
        }

        protected void UnRegisterAllEvent()
        {
            if (null != mPrivateEventIds)
            {
                mCurMgr.UnRegisterEvents(mEventIds, Process);
            }
        }

        public virtual void SendMsg(QMsg msg)
        {
            mCurMgr.SendMsg(msg);
        }

        public virtual void SendEvent<T>(T eventId) where T : IConvertible
        {
            mCurMgr.SendEvent(eventId);
        }
        
        private List<ushort> mPrivateEventIds = null;
        
        private List<ushort> mEventIds
        {
            get
            {
                if (null == mPrivateEventIds)
                {
                    mPrivateEventIds = new List<ushort>();
                }

                return mPrivateEventIds;
            }
        }

        protected virtual void OnDestroy()
        {
            OnBeforeDestroy();
            mCurMgr = null;
            
            if (Application.isPlaying) 
            {
                UnRegisterAllEvent();
            }
        }
        
        protected virtual void OnBeforeDestroy(){}
    }
}

QMgrBehaivour 维护了一个 QEventSytem 成员。用来在模块内进行消息的中转的。

那么如何实现跨模块之间的消息发送呢?

这里看下 QMsgCenter.cs 就理解了

/* Copyright (c) 2017 xiaojun@putao.com
 * Copyright (c) 2017 liangxie */

namespace QFramework
{
    using UnityEngine;

    [QMonoSingletonPath("[Event]/QMsgCenter")]
    public partial class QMsgCenter : MonoBehaviour, ISingleton
    {
        public static QMsgCenter Instance
        {
            get { return MonoSingletonProperty<QMsgCenter>.Instance; }
        }

        public void OnSingletonInit()
        {

        }

        public void Dispose()
        {
            MonoSingletonProperty<QMsgCenter>.Dispose();
        }

        void Awake()
        {
            DontDestroyOnLoad(this);
        }

        public void SendMsg(QMsg tmpMsg)
        {
            // Framework Msg
            switch (tmpMsg.ManagerID)
            {
                case QMgrID.UI:
                    QUIManager.Instance.SendMsg(tmpMsg);
                    return;
                case QMgrID.Audio:
                    AudioManager.Instance.SendMsg(tmpMsg);
                    return;
            }

            // ForwardMsg(tmpMsg);
        }
    }
}

主要是 SendMsg 这个方法。

每个消息都提供了一个 ManagerID。

什么时候消息会传递到 SendMsg 呢?

就是当消息的 Id 不在自己模块的频段时候,核心代码如下:

QMgrBehaivour.cs

        public override void SendMsg(QMsg msg)
        {
            if (msg.ManagerID == mMgrId)
            {
                Process(msg.EventID, msg);
            }
            else 
            {
                QMsgCenter.Instance.SendMsg (msg);
            }
        }

不难理解。

跨模块之间的消息发送顺序为:

UIPanel -> UIMgr -> MsgCenter -> CharacterManager -> Character。

关于模块化消息的介绍就到这里。

小结 (三)

UI Kit 有 Manager Of Managers 框架强力支持。

而 Manager Of Managers 不仅提供了模块实现的工具 QMgrBehaivour 和 QMonoBehaviour,

还提供了紧实的模块化消息支持。

而 Manager Of Manager 贯穿整个 QFramework。

从 框架层的 UIMgr、AudioManager、NetworkManager 到 GamePlay 层的 CharacterManager/GameManager (需自己实现) 等,都可以使用。

本 Chat 的两个重点,一个是 UI 的管理,和一个是事件系统。

  • UI 管理
    • 层级管理
    • 界面生命周期管理
  • 事件系统
    • 全局消息机制
    • 基于模块的消息机制

除了以上,其实还有一个系统,就是组件系统,整个组件系统的前提是强大的代码生成支持。

  • UI 组件系统
    • UIMark 标记
      • UIDefaultComponent: 默认的 Button、Image 等 Unity UGUI 提供的空间,自动识别。
      • UIElement: 不可复用的自定义控件。
      • UIComponent: 可复用的自定义控件。

以上整个系统呢在后续的 GitChat 里介绍。

搭建自己的 UI 框架

之前都是手把手的教大家如何去搭建,不如给大家一个执行清单,大家跟着一下的步骤试着自己实现一下。实现完了之后欢迎跟笔者进行更多的探讨。

MyUIKit v0.0.1 开始

  • UIRoot Prefab 结构
    • UIRoot(Canvas)
      • EventSystem
      • UICamera
  • UIManager
    • 实现容器
    • 提供动态加载方式
      • Resources
    • 提供 API
      • 加载 UIRoot
      • Open
      • Close
      • Show
      • Hide
  • UIPanel
    • 提供生命周期支持
      • OnOpen
      • OnEnter
      • OnExit
      • OnShow
      • OnHide

MyUIKit v0.0.2 层级管理

  • UIRoot
    • 添加常用层级
  • UIManager
    • 增加层级管理
    • 提供 Open API 关于层级管理的重载支持

MyUIKit v0.0.3 UIData

  • UIData 基类定义

  • UIPanel
    • 提供 UIData 接收参数
  • UIManager
    • Open API 提供 UIData 传入参数

MyUIKit v0.0.4 堆栈支持

  • UIPanelInfo 支持
    • 记录层级信息
    • 记录PanelName
    • 记录 UIData
  • UIManager
    • 提供 Stack 容器
    • 提供 Push/Back API

MyUIKit v0.0.5 事件机制实现

MyUIKit v0.0.6 事件机制集成到基类

  • UIPanel 集成消息注册列表,在 OnDestory 或者关闭时自动进行卸载操作。

MyUIKit v0.1.1 简易 UI Kit

到这里已经实现了简易的 UI Kit,包含了 UI 层级管理、堆栈、界面管理、事件系统等核心功能。

在执行以上清单的过程中遇到问题欢迎随时与我交流。

如果完成,想知道接下来的开发方向,也请与我交流。

转载请注明地址:凉鞋的笔记:liangxiegame.com

更多内容

  • QFramework 地址:https://github.com/liangxiegame/QFramework
  • QQ 交流群:623597263
  • Unity 进阶小班
    • 主要训练内容:
      • 框架搭建训练(第一年)
      • 跟着案例学 Shader(第一年)
      • 副业的孵化(第二年、第三年)
    • 权益、授课形式等具体详情请查看《小班产品手册》:https://liangxiegame.com/master/intro
  • 关注公众号:liangxiegame 获取第一时间更新通知及更多的免费内容。
    Unity 游戏框架搭建 2018 (六) UI 管理神器 UI Kit
上一篇:10.根据命令行参数注册多个服务


下一篇:#跟着教程学# 2、Maya Developer Kit下载,及 PyCharm关联Maya