浅谈使用NGUI的界面架构:功能介绍及NData

发表于2016-09-10
评论0 3.8k浏览


  文/kUANG tOBY(匡正)
  版权所有,转载须注明出处以及作者,Email:kuangtoby@163.com
  在我的印象中,Unity一直没有一套成熟的界面体系。现在可供选择的不外乎NGUI和UGUI,之前也用过EZGUI和2D ToolKit,但最后我还是选择了NGUI。
  很多人说NGUI不好用。我的感觉是,一个工具,只要你对它足够熟悉,就一定有一套最适合它的使用方法。当然每个工具都一定有它的无法回避的缺点和硬伤,但大部分人只是使用方式不对罢了,还没到受工具局限性影响的阶段。
  选择NGUI作为主要的界面工具主要是基于以下考虑:
  有较多的文档,新人容易上手
  功能比较全,一个手游需要的界面功能基本都有
  有个MVVM工具NData可以和NGUI配合使用,这样可以极大地提高开发效率,适应需求修改。这个后面会详细介绍。
  在设计界面架构的时候,我主要想实现以下几个目的:
  把游戏的各个界面模块集中管理,统一调度,但又必须把每个模块之间的耦合性降到最低。这样可以不同的人开发不同的模块,互不干扰,做出来的东西运行起来又不会互相冲突。
  把界面逻辑和界面版式尽量分离开来,让美术也可以参与界面的修改(事实证明,这个想法最后救了程序的命)。
  界面模块的开发必须有一套统一的流程,统一的格式,方便不同的人维护。
  在具体设计之前,首先要了解NGUI的局限性。
  NGUI有很多缺点,最受人诟病的就是性能问题和内存消耗问题。这两个问题都和NGUI的底层渲染机制有关。NGUI的渲染基于每一帧的Mesh重建,把一个UIPanel下的多个使用相同图集的UIWidget合并成一个Mesh,以此来减少draw call。NGUI本身对此做了优化,即如果一个UIPanel下的内容没有变化,就使用缓存的Mesh。但界面往往是不停变化的,这样就不可避免的每一帧都要重建Mesh,从而造成CPU的负担和多余的内存消耗。解决的方法就是把要经常变化和移动的界面放在单独的UIPanel下,去移动和变化UIPanel依附的物体。这样虽然会增加draw call,但节省了重建Mesh的性能损耗。
  因为这个问题,我把游戏模块分成一个个不同的页面,每个页面都有一个UIPanel,然后在一个统一的地方调度各个页面,这个统一的地方是一个单例类,叫做MainPageMgr。
  每个页面都有一个生命周期,即
  出现:准备出现->播放出现动画->动画完毕,展示在目标位置
  消失:准备消失->播放消失动画->完全消失
  其中出现动画和消失动画由NGUI自带的UIPlayTween组件控制,直接把TweenPosition等脚本贴在UIPanel所在的物体上,让技术美术去调整。我把这一系列动作的逻辑都放在一个TweenPage类里,只要调一个弹出或消失的方法,就让它自动运行这个流程。
  TweenPage类中有一个Bring(Boolean isBringIn)方法提供给MainPageMgr调用:
  Bring(true)出现
  Bring(false) 消失
  当MainPageMgr调用一个页面TweenPage的Bring方法时,页面就按照它的生命周期开始运动,并触发相应的回调方法。
  TweenPage类中有以下回调接口,供具体的页面实现相关逻辑:
  OnPreBringIn 准备弹出的回调
  OnBringIn 播放完弹出动画的回调
  OnPreBringOut 准备播放消失动画的回调
  OnBringOut 完全消失的回调
  这样,只需要在具体的页面类中重写这几个接口,就可以在这几个时间点做一些事情。例如在OnPreBring接口中实现页面的数据刷新。
  自此,页面TweenPage的框架基本成型,然后是实现MainPageMgr的统一调度。
  我的做法是把每个页面的TweenPage实例都添加到MainPageMgr中,然后在MainPageMgr中为每个TweenPage实例都提供一个弹出方法,如弹出或关闭英雄管理界面BringPageHero(Boolean isBringIn)。这个弹出方法可以根据不同页面的情况设置不同的参数,但都有一个Boolean值表示是让页面出现还是消失,并且都要调用MainPageMgr的BringPage(TweenPage page)方法。
  BringPage(TweenPage page)方法主要实现页面的统一调度管理,例如把一个页面显示到屏幕最前面,挡住其他所有页面。
  在MainPageMgr中实现List pageList,用来保存当前已经打开的页面。我把页面设计成叠加遮挡模式,即一个页面弹出会叠在前一个页面上面并挡住它。pageList中就按顺序保存正处于打开状态的页面TweenPage实例。每次打开一个页面,都会给pageList中的所有TweenPage的UIPanel设置一个新的深度,并把当前已经打开的所有页面GameObject的Z轴往前移,这样可以确保夹在两个UIPanel之间的3D模型(如角色模型)显示正常。最后把要打开的页面加入到pageList中,并给它的UIPanel赋值一个当前最大的深度,使其可以遮挡前面所有的页面。让页面消失就使用相反的操作。这些操作都是用MainPageMgr的方法BringPage(Boolean isBringIn)实现。
  界面的调度逻辑基本就是这样,现在要加一个新的页面只需要新建一个继承TweenPage的类,贴上UIPanel和其他UIPlayTween组件,并在MainPageMgr中添加一个弹出方法即可。
  这里只是提供一个大致的思路,细节就不再深入说明了。
  关于NData
  NData是三年前我无意中发现的一个界面插件,这个插件开启了我做U3D界面的新篇章,刚开始用的那个感觉,就好像写JSP的人突然用上了SSH。虽然这个插件的作者已经停止更新了,但他的MVVM思想非常值得借鉴。
  在刚接触NGUI的时候,我们一般会采用在脚本中获取NGUI 组件的形式给NGUI 组件赋值。有两种选择:一种是在代码中根据路径获取NGUI 组件;另一种是在场景中,直接把组件拖到脚本上。第一种方法的的缺点是需要维护NGUI组件的路径,第二种方法的缺点是替换组件时总是需要重新拖组件。两种方法都比较不方便,这里用第二种来举例。
  打个比方,我们界面右上角要显示玩家拥有的金币总数。于是我们做了个UILabel拖进脚本,在脚本里给它的text赋值显示当前金币数量。


  PagePlayer.cs中:





