从零开始独立游戏开发学习笔记33--Unity学习笔记16-Junior Programmer(下)

书接上回。
1. 场景流管理和数据管理
1.1 设置版本控制
突然插入一个版本管理的教程,和本节讲的东西无关,但是顺便看一下吧。版本控制对于个人来讲也是比较重要的一件事。
这里使用 git + github 进行管理。教程用的是 github desktop。我就直接用命令行了。
如果对 git 不熟练的话,可以参考我很久很久以前写的这篇文章。
1.1.1 使用
非常简单。Unity 的每个项目都是一个完整的文件夹。直接把这个文件夹作为 repo 根目录即可。
1.1.2 Unity 相关
Unity 本身并没有什么专门为 github 做的东西,毕竟有自己的 plasticSCM。要方便使用的话去找插件吧。 Github 有一个专门给 Unity 做的插件。但是评分巨低无比,看了一下,说这工具很久不维护,现在版本已经基本不能用了。
1.2 创建场景流
这一节是全文字,没有视频,太棒了。我已经受够了好几个 3 分钟的视频就为了讲一件事了。
导入项目。试玩一下 menu,可以发现 bug。就是根本没法从菜单进入游戏。那么我们先把一些基本功能像是进入游戏和离开游戏做好吧。
1.2.1 开始游戏
canvas 上有一个脚本,在这个脚本里添加一个 StartNew() 函数。用于加载游戏场景。绑上 Start 按钮。
1.2.2 离开游戏
依然在同一个脚本里添加函数,Application.Quit() 来离开游戏。绑上 Quit 按钮。
不过,就像我们已经知道的一样,这个只适用于成品游戏。测试的时候并没用。
1.2.2.1 在测试环境的时候退出 play mode
但是还没完,我们想让点击的时候,能够退出 play mode。
退出 play mode 并不是难点,有专门的方法。不过问题在于,我们如何让游戏知道自己在哪种环境下?毕竟你写了个退出 play mode 的方法,成品游戏里不就尴尬了吗?
1.2.2.2 条件编译
这个时候就要用到条件编译了。这个方式可以让你在不同编译环境下运行不同的代码。
我们的 Exit 函数里这么写:

语法还是简单的,一看大概也能猜出来。
不用担心这些代码会影响生产环境的性能,这些 # 开头的是给编译器看的。编译器看到就会知道哪些代码需要编译,哪些不用。在生产环境里不会多一层条件判断的。
此外,要使用 EditorApplication 需要引入新的 namespace:UnityEditor。但是需要注意,我们要给这个引入也加上条件编译:

这不是可选,是必须这么做,因为 build 环境下没有 UnityEditor 的 namespace。到时候花半天编译没法用就难过了。
1.2.2.3 从 main 回到 menu
简单。
1.3 场景变化中的数据持久化
困扰我很久的一个问题,在这里解决。如何在场景变化中保留一些数据。
1.3.1 保持颜色
我们想让在 menu 里选的颜色能够带到 main 里,影响一些物体的颜色。
为了做数据持久化,我们需要用到以下两个新东西:
DontDestroyOnLoad:这个方法可以让某个 object 在更换场景的时候不被销毁。
静态类和静态成员。
1.3.2 单例模式(best practice 版)
之前在 M_Studio 的小狐狸教程中讲过单例模式。但是那是个非常不靠谱的单例模式。这回讲的是单例模式的完整体。
1.3.2.1 基础代码
先创建一个 GameManager 空物体,挂载一个 GameManager 脚本。写上如下之前已经讲过的代码:

这个代码问题很多,首先第一个,切换场景后就没了。即使新场景也放一个,但那已经不是它了。因此数据没法持久化。
1.3.2.2 保留到第二个场景
于是我们刚刚提到的 DontDestroyOnLoad 不就来了吗:

