Unity 中的小技巧

脚本修改资源导入设置的正确姿势

❌错误姿势

如果当前程序中只有待修改的资源本身作为 GameObject 变量为已知, 则使用 AssetDatabase.GetAssetPath() 来获取到资源的 Assets 相对路径, 如果已经有资源的绝对路径了, 只要处理为相对路径即可, 总之第一步就是先获取到待修改资源的相对路径.

有了相对路径之后, 使用 AssetImporter.GetAtPath() 获取到资源的导入设置, 但是此时还只是通用的导入设置, 之后需要进行转换为正确的类型, 比如纹理: as TextureImporter, 模型则使用 as ModelImporter, 这样便获取到了指定类型的导入设置信息了.

之后对信息进行设置, 这一步很简单, 直接对转换后的变量进行赋值即可.

最后设置脏标志, 保存, 刷新即可.

1
2
3
EditorUtility.SetDirty(asset);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();

上面的步骤基本是对的, 但是最关键的一步也就是最后一步保存是大错特错的, 这样设置后你会发现资源的导入设置就会变得非常奇怪. 因为有些设置很简单, 没有附加设置, 但是有些设置是具有附加设置的, 比如 Mip Maps 开关, 当开启 Mip Maps 时会多出现很多关于 Mip Maps 的设置选项. 使用上述方法关闭 Mip Maps 设置后就会出现 Mip Maps 是关闭了, 但是其附加设置却没有关闭!!!

✔正确姿势

对资源的导入设置正确修改之后, 应该进行重新导入, 以关闭模型的法线导入为例:

1
2
3
4
5
// 关闭法线导入
modelImporter.importNormals = ModelImporterNormals.None;

// 重新导入资源
AssetDatabase.ImportAsset(assetPath);

既不需要 SaveAssets, 也不需要 Refresh, 这才是资源导入设置的正确修改姿势.

脚本修改 Prefab 资源中的 Animator 设置

  1. 使用 AssetDatabase.LoadAssetAtPath 加载预制体
  2. 使用 `` 获取预制体上的 Animator 组件
  3. 修改 Animator 组件的设置
  4. 使用 EditorUtility.SetDirty 设置预制体脏标志
  5. 循环执行上述步骤, 修改所有预制体上的所有 Animator 组件的设置, 并为每一个预制体设置脏标志
  6. 调用保存方法 AssetDatabase.SaveAssets(); 保存所有的预制体改动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (var i = 1; i < 9; i++)
{
// 读取 Prefab
var obj = AssetDatabase.LoadAssetAtPath<GameObject>($"Assets/Art/Effects/Cube ({i}).prefab");
if (obj == null) { DebugUtil.Log($"未读取到预制体 {i}", null, "red"); }

// 修改 Prefab 里面的动画状态机组件
var anima = obj.GetComponent<Animator>();
anima.cullingMode = AnimatorCullingMode.CullCompletely;

// 设置脏标志
EditorUtility.SetDirty(obj);
}

// 保存
AssetDatabase.SaveAssets();

如何使用 Unity Profiler 分析指定的代码段耗时?

使用 UnityEngine.Profiling.Profiler.BeginSample("在 Hierarchy 中显示的名称")UnityEngine.Profiling.Profiler.EndSample() 可以对两者中间的代码段进行单独的性能分析.

比如下面的代码可以单独对 AssetBundle.LoadFromFile(assetPath) 这行代码进行性能分析.

1
2
3
4
5
6
if (assetBundle == null)
{
UnityEngine.Profiling.Profiler.BeginSample($"Load:{assetPath}");
assetBundle = AssetBundle.LoadFromFile(assetPath);
UnityEngine.Profiling.Profiler.EndSample();
}

Debug.Log() 高亮游戏物体和路径

众所周知, Debug.Log() 中的第二个参数可以传入一个 UnityEngine.Object 物体, 用于迅速定位游戏物体.

