如何在Timeline中动态绑定ExposedReference

解读

国内项目普遍把 Timeline 当“剧情驱动器”:剧情、技能、过场、UI 动效全用它。
策划在 Timeline 里拖轨道,美术挂 Animation/Activation/Signal,程序则要在运行时把“谁上场”动态换掉——ExposedReference 就是官方留给我们的“运行时换绑”口子。
面试时,如果只说“把字段拖到 Inspector”会被直接打断;必须证明你能在代码里把 ExposedReference 解析到真正的场景对象,并保证热更新、AssetBundle、跨场景引用都不翻车
这道题考察三条硬指标:

  1. 对 Playable 图里“引用解析时机”的理解;
  2. 对 ExposedReference 的泛型约束与 Resolve 流程的掌握;
  3. 对 Addressables/AssetBundle 等“对象可能不在场景”时的兜底策略。

知识点

  1. ExposedReference<T> 是 PlayableAsset 里的特殊泛型字段,只在 PlayableGraph 构建时由 Timeline 统一解析,T 必须是 UnityEngine.Object 派生类
  2. 解析入口:IExposedPropertyTable 接口,由 Timeline 的 PlayableDirector 实现;ExposedReference.Resolve(table) 是真正拿到实例的唯一 API。
  3. 动态绑定分两步:
    a) 在 PlayableAsset 子类里声明 public ExposedReference<Transform> actor;
    b) 在运行时把“key→value”写入 director 的 exposedReferences 字典,key 必须与 ExposedReference.exposedName 一致
  4. 生命周期:PlayableGraph 构建瞬间(PlayableDirector.Play() 那一帧) 完成解析,之后即使把值改掉也要 RebuildGraph 才能再次生效。
  5. 热更新/Addressables 场景:对象可能异步加载,必须在加载完成后再调 director.SetReferenceValue + RebuildGraph,否则 Resolve 返回 null。
  6. 内存泄漏点:ExposedReference 默认把 exposedName 做成 GUID,频繁动态绑定时要手动重用 name 或调用 ClearReference,否则 exposedReferences 字典无限膨胀。

答案

分“声明—写入—解析”三步,给出可直接抄的代码模板,所有坑点都已加注释,面试官听完就能判断你确实踩过线上项目。

// 1. 在自定义 PlayableAsset 里声明 ExposedReference
[Serializable]
public class MoveToTargetClip : PlayableAsset, ITimelineClipAsset
{
    // 暴露给策划拖轨道,运行时动态换绑
    public ExposedReference<Transform> target;
    public Vector3 offset;

    public ClipCaps clipCaps => ClipCaps.Extrapolation | ClipCaps.Blending;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        var behaviour = new MoveToTargetBehaviour();
        // 2. 在 CreatePlayable 里解析:graph.GetResolver() 拿到 IExposedPropertyTable
        behaviour.target = target.Resolve(graph.GetResolver());
        behaviour.offset   = offset;
        return ScriptPlayable<MoveToTargetBehaviour>.Create(graph, behaviour);
    }
}

// 3. 运行时动态绑定:把真正的 Transform 塞给 director
public static class TimelineBinder
{
    public static void BindActor(PlayableDirector director,
                                 string exposedName, // 与 ExposedReference.exposedName 保持一致
                                 Transform realObject)
    {
        // 写入引用表
        director.SetReferenceValue(exposedName, realObject);
        // 如果图已经构建过,必须重建才能再次解析
        if (director.state == PlayState.Playing)
            director.RebuildGraph();
    }

    // 工具:根据字段名自动生成 exposedName,避免手写字符串
    public static string GetExposedName<T>(PlayableAsset asset, string fieldName)
    {
        var field = asset.GetType().GetField(fieldName,
                      System.Reflection.BindingFlags.Public |
                      System.Reflection.BindingFlags.Instance);
        if (field == null) return null;
        var refValue = field.GetValue(asset) as ExposedReference<T>;
        return refValue.exposedName;
    }
}

// 使用示例:Addressables 加载角色后绑定
async void LoadAndBind(PlayableDirector director, MoveToTargetClip clip)
{
    var handle = Addressables.LoadAssetAsync<GameObject>("Hero.prefab");
    var go = await handle.Task;
    var tf = go.transform;

    string key = TimelineBinder.GetExposedName<Transform>(clip, "target");
    TimelineBinder.BindActor(director, key, tf);
}

踩坑总结

  • 如果忘了 RebuildGraph,下一帧 Resolve 仍然拿到旧对象,表现就是“角色不动”。
  • 在编辑器模式测试时,Play 前 director.exposedReferences 是空的,需要走同一条绑定代码,别依赖 Inspector 拖引用。
  • 多人协作时,exposedName 默认 GUID 很长,建议 clip 里加 [SerializeField] string customKey,在 CreatePlayable 前把 ExposedReference.exposedName 设成 customKey,方便策划查表。

拓展思考

  1. 批量绑定:剧情里 20 个 NPC 都要动态换皮肤,可以把 exposedName 做成“Role_001”这类规律字符串,在加载完所有 Addressable 后一次性 for-loop SetReferenceValue,再统一 RebuildGraph,比每加载一个 Rebuild 一次省 90% 峰值卡顿。
  2. 跨 Timeline 复用:同一段技能 Timeline 被多个角色复用,不要把 ExposedReference 写在 Clip 里,而是写到 TrackAsset 的父级,这样 Rebuild 时只解析一次,减少 GC.Alloc。
  3. 网络同步:帧同步战斗里,Timeline 驱动大招,服务器只下发 exposedName→NetworkId 的映射,客户端根据 NetworkId 找到本地 Transform 再绑定,保证不同机型表现一致。
  4. 性能监控:在线上埋点,统计 RebuildGraph 次数与耗时,超过 3 ms 就报警——90% 是因为策划在 Timeline 里塞了巨量 ExposedReference 却忘记 Clear,导致 exposedReferences 字典爆炸。