Unity的50个使用技巧
一、Unity及相关工具优势
(一)Unity自身优势
Unity在使用上更为便捷。例如,我现在信赖FPS计数器。利用property drawer功能,能够降低编写customeditors的必要性。同时,Prefab的工作方式减少了显式嵌套Prefab或替代件的需求。Scriptable objects的使用体验也更为友好。
(二)与Visual Studio集成优势
Unity与Visual Studio的集成度更佳,这使得调试操作更加简便,同时减少了对大量Gorilla调试操作的依赖。
(三)第三方工具和库优化
Asset Store中存在许多用于可视化调试和更佳日志记录等方面的辅助工具。我们自带(免费)的扩展插件包含大量初始发布中提及的代码,以及本次版本发布涉及的许多代码。
(四)版本控制改进
如今,版本控制更加高效,或者可以说,我现在更了解如何更有效地使用它。例如,无需对Prefab进行多个副本或备份操作。
(五)个人经验积累
在过去四年里,我参与了众多Unity项目,涵盖大量游戏原型制作、如Father.IO等游戏的开发,以及我们的旗舰工具Unity asset Grids的工作。
本文是在综合考虑上述内容的基础上,对初始版本进行修订后的版本。在继续探讨技巧之前,现发布以下免责声明(与初始版本基本一致):
- 这些技巧并非适用于所有Unity项目。
- 这些技巧是基于本人与3到20人的小型团队协作参与项目所积累的经验。在结构性、可重用性、清晰度等方面存在一定成本,具体是否采用需根据团队规模、项目规模和项目目标来确定。例如,在游戏制作环节,你可能不会用到所有技巧。
二、开发流程技巧
(一)确定统一缩放比例
开始项目时,要确定一个初始的缩放比例,并以该比例构建所有原型。否则,后续可能需要重新制作assets,比如动画可能无法正确缩放。对于3D游戏,通常采用1 Unity单位 = 1m是最佳选择;对于不使用照明或物理的2D游戏,在“设计”分辨率阶段,采用1 Unity单位 = 1像素较为合适;对于UI(以及2D游戏),需选择设计分辨率,我们通常使用HD或2xHD,并将所有assets设计为以此分辨率进行缩放。
(二)确保每个场景可独立运行
让每个场景都能独立运行,这样可以避免为了运行游戏而频繁转换场景,从而加快测试速度。若要使某些对象在所有场景加载过程中持续存在,这可能需要一些技巧。一种方法是当持续对象不在场景中时,将它们设计为可自行加载的单例模式,后续会详细阐述单例模式。
(三)有效运用源代码控制
- 将assets序列化为文本:虽然这不会提高场景和Prefab的可合并性,但能使变化更易于观察。
- 采用场景和Prefab共享策略:一般情况下,多人不应同时在同一场景或Prefab中工作。对于小型制作团队,在开始工作前确保无人正在制作该场景或Prefab即可。交换表示场景所有权的物理标记可能会有所帮助,例如,桌面上有场景标记时,你只能在对应的场景中工作。
- 将标签作为书签
- 确定并坚持分支策略:由于场景和Prefab的合并不够平滑,分支策略略显复杂。但当决定使用分支时,应结合场景和Prefab共享策略。
- 谨慎使用子模块:子模块可能是维护可重用代码的最佳方式,但需注意以下问题:
- 元数据文件在多个项目中可能不一致。对于非Monobehaviour或非Scriptable object代码,通常不会有问题,但对于MonoBehaviours和Scriptable objects使用子模块可能会导致代码丢失。
- 如果你参与多个项目(包括一个或多个子模块项目),在多次迭代中对多个项目执行获取 - 合并 - 提交 - 推送操作以稳定所有项目代码时,有时会出现更新崩溃的情况。若其他人同时进行变更,可能会导致持续崩溃。降低此影响的方法是在项目初始阶段对子模块进行更改,这样只需推送仅使用子模块的项目,而无需推回。
(四)分离测试场景和代码
向存储库提交临时资源和脚本,并在完成后将它们移出项目。
(五)同步更新工具
若要更新工具(尤其是Unity),团队成员必须同时进行。当使用与先前不同的版本打开项目时,Unity能更好地保留链接,但如果团队成员使用不同版本,有时仍会出现链接丢失的情况。
(六)安全导入第三方assets
直接向项目导入第三方资源有时会引发问题,如可能存在文件或文件名冲突,尤其是在插件目录根中存在文件或在实例中使用StandardAssets中的assets时;资源可能会无序地放入自有项目文件中,若决定不使用或移除这些assets,会成为一个麻烦。为使assets导入更安全,请按以下步骤操作:
- 创建一个新项目,然后导入asset。
- 运行实例,确保它们能够正常工作。
- 将asset整理为更合适的目录结构。通常不强制为资源设置自有目录结构,但要确保所有文件都在一个目录中,且重要位置不存在可能覆盖项目中现有文件的文件。
- 再次运行实例,确保它们仍能正常工作。有时移动文件可能会导致assets损坏,但这种情况通常较少发生。
- 移除所有无需的事物,如实例。
- 确保asset仍可编译,并且Prefab仍然拥有所有自身的链接。若有需要运行的事项,对其进行测试。
- 选定所有assets,导出一个资源包。
- 将导出的资源包导入到你的项目中。
(七)实现自动构建进程
即使对于小型项目,自动构建进程也很有用,尤其适用于以下情况:
- 需要构建多个不同的游戏版本。
- 其他拥有不同技术知识水平的团队成员需要进行构建。
- 需要对项目进行小幅调整后才能进行构建。 具体可参考Unity构建编译的相关内容,了解如何执行的基本和高级可能性。
(八)为设置建立文档
大部分设置记录应在代码中,但某些事项需记录在代码之外。制作设计师筛选代码中的设置会耗费大量时间,而文档化的设置可以提高效率(前提是文档是最新的)。需要建立文档的内容包括:
- 标签使用
- 图层使用(用于碰撞、剔除和光线投射,本质上是每个图层的对应使用方式)
- 图层的GUI深度(每个图层的显示顺序)
- 场景设置
- 复杂Prefab的Prefab结构
- 常用语偏好
- 构建设置
三、通用编码技巧
(九)使用命名空间
将所有代码放入一个命名空间中,以避免自有库和第三方代码之间可能出现的代码冲突。但不要仅仅依赖命名空间来避免与重要类冲突,即使使用不同的命名空间,也不要将“对象”“动作”或“事件”作为类名称。
(十)运用断言
断言对于测试代码中的不变量非常有用,能够帮助清除逻辑错误。Unity的Assertions.Assert类提供了可用的断言方法,它们可以测试条件,若条件不满足,则在控制台中输出错误信息。若不熟悉如何有效使用断言,可参考使用断言编程的优点(a.k.a.断言语句)。
(十一)谨慎使用字符串
除显示文本外,尽量避免使用字符串。尤其要注意,不要使用字符串来标识对象或Prefab。不过存在一些例外情况,在Unity中某些内容只能通过名称访问,此时可将这些字符串定义为“AnimationNames”或 “AudioModuleNames”等文件中的常量。若这些类变得难以管理,可使用嵌套类,如AnimationNames.Player.Run。
(十二)避免使用“Invoke”和“SendMessage”
“Invoke”和“SendMessage”这两个MonoBehaviour方法通过名称调用其他方法,在代码中难以追踪调用情况(无法找到“Usages”,“SendMessage”的调用范围更广,追踪难度更大)。 可以使用Coroutines和C#操作替代“Invoke”,示例代码如下:
public static Coroutine Invoke(this MonoBehaviour monoBehaviour, Action action, float time)
{
return monoBehaviour.StartCoroutine(InvokeImpl(action, time));
}
private static IEnumerator InvokeImpl(Action action, float time)
{
yield return new WaitForSeconds(time);
action();
}
使用示例:
this.Invoke(ShootEnemy); // 其中ShootEnemy是一个无参数的void方法
若实现自己的基础MonoBehaviour,可向其中添加自己的“Invoke”方法。 对于“SendMessage”,更安全的方法较难实现,通常使用“GetComponent”变量获取父对象、当前游戏对象或子对象的组件,并直接执行调用。
(十三)管理派生对象层次结构
当游戏运行时,不要让派生对象使层次结构变得混乱。将它们的父对象设为场景对象,以便在游戏运行时更容易找到内容。可以使用一个空游戏对象,或者使用一个无行为的单例模式(详见后文),方便从代码中进行访问,可将此对象命名为“DynamicObjects”。
(十四)谨慎处理空值
明确是否将空值(null)作为合法值,并尽量避免这样做。空值可用于检测错误代码,但如果习惯让“if”语句默默地通过空值,错误代码可能会继续运行,且在很久之后才会被发现。此外,空变量在代码中传递时,可能会在代码深处暴露问题。 尽量避免将空值整体作为合法值,优先采用不进行空检查的方式,若出现问题,让代码失败。在“可重用”方法中,若检查到变量为空,可抛出异常,而不是将其传递给其他可能失败的方法。 在某些情况下,值可以合法为空,需要采取不同的处理方式。此时,添加注释解释何时某些内容可能为空以及原因。常见场景是用于inspector配置的值,用户可以指定一个值,若未指定,则使用默认值。可结合包含T值的可选类来处理,示例代码如下:
[Serializable]
public class Optional<T>
{
public bool useCustomValue;
public T value;
}
在代码中的使用示例:
health = healthMax.useCustomValue ? healthMax.Value : DefaultHealthMax;
(十五)有效使用协程
“协程”是解决许多问题的有效方法,但调试协程较为困难,且容易写出混乱的代码,导致他人甚至自己都难以理解。需要掌握以下内容:
- 如何并发执行协程
- 如何按序执行协程
- 如何从现有程序中创建新的协程
- 如何使用“CustomYieldInstruction”创建自定义协程
示例代码如下:
// 按序执行协程
IEnumerator RunInSequence()
{
yield return StartCoroutine(Coroutine1());
yield return StartCoroutine(Coroutine2());
}
// 并发执行协程
public void RunInParallel()
{
StartCoroutine(Coroutine1());
StartCoroutine(Coroutine1());
}
// 创建等待协程
Coroutine WaitASecond()
{
return new WaitForSeconds(1);
}
(十六)利用扩展方法处理接口组件
有时获取实现某个接口的组件或找到这些组件对应的对象会很方便。可以使用扩展方法,示例代码如下:
public static TInterface GetInterfaceComponent<TInterface>(this Component thisComponent)
where TInterface : class
{
return thisComponent.GetComponent(typeof(TInterface)) as TInterface;
}
(十七)利用扩展方法简化语法
例如:
public static class TransformExtensions
{
public static void SetX(this Transform transform, float x)
{
Vector3 newPosition = new Vector3(x, transform.position.y, transform.position.z);
transform.position = newPosition;
}
// 可添加更多扩展方法
}
(十八)使用防御性GetComponent方法
有时通过RequiredComponent强制组件关系可能难以操作,但这通常是可行且可取的,特别是在调用其他类上的GetComponent时。作为替代方法,当需要某个组件并打印找到的错误信息时,可使用以下GameObject扩展方法:
public static T GetRequiredComponent(this GameObject obj) where T : MonoBehaviour
{
T component = obj.GetComponent<T>();
if (component == null)
{
Debug.LogError("Expected to find component of type " + typeof(T) + " but found none", obj);
}
return component;
}
(十九)统一常用语
在许多情况下,存在多种常用方法,此时应在整个项目中选择一种常用语,原因如下:
- 某些常用语不能一起使用,在某个方向上使用一种常用语强行设计可能不适合另一种常用语。
- 整个项目使用相同的常用语能使团队成员更容易理解项目进展,使结构和代码更易理解,减少犯错的可能性。
常用语组示例包括:
- 协程与状态机
- 嵌套的Prefab、互相链接的Prefab和超级Prefab
- 数据分离策略
- 2D游戏中状态使用sprites的方法
- Prefab结构
- 派生策略
- 定位对象的方法:按类型、按名称、按标签、按图层和按引用关系(“链接”)
- 分组对象的方法:按类型、按名称、按标签、按图层和按引用数组(“链接”)
- 调用其他组件方法的途径
- 查找对象组和自注册
- 控制执行次序(使用Unity的执行次序设置、yield逻辑、Awake / Start和Update / Late Update依赖、纯手动方法或采用次序无关的架构)
- 在游戏中使用鼠标选择对象/位置/目标:SelectionManager或者对象自主管理
- 在场景变换时保存数据:通过PlayerPrefs,或者是在新场景加载时未毁损的对象
- 组合(混合、添加和分层)动画的方法
- 输入处理(中央和本地)
(二十)维护自有Time类
维护一个自有的Time类,包装“Time.DeltaTime”和“Time.TimeSinceLevelLoad”,以实现游戏暂停和游戏速度的缩放。虽然使用时可能会有些麻烦,但当对象以不同的时钟速率运行时(如界面动画和游戏动画),会更加方便。
(二十一)避免自定义类访问全局静态时间
需要更新的自定义类不应访问全局静态时间,而应将增量时间作为其Update方法的一个参数。这样在实现暂停系统或想要加快或减慢自定义类的行为时,这些类仍然可用。
(二十二)使用常见结构进行WWW调用
在有大量服务器通信的游戏中,通常会有几十个WWW调用。无论使用Unity的原始WWW类还是某个插件,从生成样板文件的顶部写入一个薄层会带来好处。 通常定义一个Call方法(分别针对Get和Post)、CallImpl协程和MakeHandler。Call方法通过MakeHandler方法,从解析器、成功和失败的处理器构建出一个超级处理器,同时调用CallImpl协程,创建一个URL,进行调用,等待完成后调用超级处理器。示例代码如下:
public void Call<T>(string call, Func<string, T> parser, Action<T> onSuccess, Action<string> onFailure)
{
var handler = MakeHandler(parser, onSuccess, onFailure);
StartCoroutine(CallImpl(call, handler));
}
public IEnumerator CallImpl<T>(string call, Action<T> handler)
{
var www = new WWW(call);
yield return www;
handler(www);
}
public Action<WWW> MakeHandler<T>(Func<string, T> parser, Action<T> onSuccess, Action<string> onFailure)
{
return (WWW www) =>
{
if (NoError(www))
{
var parsedResult = parser(www.text);
onSuccess(parsedResult);
}
else
{
onFailure("error text");
}
};
}
这种方式的优点包括:
- 避免编写大量样板代码。
- 可以在中央位置处理某些事项,如显示加载的UI组件或处理某些通用错误。
(二十三)集中管理大量文本
如果有大量文本,将它们放在同一个文件中,而不要放在inspector将编辑的字段中,这样可以在不打开Unity编辑器,尤其是无需保存场景的情况下方便地更改文本。
(二十四)实现本地化
若要执行本地化,将所有字符串分离到同一个位置。实现方法有多种,一种是针对每个字符串定义一个具有public字符串字段的Text类,默认设为英文,其他语言将其子类化,并使用同等语言重新初始化这些字段。对于文本量较大和/或语言数量较多的情况,可以采用更复杂的技术,将文本读取到电子表格中,并基于所选语言提供选择正确字符串的逻辑。
四、类的设计技巧
(二十五)确定可检查字段标准
确定实现可检查字段的方法,并将其确立为标准。有两种方法:使字段public,或者使它们private并标记为[可序列化]。后者“更正确”但不太方便(当然不是Unity本身常用的方法)。无论选择哪种方式,都要确保团队开发人员知道如何解释一个public字段。
- 可检查字段是public的:此时,public表示“设计师在运行时更改此变量是安全的,避免在代码中设置该值”。
- 可检查字段是private并标记为“可序列化”的:此时,public表示“在代码中更改此变量是安全的”(因此,在MonoBehaviours和ScriptableObjects中不应有太多public字段)。
(二十六)谨慎设置public变量
对于组件,不要将不应在inspector中调整的变量设为public,否则可能会被设计师误调整,特别是当变量用途不明确时。在某些罕见情况下无法避免时,可使用两条甚至四条下划线对变量名添加前缀以警告调整人员,例如:
public float __aVariable;
(二十七)使用Property Drawers
使用Property Drawers可以自定义inspector中的控制,创建更适合数据性质的控制,并实施某些安全保护,如限定变量范围。
(二十八)优先选择Property Drawers
相较于Custom Editors,更应优先采用Property Drawers。Property Drawers是根据字段类型实现的,工作量较少,重用性更佳,一旦实现某一类型,可应用于包含此类型的任何类;而Custom Editors是根据MonoBehaviour实现的,重用性较差,工作量较大。
(二十九)默认密封MonoBehaviours
一般来说,Unity MonoBehaviours的继承友好性不高。类似于Start和Update,Unity调用信息的方式使得在子类中使用这些方法较为困难,容易调用错误内容或忘记调用基本方法。使用custom editors时,通常需要复制继承层次结构。任何人扩展某一类时,必须提供自己的editor,或者使用现有的editor。 在继承调用时,若可以避免,不要提供任何Unity信息方法;若需要提供,不要将它们设为虚拟化。可以定义一个从信息方法调用的空的虚拟函数,子类可以覆盖此方法来执行其他工作。示例代码如下:
public class MyBaseClass
{
public sealed void Update()
{
CustomUpdate();
// This class's update
}
// Called before this class does its own update
// Override to hook in your own update code.
virtual public void CustomUpdate(){};
}
public class Child : MyBaseClass
{
override public void CustomUpdate()
{
// Do custom stuff
}
}
这种方式可以防止某一类意外地覆盖代码,但可能会存在事项次序的问题,例如子类可能想在父类更新后立即执行。
(三十)分离游戏逻辑和接口
一般来说,接口组件不应了解所应用游戏的具体内容。向它们提供需要可视化的数据,并订阅事件以了解用户与它们交互的时间。接口组件不应创建游戏逻辑,可筛选输入以确认其有效性,但主规则处理应在其他位置进行。例如,在许多拼图游戏中,拼图块作为接口扩展,不应包含任何规则(如棋子不应计算自身的合法移动)。 类似地,输入应与作用于此输入的逻辑分离。使用一个通知actor移动意图的输入控制器,由actor处理是否实际移动。以下是一个允许用户从选项列表中选择武器的UI组件的简化示例:
public class WeaponSelector : MonoBehaviour
{
public event Action<Weapon> OnWeaponSelect { add; remove; }
// the GameManager can register for this event
public void OnInit(List<Weapon> weapons)
{
foreach (var weapon in weapons)
{
var button = ... // Instantiates a child button and add it to the hierarchy
button.OnInit(weapon, () => OnSelect(weapon));
// child button displays the option,
// and sends a click-back to this component
}
}
public void OnSelect(Weapon weapon)
{
if (OnWeaponSelect != null) OnWeaponSelect(weapon);
}
}
public class WeaponButton : MonoBehaviour
{
private Action onClick;
public void OnInit(Weapon weapon, Action onClick)
{
... // set the sprite and text from weapon
this.onClick = onClick;
}
public void OnClick() // Link this method in as the OnClick of the UI Button component
{
Assert.IsTrue(onClick != null); // Should not happen
onClick();
}
}
(三十一)分离配置、状态和簿记变量
- 配置变量:指一类被inspector调整从而通过其属性定义对象的变量,如maxHealth。
- 状态变量:指一类可完全确定对象当前状态的变量,以及如果游戏支持保存操作,需要保存的一类变量,如currentHealth。
- 簿记变量:用于速度、方便或过度状态,总是可以通过状态变量确定,如previousHealth。
通过分离这些变量类型,可以更清楚地知道哪些变量可以更改、哪些需要保存、哪些需要通过网络发送/检索,并在一定程度上强制执行此类操作。示例代码如下:
public class Player
{
[Serializable]
public class PlayerConfigurationData
{
public float maxHealth;
}
[Serializable]
public class PlayerStateData
{
public float health;
}
public PlayerConfigurationData configuration;
private PlayerStateData stateData;
// book keeping
private float previousHealth;
public float Health
{
get { return stateData.health; }
private set { stateData.health = value; }
}
}
(三十二)避免使用public索引耦合数组
避免定义武器数组、子弹数组和颗粒数组等,以免代码出现类似以下情况:
public void SelectWeapon(int index)
{
currentWeaponIndex = index;
Player.SwitchWeapon(weapons[currentWeapon]);
}
public void Shoot()
{
Fire(bullets[currentWeapon]);
FireParticles(particles[currentWeapon]);
}
这类问题在代码中不易察觉,但在inspector设置时可能不会报错。应定义封装三个变量的类,并创建相应数组,示例代码如下:
[Serializable]
public class Weapon
{
public GameObject prefab;
public ParticleSystem particles;
public Bullet bullet;
}
这样代码更整洁,且在inspector中设置数据时更不容易出错。
(三十三)避免使用除序列以外的结构数组
例如,玩家可能有三种攻击类型,每种类型使用当前武器,但生成不同的子弹和不同的行为。不要将三个子弹转储到某个数组中并使用类似以下逻辑:
public void FireAttack()
{
Fire(bullets[0]);
}
public void IceAttack()
{
Fire(bullets[1]);
}
public void WindAttack()
{
Fire(bullets[2]);
}
使用Enums在代码中可能会有所改善,但最好使用分离变量,通过名称辅助显示将放入的内容。示例代码如下:
[Serializable]
public class Bullets
{
public Bullet fireBullet;
public Bullet iceBullet;
public Bullet windBullet;
}
此示例假设不存在其他火、冰和风的数据。
(三十四)集中管理数据
对于有几十个可调参数的实体,将数据集中在可序列化类中,使inspector中的事项更易于管理。具体步骤如下:
- 为各变量组定义分离类,使其公开化和可序列化。
- 在主类中,将上述每个类型的变量定义为公开。
- 不要在Awake或Start中初始化这些变量,由于它们是可序列化的,Unity会对其进行处理。可以通过在定义中分配值来指定先前的默认值。
示例代码如下:
[Serializable]
public class MovementProperties // Not a MonoBehaviour!
{
public float movementSpeed;
public float turnSpeed = 1; // default provided
}
public class HealthProperties // Not a MonoBehaviour!
{
public float maxHealth;
public float regenerationRate;
}
public class Player : MonoBehaviour
{
public MovementProperties movementProperties;
public HealthProperties healthProperties;
}
这样可以将变量集中到inspector中的可折叠单元,便于管理。
(三十五)使非MonoBehaviours类可序列化
即使非MonoBehaviours类不用于public字段,也使其可序列化。当Inspector处于Debug模式下,允许查看inspector中的类字段,这同样适用于嵌套的类(私密或公开)。
(三十六)避免修改Inspector可编辑变量
避免通过代码修改那些在Inspector中可编辑的变量。Inspector中可调整的变量属于配置变量,不应视为运行期间的常量,更不能作为状态变量。按照这种操作,将组件状态重置为初始状态的编写方法更加简便,变量动作也更清晰。示例代码如下:
public class Actor : MonoBehaviour
{
public float initialHealth = 100;
private float currentHealth;
public void Start()
{
ResetState();
}
private void Respawn()
{
// 具体实现
}
}