一、框架介绍
制作的这个UI框架可以自动化生成UI的绑定脚本,不继承Mono,所以生命周期控制便于控制(也可以继承mono),通过UGUI-Editor插件,可以一键优化,降低drawcall和打断合批等等消耗性能的点
框架特点
1.性能点
- 避免频繁网格重建带来的大量性能消耗和垃圾回收
- 避免调用setactive带来的性能消耗和GC垃圾
- 避免频繁Instans造成的卡顿和性能消耗以及GC
- 避免不必要的组件属性带来的性能开销
- 避免不正确的组件顺序导致打断合批带来的性能开销
2.功能点
- 交互性
- 可控性(不能穿插别的系统的周期调用,比如mono)
- 便携性
3.系统点
- 1.自动化系统
- 2.透明的层级关系,可控,不能让特效和界面乱套,或者修改一个界面的层级,导致其他的界面都需要临时修改
- 3.完善的遮罩系统
- 4.简洁的堆栈系统
4.UI框架具备的所有功能
- UI管理系统 (管理UI界面的生成、销毁、交互、以及生命周期)
- 遮罩系统 (管理UI弹出和关闭时遮罩的处理,单遮罩模式、叠遮模式)
- 堆栈系统 (管理UI有序弹出、比如刚进游戏大厅会按照优先级反复弹出多个界面)
- 灵活的层级系统(管理UI层级保证界面-模型-特效-界面中间不会有穿插的现象)
- 自动化系统(管理UI重复性工作,比如自动化生成脚本、方法属性声明、组件绑定等)
- 高效率系统(管理各种UI的创建与模板制作)
- 高性能系统 (管理并解决UI元素与界面的性能问题,在框架底层解决,避免后期二次性能优化)
二、Mono分离式UI管理系统
WindowBehaviour类
首先创建一个windowbehaviour类
是所有界面都必须继承的最基础最顶层的类
主要负责声明周期以及基础属性的声明
使用成员属性而不使用变量的原因是为了方便访问和修改属性值。使用成员属性可以提供更好的封装性和可控性,比如只想获得而不能修改就可以只get。
gameObject属性表示当前窗口的游戏对象,通过它可以获取和操作窗口的各种组件和属性。
transform属性代表窗口自身的变换组件,通过它可以控制窗口的位置、旋转和缩放等变换操作。
public abstract class WindowBehaviour
{
public GameObject gameObject { get; set; }//当前窗口的物体
public Transform transform { get; set; }//代表自己
public Canvas Canvas { get; set; }
public string Name { get; set; }
public bool Visible { get; set; }
//检测是否是通过堆栈系统弹出的弹窗
public bool isPopStack { get; set; }
public Action<WindowBase> PopStackListener { get; set; }
public virtual void OnAwake() { }//只会在物体创建的时候执行一次
public virtual void OnShow() { }//在物体显示时执行一次,与Mono的OnEnable一致
public virtual void OnUpdate() { }
public virtual void OnHide() { }//在物体隐藏的时候执行一次,类似OnDisable
public virtual void OnDestroy() { }
public virtual void SetVisible(bool isVisible) { }
}
WindowBase类
1.简介
UI窗口基类,负责部分公用的功能统一化处理,例如弹出、关闭动画、以及解耦合方法的声明等等公用接口的处理
首先拿到所有常用的组件的list,比如button、toggle和inputList
通过Find的方法找到UIMask和UIContent、CanvasGroup这三个组件
UIMask组件用于控制遮罩,UIContent是UIRoot节点下面的节点,子组件包含了所有的ui控件
2.动画管理
base类包含了动画管理的方法(使用DOTWeen实现)
包括ShowAnimation和HideAnimation两个方法
ShowAnimation方法
最基础的弹窗不需要动画,所以设置了只有层级大于90的弹窗才会触发动画
先把uimask的alpha值调整为0,并且使用DOFade方法调整透明度
设置对应的缩放效果
public void ShowAnimation()
{
//基础弹窗不需要动画
if (Canvas.sortingOrder>90&&mDisableAnim==false)
{
//Mask动画
mUIMask.alpha = 0;
mUIMask.DOFade(1,0.2f);
//缩放动画
mUIContent.localScale = Vector3.one * 0.8f;
mUIContent.DOScale(Vector3.one, 0.3f).SetEase(Ease.OutBack);
}
}
HideAnimation方法
基本同上
public void HideAnimation()
{
if (Canvas.sortingOrder > 90 && mDisableAnim == false)
{
mUIContent.DOScale(Vector3.one * 1.1f, 0.2f).SetEase(Ease.OutBack).OnComplete(() =>
{
//TODO:待修改
UIModule.Instance.HideWindow(Name);
});
}
else
{
UIModule.Instance.HideWindow(Name);
}
}
关闭窗口方法调用了UIModule中的HideWindow方法,传入关闭的这个窗口名,然后调用HideAnimation
3.事件管理
添加了AddButtonClickListener这种三个方法,首先传入组件本身,然后传入一个unityaction,大同小异,基本都是先判断当前传入的组件是否为空
如果不为空,则进入函数对比当前的mAllButtonList中是否存在这个组件没如果不存在则添加进去
然后清空监听列表,再添加传入的这个action事件
然后再提供删除所有的监听事件的方法
以Button组件的监听为例:
public void AddButtonClickListener(Button btn,UnityAction action)
{
if(btn!=null)
{
//如果当前的list中没有就添加进去
if(!mAllButtonList.Contains(btn))
{
mAllButtonList.Add(btn);
}
btn.onClick.RemoveAllListeners();
btn.onClick.AddListener(action);
}
}
4.代码
public class WindowBase : WindowBehaviour
{
private List<Button> mAllButtonList=new List<Button>();//所有button的列表
private List<Toggle> mToggleList=new List<Toggle>();//所有的toggle列表
private List<InputField> mInputList=new List<InputField>();//所有的输入框列表
private CanvasGroup mUIMask;
protected Transform mUIContent;
private CanvasGroup mCanvasGroup;
protected bool mDisableAnim = false;//禁用动画
private void InitializeBaseComponent()
{
mUIMask = transform.Find("UIMask").GetComponent<CanvasGroup>();
mCanvasGroup = transform.Find("CanvasGroup").GetComponent<CanvasGroup>();
mUIContent = transform.Find("UIContent").transform;
}
#region 生命周期
public override void OnAwake()
{
base.OnAwake();
InitializeBaseComponent();
}
public override void OnShow()
{
base.OnShow();
ShowAnimation();
}
public override void OnUpdate()
{
base.OnUpdate();
}
public override void OnHide()
{
base.OnHide();
}
public override void OnDestroy()
{
base.OnDestroy();
RemoveAllButtonListener();
RemoveAllToggleListener();
RemoveAllInputListener();
mAllButtonList.Clear();
mToggleList.Clear();
mInputList.Clear();
}
#endregion
#region 动画管理
public void ShowAnimation()
{
//基础弹窗不需要动画
if (Canvas.sortingOrder>90&&mDisableAnim==false)
{
//Mask动画
mUIMask.alpha = 0;
mUIMask.DOFade(1,0.2f);
//缩放动画
mUIContent.localScale = Vector3.one * 0.8f;
mUIContent.DOScale(Vector3.one, 0.3f).SetEase(Ease.OutBack);
}
}
public void HideAnimation()
{
if (Canvas.sortingOrder > 90 && mDisableAnim == false)
{
mUIContent.DOScale(Vector3.one * 1.1f, 0.2f).SetEase(Ease.OutBack).OnComplete(() =>
{
//TODO:待修改
UIModule.Instance.HideWindow(Name);
});
}
else
{
UIModule.Instance.HideWindow(Name);
}
}
#endregion
public void HideWindow()
{
UIModule.Instance.HideWindow(Name);
HideAnimation();
}
public override void SetVisible(bool isVisible)
{
//TODO:后面会修改
//gameObject.SetActive(isVisible);
mCanvasGroup.alpha = isVisible ? 1 : 0;
mCanvasGroup.blocksRaycasts = isVisible;
Visible = isVisible;
}
public void SetMaskVisible(bool isVisble)
{
//如果不是单遮罩模式
if(!UISetting.Instance.SINGMASK_SYSTEM)
{
return;
}
mUIMask.alpha = isVisble ? 1 : 0;
}
#region 事件管理
public void AddButtonClickListener(Button btn,UnityAction action)
{
if(btn!=null)
{
//如果当前的list中没有就添加进去
if(!mAllButtonList.Contains(btn))
{
mAllButtonList.Add(btn);
}
btn.onClick.RemoveAllListeners();
btn.onClick.AddListener(action);
}
}
//bool值表示当前的这个是否被选中
public void AddToggleClickListener(Toggle toggle,UnityAction<bool,Toggle> action)
{
if(toggle!=null)
{
if (!mToggleList.Contains(toggle))
{
mToggleList.Add(toggle);
}
toggle.onValueChanged.RemoveAllListeners();
toggle.onValueChanged.AddListener((isOn) =>
{
action?.Invoke(isOn, toggle);
});
}
}
public void AddInputFieldListener(InputField input,UnityAction<string> onChangeAction,UnityAction<string> endAction)
{
if(input!=null)
{
if(!mInputList.Contains(input))
{
mInputList.Add(input);
}
input.onValueChanged.RemoveAllListeners();
input.onValueChanged.AddListener(onChangeAction);
input.onEndEdit.RemoveAllListeners();
input.onEndEdit.AddListener(endAction);
}
}
public void RemoveAllButtonListener()
{
foreach(var item in mAllButtonList)
{
item.onClick.RemoveAllListeners();
}
}
public void RemoveAllToggleListener()
{
foreach(var item in mToggleList)
{
item.onValueChanged.RemoveAllListeners();
}
}
public void RemoveAllInputListener()
{
foreach(var item in mInputList)
{
item.onValueChanged.RemoveAllListeners();
item.onEndEdit.RemoveAllListeners();
}
}
#endregion
}
UIModule类
1.简介
uimodule类是管理所有UI的创建-隐藏-以及声明周期的初始化的类(差不多就是UIMgr)
注意它也是一个单例
在场景中创建一个空物体,创建一个脚本在awake的时候调用Initialize()函数进行初始化
2.具体内容
1.相关数据属性
private Dictionary<string, WindowBase> mAllWindowDic = new Dictionary<string, WindowBase>();
private List<WindowBase> mAllWindowList=new List<WindowBase>();//所有窗口的列表
private List<WindowBase> mVisibleWindowList=new List<WindowBase>();//所有可见的窗口列表
这三个列表分别存储了所有的窗口和当前可见不可见的窗口
在Initialize()方法中找到几个组件(使用Find方法绑定)
2.资源动态加载
在场景中首次生成预制体时,我们可以使用GameObject.Instantiate方法来实例化并生成来自Resources文件夹中的预制体。这个过程是通过调用Instantiate方法并传入预制体的引用来完成的。通过这种方式,我们能够动态地在场景中创建对象,并对其进行进一步的操作和管理。
为了实现动态加载预制体,我们可以使用Resources.Load方法来加载预制体。为了避免硬编码路径的问题,我们可以创建一个名为WindowConfig的脚本,并继承自ScriptableObject类。在WindowConfig脚本中,我们可以定义一个List<WindowData>,用于存储UI窗口的相关信息。
WindowData中可以包含窗口的名称和所在路径等属性。该脚本提供了一个GetWindowPath的接口,通过这个接口返回某个文件夹的具体路径。通过这种方式,我们可以在运行时通过读取WindowConfig脚本获取窗口的路径信息,然后使用Resources.Load方法动态加载对应的预制体。
这样做的好处是,我们可以灵活地管理窗口的路径信息,而不需要将预制体固定在特定的文件夹下。通过编辑WindowConfig脚本,我们可以方便地添加、修改或删除窗口的配置信息,而不用修改代码中的硬编码路径。
//资源加载
public GameObject TempLoadWindow(string widName)
{
var widdow= GameObject.Instantiate<GameObject>(Resources.Load<GameObject>(mWindowConfig.GetWindowPath(widName)), mUIRoot);
//widdow.transform.SetParent(mUIRoot);
widdow.transform.localScale = Vector3.one;
widdow.transform.localPosition = Vector3.zero;
widdow.transform.localRotation= Quaternion.identity;
widdow.name = widName;
return widdow;
}
3.窗口管理系统
这一部分负责管理所有窗口,包括预加载、弹出、隐藏和销毁窗口等功能
(1)弹出和显示窗口
PopUpWindow<T>()
通过反射找到 T 的类型和 name:
- 获取调用 PopUpWindow<T> 方法时传入的泛型类型 T。
- 获取 T 的类型和名称。
通过自定义的 GetWindow 方法找到当前窗口:
判断窗口是否存在:
- 如果窗口存在,则调用 ShowWindow(widName) 方法显示窗口。
- 如果窗口不存在,则继续执行下一步。
实例化一个新的窗口对象:
初始化窗口对象:
- 调用 InitializeWindow(t, widName) 方法,对窗口对象进行初始化操作。
public T PopUpWindow<T>() where T:WindowBase,new()
{
var type = typeof(T);
string widName = type.Name;
WindowBase wid = GetWindow(widName);
if(wid!=null)
{
return ShowWindow(widName) as T;
}
//实例化泛型类
T t = new T();
return InitializeWindow(t,widName) as T;
}
InitializeWindow方法
调用 TempLoadWindow(widName) 方法加载窗口的预制体。
进行一系列初始化操作:
设置窗口的名称、变换、画布等属性。
调用窗口的 OnAwake() 方法进行初始化。
将窗口添加到窗口字典和列表中。
设置窗口可见。
如果加载失败,输出日志信息。
private WindowBase InitializeWindow(WindowBase windowBase,string widName)
{
//1.生成对应的窗口预制体
GameObject newwiddow = TempLoadWindow(widName);
//2.初始出对应管理类
if(newwiddow!=null)
{
windowBase.gameObject=newwiddow;
windowBase.Name = newwiddow.name;
windowBase.transform=newwiddow.transform;
windowBase.Canvas = newwiddow.GetComponent<Canvas>();
windowBase.Canvas.worldCamera = mUICamera;
windowBase.transform.SetAsLastSibling();
windowBase.OnAwake();
windowBase.SetVisible(true);
windowBase.OnShow();
RectTransform rectTrans = newwiddow.GetComponent<RectTransform>();
rectTrans.anchorMax = Vector2.one;
rectTrans.offsetMax = Vector2.zero;
rectTrans.offsetMin = Vector2.zero;
mAllWindowDic.Add(widName,windowBase);
mAllWindowList.Add(windowBase);
mVisibleWindowList.Add(windowBase);
SetWindowMaskVisible();
return windowBase;
}
Debug.LogError("加载失败 窗口名:"+widName);
return null;
}
ShowWindow方法
通过遍历字典对比,找到对应的窗口。如果窗口存在并且未显示,则先将该窗口添加到 mVisibleWindowList 中,并调用 transform.SetAsLastSibling() 将其置于兄弟窗口的最后一个位置。接着将窗口的可见状态设置为显示,并调用窗口的 OnShow() 方法,如果显示失败,打印错误信息
private WindowBase ShowWindow(string winName)
{
WindowBase window = null;
if (mAllWindowDic.ContainsKey(winName))
{
window = mAllWindowDic[winName];
//如果这个窗口存在并且没有显示就改为显示
if (window.gameObject != null && window.Visible == false)
{
mVisibleWindowList.Add(window);
window.transform.SetAsLastSibling();
window.SetVisible(true);
SetWindowMaskVisible();
window.OnShow();
}
return window;
}
else
Debug.LogError(winName + "窗口不存在,请调用PopUpWindow弹出窗口");
return null;
}
GetWindow方法
这个方法有两个,一个提供给内部私有方法,一个提供给外部泛型获取窗口(注意这个只能获取显示的窗口,未显示的窗口无法获得)
//找到当前的窗口
private WindowBase GetWindow(string winName)
{
if(mAllWindowDic.ContainsKey(winName))
{
return mAllWindowDic[winName];
}
return null;
}
/// <summary>
/// 获取已经弹出的窗口
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public T GetWindow<T>() where T:WindowBase
{
var type = typeof(T);
foreach (var item in mVisibleWindowList)
{
if(item.Name==type.Name)
{
return (T)item;
}
}
Debug.LogError("没有获取到窗口:"+type.Name);
return null;
}
HideWindow方法
这个方法也分为提供给外部和提供给内部的,提供给外部的使用泛型指定要关闭的窗口,实际调用的时候将窗口移除mVisibleWindowList并且设置显隐,最后再调用OnHide()方法
还提供了一个摧毁的方法,大同小异,不再赘述
//提供给外部的接口
public void HideWindow(string widName)
{
WindowBase window=GetWindow(widName);
HideWindow(window);
}
/// <summary>
/// 提供给外部的泛型关闭窗口方法
/// </summary>
/// <typeparam name="T"></typeparam>
public void HideWindow<T>() where T:WindowBase
{
HideWindow(typeof(T).Name);
}
//实际上关闭的函数
//私有的隐藏窗口方法
private void HideWindow(WindowBase window)
{
if(window!=null&&window.Visible)
{
mVisibleWindowList.Remove(window);
window.SetVisible(false);
SetWindowMaskVisible();
window.OnHide();
}
//在出栈的情况下,上一个界面是否是队列中的,如果是,则隐藏的时候自动打开栈中的下一个界面
PopNextStackWindow(window);
}
private void DestroyWindow(string widName)
{
WindowBase window=GetWindow(widName);
DestroyWindow(window);
}
public void DestroyWindow<T>() where T:WindowBase
{
DestroyWindow(typeof(T).Name);
}
private void DestroyWindow(WindowBase window)
{
if(window!=null)
{
if(mAllWindowDic.ContainsKey(window.Name))
{
mAllWindowDic.Remove(window.Name);
mAllWindowList.Remove(window);
mVisibleWindowList.Remove(window);
}
window.SetVisible(false);
SetWindowMaskVisible();
window.OnHide();
window.OnDestroy();
GameObject.Destroy(window.gameObject);
//在出栈的情况下,上一个界面是否是队列中的,如果是,则销毁的时候自动打开栈中的下一个界面
PopNextStackWindow(window);
}
}
public void DestroyAllWindow(List<string> filterlist=null)
{
for(int i=mAllWindowList.Count-1;i>=0;i--)
{
WindowBase window = mAllWindowList[i];
if(window==null||(filterlist!=null&&filterlist.Contains(window.Name)))
{
continue;
}
DestroyWindow(window.Name);
//卸载未使用资源
Resources.UnloadUnusedAssets();
}
}这一部分负责管理所有窗口,包括预加载、弹出、隐藏和销毁窗口等功能
三、完善的遮罩系统
1.遮罩类型
1.单遮罩模式(更普遍,但是有问题)
2.叠遮模式
2.实现方法
1.创建UISetting脚本
首先创建一个SO文件,名为UISetting,将其写为单例,只有一个bool来调整是否是单遮罩模式
2.修改WindowBase
创建一个方法SetMaskVisible(bool isVisble)
public void SetMaskVisible(bool isVisble)
{
//如果不是单遮罩模式
if(!UISetting.Instance.SINGMASK_SYSTEM)
{
return;
}
mUIMask.alpha = isVisble ? 1 : 0;
}
如果不是单遮罩模式则不用处理,否则根据显隐改编alpha通道的值
3.在UIModule中实际实现
首先通过UISetting单例判断是否是单遮罩模式,如果不是就直接返回
先关闭所有的窗口的mask,设置为不可见,然后通过UIModule中的mVisibleWindowList找到所有窗口中层级最大的窗口把mask设置为可见
/// <summary>
/// 调整遮罩方法
/// </summary>
private void SetWindowMaskVisible()
{
if(!UISetting.Instance.SINGMASK_SYSTEM)
{
return;
}
WindowBase maxOrderWidBase = null;//最大渲染层级窗口
int maxOrder = 0;//最大渲染层级
int maxIndex = 0;//最大排序下标 在相同父节点位置下标
//先关闭所有窗口的mask,设置为不可见
//然后从所有的窗口中找到层级最大的窗口把mask设置为可见
for(int i=0;i<mVisibleWindowList.Count;i++)
{
WindowBase window=mVisibleWindowList[i];
if(window!=null&&window.gameObject!=null)
{
window.SetMaskVisible(false);
if(maxOrderWidBase== null)
{
maxOrderWidBase=window;
maxOrder = window.Canvas.sortingOrder;
maxIndex = window.transform.GetSiblingIndex();//获取同级索引
}
else
{
//找到最大渲染层级的窗口,拿到它
if(maxOrder<window.Canvas.sortingOrder)
{
maxOrderWidBase = window;
maxOrder =window.Canvas.sortingOrder;
}
else if(maxOrder==window.Canvas.sortingOrder&& maxIndex < window.transform.GetSiblingIndex())
{
maxOrderWidBase = window;
maxIndex=window.transform.GetSiblingIndex();
}
}
}
}
if(maxOrderWidBase!=null)
{
maxOrderWidBase.SetMaskVisible(true);
}
}
四、灵活的层级系统
canvas不会导致drawcall重绘,但是添加的ui组件会导致重绘
通过设置几个不同的层级基础模版,在这个基础上去更改即可
五、自动化系统
自动化系统简单介绍:
ZMUIFrameWork框架的自动化主要分为几个部分:
1.查找分为两种:
- (Find方法)使用[组件类型]组件名这种命名方法去找到对应的组件并且绑定
- (Bind方法)用tag标签的形式去绑定(如果使用tag则不能再去用[]这样的命名)
2.绑定也分为两种:
- (Find方法)直接通过代码绑定
- (Bind方法)将脚本挂载到窗口上 在Inspector窗口绑定
两种方式有很多类似之处,所以下面我会先说明Find脚本的编写,后面再对比两者区别
1.自动化生成查找组件脚本(Find)
通过[组件类型]name查找组件
通过自定义规则[Text]的方式找到组件,用字典和list列表存储对应的数据,找到对应的组件的文件地址
首先创建一个脚本:
GeneratorFindComponentTool
PresWindowNodeData方法
这个方法会遍历当前传入的transform子物体,如果这个子物体的名字中存在"[“和”]"则说明找到了对应的组件类型和名称,通过Substring分割字符串分别找到类型和名称,然后又添加进入一个objDataList中(其中包含了ID、名称和类型)
Bind和Find方式的两种方法大致相同,主要区别在于Bind无需查找路径
//计算当前节点的查找路径,看代码即可
/// <summary>
/// 解析窗口数据
/// </summary>
/// <param name="trans"></param>
/// <param name="WinName"></param>
public static void PresWindowNodeData(Transform trans,string WinName)
{
for(int i=0;i<trans.childCount;i++)
{
GameObject obj = trans.GetChild(i).gameObject;
string name=obj.name;
if(name.Contains("[")&&name.Contains("]"))
{
int index = name.IndexOf("]") + 1;
string fileName=name.Substring(index,name.Length-index);//获取字段昵称
string fileType = name.Substring(1, index - 2);//获取字段类型
objDataList.Add(new EditorObjectData { fileName = fileName, fileType = fileType, insID = obj.GetInstanceID() });
//计算该节点的查找路径
string objPath = name;
bool isFindOver = false;
Transform parent = obj.transform;
for(int k=0;k<20;k++)
{
for(int j=0;j<=k;j++)
{
if(k==j)
{
parent = parent.parent;
//如果如节点是当前的窗口,查找结束
if(string.Equals(parent.name,WinName))
{
isFindOver = true;
break;
}
else
{
//在前面加上上一级的名字
objPath = objPath.Insert(0, parent.name + "/");
}
}
}
if(isFindOver)
{
break;
}
}
objFindPathDic.Add(obj.GetInstanceID(), objPath);
}
PresWindowNodeData(trans.GetChild(i), WinName);
}
}
ParseWindowDataByTag方法
和上面类似,只是查找的方式改为使用tag(同样展示的是Find方式)
public static void ParseWindowDataByTag(Transform trans, string WinName)
{
for (int i = 0; i < trans.childCount; i++)
{
GameObject obj = trans.GetChild(i).gameObject;
string tagName = obj.tag;
if (GeneratorConfig.TAGArr.Contains(tagName))
{
string fileName = obj.name;//获取字段名
string fileType = tagName;//获取字段类型
objDataList.Add(new EditorObjectData { fileName = fileName, fileType = fileType, insID = obj.GetInstanceID() });
//计算该节点的查找路径
string objPath = obj.name;
bool isFindOver = false;
Transform parent = obj.transform;
for (int k = 0; k < 20; k++)
{
for (int j = 0; j <= k; j++)
{
if (k == j)
{
parent = parent.parent;
//如果如节点是当前的窗口,查找结束
if (string.Equals(parent.name, WinName))
{
isFindOver = true;
break;
}
else
{
//在前面加上上一级的名字
objPath = objPath.Insert(0, parent.name + "/");
}
}
}
if (isFindOver)
{
break;
}
objFindPathDic.Add(obj.GetInstanceID(), objPath);
}
}
ParseWindowDataByTag(trans.GetChild(i), WinName);
}
}
2.一键生成组件查找脚本
找到对应地址后生成一个stringbuilder模板,注意不要出现拼写错误
然后根据模板编写脚本
CreatCS方法
这个就是实际创建脚本模板的方法
根据前面得到的窗口数据,使用StringBuilder拼接字符串,然后返回这个字符串,最后再通过这个模板生成脚本
在前面已经获得相关窗口的信息
- objFindPathDic //存储物体的id和查找路径
- objDataList //存储查找对象的数据
该模板包括以下内容:
- 声明命名空间
- 根据组件数据声明组件类型
- 初始化组件方法(这个方法会在窗口类中调用)
- 在初始化接口函数中绑定对应的组件(使用Find方式)
- 得到逻辑类 WindowBase => LoginWindow
- 生成UI事件绑定代码
/// <summary>
/// 自动化生成脚本
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
public static string CreatCS(string name)
{
StringBuilder sb = new StringBuilder();
string nameSpaceName = "UIFrameWork";
//添加引用和注释
sb.AppendLine("/*---------------------------------");
sb.AppendLine(" *Title:UI自动化组件查找代码生成工具");
sb.AppendLine(" *Date:" + System.DateTime.Now);
sb.AppendLine(" *Description:变量需要以[Text]括号加组件类型的格式进行声明,然后右键窗口物体—— 一键生成UI组件查找脚本即可");
sb.AppendLine(" *注意:以下文件是自动生成的,任何手动修改都会被下次生成覆盖,若手动修改后,尽量避免自动生成");
sb.AppendLine("---------------------------------*/");
sb.AppendLine("using UnityEngine.UI;");
sb.AppendLine("using UnityEngine;");
sb.AppendLine();
//生成命名空间
if(!string.IsNullOrEmpty(nameSpaceName))
{
sb.AppendLine($"namespace {nameSpaceName}");
sb.AppendLine("{");
}
sb.AppendLine($"\tpublic class {name + "UIComponent"}");
sb.AppendLine("\t{");
//根据字段数据列表 声明字段
foreach (var item in objDataList)
{
sb.AppendLine("\t\tpublic " + item.fileType + " " + item.fileName + item.fileType + ";\n");
}
//声明初始化组件接口
sb.AppendLine("\t\tpublic void InitComponent(WindowBase target)");
sb.AppendLine("\t\t{");
sb.AppendLine("\t\t //组件查找");
//根据字段路径 和字段数据列表生成组件查找代码
foreach (var item in objFindPathDic)
{
EditorObjectData itemData = GetEditorObjectData(item.Key);
string relFileName = itemData.fileName + itemData.fileType;
if (string.Equals("GameObject", itemData.fileType))
{
sb.AppendLine($"\t\t {relFileName} =target.transform.Find(\"{item.Value}\").gameObject;");
}
else if (string.Equals("Transform", itemData.fileType))
{
sb.AppendLine($"\t\t {relFileName} =target.transform.Find(\"{item.Value}\").transform;");
}
else
{
sb.AppendLine($"\t\t {relFileName} =target.transform.Find(\"{item.Value}\").GetComponent<{itemData.fileType}>();");
}
}
sb.AppendLine("\t");
sb.AppendLine("\t");
sb.AppendLine("\t\t //组件事件绑定");
//得到逻辑类 WindowBase => LoginWindow
sb.AppendLine($"\t\t {name} mWindow=({name})target;");
//生成UI事件绑定代码
foreach (var item in objDataList)
{
string type = item.fileType;
string methodName = item.fileName;
string suffix = "";
if (type.Contains("Button"))
{
suffix = "Click";
sb.AppendLine($"\t\t target.AddButtonClickListener({methodName}{type},mWindow.On{methodName}Button{suffix});");
}
if (type.Contains("InputField"))
{
sb.AppendLine($"\t\t target.AddInputFieldListener({methodName}{type},mWindow.On{methodName}InputChange,mWindow.On{methodName}InputEnd);");
}
if (type.Contains("Toggle"))
{
suffix = "Change";
sb.AppendLine($"\t\t target.AddToggleClickListener({methodName}{type},mWindow.On{methodName}Toggle{suffix});");
}
}
sb.AppendLine("\t\t}");
sb.AppendLine("\t}");
if (!string.IsNullOrEmpty(nameSpaceName))
{
sb.AppendLine("}");
}
return sb.ToString();
}
3.代码生成预览窗口和代码递增生成
主要是编辑器拓展相关知识点
GeneratorFindComponentTool继承Editor类
[MenuItem]特性
[MenuItem()] 是 Unity 编辑器的一个特性,它可以将自定义方法添加到 Unity 编辑器中的菜单栏中。
例如:
[MenuItem(“GameObject/生成组件查找脚本(Shift+F) #F”, false,0)]
在点击这个选项之后就会执行特性标注的方法
我们获取到选中的当前物体,设置脚本的生成路径
根据我们在GeneratorConfig的设置,判断是使用哪种解析方式
运行CreatCS方法获得模板
最后写入脚本到指定的路径
窗口预览:UIWindowEditor类
UIWindowEditor : EditorWindow
UIWindowEditor类继承了EditorWindow,这个相较于前面继承的Editor类,我在这里简单说明一下两者的作用和区别:
- EditorWindow类是用于创建独立窗口的基类
- Editor类是用于拓展和定制Unity内置组件的编辑器功能
- 通过 ShowWindow 静态方法创建代码展示窗口,传入要展示的代码内容和生成的脚本路径。
- 在窗口中绘制一个可滚动的文本区域,展示传入的代码内容。
- 在窗口中绘制一个文本区域,展示生成的脚本文件路径。
- 在窗口中绘制一个按钮,点击按钮可以生成脚本文件并关闭窗口。
- 实现了 GetInsertIndex 方法,用于获取插入代码的位置。在这个方法中,通过正则表达式匹配找到代码中 “UI组件事件” 下面第一个 public 的位置,并返回此位置。
- 如果传入了 insertDic, 则会按照字典中的键值对,将没有的代码插入到原始代码中。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.IO;
using System;
using System.Text.RegularExpressions;
public class UIWindowEditor : EditorWindow
{
private string scriptContent;
private string filePath;
private Vector2 scroll=new Vector2 ();
/// <summary>
/// 生成展示WINDOW窗口
/// </summary>
/// <param name="content"></param>
/// <param name="filePath"></param>
/// <param name="insertDic">创建的ui的方法字典</param>
public static void ShowWindow(string content,string filePath,Dictionary<string,string> insertDic=null)
{
//创建代码展示窗口
UIWindowEditor window = (UIWindowEditor)GetWindowWithRect(typeof(UIWindowEditor), new Rect(100, 50, 800, 700), true, "Window生成界面");
window.scriptContent = content;
window.filePath = filePath;
//新增代码的处理
if(File.Exists(filePath)&&insertDic!=null)
{
//先获取原始的代码
string originScript=File.ReadAllText(filePath);
foreach (var item in insertDic)
{
//如果老代码没有就插入代码
if(!originScript.Contains(item.Key))
{
int index = window.GetInsertIndex(item.Key);
originScript=window.scriptContent=originScript.Insert(index,item.Value+"\t\t");
}
}
}
window.Show();
}
public void OnGUI()
{
//绘制ScroView
scroll= EditorGUILayout.BeginScrollView(scroll,GUILayout.Height(600),GUILayout.Width(800));
EditorGUILayout.TextArea(scriptContent);
EditorGUILayout.EndScrollView();
EditorGUILayout.Space();
//绘制脚本生成路径
EditorGUILayout.BeginHorizontal();
EditorGUILayout.TextArea("脚本生成路径:" + filePath);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
//绘制按钮
EditorGUILayout.BeginHorizontal();
if(GUILayout.Button("生成脚本",GUILayout.Height(30)))
{
//按钮事件
ButtonClick();
}
EditorGUILayout.EndHorizontal();
}
private void ButtonClick()
{
if(File.Exists(filePath))
{
File.Delete(filePath);
}
StreamWriter writer = File.CreateText(filePath);
writer.Write(scriptContent);
writer.Close();
AssetDatabase.Refresh();
if (EditorUtility.DisplayDialog("自动化生成工具", "生成脚本成功", "确定"))
{
Close();
}
}
/// <summary>
/// 获取插入代码的下标
/// </summary>
/// <param name="content"></param>
/// <returns></returns>
public int GetInsertIndex(string content)
{
//找到UI组件事件下面的第一个public位置进行插入
//先声明正责表达式
Regex regex = new Regex("UI组件事件");
Match match=regex.Match(content);
Regex regex1 = new Regex("public");
MatchCollection matchCollection=regex1.Matches(content);
//找到所有的public的索引,然后遍历去找到比match的索引大的那个
for(int i=0;i<matchCollection.Count;i++)
{
if (matchCollection[i].Index>match.Index)
{
return matchCollection[i].Index;
}
}
return -1;
}
}
4.自动化生成绑定组件脚本(Bind)
首先创建一个脚本:
GeneratorBindComponentTool
该脚本和Find方式大同小异,前面类似的部分不再赘述
不同点:
Bind脚本因为不需要通过Find去绑定组件,所以没有objFindPathDic,也不用计算节点的查找路径
绑定脚本必须要等unity编译完成才能拖拽赋值,所以我们无法直接将组件和对应的数据绑定
解决方法:
[UnityEditor.Callbacks.DidReloadScripts],这个特性会等编译完毕之后系统自动执行
注意:
在Unity的编辑器中,每次编译脚本之后,所有的编辑器类都会重新加载
所以在调用这个特性的方法的时候,临时对象已经被清空了
解决方法:
使用EditorPrefs存储需要调用的数据,等调用这个方法的时候再读取
[UnityEditor.Callbacks.DidReloadScripts]
AddComponentToWindow()方法
这个方式用于在编译结束后自动绑定UI组件
- 首先判断是否存在要处理的脚本类名,如果不存在则返回。
- 获取当前应用程序域中的所有程序集,并找到名为"Assembly-CSharp"的C#程序集。
- 根据类名获取要挂载的组件类型。
- 查找指定游戏对象,如果找不到,则尝试在"UIRoot"下找。
- 判断指定游戏对象是否已经挂载了要添加的组件,如果没有,则将其添加。
- 获取保存了对象数据列表的Json数据,并将其转换成List<EditorObjectData>类型。
- 获取要挂载组件的所有字段信息。
- 遍历字段信息列表,逐个匹配字段名与对象数据列表中的文件名和类型。
- 根据对象数据的Insid找到对应的游戏对象。
- 如果字段类型是GameObject,则直接赋值为对应的游戏对象;否则,获取游戏对象上的对应组件,并赋值给字段。
- 循环结束后,删除保存的脚本类名。
/// <summary>
/// 编译完成系统自动调用
/// </summary>
[UnityEditor.Callbacks.DidReloadScripts]
public static void AddComponentToWindow()
{
//如果当前不是生成数据脚本的回调,就不处理
string className= EditorPrefs.GetString("GeneratorClassName");
if (string.IsNullOrEmpty(className))
{
return;
}
//1.通过反射的方式,从程序集中找到这个脚本,把它挂在到当前的物体上
//获取所有的程序集
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
//找到C#程序集
var cSharpAssembly = assemblies.First(assembly => assembly.GetName().Name == "Assembly-CSharp");
//获取类所在的程序集路径
string relClassName = "UIFrameWork." + className;
Type classType=cSharpAssembly.GetType(relClassName);
if(classType == null)
{
return;
}
//获取要挂载的那个物体
string windowObjName = className.Replace("DataComponent", "");//去掉classname中多余的部分
GameObject windowObj = GameObject.Find(windowObjName);
if(windowObj == null)
{
windowObj = GameObject.Find("UIRoot/" + windowObjName);
if(windowObj==null)
{
return;
}
}
//先获取现窗口上有没有挂载该数据组件,如果没挂载再进行挂载
Component compt = windowObj.GetComponent(classType);
if(compt == null)
{
compt=windowObj.AddComponent(classType);
}
//2.通过反射的方式,遍历数据列表 找到对应的字段,赋值
//获取对象数据列表
var objDataList = JsonMgr.Instance.LoadData<List<EditorObjectData>>(GeneratorConfig.OBJDATALIST_KEY);
//获取脚本所有字段
FieldInfo[] fieldInfoList = classType.GetFields();
foreach (var item in fieldInfoList)
{
foreach (var objData in objDataList)
{
if (item.Name == objData.fileName + objData.fileType)
{
//根据Insid找到对应的对象
//EditorUtility.InstanceIDToObject这个方法是通过唯一ID找到对象
GameObject uiObject = EditorUtility.InstanceIDToObject(objData.insID) as GameObject;
//设置该字段所对应的对象
if (string.Equals(objData.fileType, "GameObject"))
{
item.SetValue(compt, uiObject);
}
else
{
item.SetValue(compt, uiObject.GetComponent(objData.fileType));
}
break;
}
}
}
EditorPrefs.DeleteKey("GeneratorClassName");
}
5.完善绑定,并且添加快捷键
(1)创建GeneratorWindowTool脚本
这个类是一个Unity编辑器工具的脚本,用于生成Window脚本。通过在Unity编辑器中选择一个GameObject,并点击菜单栏的"GameObject/生成Window脚本",可以自动生成一个与该GameObject关联的Window脚本。
这个脚本会自动生成一个C#类,该类继承自WindowBase,包含了一些声明周期函数(OnAwake、OnShow、OnHide、OnDestroy)和API函数。此外,还会根据选中的GameObject的子物体生成对应的UI组件事件,例如Button的点击事件、InputField的输入事件、Toggle的状态改变事件等。
生成的脚本会根据配置文件进行设置,可以选择生成类型(Bind或UIComponent)以及生成路径等。生成的脚本可以自动化地处理UI组件的绑定和事件处理,避免手动编写冗长重复的代码。
生成的脚本可以直接在Unity项目中使用,并且可以根据实际需求进行扩展和修改。
并且在使用Find和Bind查找绑定组件的时候,需要生成Window窗口脚本才算绑定成功不会报错
(2)给生成脚本创建快捷方式
7.自动化生成窗口加载路径
在Resources文件夹下,UIModule的资源加载路径写死在Window文件夹下,如果预制体更改位置则会导致加载失败,所以使用so文件来存储了预制体在resources文件夹下的目标文件夹位置,最后再使用这个动态加载
1.创建SO文件(WindowConfig)
- 创建一个包含基本WindowData的类,其中记录了窗口名称和路径。
- 使用List<WindowData>来存储所有的数据。
- 创建一个string数组,包含了所有可以放置预制体文件的文件夹。
- 创建一个名为GenerateWindowConfig()的方法,以检测预制体数量是否有变化。
- 遍历windowPathArray中指定的文件夹,找到所有后缀为.prefab的文件,并跳过后缀为.meta的文件。同时记录预制体数量。
- 如果记录的预制体数量与之前的列表长度相同,则直接返回,否则继续执行。
- 记录每个预制体的位置信息:通过Path API找到预制体的名称、计算文件的读取路径,并添加到WindowData中。最后将其添加到WindowDataList列表中。
- 循环执行步骤5-7,直到所有指定文件夹中的预制体都被处理完毕。
2.修改UIModule
在UIModule中声明WindowConfig,在Initialize方法中使用resources加载赋值,编译预处理命名,保证只会在编辑器模式下运行
//手机上不会调用
#if UNITY_EDITOR
mWindowConfig.GeneraWindowConfig();
#endif
在资源加载的方法中,通过windowconfig给出的方法修改原有的加载路径
3.WindoConfig源码
[CreateAssetMenu(fileName = "WindowConfig", menuName = "WindowConfig", order = 0)]
public class WindowConfig : ScriptableObject
{
//记录需要读取的文件夹
private string[] windowRootArray = new string[] {"Game","Hall","Window" };
public List<WindowData> windowDataList=new List<WindowData> ();
public void GeneraWindowConfig()
{
//检测预制体有没有新增,如果没有就不需要生成配置
int count = 0;
foreach (var item in windowRootArray)
{
string[] filePathArr = Directory.GetFiles(Application.dataPath + "/UIFrameWork/Resources/" + item, "*.prefab", SearchOption.AllDirectories);
foreach (var path in filePathArr)
{
if (path.EndsWith(".meta"))
{
continue;
}
count += 1;
}
}
if (count == windowDataList.Count)
{
Debug.Log("预制体个数没有发生改变,不生成窗口配置");
return;
}
windowDataList.Clear();
foreach (var item in windowRootArray)
{
//获取预制体文件夹读取路径
string floder = Application.dataPath + "/UIFrameWork/Resources/" + item;
//获取文件夹下的所有Prefab文件
string[] filePathArray = Directory.GetFiles(floder, "*.prefab", SearchOption.AllDirectories);
foreach (var path in filePathArray)
{
if (path.EndsWith(".meta"))
{
continue;
}
//获取预制体名字
string fileName = Path.GetFileNameWithoutExtension(path);
//计算文件读取路径
string filePath = item + "/" + fileName;
//填入文件名和路径
WindowData data = new WindowData { name = fileName, path = filePath };
windowDataList.Add(data);
}
}
}
public string GetWindowPath(string name)
{
foreach (var item in windowDataList)
{
if(string.Equals(item.name,name))
{
return item.path;
}
}
Debug.LogError(name + "配置文件中不存在,请检查预制体位置或者配置文件");
return "";
}
}
[System.Serializable]
public class WindowData
{
public string name;
public string path;
}
六、堆栈系统
管理UI弹出顺序
使用queue队列来有序管理
在最基础类设置一个ispopstack的判断和action监听事件(用来传入要运行的方法)
在出栈的时候运行,如果count大于0说明有窗口,将窗口出栈,并且实际弹出这个窗口
#region 堆栈系统
public void PushWindowToStack<T>(Action<WindowBase> popCallBack=null) where T:WindowBase,new()
{
T wndBase=new T();
wndBase.PopStackListener = popCallBack;
mWindowStack.Enqueue(wndBase);//压入队列
}
/// <summary>
/// 弹出堆栈中第一个弹窗
/// </summary>
public void StartPopFirstStackWindow()
{
if (mStartPopStackWidStats) return;
mStartPopStackWidStats = true;//开始进行堆栈弹出的流程
PopStackWindow();
}
/// <summary>
/// 压入并且弹出堆栈弹窗
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="popCallBack"></param>
public void PushAndPopStackWindow<T>(Action<WindowBase> popCallBack = null) where T : WindowBase, new()
{
PushWindowToStack<T>(popCallBack);
StartPopFirstStackWindow();
}
/// <summary>
/// 弹出堆栈中下一个窗口
/// </summary>
/// <param name="window"></param>
public void PopNextStackWindow(WindowBase window)
{
if(window!=null&&mStartPopStackWidStats&&window.isPopStack)
{
window.isPopStack = false;
PopStackWindow();
}
}
/// <summary>
/// 弹出栈的方法
/// </summary>
/// <returns></returns>
public bool PopStackWindow()
{
if(mWindowStack.Count > 0)
{
WindowBase window=mWindowStack.Dequeue();//窗口出栈
WindowBase popWindow=PopUpWindow(window);//弹出窗口
popWindow.PopStackListener = window.PopStackListener;
popWindow.isPopStack = true;
popWindow.PopStackListener?.Invoke(popWindow);
popWindow.PopStackListener = null;
return true;
}
else
{
mStartPopStackWidStats = false;
return false;
}
}
public void ClearStackWindows()
{
mWindowStack.Clear();
}
#endregion
七、UI高性能系统
(1)功能特点
- 一键优化合批(自动根据图集图片和相邻组件的特征进行重新排序)
- 避免使用使用SetActive引起的UI重绘和GC垃圾(用CanvasGroup和Scale代替)
- 使用UI对象池(避免频繁的克隆物体导致的卡顿和GC)
- 智能化禁用不必要的组件属性(避免不必要的性能开销)
- 界面预加载(针对一些比较复杂的界面可以提前加载物体,确保使用界面的时候,能够流畅的加载出界面)
- 高性能文字描边(Unity描边组件是拷贝4份相同的文本定点数,占用量大,一个字母的Text加上Unity的描边一共占用30个顶点.优化后一个字母加描边只占用6个顶点)
- 组件自动序列化(避免使用Find接口查找组件带来的性能消耗,而使用自动化序列的方式拿到组件,将性能消耗尽可能得降至最低)
ZMUIFrameWrok教程并没有给出高性能文字和使用UI对象池这部分,所以略过
(2)窗口预加载
没有什么特别的,只是提前加载好窗口,并且设置不显示
(3)性能核心、网格重建剖析和优化
[1]优化CanvasGrounp
直接使用setactive控制ui的显隐会导致触发网格重建,导致不必要的性能消耗
解决方法:给每个window都添加CanvasGrounp,控制alpha值调整显隐,并且在隐藏的时候取消raycast
在windowbase控制显隐的时候使用alpha更改
编写一个UGUIAgent,创建拓展方法,给对应的button组件之类的设置拓展方法(Setactive),通过控制localScale来控制显隐
[2]智能禁用RaycastTarget
[InitializeOnLoadMethod] 是 Unity 引擎中的一个编辑器扩展特性(Attribute),它可以用来标记一个静态方法,在 Unity 编辑器中加载时自动执行。
EditorApplication.hierarchyChanged+=xxx;
//Hierarchy窗口变化回调(创建对象以及对其进行重命名、重定父级或销毁,以及加载、卸载、重命名或重新排序已加载的场景)//Hierarchy窗口变化回调(创建对象以及对其进行重命名、重定父级或销毁,以及加载、卸载、重命名或重新排序已加载的场景)
通过这个事件添加方法修改,用Selection.activeGameObject;找到选中的物体,然后通过找到的这个obj的name对比,如果是不需要raycasttarget的组件就禁用
(4)使用演示
在Github搜索:UGUI-Editor,下载导入项目中即可使用
右键点击优化Batch即可使用
额外内容
ZMUIFrame经过测试可以直接绑定TextMeshPro的button等组件,但是text不能直接绑定,所以要作出一些修改:
以GeneratorBindComponentTool脚本为例,需要在使用tag搜索的方法中对原本的代码作出一点修改:
string fileType = tagName;//获取字段类型
这部分修改为:
string fileType = tagName == "Text(TMP)" ? "TextMeshProUGUI" : tagName; //根据标签修改字段类型
使用的时候添加一个tag为Text(TMP)即可,注意要去GeneratorConfig脚本中的TAGArr也添加这个tag