跳到主要内容

NVDA 内幕故事3:构成 NVDA 屏幕阅读器的组件

· 阅读需 23 分钟
Joseph Lee
The author of NVDA-Internals
Inkydragon
Opensource contribution @ Github

本故事的部分内容包括实际的 NVDA 源代码。对于大多数人来说,这可能是你第一次真正看到用 Python 编写的屏幕阅读器源代码。我认为让大家看到一些源代码是很重要的,这样人们就可以更好地理解正在发生的事情。并让我能用实际的代码来解释一些概念和机制。

此外,这个故事的部分内容来自 NVDA 源代码库中包含的技术设计概述文档(英文)

简短的回顾

这是我们关于 NVDA 运行的第一个故事:构成 NVDA 屏幕阅读器的组件。

在上一个内幕故事中深入探讨了一些提醒和定义之后,我认为一篇关于 NVDA 组件的故事可以帮助我们过渡到更实际的问题,并为讨论功能的内部结构奠定基础。

但首先回顾一下我们目前所看到的:

  • 屏幕阅读器不是一种生产力工具。我们上次看到了原因。
  • 从本质上讲,屏幕阅读器(或者说今天的屏幕阅读器)是一种复杂的信息处理器。 它收集、解释和呈现用户关注的屏幕内容。当我们开始讨论事件时,最后一部分(用户关注的内容)将变得重要。更具体地说,我们会讨论:选择性 UIA 事件注册以及它如何工作和提供帮助。 在这个故事里,我想将“呈现”添加到我们将看到的执行步骤中。
  • 开发屏幕阅读器就是承认残疾和辅助技术在社会和文化方面的可能性和限制。正如我在第二个故事中解释的那样:屏幕阅读器是什么,不是什么。 无障碍倡导与宣传仍然很重要,因为编写软件是为了说服人类,而不是机器。 这就是为什么人们把视频、图标、按钮,甚至屏幕阅读器等辅助技术当作数字艺术品来谈论。

我将在整个《内幕故事》系列中回到我上面写的内容。

NVDA 是什么?

NVDA 是一个信息处理器。

顾名思义,处理器是处理某些东西的软件或硬件。 举个直接的例子,在计算机或智能手机上,有一个由数百万到数十亿个硅晶体管组成的硬件(或者叫做芯片),可以在几分之一秒内计算出各种事情。没有这个计算芯片,计算机就变成了金属和电线的集合。这就是为什么我们称这种芯片为“中央处理器(Central Processing Unit)”或简称为 CPU。 另一个论坛上有关于英特尔和 AMD 处理器比较的讨论。但从本质上讲,两个品牌的处理器都以相同的原理运行:使用硅晶体管同时计算许多事情。CPU 的实际内部结构超出了本论坛的范围,但可以说 CPU 由数十亿个硅晶体管组成,这些晶体管根据特定规则打开或关闭。

或者让我们举一个软件的例子:多媒体播放器。 VLC 等媒体播放器程序是编码和解码多媒体文件格式的专家。这些程序读取音频或视频文件,对其进行处理(确定文件格式,使用规则将其转换为可以在屏幕和/或扬声器上播放的内容),然后播放它们。就像 CPU 硬件一样,媒体播放器等软件由许多组件组成,旨在协同工作以完成某些事情。

译注:VLC 是一个开源的多媒体播放器。官网为:www.videolan.org/vlc

同样,NVDA 包含了许多组件,旨在实现其信息处理目标。 其中一个组件负责以用户和屏幕阅读器可以理解的方式表示屏幕内容;另一组组件表示用户将如何向计算机输入内容;一些组件响应屏幕上发生的事件;一些组件帮助 NVDA 根据内置规则和用户自定义规则解释屏幕内容;还有一些组件负责处理特殊情况,如网络浏览器;另一组模块负责输出,如语音合成和盲文显示输出。 NVDA 配备了自己的图形小部件,由用户图形界面(Graphical User Interace, GUI)工具包提供支持。它需要一种与 Windows 和其他应用程序进行通信的方式,同时提供安装、运行、启用或禁用以及删除附加组件的机制(并且有一天还可以更新附加组件)。

