编码指南 - MRTK2

本文档概述了参与编写 MRTK 时应遵循的编程原则和约定。


原则

简明扼要,力求简单

最简单的解决方案通常是最佳解决方案。 这是这些准则的一个首要目标,应成为所有编程活动的目标。 简单的一部分是简洁,并且与现有代码一致。 尽量使代码保持简单。

读者只应遇到提供有用信息的项目。 例如,重新陈述明显情况的评论不会提供额外信息,并且会增加信噪比。

使代码逻辑保持简单。 请注意,这不是有关使用最小行数、最小化标识符名称或大括号样式的大小的语句,而是关于减少概念的数量并通过熟悉的模式最大化这些概念的可见性的语句。

生成一致的可读代码

代码可读性与低缺陷率相关。 力求创建易于阅读的代码。 力求创建具有简单逻辑的代码,并重新使用现有组件,因为它还有助于确保正确性。

从正确性的最基本细节到一致的样式和格式设置,所生成代码的所有详细信息都很重要。 保持编程样式与现有样式一致,即使它不符合你的偏好。 这提高了整体代码库的可读性。

支持在编辑器中和运行时配置组件

MRTK 支持一组不同的用户 - 喜欢在 Unity 编辑器中配置组件和加载预制件的用户,以及需要在运行时实例化并配置对象的用户。

所有代码都应通过向已保存场景中的 GameObject 添加组件,以及通过代码实例化该组件来工作。 测试应包括一个测试用例,用于实例化预制件和在运行时实例化并配置组件。

Play-in-editor 是首个和主要目标平台

Play-In-Editor 是在 Unity 中迭代的最快方法。 为客户提供快速迭代的方法可以让他们更快开发解决方案并尝试更多想法。 换句话说,最大化迭代速度可帮助客户实现更多目标。

使一切在编辑器中正常工作,然后使其在任何其他平台上运行。 在编辑器中保持工作。 将新平台添加到 Play-In-Editor 很容易。 如果应用仅在设备上工作,则很难让 Play-In-Editor 正常工作。

谨慎添加新的公共字段、属性、方法和序列化私有字段

每次添加公共方法、字段、属性时,它都会成为 MRTK 的公共 API 外围应用的一部分。 标记为 [SerializeField] 的私有字段还向编辑器公开字段,并且属于公共 API 外围应用。 其他人可能会使用该公共方法,使用公共字段配置自定义预制件,并依赖于它。

应仔细检查新的公共成员。 任何公共字段将来都需要维护。 请记住,如果公共字段的类型(序列化的私有字段)更改或从 MonoBehaviour 删除,则可能会中断其他人。 该字段首先需要在发布时弃用,并且需要提供代码来为具有依赖项的人员迁移更改。

优先编写测试

MRTK 是一个社区项目,由各种参与者修改。 这些参与者可能不知道 bug 修复/功能的详细信息,并意外中断你的功能。 MRTK 在完成每个拉取请求之前运行持续集成测试。 无法签入中断测试的更改。 因此,测试是确保其他人不会破坏你的功能的最佳方法。

修复 bug 时,编写测试以确保它将来不会回退。 如果添加功能,请编写验证功能是否正常工作的测试。 这是除实验性功能之外的所有 UX 功能所必需的。

C# 编程约定

脚本许可证信息标头

所有提供新文件的 Microsoft 员工都应在任何新文件的顶部添加以下标准许可证标头,如下所示:

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

函数/方法摘要标头

发布到 MRTK 的所有公共类、结构、枚举、函数、属性、字段都应描述为其用途和使用,具体如下所示:

/// <summary>
/// The Controller definition defines the Controller as defined by the SDK / Unity.
/// </summary>
public struct Controller
{
    /// <summary>
    /// The ID assigned to the Controller
    /// </summary>
    public string ID;
}

这可确保正确生成文档,并针对所有类、方法和属性分发文档。

任何在没有正确摘要标记的情况下提交的脚本文件都将被拒绝。

MRTK 命名空间规则

混合现实工具包使用基于特征的命名空间模型,其中所有基础命名空间都以“Microsoft.MixedReality.Toolkit”开头。 一般情况下,无需在命名空间中指定工具层,例如:Core、提供程序、服务。

