浅谈使用NGUI的界面架构:功能介绍及NData
发表于2016-09-10
文/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,可以很快速地搭建一套页面框架。现在我已经把这两个工具专门打成插件包,开发新项目时直接导进去用,非常方便。