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

写在前面

刚刚做的项目,由于界面管理做的不太好,所以在开发的过程中出现了很多奇怪或难缠的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框架,不然对不起自己写了这么久界面。最近终于完成了第一版,里面还存在很多问题,比如多个界面的层次关系怎么管理、有两处代码使用了反射可以想办法改进,当然还有没有考虑到的问题,所以后续还要陆续修改。

写在最后:

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容

  • 原文: iOS应用架构谈 view层的组织和调用方案 iOS应用架构谈 开篇 iOS应用架构谈 网络层设计方案 i...
    难却却阅读 1,252评论 0 7
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,413评论 25 707
  • 前言 看了下上篇博客的发表时间到这篇博客,竟然过了11个月,罪过,罪过。这一年时间也是够折腾的,年初离职跳槽到鹅厂...
    西木柚子阅读 21,205评论 12 185
  • 踏入社会以后,C小姐开始习惯别人把她当做大人来看。没人会因为她初来乍到就迁就她,她学着为自己说的每句话、做的每件事...
    栗子的月亮船阅读 243评论 0 0
  • 最近总想着怎么能跳出温水一样的生活,毕竟我不是一只青蛙。最近开始跳出生活审视自己,还是学不会少说一些心里的话,所以...
    TTTTERRY阅读 131评论 0 0