如果传入自身, 就可以快速定位打印日志的游戏物体; 如果传入日志相关的游戏物体, 自然就可以快速定位到相关的游戏物体.

但是如何定位一个文件夹呢? 比如现在要打印一个日志: "文件夹 XXX 下有 XXX 个资源文件, 超过最大数量限制, 请尽快拆分文件夹!", 需要让相关的处理者们快速定位到有问题的路径.

可以使用 AssetDatabase.LoadAssetAtPath<Object>() 来实现. 这个 API 不仅可以获取 Project 面板中的资源文件, 也可以获取目录.

1
2
3
4
5
6
7
8
9
10
11
12
13
foreach (var key in detectResult.Keys.Where(key => key.Name.Equals(".git") == false))
{
if (detectResult[key] > 70)
{
Debug.Log ($"<color='red'>路径 {key.FullName} 下有 {detectResult[key]} 个资源</color>",
AssetDatabase.LoadAssetAtPath<Object>(key.FullName.Substring(key.FullName.IndexOf("Assets", StringComparison.Ordinal))));
}
else if (bundleAssetCounterLogSwitch)
{
Debug.Log ($"路径 {key.FullName} 下有 {detectResult[key]} 个资源",
AssetDatabase.LoadAssetAtPath<Object>(key.FullName.Substring(key.FullName.IndexOf("Assets", StringComparison.Ordinal))));
}
}

C# 移除字典中所有符合特定规则的数据

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
/// <summary>
/// 字典工具类
/// </summary>
public static class DictionaryUtility
{
/// <summary>
/// 移除字典中所有符合特定规则的数据
/// </summary>
/// <typeparam name="K">键类型</typeparam>
/// <typeparam name="V">值类型</typeparam>
/// <param name="dictionary">待处理字典</param>
/// <param name="predicate">规则</param>
public static void RemoveAll<K, V>(IDictionary<K, V> dictionary, Func<K, V, bool> predicate)
{
var keys = new List<K>();

foreach (var key in dictionary.Keys)
{
if (predicate(key, dictionary[key]))
{
keys.Add(key);
}
}

foreach (var key in keys)
{
dictionary.Remove(key);
}
}
}

在脚本中清空 Console 窗口

在想要清空控制台的位置调用下面的方法即可清空控制台.

[注] 此方法在安卓平台下无法通过编译!

使用 Conditional 区分平台调用, #if 区分平台编译.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// <summary>
/// Clear Console Window
/// </summary>
[System.Diagnostics.Conditional("UNITY_EDITOR")]
public static void ClearConsole()
{
#if UNITY_EDITOR

var assembly = System.Reflection.Assembly.GetAssembly(typeof(SceneView));
var logEntries = assembly.GetType("UnityEditor.LogEntries");
var clearConsoleMethod = logEntries.GetMethod("Clear");
clearConsoleMethod?.Invoke(new object(), null);

#endif
}

UGUI 按钮点击之后持续高亮的 Bug

Unity 中按钮的外观可以设置为图片切换模式, 这种模式下有一个高亮状态, 当鼠标悬浮在按钮上时, 按钮就会处于高亮状态, 但是我发现默认设置下, 按钮点击之后, 按钮的高亮状态就无法触发了, 除非按下另一个按钮.

查阅资料发现是 "导航" 的问题, 设置为 none 就可以了.

在 Button 控件中有一个 Navigation 属性, 设置为 none 即可解决问题. 【当然前提是项目中不使用导航来做界面操作】

闭包

之前在学习委托事件的时候了解了一下闭包, 没想到后来在游戏制作中就犯了一个闭包的错误.

使用的是 Unity 引擎, 先看一个 Start 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void Start()
{
GameObject gameObject;
characterPackage = characterDataScript.characterData.Package;

for (int index = 0; index < characterPackage.Count; index++)
{
// 生成菜单项, 并设置父物体
gameObject = Instantiate(itemMenuItemPrefab, itemMenuItemParent.transform);

// 修改菜单项的名称
gameObject.name = index.ToString();

// 修改菜单项的图标
gameObject.transform.Find("Icon").Find("Image").GetComponent<Image>().sprite = characterPackage[index].Icon;

// 修改菜单项的文本
gameObject.transform.Find("Name").Find("Text").GetComponent<Text>().text = characterPackage[index].Name;

// 给菜单项的按钮注册事件, [UnityAction: 一个无参的委托, 返回值无所谓]
gameObject.GetComponent<Button>().onClick.AddListener(() => Event_ItemSelected(gameObject));
}
}

执行步骤:

  1. 新建一个菜单项, 用 gameObject 保存.
  2. 修改菜单项各项属性. 其中名称属性就是遍历索引 index 的值, 即第一个菜单项的名称是 0, 第二个是 1, 第十个是 9 等等;
  3. 给菜单项上的按钮注册事件, 事件的参数为 gameObject, 即将菜单项自身作为参数传递到方法中.

那么问题来了, 假设有 4 个菜单项, 那么在事件 Event_ItemSelected 中输出按钮的名称的话, 依次点击菜单项会输出什么呢?

不是很了解闭包的人应该会说出这个答案: 0, 1, 2, 3, 而正确的答案是: 3, 3, 3, 3!

什么是闭包?

在上面的代码段中, gameObject 变量是在 Start 方法中定义的, 那么按理来说只要出了 Start 方法, gameObject 变量就不存在了. 但是我们在按钮的 Event_ItemSelected 事件中却又访问了变量 gameObject, 这种现象就是闭包.

当一个变量脱离了其自身的作用域后, 根据上下文关系继续在某些方法或者类中发挥作用的现象就是闭包. 或者说闭包可以让变量脱离其作用域继续发挥作用.

答案解析

在上面的代码中, 每次循环都会创建一个菜单项, 并将 gameObject 变量作为参数绑定事件, 所以最后的结果就是 菜单项 0, 菜单项 1, 菜单项 2, 菜单项 3 都使用了 gameObject 变量作为参数, 而 gameObject 是一个变量啊, 不是一个常量, 创建菜单项 0 的时候, gameObject 确实是菜单项 0, 但是等到了创建菜单项 3 的时候, gameObject 也就变成菜单项 3 了, 因此调用事件的瞬间, 使用的也是那个瞬间的 gameObject 值, 也就是 3.

纠正

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void Start()
{
characterPackage = characterDataScript.characterData.Package;

for (int index = 0; index < characterPackage.Count; index++)
{
// 生成菜单项, 并设置父物体
GameObject gameObject = Instantiate(itemMenuItemPrefab, itemMenuItemParent.transform);

// 修改菜单项的名称
gameObject.name = index.ToString();

// 修改菜单项的图标
gameObject.transform.Find("Icon").Find("Image").GetComponent<Image>().sprite = characterPackage[index].Icon;

// 修改菜单项的文本
gameObject.transform.Find("Name").Find("Text").GetComponent<Text>().text = characterPackage[index].Name;

// 给菜单项的按钮注册事件, [UnityAction: 一个无参的委托, 返回值无所谓]
gameObject.GetComponent<Button>().onClick.AddListener(() => Event_ItemSelected(gameObject));
}
}

使用这段代码就可以纠正那个错误, 两段代码只有一个区别, 就是 gameObject 变量定义的位置不同. 一个在 for 循环外, 一个在 for 循环内.

  1. 在 for 循环外部定义时, 每一个菜单项使用的都是同一个 gameObject;

  2. 在 for 循环内部定义时, 每一次循环都会重新定义一个 gameObject, 每一个菜单项之间使用的都是不同的 gameObject;

closure

重命名序列化的变量时如果保持引用不丢失?

[] 参考链接: Unity 的各种小实验

