Unity-UI架构优化小技巧
此篇总结自siki学院的<<暗黑战神>>课程
上一篇文章介绍过一种常用的游戏开发架构:https://www.cnblogs.com/czw52460183/p/11010971.html,现在我们基于这种架构记录一种优化小技巧。
先来看下上一篇文章的重点:
一种常见的游戏开发架构思路是创建一个空物体,将一个总管理模块的脚本挂在此物体下,它负责启动游戏并初始化各个模块,根据游戏中各个部分功能的不同,不同的脚本大致可分为公共服务模块和单个业务系统模块,公共服务模块会向系统中的所有模块提供一些公共服务,比如资源加载,音频播放,网络服务等等。
单个业务系统模块分为很多种,比如登录系统业务模块,战斗业务模块,副本业务模块等等,各个单独业务模块负责管理本业务相关的脚本,比如某些UI窗口下是某个业务模块的界面,每个UI窗口下都会有对应的窗口管理脚本,这些窗口管理脚本就是由对应归属的单个业务系统模块脚本来操控的,反映到代码上,就是这些业务模块脚本会持有这些窗口管理脚本的引用。
而公共服务模块和单个业务系统模块,是统一由管理模块初始化的,因此管理模块中需要持有对这两个模块的引用,我们可以不使用拖拽的方式来实现,可以将这两个模块与管理模块一起,放在空物体下,管理模块初始化时用代码获取这两个模块的引用,并控制这两个模块的初始化顺序。
注意,比较特殊的是某些窗口管理脚本并不归属于任何一个业务模块,比如加载界面管理,又如动态窗口展示界面管理,这两种界面是每个业务模块都有可能用到的,它其实类似于公共服务模块,但由于它们与某个UI界面关联,因此可以直接由总管理模块来控制。
现在假设有这么一种情况:假设我们要做一个加载登录场景,效果是:进入游戏时,显示一个加载的界面,界面上有进度条,此时会异步加载另一个场景,进度条随着加载而移动,场景加载完毕时,加载界面消失,同时在已加载的场景中显示注册登录界面。
如何实现上述效果?
基本思路如下:这里牵涉3个模块的交互,总管理模块负责初始化公共服务模块(这里用到的是资源加载服务模块)和单个业务系统模块(这里是登录注册业务模块)。随后从登录注册业务模块提供的方法来实现加载场景,加载的时候分三步:一,展示加载界面。二,异步加载场景并显示进度。三,取消展示加载界面并展示登录注册界面。
之前说了,资源加载服务是公共服务模块,因此在登录注册业务模块中可以以单例形式去调用并使用这个服务,那么现在问题来了:
显示加载界面的代码放哪里?
由于牵涉到模块的交互,因此显示加载界面的代码即可以放在登录注册业务模块中,也可以放在资源加载服务模块中,但由于其他业务模块也会用到加载服务,也要显示加载界面,因此,最好把显示加载界面的代码放在资源加载服务模块中。
注意,要显示加载界面,就要取到加载界面对应窗口脚本的引用,由于加载界面直接由总管理模块控制,因此不要在资源加载服务模块中持有此脚本的引用,而是要通过总管理模块间接访问加载界面,这样层次更清晰。
异步加载场景在资源加载服务模块中完成,怎么实时更新进度条呢?
基本思路是调用资源加载函数后,用Update,在每一帧观测加载的进度(加载函数的调用有个返回值,里面存着加载进度属性)并进行UI的更新。注意,这里很重要的一点是,我们怎么在Update里获取到加载函数调用的返回值,难道是以参数形式把加载函数调用的返回值传递给Update吗?不是的,这就体现出委托的重要性了,我们可以在加载函数调用时设置一个委托方法,在委托方法里去查看这个返回值的加载进度,并进行UI更新,而Update里只进行委托的调用。
发现没,委托最神奇的一点就是可以帮助我们在一个函数中,以不传值的形式调用另一函数,而且可以用到另一函数中的临时变量!!
其实这里面的机制我到现在也不大明白,似乎类似于协程?不管怎么说,就像我在https://www.cnblogs.com/czw52460183/p/10494285.html中说的一样,这种机制为程序设计提供了很大的便利,但似乎也有着破环封装性的危险,毕竟另一函数,即使是某个类的私有方法,一样能通过委托,在另一个类中被调用。
更新完进度条后还要检测进度是否完成,若完成,显然要清空委托,防止在Update中继续被调用,同时要取消展示加载界面,并展示登录界面,这里又有一个问题:登录界面怎么展示?难道在资源加载服务模块中持有登录界面窗口管理脚本的引用吗?
当然不是,登录界面窗口是属于登录注册业务模块的,因此肯定要通过这个模块去访问此界面的脚本引用。
那是否可以把登录注册业务模块设置成单例的,在资源加载服务模块中去调用它,随后去进行登录界面展示呢?
也不是,因为这样的话,其他业务系统中要使用资源加载服务,到最后也会展示登录注册窗口界面,显然不行。怎么办?
答案也是使用委托,由于每个业务系统在使用加载服务完成后要显示的界面不同,因此可以在业务模块调用资源加载服务时,传入一个委托,该委托负责打开业务模块自己持有管理的一些界面,相应地,资源加载服务模块只要在加载完成后,调用此委托即可。
以上就是游戏开发的基本架构,举了实现异步加载登录场景的例子进行说明,相关代码如下:
/**************************************************** 文件:GameRoot.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/12 22:40:42 功能:游戏启动入口 *****************************************************/ using UnityEngine; public class GameRoot : MonoBehaviour { //GameRoot可以被各系统用来访问公共界面,所以设置成单例 public static GameRoot Instance = null; //加载进度界面和动态元素界面是公用的,由GameRoot持有 public LoadingWnd loadingWnd; private void Start() { Instance = this; //切换场景时为了防止本场景物体被销毁,手动指定GameRoot不销毁,且把所有UI元素挂在其下保护起来 DontDestroyOnLoad(this); Debug.Log("游戏启动..."); Init(); } //初始化各个模块 private void Init() { //初始化资源加载服务模块 ResSvc res = GetComponent<ResSvc>(); res.InitSvc(); //初始化登录注册业务系统模块 LoginSys login = GetComponent<LoginSys>(); login.InitSys(); //进入登录场景并加载相应UI login.EnterLogin(); } }
为了方便说明,我们只展示代码中的和架构有关的部分:
上面的是总管理脚本,它直接管理公共的加载进度界面,各个模块初始化完成后(注意初始化顺序),通过登录注册业务模块提供的方法,开始加载场景。
/**************************************************** 文件:LoginSys.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/12 22:47:56 功能:登录注册业务模块 *****************************************************/ using UnityEngine; public class LoginSys : MonoBehaviour { //单例 public static LoginSys Instance = null; //登录注册业务模块下有登录注册界面 public LoginWnd loginWnd; //初始化模块 public void InitSys() { Instance = this; Debug.Log("登录注册业务模块加载完毕..."); } /// <summary> /// 进入登录场景 /// </summary> public void EnterLogin() { //加载登录场景 ResSvc.Instance.AsyncLoadScene(Constants.SceneLogin,()=>{ //加载完成后打开登录注册界面 loginWnd.gameObject.SetActive(true); //初始化登录注册界面 loginWnd.InitWnd(); }); }
上面的是登录注册业务模块,它直接管理的UI窗口是登录注册界面,要加载场景时,它会去调用资源加载服务模块,因此资源加载服务模块必须是单例的,加载完成后通过匿名方法传递委托给资源加载服务模块,从而实现登录注册界面的显示。
/**************************************************** 文件:ResSvc.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/12 22:47:38 功能:带进度条的显示资源加载服务模块 *****************************************************/ using System; using UnityEngine; using UnityEngine.SceneManagement; public class ResSvc : MonoBehaviour { //单例 public static ResSvc Instance = null; //初始化模块 public void InitSvc() { Instance = this; Debug.Log("资源加载服务模块加载完毕..."); } Action prgCB = null; //异步加载场景方法 public void AsyncLoadScene(string sceneName,Action loaded) { //显示加载界面 GameRoot.Instance.loadingWnd.gameObject.SetActive(true); GameRoot.Instance.loadingWnd.InitWnd(); //异步加载指定名字的场景 AsyncOperation sceneAsync = SceneManager.LoadSceneAsync(sceneName); prgCB = ()=> { //获取当前进度 float val = sceneAsync.progress; //在加载界面设置当前进度 GameRoot.Instance.loadingWnd.SetProgress(val); //加载完成 if(val == 1) { //加载完成后调用回调函数 if(loaded != null) { loaded(); } //清空委托和中间结构 sceneAsync = null; prgCB = null; //取消对加载界面的展示 GameRoot.Instance.loadingWnd.gameObject.SetActive(false); } }; } //加载开始后,每一帧检测进度 private void Update() { if(prgCB != null) { prgCB(); } } }
上面的是资源加载服务模块,注意异步加载时它会通过委托实现每过一帧检测进度,设置进度条时需要用到总管理脚本下的公用的加载进度界面,因此需要通过GameRoot去访问,加载完成后取消对加载进度页面的展示,同时调用传入的回调委托,实现特定功能,比如登录注册业务模块为它传入的切换登录注册界面功能。
/**************************************************** 文件:LoadingWnd.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/16 12:44:28 功能:加载进度界面 *****************************************************/ using UnityEngine; using UnityEngine.UI; public class LoadingWnd : MonoBehaviour { //Tips文字 public Text txtTips; //进度条图片 public Image loadingFg; //进度条滑点 public Image imgPoint; //进度条文字 public Text txtPrg; //初始化加载进度界面 protected override void InitWnd() { //初始化Tips文字 txtTips.text = "这是一条游戏Tips"; //进度条归零 loadingFg.fillAmount = 0; //初始化进度条文字 txtPrg.text = "0%"; //初始化进度条点位置 imgPoint.transform.localPosition = new Vector3(-508f, 0, 0); } //设置进度 public void SetProgress(float prg) { //设置进度条 loadingFg.fillAmount = prg; //设置进度条文字(转换成百分比显示,且忽略小数) txtPrg.text = (int)(prg * 100) + "%"; //设置进度条点位置(教程里设置的是recttransform里的anchoredPosition) imgPoint.transform.localPosition = new Vector3(loadingFg.rectTransform.sizeDelta.x * prg - 508f, 0,0); } }
上面的是加载进度界面的管理脚本,由于它和Unity中的一些UI关联了,这里我们重点介绍架构,因此无需理解它里面做了什么,只需要知道它有一个自己的初始化方法和设置进度方法就行了。
/**************************************************** 文件:LoginWnd.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/16 16:30:3 功能:登录注册界面 *****************************************************/ using UnityEngine; using UnityEngine.UI; public class LoginWnd : MonoBehaviour { public InputField iptAcct; public InputField iptPass; public Button btnNotice; public Button btnEnter; //初始化登录注册界面 public void InitWnd() { //获取本地存储的账号与密码 if(PlayerPrefs.HasKey("Acct") && PlayerPrefs.HasKey("Pass")) { iptAcct.text = PlayerPrefs.GetString("Acct"); iptPass.text = PlayerPrefs.GetString("Pass"); } else { iptAcct.text = ""; iptPass.text = ""; } } }
最后是登录注册界面管理脚本,同样的,由于它和Unity中的一些UI关联了,这里我们重点介绍架构,因此无需理解它里面做了什么,只需要知道它有一个自己的初始化方法就行了。
注意,这篇文章的重点是讲述架构的优化技巧,因此我们不详细描述异步加载进度条的具体实现,等有时间可以具体分析下代码,但就我们马上要记录的架构优化而言,先熟悉下基本架构就够了。
那么重点来了,上面这个架构有什么可以优化的地方吗?
当然,要知道,每个界面窗口,比如上面的加载界面和登录注册界面,都有自己的窗口管理脚本,而当我们控制某个界面进行展示的时候,是通过其脚本获取对此界面的引用,从而将它展示的。但是,每个界面要做的初始化工作是不一样的,比如加载界面要将进度条置0,比如登录注册界面要获取本地的账号密码,这些初始化工作,都是由该界面的窗口管理脚本提供的,而我们在每个地方调用并将界面显示出来时(比如上面的例子就是在资源加载服务模块中显示加载界面和登录注册界面),都要手动地去调用管理脚本提供的初始化方法,仔细想想,其实这不大合理,毕竟我只要用到这个界面的展示,或者这个界面脚本提供的一些功能,我不应该还要负责界面的初始化工作的调用。
怎么解决这个问题?
答案就是抽取出一个窗口基类,所有界面管理脚本都继承自这个窗口基类,将界面的初始化方法与设置界面是否激活绑定在一起,并向外部提供这个设置显示状态的方法,这样,当外部(比如资源加载服务模块)调用显示某个界面时(比如加载界面),只要调用这个设置显示状态方法就行,那如何让不同的界面管理脚本有特定的初始化方法呢?
办法就是利用多态,将窗口基类中的初始化方法设为虚函数,在每个界面管理脚本中去覆写这个初始化方法,而初始化方法的调用因为与设置显示状态的方法被绑定在一起,因此是在窗口基类中的设置显示状态的方法中被调用的,这样的话这些初始化方法权限设置为protected就行了,只给子类调用,外部访问不到它,也有保障安全性的作用。
那么,按照上面的思路,可以得到窗口基类代码如下:
/**************************************************** 文件:WindowRoot.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/18 21:14:1 功能:UI界面基类 *****************************************************/ using UnityEngine; public class WindowRoot : MonoBehaviour { //设置界面显示状态 public void SetWndState(bool isActive = true) { //只有界面当前状态和要显示的状态不同才要改变状态 if(gameObject.activeSelf != isActive) { gameObject.SetActive(isActive); } //显示后需要初始化 if(isActive) { InitWnd(); } //关闭显示后需要清理 else { ClearWnd(); } } //初始化方法,子类中覆盖实现 protected virtual void InitWnd() { } //清理方法,子类中覆盖实现 protected virtual void ClearWnd() { } }
注意,这个时候,初始化方法已经被封装起来了,在设置状态时会自动调用,而初始化方法设置成了虚函数,方便子类进行定制。
因此加载进度界面和登录注册界面的代码需要修改为:
/**************************************************** 文件:LoadingWnd.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/16 12:44:28 功能:加载进度界面 *****************************************************/ using UnityEngine; using UnityEngine.UI; public class LoadingWnd : WindowRoot { //初始化加载进度界面 protected override void InitWnd() { //实际操作代码 } //设置进度 public void SetProgress(float prg) { //实际操作代码 } }
/**************************************************** 文件:LoginWnd.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/16 16:30:3 功能:登录注册界面 *****************************************************/ using UnityEngine; using UnityEngine.UI; public class LoginWnd : WindowRoot { //初始化登录注册界面 protected override void InitWnd() { //实际操作代码 } }
因为改动不大,因此我们仅用红字列出了发生改动的地方,注意现在初始化方法要对父类的初始化方法覆写,同时权限不再是公有,因为父类中权限为protected,所以子类中无法设置成public,因此此时初始化方法只能被设置状态时自动调用,无需也不能在外部手动调用了。
此时无需改动GameRoot,但登录注册业务模块和资源加载服务模块对界面的调用也应该发生改变:
/**************************************************** 文件:ResSvc.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/12 22:47:38 功能:带进度条的显示资源加载服务模块 *****************************************************/ using System; using UnityEngine; using UnityEngine.SceneManagement; public class ResSvc : MonoBehaviour { public void AsyncLoadScene(string sceneName,Action loaded) { //显示加载界面 GameRoot.Instance.loadingWnd.SetWndState(true); //异步加载指定名字的场景 AsyncOperation sceneAsync = SceneManager.LoadSceneAsync(sceneName); prgCB = ()=> { //获取当前进度 float val = sceneAsync.progress; //在加载界面设置当前进度 GameRoot.Instance.loadingWnd.SetProgress(val); //加载完成 if(val == 1) { //加载完成后调用回调函数 if(loaded != null) { loaded(); } //清空委托和中间结构 sceneAsync = null; prgCB = null; //取消对加载界面的展示 GameRoot.Instance.loadingWnd.SetWndState(false); } }; } }
文件:LoginSys.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/12 22:47:56 功能:登录注册业务模块 *****************************************************/ using UnityEngine; public class LoginSys : MonoBehaviour { public void EnterLogin() { //加载登录场景 ResSvc.Instance.AsyncLoadScene(Constants.SceneLogin,()=>{ //加载完成后打开登录注册界面 loginWnd.SetWndState(true); }); } }
同样地,我们也省略了没有修改的部分,用红字标出了要修改的代码。
你可能会说,代码好像也不见减少了很多啊,这样改进有意义吗?
有的,因为其实每个窗口界面里也有可能会用到公共服务(如资源加载服务模块),如果不作改进,那我们每个窗口界面中都要去获取资源加载服务模块的单例并调用功能,这样改进之后,我们可以在基类窗口脚本中持有对此服务模块的引用,随后在基类初始化方法中对引用赋值,然后在每个窗口的自定义初始化函数中多调用下基类的初始化方法,就可以进一步精简代码,架构也更加清晰。
代码如下:
文件:WindowRoot.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/18 21:14:1 功能:UI界面基类 *****************************************************/ using UnityEngine; public class WindowRoot : MonoBehaviour { //每个窗口都配置一些公用服务模块 protected ResSvc resSvc = null; //设置界面显示状态 public void SetWndState(bool isActive = true) { //.... } //初始化方法,子类中覆盖实现 protected virtual void InitWnd() { //初始化公用服务模块 resSvc = ResSvc.Instance; } //清理方法,子类中覆盖实现 protected virtual void ClearWnd() { resSvc = null; } }
/**************************************************** 文件:LoadingWnd.cs 作者:czw52460183 邮箱: czw52460183@163.com 日期:2019/6/16 12:44:28 功能:加载进度界面 *****************************************************/ using UnityEngine; using UnityEngine.UI; public class LoadingWnd : WindowRoot { //初始化加载进度界面 protected override void InitWnd() { //调用基类初始化方法来配置公共服务模块 base.InitWnd(); //... } //设置进度 public void SetProgress(float prg) { //... }
红字标出的就是要修改的地方,功能已经说过了,就不说了,同理,其他界面,如登录注册界面,也可以这么改。
完结。