第04课:UI 架构设计

前面的内容是作为架构底层设计,基本上每一款游戏都会这么做的,只是封装的方式不同罢了。下面开始实现 UI 架构设计,在每款游戏中都会涉及到 UI 逻辑编写,而且 UI 还有一个特点就是资源更换非常频繁,程序在做 UI 架构设计时,这些问题也是要重点考虑的。

市面上也有一些关于 UI 的插件,如 psd2ugui、psd2ngui 等,这些插件美术使用起来非常方便,直接把 psd 源文件给 Unity,然后 Unity 利用上述插件,生成所需要的 UI 和图集,psd 中的图层在 Unity 同样也可以实现,但是它们是作为一个整体去处理的,这样一旦某些部件需要更换,那整个 psd 都需要更换,如果 UI 资源需求量比较少,可以直接使用该插件,但是如果 UI 资源量大,再加上 UI 频繁的更换,这样的游戏逻辑就会出现各种问题,比如名字不对了,图层的的设置对应着 Unity 中的 Layer Order 设置,psd 中的命名变换了等等,这些因素都不利于程序员维护。为此我们需要提供新的 UI 框架设计,先把模块架构设计拿出来给读者展示如下所示:

unity 网络游戏架构设计(第04课:UI 架构设计)之美_控件

先说说为什么这么设计?UI 的设计框架很多的,本课程的 UI 框架不能说是最好的,但是它已经经过了商业游戏的验证,是可行的。它最大的优点就是实现了资源和代码的彻底分离,真正实现了程序和美术人员分工合作。我们的 UI 框架也是采用了 MVC 设计模式,但是在外面加了一层 State 状态变换,因为每个 UI 切换是不同的状态之间的变换,这样整个 UI 架构设计就完成了,剩下的事情就是细节的完善了,也是具体工作的开始。

UI 窗体的设计

游戏的每个 UI 使用 Panel 面板制作完成,每个面板就是一个窗体,用 MVC 中的 View 表示,用代码表示就是 Window 窗体类,MVC 中的 Control 也同样对应窗体的 Control 类。MVC 中的 Model 表示的是数据的传输,在当前可以直接使用配置文件进行读取加载,或者通过网络进行传输,它在本课程的 UI 设计中 Model 地位不明显,在此就不介绍了。

先说说 Window 窗体类的设计,每个 UI 对应自己的 Window 窗体类。下面先介绍 Window 类模块的代码编写,在写某个模块时,首先做的事情是搞清楚这个模块包括哪些内容,再考虑一下编写此类时扩展是否方便。这些都是编写程序注意的问题,有的程序员拿过策划需求就开始编写代码,这样导致的后果是一旦策划需求改变,代码就要重新加功能,搞的很被动,这对于程序员是大忌。

以 Window 类编写为例,思考一下,游戏中显示窗体首先要创建窗体,还有窗体可以隐藏、销毁,这是最基本的功能。另外,我们创建窗体首先要知道窗体的资源名字,还有这个窗体是在哪个场景中创建的,是登录场景还是游戏战斗场景等。因为窗体类不继承 Mono,为了方便使用窗体中的控件,所以还要做初始化窗体控件的功能以及做监听处理等。

我们说的这些功能对于游戏中的任何 UI 窗体都是适用的,换句话说,所有的 UI 这些功能都是必备的,也是 UI 的共性,这让人自然而然想到建一个父类,如果不建父类,每个 Window 类都要写一套逻辑,这会导致代码很乱,而且如果是多个人协作写,每人再来一套逻辑,后期代码无法维护。所以必须要建一个父类,其实编程没有那么难的,需要自己多想想就明白了,下面开始具体编写窗体父类代码,代码如下:

    public abstract class BaseWindow
{
    protected Transform mRoot; //UI根结点

    protected EScenesType mScenesType; //场景类型
    protected string mResName;         //资源名
    protected bool mResident;          //是否常驻 
    protected bool mVisible = false;   //是否可见


    //类对象初始化
    public abstract void Init();

    //类对象释放
    public abstract void Realse();

    //窗口控制初始化
    protected abstract void InitWidget();

    //窗口控件释放
    protected abstract void RealseWidget();

    //游戏事件注册
    protected abstract void OnAddListener();

    //游戏事件注消
    protected abstract void OnRemoveListener();

    //显示初始化
    public abstract void OnEnable();

    //隐藏处理
    public abstract void OnDisable();

    //每帧更新
    public virtual void Update(float deltaTime) { }

    //取得所以场景类型
    public EScenesType GetScenseType()
    {
        return mScenesType;
    }

