OHIF ViewedDataService:DICOM 阅片“已查看”状态跟踪服务的设计与实现
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
本文基于 OHIF 官方文档 ViewedDataService,讲解ViewedDataService这一轻量级会话级服务的定位、事件模型与完整 API,并结合cornerstone扩展中的真实源码(服务实现、注册与清理、滚动条消费端)说明它如何支撑阅片界面中“已查看切片”的增量 UI 更新。读完后,你将能在自己的扩展或组件中正确调用该服务、订阅其变更事件,并理解切片进度条“已阅填充”效果的底层链路。
一、服务概述:跟踪当前会话中“已查看”的数据项
ViewedDataService跟踪哪些 dataId 在当前会话中被标记为“已查看”(viewed)。其内部实现非常克制:用一个Set<string>存储 id,并在 viewed 状态发生变化时发布单一事件。从源码看(ViewedDataService.ts):
private viewedDataIds = new Set<string>(); public markDataViewed(dataId: string): void { if (!dataId || this.viewedDataIds.has(dataId)) { return; } this.viewedDataIds.add(dataId); this._broadcastEvent(this.EVENTS.VIEWED_DATA_CHANGED, { viewedDataId: dataId, }); }典型的用法场景(来自文档 Overview 部分):
- 用户翻到某一切片/数据项时,将其标记为 viewed;
- 查询某数据项是否已被查看,用于初始化(seed)UI 状态;
- 订阅 viewed 变化,实现增量更新;
- 在需要重置上下文时清空 viewed 状态。
它的设计目标是做纯内存、会话级的状态容器:默认不持久化、不落盘,刷新页面或退出模式即归零(详见文末 Notes 部分与生命周期分析)。
二、事件模型:VIEWED_DATA_CHANGED
该服务只发布一个事件:
| 事件 | 描述 |
|---|---|
VIEWED_DATA_CHANGED | 当某个数据项被新标记为 viewed,或全部 viewed 数据被清空时触发。 |
在源码中,事件名常量定义为(ViewedDataService.ts):
class ViewedDataService extends PubSubService { public static readonly EVENTS = { VIEWED_DATA_CHANGED: 'event::viewedDataChanged', };事件载荷类型(Event payload):
type ViewedDataPayload = { viewedDataId?: string; viewedDataCleared?: boolean; };两种载荷语义:
- 单个数据项被标记为 viewed 时:
{ viewedDataId: string }; - 全部 viewed 数据被清空时:
{ viewedDataCleared: true }。
注意“只在新标记时触发”这一语义:markDataViewed对 falsy 的dataId以及已存在于集合中的 id 直接返回,不广播事件(见上文源码 L27-L36)。订阅方因此不会收到重复噪声。
事件发布与订阅机制继承自平台核心PubSubService(pubSubServiceInterface.ts),它提供subscribe、_broadcastEvent、_unsubscribe等基础能力,ViewedDataService的subscribeViewedDataChanges本质上就是对VIEWED_DATA_CHANGED事件的一个具名包装。
三、API 参考
文档定义了四个公开方法,全部在 ViewedDataService.ts 中有对应实现:
1.markDataViewed(dataId: string): void
将一个数据项标记为 viewed,仅当满足以下两个条件时才发出VIEWED_DATA_CHANGED:
dataId为 truthy(非空字符串);- 该 id 此前尚未被标记为 viewed。
2.isDataViewed(dataId: string): boolean
返回dataId当前是否在 viewed 集合中。实现上对 falsy 输入直接返回false(源码 L38-L44)。这是同步查询,适合在渲染前一次性“播种”UI 状态。
3.clearViewedData(): void
清空全部已记录的 viewed dataId,并以{ viewedDataCleared: true }载荷广播VIEWED_DATA_CHANGED(源码 L46-L51)。
4.subscribeViewedDataChanges(listener): Subscription
订阅VIEWED_DATA_CHANGED载荷,返回带unsubscribe()的订阅句柄。文档给出的标准用法示例:
const subscription = viewedDataService.subscribeViewedDataChanges(payload => { if (payload.viewedDataCleared) { // 重置本地 viewed 状态 return; } if (payload.viewedDataId) { // 在本地标记单个数据项为 viewed } }); // 之后 subscription.unsubscribe();这一“先查询播种、后订阅增量”的两段式模式,是消费该服务的推荐姿势——下文滚动条的例子正是如此。
四、服务注册与生命周期
注册
ViewedDataService定义了静态注册元数据:
public static REGISTRATION = { name: 'viewedDataService', altName: 'ViewedDataService', create: () => { return new ViewedDataService(); }, };- 注册名(
servicesManager中的 key):viewedDataService - 备用注册名:
ViewedDataService
它由cornerstone扩展在preRegistration钩子中随其他 Cornerstone 服务一起注册(index.tsx):
preRegistration: async function (props) { const { servicesManager } = props; servicesManager.registerService(CornerstoneViewportService.REGISTRATION); servicesManager.registerService(ToolGroupService.REGISTRATION); // ... 其他服务 servicesManager.registerService(ViewedDataService.REGISTRATION); await init.call(this, props); }因此消费方可以直接从servicesManager.services.viewedDataService取到实例;在cornerstone扩展的类型体系里,它也被显式声明为(AppTypes.ts):
import ViewedDataServiceType from '../services/ViewedDataService'; // services 接口中: viewedDataService?: ViewedDataServiceType;退出模式时清空
cornerstone扩展的onModeExit钩子中会主动清空 viewed 状态(index.tsx):
servicesManager.services.viewedDataService?.clearViewedData();从源码结构看,这意味着 viewed 记录与“一次模式会话”对齐:退出模式后,所有“已查看切片”的记忆随之清除,避免跨会话残留。这也解释了订阅端为什么要处理viewedDataCleared分支——重置场景是设计上被预期的一等路径,而非异常。
五、实战链路:切片进度条如何消费 ViewedDataService
该服务当前最典型的消费方是视口的切片进度滚动条(Slice Progress Scrollbar),位于extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/。它把“已查看”状态渲染为滚动条轨道上的填充色块,与“已加载/已缓存”填充(loaded fill)形成对照。
1. 组件侧:读取定制项并注入服务
在 ViewportSliceProgressScrollbar.tsx 中,组件从servicesManager.services解构出viewedDataService,并通过customizationService读取相关定制项:
const { cineService, cornerstoneViewportService, customizationService, viewedDataService } = servicesManager.services; const showViewedFill = customizationService.getCustomization('viewportScrollbar.showViewedFill') !== false; const viewedDwellMsRaw = customizationService.getCustomization('viewportScrollbar.viewedDwellMs'); const viewedDwellMs = typeof viewedDwellMsRaw === 'number' && viewedDwellMsRaw >= 0 ? viewedDwellMsRaw : 0;2. 消费端 Hook:useViewedSliceBytes
核心逻辑在 hooks.ts 的useViewedSliceBytes中,完整体现了文档描述的四段式用法:
- 播种:堆栈/切片数变化时,遍历
imageIds,用viewedDataService.isDataViewed(imageId)同步查询,把对应字节位置置 1; - 增量订阅:通过
subscribeViewedDataChanges监听变更——收到viewedDataCleared时整体归零(bytes.fill(0)),收到viewedDataId时按imageIdToIndex映射只置位单个字节,避免全量重算; - 标记:用户停留在某切片时调用
markDataViewed(imageId),且支持“停留计时”——viewedDwellMs === 0时切片即标记,否则等待定时器到期才标记,并在切片继续变化或卸载时清理定时器; - 退订:effect 清理函数中调用
subscription.unsubscribe(),与文档示例完全一致。
const subscription = viewedDataService.subscribeViewedDataChanges( ({ viewedDataId, viewedDataCleared }) => { if (viewedDataCleared) { resetViewed(bytes => { bytes.fill(0); }); return; } const index = imageIdToIndex.get(viewedDataId); if (index !== undefined) { setViewedByte(index); } } );这里也印证了文档中 payload 的双分支设计:订阅端必须同时处理“新增 viewed”与“整体清空”两种情况。
3. 相关定制项
滚动条行为可通过window.config.customizationService调整(示例见 sampleCustomizations.tsx):
window.config = { customizationService: [ { // 是否显示“已查看”填充轨道,默认 true 'viewportScrollbar.showViewedFill': { $set: false }, // 当前切片停留多少毫秒后才标记为 viewed,0 表示立即标记 'viewportScrollbar.viewedDwellMs': { $set: 500 }, }, ], };其中viewedDwellMs正是控制markDataViewed调用时机的参数:快速翻片场景下设为 0 可在切到即标记,设为正数则可避免“一闪而过”的切片被计入已阅。
六、Notes:定位与边界
- 服务注册名:
viewedDataService(servicesManager.services的 key); - 备用注册名:
ViewedDataService(altName); - 作用域:会话级内存状态,默认不持久化。
结合源码可以确认:服务内部仅有private viewedDataIds = new Set<string>()一个状态字段,没有任何存储/网络依赖;其生命周期由cornerstone扩展的preRegistration(注册)与onModeExit(清空)两个钩子约束。对扩展开发者而言,这意味着:
- 该服务是只读快照式的会话记忆,跨页面刷新不保留;如需持久化“阅片进度”,应在此基础上自行对接存储服务;
- 订阅方只需关心
VIEWED_DATA_CHANGED一个事件,且必须同时处理两种载荷形态; - 由于
markDataViewed自带去重与幂等短路,重复调用安全,但不会触发多余事件。
七、小结
ViewedDataService是 OHIF 中一个“小而完整”的参考样本:一个Set、一个事件、四个方法,却清晰地展示了 OHIF 服务体系的典型模式——静态REGISTRATION元数据注册、PubSubService事件驱动、customizationService行为调节、onModeExit生命周期清理。理解它之后,阅读其他单事件服务(如同步、缓存类服务)的实现路径都会更加直接。
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考