前言
现在假设有这么一种情况:假设我们要做一个加载登录场景,其效果是:进入游戏时,显示一个加载的界面,界面上有进度条,此时会异步加载另一个场景,进度条随着加载而移动,场景加载完毕时,加载界面消失,同时在已加载的场景中显示注册登录界面。
那么我们该如何实现上述效果呢?
基本思路如下:
这里牵涉 3 个模块的交互,总管理模块负责初始化公共服务模块 (这里用到的是资源加载服务模块) 和单个业务系统模块 (这里是登录注册业务模块)。
随后从登录注册业务模块提供的方法来实现加载场景,加载的时候分三步:
一,展示加载界面。
二,异步加载场景并显示进度。
三,取消展示加载界面并展示登录注册界面。
注意,比较特殊的是某些窗口管理脚本并不归属于任何一个业务模块,比如加载界面管理,又如动态窗口展示界面管理,这两种界面是每个业务模块都有可能用到的,它其实类似于公共服务模块,但由于它们与某个 UI 界面关联,因此可以直接由总管理模块来控制。
那么现在问题来了:
显示加载界面的代码放哪里?
由于牵涉到模块的交互,因此显示加载界面的代码即可以放在登录注册业务模块中,也可以放在资源加载服务模块中,但由于其他业务模块也会用到加载服务,也要显示加载界面,因此,最好把显示加载界面的代码放在资源加载服务模块中。
注意,要显示加载界面,就要取到加载界面对应窗口脚本的引用,由于加载界面直接由总管理模块控制,因此不要在资源加载服务模块中持有此脚本的引用,而是要通过总管理模块间接访问加载界面,这样层次更清晰。
异步加载场景在资源加载服务模块中完成,怎么实时更新进度条呢?
基本思路是调用资源加载函数后,用 Update,在每一帧观测加载的进度 (加载函数的调用有个返回值,里面存着加载进度属性) 并进行 UI 的更新。
注意,这里很重要的一点是,我们怎么在 Update 里获取到加载函数调用的返回值,难道是以参数形式把加载函数调用的返回值传递给 Update 吗?
并不是。这就体现出委托的重要性了,我们可以在加载函数调用时设置一个委托方法,在委托方法里去查看这个返回值的加载进度,并进行 UI 更新,而 Update 里只进行委托的调用。
这里又有一个问题:登录界面怎么展示?
难道在资源加载服务模块中持有登录界面窗口管理脚本的引用吗?
当然不是,登录界面窗口是属于登录注册业务模块的,因此肯定要通过这个模块去访问此界面的脚本引用。
那是否可以把登录注册业务模块设置成单例的,在资源加载服务模块中去调用它,随后去进行登录界面展示呢?
也不是,因为这样的话,其他业务系统中要使用资源加载服务,到最后也会展示登录注册窗口界面,显然不行。怎么办?
答案也是使用委托,由于每个业务系统在使用加载服务完成后要显示的界面不同,因此可以在业务模块调用资源加载服务时,传入一个委托,该委托负责打开业务模块自己持有管理的一些界面,相应地,资源加载服务模块只要在加载完成后,调用此委托即可。
正文
代码架构展示
以上介绍了游戏开发的基本架构,下面以实现异步加载登录场景的例子进行说明,相关代码如下:
(为了方便说明,我们只展示代码中的和架构有关的部分)
//总管理脚本,它直接管理公共的加载进度界面,各个模块初始化完成后 (注意初始化顺序),通过登录注册业务模块提供的方法,开始加载场景。 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(); } }
//登录注册业务模块,它直接管理的 UI 窗口是登录注册界面,要加载场景时,它会去调用资源加载服务模块,因此资源加载服务模块必须是单例的,加载完成后通过匿名方法传递委托给资源加载服务模块,从而实现登录注册界面的显示。 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(); }); }
//资源加载服务模块,注意异步加载时它会通过委托实现每过一帧检测进度,设置进度条时需要用到总管理脚本下的公用的加载进度界面,因此需要通过 GameRoot 去访问,加载完成后取消对加载进度页面的展示,同时调用传入的回调委托,实现特定功能,比如登录注册业务模块为它传入的切换登录注册界面功能。 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(); } } }
//加载进度界面的管理脚本,由于它和 Unity 中的一些 UI 关联了,这里我们重点介绍架构,因此无需理解它里面做了什么,只需要知道它有一个自己的初始化方法和设置进度方法就行了。 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 关联了,这里我们重点介绍架构,因此无需理解它里面做了什么,只需要知道它有一个自己的初始化方法就行了。 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 = ""; } } }
注意,这篇文章的重点是讲述架构的优化技巧,因此我们不详细描述异步加载进度条的具体实现,先熟悉下基本架构就够了。
那么重点来了,上面这个架构怎么进行优化呢?又在哪里优化呢?
要知道,每个界面窗口,比如上面的加载界面和登录注册界面,都有自己的窗口管理脚本,而当我们控制某个界面进行展示的时候,是通过其脚本获取对此界面的引用,从而将它展示的。
但是,每个界面要做的初始化工作是不一样的,比如加载界面要将进度条置 0,比如登录注册界面要获取本地的账号密码,这些初始化工作,都是由该界面的窗口管理脚本提供的,而我们在每个地方调用并将界面显示出来时 (比如上面的例子就是在资源加载服务模块中显示加载界面和登录注册界面),都要手动地去调用管理脚本提供的初始化方法.
仔细想想,其实这不大合理,毕竟我只要用到这个界面的展示,或者这个界面脚本提供的一些功能,我不应该还要负责界面的初始化工作的调用。
怎么解决这个问题?
答案就是抽取出一个窗口基类,所有界面管理脚本都继承自这个窗口基类,将界面的初始化方法与设置界面是否激活绑定在一起,并向外部提供这个设置显示状态的方法,这样,当外部 (比如资源加载服务模块) 调用显示某个界面时 (比如加载界面),只要调用这个设置显示状态方法就行.
那如何让不同的界面管理脚本有特定的初始化方法呢?
办法就是利用多态,将窗口基类中的初始化方法设为虚函数,在每个界面管理脚本中去覆写。而初始化方法的调用因为与设置显示状态的方法被绑定在一起,因此是在窗口基类中的设置显示状态的方法中被调用的,这样的话这些初始化方法权限设置为 protected 就行了,只给子类调用,外部访问不到它,也有保障安全性的作用。
优化后窗口基类代码如下:
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() { } }