寫在前面
剛剛做的項目,由于界面管理做的不太好,所以在開發(fā)的過程中出現(xiàn)了很多奇怪或難纏的bug,搞得我們幾個寫UI邏輯的越寫越覺得沒意思,想方設(shè)法的到處打補丁,后來也就是在這樣的情況下,一直在總結(jié)開發(fā)中關(guān)于界面上遇到的坑,寫了一年多的UI邏輯,針對那些由于界面架構(gòu)上導(dǎo)致的問題,自己琢磨了一個簡易的UI框架,只是簡單的跑了一下沒什么問題。
好了正式開始吧。
關(guān)于界面的問題(我開發(fā)時遇到的)
這個可是太多,總結(jié)了幾個重要的點
1、界面的管理
2、界面生命周期
3、界面的顯示和隱藏
4、界面邏輯的管理
5、邏輯代碼和view分離
6、界面之間傳值問題
7、界面穿插和界面層級管理
8、引用關(guān)系
9、腳本該不該掛在gameobject上
那么下面我就圍繞以上幾點寫了。
界面管理
界面的資源全部都是打在AssetBundle中,然后通過底層函數(shù)把prefab load起來,給它掛上一個腳本,這個腳本就包含著該界面的邏輯,有一個WindowManager來管理這些window,每個window之間有父子引用關(guān)系,在WindowManager中還維護了一個棧來管理,每次界面打開或關(guān)閉都與該界面的父或子有關(guān)系。
例如,當(dāng)打開一個新界面時,會把父界面的gameobject傳進去,把界面顯示出來,把父界面隱藏,關(guān)閉界面的時候,把當(dāng)前界面隱藏,父界面顯示,這樣會出現(xiàn)一個問題,當(dāng)兩個界面同時在最上面時,當(dāng)它們無論關(guān)閉時都會下面的界面顯示出來,有時候就會出現(xiàn)穿插。
正常情況下,在同一時刻應(yīng)該只允許一個界面是可操作的。
又是維持父子關(guān)系,一方面又用棧來保存,這樣讓我真的不知道應(yīng)該怎么獲取父界面,因為有可能在界面中父的引用不是棧里面的“父”。
界面生命周期
界面的生命周期可說是個比較重要的問題,提醒一下?。?!
千萬不要把兩個不同的生命周期順序?qū)懺谝黄?,如果真要寫一起,請一定一定注意它們之間的順序。
自己的界面生命周期函數(shù)的調(diào)用時機一定要很清楚。
說說我們項目,掛在界面上的那個腳本里面就存在兩套生命周期函數(shù),一個是Mono的那一套,另一個是底層框架維護的一套。這東西當(dāng)開始的時候沒什么問題,越往后寫越改就發(fā)現(xiàn)很多時候的bug,都是由于生命周期順序造成的。例如:NGUI里面很多東西都是在Start做的,所以只要用NGUI,所有設(shè)置界面顯示都最好是在Start之后去調(diào)用,不然可能會出現(xiàn)ScrollView的Item錯位的情況。
我們界面幾個狀態(tài),可見、可操作、不可見。轉(zhuǎn)圈的進度條也被用界面來管理了,所以當(dāng)時每次轉(zhuǎn)圈完了之后,就會調(diào)用一次“可操作”的周期函數(shù),有時候遇到斷線重連,就會不停的轉(zhuǎn)圈,當(dāng)然也會不停的調(diào)用函數(shù)。
界面的顯示和隱藏
有很多種方法
1、gameObject.SetActive(true or false)
2、把界面移到UI攝像機外面
3、改變界面的Layer到UI相機不照的層
4、設(shè)置為透明
5、用不透明的背景遮擋
6、每個界面都放在不同位置上,這樣移動UI相機到相應(yīng)界面也實現(xiàn)顯示隱藏了。
7、也可采用多相機的方式
其中1、4兩種方法對于NGUI并不好,因為那樣操作會導(dǎo)致panel的所有“頂點重建”,重新生成drawcall。這也是NGUI消耗性能的地方,過段時間我會整理一下對NGUI的分析。
其中5,要看具體需求(自己腦補)
其中2、3、6、7都是可取的,但具體細(xì)節(jié)還得認(rèn)真考慮,我用了改變Layer的方式。
界面邏輯的管理
我們直接在上掛了一個腳本,剛開始做unity的時候,把界面的邏輯全部寫在這個腳本里面。一般簡單界面還好,但遇到復(fù)雜界面就完蛋了,有時候這一個腳本就得上千行,可讀寫性很差,過一段時間修改原代碼很費勁,而且很多邏輯狀態(tài)放在一起非常容易出現(xiàn)bug,有一段時間bug特別多。
遇到了一個狀態(tài)非常多的界面,腳本里面放了很多狀態(tài)變量,有些變量是互斥的,有些可以共存的,然后就這樣沒有規(guī)劃的寫了,結(jié)果這個界面很亂,都不敢做太大改動,出了bug改好了又引發(fā)其他的bug。所以后來就用有限狀態(tài)機來管理這些,把每個狀態(tài)和狀態(tài)對應(yīng)的邏輯拆分,這樣每個腳本行數(shù)變少了,邏輯得到很大的改善,后來改bug都不費腦子了,呵呵。(后來在知乎上看到一個人說用行為樹。。。后面再嘗試吧)
邏輯代碼和view分離
為什么?
1、當(dāng)業(yè)務(wù)代碼越復(fù)雜時,修改代碼就成了費腦的事情。
2、當(dāng)時間越來越久,理解代碼就非常困難。
3、同一個邏輯不能復(fù)用,在很多地方復(fù)制粘貼,如果出現(xiàn)錯誤就會修改很多地方。
4、測試變得非常麻煩,沒都要整體測一次才能確保一切完好。
怎么做?
使用MVC或MVP等架構(gòu)模式,使代碼達(dá)到低耦合、高復(fù)用、易測試、好維護、易擴展。
記得剛剛學(xué)習(xí)網(wǎng)站開發(fā)的時候,MVC是首先接觸到的設(shè)計思想,應(yīng)該滾瓜爛熟的東西。有一段時間我研究了一下MVC,發(fā)現(xiàn)和之前的認(rèn)識不一樣,比如View需要觀察Model,MVC實際是UI框架的一種模式,可并不是整個系統(tǒng)。下面就看看那些模式:
MVC
是一種使用Model View Controller設(shè)計創(chuàng)建web應(yīng)用程序的程序。它強制性的使應(yīng)用程序的輸入、處理和輸出分開。使用MVC應(yīng)用程序被分成三個核心部件:模型、視圖、控制器。它們各自處理自己的任務(wù)。最典型的MVC就是jsp + servlet + javabean的模式。
Model - 表示應(yīng)用的程序的核心,提供數(shù)據(jù)和數(shù)據(jù)相關(guān)的邏輯,通知View數(shù)據(jù)變化
View - 顯示數(shù)據(jù),觀察Model變化,可以從Model取得數(shù)據(jù)進行顯示
Controller - 處理輸入,調(diào)用model處理業(yè)務(wù)邏輯,邏輯處理完之后,修改Model,并選擇View顯示結(jié)果
注意:這里所說的是經(jīng)典MVC模式,后來發(fā)展了很多版本,它們之間無非就是這三者關(guān)系的變化,具體可以看看相關(guān)的文章和論文。

