开篇先交代一下背景。上个月接了一个第三方渲染插件,文档第一行就写着:"请确保项目中存在 Water 这个 Layer,并给触发组件挂上对应的 Tag。"我当时觉得小事一桩,跑到 Project Settings 里手动加。等到团队里另外几个人各自拉分支,隔三差五有人在群里喊"水不渲染了""触发器没反应",我才意识到,Tag 和 Layer 这种看起来八百年才碰一次的项目配置,恰恰是最该用代码自动化的一类东西。
"unity 里用代码控制增加后者删除标签Tag和层Layer"这个需求,中文社区里的答案大多比较零散:要么只给新增不管删除,要么干脆让你手改 ProjectSettings 文件,没讲清楚你到底改的是什么、为什么删除 Layer 时不能照着删除 Tag 的思路来。这篇文章把我实际封装并维护过的一套编辑器工具完整拆开,覆盖 Tag 和 Layer 的新增、删除、防重、内置项保护、删除前的引用迁移,以及如何接到构建流程里做自动校验。适合所有被项目初始化、多人协作、批量预制体清理折腾过的 Unity 开发者;编辑器脚本刚入门的朋友也能照着抄,我会把每段关键代码为什么这么写讲清楚。
1. 动手之前先搞清楚:Tag和Layer在Unity里到底存在哪
1.1 TagManager.asset里到底有什么
Unity 的 Tag 和 Layer 都不是运行时概念,它们的"户籍"都在同一个项目级序列化文件里:ProjectSettings/TagManager.asset。这个文件的真实面目是一段 YAML,结构大概长这样:
--- !u!78 &1 TagManager: serializedVersion: 2 tags: - Untagged - Respawn - Finish - EditorOnly - MainCamera - Player - GameController layers: - Default - TransparentFX - Ignore Raycast - - Water - UItags 是一个可以随时变长的字符串数组;layers 则是一个固定 32 个槽位的数组,对应 Layer 的索引 0 到 31。写代码操作这两样东西,本质就是改这个文件里的数组。搞清楚这一点,后面所有"为什么这样写"就都有了着落。
顺带提一句,同一个文件下面还有 m_SortingLayers(排序层),那是给 UI 和 Sprite 渲染用的,跟本文说的 Tag/Layer 不是一回事,但操作方式几乎一致。你只要搞懂 TagManager 的套路,排序层的增删改查自然也会了。
1.2 为什么运行时代码改不了Tag和Layer
这是很多人踩的第一个认知坑。Tag 和 Layer 是项目设置,构建时会被烘焙进工程的配置里,运行期间的项目配置是只读的,Unity 也没有提供任何公开的运行时 API 去增删它们。你只能在编辑器里通过 SerializedObject 这类编辑器接口去改 TagManager。运行时你只能做一件事:使用已经存在的 Tag 和 Layer。
比如运行时给 GameObject 赋值标签:
go.tag = "Player"; // 如果 Player 这个 Tag 在项目设置里不存在,直接抛异常赋值不存在的 Tag,Unity 会报Tag: xxx is not defined并且赋值失败。Layer 相对宽容一点,因为 Layer 的本质就是 0~31 的整数,LayerMask.NameToLayer("Water")在找不到名字时返回 -1,但你直接写go.layer = 10,哪怕第 10 个槽位是空的也可以赋上去——这就是后面删除 Layer 时"索引空洞"问题的根源,先记住这句话。
所以正确的分工是:编辑期脚本负责增删 Tag/Layer,运行时脚本只负责使用。如果你在运行时做增删,只会得到"API 不存在"的编译错误。
1.3 内置项和自定义项的边界
不是所有 Tag 和 Layer 都能随便删。内置 Tag 一共 7 个,内置 Layer 是 0~7 这 8 个槽位,它们在 Project Settings 界面里都会显示成不可删除的区域。具体名单如下。
内置 Tag 列表:
| Tag | 说明 |
|---|---|
| Untagged | 默认标签 |
| Respawn / Finish / EditorOnly / MainCamera / Player / GameController | Unity 预置标签 |
内置 Layer 槽位:
| 索引 | 名称 | 可删吗 |
|---|---|---|
| 0 | Default | 否 |
| 1 | TransparentFX | 否 |
| 2 | Ignore Raycast | 否 |
| 3 | (空) | 否 |
| 4 | Water | 否 |
| 5 | UI | 否 |
| 6、7 | (空) | 不建议动 |
| 8 ~ 31 | 自定义 | 可增删(共 24 个) |
写删除逻辑时一定要加一层"白名单保护",否则手滑把 Default 或 UI 删了,整个项目的物理系统和 UI 渲染都会变得说不清道不明。我在真实项目里见过一次误删 TransparentFX 之后,某个第三方特效的渲染层级全乱,排查了整整一个下午才想到去对比 TagManager.asset。
2. 新增 Tag:核心代码只有几行,但坑都在细节里
2.1 AddTag 完整实现
先直接上手。这是我在项目里一直用到的基础版本:
using UnityEditor; using UnityEngine; public static class TagLayerUtility { private const string TagManagerPath = "ProjectSettings/TagManager.asset"; public static bool AddTag(string tagName) { tagName = tagName?.Trim(); if (string.IsNullOrEmpty(tagName)) { Debug.LogWarning("[TagLayer] Tag 名称不能为空"); return false; } if (TagExists(tagName)) { Debug.LogWarning($"[TagLayer] Tag 已存在: {tagName}"); return false; } var serializedTagManager = new SerializedObject( AssetDatabase.LoadAllAssetsAtPath(TagManagerPath)[0]); var tagsProp = serializedTagManager.FindProperty("tags"); tagsProp.InsertArrayElementAtIndex(tagsProp.arraySize); tagsProp.GetArrayElementAtIndex(tagsProp.arraySize - 1).stringValue = tagName; serializedTagManager.ApplyModifiedProperties(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($"[TagLayer] 新增 Tag 成功: {tagName}"); return true; } public static bool TagExists(string tagName) { var serializedTagManager = new SerializedObject( AssetDatabase.LoadAllAssetsAtPath(TagManagerPath)[0]); var tagsProp = serializedTagManager.FindProperty("tags"); for (int i = 0; i < tagsProp.arraySize; i++) { if (tagsProp.GetArrayElementAtIndex(i).stringValue == tagName) return true; } return false; } }2.2 为什么用 SerializedObject 而不是直接编辑文件
这一段值得展开。TagManager.asset 是文本文件,从原理上讲,把上面的 YAML 读进来、往 tags 数组里追加一个字符串再写回去,效果是一样的。但你千万别这么干,原因有三个:
第一,直接改文件绕过了 Unity 的序列化回调,改完以后编辑器里的 Project Settings 窗口不会自动感知变化,很可能出现"文件已经改了,界面上还是老样子,构建却用新配置"这种状态。
第二,手写解析 YAML 很脆弱。Unity 序列化版本升级、文件头标签变化、项目里残留异常片段,任何一个小变化都会让你的文本解析脚本变成一次性代码。
第三,SerializedObject 走的是 Unity 官方定义的修改通道,改完调用ApplyModifiedProperties和SaveAssets,Unity 会自己处理好脏标记、撤销栈和资源刷新。简单说,用系统提供的钥匙开系统提供的锁。
ApplyModifiedProperties()把 SerializedProperty 上的改动写回对象;SaveAssets()把改动真正落盘到 TagManager.asset;AssetDatabase.Refresh()则用来触发资源系统刷新,让 Inspector 里的 Tag 下拉框尽快读到新值。三步少一步,后面都会遇到莫名其妙的问题。
2.3 几个没人提醒你的细节
- 名称要先 Trim 再判断。UI 上手动添加 Tag 时多敲一个空格很难发现,但代码判断是字符精确匹配的," Enemy" 和 "Enemy" 是两个完全不同的 Tag。入口处统一
Trim(),能少很多鬼故事。 - 大小写敏感。Unity 的 Tag 匹配本身区分大小写,团队里最好定一条命名规范,否则同事 A 建了
enemy,同事 B 写Enemy,同事 C 又复制了一个带空格的,项目 Tag 列表直接变成垃圾桶。 - 新增 Tag 后,场景里已有的对象并不会自动获得新 Tag,需要你主动去赋值。工具只管"注册名字",不管"分发使用",这是很多新手误解的。
- 想快速手动测试,挂一个菜单入口就行:
[MenuItem("Tools/测试/新增测试Tag")] private static void MenuTestAddTag() { AddTag("MyCustomTag"); }运行后在 Project Settings 的 Tags 面板里就能看到新名字。如果界面没及时刷新,切到别的窗口再切回来基本就好了。
3. 删除 Tag:先处理引用,再动数组,顺序错了必踩坑
3.1 直接删会发生什么
在编辑器 UI 里删除一个正在被场景对象使用的 Tag,Unity 会弹一个确认框:"所有使用该 Tag 的对象将被设为 Untagged。"
如果你没留意这个问题,直接写一段"找到数组里匹配的名字然后删除"的脚本,结果是:场景里所有挂过这个 Tag 的对象都会悄悄变成 Untagged,而且不会有任何提示。物体的标签一变,依赖CompareTag("xxx")的所有逻辑全部静默失效。所以删除 Tag 的正确顺序是:先统计引用、迁移引用,再删数组项。
3.2 RemoveTag 完整实现
public static readonly string[] BuiltinTags = { "Untagged", "Respawn", "Finish", "EditorOnly", "MainCamera", "Player", "GameController" }; public static bool RemoveTag(string tagName) { tagName = tagName?.Trim(); if (string.IsNullOrEmpty(tagName)) return false; if (System.Array.IndexOf(BuiltinTags, tagName) >= 0) { Debug.LogWarning($"[TagLayer] {tagName} 是内置 Tag,禁止删除"); return false; } if (!TagExists(tagName)) { Debug.LogWarning($"[TagLayer] Tag 不存在: {tagName}"); return false; } // 第一步:迁移引用 int migrated = ResetObjectsWithTag(tagName, "Untagged"); // 第二步:真正删除数组元素 var serializedTagManager = new SerializedObject( AssetDatabase.LoadAllAssetsAtPath(TagManagerPath)[0]); var tagsProp = serializedTagManager.FindProperty("tags"); int targetIndex = -1; for (int i = 0; i < tagsProp.arraySize; i++) { if (tagsProp.GetArrayElementAtIndex(i).stringValue == tagName) { targetIndex = i; break; } } if (targetIndex >= 0) { tagsProp.DeleteArrayElementAtIndex(targetIndex); serializedTagManager.ApplyModifiedProperties(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } Debug.Log($"[TagLayer] 删除 Tag 成功: {tagName},已迁移 {migrated} 个对象"); return true; }3.3 迁移函数与它扫不到的区域
迁移函数负责把旧 Tag 换掉:
private static int ResetObjectsWithTag(string oldTag, string newTag) { int count = 0; var allGos = Resources.FindObjectsOfTypeAll<GameObject>(); foreach (var go in allGos) { if (go == null) continue; if ((go.hideFlags & HideFlags.DontSave) != 0) continue; if (PrefabUtility.IsPartOfPrefabAsset(go)) continue; if (go.CompareTag(oldTag)) { go.tag = newTag; EditorUtility.SetDirty(go); count++; } } AssetDatabase.SaveAssets(); return count; }为什么用Resources.FindObjectsOfTypeAll<GameObject>()而不是普通FindObjectsOfType?因为前者能拿到未激活对象,后者的普通版本会漏掉 inactive 的 GameObject。删除工具最怕的就是"我明明清干净了,运行时某个被隐藏的对象还守着旧 Tag"。
那个PrefabUtility.IsPartOfPrefabAsset(go)判断默认不碰预制体资产文件。这么做的原因很实际:改预制体资产等于改所有人共享的文件,如果你的删除操作只是清理废弃测试 Tag,最好别顺手把预制体也改了;如果确实要清理预制体里的旧 Tag,把这个判断去掉,再配合AssetDatabase.SaveAssets()落盘就行。
这个函数最大的盲区是未打开的 Scene。它只能扫到当前内存里存在的对象,其他场景文件里的引用它看不到。如果你要删的 Tag 只在某个还没打开的场景里出现过,直接删完数组项,下次打开那个场景,Unity 加载时找不到这个 Tag,对象会被归为 Untagged,而且不一定有警告。稳妥做法是删除前自己把所有相关场景打开检查一遍,或者至少让工具输出"扫描范围仅限当前加载场景"的提示,别让使用者误以为全项目都干净了。
3.4 一个防御性建议:先统计再删除
我在封装删除工具时加了一个约定:删除前先统计引用数量,如果迁移数量大于 0,日志就明确写出来。这个习惯帮助我在一个收尾项目里避免了一次事故——有个临时 Tag 看起来就是随手建的,结果一扫描发现五十多个预制体都在用。要是当时跳过迁移直接删,交付版本里所有特效都会被静默改名。后来凡是删除类操作,我默认都带一句"已迁移 xx 个对象"的日志,让使用者自己判断影响面。
4. 新增 Layer:从索引 8 开始的 24 个自定义槽位怎么填
4.1 AddLayer 完整实现
Layer 的新增和 Tag 长得像,但设计上有本质区别:Layer 的名字记录在固定 32 个槽位里,不能往数组末尾追加,只能找空槽占坑。
public static bool AddLayer(string layerName) { layerName = layerName?.Trim(); if (string.IsNullOrEmpty(layerName)) return false; var serializedTagManager = new SerializedObject( AssetDatabase.LoadAllAssetsAtPath(TagManagerPath)[0]); var layersProp = serializedTagManager.FindProperty("layers"); // 保证数组长度至少是 32,老项目里可能出现过数组长度不足的情况 while (layersProp.arraySize < 32) layersProp.InsertArrayElementAtIndex(layersProp.arraySize); // 先查重,避免同名 Layer for (int i = 0; i < 32; i++) { if (layersProp.GetArrayElementAtIndex(i).stringValue == layerName) { Debug.LogWarning($"[TagLayer] Layer 已存在: {layerName}"); return false; } } // 从索引 8 开始找空槽,0~7 是 Unity 内置保留位 for (int i = 8; i < 32; i++) { if (string.IsNullOrEmpty(layersProp.GetArrayElementAtIndex(i).stringValue)) { layersProp.GetArrayElementAtIndex(i).stringValue = layerName; serializedTagManager.ApplyModifiedProperties(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($"[TagLayer] 新增 Layer 成功: {layerName} (索引 {i})"); return true; } } Debug.LogError("[TagLayer] 自定义 Layer 槽位已满,请先清理无用 Layer"); return false; }4.2 为什么要从索引 8 开始找
0~7 是 Unity 保留的 8 个槽位,虽然在早期版本里索引 3、6、7 是空的,但它们是"显式保留的空位",不是给你自定义用的。UI 上其实也支持你往 3 号空槽写名字,但那会绕过 Unity 的约定。新手项目还好,一旦团队里有人依赖"自定义层从 8 开始"这个规律做 culling,就会出问题。
从索引 8 开始扫描是本主题里最值得向别人强调的一点。我见过一份团队内部代码,从 0 开始找空槽,往索引 3 写了自定义层,后来同事在 Inspector 里看到索引 3 有名字但感觉不对,又去索引 8 建了一模一样的名字,两个 Layer 并存,LayerMask 一下写错,相机遮挡直接乱套。
另外,查重一定要做全 32 个槽位,而不只是 8~31。虽然理论上自定义层不会撞默认层,但万一项目早年被别的脚本污染过,漏查会导致两个槽位拥有同名 Layer,运行时LayerMask.NameToLayer行为不可预期。
4.3 新增 Layer 时注意碰撞矩阵
Layer 新增完成后,建议打开一次Edit > Project Settings > Physics看一眼碰撞矩阵。因为碰撞矩阵的记录并不完全存放在 TagManager 里,它由ProjectSettings/DynamicsManager.asset维护。代码新增的 Layer 在矩阵里通常会正常生成一行一列,但如果你用的版本比较老,或者项目里用了自定义物理步骤,界面上的矩阵可能不会立刻刷新。这不是你代码写错了,切换一下窗口或者点一下矩阵区域通常就好了。
提示:Layer 数量最多就是 24 个自定义层(索引 8~31)。如果你发现槽位满了,别硬加,先看看有没有废弃的同名层可以清掉。
5. 删除 Layer:最容易把项目搞乱的索引空洞问题
5.1 RemoveLayer 完整实现
public static bool RemoveLayer(string layerName) { layerName = layerName?.Trim(); if (string.IsNullOrEmpty(layerName)) return false; int targetIndex = FindLayerIndex(layerName); if (targetIndex < 0) { Debug.LogWarning($"[TagLayer] Layer 不存在: {layerName}"); return false; } if (targetIndex < 8) { Debug.LogWarning($"[TagLayer] {layerName} 是内置 Layer,禁止删除"); return false; } // 先把所有引用该索引的对象迁移到 Default(0) int migrated = ResetObjectsWithLayer(targetIndex, 0); var serializedTagManager = new SerializedObject( AssetDatabase.LoadAllAssetsAtPath(TagManagerPath)[0]); var layersProp = serializedTagManager.FindProperty("layers"); // 关键:只清空名字,不删除数组元素 layersProp.GetArrayElementAtIndex(targetIndex).stringValue = string.Empty; serializedTagManager.ApplyModifiedProperties(); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($"[TagLayer] 删除 Layer 成功: {layerName} (索引 {targetIndex}),已迁移 {migrated} 个对象"); return true; } private static int FindLayerIndex(string layerName) { var serializedTagManager = new SerializedObject( AssetDatabase.LoadAllAssetsAtPath(TagManagerPath)[0]); var layersProp = serializedTagManager.FindProperty("layers"); for (int i = 0; i < 32; i++) { if (layersProp.GetArrayElementAtIndex(i).stringValue == layerName) return i; } return -1; }5.2 铁律:清空名字,永远不要 DeleteArrayElementAtIndex
这是整个主题里最重的一条警示。删除 Tag 时,tagsProp.DeleteArrayElementAtIndex(index)是安全的,因为 Tag 在 GameObject 序列化数据里按字符串存储,数组删一项、后面的项前移,不影响任何已经存在的引用。
但 Layer 完全不同。Layer 在所有序列化数据里都是按索引存储的:GameObject 的 m_Layer 是整数,Prefab 文件里层也是整数,Camera 的 cullingMask、物理碰撞矩阵、Raycast 的 layerMask 全都是基于索引的位计算。你对 layers 数组执行DeleteArrayElementAtIndex,等于是把索引 10 的层挪到索引 9,顺带把后面 22 个层全部平移,全项目几千个对象的层引用一夜之间全部错位。这个 bug 的排查难度极高,因为它不会炸编译,只是让遮挡、碰撞、相机剔除全部变得莫名其妙。
所以删除 Layer 的唯一合法操作是:把目标槽位的名字清空,数组长度保持 32 不变。代码里写的是layersProp.GetArrayElementAtIndex(targetIndex).stringValue = string.Empty;,这个坑我建议你在注释里写清楚,防止半年后的自己手滑改回去。
5.3 索引空洞:清掉了名字,对象还留在原地
清空名字之后,问题并没有完全结束。如果场景里还有对象依然使用索引 targetIndex,它们的层不会因为你清空名字就自动变成 Default,它们还停在原来的索引上,只是 Inspector 的 Layer 下拉框里显示会变成类似"Layer 12"这种只有序号没有名字的样子。
更要命的是后面的连锁反应:你清空了索引 12,后来某一天另一个同事用工具新增了一个 Environment 层,代码会从 12 这个空槽开始填,于是所有还占着索引 12 的旧对象,一夜之间全部自动变成了 Environment。这些对象本来只是上一个废弃功能的遗留物,现在却被新的层语义收编,遮罩、碰撞、渲染全都可能被意外改变。这类问题特别难查,因为从代码上看每一处都是合理赋值,只有把时间线拉长才能看出是"改名继承"导致的。
所以 RemoveLayer 里那句ResetObjectsWithLayer(targetIndex, 0)绝不能省:
private static int ResetObjectsWithLayer(int layerIndex, int newLayerIndex) { int count = 0; var allGos = Resources.FindObjectsOfTypeAll<GameObject>(); foreach (var go in allGos) { if (go == null) continue; if ((go.hideFlags & HideFlags.DontSave) != 0) continue; if (PrefabUtility.IsPartOfPrefabAsset(go)) continue; if (go.layer == layerIndex) { go.layer = newLayerIndex; EditorUtility.SetDirty(go); count++; } } AssetDatabase.SaveAssets(); return count; }5.4 删除 Layer 后最容易翻车的三处残留
清空名字并不等于"层被完全移除",下面这三样东西不会自动跟着清理:
- Prefab 文件里的层索引。预制体里挂过这个 Layer 的对象,它的 m_Layer 还是那个整数,下次在预制体上看到"只有序号没有名字"的层,多半就是这种。
- 物理碰撞矩阵。碰撞矩阵按索引记录,清空名字后那行那列还在,只是没了名字。如果后来有新层占用这个索引,它会直接继承旧层的碰撞配置,同时旧对象又被新层收编,物理行为彻底不可预估。
- 脚本里硬编码的索引。比如
go.layer = 12这类魔法数字,清理层名后不会报错,但语义已经变了。这也是为什么我强烈建议项目里把 Layer 名字集中定义成常量,而不是散落成一堆魔法数字。
6. 把增删工具接进项目工作流:菜单、初始化与构建前自检
6.1 一键初始化:让新同事拉下项目就能跑
编辑器工具最值得做的不是"能跑",而是"别人会跑"。为了让初始化成本趋近于零,我把工具整理成一个菜单入口,新项目或新人入职时只需要点一次:
[MenuItem("Tools/项目设置/一键初始化 Tag 与 Layer")] public static void InitProjectSettings() { bool changed = false; changed |= AddTag("Player"); changed |= AddLayer("Ground"); changed |= AddLayer("Interactable"); changed |= AddLayer("UI_FX"); if (changed) { AssetDatabase.SaveAssets(); EditorUtility.DisplayDialog("项目设置", "Tag 与 Layer 初始化完成", "确定"); } else { EditorUtility.DisplayDialog("项目设置", "全部已存在,无需重复初始化", "确定"); } }这套菜单我放在Tools/项目设置/下,团队里任何人都能一键执行。注意我用了|=统计"是否真的发生了修改",避免每次点菜单都刷资源。如果你的团队项目多、初始化项杂,可以在同一个框架里把 PlayerSettings 里的其他项目级配置一起做掉。做这类工具的核心原则是:幂等。点十次和点一次效果一样,没人会记得自己今天到底点没点过。
6.2 构建前自检:缺 Tag 缺 Layer 直接拦下
代码写得再全,也挡不住有人绕开工具手动增删。所以我加了一道构建前校验,利用IPreprocessBuildWithReport接口:
using System.Collections.Generic; using UnityEditor.Build; using UnityEditor.Build.Reporting; public class BuildSettingValidator : IPreprocessBuildWithReport { public int callbackOrder => 0; public void OnPreprocessBuild(BuildReport report) { var required = new List<(string type, string name)> { ("tag", "Player"), ("tag", "Ground"), ("layer", "Interactable"), }; foreach (var item in required) { bool exists = item.type == "tag" ? TagLayerUtility.TagExists(item.name) : TagLayerUtility.FindLayerIndex(item.name) >= 0; if (!exists) { throw new BuildFailedException( $"[项目设置校验] 缺少 {item.type}: {item.name},请先执行 Tools/项目设置/一键初始化"); } } } }BuildFailedException会直接中断本次构建,并给出明确的修复指引。这一步的价值在于,它把"运行时才发现的配置缺失"提前到了"构建阶段就发现",构建服务器上的报错信息也给足了细节,省去来回拉日志的时间。
6.3 运行时的几个边界提醒
最后补充几个运行时容易踩的边界,虽然编辑器工具管不到运行时,但你写项目代码时会用到:
- Layer 判断用
LayerMask.NameToLayer,找不到返回 -1。拿到 -1 千万别直接塞给gameObject.layer,那会得到一个合法但莫名其妙的层。一定要加防御。 - Tag 判断用
CompareTag,不要自己写go.tag == "Enemy"这种字符串比较。CompareTag在序列化层有特殊处理,而字符串比较在 Tag 不存在时不会抛异常,只会默默返回 false,调试起来更痛苦。 - 项目里如果大量用了魔法数字层索引,建议迁移读取方式,统一走
LayerMask.NameToLayer,这样删层改层时至少能通过名字搜到引用点。
6.4 我的三个实战建议
这套工具我在团队里跑了快一年,最后说三个用真金白银换来的体会。
第一,删除类功能永远要设计成可反悔的形式。我建议把删除 Tag 和删除 Layer 先放到一个回收站机制里——不是真的从数组里删掉,而是先把废弃项重命名成_deprecated_xxx_待清理,等确认几个月都没人用,再统一执行真正的删除。这样既满足了清理的需求,又给误删留了缓冲区。
第二,Layer 的增删要尽量避免在团队并行时做。层索引是全项目共享资源,A 在删除索引 12,B 在同一个分支上往索引 12 加新层,合并时不会冲突,但最终行为可能是 B 的新层覆盖了 A 的空槽,然后 A 代码里写的索引 12 语义全变。对于大的层调整,最好固定成一次性的、有人认领的迁移任务,而不是随手工具。
第三,工具代码要跟项目配置建立单一事实来源。把所有需要的 Tag/Layer 名字集中写在一个静态类里,工具初始化和运行时逻辑都引用同一个常量,不要一个地方写字符串、另一个地方再抄一遍。这个习惯一开始看起来只是"整洁一点",到项目中期新增了十几个功能模块之后,你会感谢当初的自己。
Tag 和 Layer 的增删在编辑器脚本里只是几十行代码的事,但它背后牵涉的序列化存储、索引引用、顺序副作用,才是真正决定工具质量的分水岭。删除 Tag 先迁移引用、删除 Layer 永远清空而不删元素、新增 Layer 从索引 8 开始扫空槽,这三条只要你记住,这套工具就基本不会坑队友。