基于NGUI的unity界面管理的讨论

作者: 小飞不会飞_ | 来源:发表于2016-09-18 10:35 被阅读382次

    写在前面

    刚刚做的项目,由于界面管理做的不太好,所以在开发的过程中出现了很多奇怪或难缠的bug,搞得我们几个写UI逻辑的越写越觉得没意思,想方设法的到处打补丁,后来也就是在这样的情况下,一直在总结开发中关于界面上遇到的坑,写了一年多的UI逻辑,针对那些由于界面架构上导致的问题,自己琢磨了一个简易的UI框架,只是简单的跑了一下没什么问题。

    好了正式开始吧。

    关于界面的问题(我开发时遇到的)

    这个可是太多,总结了几个重要的点
    1、界面的管理
    2、界面生命周期
    3、界面的显示和隐藏
    4、界面逻辑的管理
    5、逻辑代码和view分离
    6、界面之间传值问题
    7、界面穿插和界面层级管理
    8、引用关系
    9、脚本该不该挂在gameobject上

    那么下面我就围绕以上几点写了。

    界面管理

    界面的资源全部都是打在AssetBundle中,然后通过底层函数把prefab load起来,给它挂上一个脚本,这个脚本就包含着该界面的逻辑,有一个WindowManager来管理这些window,每个window之间有父子引用关系,在WindowManager中还维护了一个栈来管理,每次界面打开或关闭都与该界面的父或子有关系。

    例如,当打开一个新界面时,会把父界面的gameobject传进去,把界面显示出来,把父界面隐藏,关闭界面的时候,把当前界面隐藏,父界面显示,这样会出现一个问题,当两个界面同时在最上面时,当它们无论关闭时都会下面的界面显示出来,有时候就会出现穿插。

    正常情况下,在同一时刻应该只允许一个界面是可操作的。

    又是维持父子关系,一方面又用栈来保存,这样让我真的不知道应该怎么获取父界面,因为有可能在界面中父的引用不是栈里面的“父”。

    界面生命周期

    界面的生命周期可说是个比较重要的问题,提醒一下!!!
    千万不要把两个不同的生命周期顺序写在一起,如果真要写一起,请一定一定注意它们之间的顺序。

    自己的界面生命周期函数的调用时机一定要很清楚。

    说说我们项目,挂在界面上的那个脚本里面就存在两套生命周期函数,一个是Mono的那一套,另一个是底层框架维护的一套。这东西当开始的时候没什么问题,越往后写越改就发现很多时候的bug,都是由于生命周期顺序造成的。例如:NGUI里面很多东西都是在Start做的,所以只要用NGUI,所有设置界面显示都最好是在Start之后去调用,不然可能会出现ScrollView的Item错位的情况。

    我们界面几个状态,可见、可操作、不可见。转圈的进度条也被用界面来管理了,所以当时每次转圈完了之后,就会调用一次“可操作”的周期函数,有时候遇到断线重连,就会不停的转圈,当然也会不停的调用函数。

    界面的显示和隐藏

    有很多种方法
    1、gameObject.SetActive(true or false)
    2、把界面移到UI摄像机外面
    3、改变界面的Layer到UI相机不照的层
    4、设置为透明
    5、用不透明的背景遮挡
    6、每个界面都放在不同位置上,这样移动UI相机到相应界面也实现显示隐藏了。
    7、也可采用多相机的方式

    其中1、4两种方法对于NGUI并不好,因为那样操作会导致panel的所有“顶点重建”,重新生成drawcall。这也是NGUI消耗性能的地方,过段时间我会整理一下对NGUI的分析。
    其中5,要看具体需求(自己脑补)
    其中2、3、6、7都是可取的,但具体细节还得认真考虑,我用了改变Layer的方式。

    界面逻辑的管理

    我们直接在上挂了一个脚本,刚开始做unity的时候,把界面的逻辑全部写在这个脚本里面。一般简单界面还好,但遇到复杂界面就完蛋了,有时候这一个脚本就得上千行,可读写性很差,过一段时间修改原代码很费劲,而且很多逻辑状态放在一起非常容易出现bug,有一段时间bug特别多。

    遇到了一个状态非常多的界面,脚本里面放了很多状态变量,有些变量是互斥的,有些可以共存的,然后就这样没有规划的写了,结果这个界面很乱,都不敢做太大改动,出了bug改好了又引发其他的bug。所以后来就用有限状态机来管理这些,把每个状态和状态对应的逻辑拆分,这样每个脚本行数变少了,逻辑得到很大的改善,后来改bug都不费脑子了,呵呵。(后来在知乎上看到一个人说用行为树。。。后面再尝试吧)

    逻辑代码和view分离

    为什么?
    1、当业务代码越复杂时,修改代码就成了费脑的事情。
    2、当时间越来越久,理解代码就非常困难。
    3、同一个逻辑不能复用,在很多地方复制粘贴,如果出现错误就会修改很多地方。
    4、测试变得非常麻烦,没都要整体测一次才能确保一切完好。

    怎么做?
    使用MVC或MVP等架构模式,使代码达到低耦合、高复用、易测试、好维护、易扩展。

    记得刚刚学习网站开发的时候,MVC是首先接触到的设计思想,应该滚瓜烂熟的东西。有一段时间我研究了一下MVC,发现和之前的认识不一样,比如View需要观察Model,MVC实际是UI框架的一种模式,可并不是整个系统。下面就看看那些模式:

    MVC
    是一种使用Model View Controller设计创建web应用程序的程序。它强制性的使应用程序的输入、处理和输出分开。使用MVC应用程序被分成三个核心部件:模型、视图、控制器。它们各自处理自己的任务。最典型的MVC就是jsp + servlet + javabean的模式。

    Model - 表示应用的程序的核心,提供数据和数据相关的逻辑,通知View数据变化
    View - 显示数据,观察Model变化,可以从Model取得数据进行显示
    Controller - 处理输入,调用model处理业务逻辑,逻辑处理完之后,修改Model,并选择View显示结果
    注意:这里所说的是经典MVC模式,后来发展了很多版本,它们之间无非就是这三者关系的变化,具体可以看看相关的文章和论文。

    clipboard.png

    MVP
    它是从MVC演变而来,其中Presenter处理业务逻辑,Model提供数据和数据的逻辑,View负责显示。
    作为一种新模式,和MVC的重大区别就是在MVP中View不直接使用Model,它们之间通过Presenter来进行的,所有交互发生在Presenter内部,Presenter代替了Controller的角色,在处理业务逻辑的基础上还要负责帮View从Model中取数据。而在MVC中,View会直接从Model中读取数据。

    clipboard.png

    MVVM
    对MVVM不了解,也没有使用过,看了一些网上的文章,最重要的概念应该就是:数据绑定。把Presenter换成了ViewModel,换汤不换药,最终发生改变就是三者之间的关系和三者所负责的事情。了解更多就去网上搜一搜。

    以上对一些模式的简介,总结起来,虽然有这些模式的存在,但需求是万变的,没有哪个模式能适用于一切情况,所以一切都要以实际项目、实际需求为主,吸收那些模式的思想,应用于各个开发场景。一句话就是,怎样让开发简单、代码好看、易于维护就怎么做喽。

    界面之间传值问题

    不管是使用哪种开发模式。在实际开发中应该都会遇到一个问题,对于界面管理,界面之间的传值是一个重要的问题。
    在Android中,两个Activity之间传值使用了一个叫Intent的组件,Activity持有Intent的引用。
    在unity开发中,需要注意传值的时机,在界面逻辑脚本中用成员变量保存该值。

    界面穿插和界面层级管理

    影响渲染顺序的因素:

    clipboard.png

    在NGUI中,panel之间的层级,weight之间的层级都是用depth属性控制的。虽然有以上几个方面都可以控制渲染顺序,但还是建议使用depth吧,毕竟这是NGUI提供的最正规的方式。

    注意,panel和weight的depth是不交叉的,先是panel和panel深度排序,然后再是同一个panel下的weight进行深度排序。而且即使panel在hierarchy视图中有层次关系,也不会影响depth的排序。

    当然关于层级关系还有一个重要的方面:3D模型和粒子特效的裁剪问题,有些游戏有这样的需求,比如在界面上显示一个英雄的模型,有些界面需要在模型上面,有些则在模型下面。我现在的做法是用多个相机,一个界面对应一个相机,模型相机也是分开,利用相机的depth达到效果。

    引用关系

    取决于具体开发的框架了,建议使用MVC或MVP,各个层次的引用关系就是这些模式所描述的,能使代码结构清晰,减少bug的出现,利于后期维护。

    脚本该不该挂在gameobject上

    关于这个问题就看项目的框架了,有些框架是把界面的脚本直接挂在gameObject上,有些则是通过脚本内持有gameObject引用关联的。

    经过上面的讨论,已经把遇到过关于界面比较重要一些地方了解了,然后自己写了一个简单的UI框架。

    在Unity开发中,客户端UI框架的脚本有两种方式:

    1、如果每个界面都有单独处理业务逻辑的脚本挂在自己身上,这种是通过Unity自身来驱动界面,把两个生命周期放在一个脚本中。

    首先需要知道,写逻辑的脚本不能静态绑定的,因为网络游戏都需要资源热更新,所以我们要把几乎所有的美术资源打成AssetBundle的形式(这是Unity美术资源的一种存在形式),unity中资源结构的组织及管理通过.meta文件完成的,unity会为工程中每个文件和文件夹创建一个.meta文件,里面记录着一个GUID,每个电脑生成的GUID不一样,而且资源只要变化了就会重新生成GUID,在开发时要不停往这些脚本中写代码,脚本变化对应的GUID也会变化,这会导致已经打好的AssetBundle里通过记录的GUID找不到挂的脚本,也就是脚本丢失。

    那么逻辑脚本也就只能动态的挂上去了:
    TestScript test = gameObject.AddComponent<TestScript>();
    test.SetParams(param); //传值
    test.Init(); //初始化
    这段代码是很多时候是这样的,但需要注意,此时的TestScript只执行了Awake,还没有执行Start就调用了初始化,如果界面是NGUI的,那么NGUI很多初始化工作都在Start中完成,也就是说UI本身都还没有初始化完成,就开始执行显示逻辑了,这是不对的。所以Init里面不能写让UI显示数据的代码,只能写在TestScript 的Start中,这样才能保证所有UI控件已经初始化完成了。

    2、如果整个框架是有某个脚本来驱动的,也就是界面的逻辑不直接挂在gameObject上的,而是通过代码中存在的引用关联的,这样脚本中没有mono相关的生命周期,只有自己底层维护的周期了,所有脚本都完全自己把控。但还是得注意,自己的周期也一定要合理,NGUI中一定要保证UI全部初始化完成了才能执行显示逻辑。

    UI框架部分

    整体的类图

    clipboard.png

    我直接在gameObject上挂脚本,但是挂的一个通用的脚本:Window,这个类继承自MonoBehaviour,用来驱动我的逻辑。

    Window.cs

    using System;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class Window : MonoBehaviour
    {
        private IPresenter _presenter = null;
        private bool _isStart = false;
    
        void Start()
        {
            _isStart = true;
            gameObject.layer = UnityLayer.ShowUILayer;
            _presenter.OnStart();
            this.Show();
        }
    
        void OnDestroy()
        {
            _presenter.OnDestroy();
        }
    
        public void AddPresenter(IPresenter presenter)
        {
            this._presenter = presenter;
        }
    
        public void Show()
        {
            if (_isStart)
            {
                _presenter.OnEnter();
            }
        }
    
        public void Hide()
        {
            _presenter.OnLeave();
        }
    
        public void OnStop()
        {
            _presenter.OnStop();
        }
    
        //重用界面时调用
    
        public void ReStart(IIntent intent)
        {
            _presenter.SetIntent(intent);
            _presenter.OnStart();
            this.Show();
        }
    }
    

    IPresenter是定义的处理界面逻辑的接口

    public interface IPresenter
    {
        void OnStart();
        void OnEnter();
        void OnLeave();
        void OnStop();
        void OnDestroy();
    
        void BindView(GameObject go); //这就是绑定gameObject到逻辑
        void SetIntent(IIntent intent); //传递界面参数
    }
    

    IView是定义的界面接口

    public interface IView
    {
        void Init(GameObject view); //在Presenter中会把传递的界面gameObject绑定到View上,Presenter持有View的引用,而不直接持有gameObject
    }
    

    IIntent是参数传递的接口

    public interface IIntent { }
    

    结构可以理解为一个界面对一个IPresenter,对应一个IView。IPresenter中负责业务逻辑、设置界面,IView中负责写界面设置函数和事件监听,这样把UI和逻辑分开了。

    接着看看实现IPresenter的一个基础类:Presenter<T>,它接受一个泛型,用来把IView和它联系起来,并实现了一些函数。

    using System;
    using System.Collections.Generic;
    using UnityEngine;
    
    public abstract class Presenter<T> : IPresenter where T : IView
    {
        protected FSM _fsm = null;
        protected IIntent _intent = null;
        protected T _view = default(T);
    
        public void SetIntent(IIntent intent)
        {
            this._intent = intent;
        }
    
        //每次压栈都会调用
        public abstract void OnEnter();
        //{
        //    //_view.Show();
        //}
    
        //每次退栈都会调用
        public abstract void OnLeave();
        //{
        //    //_view.Hide();
        //}
    
        //在mono start和时调用
        public virtual void OnStart() { }
    
        public virtual void OnStop() { }
    
        public virtual void OnDestroy() { }
    
        public void BindView(GameObject view)
        {
            _view = Activator.CreateInstance<T>();
            _view.Init(view);
        }
    }
    

    当然IView也有基本实现:View

    public abstract class View : IView
    {
        protected GameObject _view = null;
    
        public virtual void Init(GameObject view)
        {
            this._view = view;
        }
    
        public void Show()
        {
            _view.layer = UnityLayer.ShowUILayer;
        }
    
        public void Hide()
        {
            _view.layer = UnityLayer.HideUILayer;
        }
    }
    

    其中UnityLayer是定义的通过UnityEditor创建的Layer,之前也说过,我是通过改变layer来显示和隐藏界面的。

    public class UnityLayer
    {
        public const int HideUILayer = 8;
        public const int ShowUILayer = 5;
    }
    

    还有一个类负责管理界面:WindowManager,它维护了一个栈的结构(虽然我是用List装的),每次打开界面的时候 - 进栈,每次关闭界面的时候 - 出栈。

    界面IPresenter的生命周期:

    clipboard.png

    WindowManager 对外提供两个函数,一个打开一个关闭,并且还对无用的界面做了缓存,限制cache容器的大小,并用一个定时器定期去检查cache,超过限制就把前面的释放掉,满足先进先出的规则。

    public class WindowManager
    {
        private List<Window> win = new List<Window>();
        private List<Window> cache = new List<Window>();
    
        private static WindowManager ins = null;
    
        private WindowManager()
        {
            //运行检查缓存的定时器
        }
    
        public static WindowManager GetInstance()
        {
            if (ins == null)
            {
                ins = new WindowManager();
            }
    
            return ins;
        }
    
        public void OpenWin(string name, IIntent intent)
        {
            List<Window>.Enumerator etor = cache.GetEnumerator();
            Window old = null;
            while (etor.MoveNext())
            {
                if (etor.Current.gameObject.name.Equals(name))
                {
                    old = etor.Current;
                }
            }
    
            if (old != null)
            {
                cache.Remove(old);
                win.Add(old);
                //手动调用,表示重用
                old.ReStart(intent);
            }
            else
            {
                //为了简单,所以这里就直接使用Resources加载了
                UnityEngine.Object obj = Resources.Load(name);
                GameObject go = GameObject.Instantiate(obj) as GameObject;
    
                //通过配置,关联界面和Presenter
                Type type = PresenterCfg.pconfig[name];
                IPresenter p = Activator.CreateInstance(type) as IPresenter;
    
                Window w = go.AddComponent<Window>();
                w.AddPresenter(p);
    
                if (win.Count > 0)
                {
                    win[win.Count - 1].Hide();
                }
                win.Add(w);
    
                p.SetIntent(intent);
                p.BindView(go);
            }
        }
    
        public void CloseWin(GameObject go)
        {
            int i = 0;
            for (i = 0; i < win.Count; ++i)
            {
                if (win[i].gameObject == go)
                {
                    //把当前最上面的窗口hide
                    win[win.Count - 1].Hide();
                    break;
                }
            }
    
            //没有找到相应的窗口
            if (i >= win.Count)
            {
                return;
            }
    
            for (int j = win.Count - 1; j >= i; --j)
            {
                win[j].OnStop();
                //缓存界面
                cache.Add(win[j]);
            }
    
            //弹出栈之后,需要销毁资源
            win.RemoveRange(i, win.Count);
    
            if (win.Count > 0)
            {
                win[win.Count - 1].Show();
            }
        }
    
        //检查并清理缓存
        private void _Examine()
        {
            if(cache.Count > 0)
            {
                //先进先出
                Window w = cache[0];
                cache.Remove(w);
    
                //释放资源
            }
        }
    }
    

    至此,一个简单的界面框架就完成了,那么在开发的时候只需要写一个Presenter和一个View:

    public class ViewPresenter : Presenter<MainView>
    {
        public override void OnStart()
        {
            //listen click
            Debug.Log("view presenter start");
    
            //get model data
            //set view data
    
        }
    
        public override void OnEnter()
        {
            Debug.Log("view presenter enter");
    
            _view.Show();
    
            //set attr
            //set icon
            //set name
            //set level
            //set quality
            //set ...
        }
    
        public override void OnLeave()
        {
            Debug.Log("view presenter leave");
            _view.Hide();
        }
    
        public override void OnStop()
        {
            Debug.Log("view presenter stop");
            //unlisten
        }
    
        public override void OnDestroy() { Debug.Log("view presenter destroy"); }
    
        //some
    }
    
    public class MainView : View
    {
        //private event EventHandler Clicked;
    
        public override void Init(GameObject view)
        {
            base.Init(view);
    
            //UISprite sp = _view.transform.Find("").GetComponent<UISprite>();
            //UILabel label = _view.transform.Find("").GetComponent<UILabel>();
            //Transform test = _view.transform.Find("");
        }
    }
    

    总结:

    在做项目的时候就一直琢磨,要自己写一个UI框架,不然对不起自己写了这么久界面。最近终于完成了第一版,里面还存在很多问题,比如多个界面的层次关系怎么管理、有两处代码使用了反射可以想办法改进,当然还有没有考虑到的问题,所以后续还要陆续修改。

    写在最后:

    花了一周时间整理了这些东西,整理自己的思路,这次一定印象深刻,可能写的不太好,有什么问题请直接指出,一起讨论,不断总结,不断学习,不断提升。

    相关文章

      网友评论

      • 水瓶座阿斌:感谢分享
        小飞不会飞_:@我爱小黄瓜 之前研究了很久puremvc,不过后来没用,现在又忘了:sweat:
        我爱小黄瓜:现在是用puremvc来做这个,也算一种解决方法把。
        小飞不会飞_:@水瓶座阿斌 😄谢谢捧场
      • FengxinLi:请问楼主你这个看到像java语言?
        小飞不会飞_:@Fengxinliju 这是C#啊,你再仔细看看,如果你是感觉没有用一些C#独有的高级语法,好吧, 以前做过很长时间java,目前还习惯java的风格 :disappointed_relieved:

      本文标题:基于NGUI的unity界面管理的讨论

      本文链接:https://www.haomeiwen.com/subject/spgwettx.html