NVDA 的组成

NVDA包含的具体组件有:

用户图形界面控件

名为 NVDAObjects 的模块集合定义了 NVDA 对屏幕控件的表示形式。 在最底层是一个理想化的屏幕控件,名为“NVDA对象”(NVDAObjects.NVDAObject),定义了所有屏幕控件共有的属性,如名称、角色和事件处理基础。该对象还负责在请求时记录开发者信息(NVDA+F1),其他层会附加更具体的信息。 并非所有 NVDA 对象机制都在基本 NVDA 对象中定义,因为 NVDA 需要处理可访问性 API(见下文)。

另一个基本的 NVDA 对象是窗口对象(NVDAObjects.window.Window),它是实际屏幕控件的基本表示形式。按钮、复选框、文档字段甚至应用程序界面都是窗口。并提供辅助功能 API 和其他组件所依赖的窗口函数,例如录制屏幕控件的窗口句柄(Window handle)。

辅助功能 API支持

NVDA 可以处理辅助功能 API (Accessibility API)。 例如 Microsoft Active Accessibility(MSAA/IAccessible)、IAccessible2、Java Access Bridge(JAB)和 UI Automation(UIA)。这些 API 支持模块分散在 NVDA 源代码中,分为 “API 和处理程序定义”,以及“由这些 API 驱动的控件的 NVDA 对象表示”。

举例来说:为了支持 MSAA,名为 IAccessibleHandler 的包附带了 MSAA 和 IAccessible2 的事件定义和一些变通方法,以及表示 NVDAObjects.IAccessible 包中的 MSAA 控件的对象。NVDAObjects.IAccessible 包中是理想化的 MSAA 对象 (NVDAObjects.IAccessible.IAccessible), 它扩展了“理想化”的基类和窗口 NVDA 对象,并添加了从 MSAA API 获取信息的过程。 同样,UIA 支持来自名为 UIAHandler 的处理程序模块和存储在 NVDAObjects.UIA 中的 UIA 对象。

NVDA 对象是 NVDA 的一部分,并用作辅助功能 API 控件的表示形式,统称为“API类”。

来自应用程序和插件的自定义控件

如果 NVDA 仅限于识别仅由辅助功能 API 驱动的控件,那么屏幕阅读器的功能将受到严重限制。因此,NVDA 允许应用模块和全局插件扩展内置对象或创建新对象。这些类称为“覆盖类(Overlay classes)”,特定于手头的任务。

一种特殊类型的对象,称为“树拦截器(Tree interceptor)”,用于让一个 NVDA 对象处理来自其中包含的控件的事件和更改(将 NVDA 对象树视为单个 NVDA 对象),在浏览模式等地方使用。树拦截器是浏览模式和其他复杂文档交互工作的主要机制。

输入机制

理论上,NVDA 可以与各种输入工具一起使用:例如键盘,鼠标,触摸屏,操纵杆,应有尽有。 但是,由于“通用”输入工具(如键盘)的数量有限,NVDA 只能处理常见的输入源,即键盘,鼠标,触摸屏和盲文显示硬件。 曾经存在一些尝试让 NVDA 支持响应语音输入。

每一段输入都被称为“手势(gesture)”,相关术语是“脚本(scripts)”。在 NVDA 世界中,脚本是在处理特定手势时执行的命令,例如按键; 现在你知道为什么 NVDA 中有一个名为“输入手势”的对话框了。 就像 NVDA 对象一样,一个理想化的输入手势(源)称为“输入手势”(inputCore.InputGesture),键盘和触摸屏等工具都是从它派生出来的。 对于盲文显示器,独立的盲文和盲文输入模块负责来自显示硬件的输入。

输出机制

NVDA 可以将输出发送到语音合成器和盲文显示器(可以生成音调并播放波形文件)。 没有理想化的输出表示形式(实际上有两个,一个位于数据解释和输出之间,另一个是驱动程序基类,下面会解释)。

