跳转至

可观察集合

Aesir Architecture 为独立游戏内置的三种高频集合 —— ObservableList<T>、ObservableDictionary<TKey, TValue>、ObservableHashSet<T>,组合 BCL 集合存储 + MiniEvent 零分配事件,与 ObservableValue 同一套读写分离与句柄模式。

类型命名参考 Cysharp/ObservableCollections(MIT),通知语义为本项目自有约定(单轨、无变更不通知、批量逐项)。队列等其他集合形态与同步视图 / R3 / 环形缓冲等高级能力可直接使用上游库 —— 两者可在同一项目共存,见文末与上游的关系。

集合家族

集合 适用场景 专属能力
ObservableList\<T> 背包、任务列表、排行榜 AddRange / InsertRange / RemoveRange / Move / Sort / Reverse
ObservableDictionary\<TKey, TValue> 配置表、属性表、名称索引 索引器分流(新键 Add / 已有键 Replace 含旧值)
ObservableHashSet\<T> 在线玩家、去重标记 Add / Remove / Contains 与批量增删(0.23.0 起不再继承 ISet<T>,不含集合代数——需要时用内部 HashSet<T> 或上游)

三者统一经 AddListener / RemoveListener 订阅变更,并各自提供 ClearListeners() 一次清空全部监听(已声明进 IObservableCollection<T> / IReadOnlyObservableValue<T> 接口面,只持有只读接口的 View / Presenter 同样可调)。

单轨变更通知

每个集合只有一个变更事件,经 MiniEvent<T> 分发(Invoke 路径零分配),载荷是普通只读结构体 CollectionChangedEventArgs<T> —— 可自由存入集合与闭包:

// 订阅:返回 AutoRemoveListenerHandle
var handle = list.AddListener(e =>
{
    switch (e.Action)
    {
        case NotifyCollectionChangedAction.Add:     // e.NewItem / e.NewStartingIndex
        case NotifyCollectionChangedAction.Remove:  // e.OldItem / e.OldStartingIndex(变更前索引)
        case NotifyCollectionChangedAction.Replace: // e.NewItem + e.OldItem(旧值)
        case NotifyCollectionChangedAction.Move:    // 被移动元素 + 移动前后两个索引
        case NotifyCollectionChangedAction.Reset:   // 无附加字段(Clear / Sort / Reverse 共用)
            break;
    }
});

语义要点:

  • 无变更的写操作不通知 —— 索引器赋相同值、Remove 不存在的元素、Clear 空集合、HashSet 添加重复元素,一律静默
  • 批量操作逐项通知 —— AddRange / InsertRange / RemoveRange,每个实际变更的元素触发一次事件(零中间集合);需要整批合并处理时在回调内自行缓冲
  • 写操作完成后才通知 —— 回调中读取集合已是变更后的状态;监听回调不应抛异常(fail-fast 与原生事件一致)
  • Sort / Reverse / Clear 走 Reset —— 无附加字段,监听方按「重建视图」处理;少于 2 个元素的排序 / 反转视为无变化,不通知
  • 无索引概念的集合索引固定 -1 —— 字典与 HashSet 的载荷索引恒为 -1;列表的 Remove / Replace 携带变更前索引

只读接口(IReadOnlyObservableList<T> 等)为不变型(无 out):结构体事件参数与协变冲突(CS1961),这是有意设计。

句柄生命周期

AddListener 返回 AutoRemoveListenerHandle,三种清理方式按场景选用:

// ① using 作用域结束自动移除(临时监听 / 单元测试)
using (list.AddListener(e => { })) { /* ... */ }

// ② 手动 Dispose(存句柄字段,OnDisable 里释放)
var handle = list.AddListener(OnChanged);
handle.Dispose();

// ③ 绑定 Unity 生命周期:OnDestroy / OnDisable / 场景卸载时自动移除
list.AddListener(OnChanged).RemoveListenerWhenGameObjectOnDisable(this);

RemoveListenerExtensions 家族与 MiniEvent 共用同一套句柄体系 —— 一次学习,全框架适用。

设计边界

  • 不加锁 —— 集合内部无线程同步,仅约定主线程使用;跨线程访问在调用方自行同步
  • 回调中重入合法但会递归通知 —— 在变更回调里再写同一集合会再次触发通知,请避免
  • 批量能力收敛 —— 仅提供列表的 Range 操作与 Move / Sort / Reverse,不做同步视图、过滤器、环形缓冲、可写视图
  • Odin 面板为可选增强 —— 安装 Odin Inspector 后,集合字段上方显示内联摘要(元素数 / 变更监听数 / 元素预览);未安装时该面板不参与编译,纯代码 API 照常可用

与上游的关系

需要高级功能时建议使用 Cysharp/ObservableCollections(MIT),它提供本模块刻意不做的完整能力:

需求 建议
三种高频集合 + 单轨通知 用本模块
队列等其他集合形态 使用上游
同步视图与过滤器(列表驱动 GameObject / UI) 使用上游
R3 响应式(ObserveAdd / ObserveSort …) 使用上游 + ObservableCollections.R3
环形缓冲区 / 栈 / 交替索引列表 使用上游
INotifyCollectionChanged(WPF/XAML)绑定 使用上游

共存保证:程序集(ObservableCollections vs Runestone.AesirArchitecture)、UPM 包名、命名空间三层完全隔离,互不引用 —— 同一项目可同时安装两库。跨库同名类型共 6 个(三种集合与 IObservableCollection<T> / IReadOnlyObservableList<T> / IReadOnlyObservableDictionary<TKey, TValue>);同一源文件同时 using 两个命名空间并裸引用同名类型时会产生 CS0104 二义性,用命名空间别名(using AesirList = Runestone.AesirArchitecture.ObservableList<T>;)或完全限定名解决。推荐按模块划分文件,同一文件只 using 一侧;两套体系不混用(上游扩展方法作用于上游类型)。

继续阅读

  • 响应式与事件 —— ObservableValue / MiniEvent / 生命周期机制与事件机制决策表
  • 示例总览 —— ObservableCollections 示例(ContextMenu 驱动增删改查与集合运算)
  • Scripting API —— 集合与接口的类型参考