public UILabel goldLabel;
   public void SetGold( string gold )
   {
       goldLabel.text = gold;
   }
  后来策划需求在左下角和右下角也要显示金币,于是我们又做了两个uilabel放到相应位置,并在脚本里添加变量,把新加的uilabel拖到脚本里。每次金币的值发生变化,就要找到所有的uilabel变量给他们一一赋值。


  PagePlayer.cs中:









public UILabel goldLabel;
    public UILabel goldLabel1;
    public UILabel goldLabel2;
    public void SetGold( string gold )
    {
        goldLabel.text = gold;
        goldLabel1.text = gold;
        goldLabel2.text = gold;
    }
  然而可能以后又会增加其它显示金币的界面,每加一个金币的UILabel,就要去脚本里增加一个UILabel 的变量然后在金币变化的时候给它赋值。虽然麻烦,但也能把功能做出来。我们不能就此满足,偷懒是提高生产力的最大动力。
  现在想要简化这个流程,就要实现以下功能。
  不需要每次添加金币文字的时候都在脚本中新增一个UILabel变量,并把对应的UILabel组件拖进来。
  不需要每次修改金币的时候都要找到所有的金币UILabel变量去修改他们的值。
  初步的解决方案是这样子的:我希望脚本里面有一个值 gold代表的是金币数量。所有的金币UILabel 都跟这个值产生关联。只要修改这个值,所有跟他关联的UILabel 都自动发生变化。另外,在我要添加一个金币UILabel 的时候,我希望它能自动去找页面脚本中的gold变量来发生关联,而不需要我在脚本中改代码。
  具体实现的思路,就是在带有UILabel脚本的物体上加一个脚本,使其与页面脚本种的gold变量发生关联。然后给gold变量加set方法,在这个方法中发一个消息,告知所有和gold有过关联的的UILabel要发生值的改变。这样每次给gold变量赋值的时候,所有与其关联的UILabel就会自动更新显示的内容。
  本着不重复造轮子的原则,在疑似开始造轮子之前一定要Google一下。于是在网上搜出了MVVM模式,NData插件等等。并发现NData不仅可以用于UILabel,还可以用于各种NGUI组件,并有很好的绑定层级管理。
  NData就是基于MVVM模式,其中用户自定义继承EZData.Context的类,就相当于是自定义ViewModel层的内容。
  剩下的问题就是,怎样用这个工具来管理页面。
  根据上文,我把界面分成很多个TweenPage,然后在单例MainPageMgr中统一管理。对于数据,我希望把每个页面的数据也独立出来,即每个页面有一个对应的继承EZData.Context的类,这个页面相关的数据都放在这个类中,然后再由MainPageMgr来统一管理。
  例如有个游戏页面PageInGame,用来显示游戏中获得的金币,钻石和星星。现在新建一个PageInGameContext继承EZData.Context。现在PageInGame页面就有一个model层PageInGame类和一个ViewModel层PageInGameContext类。View层自然就是PageInGame物体下面的NGUI组件了。这样就形成了MVVM模式。













































































