Unity 极简UI框架
写ui的时候一般追求控制逻辑和显示逻辑分离,经典的类似于MVC,其余大多都是这个模式的衍生,实际上书写的时候M是在整个游戏的底层,我更倾向于将它称之为D(Data)而不是M(Model),而C(Ctrl)负责接收用户的各类UI事件,例如点击,滑动,还有其他游戏逻辑板块发过来的事件或消息,处理这些消息并更新V(View)当中的各类显示数据,这里更新数据的方式可以抽象为两种:
1.外部事件触发View更新,这时不用在意底层数据更新,因为在刷新View之前这些改变的数据可以在其他逻辑版块中直接更新完。
2.UI内部点击,滑动等事件触发View更新,这种情况下有可能需要更新底层数据,但最好不要直接修改和调用,而是选择向外部发送事件和消息的方式来告知外部需要更新数据。
无论是上面两种情况中的哪一种,都不是View直接参与外部逻辑联系,而是借助中间的Ctrl来联系,Ctrl中处理UI与外部对接的所有逻辑,并能够及时的更新View。
再来分析下Ctrl,我们发现Ctrl的控制流程是可以固定下来,抽象如下:
1.进入一个View界面之前,得到View组件,初始化View中各个元素的状态
2.播放一段进入动画,例如淡入
3.进入动画播放完成后,对View中的一些元素添加事件侦听,或对外部的一些事件添加侦听
4.当侦听中的事件触发后,可以选择是否对View更新,或向外部发送事件,消息
5.同样的,离开时播放一段动画,例如淡出
6.离开动画播放完成后,移除所有事件侦听,载入一个新的View或场景
定义Ctrl基类:
1 using UnityEngine; 2 using UnityEngine.Events; 3 using UnityEngine.SceneManagement; 4 5 6 public class HudBase : MonoBehaviour 7 { 8 public GameObject Root; 9 protected Canvas Canvas; 10 protected HudView HudView; 11 private void Awake() 12 { 13 Canvas = GetComponentInParent<Canvas>(); 14 HudView = GetComponent<HudView>(); 15 } 16 17 private void Start() => InitState(); 18 19 private void OnEnable() => Enter(() => AddListeners()); 20 21 private void OnDisable() => RemoveListeners(); 22 23 protected virtual void InitState() { } 24 25 protected virtual void AddListeners() { } 26 27 protected virtual void RemoveListeners() { } 28 29 protected void Enter(UnityAction complete) => Canvas.FadeIn(Root, () => complete()); 30 31 protected void ExitTo(string sceneName) => Canvas.FadeOut(Root, () => SceneManager.LoadScene(sceneName)); 32 33 protected void UpdateView<T>(T t) where T : HudView => t.Refresh(); 34 }
View基类:
1 using UnityEngine; 2 3 public class HudView : MonoBehaviour 4 { 5 public virtual void Refresh() { } 6 }
View只有一个自带的更新视图的通用方法,数据来源则直接取游戏底层即可,能够从Ctrl中直接调用View视图的更新。
其他通用的UI方法则全部写在一个统一的地方,例如淡入淡出的函数,向外部发送事件,侦听事件等,这里统一写成了Canvas的扩展方法,便于在基类中也方便直接调用:
1 using System.Collections.Generic; 2 using UnityEngine; 3 using UnityEngine.UI; 4 using UnityEngine.Events; 5 using DG.Tweening; 6 7 public static class HudHelper 8 { 9 //UI 10 public static void FadeIn(this Canvas canvas, GameObject target, TweenCallback action) 11 { 12 var cg = target.GetOrAddComponent<CanvasGroup>(); 13 cg.alpha = 0; 14 cg.DOFade(1, .3f).OnComplete(action); 15 } 16 17 public static void FadeOut(this Canvas canvas, GameObject target, TweenCallback action) 18 { 19 var cg = target.GetOrAddComponent<CanvasGroup>(); 20 cg.DOFade(0, .3f).OnComplete(action); 21 } 22 23 public static void SendEvent<T>(this Canvas canvas, T e) where T : GameEvent => EventManager.QueueEvent(e); 24 25 public static void AddListener<T>(this Canvas canvas, EventManager.EventDelegate<T> del) where T : GameEvent => EventManager.AddListener(del); 26 27 public static void RemoveListener<T>(this Canvas canvas, EventManager.EventDelegate<T> del) where T : GameEvent => EventManager.RemoveListener(del); 28 29 public static void ButtonListAddListener(this Canvas canvas, List<Button> buttons, UnityAction action) 30 { 31 foreach (var bt in buttons) 32 { 33 bt.onClick.AddListener(action); 34 } 35 } 36 37 public static void ButtonListRemoveListener(this Canvas canvas, List<Button> buttons) 38 { 39 foreach (var bt in buttons) 40 { 41 bt.onClick.RemoveAllListeners(); 42 } 43 } 44 }
关于事件队列可以详细见之前的随笔:
https://www.cnblogs.com/koshio0219/p/11209191.html
具体的用法如下:(Ctrl)
1 using UnityEngine; 2 using UnityEngine.EventSystems; 3 4 public class MapCanvasCtrl : HudBase 5 { 6 private MapCanvasView View; 7 public GameObject UnderPanel; 8 9 protected override void InitState() 10 { 11 //将基类的View转化为对应子类 12 View = HudView as MapCanvasView; 13 UnderPanel.SetActive(false); 14 UpdateView(View); 15 } 16 17 protected override void AddListeners() 18 { 19 View.Back.onClick.AddListener(() => ExitTo("S_Main")); 20 21 Canvas.AddTriggerListener(View.Map, EventTriggerType.Drag, OnDrag); 22 23 Canvas.ButtonListAddListener(View.TaskPoints, () => 24 { 25 UnderPanel.SetActive(true); 26 Canvas.FadeIn(UnderPanel, () => View.HitOut.onClick.AddListener(() => ExitTo("S_DemoBattle"))); 27 }); 28 } 29 30 private void OnDrag(BaseEventData data) 31 { 32 //将基类的Data转化为对应子类 33 var d = data as PointerEventData; 34 Debug.Log(d.dragging); 35 } 36 37 protected override void RemoveListeners() 38 { 39 View.HitOut.onClick.RemoveAllListeners(); 40 Canvas.ButtonListRemoveListener(View.TaskPoints); 41 Canvas.RemoveTriggerListener(View.Map); 42 } 43 }
只需要重写以上三个方法即可。注意初始化时将基类的View转为对应子类使用,使用关键字as。
对应的具体View:
1 using UnityEngine.UI; 2 3 public class MapCanvasView : HudView 4 { 5 public UpBoxView UpBoxView; 6 7 public Button HitOut; 8 public Button Back; 9 10 public Image Map; 11 12 public List<Button> TaskPoints = new List<Button>(); 13 14 public override void Refresh() 15 { 16 UpBoxView.Refresh(); 17 //Do something else... 18 } 19 }
当然了,大的View中也可能嵌套小的View,这样可以更为方便的将一些零散的UI控件随意的插入到其他View中,例如一般游戏中顶部的角色基础信息栏等:
1 using TMPro; 2 3 public class UpBoxView : HudView 4 { 5 public TextMeshProUGUI Name; 6 public TextMeshProUGUI Resource; 7 public TextMeshProUGUI Level; 8 9 public override void Refresh() 10 { 11 var d = GameData.Instance.PlayerData; 12 Level.text = "Lv. " + d.lv.ToString(); 13 Resource.text = d.ResourcePoint.ToString(); 14 Name.text = "咕噜灵波"; 15 } 16 }
在上面的例子中,用到了动态添加EventTrigger侦听的扩展方法:(看了下网上的很多写法都有些问题,要不就是不判断列表中有没有同类型的就直接往里塞,要不就是判断了之后发现没有同类型的实例化一个不添加侦听就放进去)
1 public static void AddTriggerListener(this Canvas canvas, Component obj, EventTriggerType type, UnityAction<BaseEventData> action) 2 { 3 //先看有没有对应组件没有就加上 4 var trigger = obj.gameObject.GetOrAddComponent<EventTrigger>(); 5 //再看看触发列表中有没有事件,没有就新建一个列表 6 if (trigger.triggers == null || trigger.triggers.Count == 0) 7 trigger.triggers = new List<EventTrigger.Entry>(); 8 //再看事件列表中是不是已经存在对应类型的值,如果存在的话简单直接给那个事件加个侦听就好 9 foreach (var e in trigger.triggers) 10 { 11 if (e.eventID == type) 12 { 13 e.callback.AddListener(action); 14 return; 15 } 16 } 17 //到这里就是很遗憾没有对应类型的事件,那就实例化一个新的,注意实例化完了以后还要把对应的事件类型和回调设定进去 18 var entry = new EventTrigger.Entry(); 19 entry.eventID = type; 20 entry.callback.AddListener(action); 21 //全部设定好了再加进去,要不然没有效果知道么 22 trigger.triggers.Add(entry); 23 }
public static T GetOrAddComponent<T>(this GameObject obj) where T : Component => obj.GetComponent<T>() ? obj.GetComponent<T>() : obj.AddComponent<T>();
调用的时候可以进行as转换类型来使用,这样就可以取到对应子类的值了:
1 private void OnDrag(BaseEventData data) 2 { 3 var d = data as PointerEventData; 4 Debug.Log(d.dragging); 5 }
2020年5月25日更新:
1.在刷新视图时可传入不定类型和个数的参数。
在实际使用的过程中发现,不传递参数有时刷新视图比较困难,但不同的View又会根据不同的需要传递类型和个数均不同的参数,这时就想到了使用params object[] parameters作为参数进行传递。
1 using UnityEngine; 2 3 public class HudView : MonoBehaviour 4 { 5 public virtual void Refresh(params object[] parameters) { } 6 }
1 /// <summary> 2 /// 刷新视图 3 /// </summary> 4 /// <typeparam name="T">视图类型</typeparam> 5 /// <param name="t">视图实例</param> 6 /// <param name="parameters">不定类型和个数的参数</param> 7 protected void UpdateView<T>(T t, params object[] parameters) where T : HudView => t.Refresh(parameters);
因为C#中所有的类型都继承自object,通过装箱和拆箱就可以传递任何类型的参数,这样写非常简洁,但缺点是对于性能会有一点的损失;如果你实在不想损失性能,也可以考虑用多种不同类型的泛型参数作为替代。
1 protected void UpdateView<T0, T1>(T0 t0, T1 t1) where T0 : HudView => t0.Refresh(t1); 2 protected void UpdateView<T0, T1, T2>(T0 t0, T1 t1, T2 t2) where T0 : HudView => t0.Refresh(t1, t2); 3 protected void UpdateView<T0, T1, T2, T3>(T0 t0, T1 t1, T2 t2, T3 t3) where T0 : HudView => t0.Refresh(t1, t2, t3); 4 protected void UpdateView<T0, T1, T2, T3, T4>(T0 t0, T1 t1, T2 t2, T3 t3, T4 t4) where T0 : HudView => t0.Refresh(t1, t2, t3, t4);
这样的缺点就是,写起来会很繁琐,需要多少参数就要加几个不同类型的函数,但因为不需要频繁的转换类型,性能来讲是较优解。
2.可以灵活控制切换View的函数。
有时我们不仅仅希望只用FadeIn或者FadeOut来进入或退出页面,而是可以自定义各种切换的方式,这是就需要用到委托作为参数了。
1 /// <summary> 2 /// 切换页面 3 /// </summary> 4 /// <param name="obj">目标</param> 5 /// <param name="way">切换方式委托</param> 6 /// <param name="complete">切换完成后委托</param> 7 protected void Shift(GameObject obj, UnityAction<GameObject, TweenCallback> way, UnityAction complete) => way(obj, () => complete());
当然了,这样的话我们就需要规定所有的切换方式的函数参数类型和个数都需要与way保持一致。
补充——关于类型的判断与转换:
使用params object[] parameters在实际函数实现的过程中需要判断传入的参数类型是否符合当前预期,可以通过下来的方式来进行具体判断,例如:
1 if (parameters.Length > 0 && parameters[0] is int) 2 { 3 Debug.Log((int)parameters[0]); 4 }
1 if (parameters.Length > 0 && parameters[0].GetType() == typeof(int)) 2 { 3 Debug.Log((int)parameters[0]); 4 }
当然了,如果是引用类型,除了强制类型转换之外还可以使用关键字as进行装换,上面的例子中已经有了就不再举例了。
但如果是泛型的话是不能直接执行强制类型转换的,但还是可以先转换为object类型,再执行强制类型转换:
1 private void Test<T>(T t) 2 { 3 if (t is int) 4 { 5 object temp = t; 6 Debug.Log((int)temp); 7 } 8 //or 9 if (typeof(T) == typeof(int)) 10 { 11 object temp = t; 12 Debug.Log((int)temp); 13 } 14 }