    //是否已打开
    public bool IsVisible() { return mVisible;  }

    //是否常驻
    public bool IsResident() { return mResident; }

    //显示
    public void Show()
    {
        if (mRoot == null)
        {
            if (Create())
            {
                InitWidget();
            }
        }

        if (mRoot && mRoot.gameObject.activeSelf == false)
        {
            mRoot.gameObject.SetActive(true);

            mVisible = true;

             OnEnable();

            OnAddListener();
        }
    }

    //隐藏
    public void Hide()
    {
        if (mRoot && mRoot.gameObject.activeSelf == true)
        {
            OnRemoveListener();
            OnDisable();

            if (mResident)
            {
                mRoot.gameObject.SetActive(false);
            }
            else
            {
                RealseWidget();
                Destroy();
            }
        }

        mVisible = false;
    }

    //预加载
    public void PreLoad()
    {
        if (mRoot == null)
        {
            if (Create())
            {
                InitWidget();
            }
        }
    }

    //延时删除
    public void DelayDestory()
    {
        if (mRoot)
        {
            RealseWidget();
            Destroy();
        }
    }

    //创建窗体
    private bool Create()
    {
        if (mRoot)
        {
            Debug.LogError("Window Create Error Exist!");
            return false;
        }

        if (mResName == null || mResName == "")
        {
            Debug.LogError("Window Create Error ResName is empty!");
            return false;
        }

        if (GameMethod.GetUiCamera.transform== null)
        {
            return false;
        }

        GameObject obj = LoadUiResource.LoadRes(GameMethod.GetUiCamera.transform, mResName);

        if (obj == null)
        {
            return false;
        }

        mRoot = obj.transform;

        mRoot.gameObject.SetActive(false);

        return true;
    }

    //销毁窗体
    protected void Destroy()
    {
        if (mRoot)
        {
            LoadUiResource.DestroyLoad(mRoot.gameObject);
            mRoot = null;
        }
    }

    //取得根节点
    public Transform GetRoot()
    {
        return mRoot;
    }

}

在关键代码片段地方都加了注释,我们的父类设计成了一个抽象类并提供了一些接口便于子类实现,这些接口所要实现的具体内容是不同的,父类无法具体一一实现,但是显示、隐藏、破坏这些都是通用的函数,可以在父类中实现。再看看子类的实现方式,以 LoginWindow 类为例,我们制作一个简单的 UI 界面,如下图所示:

unity 网络游戏架构设计(第04课:UI 架构设计)之美_ide_02

在 Unity 的显示如下所示:

unity 网络游戏架构设计(第04课:UI 架构设计)之美_其他_03

UI 中有两个 Button 按钮,我们就以此为例进行代码编写,如下所示:

 public class LoginWindow : BaseWindow
{
    //开始
    Transform mBtnStart;

    enum LOGINUI
    {
        None = -1,
        Login,
        SelectServer,
    }

    public LoginWindow() 
    {
        //场景类型
        mScenesType = EScenesType.EST_Login;
        //场景资源
        mResName = GameConstDefine.LoadGameLoginUI;
        //是否常驻内存
        mResident = false;
    }

    继承接口/
    //类对象初始化监听显示和隐藏,为了解耦合
    public override void Init()
    {
        EventCenter.AddListener(EGameEvent.eGameEvent_LoginEnter, Show);
        EventCenter.AddListener(EGameEvent.eGameEvent_LoginExit, Hide);
    }

    //类对象释放
    public override void Realse()
    {
        EventCenter.RemoveListener(EGameEvent.eGameEvent_LoginEnter, Show);
        EventCenter.RemoveListener(EGameEvent.eGameEvent_LoginExit, Hide);
    }

    //窗口控件初始化以及控件监听
    protected override void InitWidget()
    {
        mBtnStart = mRoot.Find("BtnStart");
        mBtnStart.GetComponent<Button>().onClick.AddListener(OnClickAddButton);

        DestroyOtherUI();
    }

    //消息回调函数
    private void OnClickAddButton()
    {
        //在这里监听按钮的点击事件
        LoginCtrl.Instance.StartGame();
    }

    //删除Login外其他控件,例如
    public static void DestroyOtherUI()
    {
        Canvas canvas = GameMethod.GetCanvas;
        for (int i = 0; i < canvas.transform.childCount; i++)
        {
            if (canvas.transform.GetChild(i) != null && canvas.transform.GetChild(i).gameObject != null)
            {

                GameObject obj = canvas.transform.GetChild(i).gameObject;
                if (obj.name != "Login(Clone)")
                {
                    GameObject.DestroyImmediate(obj);
                }                    
            }
        }
    }

