本文进度:界面切换&音频播放。
0 前言
一直觉得自己的代码写得不够“漂亮”。展开来说就是一些数据结构、设计原则、设计模式只知道理论,但不能很好地实践到工程中。所以近期开始看一些dalao的源码,学习一些开发思路,同时巩固一下理论知识。
大概每天都会更新一些新学到的东西。
1 单例模式
1.1 单例模式
在Unity中我们用到的最多的设计模式之一就是单例模式。单例模式(Singleton)保证一个类仅有一个实例,并提供一个访问它的全局访问点。
需要注意的是:
- 单例类只能有一个实例。
- 单例类必须自己创建自己的唯一实例。
- 单例类必须给所有其他对象提供这一实例。
以下为三种常见单例模式:
1.1.1 懒汉式单例
public class SingletonClass{
private static SingletonClass instance;
//私有化构造函数,保证实例为单例类自己创建
private SingletonClass(){
}
public static Singleton Instance{
get{
if(instance==null) instance = new SingletonClass();
return instance;
}
}
}
直到对象请求实例时才执行实例化,这样的方法称为懒汉单例化。懒汉单例化避免了在程序启动时实例化不必要的单例,避免了内存浪费。
然而,这种实现的主要缺点是在多线程下不安全。如果独立的执行线程同时进入Instance属性方法,那么可能会创建多个SingletonClass对象的实例。
有许多方法可以解决这个问题。其中一种是使用叫双重检查锁(double-check locking)的惯用法。然而,C#结合CLR(公共语言运行库)提供了一种静态初始化(static initialization)方法,它可以避免这些问题,而不需要开发者显式地编写线程安全代码。
1.1.2 饿汉式单例
public sealed class SingletonClass{
private static readonly SingletonClass instance = new SingletonClass();
private SIngletonClass(){
}
public static SingletonClass Instance{
get{
return instance; }
}
}
静态初始化方法又称为饿汉单例化。在这种方法中,当类中任何成员第一次被引用时,实例会被创建(与静态变量的特性有关)。CLR负责变量初始化。该类标记为sealed以防止派生,因为派生可能会增加实例。此外,变量被标记为readonly,这意味着只能在静态初始化期间(此处显示的)或在类构造函数中对其赋值。
1.1.3 双重检查锁
静态初始化对于大多数情况是可行的。但当程序必须延迟实例化,使用非默认构造函数或在实例化之前执行其他任务,和在多线程环境中工作时(存在着不能依赖CLR确保线程安全的情况),你就需要别的解决方案了。
其中一种解决方法是使用双重检查锁来隔离线程,避免同时创建单例的新实例。
public sealed class SingletonClass{
private static volatile SingletonClass instance;
private static object syncRoot = new Object();
private SingletonClass(){
}
public static SingletonClass Instance{
get{
//先判断再加锁,避免频繁加锁造成性能消耗
if(instance==null){
lock(syncRoot){
//加锁后再判断,避免在等待锁的过程中变量已被修改。
if(instance==null) instance = new SingletonClass();
}
}
}
}
}
这种方法确保只创建一个实例,并且只在需要实例时创建。另外,将变量声明为volatile(volatile关键字指示一个字段可以由多个同时执行的线程修改),以确保在访问实例变量之前完成对实例变量的赋值,解决程序运行中带来的一些指令重排问题。最后,这种方法使用syncRoot实例来上锁,而不是锁住类型本身,以避免死锁。
这种双重检查锁的方法解决了线程并发性问题,同时避免了在每次调用Instance属性方法时使用独占锁。它还允许你延迟实例化,直到对象第一次被访问。事实上,程序很少使用这种实现。在大多数情况下,静态初始化方法就足矣。
1.2 Unity单例写法
在这里记录两种Unity内常用的单例模式的写法。
1.2.1 普通单例
普通单例在Awake()中实现。在Unity中Awake()方法一般在Start()方法前调用,而我们一般在Start()中获取物体组件或者对字段初始化赋值,因此我们在Awake()中实现单例模式的主要逻辑。
public class SingletonClass : MonoBehaviour{
public static SingletonClass Instance;
public void Awake(){
if(Instance==null) Instance = this;
else Destory(gameObject);
}
}
1.2.2 通用单例模式
利用泛型使类模板化,通过让子类继承单例模式就可以让子类实现单例模式的功能。
//单例基类
public class SingletonBaseClass<T> : MonoBehaviour{
public static T Instance;
public void Awake(){
if(Instance==null) Instance = this;
else Destory(gameObject);
}
}
//单例子类
public class SingletonClass<T> : SingletonBaseClass<SingletonClass>{
...
}
其中
if(Instance==null) Instance = this;
else Destory(gameObject);
也可以写为:
//返回SingletonClass类型第一个激活的加载的物体。
if(Instance==null) Instance = FindObjectOfType(typeof(SingletonClass)) as SingletonClass;
但需要注意的是,以上的代码都没有考虑多线程的情况,当类在不同线程创建时这种写法的单例模式会失效。
2 委托(delegate)
2.1 概述
为了实现方法参数化,提出了委托的概念。委托是一种类,是一种引用类型,可以指向一个或者多个方法。在委托对象的引用中存放的不是数据的引用,而是方法的引用。存储的方法要求类型兼容(即返回值和参数与委托相同)。
2.2 自定义委托
2.2.1 声明格式
public delegate void Mydelegate(); //该委托类型可以指向任何一个返回值为空,参数列表为空的其他方法。
2.2.2 委托的订阅
1.单播委托
一个委托封装了一个方法的形式叫做【单播委托】。
mydelegate = new Mydelegate(Method); //完整订阅格式
mydelegate = Method; //简洁订阅格式,“=”可以用“+=”代替。
2.多播委托
一个委托封装了多个方法叫做【多播委托】。
mydelegate = MethodA;
mydelegate += MethodB;
mydelegate += MethodC;
多播委托切记只有第一个方法的订阅可以使用赋值操作(即“=”),之后的订阅必须是“+=”,不然的话之后的订阅会覆盖之前的订阅。
3.简单例子
public class Test : MonoBehaviour{
public delegate int Mydelegate(int a,int b); //嵌套类
Mydelegate mydelegate;
private void OnEnable(){
mydelegate = Add;
mydelegate += Mutiply;
}
public void Update(){
if(Input.GetKeyDown(keyCode.Space)){
Debug.Log(mydelegate(2,3));
}
}
public int Add(int a,int b){
Debug.Log(a+b);
return a+b;
}
public int Mutiply(int a,int b){
Debug.Log(a*b);
return a*b;
}
}
输出:
5 //执行Add(2,3)
6 //执行Mutiply(2,3)
6 //执行Debug.Log(6)
2.3 Action&Func
Action<>和Func<>是C#自带的委托类型。
2.3.1 Action
Action委托一定指向一个无返回值的方法,参数可有可无。
//声明
Action action;
Action<参数> action;
代码示例:
public class Test : MonoBehaviour{
//指向一个无返回值,参数为string的方法
Action<string> action;
//OnEnable和Awake/Start的区别在于,当挂载脚本的游戏物体被取消激活再重新激活的时候,脚本的Awake/Start都不会重新执行,而OnEnable会重新在第一帧执行一次。
private void OnEnable(){
action = SayHello;
}
void Update(){
//用户按下空格时
if(Input.GetKeyDown(keyCode.Space){
action("Ben"); //or action.Invoke("Ben");
}
}
private void SayHello(string name){
Debug.Log("Hello~"+name);
}
}
2.3.2 Func
Func<>一定指向一个有返回值的方法,参数可有可无。
//声明
Func<返回值> func;
Func<参数,参数,返回值> func;
2.4 常用用法
2.4.1 模板方法
有一处不确定,其余代码都是固定写好的,这个不确定的部分(参数)就是靠我们传进来的委托类型的参数所包含的方法来填补。由于这个方法一般需要返回值,所以一般都用Func委托作为模板方法。
举例:这个例子是为了实现输出玩家的夺旗数,死亡数和击杀数最高的玩家名称。
1.定义玩家基本信息类。
public class PlayerStatus{
public string playerName;
public int killNum,flagNum,deathNum;
}
2.声明一个委托类型。
public delegate int GetTopScoreDelegate(PlayerStatus player);
3.创建与委托类型兼容的方法。
public int GetKillNum(PlayerStatus player){
return player.killNum;
}
public int GetFlagNum(PlayerStatus player){
return player.flagNum;
}
public int GetDeathNum(PlayerStatus player){
return player.deathNum;
}
4.建立以委托类型为参数的方法。
public string GetTopName(GetTopScoreDelegate _delegate){
int topNum = 0;
string name = "";
foreach(PlayerStatus player in playerStatuses){
int tempNum = _delegate(player);
if(tempNum>topNum){
topNum = tempNum;
name = player.playerName;
}
}
return name;
}
5.使用。
public void Start(){
topKillName = GetTopName(GetKillName);
topFlagName = GetTopName(GetFlagName);
topDeathName = GetTopName(GetDeathName);
Debug.Log(topKillName+" "+topFlagName+" "+topDeathName);
}
6.补充
可以用Lambda表达式匿名函数作为方法参数,这样可以省略第四个步骤。
Lambda表达式:(输入参数的参数名)=>return返回的数值。
public void Start(){
topKillName = GetTopName((player)=>player.killNum);
topFlagName = GetTopName((player)=>player.flagNum);
topDeathName = GetTopName((player)=>player.deathNum);
Debug.Log(topKillName+" "+topFlagName+" "+topDeathName);
}
2.4.2 回调方法
以回调方法的形式使用委托,根据逻辑,动态选择是否调用。
例子:
public class Test : MonoBehaviour{
public Box WrapProduct(Func<Product> _func,Action<Product> _action){
Box box = new Box();
box.Product = _func();
if(_func().Price>5) _action(_func());
return box;
}
}
该方法的参数有一个返回值为Product类型的Fuc<>委托和一个参数为Product类型的Action<>委托,将func委托的返回值赋给box.Product,如果func()的返回值的Price大于了5则将func返回的Product类型返回值,作为Action委托的参数使用。那如果func()的返回值的Price不大于5,那么永远也不会调用Action委托所以Action是通过内部逻辑才会调用的,所以这里的Action委托所封装的方法是作为回调方法使用的,则Func委托所封装的方法是作为模板方法使用的。
3 实践
准备:将页面搭建好存放为预制体。
3.1 处理界面关系
3.1.1 UIBase
UIBase作为界面基类,所有界面类需继承自UIBase。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 界面基类
/// </summary>
public class UIBase : MonoBehaviour
{
//方法1:显示
public virtual void Show() {
gameObject.SetActive(true);
}
//方法2:隐藏
public virtual void Hide() {
gameObject.SetActive(false);
}
//方法3:销毁
public virtual void Close() {
UIManager.Instance.CloseUI(gameObject.name);
}
}
3.1.2 UIManager
UIManager作为界面管理类,统一管理各界面,使用单例模式。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 界面管理器
/// </summary>
public class UIManager : MonoBehaviour
{
public static UIManager Instance; //单例
public List<UIBase> uiList; //容器:存储界面
private Transform canvasTF; //canvas物体作为界面物体的父物体,transform一般用来描述父子关系
public void Awake() {
if (Instance == null) Instance = this;
else Destroy(gameObject);
uiList = new List<UIBase>();
canvasTF = GameObject.Find("Canvas").transform;
}
//方法1:显示(泛型约束,T需要继承自UIBase
public UIBase ShowUI<T>(string uiName) where T : UIBase {
UIBase ui = Find(uiName);
//集合中没有,需要从Resources/UI文件夹中加载
if (ui == null) {
//Instantiate(Object original,Transform parent);
//original:要实例化的物体; parent:实例化的物体将作为该物体的子对象
//PS:Transform可用来指定对象的父子关系,如A物体的trasnform类a是游戏物体B的transform类b的父类的话,物体A也是物体B的父类。
GameObject obj = Instantiate(Resources.Load("UI/" + uiName), canvasTF) as GameObject;
obj.name = uiName; //改名字
ui = obj.AddComponent<T>(); //添加需要的脚本
uiList.Add(ui); //添加到集合进行存储
}
//显示
else {
ui.Show();
}
return ui;
}
//方法2:隐藏
public void HideUI(string uiName) {
UIBase ui = Find(uiName);
if (ui != null) {
ui.Hide();
}
}
//方法3:关闭
//3.1 关闭所有界面
public void CloseAllUI() {
for (int i = uiList.Count - 1; i >= 0; i--) {
Destroy(uiList[i].gameObject);
}
uiList.Clear();
}
//3.2 关闭某个界面
public void CloseUI(string uiName) {
UIBase ui = Find(uiName);
if (ui != null) {
uiList.Remove(ui);
Destroy(ui.gameObject);
}
}
//辅助方法1:从集合中找到名字对应的界面
public UIBase Find(string uiName) {
for (int i = 0; i < uiList.Count; i++) {
if (uiList[i].name == uiName) {
return uiList[i];
}
}
return null;
}
}
3.2 事件监听
这里实现点击【开始游戏】跳出开始界面。
3.2.1 UIEventTrigger
using UnityEngine;
using UnityEngine.EventSystems;
using System;
/// <summary>
/// 事件监听
/// </summary>
public class UIEventTrigger : MonoBehaviour,IPointerClickHandler
{
//Action:Unity自带委托,PointerEventData:鼠标点击事件
public Action<GameObject, PointerEventData> onClick;
//方法1:给物体加UIEventTrigger脚本
public static UIEventTrigger Get(GameObject obj) {
UIEventTrigger trigger = obj.GetComponent<UIEventTrigger>();
if (trigger == null) {
trigger = obj.AddComponent<UIEventTrigger>();
}
return trigger;
}
//方法2:UI对应的鼠标点击事件,触发时调用委托
public void OnPointerClick(PointerEventData eventData) {
if (onClick != null) {
onClick(gameObject, eventData);
}
}
}
3.2.2 UIBase
添加了一个事件注册方法。
//方法0:注册事件
public UIEventTrigger Register(string name) {
Transform tf = transform.Find(name); //transform.Find(string):找子辈物体
return UIEventTrigger.Get(tf.gameObject); //给物体加UIEventTrigger脚本
}
3.2.3 LoginUI
开始界面脚本。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
/// <summary>
/// 开始界面
/// </summary>
public class LoginUI : UIBase {
private void Awake() {
//开始游戏:Register("bg/startBtn"):给物体加EventTrigger脚本,.onClick=onStartGameBtn:委托的方法参数。
Register("bg/startBtn").onClick = OnStartGameBtn;
}
private void OnStartGameBtn() {
//关闭login界面
Close();
}
}
3.2.4 GameApp
作为全局统领脚本,也可以认为是启动入口。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 游戏入口脚本
/// </summary>
public class GameApp : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
//显示loginUI界面
UIManager.Instance.ShowUI<LoginUI>("LoginUI");
}
}
3.3 音频播放
3.3.1 PlayClipAtPoint()用法
游戏内的声音通常可以分为音乐和音效,音乐通常时间较长,且需要循环播放,音效则时间较短,不需要循环播放。
播放声音的方式常见有两种:
1.建立一个空物体,为每一个音乐或音效添加AudioSource,给每个AudioSource添加相应的声音剪辑,播放时,获取各个AudioSource来播放声音。
2.建立一个空物体,添加一个AudioSource来播放背景音乐,再添加一个AudioSource用来播放音效。该组件的AudioClip属性,在运行时根据需要播放的声音,赋予不同的声音剪辑,以此实现一个AudioSource播放多个音效。但这种方法存在一个问题:当有多个音效需要同时播放时,后播放的音效会终止先播放的音效。解决方案:给所有音效分组,不可能同时播放的音效为一组,再为每一组音效添加一个AudioSource。
但还有一种方法:
static void PlayClipAtPoint(AudioClip clip,Vector3 position,float volume = 1.0F);
使用AudioSource.PlayClipAtPoint播放声音,会自动生成一个名为“One shot audio"的物体,并自动添加AudioSource和相应的AudioClip,同时播放多个声音会生成多个同名物体,各声音播放互不影响,缺点是只能设置音量、位置,不能设置loop。播放完成后,One shot audio自动销毁。
下面的操作就是采用这种方式。
3.3.2 AudioManager
音频管理器。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 声音管理器
/// </summary>
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance; //单例模式
public AudioSource bgmSource; //播放bgm的音频
public void Awake() {
if (Instance == null) Instance = this;
else Destroy(gameObject);
}
//方法1:初始化
public void Init() {
bgmSource = gameObject.AddComponent<AudioSource>();
}
//方法2:播放bgm
public void PlayBGM(string name,bool isLoop = true) {
AudioClip clip = Resources.Load<AudioClip>("Sounds/BGM/"+name);
bgmSource.clip = clip;
bgmSource.loop = isLoop;
bgmSource.Play();
}
//方法3:播放音效
public void PlayEffect(string name) {
AudioClip clip = Resources.Load<AudioClip>("Sounds/" + name);
AudioSource.PlayClipAtPoint(clip, transform.position);
}
}
3.3.3 GameApp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 游戏入口脚本
/// </summary>
public class GameApp : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
//1.显示loginUI界面
UIManager.Instance.ShowUI<LoginUI>("LoginUI");
//2.初始化音频
AudioManager.Instance.Init();
AudioManager.Instance.PlayBGM("bgm1");
}
}
4 结果
进入开始界面,播放bgm,点击【开始游戏】可以进入游戏界面。