如何自定义一个复合节点并可视化
解读
在国内 Unity 项目面试中,“复合节点”通常指由多个 MonoBehaviour 或 ScriptableObject 组合而成的可复用逻辑单元,例如技能、Buff、AI 子树、动画状态机、Timeline 剪辑等。面试官想确认你能否:
- 用纯代码把“节点”抽象成可序列化、可嵌套、可复用的数据结构;
- 在 Editor 阶段提供零代码、拖拽式的可视化方案,让策划/美术能直接拼装;
- 运行时零反射、零 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。
拓展思考
-
大型项目节点树膨胀后,GraphView 会卡:
可把节点分块,用 SubGraph 资产嵌套,编辑器只展开当前块,内存里只保留可见区域;
同时给每个INode实现GetID()接口,配合 UIDocument+UI Toolkit 的 VirtualizedGridView 做万级节点分页。 -
热更场景:
如果项目用 HybridCLR 或 il2cpp + AOT 补充元数据,SerializeReference的多态类型必须在主包注册;
推荐把节点实现拆到 热更 DLL,主包只保留接口标记,并在启动时通过RuntimeHelpers.GetUninitializedObject + ReflectionUtils做一次性注册,防止更新后类型找不到。 -
真·运行时可视化调试:
在CompositeNodeRunner里维护一个List<NodeDebugInfo>,每帧把当前运行的节点路径、状态写进去;
开发包挂一个小窗口,用 Unity Recorder + GraphView 的 MiniMap 实时回放节点树执行动画,策划在手机上就能看见 AI 为什么“发呆”。 -
与 Addressable 结合:
把CompositeNodeAsset打进 Addressable Group,按标签分技能包;
在 GraphView 里加“一键打标签”按钮,自动给根节点和引用到的子节点打同一标签,防止节点树被拆成多个包导致运行时缺失引用。
把以上四点准备成口述故事写在简历里,面试官基本会顺着问下去,主动权就到你手上了。