语音合成器由“合成器驱动程序”(synthDriverHandler.SynthDriver)表示,盲文显示器由“盲文显示驱动程序”(braille.BrailleDisplayDriver)表示,两者都派生自基类“驱动程序”(driverHandler.Driver)。 然后,每个输出机制进一步分为“驱动程序设置集合”和“特定输出驱动程序”。例如,语音合成器驱动程序可以包含速率、音高和其他指令(如果需要)等设置。并且每个合成器(内置或来自加载项)都位于 synthDrivers 集合中。 类似的,盲文显示器驱动程序位于盲文显示器驱动程序包(brailleDisplayDrivers)中,提供了让 NVDA 与硬件通信的方式(或在 NVDA 的盲文查看器中与软件通信)。例如,Windows OneCore 合成器位于 synthDrivers.oneCore 模块中,提供获取 Windows 10 或 11 系统上安装的语音列表、速率提升设置和处理语音处理的方法。

与 Windows 和其他应用程序的交互

作为 Windows 上的屏幕阅读器,NVDA 附带了一些模块,用于通过 Windows API、组件对象模型(Component Object Model,COM)与 Windows 和其他应用程序进行通信。在某些情况下,NVDA 会将自身的一部分注入其他程序以进行通讯。

Windows API 支持由一些以字母“win”开头的模块提供。例如:winUser 处理 user32.dllwinKernel 用于 kernel32.dll 等。 COM 支持由名为 comtypes 的外部模块提供,NVDA 提供了一些变通方法和特定的 COM 接口(值得注意的是,COM 用于与 UI 自动化通信)。

将 NVDA 的一部分注入到其他程序中由两个模块提供:NVDA 助手(NVDA Helper),用于与某些程序通信;以及 NVDA 远程加载器(NVDA Remote Loader),注意不要与 NVDA 远程插件混淆。这是一个64位程序,旨在让 NVDA(32位程序)与64位程序通信。因为 NVDA 运行在 32 位 Python 运行时上。

事件处理

为了收集和解释数据,NVDA 需要处理来自 Windows 和应用程序的事件。 一个名为“事件处理程序(event handler)”的模块被包含在内,用于定义事件的处理方式(也许这应该在以后的内幕故事帖子中详细介绍)。使事件处理实际工作和有效的是:NVDA 的各个部分,特别是 NVDA 对象,可以响应事件并采取适当的操作,对数据进行处理、解释。

数据解释

如果事件是数据收集阶段的重要组成部分,则许多模块协同工作以提供解释服务,有时用户可以通过 NVDA 设置提供建议。

首先,NVDA 会记录收集的数据来自何处,然后要求相关组件(应用程序模块、NVDA 对象、辅助功能 API 处理程序等)创建收集数据的表示形式。在大多数情况下,这涉及创建与数据来源的屏幕控件相对应的 NVDA 对象。反过来,刚刚创建的对象使用其对所收集数据的了解(例如事件)来处理和解释数据。 解释阶段取决于可访问性 API、覆盖类、用户设置(例如宣告工具提示和播放拼写错误声音)、咨询附加组件等因素。如果被告知输出它所拥有的任何东西,解释后的数据又被输入到呈现预处理程序中,例如语音和盲文数据准备(这是一个非常复杂的主题,其中包括字符和语音词典处理、语音输出的对象缓存、盲文表查找等)。 用于从数据处理和解释过渡到输出的关键代码部分是 ui.message,它负责将它得到的任何文本输出到语音和盲文。

配置工具

配置工具由一个名为 configobj 的外部模块组成。用于读取和写入配置文件、处理用户配置集(包括配置文件触发器)和 NVDA 设置界面。

其余的组件

NVDA 的运行还需要其他关键组件,包括 GUI 工具包(wxWidgets/wxPython)、更新检查、附加组件管理、浏览模式和虚拟缓冲区、光学字符识别(Optical Character Recognition,OCR)和屏幕幕布等全局命令等等。由于篇幅原因,这些内容在本文中没有提到。

主事件循环