MVP
它是從MVC演變而來,其中Presenter處理業(yè)務(wù)邏輯,Model提供數(shù)據(jù)和數(shù)據(jù)的邏輯,View負(fù)責(zé)顯示。
作為一種新模式,和MVC的重大區(qū)別就是在MVP中View不直接使用Model,它們之間通過Presenter來進行的,所有交互發(fā)生在Presenter內(nèi)部,Presenter代替了Controller的角色,在處理業(yè)務(wù)邏輯的基礎(chǔ)上還要負(fù)責(zé)幫View從Model中取數(shù)據(jù)。而在MVC中,View會直接從Model中讀取數(shù)據(jù)。

MVVM
對MVVM不了解,也沒有使用過,看了一些網(wǎng)上的文章,最重要的概念應(yīng)該就是:數(shù)據(jù)綁定。把Presenter換成了ViewModel,換湯不換藥,最終發(fā)生改變就是三者之間的關(guān)系和三者所負(fù)責(zé)的事情。了解更多就去網(wǎng)上搜一搜。
以上對一些模式的簡介,總結(jié)起來,雖然有這些模式的存在,但需求是萬變的,沒有哪個模式能適用于一切情況,所以一切都要以實際項目、實際需求為主,吸收那些模式的思想,應(yīng)用于各個開發(fā)場景。一句話就是,怎樣讓開發(fā)簡單、代碼好看、易于維護就怎么做嘍。
界面之間傳值問題
不管是使用哪種開發(fā)模式。在實際開發(fā)中應(yīng)該都會遇到一個問題,對于界面管理,界面之間的傳值是一個重要的問題。
在Android中,兩個Activity之間傳值使用了一個叫Intent的組件,Activity持有Intent的引用。
在unity開發(fā)中,需要注意傳值的時機,在界面邏輯腳本中用成員變量保存該值。
界面穿插和界面層級管理
影響渲染順序的因素:

在NGUI中,panel之間的層級,weight之間的層級都是用depth屬性控制的。雖然有以上幾個方面都可以控制渲染順序,但還是建議使用depth吧,畢竟這是NGUI提供的最正規(guī)的方式。
注意,panel和weight的depth是不交叉的,先是panel和panel深度排序,然后再是同一個panel下的weight進行深度排序。而且即使panel在hierarchy視圖中有層次關(guān)系,也不會影響depth的排序。
當(dāng)然關(guān)于層級關(guān)系還有一個重要的方面:3D模型和粒子特效的裁剪問題,有些游戲有這樣的需求,比如在界面上顯示一個英雄的模型,有些界面需要在模型上面,有些則在模型下面。我現(xiàn)在的做法是用多個相機,一個界面對應(yīng)一個相機,模型相機也是分開,利用相機的depth達(dá)到效果。
引用關(guān)系
取決于具體開發(fā)的框架了,建議使用MVC或MVP,各個層次的引用關(guān)系就是這些模式所描述的,能使代碼結(jié)構(gòu)清晰,減少bug的出現(xiàn),利于后期維護。
腳本該不該掛在gameobject上
關(guān)于這個問題就看項目的框架了,有些框架是把界面的腳本直接掛在gameObject上,有些則是通過腳本內(nèi)持有g(shù)ameObject引用關(guān)聯(lián)的。
經(jīng)過上面的討論,已經(jīng)把遇到過關(guān)于界面比較重要一些地方了解了,然后自己寫了一個簡單的UI框架。
在Unity開發(fā)中,客戶端UI框架的腳本有兩種方式:
1、如果每個界面都有單獨處理業(yè)務(wù)邏輯的腳本掛在自己身上,這種是通過Unity自身來驅(qū)動界面,把兩個生命周期放在一個腳本中。
首先需要知道,寫邏輯的腳本不能靜態(tài)綁定的,因為網(wǎng)絡(luò)游戲都需要資源熱更新,所以我們要把幾乎所有的美術(shù)資源打成AssetBundle的形式(這是Unity美術(shù)資源的一種存在形式),unity中資源結(jié)構(gòu)的組織及管理通過.meta文件完成的,unity會為工程中每個文件和文件夾創(chuàng)建一個.meta文件,里面記錄著一個GUID,每個電腦生成的GUID不一樣,而且資源只要變化了就會重新生成GUID,在開發(fā)時要不停往這些腳本中寫代碼,腳本變化對應(yīng)的GUID也會變化,這會導(dǎo)致已經(jīng)打好的AssetBundle里通過記錄的GUID找不到掛的腳本,也就是腳本丟失。
那么邏輯腳本也就只能動態(tài)的掛上去了:
TestScript test = gameObject.AddComponent<TestScript>();
test.SetParams(param); //傳值
test.Init(); //初始化
這段代碼是很多時候是這樣的,但需要注意,此時的TestScript只執(zhí)行了Awake,還沒有執(zhí)行Start就調(diào)用了初始化,如果界面是NGUI的,那么NGUI很多初始化工作都在Start中完成,也就是說UI本身都還沒有初始化完成,就開始執(zhí)行顯示邏輯了,這是不對的。所以Init里面不能寫讓UI顯示數(shù)據(jù)的代碼,只能寫在TestScript 的Start中,這樣才能保證所有UI控件已經(jīng)初始化完成了。
2、如果整個框架是有某個腳本來驅(qū)動的,也就是界面的邏輯不直接掛在gameObject上的,而是通過代碼中存在的引用關(guān)聯(lián)的,這樣腳本中沒有mono相關(guān)的生命周期,只有自己底層維護的周期了,所有腳本都完全自己把控。但還是得注意,自己的周期也一定要合理,NGUI中一定要保證UI全部初始化完成了才能執(zhí)行顯示邏輯。
UI框架部分
整體的類圖