注意参数是 gameObject,不是 this。this 是这个脚本组件实例,gameObject 才是场景上的那个 GameManager 物体。
但是这并不是最终版,它仍然有巨大问题。
我们去测试一下现在的游戏,我们点 start,可以在 Hierarchy 里看到有一个专门的 DontDestroyOnLoad 的 Scene,里面可以看到 gameManager 还保留着,很好。但是我们再回到菜单看看。咦?怎么现在有两个 GameManager 了?
原因:因为我们把 GameManager 从 menu scene 带往 main scene ,然后我们带着这个 GameManegr 回到 menu 的时候。menu 的物体又被全部加载(也就是实例化)一遍,包括一个新的 GameManager。毕竟 DontDestroyOnLoad 只是告诉 unity 不要在换场景的时候销毁该物体。其他的表现和别的物体是一样的。当你加载某个场景的时候,就会实例化全部的 object。那这个自然也会被再次加载一遍。
ps. 在 DontDestroyOnLoad 的 hierarchy 里还可以看到一个 [Debug Updater] ,这是 Unity 自己用于 debug 的物体,不用管。
1.3.2.3 不再生成新的 GameManegr
不过,这个问题其实也很好解决。
首先我们知道两件事:
静态属性的特点是,和实例无关,所有地方共享同一个值。
因此,再次回到 menu 的时候,GameManage 的 Instance 已经是有值的。
因此,我们对 Awake 里的代码进行一些修改:

第一次实例化的时候,Instance 为 null,因此不会执行 if 语句里的内容。
第二次实例化的时候,Instance 因为是共享的静态属性,已经有值了。于是执行 if 语句里的内容。销毁自身。
发现没有,我们其实没办法阻止到一个新场景的时候所有物体会被重新实例化一遍的过程。但我们可以在一开始就把该物体销毁掉。Awake 作为最早的生命周期函数正好适合这个作用,避免其它副作用。
1.3.2.4 数据持久化
我们已经有了一个会在所有场景中保持不变的物体。那数据持久化就变得相当简单了,只要把想持久化的数据存在这个物体上就可以了。
以前我还会在单例类里把一些其他属性也写成静态。但是其实没有必要,单例模式的类已经可以代替静态类了,反正都是同一个实例,怕什么。而且静态和非静态混用,在 GameManegr 类里面写代码会很难受,一会儿写 this.xxx,一会儿写 GameManegr.xxx 真的很容易混淆。不如好好享受单例模式的快乐,类里面全用非静态的,类外面全用 GameManegr.Instance.xxx 就行了。
1.4 不同 session 中的持久化(存档)
刚刚的持久化是在游戏中的持久化。还有一种持久化是在玩家离开游戏,下次玩的时候还存在的数据,也就是存档。
1.4.1 持久化过程
跨越游戏进程的持久化。需要一个文件来储存数据。
但是除了储存的行为以外,我们还要考虑到,游戏过程的数据是很复杂的。我们肯定不能这么直接存。我们需要将其转化为易于储存的数据形式。这一过程叫做序列化。反之,当我们读取数据的时候,将其转化为游戏状态信息的过程就叫做反序列化。
下图便是描述了这种形式:

1.4.2 持久化数据形式
上图看到其实中间的数据形式可以是很多种。毕竟我们有那么多储存数据的格式。json,xml,数据库,普通的文本文件,excel,等等等等。
以上说的这些全都可以用,事实上,以上这些也确实被用在各种游戏中过。
不过,在本教程中,我们使用 JSON 格式。原因无他,简单,好用。
1.4.3 JsonUtility
Unity 自带一个 JsonUtility 类来帮助我们使用 JSON。
1.4.3.1 序列化
假如我们有这么一个 object 信息需要序列化:
[Serializable]public class PlayerData {
public int level; public Vector3 position; public string playerName;
}
那么,假如在游戏过程中,玩家就很有可能产生这样的数据:
_myPlayerData.level = 1;
_myPlayerData.position = new Vector3(1.0f, 2.0f, 3.0f);
_myPlayerData.playerName = "Joe";
那么我们可以用 JsonUtililty.ToJson(_myPlayerdata)
来对其进行序列化,序列化的结果是这样的(注意:方法返回的是个字符串):
{ "level": 1,
"position": { "x" : 3.0, "y" : 4.4, "z" : 2.3 },
"playerName": “John”
}
这里重点看第二个,因为 json 并没有 Vector3 这样的类型。
问题来了,明明我代码里都没有写 x,y,z,这几个字母哪来的呢? 答案是 Vector3 类的定义里:
// 极度简化后的 Vector3 类定义[Serializable]public class Vector3 {
public float x;
public float y; public float z;
}
也就是说,当遇到复杂的结构(自定义类)的时候,就会去该类里找成员,还是挺智能的。
关于 JsonUtility 支持哪些类型请参考文档。
JsonUtility 会自动忽略不能序列化的数据,因此当你没看到一些属性的时候,除了看一下有没有 bug 以外。也要看一下是不是不支持这种类型。这种类型是否是 Serializable 的。
1.4.3.2 反序列化
反序列化用的方法叫做 JsonUtility.FromJson<T>
,接受一个 json string 作为参数,泛型里指定反序列化之后的对象类型。
要把类名给出来,这也很合理,毕竟上面的 json 结构可以对应无数个类定义。
PlayerData _mySavePlayerData = JsonUtility.FromJson<PlayerData>(MySaveDataJsonString);
1.4.4 实践
回到练习中。我们用 JsonUtility 试着做一个存档出来看看。
我们在 GameManage 里添加一个新的类(仍然在 GameManager 的范围里),里面存一个 TeamColor 变量如下:

