写在前面
刚刚做的项目,由于界面管理做的不太好,所以在开发的过程中出现了很多奇怪或难缠的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模式,后来发展了很多版本,它们之间无非就是这三者关系的变化,具体可以看看相关的文章和论文。
MVP
它是从MVC演变而来,其中Presenter处理业务逻辑,Model提供数据和数据的逻辑,View负责显示。
作为一种新模式,和MVC的重大区别就是在MVP中View不直接使用Model,它们之间通过Presenter来进行的,所有交互发生在Presenter内部,Presenter代替了Controller的角色,在处理业务逻辑的基础上还要负责帮View从Model中取数据。而在MVC中,View会直接从Model中读取数据。
MVVM
对MVVM不了解,也没有使用过,看了一些网上的文章,最重要的概念应该就是:数据绑定。把Presenter换成了ViewModel,换汤不换药,最终发生改变就是三者之间的关系和三者所负责的事情。了解更多就去网上搜一搜。
以上对一些模式的简介,总结起来,虽然有这些模式的存在,但需求是万变的,没有哪个模式能适用于一切情况,所以一切都要以实际项目、实际需求为主,吸收那些模式的思想,应用于各个开发场景。一句话就是,怎样让开发简单、代码好看、易于维护就怎么做喽。
界面之间传值问题
不管是使用哪种开发模式。在实际开发中应该都会遇到一个问题,对于界面管理,界面之间的传值是一个重要的问题。
在Android中,两个Activity之间传值使用了一个叫Intent的组件,Activity持有Intent的引用。
在unity开发中,需要注意传值的时机,在界面逻辑脚本中用成员变量保存该值。
界面穿插和界面层级管理
影响渲染顺序的因素:
在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框架部分
整体的类图
我直接在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的生命周期:
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框架,不然对不起自己写了这么久界面。最近终于完成了第一版,里面还存在很多问题,比如多个界面的层次关系怎么管理、有两处代码使用了反射可以想办法改进,当然还有没有考虑到的问题,所以后续还要陆续修改。
写在最后:
花了一周时间整理了这些东西,整理自己的思路,这次一定印象深刻,可能写的不太好,有什么问题请直接指出,一起讨论,不断总结,不断学习,不断提升。