当前定义的命名空间包括:

  • Microsoft.MixedReality.Toolkit
  • Microsoft.MixedReality.Toolkit.Boundary
  • Microsoft.MixedReality.Toolkit.Diagnostics
  • Microsoft.MixedReality.Toolkit.Editor
  • Microsoft.MixedReality.Toolkit.Input
  • Microsoft.MixedReality.Toolkit.SpatialAwareness
  • Microsoft.MixedReality.Toolkit.Teleport
  • Microsoft.MixedReality.Toolkit.Utilities

对于具有大量类型的命名空间,可以创建有限数量的子命名空间,帮助确定使用范围。

省略接口、类或数据类型的命名空间将导致更改被阻止。

添加新的 MonoBehaviour 脚本

使用拉取请求添加新的 MonoBehaviour 脚本时,请确保将 AddComponentMenu 属性应用于所有适用的文件。 这可确保在编辑器中的“添加组件”按钮下容易发现组件。 如果组件无法在编辑器(如抽象类)中显示,则不需要属性标志。

在下面的示例中,“此处的包”应填写组件的包位置。 如果将项放入 MRTK/SDK 文件夹,包将为 SDK

[AddComponentMenu("Scripts/MRTK/{Package here}/MyNewComponent")]
public class MyNewComponent : MonoBehaviour

添加新的 Unity 检查器脚本

通常,请尝尽量避免为 MRTK 组件创建自定义检查器脚本。 这会增加 Unity 引擎可以处理的代码库的额外开销和管理。

如果需要检查器类,请尝试使用 Unity 的 DrawDefaultInspector()。 这再次简化了检查器类,将大部分工作留给了 Unity。

public override void OnInspectorGUI()
{
    // Do some custom calculations or checks
    // ....
    DrawDefaultInspector();
}

如果检查器类需要自定义渲染,请尝试使用 SerializedPropertyEditorGUILayout.PropertyField。 这将确保 Unity 正确处理渲染嵌套预制件和修改后的值。

如果 EditorGUILayout.PropertyField 由于自定义逻辑中的要求而无法使用,请确保所有使用情况都环绕在 EditorGUI.PropertyScope 周围。 这将确保 Unity 为具有给定属性的嵌套预制件和已修改值正确渲染检查器。

此外,请尝试使用 CanEditMultipleObjects 修饰自定义检查器类。 此标记可确保一起选择和修改场景中具有此组件的多个对象。 任何新的检查器类都应测试其代码在场景中的此情况下是否正常工作。

    // Example inspector class demonstrating usage of SerializedProperty & EditorGUILayout.PropertyField
    // as well as use of EditorGUI.PropertyScope for custom property logic
    [CustomEditor(typeof(MyComponent))]
    public class MyComponentInspector : UnityEditor.Editor
    {
        private SerializedProperty myProperty;
        private SerializedProperty handedness;

        protected virtual void OnEnable()
        {
            myProperty = serializedObject.FindProperty("myProperty");
            handedness = serializedObject.FindProperty("handedness");
        }

        public override void OnInspectorGUI()
        {
            EditorGUILayout.PropertyField(destroyOnSourceLost);

            Rect position = EditorGUILayout.GetControlRect();
            var label = new GUIContent(handedness.displayName);
            using (new EditorGUI.PropertyScope(position, label, handedness))
            {
                var currentHandedness = (Handedness)handedness.enumValueIndex;

                handedness.enumValueIndex = (int)(Handedness)EditorGUI.EnumPopup(
                    position,
                    label,
                    currentHandedness,
                    (value) => {
                        // This function is executed by Unity to determine if a possible enum value
                        // is valid for selection in the editor view
                        // In this case, only Handedness.Left and Handedness.Right can be selected
                        return (Handedness)value == Handedness.Left
                        || (Handedness)value == Handedness.Right;
                    });
            }
        }
    }

添加新的 ScriptableObjects

添加新的 ScriptableObject 脚本时,请确保将 CreateAssetMenu 属性应用于所有适用的文件。 这可确保通过资产创建菜单在编辑器中轻松发现组件。 如果组件无法在编辑器(如抽象类)中显示,则不需要属性标志。

在下面的示例中,“子文件夹”应填写 MRTK 子文件夹(如果适用)。 如果将项放入 MRTK/providers 文件夹,则包将为提供程序。 如果将项放入 MRTK/Core 文件夹,请将此项设置为“配置文件”

在下面的示例中,“MyNewService | MyNewProvider”应填写新类的名称(如果适用)。 如果将项放入 MixedRealityToolkit 文件夹中,请忽略此字符串

[CreateAssetMenu(fileName = "MyNewProfile", menuName = "Mixed Reality Toolkit/{Subfolder}/{MyNewService | MyNewProvider}/MyNewProfile")]
public class MyNewProfile : ScriptableObject

日志记录

添加新功能或更新现有功能时,请考虑将 DebugUtilities.LogVerbose 日志添加到可能对将来调试有用的有趣代码中。 在添加日志记录和添加干扰以及日志记录不足(这使诊断变得困难)之间存在一个折中。

一个有趣的示例,其中日志记录有用(以及有趣的有效负载):

DebugUtilities.LogVerboseFormat("RaiseSourceDetected: Source ID: {0}, Source Type: {1}", source.SourceId, source.SourceType);

这种类型的日志记录有助于捕获类似 https://github.com/microsoft/MixedRealityToolkit-Unity/issues/8016 的问题,这是由于检测到的源不匹配和源丢失事件引起的。

避免添加每个帧上发生的数据和事件的日志 - 理想情况下,日志记录应涵盖由不同用户输入驱动的“有趣”事件(即用户“单击”以及值得记录的有趣的更改和事件集)。 每帧记录的“用户仍在保持手势”的持续状态并不有趣,并且使日志重负。

请注意,默认情况下不启用此详细日志记录(必须在诊断系统设置中启用该日志记录)

空格与制表符

参与此项目时,请务必使用 4 个空格而不是制表符。

间距

请不要在方括号和圆括号之间添加其他空格:

不要

private Foo()
{
    int[ ] var = new int [ 9 ];
    Vector2 vector = new Vector2 ( 0f, 10f );
}

private Foo()
{
    int[] var = new int[9];
    Vector2 vector = new Vector2(0f, 10f);
}

命名约定

始终对属性使用 PascalCase。 对大多数字段使用 camelCase,除了对 static readonlyconst 字段使用 PascalCase。 这种情况唯一的例外是对于要求字段由 JsonUtility 序列化的数据结构。

不要

public string myProperty; // <- Starts with a lowercase letter
private string MyField; // <- Starts with an uppercase letter

public string MyProperty;
protected string MyProperty;
private static readonly string MyField;
private string myField;

访问修饰符

始终声明所有字段、属性和方法的访问修饰符。

  • 默认情况下,所有 Unity API 方法应为 private,除非需要在派生类中重写这些方法。 在这种情况下应使用 protected

  • 字段应始终为 private,具有 publicprotected 属性访问器。

  • 尽可能使用 expression-bodied 成员自动属性

不要

// protected field should be private
protected int myVariable = 0;

// property should have protected setter
public int MyVariable => myVariable;

// No public / private access modifiers
void Foo() { }
void Bar() { }

public int MyVariable { get; protected set; } = 0;

private void Foo() { }
public void Bar() { }
protected virtual void FooBar() { }

使用大括号

请始终在每个语句块后使用大括号,并将其放在下一行。

禁止事项

private Foo()
{
    if (Bar==null) // <- missing braces surrounding if action
        DoThing();
    else
        DoTheOtherThing();
}

不要

private Foo() { // <- Open bracket on same line
    if (Bar==null) DoThing(); <- if action on same line with no surrounding brackets
    else DoTheOtherThing();
}

private Foo()
{
    if (Bar==true)
    {
        DoThing();
    }
    else
    {
        DoTheOtherThing();
    }
}

公共类、结构以及枚举都应放入其自己的文件中

如果类、结构或枚举可以成为私有的,则可以包括在同一文件中。 这样可以避免 Unity 的编译问题,并确保发生正确的代码抽象,还减少了代码需要更改时的冲突和中断性变更。

不要

public class MyClass
{
    public struct MyStruct() { }
    public enum MyEnumType() { }
    public class MyNestedClass() { }
}

 // Private references for use inside the class only