看到后一定会有问题:
为啥不直接序列化 GameManager,又来个新的,上面已经有了 TeamColor,新的类还要重新写一遍。
那么,其实用 GameManager 是可以的,不过继承了 MonoBehaviour,本身又有一大堆属性方法啥的。JsonUtility 读取的时候会稍微性能多耗损一点。用一个简单的类来储存就会比较快一些。
当然了,这么点性能只是九牛一毛,最重要的是,这样的话,也容易控制哪些需要储存哪些不需要。以后想知道哪些数据被储存了,一眼就能看出来也方便查找。
为什么是 private
public 也行,不过一般不会在其他地方用到存档数据类。这样作用域比较安全。
1.4.4.1 储存方法
有了这个类,我们可以写一个方法来储存数据了,比如我们写这么一个方法来储存颜色:

File.WriteAllText 的使用需要添加命名空间 System.IO。
JsonUtility.ToJson 返回的是一个字符串。
Application.persistentDataPath 在不同的平台上不一样。具体路径可以查看文档。但总之不在项目文件里。PC 端在那个 AppData 一大堆里。
SaveColor 因为和 SaveData 在同一个类里,因此可以访问 SavaData 类。这样作用域比较安全。
1.4.4.2 加载方法
同理,写一下加载方法:

先判断文件是否存在。
1.4.4.3 绑定使用
首先我们要在游戏开始的时候加载存档,我们选择在 GameManager 的 Awake 函数里加载:

这里只是加载存档了,我们还要把数据显示到屏幕上。在 menu 的 Start 函数里把刚刚读取的颜色填充上。
在退出的时候,存储颜色。于是在 Exit 函数里使用刚刚写的 SaveColor 方法。
游玩,选择颜色,退出后。再进游戏,可以看到之前的颜色保留下来了!

也可以顺便给 save color 等按钮也加上功能。
2. 编程 tips 以及性能优化
前面部分讲一些 oop 的特性没什么好说的。
后面有很多小 tips 倒是挺有趣的:
2.1 变量安全问题
2.1.1 安全的单例模式
之前的单例模式,虽然在使用上已经是完整的。但是有一个安全问题,那就是可以在别的地方设置值,如果有人(虽然没有这样的人)在其他脚本文件里获取这个 Instance 变量,然后进行赋值,这样整个游戏就坏掉了。为了提高代码的健壮性,也为了防止自己误操作,可以改成如下声明:
public static GameManager Instance {get; private set};
以上代码表示,我是 public 字段,你们都可以访问,但是想要赋值则只能在类内部赋值。
2.1.2 安全的数值,或其他类型
像是有些数值字段如物品数,在真实情况下不能小于 0,以及别的一些有真实含义限制的类型。为了防止别人,或者自己的误操作或者在什么地方不小心赋值错误的时候,能够迅速找到原因。可以给变量添加 setter 来验证。
2.2 使用 Profiler 进行性能分析和优化
打开之前的叉车项目。有一个 Optimization 文件夹里有一个 scene。进入 play 模式可以发现会在屏幕上渲染超多叉车。很卡。

右上角的 stats 打开可以看到各种负载情况。
现在帧数相当低,7 帧。
当然,只要把叉车数量减少。很容易就可以增加帧数。但是,假如我们就是想要 2000 个叉车出现在屏幕上还能 60 帧运行怎么办?
那么,我们就要找出性能瓶颈了。
2.2.1 使用 Profiler 获取数据信息
打开 Window -> Analuysis -> Profiler 窗口。 打开 Profiler 的录制按钮(默认应该是开着的,就那个红点)。然后进入游戏模式,便可以看到 Profiler 正在努力收集数据。差不多后,我们退出游戏模式。可以看到一套非常详细的性能图:

上半部分可以拉下来,有很多信息的使用情况。

本教程里,我们只看 CPU 信息。
2.2.1.1 CPU 信息分析
先看上部分的信息。

左边显示了不同颜色代表的操作。右边的图案则是显示整个时间段里,不同操作花费的时间。
取消勾选,右边就不会显示了。因为有很多颜色相同,很难判断这个蓝色到底是 Scripts 还是 Animation。可以试着取消其中一个,看右边的变化就知道是哪个了。
可以看到,目前占大头的是蓝色的 Scripts(别当成背景了),然后是绿色的 Others。其他颜色几乎看不见。
那么,我们就大概知道 Scripts 中有大问题。之后我们会去找出问题。
2.2.1.2 下半部分的详细信息
上面的图表中有一个白色条条,表示当前帧。而下半部分显示的就是这一帧的信息。
拉动条上显示着当前这一帧花的全部时间:

可以看到这一帧花了 147ms。如果我我们想要达到 60 帧,那么我们需要把这个速度降到 1000/60 = 16 ms 左右。
2.2.1.3 Timeline 分析
Timeline 就是下面这每一帧的信息。左边可以调整 Timeline 还是 Hierarchy 形式。现在我们就选择比较直观的 Timeline 形式。
2.2.1.4 第一行
先看第一行。可能乍一看好像只有一个绿色的 PlayerLoop(截图红框标识了),但实际上后面还断断续续地跟着一些灰色的 EditorLoop(截图紫框标识了)。

PlayerLoop 就是发生在游戏中的所有事情。EditorLoop 则是发生在编辑器中的事情。由于最后玩家玩的时候已经没有编辑器了,因此可以忽略掉这些 EditorLoop。
2.2.1.5 其它行
PlayerLoop 下面这几行则是表示发生在应用中的所有事,因此你看不到 EditorLoop。
下面这几行是排序过的,从上到下越来越少。当然你也可以说 PlayerLoop 在第一行是因为它作为一个综合的,肯定是最长的。
颜色和上半部分是对应的,蓝色是 Scripts,绿色是 Others
2.2.1.6 蓝色行
刚刚的图中,那里有一个长长的蓝色行,说明这一帧中有很大的 Script 开销。(当然了,我们早在之前大图中就发现了所有帧中 Script 开销都很大)
点击这个蓝色条,会告诉你这开销是在哪里。我们点一下发现是在 OptimUnit 里的 Update 方法。这是教程里的物体上挂载的方法。

这里会显示一个总体的值(截图下方 115.46ms),并告诉我们有 2000 个 instances。以及某个 instance 的花费时间(上面的 0.062ms)。点击这个 bar 的不同地方可以看到不同单位花费的时间,不过这个信息目前对我们没用。
以上问题当然是合理的,毕竟我们场景中就是有 2000 个物体。每个物体上都挂载着 OptimUnit。自然每一帧都会一共调用 2000 次。
那么,知道了是哪个方法当然还不够,毕竟谁不知道 Update 花销大,大部分逻辑都在这里面,我们还想知道到底是哪一行代码开销这么大。
2.2.2 使用 Profiler 寻找代码中开销大的地方
去往 OptimUnit 的 Update 方法中看一下,大概可以分为 4 个部分:

于是我们要对每个部分设定一个采样器,可以看一下第一部分的使用例:

一个开始采样,和一个结束采样。开始采样里有一个字符串参数,这是为了在 Profiler 显示,用于区分不同的采样。
就这样依次把每个部分包住。
万事俱备后,我们再到 Profiler 里录制一次:

可以看到多出了新的一个蓝色行,选中可以看到名字正是我们当时写的采样字符串。
不过有个问题,我们并不能看到这 4 个采样的分布情况,虽然鼠标点中的地方会显示是哪个,我们可以在条上多点几下,看下哪个出现的频率高。不过这显然不是正规做法,也不严谨。
正确的方法是,点截图左边红框标识的 Timeline,之前我们用不上 Hierarchy,现在可以用上了:

一眼就看出来,3:move 很显然是花销最大的地方,远超其它。
2.2.2.1 优化代码
我们看一下 Move 的代码:

好家伙,搁这萃香玩元宇宙————糊弄鬼呢。
立刻把这个 for loop 取消掉,再打开 play mode 看一看,世界立刻就轻松了:
