对资源的规范化我们能做什么

游戏开发中,资源的管理是一项非常精细的活,不同类型的资源往往拥有完全不同的管理方式和配置方式,本文希望从 5 个不同的管理级别对资源管理进行解构

  • 致命资源错误不让保存
  • 保存了不让提交
  • 提交了可以快速发现问题
  • 发现问题可以快速修复
  • 版本问题记录

对于可以使用 AssetPostprocessor 解决的问题,这里就不做介绍了,这种问题一般都非常简单,如果不清楚如何使用,可以搜一下相关实现

本文提到的工具,后面如果时间充足,整理完毕后,应该会开源,涉及代码的部分因为会比较冗长,可能会只贴原理部分

致命资源错误不让保存

这里不让保存的原理非常简单,我们需要监听 prefab 保存的事件,在保存前,检查自定义的规则,一旦发现错误,直接抛一个异常即可阻止保存

需要注意的是,虽然我们会监听足够的保存事件,但仍然有取巧的办法突破这个限制,对于这种情况,一般都是 git blame 找到是谁干的,定性为恶意行为

要实现这个过程,我们需要使用 InitializeOnLoadInitializeOnLoadMethod 在 Editor 编译时,自动注册我们的监听函数,这里需要监听如下几个事件

  1. PrefabUtility.prefabInstanceUpdated
    • ApplyAll Prefab 保存事件
  2. UnityEditor.SceneManagement.PrefabStage.prefabSaving
    • Open Prefab 保存事件
  3. UnityEditor.SceneManagement.PrefabStage.prefabStageOpened
    • Open Prefab 打开事件
  4. Selection.selectionChanged
    • 选中 Prefab 的事件

这里的 1 和 2 对应两种不同情况的 prefab 保存事件,可以统一处理过程,3 和 4 是为了处理某些情况下无法获取到当前 prefab 路径的问题,临时缓存一下为接下来有效性验证做准备

selectionChanged 比较特殊,在 Unity Assets 中,我们可以直接点击某个 prefab 对最外层的 GameObject 进行修改,所以这个事件是为了阻止这类错误改动

至于后续的处理过程就比较简单,这里我们可以通过自定义 ScriptObject 配置,来粒度控制每种 prefab 的具体处理过程,这里就不在赘述了

Preset Prefab 只读

在说这个规则之前,我们要明白 Unity prefab 嵌套规则,当 A 嵌套了 B,此时在 A 中 修改 B 的属性,我们 apply 的时候,有两个分支,一个是 apply 到 A,另一个是 apply 到 B,这两个分支对应的结果会 完全不一致

如果我们 apply 到 A,那么当我们单独编辑 B 时,可能会导致部分属性无法直接同步到 A 上,而这个并不是我们希望看到的,如果我们 apply 到 B 上,大部分情况下所有嵌套了 B 的 prefab 都会同步这个改动

这个规则就是为了禁止在 A 上直接修改 B 中核心组件

受到 UGUI-Editor 这个工具的启发,我们可以将一些非常通用的组件做成一个个的 preset,通过 preset 窗口将已有的内容拖到对应的 UI 中

比如 UI 中通用的确认、取消按钮,可以作为 preset 资源供开发者选取

比较可惜的是这个库作者应该是不维护了,我自己是改动了一下,适配了 2021,因为引入了 preset 的制作流程,那么我们应当更进一步,只要发现 prefab 中存在 preset 资源,需要按照规则将部分组件设为只读,不可修改

这里对通用的关闭按钮增加了一个规则,这个 prefab 下的所有 Image 组件会被设为 readonly

核心代码也非常简单,就一行

component.hideFlags = HideFlags.NotEditable;

在致命错误禁止保存的过程中,我们监听了足够多的 prefab 回调,在 prefab 打开时,我们调用这个 NoEdit 函数,对 preset 中的预制按照规则将每个 commponet 设为 NotEditable 即可

基于这个流程,我们可以保证凡是 preset 中的资源,不可以在任何嵌套的 prefab 中直接修改,仅允许直接打开这个 preset 时进行修改

GameObject Copy

我们有部分 prefab 的制作流程是交给美术进行的,美术在制作部分资源时往往没有程序那么严谨,通常会特别喜欢从现成的组件中直接 copy,这个时候可能会导致一些极其难查的问题出现,曾经也是非常头大...

比如游戏中的红点,通过直接挂载 Mono 脚步的方式来实现,每个脚本上会标记当前红点的路径,此时这个 GameObject 被复制并保存到其他 UI 上,这个问题直接让你查到裂开

跟上面一样,我们仍然将所有的规则都配置到 ScriptObject 中

这里的规则就是,禁止 View.RedDotUI 组件的复制

ScriptObject 的代码比较简单我就不贴了,下方为禁止复制的处理过程,稍微有些绕,不贴代码很难讲清楚处理过程

internal class CopyAutomation
{
    static CopyAutomation() { EditorApplication.hierarchyWindowItemOnGUI += _HierarchyWindowItemOnGUI; }

    private static double _threshold = 0.1f;
    private static double _last_tick = 0;

    private static void _HierarchyWindowItemOnGUI(int instance_id, Rect selection_rect)
    {
        Event e = Event.current;

        // 如果是无效的指令
        // 直接退出
        if(e.type != UnityEngine.EventType.ValidateCommand)
        {
            return;
        }

        // 这里 id = 0时, 说明没有选择任何对象
        // Hierarchy 传入 id = 当前选择对象
        if(Selection.activeInstanceID != 0 && instance_id != Selection.activeInstanceID)
        {
            return;
        }
        
        switch(e.commandName)
        {
            // 只处理如下两种指令
            case"Duplicate":
            case"Paste":

                // 需要等 Hierarchy 已经刷新后, 再进行校验
                // 此时 Selection 当前选择的对象就是 复制好的
                EditorApplication.delayCall += _ValidateComponent;
                break;
        }
    }

    private static void _ValidateComponent()
    {
        EditorApplication.delayCall -= _ValidateComponent;

        // 如果是 Prefab 实例, 那么不做任何校验
        if(PrefabUtility.IsPartOfPrefabInstance(Selection.activeObject))
        {
            return;
        }

        CopyConfigs configs = CopyConfigs.default_configs;

        if(!configs.enable)
        {
            return;
        }

        double tick = TimeSpan.FromTicks(DateTime.Now.Ticks).TotalSeconds;

        // 此处阻止短时间内的多次检测
        if(_last_tick - tick > _threshold)
        {
            return;
        }

        _last_tick = tick;

        var components = Selection.activeGameObject.GetComponentsInChildren<Component>();

        HashSet<string> error_com = new HashSet<string>();

        foreach(var component in components)
        {
            Type type = component.GetType();

            foreach(CopyConfig config in configs.configs)
            {
                if(!configs.enable)
                {
                    continue;
                }

                if(!string.Equals(type.FullName, config.type))
                {
                    continue;
                }


                if(!error_com.Contains(type.FullName))
                {
                    error_com.Add(type.FullName);
                }

                UnityEngine.Object.DestroyImmediate(component);
            }
        }

        if(error_com.Count <= 0)
        {
            return;
        }

        string all_name = string.Empty;

        foreach(string name in error_com)
        {
            all_name = \$"{all_name}, {name}";
        }

        EditorUtility.DisplayDialog("警告", \$"当前检测到不允许复制的组件 {all_name}, 已经自动移除", "确定");
    }
}

保存了不让提交

不让提交这个事情我们就需要 git hook 这个老朋友出场了,这个过程非常简单,在运行 Unity 时,检查 .git/hook/pre-commit 脚本是否为最新的,不是就复制一份过来

sync 脚本参考 hook 脚本参考

HookConfig.conf 这个文件是为了照顾一些 GUI 的用户,确实需要修改一些禁止修改的文件时,通过开关临时关闭这条规则

