文章目录
-
- [1. 概述](#1. 概述)
- [2. 四层职责模型](#2. 四层职责模型)
- [3. 事件流详解](#3. 事件流详解)
-
- [3.1 完整调用链](#3.1 完整调用链)
- [3.2 NotifyClicked 与 OnClicked:为什么需要两个?](#3.2 NotifyClicked 与 OnClicked:为什么需要两个?)
- [3.3 OnItemClicked:私有路由器](#3.3 OnItemClicked:私有路由器)
- [3.4 Select / Deselect:状态机](#3.4 Select / Deselect:状态机)
- [3.5 SuppressSelectionEvents:静默状态恢复](#3.5 SuppressSelectionEvents:静默状态恢复)
- [4. Item 状态机](#4. Item 状态机)
-
- [4.1 三态](#4.1 三态)
- [4.2 通过 OnStateChanged 实现视觉定制](#4.2 通过 OnStateChanged 实现视觉定制)
- [5. 数据流:Item 如何获取内容](#5. 数据流:Item 如何获取内容)
- [6. 为什么不直接用 Unity 的 ScrollView?](#6. 为什么不直接用 Unity 的 ScrollView?)
-
- [6.1 选中状态管理](#6.1 选中状态管理)
- [6.2 数据驱动的 Item 生命周期](#6.2 数据驱动的 Item 生命周期)
- [6.3 类型安全的泛型 Key](#6.3 类型安全的泛型 Key)
- [6.4 一致的三态视觉契约](#6.4 一致的三态视觉契约)
- [6.5 事件架构](#6.5 事件架构)
- [6.6 动画协调](#6.6 动画协调)
- [6.7 能力对比总览](#6.7 能力对比总览)
- [7. 使用的设计模式](#7. 使用的设计模式)
-
- [7.1 模板方法(Template Method)](#7.1 模板方法(Template Method))
- [7.2 观察者模式(Observer / Event-Driven)](#7.2 观察者模式(Observer / Event-Driven))
- [7.3 简化的状态模式(State Pattern)](#7.3 简化的状态模式(State Pattern))
- [7.4 泛型即契约(Generics as Contract)](#7.4 泛型即契约(Generics as Contract))
- [8. 可扩展方向](#8. 可扩展方向)
-
- [8.1 多选模式](#8.1 多选模式)
- [8.2 虚拟滚动(对象池化)](#8.2 虚拟滚动(对象池化))
- [8.3 拖拽排序](#8.3 拖拽排序)
- [8.4 滑动操作](#8.4 滑动操作)
- [8.5 搜索 / 过滤](#8.5 搜索 / 过滤)
- [8.6 排序](#8.6 排序)
- [8.7 空状态 / 加载状态](#8.7 空状态 / 加载状态)
- [8.8 键盘 / 手柄导航](#8.8 键盘 / 手柄导航)
- [8.9 右键菜单](#8.9 右键菜单)
- [9. 架构决策与权衡](#9. 架构决策与权衡)
-
- [9.1 Dictionary vs List 存储 Item](#9.1 Dictionary vs List 存储 Item)
- [9.2 C# Event vs UnityEvent](# Event vs UnityEvent)
- [9.3 布局采用组合而非继承](#9.3 布局采用组合而非继承)
- [9.4 没有 ViewModel 层](#9.4 没有 ViewModel 层)
- [10. 类图](#10. 类图)
1. 概述
本项目在 Unity ScrollRect 之上实现了一套泛型双层抽象:ListView<TKey, TItem> + ListViewItem<TKey>。它为任何需要以下能力的滚动列表提供可复用的基础设施:
- 数据驱动的 Item 增删
- 单选状态管理(支持两种选中模式)
- 三态视觉生命周期(Normal / Selected / Disabled)
- 从 UI 点击到业务逻辑的干净事件传播链
- 错落式入场动画
目前系统中有四个具体列表:
| ListView | Key 类型 | Item 类型 | 用途 |
|---|---|---|---|
ConnectedDeviceListView |
uint(DeviceId) |
ConnectedDeviceItem |
实时已连接传感器 |
MyDeviceListView |
uint(DeviceId) |
MyDeviceItem |
历史设备列表 |
DeviceLibraryListView |
ProductType(枚举) |
DeviceLibraryItem |
产品目录 |
SessionListView |
string(FilePath) |
SessionListItem |
录制会话文件 |
2. 四层职责模型
架构在四个层次上强制实施关注点分离:
┌─────────────────────────────────────────────────────────────────┐
│ 第四层:业务逻辑(ListView 子类) │
│ HandleItemSelected() --- 选中状态变化之后做什么 │
│ 例:切换 3D 可视化、发布领域事件、页面导航 │
├─────────────────────────────────────────────────────────────────┤
│ 第三层:选中状态机(ListView 基类) │
│ Select() / Deselect() --- 状态的唯一写入路径 │
│ 维护 _selectedKey、_hasSelection,触发 OnItemSelected │
├─────────────────────────────────────────────────────────────────┤
│ 第二层:点击路由(ListView 基类,private) │
│ OnItemClicked() --- 过滤 Disabled、处理 Toggle 模式 │
│ 将原始点击翻译为 Select() / Deselect() 调用 │
├─────────────────────────────────────────────────────────────────┤
│ 第一层:UI 输入(ListViewItem 基类 + 子类) │
│ Button.onClick → NotifyClicked() → OnClicked 事件 │
│ 纯信号:"这个 Item 被物理点击了" │
└─────────────────────────────────────────────────────────────────┘
为什么要分四层?
每一层回答一个不同的问题:
- 第一层回答:"有东西被点了吗?" ------ 纯 UI 输入,不含逻辑。
- 第二层回答:"这次点击应该改变选中状态吗?" ------ 策略判断(是否禁用?是否已选中?Toggle 模式?)。
- 第三层回答:"当前选中状态是什么?" ------ 单一事实来源,确定性状态转换。
- 第四层回答:"应用应该对这次选中做什么?" ------ 业务逻辑,与 UI 机制完全解耦。
这意味着你可以:
- 从代码直接调用
Select(key)而无需模拟点击(直接进入第三层,跳过第一、二层) - 更改选中策略(StickySelect vs ToggleSelect)而不触碰业务逻辑
- 新增一种 Item 视觉样式而不触碰选中逻辑
- 替换整个选中后的业务响应而不触碰状态机
3. 事件流详解
3.1 完整调用链
用户点击某个具体 Item 上的 Button(如 ConnectedDeviceItem)
│
│ Button.onClick.AddListener(NotifyClicked) [Awake, 每个子类]
▼
NotifyClicked() [ListViewItem:76]
│ protected --- 只有 Item 自身能触发
│ OnClicked?.Invoke(Key)
▼
OnClicked(ListViewItem 上的 C# event) [ListViewItem:38]
│ public event --- 外部代码可以 += 但不能 Invoke
│ 由 ListView.AddItem() 订阅:item.OnClicked += OnItemClicked
▼
OnItemClicked(TKey key) [ListView:196]
│ private --- 内部路由,不可重写
│ 守卫:item 存在?未被禁用?
│ 点击已选中项 + ToggleSelect → Deselect()
│ 点击已选中项 + StickySelect → return(无操作)
│ 否则 → Select(key)
▼
Select(TKey key) [ListView:131]
│ public --- 也可由代码直接调用
│ 1. 旧选中项视觉复位(SetState Normal)
│ 2. 更新 _selectedKey、_hasSelection
│ 3. 新选中项视觉高亮(SetState Selected)
│ 4. 若 !SuppressSelectionEvents → OnItemSelected?.Invoke(key)
▼
OnItemSelected(ListView 上的 C# event) [ListView:42]
│ public event --- 由 ListView 子类订阅
▼
HandleItemSelected(TKey key) [各 ListView 子类]
│ 业务逻辑:发布领域事件、切换渲染器等
▼
(领域层响应:SensorSelectedEvent、可视化器切换等)
3.2 NotifyClicked 与 OnClicked:为什么需要两个?
这是经典的触发器/信号分离模式:
csharp
// ListViewItem.cs
protected void NotifyClicked() => OnClicked?.Invoke(Key); // 触发器(protected)
public event Action<TKey> OnClicked; // 信号(public event)
NotifyClicked 是触发器 ------ protected,只有 Item 子类自己能拉动它。设计上就是为了直接作为 UnityAction 传给 Button.onClick:
csharp
button.onClick.AddListener(NotifyClicked); // 每个子类一行搞定
OnClicked 是信号 ------ 一个 C# event,这意味着:
- 外部代码(ListView)可以订阅(
+=)和取消订阅(-=) - 外部代码不能 调用它 ------ 只有声明类自身能调用
Invoke() - 外部代码不能 置空它 ------ 类外
= null会编译报错
如果 OnClicked 是一个普通的 Action<TKey> 字段而非 event,任何持有 Item 引用的代码都可以:
- 伪造点击:
item.OnClicked.Invoke(someKey)------ 绕过真实 UI - 清除所有订阅者:
item.OnClicked = null------ 静默破坏 ListView - 替换委托:
item.OnClicked = myHandler------ 覆盖 ListView 的订阅
event 关键字阻止了以上三种情况。protected NotifyClicked() 方法则提供了一个受控的、唯一的触发入口。
3.3 OnItemClicked:私有路由器
csharp
private void OnItemClicked(TKey key) // ListView:196
这个方法刻意设为 private ------ 不是 protected,不是 virtual。它是基类的内部实现细节,子类永远不需要重写或调用它。它的工作纯粹是机械性的:
- 守卫:item 存在吗?是 Disabled 状态吗?→ 拒绝
- 点击已选中项 :这是当前已选中的 item 吗?
StickySelect→ 无操作(选中是"粘性"的,点击不能取消)ToggleSelect→ 调用Deselect()(再次点击切换取消)
- 点击新 item → 调用
Select(key)
保持 private 的好处是,基类保证了所有点击到选中的路由都走同一条代码路径。没有任何子类能意外创建一条绕过 Disabled 检查或 SelectionMode 逻辑的替代路径。
3.4 Select / Deselect:状态机
csharp
public void Select(TKey key) // ListView:131
public void Deselect() // ListView:153
这两个方法是 public 的,因为选中不仅仅由点击驱动。代码需要:
- 设备连接时自动选中第一个(
ConnectedDeviceListView:172) - 数据刷新后恢复之前的选中项(
MyDeviceListView:117、SessionListView:30) - 录制进行中时程序化回退选中(
ConnectedDeviceListView:231)
状态机简单但严格:
- 单一事实来源 :
_selectedKey+_hasSelection - 视觉同步 :对受影响的 Item 调用
SetState(Selected)/SetState(Normal) - 事件发射 :
OnItemSelected/OnItemDeselected,受SuppressSelectionEvents门控
3.5 SuppressSelectionEvents:静默状态恢复
csharp
protected bool SuppressSelectionEvents; // ListView:33
这个标志为一个特定场景而存在:重建列表时不触发业务副作用。
MyDeviceListView.RefreshFromHistory() 中的示例:
csharp
SuppressSelectionEvents = true;
Clear(); // 正常情况下会触发 OnItemDeselected
// ... 重建所有 item ...
Select(selectedBefore.Value); // 正常情况下会触发 OnItemSelected
SuppressSelectionEvents = false;
如果不抑制,Clear() 会触发 OnItemDeselected(关闭详情面板、停止可视化器),Select() 会触发 OnItemSelected(重新打开、重新启动)------ 导致可见的闪烁和不必要的开销。这个标志让代码静默恢复状态,就像什么都没发生过一样。
另一个用途:ConnectedDeviceListView 在录制进行中时回退选中:
csharp
SuppressSelectionEvents = true;
Select(_previousSelectedKey.Value); // 回退但不重新触发业务逻辑
SuppressSelectionEvents = false;
4. Item 状态机
4.1 三态
csharp
public enum ItemState { Normal, Selected, Disabled }
Select() Deselect() / 另一个 item 被选中
Normal ──────────► Selected ──────────────────────► Normal
│ ▲
│ SetDisabled(true) SetDisabled(false) │
▼ │
Disabled ──────────────────────────────────────────────┘
关键不变量:
- Disabled 的 item 不能被选中 ------
Select()检查item.State == ItemState.Disabled后直接返回 - 选中一个 Disabled item 是无操作,不是错误
- 禁用一个已选中的 item 会先自动取消选中 ------
SetDisabled()在设置 Disabled 状态前先调用Deselect() - 状态转换是幂等的 ------
SetState()检查if (State == state) return
4.2 通过 OnStateChanged 实现视觉定制
csharp
protected abstract void OnStateChanged(ItemState state); // ListViewItem:71
每个 Item 子类根据自身的视觉设计做不同实现:
SessionListItem ------ 简单的 Sprite 切换:
csharp
protected override void OnStateChanged(ItemState state)
{
background.sprite = State == ItemState.Selected ? frameSprites[1] : frameSprites[0];
}
ConnectedDeviceItem ------ 复杂的多因子视觉(ItemState × SensorStatus 交叉):
csharp
protected override void OnStateChanged(ItemState state)
{
ApplyOnlineDot(); // 根据 SensorStatus 显示/隐藏在线点
ApplyInteractable(); // 根据 State 和 SensorStatus 设置按钮可交互性
ApplyFrame(); // 3 种 Sprite:normal、grey(禁用/断开)、green(选中)
ApplyTextColor(); // 根据 State 和 SensorStatus 设置文字颜色
}
MyDeviceItem ------ 按状态控制按钮可交互性 + 边框 + 颜色:
csharp
protected override void OnStateChanged(ItemState state)
{
// Normal:白色背景、frame[0]、按钮启用
// Selected:白色背景、frame[1]、按钮启用
// Disabled:暗色背景、frame[0]、所有按钮禁用
}
这是模板方法模式 ------ 基类控制状态变化何时 发生,子类控制看起来是什么样。
5. 数据流:Item 如何获取内容
基类刻意不知道 Item 显示什么数据。它只管理:
- 创建/销毁(
AddItem/RemoveItem/Clear) - 选中状态
TKey身份标识
内容绑定完全在子类层:
ListView 子类 ListViewItem 子类
───────────────── ──────────────────────
var item = AddItem(key); → item 被创建,Key 被设置
item.SetData(someStruct); → 填充 TMP_Text、Image 等
item.SetSensorStatus(status); → 更新状态相关的视觉
每个 Item 子类定义自己的数据结构和 SetData() 方法:
| Item | 数据结构 | 主要字段 |
|---|---|---|
ConnectedDeviceItem |
ConnectedDeviceData |
DeviceId, ProductType, SensorName, PortName, Status |
MyDeviceItem |
MyDeviceCardData |
DeviceId, DeviceName, ProductType, LastConnectedTicks, IsOnline |
DeviceLibraryItem |
DeviceLibraryCardData |
ProductType, DisplayName, SensorPointCount, SdkSupported |
SessionListItem |
SessionItemData |
FilePath, FileName, DeviceId, RecordTime, Duration, Integrity |
这使基类保持泛型 ------ 它不需要知道传感器、会话或产品的任何信息。
6. 为什么不直接用 Unity 的 ScrollView?
Unity 的 ScrollRect + LayoutGroup 提供的能力:
- 带惯性的可滚动视口
- 自动子物体布局(垂直/水平/网格)
- 内容尺寸自适应
仅此而已。让一个列表在真实应用中可用所需的其他一切,都得自己构建。以下是自定义 ListView 提供而原生 ScrollRect 不具备的能力:
6.1 选中状态管理
Unity 的 ScrollRect 中没有"哪个 item 被选中了"的概念。你需要自己:
- 追踪选中项
- 点击新 item 时处理旧 item 的取消选中
- 决定点击已选中项时的行为
- 阻止选中被禁用的 item
- 处理选中项被移除的情况
所有这些都在 ListView 基类中,写一次,到处复用。
6.2 数据驱动的 Item 生命周期
用原生 ScrollRect,你需要手动 Instantiate() 预制体、维护自己的 Dictionary 或 List、接线点击处理器、销毁时清理。每个列表都要重新实现一遍。ListView 提供:
csharp
var item = AddItem(key); // 实例化、注册、接线点击处理器、播放动画
RemoveItem(key); // 取消注册、处理选中清理、销毁
Clear(); // 批量清理并正确处理取消选中
6.3 类型安全的泛型 Key
ListView<TKey, TItem> 对 Key 类型是泛型的。ConnectedDeviceListView 用 uint(DeviceId),SessionListView 用 string(FilePath),DeviceLibraryListView 用 ProductType(枚举)。编译器保证你不会意外把 DeviceId 传给会话列表。
6.4 一致的三态视觉契约
系统中每个 item 都遵循相同的 Normal → Selected → Disabled 生命周期。基类强制执行状态转换;子类只实现视觉。没有这个,每个列表都会发明自己的状态管理,导致行为不一致(某些列表可能忘记处理"禁用一个已选中 item"的边界情况等)。
6.5 事件架构
原生 ScrollRect 不提供任何将"item X 被选中"传播到业务逻辑的机制。你得逐个 item 接线 Button.onClick,管理回调生命周期,并祈祷不会泄漏监听器。ListView 提供了一条干净的事件链,订阅/取消订阅的生命周期与 AddItem/RemoveItem/OnDestroy 绑定。
6.6 动画协调
错落式入场动画(PlayEnterAnimation 带逐项延迟)需要知道 item 在列表中的索引并协调各 item 的时序。在基类中实现很简单,每个列表重新实现则很痛苦。
6.7 能力对比总览
| 关注点 | Unity ScrollRect | ListView 基类 | ListView 子类 | ListViewItem 子类 |
|---|---|---|---|---|
| 滚动 + 布局 | ✓ | (委托给 ScrollRect) | --- | --- |
| Item 实例化/销毁 | ✗ | ✓ | --- | --- |
| 基于 Key 的查找 | ✗ | ✓(Dictionary<TKey, TItem>) |
--- | --- |
| 选中状态机 | ✗ | ✓ | --- | --- |
| 选中模式(Sticky/Toggle) | ✗ | ✓ | --- | --- |
| 禁用状态处理 | ✗ | ✓ | --- | --- |
| 点击 → 选中路由 | ✗ | ✓ | --- | --- |
| 事件抑制 | ✗ | ✓ | --- | --- |
| 选中后的业务逻辑 | ✗ | --- | ✓ | --- |
| 数据绑定(SetData) | ✗ | --- | --- | ✓ |
| 视觉状态渲染 | ✗ | --- | --- | ✓ |
| 入场动画 | ✗ | ✓(协调) | --- | ✓(渲染) |
7. 使用的设计模式
7.1 模板方法(Template Method)
基类定义算法骨架;子类填充具体步骤:
ListViewItem.SetState()→ 调用OnStateChanged()(抽象方法,子类实现视觉)ListView.AddItem()→ 创建 item、接线事件 → 子类在之后调用item.SetData()
7.2 观察者模式(Observer / Event-Driven)
四级事件链,每级有不同的可见性:
| 事件 | 声明位置 | 可见性 | 订阅者 |
|---|---|---|---|
Button.onClick |
Unity | public | NotifyClicked(单个 item) |
OnClicked |
ListViewItem |
public event | OnItemClicked(单个 ListView) |
OnItemSelected |
ListView |
public event | HandleItemSelected(子类 + 外部) |
| 领域事件 | CustomEventBus |
全局 | 任意订阅者 |
7.3 简化的状态模式(State Pattern)
ItemState 枚举 + OnStateChanged() 分发 = 轻量级状态模式,无需独立的状态对象。在这里是合适的,因为状态少、转换简单。
7.4 泛型即契约(Generics as Contract)
ListView<TKey, TItem> where TItem : ListViewItem<TKey> ------ 泛型约束确保编译期安全:
- ListView 和其 Item 之间的 Key 类型匹配
- Item 保证拥有
ListViewItem的 API(Key、State、OnClicked、SetState) - 无需类型转换,无运行时类型检查
8. 可扩展方向
8.1 多选模式
当前仅支持单选。扩展方式:
csharp
public enum SelectionMode { StickySelect, ToggleSelect, MultiSelect }
需要的改动:
_selectedKey→HashSet<TKey> _selectedKeysOnItemSelected→OnSelectionChanged(IReadOnlySet<TKey> selected)- 在
OnItemClicked中处理 Shift+Click / Ctrl+Click - 新增
SelectAll()/DeselectAll()公开 API
影响范围:仅基类改动。基础多选不需要修改任何子类。
8.2 虚拟滚动(对象池化)
当前所有 item 都被实例化。对于数百个 item 的列表,这会浪费内存和布局计算时间。
虚拟滚动的做法:
- 只实例化足够填满可见视口 + 缓冲区的 item
- item 滚出视口时回收(
Pool()/Activate()) - 回收时重新绑定数据(
SetData()在每个 item 上已经存在)
当前的 AddItem / RemoveItem API 会变为内部使用;新增 SetDataSource(IList<TData>) API 驱动列表。
8.3 拖拽排序
需要新增:
ListViewItem上的IDraggableItem接口- 拖拽手柄检测 + 视觉反馈
OnItemReordered(TKey key, int oldIndex, int newIndex)事件- 可选:将顺序持久化到
PlayerPrefs或配置文件
8.4 滑动操作
移动端风格的滑动展开操作(删除、归档、重命名):
ISwipeableItem接口,GetSwipeActions()返回操作描述符- 基类处理手势检测和操作面板动画
- 子类定义每个 item 可用的操作
8.5 搜索 / 过滤
在基类中添加过滤谓词:
csharp
public void SetFilter(Func<TKey, TItem, bool> predicate)
{
foreach (var (key, item) in Items)
item.gameObject.SetActive(predicate(key, item));
}
Item 保留在字典中(保持选中状态),但从视图中隐藏。ClearFilter() 恢复全部。
8.6 排序
csharp
public void Sort(Comparison<TItem> comparison)
{
var sorted = Items.Values.OrderBy(x => x, Comparer<TItem>.Create(comparison));
int i = 0;
foreach (var item in sorted)
item.transform.SetSiblingIndex(i++);
}
利用 Unity LayoutGroup 尊重 sibling 顺序的特性。
8.7 空状态 / 加载状态
csharp
[SerializeField] private GameObject emptyStateView;
[SerializeField] private GameObject loadingView;
protected void ShowEmptyState(bool show) => emptyStateView?.SetActive(show && Items.Count == 0);
protected void ShowLoading(bool show) => loadingView?.SetActive(show);
子类在数据加载期间调用。基类可以在 RemoveItem / Clear 后 Items.Count == 0 时自动显示空状态。
8.8 键盘 / 手柄导航
csharp
public void SelectNext()
{
// 找到当前索引,选中 index+1(跳过 Disabled)
}
public void SelectPrevious()
{
// 找到当前索引,选中 index-1(跳过 Disabled)
}
可与 Unity Input System 集成,支持方向键 / D-pad 导航。
8.9 右键菜单
右键或长按显示每个 item 的上下文菜单:
csharp
// 在 ListViewItem 基类中
public event Action<TKey> OnContextMenu;
protected void NotifyContextMenu() => OnContextMenu?.Invoke(Key);
遵循与 NotifyClicked / OnClicked 相同的触发器/信号模式。
9. 架构决策与权衡
9.1 Dictionary vs List 存储 Item
基类使用 Dictionary<TKey, TItem> 而非 List<TItem>。这意味着:
- O(1) 按 Key 查找(对
Select(key)、GetItem(key)、RemoveItem(key)至关重要) - 不保证顺序(顺序由 Unity
LayoutGroup+ sibling index 决定) - 无基于索引的访问(需要
Items.Values.ElementAt(i))
对于 item 由业务 Key 标识而非位置标识的数据驱动列表,这是正确的权衡。
9.2 C# Event vs UnityEvent
代码库使用 C# event(而非 UnityEvent)进行所有组件间通信。原因:
- 类型安全(泛型
Action<TKey>vs 装箱的UnityEvent) - 无序列化开销
- Inspector 中不会出现始终在代码中接线的事件的杂乱条目
- 正确的
+=/-=生命周期管理
9.3 布局采用组合而非继承
基类不控制布局。它完全委托给 container 上的 Unity LayoutGroup。这意味着:
- 垂直列表?在预制体的 container 上添加
VerticalLayoutGroup - 网格?添加
GridLayoutGroup - 自定义?添加任何
LayoutGroup子类
ListView 不关心。它只是将 item 实例化为 container 的子物体,让 Unity 处理定位。
9.4 没有 ViewModel 层
当前设计中 Item 直接持有 UI 引用(TMP_Text、Image 等)并在 SetData() 中绑定数据。没有中间的 ViewModel。这是有意为之 ------ 对于 10-50 个 item、数据简单的列表,ViewModel 层增加复杂度却没有收益。如果 item 发展出复杂的派生状态或需要对显示逻辑做单元测试,ViewModel 才值得引入。
10. 类图
┌──────────────────────────┐
│ ListViewItem<TKey> │ 抽象基类
│──────────────────────────│
│ + Key: TKey │
│ + State: ItemState │
│ + OnClicked: event │
│──────────────────────────│
│ # NotifyClicked() │ ← 子类的触发器
│ + SetState(ItemState) │ ← 由 ListView 调用
│ # OnStateChanged() │ ← 抽象方法,视觉钩子
│ + PlayEnterAnimation() │
└──────────┬───────────────┘
│ 继承
┌──────────────────┼──────────────────┬─────────────────┐
▼ ▼ ▼ ▼
ConnectedDeviceItem MyDeviceItem DeviceLibraryItem SessionListItem
(3-Sprite 边框、 (在线点、 (产品图片、 (完整性颜色、
传感器状态、 跳转按钮、 SDK 标签) 时长格式化)
文字颜色逻辑) 时间格式化)
┌──────────────────────────┐
│ ListView<TKey, TItem> │ 抽象基类
│──────────────────────────│
│ # Items: Dictionary │
│ + SelectedKey: TKey │
│ + HasSelection: bool │
│ + OnItemSelected: event │
│ + OnItemDeselected: event │
│──────────────────────────│
│ # AddItem(key): TItem │
│ + RemoveItem(key) │
│ # Clear() │
│ + Select(key) │
│ + Deselect() │
│ + SetDisabled(key, bool) │
│ - OnItemClicked(key) │ ← 私有路由器
└──────────┬───────────────┘
│ 继承
┌──────────────────┼──────────────────┬─────────────────┐
▼ ▼ ▼ ▼
ConnectedDeviceListView MyDeviceListView DeviceLibraryListView SessionListView
(传感器事件、 (历史同步、 (静态目录、 (批量 SetData、
可视化器控制、 在线追踪、 配置驱动) 保持选中)
录制守卫、 跳转控制台)
重连逻辑)