思考并回答以下问题:
- 发布后,Unity将DLL转换成了什么?
- 脚本模板是做什么用的?在哪个位置?
- 添加脚本模板的缺点是什么?怎么解决这个问题?
- 监听脚本绑定事件是什么意思?什么时候触发?如何监听?
- 游戏对象和脚本的禁用状态和哪两个生命周期方法有关?
游戏脚本是整个游戏的核心组件,使用它可以创建游戏对象、控制图形渲染,接受并处理用户输入事件,控制内存等。Unity系统提供了很多脚本,它们拥有一套完整的生命周期。在开发模式下,Unity使用Mono来跨平台地编译和解析C#脚本。游戏发布后,Unity还提供了自动将DLL转成IL2CPP(中间语言转换成C++)的方式,这可以提升代码编译后的执行效率以及稳定性。只需在设置界面简单操作一下即可。
创建脚本
脚本需要放在除Editor以外的任意目录或子目录下,因为Editor目录下的代码会被系统认为是编辑模式代码,打包后会被自动剥离。
脚本模板
菜单中还有两类脚本--Testing和Playables,前者是用来做单元测试的,后者是Unity新功能TimeLine中引入的全新概念,用于管理时间线上每一帧的动画、声音和视频等。
脚本创建完毕后,会自动生成一套模板代码,而模板文件在”安装目录\Editor\Data\Resources\ScriptTemplates”下,如下图所示,其中C#脚本、Testing脚本、Playables脚本以及Shader都在ScriptTemplates目录下。我们可以修改脚本模板的格式,这样以后再创建脚本时,就会按照修改后的格式来。模板文件名前面的数字代表菜单栏的排序,如果想新增一套模板,可以按照这个加一套新的。
添加自定义模板其实很有意义。例如,程序使用一些框架来编写,它们的基础模板需要拓展,如果每次创建脚本后,都将其手动添加到代码中,那就太麻烦了,此时就可以使用自定义模板。
拓展脚本模板
添加模板的缺点就是无法进行版本化管理,项目组里的每个人都需要手动在本地安装的Unity目录下修改这个模板,未来如果要修改模板,也需要每个人单独改自己电脑文件下的模板文件,没有办法即时方便的同步。下面介绍一种新的添加模板的方式。首先将代码模板C# Script-MyNewBehaviourScript.cs.txt放入”项目/Assets/Editor/ScriptTemplates”目录下。该模板的代码如下:1
2
3
4
5
6
7
8
9
10
11using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class #NAME# : MonoBehaviour
{
void MyFunction()
{
}
}
接下来需要在Project视图的Create菜单中添加C# MyScript菜单项,如下图所示。因为创建脚本时,需要监听用户输入的名字,所以代码需要继承EndNameEditAction来监听Callback,最终根据用户输入的名称自动创建对应的模板类。
1 | using UnityEngine; |
这段代码的核心部分在CreateScriptAssetFromTemplate()回调方法中。这里可以拿到用户输入的名称以及文件将创建的目录,进行简单的字符串替换后,就会创建一个全新的模板脚本类。
脚本的生命周期
Unity脚本有一套完整的生命周期,脚本需要挂在任意游戏对象上,并且同一个游戏对象可以挂不同的脚本,各脚本执行自己的生命周期,它们可以相互组合并且互不干预。下图这张图完整地描述了脚本的生命周期。
生命周期中的所有方法都是Unity系统自己回调的,不需要手动调用,主要有编辑脚本、初始化、物理碰撞事件、更新回调、渲染和销毁等。
脚本绑定事件
在编辑模式下,Unity并没有提供脚本的绑定事件,但是我们可以通过生命周期中的Reset()方法来实现。Reset()方法仅在非运行模式下才会生效,当把脚本挂在某个游戏对象上时,或者右击已经挂上脚本的对象,从弹出菜单中选择Reset菜单项(如下图所示)时,就可以监听脚本绑定时的事件了。
如下代码所示,在脚本中添加Reset()方法,就可以监听脚本绑定时的事件了。1
2
3
4
5
6
7
8
9
10
11
12
13
14using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Script_04_02 : MonoBehaviour
{
void Reset()
{
Debug.LogFormat("GameObject: {0} 绑定Script_04_02.cs脚本", gameObject.name);
}
}
脚本初始化和销毁
脚本挂在游戏对象上,运行时就会立即执行初始化方法Awake(),它是一个同步方法,而Start()方法会在下一帧执行。如果游戏对象被删除,或者挂在它身上的脚本被删除,就会执行OnDestroy()销毁方法。需要记住的是,初始化或销毁在脚本的生命周期中只会执行一次。
此外,游戏对象还有个状态,叫禁用状态。左上角的复选框控制整个游戏对象(包括绑定的所有脚本)的激活或禁用状态,下面脚本左边的复选框只控制某个脚本是否激活或禁用。在程序运行的过程中,可以多次设置激活/禁用,同时系统会分别回调生命周期中的OnEnable()和OnDisable()方法。
如下代码所示,在代码中添加脚本生命周期方法。该脚本绑定在任意对象后,运行游戏即可查看它们的执行顺序。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Script_04_03 : MonoBehaviour
{
void Awake()
{
Debug.Log("Awake用于初始化并且永远只会执行一次");
}
void OnEnable()
{
Debug.Log("OnEnable在绑定的对象或脚本每次激活时执行一次");
}
void Start()
{
Debug.Log("Start在初始化后的下一帧执行,并且永远只会执行一次");
}
void OnDisable()
{
Debug.Log("OnDisable在绑定的对象或脚本每次取消激活时,执行一次");
}
void OnDestroy()
{
Debug.Log("OnDestroy用于在绑定的对象删除或脚本移除时执行并且永远只会执行一次");
}
void OnApplicationQuit()
{
Debug.Log("应用程序退出时执行一次");
}
}
脚本更新与协程任务
在整个生命周期中,主要提供了如下3种更新方法。
Update():每一帧执行时,都会立即调用此方法。
LateUpdate():Update()方法执行后,都会调用此方法。
FixedUpdate():固定更新。默认情况下,系统每0.02秒调用一次,具体的间隔时间可以在TimeManager中配置。在导航菜单栏中选择Editor->Project Settings->Time菜单项,即可打开Time Manager。
总体来说,Update()和LateUpdate()属于立即更新,更新之间的频率是不固定的,比如某一帧有一个耗时操作时,就会影响到下一帧更新的时间,所以对更新频率要求比较稳定的物理系统就不太适合在这里处理更新。
FixedUpdate()虽然是固定更新,但是其实也是相对固定的,比如某一帧耗了好几秒,它依然会卡住。不过正常的程序会优化耗时操作,小范围的频率波动是正常的,可以让它更新的时间间隔稍微长一点,这样它的更新是比较平滑的。在实际的开发中,例如以秒为单位的倒计时,并不需要每一帧去判断时间,所以用FixedUpdate()就再合适不过了。
Unity的脚本只支持单线程,不过它引入了C#语言协程的概念,可以用来模拟多线程,而不是真正的多线程。举个实际点的例子,每等一秒就创建一个游戏对象,这在Update()中写就比较麻烦,但是引入协程的概念后,就可以直接用for循环来写了。使用StartCoroutine()方法,即可启动一个协程任务。在for循环中,我们使用yield return语句,告诉Unity需要等待多久再执行下一个循环。相关代码如下。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Script_04_04 : MonoBehaviour
{
void Start ()
{
StartCoroutine(CreateCube());
}
IEnumerator CreateCube()
{
for (int i = 0; i < 100; i ++)
{
GameObject.CreatePrimitive(PrimitiveType.Cube).transform.position = Vector3.one * i; // 往右前上方每个一秒形成一个Cube
yield return new WaitForSeconds(1f);
}
}
}
停止协程任务
在协程任务启动的过程中,如果需要重新启动它,必须停掉之前的协程。每次启动协程时,StartCoroutine()将返回这个协程的对象,需要停止的时候使用StopCoroutine()传入对象即可。当然,也可以调用StopAllCoroutine()停止这个脚本所启动的所有协程任务。如下代码所示,启动CreateCube()协程方法,在循环中每隔一秒创建一个立方体对象。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Script_04_05 : MonoBehaviour
{
IEnumerator CreateCube()
{
for (int i = 0; i < 100; i++)
{
GameObject.CreatePrimitive(PrimitiveType.Cube).transform.position = Vector3.one * i;
yield return new WaitForSeconds(1f);
}
}
private Coroutine m_Coroutine = null;
void OnGUI()
{
if (GUILayout.Button("StartCoroutine"))
{
if (m_Coroutine != null)
{
StopCoroutine(m_Coroutine);
}
m_Coroutine = StartCoroutine(CreateCube());
}
if (GUILayout.Button("StopCoroutine"))
{
if (m_Coroutine != null)
{
StopCoroutine(m_Coroutine);
}
}
}
}
使用OnGUI显示FPS
GUI是Unity4.6版本之前的UI系统,因为其功能比较单一并且效率不高,已经被新版的UGUI所替代。如果想显示一些辅助信息或者调试按钮等,大多还会使用它。下面用OnGUI显示FPS。
FPS的含义就是一秒钟Update被执行了多少次。其计算原理就是先记一个初始时间,接着取当前时间减去初始时间,这期间Update执行的次数就是FPS了。
如下代码所示,在Update()中获取每一秒所执行的次数,最终在OnGUI()方法中将FPS打印在屏幕右上角。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Script_04_06 : MonoBehaviour
{
public float updateInterval = 0.5F;
private float accum = 0;
private int frames = 0;
private float timeleft;
private string stringFps;
void Start()
{
timeleft = updateInterval;
}
void Update()
{
timeleft -= Time.deltaTime;
accum += Time.timeScale / Time.deltaTime;
++frames;
if (timeleft <= 0.0) {
float fps = accum / frames;
string format = System.String.Format ("{0:F2} FPS", fps);
stringFps = format;
timeleft = updateInterval;
accum = 0.0F;
frames = 0;
}
}
void OnGUI()
{
GUIStyle guiStyle = GUIStyle.none;
guiStyle.fontSize = 30;
guiStyle.normal.textColor = Color.red;
guiStyle.alignment = TextAnchor.UpperLeft;
Rect rt = new Rect(40, 0, 100, 100);
GUI.Label(rt, stringFps, guiStyle);
}
}
需要说明的是,FPS值越高,游戏就越流畅。但是手机上如果FPS太高,可能会影响发热并且会费电,所以可以考虑降低FPS。下面的代码强制设置FPS最高30帧:1
Application.targetFrameRate = 30f; // 强制设置FPS最高30帧
多脚本管理
Unity脚本可以灵活地挂载多个游戏对象上,此时就衍生出一个问题:脚本多了,如何来管理,如何控制不同脚本执行的先后顺序。启动游戏后,Unity会同时处理所有脚本。比如,执行脚本中的Awake()方法时,Unity会先找到此时需要初始化的所有脚本,然后同时执行这些脚本的所有Awake()方法。计算机是没有同时这个概念的,它们都是有先后顺序的,也就是说排在前面的脚本会优先执行。
脚本的执行顺序
脚本既可以在运行时动态添加在游戏对象上,也可以运行游戏前预制挂在游戏对象上。动态添加的脚本按添加的先后顺序决定执行顺序。但是静态脚本因为提前挂在了游戏对象上,所以初始化的顺序就不一样了。如下图所示,在Project视图中点击脚本,再点击Execution Order,可以设置脚本的执行顺序。
这就说明为什么在脚本生命周期中会提供Start()方法。例如,A脚本先执行B脚本后执行,如果A脚本在自己的Awake()方法中获取B脚本的数据,那么可能就会出错。因为此时B脚本的初始化方法还没有执行,所以Awake()方法适合做初始化,而在Start()方法中才适合安全地访问其他脚本数据。
多脚本优化
脚本挂得越多,执行效率就越低。这些脚本都需要执行生命周期的方法,此时Unity需要遍历它们,然后再反射调用每个脚本的方法。一次全局的Update调用会在Unity内部干很多事情。
所以我们能做的优化就是避免挂太多的脚本,避免在脚本中写入这种空方法,如果不需要用,就把它删除掉:1
2
3void Start()
{
}
脚本序列化
脚本可以通过序列化和反序列化来保存游戏数据,换句话说,就是脚本自身并没有保存数据,而是将数据保存在文件中,使用的时候不需要自己重新组织数据,而是通过语法直接访问即可。如下图所示,脚本挂在GameObject上,而GameObject属于Scene游戏场景,所以Inspector面板中写入的最终将保存在Scene.Unity这个文件中。
接着再复制一份对象,并将其起名为Prefab。我们将它从Hierarchy视图拖入Project视图中,如下图所示,这份资源将变成预制体(Prefab.prefab)。如果在Project视图中赋值的话,所序列化的数据将会保存在Prefab.prefab文件中。但是如果在Hierarchy视图中赋值Prefab的话,数据将会保存在Scene.unity文件中。
查看数据
Unity可以设置资源编辑器中的保存格式,如下图所示,其中Mixed表示混合模型,而Force Binary表示二进制格式。这里推荐使用
单例脚本
Unity的脚本也属于一种特殊的C#类,它不能new出来,而需要绑定在游戏对象,而且脚本会随着游戏对象的删除而自动被释放掉。如果代码逻辑都写在了脚本中,那么出现切换场景一类的情形时,脚本的逻辑就走不了了。虽然Unity提供了DontDestroyOnLoad()方法,它可以在切换场景时不卸载游戏对象,但是总不能给所有对象都添加这个属性。总之,游戏中大量的逻辑代码是不需要写在脚本中的,除了依赖脚本生命周期中的回调方法以外。
有些功能比较单一且需要用到脚本生命周期方法的类,就比较适合使用单例脚本了。单例脚本的特点是它必须依赖游戏对象,并且必须保证这个游戏对象不能被卸载掉。
如下代码所示,Global脚本在它自己的static构造方法中创建对象并且设置DontDestroyOnLoad(),这样就能保证它自己不被主动卸载掉,并且构造方法只会执行一次。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Global : MonoBehaviour
{
public static Global instance;
static Global()
{
GameObject go = new GameObject("#Globa#");
DontDestroyOnLoad(go);
instance = go.AddComponent<Global>();
}
public void DoSomeThings()
{
Debug.Log("DoSomeThings");
}
void Start ()
{
Debug.Log("Start");
}
}
Global脚本不需要在编辑模式下绑定在某个对象上,运行时直接获取它的实例就能操作它了。具体使用方法如下所示:1
2// 调用单例脚本的方法
Global.instance.DoSomeThings();
定时器
协程任务是可以做定时器的,但是有个最大的问题,那就是必须用在脚本中,但是我们游戏的逻辑大部分都在C#代码中,所以需要封装一个不依赖于脚本实现的定时器。
如下代码所示,利用协程程序来做定时器。给WaitTime()传入定时时间以及时间结束后的回调方法,外部代码就可以处理定时解决的事件了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class WaitTimeManager
{
private static TaskBehaviour m_Task;
static WaitTimeManager()
{
GameObject go = new GameObject("#WaitTimeManager#");
GameObject.DontDestroyOnLoad(go);
m_Task = go.AddComponent<TaskBehaviour> ();
}
// 等待
static public Coroutine WaitTime(float time,UnityAction callback)
{
return m_Task.StartCoroutine (Coroutine(time,callback));
}
// 取消等待
static public void CancelWait(ref Coroutine coroutine)
{
if (coroutine != null) {
m_Task.StopCoroutine (coroutine);
coroutine = null;
}
}
static IEnumerator Coroutine(float time,UnityAction callback){
yield return new WaitForSeconds (time);
if (callback != null) {
callback ();
}
}
// 内部类
class TaskBehaviour : MonoBehaviour{}
}
无论是脚本还是类,在需要定时器的地方调用它即可,相关代码如下:1
2
3
4
5// 开启定时器
Coroutine coroutine = WaitTimeManager.WaitTime(5f, delegate{Debug.Log("等待5秒后回调");
});
// 等待过程中取消它
WaitTimeManager.CancelWait(ref coroutine);
脚本编译
编译规则
优化编译
编译DLL
脚本跨平台
程序集定义
日志
Unity提供了Debug类来打印日志,常用的就是如下几种:1
2
3Debug.Log("Log");
Debug.LogError("LogError");
Debug.LogWarning("LogWarning");
在开发阶段,多打印日志可以方便地查看程序的行为。但是一旦发布以后,一定要把日志关闭掉,因为它会有一些额外的开销。如下代码所示,可以在初始化的位置设置条件编译,在非编辑模式下运行时,则关闭掉所有日志的输出:1
2
3#if !UNITY_EDITOR
Debug.unityLogger.logEnabled = false;
#endif
另外,错误日志并不是我们主动打印出来的。错误日志的现场往往是非常珍贵的,我们需要尽可能地将错误日志保存下来。如果是移动平台,那么保存和提取日志其实挺不方便的,所以可以监听错误以及异常,并且及时将其打印到屏幕上。如下代码所示,监听Application.logMessageReceived事件即可捕获错误日志,最终在OnGUI()方法中将它们打印在屏幕上。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84using System;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 下面这个类是将错误打印到一个新建的窗口中。
/// </summary>
public class AddNewLogShowMethod : MonoBehaviour
{
// 错误详情
private List<String> m_logEntries = new List<String>(); // Entry是条目的意思,可以用来作为变量名
// 是否显示错误窗口
private bool m_IsVisible = false;
// 窗口显示区域
private Rect m_WindowRect = new Rect(0, 0, Screen.width, Screen.height);
// 窗口滚动区域
private Vector2 m_scrollPositionText = Vector2.zero; // scroll是滚动的意思
private void Start()
{
// 监听错误
// 委托的好处和必要性:
// 委托的作用之一是动态增加方法,比如日志处理,在日志出来的时候执行委托。
// 日志可以用多种记录方式,可以写进数据表,可以直接打印,可以发邮件。
// 日志出来的地方直接执行委托函数。以后比如想日志在新设计的窗口展示,则扩展委托函数即可。
// 日志出来的地方不用动。对扩展开放,对修改关闭。实现了开闭原则。
// 比如此处,Application.logMessageReceived肯定在日志出现的地方执行了
// 这里直接赋予一个新的函数,则不影响原来的日志记录同时也可以执行新增的方法。
Application.logMessageReceived += (condition, stackTrace, type) =>
{
if (type == LogType.Exception || type == LogType.Error)
{
if (!m_IsVisible)
{
m_IsVisible = true;
}
m_logEntries.Add(string.Format("{0}\n{1}", condition, stackTrace));
}
};
// 创建异常以及错误
// 触发日志记录函数,执行新增的方法
for (int i = 0; i < 10; i++)
{
Debug.LogError("momo");
}
int[] a = null;
a[1] = 100;
}
void OnGUI()
{
if(m_IsVisible){
m_WindowRect = GUILayout.Window(0, m_WindowRect, ConsoleWindow, "Console");
}
}
// 日志窗口
void ConsoleWindow(int windowID)
{
GUILayout.BeginHorizontal();
if (GUILayout.Button("Clear", GUILayout.MaxWidth(200)))
{
m_logEntries.Clear();
}
if (GUILayout.Button("Close", GUILayout.MaxWidth(200)))
{
m_IsVisible = false;
}
GUILayout.EndHorizontal();
m_scrollPositionText = GUILayout.BeginScrollView(m_scrollPositionText);
foreach (var entry in m_logEntries)
{
Color currentColor = GUI.contentColor;
GUI.contentColor = Color.red;
GUILayout.TextArea(entry);
GUI.contentColor = currentColor;
}
GUILayout.EndScrollView();
}
}
错误的调用栈已经打印在屏幕上。如果错误比较多,那么右侧需要一个滚动条。此外,还提供了Clear和Close操作。