public class MyClass
{
    private struct MyStruct() { }
    private enum MyEnumType() { }
    private class MyNestedClass() { }
}

应做事项

MyStruct.cs

// Public Struct / Enum definitions for use in your class.  Try to make them generic for reuse.
public struct MyStruct
{
    public string Var1;
    public string Var2;
}

MyEnumType.cs

public enum MuEnumType
{
    Value1,
    Value2 // <- note, no "," on last value to denote end of list.
}

MyClass.cs

public class MyClass
{
    private MyStruct myStructReference;
    private MyEnumType myEnumReference;
}

初始化枚举

为了确保从 0 开始正确初始化所有枚举,.NET 提供了一个整洁的快捷方式,只需添加第一个(起始)值即可自动初始化枚举。 (例如值 1 = 0 不需要其余值)

不要

public enum Value
{
    Value1, <- no initializer
    Value2,
    Value3
}

public enum ValueType
{
    Value1 = 0,
    Value2,
    Value3
}

相应扩展的排序枚举

如果将来可能会扩展枚举,以便对枚举顶部的默认值排序,这可以确保枚举索引不受新增功能的影响,这一点至关重要。

不要

public enum SDKType
{
    WindowsMR,
    OpenVR,
    OpenXR,
    None, <- default value not at start
    Other <- anonymous value left to end of enum
}

/// <summary>
/// The SDKType lists the VR SDKs that are supported by the MRTK
/// Initially, this lists proposed SDKs, not all may be implemented at this time (please see ReleaseNotes for more details)
/// </summary>
public enum SDKType
{
    /// <summary>
    /// No specified type or Standalone / non-VR type
    /// </summary>
    None = 0,
    /// <summary>
    /// Undefined SDK.
    /// </summary>
    Other,
    /// <summary>
    /// The Windows 10 Mixed reality SDK provided by the Universal Windows Platform (UWP), for Immersive MR headsets and HoloLens.
    /// </summary>
    WindowsMR,
    /// <summary>
    /// The OpenVR platform provided by Unity (does not support the downloadable SteamVR SDK).
    /// </summary>
    OpenVR,
    /// <summary>
    /// The OpenXR platform. SDK to be determined once released.
    /// </summary>
    OpenXR
}

查看位域的枚举用途

如果枚举可能需要多个状态作为值,例如惯用手 = 左和右。 然后,需使用 BitFlag 正确修饰枚举,才能正确使用枚举

Handedness.cs 文件对此有一个具体实现

不要

public enum Handedness
{
    None,
    Left,
    Right
}

[Flags]
public enum Handedness
{
    None = 0 << 0,
    Left = 1 << 0,
    Right = 1 << 1,
    Both = Left | Right
}

硬编码的文件路径

生成字符串文件路径(尤其是编写硬编码的字符串路径)时,执行以下操作:

  1. 尽可能使用 C# 的 Path API,如 Path.CombinePath.GetFullPath
  2. 使用 / 或 Path.DirectorySeparatorChar 而不是 \ 或 \\。

这些步骤可确保 MRTK 在基于 Windows 和 Unix 的系统上都能正常工作。

不要

private const string FilePath = "MyPath\\to\\a\\file.txt";
private const string OtherFilePath = "MyPath\to\a\file.txt";

string filePath = myVarRootPath + myRelativePath;

private const string FilePath = "MyPath/to/a/file.txt";
private const string OtherFilePath = "folder{Path.DirectorySeparatorChar}file.txt";

string filePath = Path.Combine(myVarRootPath,myRelativePath);

// Path.GetFullPath() will return the full length path of provided with correct system directory separators
string cleanedFilePath = Path.GetFullPath(unknownSourceFilePath);

最佳做法,包括 Unity 建议

此项目的一些目标平台需要考虑性能。 请记住,在紧密更新循环或算法中频繁调用的代码中分配内存时,请始终小心。

封装

如果需要从类或结构外部访问字段,请始终使用私有字段和公共属性。 请确保将私有字段和公共属性放在一起。 这样一来,可以更轻松地一目了然地查看持该属性的内容以及该字段可通过脚本修改。

注意

这种情况的唯一例外是要求字段由 JsonUtility 序列化的数据结构,其中数据类需要具有所有公共字段才能使序列化正常工作。