一般情况下, 从 Unity 编辑器上对脚本中的变量复制引用之后, 如果修改了脚本中变量的名称, 那么 Unity 重新编译之后变量的引用就是变成空.

Unity 提供了一个解决办法, 就是使用 [FormerlySerializedAs("OldNameString")] 特性.

比如目前变量 playerID 的值是 "岚", 现在需要修改变量的名称, 为了维持变量的引用, 修改名称之后先写一句: [FormerlySerializedAs("playerID")], 之后将变量名修改为 playerName, 保存, 重新编译后会发现 Unity 中 playerName 的值还是 "岚", 此时可以删除这个特性, 保留着也没问题.

Unity 的脚本编译规则

[] 参考链接: Unity 的各种小实验

脚本编辑规则

最终所有代码都会生成 dll, 放在 Project/Library/ScriptAssembiles 目录中.

脚本分为运行时和编辑时两类, 运行时脚本最终会编译进游戏包中, 而编辑时脚本仅用于编辑器模式下, 不会被打包进游戏包.

脚本编译顺序

最先编译 Plugins 目录下的, 然后是 Plugins 下的所有 Editor 子目录, 然后编译其他目录, 最后编译其他 Editor 目录.

先编译的不可以访问后面的数据, 所以 Plugins 下的代码不能访问其他代码, 后编译的可以访问先编译的脚本.

各目录脚本最终所在的 dll

  • Plugins 下非 Editor 目录脚本编译进 Assembly-CSharp-firstpass.dll
  • Plugins 下的 Editor 目录脚本编译进 Assembly-CSharp-Editor-firstpass.dll
  • 其他非 Editor 目录脚本编译进 Assembly-CSharp.dll
  • 其他的 Editor 目录脚本编译进 Assembly-CSharp-Editor.dll

脚本控制 Unity 编辑器的暂停与退出

退出功能:

1
2
3
4
5
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif

暂停功能:

1
UnityEditor.EditorApplication.isPaused = true;

Unity 中常用的 C# 特性

特性 作用 应用场景
[Serializefield] 序列化特定的可序列化字段 将私有的可序列化字段进行标识后, Unity 的 Inspector 面板中将会显示该字段
[HideInInspector] 隐藏字段 [Serializefield] 正好相反, 此特性是为了在 Inspector 面板中隐藏字段的显示
[Header(string)] 字段添加头部注释 Unity 的 Inspector 面板中字段前会显示一段提示文字, 主要用于字段分类
[Tooltip(string)] 字段添加气泡提示 鼠标放到 Inspector 面板的字段上会显示气泡提示
[TextArea] 文本区域 使 string 类型的变量在 Inspector 面板显示为文本框, 可多行输入
[Range(float,float)] 滑动条 使 float 类型的变量在 Inspector 面板中显示为滑动条
[Space(float)] 空白 布局时使用, 在 Inspector 面板的相应位置生成一块空白
特性 作用 应用场景
[RequireComponent(typeof(XXX)] 组件依赖 当脚本需要依赖特定组件的时候使用此特性添加依赖, 依赖的组件将被自动添加并不可移除
[DisallowMultipleComponent] 禁止重复组件 当组件需要只能存在一个的时候可以使用此特性, 强制特定组件在同一个游戏物体上只能添加一个
[ExecuteInEditMode] 使脚本在编辑器模式下也能运行 目前还没用到过~ 😂
[SelectionBase] 修改 Scene 窗口中物体的默认选中 有时候我们在 Scene 窗口中, 通过单击看到的物体查找它在 Hierarchy 面板中的位置, 但是我们这个物体是没有外观的, 他的子物体有外观, 这时候就可以将此物体的一个组件使用此特性修饰, 当单击有外观的子物体时, 定位的不是子物体, 而是我们想要的那个没有外观的物体
[CanEditMultipleObjects] 多选编辑 让游戏物体可以被多选同时编辑