对于命令行用户跳过 hook 就很简单了 git commit -m "xxx" -n

提交了可以快速发现问题

资源规范的制定往往是一点点建立的,某些资源在最开始通过了上述所有验证,但是新增规则后,现有的资源就存在问题,此时感知问题的存在是非常非常重要的,而且更重要的是,这个感知过程编写的成本要足够低

这个时候就需要 Odin Validator 3.1 版本出马了

Odin 的团队真是神了,期待一下 基于 UIToolkit 的版本,如果你还没听过 Odin 那可真的太遗憾了...

这条检测规则为:凡是发现 ParticleSystem 中 max particles 数量大于 30,直接报错

具体的代码会贴在下面

发现问题可以快速修复

当我们已经拥有感知问题的能力后,你一定不希望一些小的问题都需要自己亲力亲为来修复,这条规则同样是基于 Odin Validator 3.1 在感知问题的同时,Odin 给了一个快速 Fix 的方式

注意这里红框的部分,凡是有这个小扳手的错误,我们都可以直接点击 Execute 一键修复

下方代码定义了两组规则,首先粒子禁止开启 prewarmmaxParticles 默认不允许超过 30,如果是ParticleSystemRenderMode.Mesh 的方式,则会限制为 5

这里对 ParticalSystem 属性的修改,在不同版本的 Unity 中会不一致,这里是 Unity 2021,是通过 SerializedObject 来间接修改的

#if UNITY_EDITOR
using Sirenix.OdinInspector.Editor.Validation;
using UnityEngine;
using UnityEditor;

[assembly:
    RegisterValidationRule(typeof(ParticleSystemValidator), Name = "粒子合法校验", Description = "Some description text.")]

public class ParticleSystemValidator : RootObjectValidator<ParticleSystem>
{
    protected override void Validate(ValidationResult result)
    {
        ParticleSystem ps = Object;

        if(ps.main.prewarm)
        {
            result.AddError("ParticleSystem should not prewarm!").
                   WithFix(
                       Fix.Create(
                           "Disable Prewarm",
                           () =>
                           {
                               var so = new SerializedObject(ps);

                               var prewarm = so.FindProperty("prewarm");
                               if(prewarm is null || !prewarm.boolValue)
                               {
                                   return;
                               }

                               prewarm.boolValue = false;
                               so.ApplyModifiedProperties();
                           }
                       )
                   );

            return;
        }

        var renderer = ps.GetComponent<ParticleSystemRenderer>();

        int max_limit = 30;

        if(renderer.renderMode == ParticleSystemRenderMode.Mesh)
        {
            max_limit = 5;
        }

        if(ps.main.maxParticles > max_limit)
        {
            result.AddError($"ParticleSystem max particles > {max_limit}!").
                   WithFix(
                       Fix.Create(
                           $"Down to {max_limit}",
                           () =>
                           {
                               var so = new SerializedObject(ps);

                               var max = so.FindProperty("InitialModule.maxNumParticles");

                               if(max is null || max.intValue <= max_limit)
                               {
                                   return;
                               }

                               max.intValue = max_limit;
                               so.ApplyModifiedProperties();
                           }
                       )
                   );
        }
    }
}
#endif

版本问题记录

我们公司采购了 UWA Pipeline,所以自然会将 UWA 的资源检测纳入到整体流程中,在资源检测这一项中,UWA 扮演了非常重要的一个角色,比如上面的粒子规则就是参考 UWA 资源监测中的内容 确定并实现的。以及每天一次的资源报告,让我们可以对项目中总体问题的变化做到心中有数

UWA 的资源检测更像一个知识库,可以有效的帮助提升团队的平均水平,每条是否存在问题、应该怎么解决、为什么需要优化,都列的非常清楚

在项目的前期,可以快速建立整体资源的标准,在中后期,可以将报告的优化任务轮流交给组内的开发者