不要

private float myValue1;
private float myValue2;

public float MyValue1
{
    get{ return myValue1; }
    set{ myValue1 = value }
}

public float MyValue2
{
    get{ return myValue2; }
    set{ myValue2 = value }
}

// Enable field to be configurable in the editor and available externally to other scripts (field is correctly serialized in Unity)
[SerializeField]
[ToolTip("If using a tooltip, the text should match the public property's summary documentation, if appropriate.")]
private float myValue; // <- Notice we co-located the backing field above our corresponding property.

/// <summary>
/// If using a tooltip, the text should match the public property's summary documentation, if appropriate.
/// </summary>
public float MyValue
{
    get => myValue;
    set => myValue = value;
}

/// <summary>
/// Getter/Setters not wrapping a value directly should contain documentation comments just as public functions would
/// </summary>
public float AbsMyValue
{
    get
    {
        if (MyValue < 0)
        {
            return -MyValue;
        }

        return MyValue
    }
}

缓存值,并尽可能在场景/预制件中序列化这些值

考虑到 HoloLens,最好优化场景或预制件中的性能和缓存引用,以限制运行时内存分配。

不要

void Update()
{
    gameObject.GetComponent<Renderer>().Foo(Bar);
}

[SerializeField] // To enable setting the reference in the inspector.
private Renderer myRenderer;

private void Awake()
{
    // If you didn't set it in the inspector, then we cache it on awake.
    if (myRenderer == null)
    {
        myRenderer = gameObject.GetComponent<Renderer>();
    }
}

private void Update()
{
    myRenderer.Foo(Bar);
}

缓存对材料的引用,不要每次调用“.material”

每次使用 ".material" 时,Unity 都会创建新材料,如果未正确清理,则会导致内存泄漏。

不要

public class MyClass
{
    void Update()
    {
        Material myMaterial = GetComponent<Renderer>().material;
        myMaterial.SetColor("_Color", Color.White);
    }
}

// Private references for use inside the class only
public class MyClass
{
    private Material cachedMaterial;

    private void Awake()
    {
        cachedMaterial = GetComponent<Renderer>().material;
    }

    void Update()
    {
        cachedMaterial.SetColor("_Color", Color.White);
    }

    private void OnDestroy()
    {
        Destroy(cachedMaterial);
    }
}

注意

或者,使用 Unity 的 "SharedMaterial" 属性,该属性不会每次引用材料时都创建新材料。

使用平台依赖编译来确保工具包不会中断另一个平台上的生成

  • 使用 WINDOWS_UWP 来使用 UWP 特定的非 Unity API。 这会阻止它们尝试在编辑器中或不受支持的平台中运行。 这等效于 UNITY_WSA && !UNITY_EDITOR 并且应使用来取代。
  • 使用 UNITY_WSA 来使用 UWP 特定的 Unity API,如 UnityEngine.XR.WSA 命名空间。 当平台设置为 UWP 时,这将在编辑器以及内置 UWP 应用中运行。

此图表可帮助你根据自己的用例和所需的生成设置来决定要使用哪个 #if

平台 UWP IL2CPP UWP .NET 编辑器
UNITY_EDITOR False False True
UNITY_WSA True True True
WINDOWS_UWP True True
UNITY_WSA && !UNITY_EDITOR True True
ENABLE_WINMD_SUPPORT True True 错误
NETFX_CORE False True False

首选 DateTime.UtcNow,而非 DateTime.Now

DateTime.UtcNow 比 DateTime.Now 更快。 在以前的性能调查中,我们发现使用 DateTime.Now 会增加大量开销,尤其是在 Update() 循环中使用时。 其他人遇到了相同的问题

首选使用 DateTime.UtcNow,除非实际需要本地化时间(原因可能是你想要在用户的时区显示当前时间)。 如果要处理相对时间(即上次更新和现在之间的增量),则最好使用 DateTime.UtcNow 以避免执行时区转换的开销。

PowerShell 编程约定

MRTK 代码库的子集将 PowerShell 用于管道基础结构和各种脚本以及实用程序。 新 PowerShell 代码应遵循 PoshCode 样式

另请参阅

MSDN 中的 C# 编码约定