所有提及以及未提及组件的核心是主事件循环,有时缩写为“事件循环”或“主循环”。

类比来说,可以将事件循环想象成一个等待接载乘客的出租车司机。我认为这是与事件循环在幕后工作方式最接近的类比。 出租车司机的工作是等待出租车公司或调度员的消息,通知司机接载乘客。乘客在付钱后(或之前)会告诉司机目的地位置,几分钟后,乘客下车。下车后,出租车司机返回公司或最近的车库,等待新的调度消息。如果你有在线打车的经历,请考虑滴滴等公司的司机如何工作。

从技术方面看,事件循环由一个循环组成。它一直等待来自某人(或某物)的消息,直到被告知退出循环。 调度(事件)消息主要由操作系统(在本例中为 Windows)代表用户正在与之交互或不与之交互的程序发送,包括 Windows 代表程序本身引发的消息(这就是 NVDA 如何使用辅助功能 API 宣布自己的 GUI 控件的方式)。 一旦收到调度消息,事件循环会以某种方式解释调度消息(在 Windows 中,调用 TranslateMessage 函数),然后根据其解释通知应用组件(技术上是应用程序窗口)处理调度消息(这称为事件处理)。

作为一个有自己 GUI 的图形应用程序,NVDA 确实包含一个事件循环。这来自 wxWidgets(在这个社区中更广为人知的是 wxPython 的 wx.App.MainLoop() 函数)。此循环从 core.main 函数中进入(参见下面的内容)。

总而言之,上面列出以及未列出的组件由主循环管理,具体逻辑如下所示:

  1. NVDA 启动并准备模块以进行操作。
  2. 第一个模块名为“NVDA 启动器”(nvda.pyw),检查启动信息并调用 core.py 模块中的 core.main 函数。
  3. 在核心模块内部,main 函数初始化合成器支持和辅助功能 API 处理程序等组件,获取 NVDA 的 wxWidgets 应用程序表示,然后调用主循环 ap.MainLoop()
  4. 当主循环运行时,它会“泵送”(分发)来自辅助功能 API、来自 Windows 和应用程序的事件消息。这反过来又会导致上面列出的组件执行其任务。
  5. 主循环退出。NVDA 终止各种组件,最后离开 core.main函数(退出 NVDA)。

译注:注意到 “NVDA 启动器” 的文件名 nvda.pyw 的后缀是 .pyw,区别于一般的 Python 源代码文件后缀 .py。使用 .pyw 作为源代码后缀并当作主文件执行时,可以隐藏通常出现的控制台窗口。也就是默认在后台运行,但不会影响用户图形界面的显示。

附加奖励

作为奖励,为了澄清几周前在“数学表达式宣告(announcement)”中提出的一个观点:屏幕阅读器与语音合成器不同。NVDA 可以让用户自定义语音词典,从而影响数据解释和输出。但语音合成器的问题应该向合成器供应商提出,而不是向 NV Access 提出。

在讨论中,即使 NVDA 已经收集并解释了数据,以便能够正确地宣告数学表达式,那么语音合成器也可以从NVDA 中获取任何数据,并使用它们附带的任何规则进行朗读。换句话说,除非屏幕阅读器供应商非常熟悉当时使用的合成器(可能会在数据解释和处理时影响某些语音输出规则),否则 NVDA 无法影响与语音输出相关的所有内容,因为该工作留给语音合成器驱动程序维护者; 即使用户自定义了语音词典条目并在一定程度上影响了字符处理步骤,合成器最终得到的还是文本和语音命令。

关于数学表达式宣告的问题是编写有关 NVDA 组件内幕的原因之一。这既是为了展示在数据收集、表示、解释、呈现步骤中哪些内容会被调用以及调用的原因,也是为了展示屏幕阅读器和 TTS 引擎之间的分离。

希望这有助于澄清很多事情。 接下来:你有什么功能想让我谈谈它的内部结构吗?

Cheers, Joseph

评论与回复

译注:截至发布时无评论

译注

译自 Joseph Lee - The Inside Story of NVDA: parts that make up NVDA screen reader (2022-09-24)