在Unity编辑器开发中,我们经常需要处理数组或列表类型的数据。比如设计关卡时管理敌人波次、配置技能系统中的效果列表、或者编排对话树中的节点顺序。默认情况下,Unity Inspector中的数组显示方式存在三个明显痛点:
第一是顺序调整困难。想要交换第3和第4个元素的位置?必须手动复制粘贴所有字段值。我曾经在配置包含20多个参数的技能效果数组时,因为一个顺序调整错误导致整个下午都在排查数据异常。
第二是增删操作繁琐。每次新增元素都需要先修改Size数值,然后逐项填写默认值。删除元素时更麻烦,要么重置所有后续元素索引,要么忍受数组中间的空洞。有次在制作RPG任务系统时,因为误删了一个对话节点,不得不重新构建整个分支逻辑。
第三是可视化程度低。当数组元素包含嵌套结构(比如数组套字典)时,默认的折叠式显示会让数据关系变得难以理解。我们团队曾因为看不清道具合成配方的层级关系,导致线上版本出现了材料消耗计算错误。
ReorderableList的价值就在于用拖拽交互解决这些问题。它提供的功能包括:
实测下来,使用ReorderableList后,策划配置一个包含50个波次的塔防关卡,操作时间从原来的2小时缩短到20分钟,而且错误率降低了90%。这就是为什么我说它是Unity编辑器开发的效率神器。
首先需要确保你的数据结构能被Unity序列化。这里有个新手常踩的坑:直接使用List<T>而不加序列化标记。正确的做法有两种:
csharp复制// 方法一:使用可序列化的结构体数组
[System.Serializable]
public struct EnemyWave {
public EnemyType enemyType;
public int count;
public float spawnInterval;
}
public EnemyWave[] waves;
// 方法二:标记为可序列化的List
[System.Serializable]
public class SkillEffect {
public EffectType type;
public float value;
}
public List<SkillEffect> effects;
建议优先使用结构体数组,因为:
在Editor文件夹下创建对应的编辑器脚本。注意命名规范:[类名]Editor.cs。比如为LevelManager创建LevelManagerEditor.cs:
csharp复制using UnityEditor;
using UnityEditorInternal;
[CustomEditor(typeof(LevelManager))]
public class LevelManagerEditor : Editor {
private SerializedProperty wavesProp;
private ReorderableList wavesList;
private void OnEnable() {
wavesProp = serializedObject.FindProperty("waves");
wavesList = new ReorderableList(serializedObject, wavesProp,
true, // 是否可拖拽
true, // 是否显示Header
true, // 是否可添加
true); // 是否可删除
}
}
这里有个实用技巧:在构造函数参数中,我建议始终将四个bool参数设为true。这样能获得最完整的功能集,后续再通过回调函数控制细节表现。
ReorderableList的强大之处在于其灵活的回调系统。最常用的三个回调是:
csharp复制private void OnEnable() {
// ...初始化代码...
wavesList.drawHeaderCallback = DrawHeader;
wavesList.drawElementCallback = DrawElement;
wavesList.elementHeightCallback = GetElementHeight;
}
// 绘制列表标题
private void DrawHeader(Rect rect) {
EditorGUI.LabelField(rect, "敌人波次配置");
}
// 计算每个元素的高度
private float GetElementHeight(int index) {
return EditorGUIUtility.singleLineHeight * 3 + 6;
}
// 绘制单个元素
private void DrawElement(Rect rect, int index, bool isActive, bool isFocused) {
SerializedProperty element = wavesList.serializedProperty.GetArrayElementAtIndex(index);
rect.y += 2;
EditorGUI.PropertyField(
new Rect(rect.x, rect.y, rect.width, EditorGUIUtility.singleLineHeight),
element.FindPropertyRelative("enemyType"),
new GUIContent("敌人类型"));
EditorGUI.PropertyField(
new Rect(rect.x, rect.y + 20, rect.width, EditorGUIUtility.singleLineHeight),
element.FindPropertyRelative("count"),
new GUIContent("数量"));
EditorGUI.PropertyField(
new Rect(rect.x, rect.y + 40, rect.width, EditorGUIUtility.singleLineHeight),
element.FindPropertyRelative("spawnInterval"),
new GUIContent("间隔时间"));
}
实际项目中,我会在DrawElement里添加更多交互元素。比如根据敌人类型显示不同的颜色标签,或者在数量超过10时显示警告图标。这些细节能极大提升使用体验。
最后在OnInspectorGUI中完成渲染:
csharp复制public override void OnInspectorGUI() {
serializedObject.Update();
wavesList.DoLayoutList();
serializedObject.ApplyModifiedProperties();
}
注意一定要调用serializedObject的Update和ApplyModifiedProperties方法,否则修改无法保存。这是很多新手容易遗漏的关键步骤。
当遇到类似"关卡包含多个波次,每个波次又包含多种敌人"的嵌套结构时,常规方法会显示异常。解决方法是在elementHeightCallback和drawElementCallback中特殊处理:
csharp复制[System.Serializable]
public class LevelData {
public string levelName;
public Wave[] waves;
}
// 在Editor脚本中:
private float GetElementHeight(int index) {
SerializedProperty element = list.serializedProperty.GetArrayElementAtIndex(index);
SerializedProperty wavesProp = element.FindPropertyRelative("waves");
return EditorGUI.GetPropertyHeight(wavesProp) + 10;
}
private void DrawElement(Rect rect, int index, bool isActive, bool isFocused) {
SerializedProperty element = list.serializedProperty.GetArrayElementAtIndex(index);
// 绘制关卡名称
EditorGUI.PropertyField(
new Rect(rect.x, rect.y, 200, EditorGUIUtility.singleLineHeight),
element.FindPropertyRelative("levelName"),
GUIContent.none);
// 绘制波次数组
SerializedProperty wavesProp = element.FindPropertyRelative("waves");
EditorGUI.PropertyField(
new Rect(rect.x + 210, rect.y, rect.width - 210, EditorGUI.GetPropertyHeight(wavesProp)),
wavesProp,
includeChildren: true);
}
关键点在于:
GetPropertyHeight获取嵌套数组的实际高度PropertyField的includeChildren参数设为true默认的添加按钮会创建空元素,但有时我们需要初始化默认值。比如新增对话节点时自动填充默认说话人:
csharp复制wavesList.onAddCallback = (list) => {
int index = list.serializedProperty.arraySize;
list.serializedProperty.arraySize++;
SerializedProperty newElement = list.serializedProperty.GetArrayElementAtIndex(index);
newElement.FindPropertyRelative("speaker").stringValue = "NPC";
newElement.FindPropertyRelative("text").stringValue = "...";
};
删除时的确认对话框也很实用:
csharp复制wavesList.onRemoveCallback = (list) => {
if (EditorUtility.DisplayDialog("警告", "确定删除这个波次吗?", "删除", "取消")) {
ReorderableList.defaultBehaviours.DoRemoveButton(list);
}
};
通过drawElementBackgroundCallback可以实现斑马纹交替背景色,或者根据数据状态改变颜色:
csharp复制wavesList.drawElementBackgroundCallback = (rect, index, isActive, isFocused) => {
SerializedProperty element = wavesList.serializedProperty.GetArrayElementAtIndex(index);
bool isBossWave = element.FindPropertyRelative("isBoss").boolValue;
if (isBossWave) {
GUI.color = new Color(0.8f, 0.2f, 0.2f, 0.3f);
} else if (index % 2 == 0) {
GUI.color = new Color(0.2f, 0.2f, 0.2f, 0.1f);
} else {
GUI.color = Color.clear;
}
GUI.DrawTexture(rect, EditorGUIUtility.whiteTexture);
GUI.color = Color.white;
};
不要在OnInspectorGUI中创建新的ReorderableList实例,这会导致性能问题。正确的做法是在OnEnable中初始化,并在脚本重新编译后自动重建:
csharp复制private void OnEnable() {
CreateListIfNeeded();
}
private void CreateListIfNeeded() {
if (wavesList == null) {
// 初始化代码...
}
}
当列表超过100个元素时,可以考虑添加分页功能。这里给出一个简单实现:
csharp复制private int pageIndex = 0;
private const int PAGE_SIZE = 20;
public override void OnInspectorGUI() {
serializedObject.Update();
// 分页工具栏
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("上一页") && pageIndex > 0) pageIndex--;
GUILayout.Label($"第 {pageIndex + 1} 页");
if (GUILayout.Button("下一页") && (pageIndex + 1) * PAGE_SIZE < wavesProp.arraySize) pageIndex++;
EditorGUILayout.EndHorizontal();
// 只渲染当前页的元素
for (int i = pageIndex * PAGE_SIZE; i < Mathf.Min((pageIndex + 1) * PAGE_SIZE, wavesProp.arraySize); i++) {
// 自定义绘制逻辑...
}
serializedObject.ApplyModifiedProperties();
}
如果遇到拖拽无效的情况,检查以下三点:
serializedObject.Update()或ApplyModifiedProperties()当列表显示异常时,可以添加调试日志:
csharp复制wavesList.drawElementCallback = (rect, index, isActive, isFocused) => {
Debug.Log($"正在绘制第{index}个元素");
// ...绘制代码...
};
我在实际项目中最常遇到的问题是忘记调用ApplyModifiedProperties,导致数据修改无法保存。后来养成了习惯:每次修改数组后立即保存,避免丢失重要配置。