我直接在gameObject上掛腳本,但是掛的一個通用的腳本:Window,這個類繼承自MonoBehaviour,用來驅(qū)動我的邏輯。
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();
}
//重用界面時調(diào)用
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); //傳遞界面參數(shù)
}
IView是定義的界面接口
public interface IView
{
void Init(GameObject view); //在Presenter中會把傳遞的界面gameObject綁定到View上,Presenter持有View的引用,而不直接持有g(shù)ameObject
}
IIntent是參數(shù)傳遞的接口
public interface IIntent { }
結(jié)構(gòu)可以理解為一個界面對一個IPresenter,對應(yīng)一個IView。IPresenter中負(fù)責(zé)業(yè)務(wù)邏輯、設(shè)置界面,IView中負(fù)責(zé)寫界面設(shè)置函數(shù)和事件監(jiān)聽,這樣把UI和邏輯分開了。
接著看看實現(xiàn)IPresenter的一個基礎(chǔ)類:Presenter<T>,它接受一個泛型,用來把IView和它聯(lián)系起來,并實現(xiàn)了一些函數(shù)。
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;
}
//每次壓棧都會調(diào)用
public abstract void OnEnter();
//{
// //_view.Show();
//}
//每次退棧都會調(diào)用
public abstract void OnLeave();
//{
// //_view.Hide();
//}
//在mono start和時調(diào)用
public virtual void OnStart() { }
public virtual void OnStop() { }
public virtual void OnDestroy() { }
public void BindView(GameObject view)
{
_view = Activator.CreateInstance<T>();
_view.Init(view);
}
}
當(dāng)然IView也有基本實現(xiàn):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創(chuàng)建的Layer,之前也說過,我是通過改變layer來顯示和隱藏界面的。
public class UnityLayer
{
public const int HideUILayer = 8;
public const int ShowUILayer = 5;
}
還有一個類負(fù)責(zé)管理界面:WindowManager,它維護了一個棧的結(jié)構(gòu)(雖然我是用List裝的),每次打開界面的時候 - 進棧,每次關(guān)閉界面的時候 - 出棧。
界面IPresenter的生命周期:

WindowManager 對外提供兩個函數(shù),一個打開一個關(guān)閉,并且還對無用的界面做了緩存,限制cache容器的大小,并用一個定時器定期去檢查cache,超過限制就把前面的釋放掉,滿足先進先出的規(guī)則。
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);
//手動調(diào)用,表示重用
old.ReStart(intent);
}
else
{
//為了簡單,所以這里就直接使用Resources加載了
UnityEngine.Object obj = Resources.Load(name);
GameObject go = GameObject.Instantiate(obj) as GameObject;
//通過配置,關(guān)聯(lián)界面和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)
{
//把當(dāng)前最上面的窗口hide
win[win.Count - 1].Hide();
break;
}
}
//沒有找到相應(yīng)的窗口
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);
//釋放資源
}
}
}
至此,一個簡單的界面框架就完成了,那么在開發(fā)的時候只需要寫一個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("");
}
}
總結(jié):
在做項目的時候就一直琢磨,要自己寫一個UI框架,不然對不起自己寫了這么久界面。最近終于完成了第一版,里面還存在很多問題,比如多個界面的層次關(guān)系怎么管理、有兩處代碼使用了反射可以想辦法改進,當(dāng)然還有沒有考慮到的問題,所以后續(xù)還要陸續(xù)修改。
寫在最后:
花了一周時間整理了這些東西,整理自己的思路,這次一定印象深刻,可能寫的不太好,有什么問題請直接指出,一起討論,不斷總結(jié),不斷學(xué)習(xí),不斷提升。