OHIF ViewedDataService:DICOM 阅片“已查看”状态跟踪服务的设计与实现
2026/9/18 14:54:25 网站建设 项目流程

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等基础能力,ViewedDataServicesubscribeViewedDataChanges本质上就是对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:定位与边界

  • 服务注册名viewedDataServiceservicesManager.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询