You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Unity节点图编辑器:兼容预制件的Serializable方案性能优化问询

Unity UIToolkit节点编辑器:预制件兼容+性能优化方案探讨

我用纯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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 10:15:53