Unity节点图编辑器:兼容预制件的Serializable方案性能优化问询
我用纯UIToolkit(无第三方图框架)开发Unity节点编辑器工具,因为工具基于场景,所以采用MonoBehaviour而非多数图系统使用的ScriptableObject。每个节点需要支持PropertyField绘制自定义属性UI,目前实现了两种方案,各有优劣:
方案1:基于内存ScriptableObject的实现
核心是把图数据和节点数据都定义为ScriptableObject,挂载在场景中的MonoBehaviour上:
public class GraphComponent : MonoBehaviour { [SerializeReference] [HideInInspector] public GraphData graphData; // 其他逻辑... }
public class GraphData : ScriptableObject { [HideInInspector] public string GUID = Guid.NewGuid().ToString(); [HideInInspector] [SerializeReference] public List<NodeData> nodes = new(); // 其他逻辑... }
public abstract class NodeData : ScriptableObject { [NodeHideVariable] public string guid = ""; [SerializeReference] public GraphData graphData; // 其他逻辑... }
节点UI构建的核心方法BuildValues()通过创建NodeData的Editor来生成PropertyField:
public void BuildValues() { var _editor = Editor.CreateEditor(nodeData); var sp = _editor.serializedObject.GetIterator(); sp.NextVisible(true); while (sp.NextVisible(false)) { System.Type t = sp.serializedObject.targetObject.GetType(); FieldInfo f = t.GetField(sp.propertyPath); if (f != null) { var _hideAttribute = f.GetCustomAttribute(typeof(NodeHideVariable), true); if (_hideAttribute == null) { var _property = new PropertyField(); _property.BindProperty(sp); valuesContent.Add(_property); } } } _fromNodeInspector = false; _editor.serializedObject.ApplyModifiedProperties(); }
优缺点
- 优点:性能极高,轻松支撑200+节点,编辑器帧率流畅;内存中的ScriptableObject可正常序列化到场景。
- 缺点:将GraphComponent拖入项目制作预制件时,graphData会丢失所有节点——Unity不会把ScriptableObject引用序列化到预制件中,需要手动实现子资源生成和预制件内引用解析,复杂度很高。
方案2:基于System.Serializable类的实现
把GraphData和NodeData改为普通的可序列化类,直接嵌套在GraphComponent中:
[System.Serializable] public class GraphData { [HideInInspector] [SerializeReference] public MonoBehaviour monoBehaviour; // 记录所属的GraphComponent [HideInInspector] public string GUID = Guid.NewGuid().ToString(); [HideInInspector] [SerializeReference] public List<NodeData> nodes = new(); // 其他逻辑... }
[System.Serializable] public abstract class NodeData { [NodeHideVariable] public string guid = ""; [SerializeReference] public GraphData graphData; // 其他逻辑... }
节点UI构建的BuildValues()改为绑定到顶层GraphComponent的SerializedObject,通过遍历序列化路径找到对应节点的属性:
public void BuildValues() { SerializedObject serializedObject = new SerializedObject(graphData.monoBehaviour); serializedObject.Update(); // 查找GraphData属性 SerializedProperty graphDataProperty = serializedObject.FindProperty("graphData"); // 查找节点列表 SerializedProperty nodesProperty = graphDataProperty.FindPropertyRelative("nodes"); // 通过GUID查找节点索引 int nodeIndex = -1; for (int i = 0; i < nodesProperty.arraySize; i++) { SerializedProperty nodeProperty = nodesProperty.GetArrayElementAtIndex(i); SerializedProperty guidProperty = nodeProperty.FindPropertyRelative("guid"); if (guidProperty != null && guidProperty.stringValue == nodeData.guid) { nodeIndex = i; break; } } if (nodeIndex == -1) { Debug.LogError("NodeData not found in GraphData nodes list."); return; } // 获取当前节点的SerializedProperty SerializedProperty nodeDataProperty = nodesProperty.GetArrayElementAtIndex(nodeIndex); // 清空现有内容 valuesContent.Clear(); // 遍历节点字段 SerializedProperty iterator = nodeDataProperty.Copy(); SerializedProperty endProperty = nodeDataProperty.GetEndProperty(); // 移动到第一个子属性 if (iterator.Next(true)) { int startingDepth = iterator.depth; // 记录起始深度 do { // 到达结束属性或超出当前深度时停止 if (SerializedProperty.EqualContents(iterator, endProperty) || iterator.depth != startingDepth) break; // 获取对应的FieldInfo FieldInfo fieldInfo = nodeData.GetType().GetField(iterator.name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy); if (fieldInfo == null) continue; // 创建PropertyField并绑定 PropertyField propertyField = new PropertyField(iterator.Copy()); propertyField.Bind(serializedObject); valuesContent.Add(propertyField); } while (iterator.NextVisible(false)); } // 应用修改 serializedObject.ApplyModifiedProperties(); }
优缺点
- 优点:完美适配预制件工作流,节点数据能随预制件正常序列化。
- 缺点:节点数超过30-35个时严重卡顿,Profiler显示原因是所有PropertyField都绑定到顶层MonoBehaviour,任何微小改动(比如移动单个节点)都会触发整个结构的序列化更新,导致所有节点重绘。试过用ScriptableObject包装NodeData、自定义属性抽屉,均无性能改善。
问题:能否在保持预制件兼容的前提下,优化Serializable方案的性能?
可行优化方案
1. 拆分序列化上下文,避免全局重绘
不要让所有节点的PropertyField都绑定到顶层GraphComponent的SerializedObject,改为针对单个节点的序列化路径做局部绑定:
- 给NodeData套一层
NodeSerializedWrapper,作为GraphData列表的元素:[HideInInspector] [SerializeReference] public List<NodeSerializedWrapper> nodes = new(); [System.Serializable] public class NodeSerializedWrapper { [NodeHideVariable] public string guid = ""; [SerializeReference] public NodeData nodeData; } - 在BuildValues时,直接找到当前NodeSerializedWrapper的SerializedProperty,再绑定到其内部的nodeData字段。这样单个节点的属性变化只会触发自身PropertyField重绘,不会波及其他节点。
2. 延迟绑定与按需重绘
只在节点被选中/展开时才构建并绑定PropertyField,未选中的节点只显示占位UI:
- 监听节点的选中事件,选中时调用BuildValues,取消选中时销毁PropertyField并解绑,减少活跃的序列化绑定数量。
3. 自定义控件替代原生PropertyField
绕过Unity的SerializedObject机制,直接读写NodeData字段:
- 针对常用属性类型(字符串、浮点数、对象引用)自定义UIToolkit控件,修改时直接赋值给NodeData,并手动标记GraphComponent为脏:
var customStringField = new TextField(); customStringField.value = nodeData.MyStringProperty; customStringField.RegisterValueChangedCallback(evt => { nodeData.MyStringProperty = evt.newValue; EditorUtility.SetDirty(graphData.monoBehaviour); }); valuesContent.Add(customStringField); - 这种方式性能提升明显,但需要为每种属性类型实现自定义控件,工作量较大。
4. 优化节点索引查找逻辑
原方案每次BuildValues都要遍历节点列表找GUID,改为维护GUID到索引的字典:
// 在GraphData中添加映射字典 [HideInInspector] public Dictionary<string, int> nodeGuidToIndex = new Dictionary<string, int>(); // 节点列表变化时更新映射 public void UpdateNodeGuidMapping() { nodeGuidToIndex.Clear(); for (int i = 0; i < nodes.Count; i++) { if (!string.IsNullOrEmpty(nodes[i].guid)) { nodeGuidToIndex[nodes[i].guid] = i; } } }
- 后续查找节点索引直接通过字典获取,时间复杂度从O(n)降到O(1)。
5. 防抖与批量更新
对节点移动等频繁操作做防抖处理,延迟执行序列化更新:
- 记录最后一次操作的时间,延迟100ms再调用
serializedObject.ApplyModifiedProperties(),合并多次操作的更新,减少性能冲击。
折中方案:结合ScriptableObject与预制件兼容
如果Serializable方案优化后仍达不到性能要求,可以回到方案1,解决预制件序列化问题:
- 将GraphData和NodeData保存为预制件的子资源:在创建预制件时,用
AssetDatabase.AddObjectToAsset()将这些SO添加到预制件中,确保引用能被序列化:// 创建预制件时调用 public void SaveAsPrefabSubAssets(GameObject prefab) { if (graphData != null) { AssetDatabase.AddObjectToAsset(graphData, prefab); foreach (var node in graphData.nodes) { AssetDatabase.AddObjectToAsset(node, prefab); } AssetDatabase.SaveAssets(); } } - 这种方式既保留了SO的高性能,又能兼容预制件工作流,需要处理预制件实例化时的引用恢复逻辑(比如Awake时重新建立NodeData与GraphData的关联)。
内容的提问来源于stack exchange,提问作者Martin