    //窗口控件释放
    protected override void RealseWidget()
    {
    }

    //游戏事件注册
    protected override void OnAddListener()
    {

    }

    //游戏事件注消
    protected override void OnRemoveListener()
    {

    }

    //显示
    public override void OnEnable()
    {

    }

    //隐藏
    public override void OnDisable()
    {
    }
}

构造函数对资源文件和 UI 所在的场景类型初始化,以及该 UI 是否常住内存。后面函数是继承 baseWindow 中具体实现,在函数中有 LoginCtrl 类接口调用,这个跟 LoginWidow 窗体息息相关,它是 MVC 中的 Control。下面再介绍 LoginCtrl 类的编写。

控制类的主要作用是播放消息,然后在 Loginwindow 中触发已设置监听的函数,如 Show、显示窗体,控制类的代码如下所示:

        public class LoginCtrl : Singleton<LoginCtrl>
    {
        public void Enter()
        {
            EventCenter.Broadcast(EGameEvent.eGameEvent_LoginEnter);   
        }

        public void Exit()
        {
           EventCenter.Broadcast(EGameEvent.eGameEvent_LoginExit);
        }

        //登陆
        public void Login(string account, string pass)
        {

        }

        //登陆错误反馈
        public void LoginError(int code)
        {

        }


        //登陆失败
        public void LoginFail()
        {

        }

        //开始游戏
        public void StartGame()
        {
            SceneManager.LoadScene("Play");
            WindowManager.Instance.ChangeScenseToPlay(EScenesType.EST_Login);
            GameStateManager.Instance.ChangeGameStateTo(GameStateType.GST_Play);
        }

   }

我们将其设置成单例模式,在 Enter 函数中进行消息广播,另外在函数 StartGame 中使用了 WindowManager 类接口和 GameStateManager 类接口。这两个类很关键的,将在后面介绍。当然如果只有 Loginwindow 和 LoginCtrl 还是无法执行的,我们还缺少 State 状态类,状态类是负责窗体 UI 之间的切换,每个 UI 窗体对应着自己的状态,为了区分不同的 UI 窗体,利用这些状态枚举表示,代码定义如下:

    public enum GameStateType
{
    GST_Continue,
    GST_Login,
    GST_Role,
    GST_Loading,
    GST_Play,
}

状态类都包括那些方法?通常来说,我们会定义设置某个状态、获取状态、进入状态、停止状态、更新状态这些方法。我们在设计每个模块时,都会先明确这个模块具体要做哪些事情,其实在设计一个类内容时,自己可以多想想,这样类的函数定义自然就有了。另外,提到的这些方法每个 UI 状态都会包含,这样我们将其抽离出来,定义成一个接口模块供程序使用,代码内容如下:

 public interface IGameState
{
    GameStateType GetStateType();
    void SetStateTo(GameStateType gsType);
    void Enter();
    GameStateType Update(float fDeltaTime);
    void FixedUpdate(float fixedDeltaTime);
    void Exit();
}

细心的读者可能发现,一会抽象、一会接口,这里涉及到一个抽象类和接口类的区别,为了帮助读者理解,在此介绍一下:抽象类实现了 oop 中的一个原则,把可变的与不可变的分离,所以 BaseWindow 采用了抽象的定义,UI 通用函数方法在父类中实现出来,而不通用的函数方法只提供接口,再介绍接口类,好的接口类定义应该是具有专一功能性的,而不是多功能的,否则造成接口污染。我们的状态类 IGameState 功能是单一的,所以 IGameState 采用了接口的定义。窗体中的状态类设计继承 IGameState 类,代码如下所示:

class LoginState : IGameState
{
   private  GameStateType _stateTo;
    //构造函数
    public LoginState()
    {
    }
    //获取状态
    public GameStateType GetStateType()
    {
        return GameStateType.GST_Login;
    }
    //设置状态
    public void SetStateTo(GameStateType gs)
    {
        _stateTo = gs;
    }
    //进入状态
    public void Enter()
    {
        SetStateTo(GameStateType.GST_Continue);

        LoginCtrl.Instance.Enter();        
    }
    //停止状态
    public void Exit()
    {
        LoginCtrl.Instance.Exit();
    }

    public void FixedUpdate(float fixedDeltaTime)
    {

    }
    //更新状态
    public GameStateType Update(float fDeltaTime)
    {
        return _stateTo;
    }

}

在这个 UI 窗体状态类中实现了 IGameState 中定义的接口函数,UI 架构设计到这里还是没有完成,因为我们还不知道咋用?在下章中给读者介绍。