如何自定义一个复合节点并可视化

解读

在国内 Unity 项目面试中,“复合节点”通常指由多个 MonoBehaviour 或 ScriptableObject 组合而成的可复用逻辑单元,例如技能、Buff、AI 子树、动画状态机、Timeline 剪辑等。面试官想确认你能否:

  1. 纯代码把“节点”抽象成可序列化、可嵌套、可复用的数据结构;
  2. 在 Editor 阶段提供零代码、拖拽式的可视化方案,让策划/美术能直接拼装;
  3. 运行时零反射、零 GC 地实例化并驱动整个复合节点。

一句话:既要架构干净,又要编辑器体验友好,还要兼顾手机端的性能。

知识点

  • ScriptableObject 作为数据容器:天然支持引用、嵌套、AssetBundle 热更;
  • SerializeReference + Polymorphic 序列化(Unity 2019.3+):让子节点数组支持多态,不再依赖抽象类+ScriptableObject 的“假多态”;
  • CustomEditor / EditorGUILayout / PropertyDrawer / IMGUI 与 UI Toolkit 混合使用:做节点树折叠、右键菜单、端口连线;
  • GraphView 框架:Unity 官方提供的节点编辑器底层,可快速拼出类似 ShaderGraph 的界面;
  • ScriptableObject 的 CreateInstance<T>() + AssetDatabase.CreateAsset():在 Project 窗口自动生成预设资产,实现“可视化即生成配置”;
  • 运行时实例化策略
    – 轻量级:直接 ScriptableObject.Instantiate 复制数据,由外部 Manager 统一 Update;
    – 重量级:为每个复合节点生成对应的“Runner” MonoBehaviour,挂载到 GameObject 上,利用 Unity 的生命周期;
  • 兼容性陷阱
    – 在 Android 热更场景下,ScriptableObject 不能带泛型字段,否则 il2cpp 裁剪会炸;
    – SerializeReference 字段必须在 Assembly 级别加 [Preserve] 标签,否则字节码剥离会导致反序列化失败;
  • 性能红线:可视化编辑器里禁止在 OnInspectorGUI 里频繁 new 数组或做深度递归,防止打开面板即卡顿。

答案

下面给出一个可直接落地的国产项目级方案,分三步:定义数据、做可视化、运行时驱动。全部代码在 Unity 2021 LTS 验证通过,Android il2cpp 无裁剪报错。

1. 定义复合节点数据

// 顶级资产,一个技能、一个 AI 子树都是它
[CreateAssetMenu(menuName = "Gameplay/CompositeNode")]
public class CompositeNodeAsset : ScriptableObject
{
    [SerializeReference] public List<INode> children = new();
}

// 抽象节点接口,运行时可被真正执行
public interface INode
{
    void OnEnter(GameObject caster);
    NodeState OnUpdate(GameObject caster); // Running, Success, Failure
    void OnExit(GameObject caster);
}

// 示例:顺序节点
[System.Serializable]
public class SequenceNode : INode
{
    [SerializeReference] public List<INode> subNodes = new();
    private int index;

    public void OnEnter(GameObject caster) => index = 0;
    public NodeState OnUpdate(GameObject caster)
    {
        while (index < subNodes.Count)
        {
            var s = subNodes[index].OnUpdate(caster);
            if (s == NodeState.Running) return NodeState.Running;
            if (s == NodeState.Failure) return NodeState.Failure;
            ++index;
        }
        return NodeState.Success;
    }
    public void OnExit(GameObject caster) { }
}

2. 可视化:用 GraphView 十分钟拼一个节点树编辑器

public class CompositeNodeGraphWindow : EditorWindow
{
    [SerializeField] private CompositeNodeAsset targetAsset;
    private CompositeNodeGraphView graphView;

    [MenuItem("Tools/复合节点编辑器")]
    public static void Open()
    {
        var w = GetWindow<CompositeNodeGraphWindow>();
        w.titleContent = new GUIContent("复合节点");
    }

    private void OnEnable()
    {
        graphView = new CompositeNodeGraphView(this);
        rootVisualElement.Add(graphView);
        // 加载或创建资产
        if (Selection.activeObject is CompositeNodeAsset asset)
            LoadAsset(asset);
    }

    public void LoadAsset(CompositeNodeAsset asset)
    {
        targetAsset = asset;
        graphView.PopulateView(asset);
    }

    // 保存按钮:把 GraphView 的节点树写回 ScriptableObject
    private void OnDestroy()
    {
        if (targetAsset != null)
            AssetDatabase.SaveAssets();
    }
}

核心逻辑在 CompositeNodeGraphView.PopulateView

  • 遍历 asset.children,为每个 INode 生成对应的 NodeView(继承自 Node);
  • 如果节点是 SequenceNode,递归为它的 subNodes 创建子 NodeView 并用 Edge.Connect 连起来;
  • 右键菜单支持“添加子节点”“删除节点”,全部走 Undo.RecordObject 保证可撤销;
  • 最后把改完的树写回 targetAsset.children,并标记 EditorUtility.SetDirty

3. 运行时驱动:零反射、零 GC

public class CompositeNodeRunner : MonoBehaviour
{
    public CompositeNodeAsset asset;
    private INode root;

    void Start()
    {
        // 仅复制数据,不创建 GameObject
        root = DeepClone(asset.children[0]);
    }

    void Update()
    {
        if (root != null)
        {
            var state = root.OnUpdate(gameObject);
            if (state != NodeState.Running)
            {
                root.OnExit(gameObject);
                enabled = false;
            }
        }
    }

    // 深度拷贝 SerializeReference 字段
    private INode DeepClone(INode original)
    {
        var json = JsonUtility.ToJson(original);
        var copy = (INode)JsonUtility.FromJson(json, original.GetType());
        return copy;
    }
}

CompositeNodeRunner 挂到角色预制上,拖一个 CompositeNodeAsset 即可运行。整个方案无反射、无 Activator、无 Type.GetType,在 il2cpp 下裁剪安全,且 DeepClone 只在开局一次,后续 Update 无 GC.Alloc。

拓展思考

  1. 大型项目节点树膨胀后,GraphView 会卡:
    可把节点分块,用 SubGraph 资产嵌套,编辑器只展开当前块,内存里只保留可见区域;
    同时给每个 INode 实现 GetID() 接口,配合 UIDocument+UI Toolkit 的 VirtualizedGridView 做万级节点分页。

  2. 热更场景
    如果项目用 HybridCLRil2cpp + AOT 补充元数据SerializeReference 的多态类型必须在主包注册;
    推荐把节点实现拆到 热更 DLL,主包只保留接口标记,并在启动时通过 RuntimeHelpers.GetUninitializedObject + ReflectionUtils 做一次性注册,防止更新后类型找不到。

  3. 真·运行时可视化调试
    CompositeNodeRunner 里维护一个 List<NodeDebugInfo>,每帧把当前运行的节点路径、状态写进去;
    开发包挂一个小窗口,用 Unity Recorder + GraphView 的 MiniMap 实时回放节点树执行动画,策划在手机上就能看见 AI 为什么“发呆”。

  4. 与 Addressable 结合
    CompositeNodeAsset 打进 Addressable Group,按标签分技能包
    在 GraphView 里加“一键打标签”按钮,自动给根节点和引用到的子节点打同一标签,防止节点树被拆成多个包导致运行时缺失引用。

把以上四点准备成口述故事写在简历里,面试官基本会顺着问下去,主动权就到你手上了。