using UnityEngine;
using System.Collections;
 
public class PageInGameContext : EZData.Context
{
    #region Property Gold
 
    private readonly EZData.Property<int> _privateGoldProperty = new EZData.Property<int> ();
 
    public EZData.Property<int> GoldProperty { get { return _privateGoldProperty; } }
 
    public int Gold {
        get    { return GoldProperty.GetValue (); }
        set    { GoldProperty.SetValue (value); }
    }
 
    #endregion
 
 
    #region Property Diamond
 
    private readonly EZData.Property<int> _privateDiamondProperty = new EZData.Property<int> ();
 
    public EZData.Property<int> DiamondProperty { get { return _privateDiamondProperty; } }
 
    public int Diamond {
        get    { return DiamondProperty.GetValue (); }
        set    { DiamondProperty.SetValue (value); }
    }
 
    #endregion
 
    #region Property Star
 
    private readonly EZData.Property<int> _privateStarProperty = new EZData.Property<int> ();
 
    public EZData.Property<int> StarProperty { get { return _privateStarProperty; } }
 
    public int Star {
        get    { return StarProperty.GetValue (); }
        set    { StarProperty.SetValue (value); }
    }
 
    #endregion
 
}
 
 
public class PageInGame : TweenPage {
 
    public PageInGameContext Context;
 
   
 
    protected override void Awake ()
    {
        base.Awake ();
 
        MainPageMgr.instance.Context.pageInGame = this;
        Context = MainPageMgr.instance.Context.pageInGameCtx;
 
    }
 
 
 
    protected override void OnPreBringIn ()
    {
        base.OnPreBringIn ();
        
 
    }
 
    protected override void OnPreBringOut ()
    {
        base.OnPreBringOut ();
        
    }
}int>int>int>int>int>int>int>int>int>
  在MainPageMgr中有一个MainView Context是用来管理所有页面的Context(下文中继承EZData.Context的类,都统称为Context。):























public class MainPageMgr : PageMgrSingleton
{
 
    public NguiRootContext View;
    //这个代表页面模型
    public MainView Context;
 
 
 
    void Awake()
    {
       
        Context = new MainView();
        SetContext();
 
 
    }
 
    public void SetContext()
    {
        View.SetContext(Context);
    }
}
  MainView.cs
  这样,所有的页面都可以通过MainPageMgr.instance.Context来获取所有页面的Context,如pageXXXContext,也可以获得所有页面的逻辑脚本,如pagXXX。
  在场景里,只需要把页面放在MainPageMgr的下级,然后再通过Master path来绑定到MainPageMgr中的Context就可以了。



  在开发中,可能出现不同的页面共用相同的数据,这种情况就可以直接把两个页面的Master Path绑定到同一个Context上,这样开发起来会方便很多。
  总结:
  引入NData这个插件,主要是为了减少一些对NGUI组件的操作(如获取组件和赋值等),把所有的工作都简化为改变Context中的值,来动态改变NGUI组件的显示。把各个页面的Context都统一管理,是为了更方便地获取数据,但原则上不应该在A页面的model层中去修改B页面Context中的数据,因为这样容易造成混乱。
  使用NData加NGUI,可以很快速地搭建一套页面框架。现在我已经把这两个工具专门打成插件包,开发新项目时直接导进去用,非常方便。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引