跳到主要内容

NVDA 内幕故事6:解析 NVDA 对象

· 阅读需 14 分钟
Joseph Lee
The author of NVDA-Internals
Chengyu HAN
Open Source Contributor

本文是第六篇《内幕故事》的译文,也是 NVDA 对象的三部曲之一: NVDA 对象的解析,深入探索构成 NVDA 对象组成部分。

对于新读者:《NVDA 内幕故事》是一系列详细介绍 NVDA 操作和内部机制的帖子。 由于某些部分可能会变得相当技术性,我会尽力简化一些,但不会削减关键细节。 对于经验丰富的 NVDA 用户,我希望这些帖子能帮助他们理解 NVDA 的工作原理。

希望大家这段时间都能保持安全和健康。 (译者注:原帖发表于 2022年12月份)

系列文章​

以下是关于 NVDA 对象的系列文章:

  1. 控件名称和角色的来源:关于 NVDA 如何宣告控件的名称和角色的详细概述。
  2. (本帖)NVDA 对象的解剖:涉及构成 NVDA 对象的部分。
  3. 叠加类(下一篇文章):进入叠加类(非常接近附加组件开发领域)。

这篇文章也应该会澄清最近的一些帖子提到的:事件、任务切换、浏览模式等话题。 再次提醒,下面的帖子可能会变得非常枯燥,请耐心阅读:

几个小时前,有人问了个关于任务切换内部机制的问题:“为什么 NVDA 在应用程序之间切换时,有时会发出提示音?“。 答案是事件处理,因为 NVDA 需要知道新的焦点控件是什么,并宣告控件信息。 虽然在讨论 NVDA 对象内部机制时,这可能并非完全相关, 因为我之前承诺过会在秋季学期期末考试后写这篇文章,所以我借此机会向大家介绍一下 NVDA 对象, 它是 NVDA 屏幕阅读器正常工作的关键组件。

正如多年来多次提到的,NVDA 将屏幕控件表示为 NVDA 对象。 简而言之,NVDA 对象 (NVDAObjects.NVDAObject) 是 NVDA 所理解的屏幕控件的理想表示。 关键词是“理想”(在引号中)—— 虽然有时直接使用 NVDA 对象(类), 但大多数情况下,您会在 Firefox、Word、Web 文档等地方遇到 IAccessible 和 UIA 对象之类的对象。 我刚才所说的全部内容将在下一篇关于覆盖类的“内幕”中得到充分展示,但可以说,基础 NVDA 对象是蓝图、模板, 而 UIA 对象等无障碍 API 对象提供了具体的实现 (实际情况会比这更复杂一些,我们将在下一篇《内幕故事》中看到)。

面向对象编程​

为了更好地理解 NVDA 对象在 NVDA 世界中的作用, 有必要讨论面向对象编程 (Object-Oriented Programming, OOP) 的类和基础知识。

面向对象编程是一种将世界表示为众多对象交互的方法。 对象可以是任何事物 —— 人、动物、游戏角色、汽车、运动队,甚至是我们生活的世界和环境。 这些对象由类定义,类是一个容器,它定义了即将被赋予生命的对象的属性和行为。 面向对象编程的目标之一是:用类和对象来表示问题,并基于对象之间的交互提供解决方案。

例如,一个 人物(Person) 类定义了诸如姓名和年龄等属性, 以及诸如说话、行走和其他动作等行为。 “人”的对象是该人类的一个实例 (instance)(实例是类的活生生的体现)。 现在假设这个人想要探索某个环境,那么就定义一个环境类, 其中包含诸如位置、环境类型、树木和动物等特征以及数千个其他属性(attributes)和动作(methods)。 然后,该人物类可以定义一个“行走(walk)”方法,该方法简单地告知环境类/对象该人正在环境中行走。 至少,这是面向对象编程在高级(或最高)层面上的重要组成部分。

抽象类与继承​

但是,如果一个对象仅由一个类定义,世界将会变得平淡无趣。 为了让世界变得更有趣,程序员会定义额外的类,有时这些类会相互叠加。

一个类可以从另一个类中获取其功能(继承, inheritance),处理一组可能不相关的对象的相同操作(某种意义上的多态性), 或者该类可能只是人们心目中的一个理想形象(抽象基类, abstract base class)。 类继承是指第二个类如何扩展第一个类,或者第三个类如何包含两个类的部分内容(这种情况称为多重继承)。 多态性是指程序处理看似不相关的对象执行相同(或相似)操作的能力 (例如,当计算面积时,正方形和圆形需要不同的公式)。

抽象基类是无法变成对象(实例化/变为现实)的类, 一个很好的例子是交通运输的抽象概念,它由汽车、飞机、轮船甚至航天器(耗资数百万美元)等实际事物来实现; 如果你还记得的话,对于从事面向对象编程的人来说,汽车和航天器都是对象,或许它们都基于“交通工具”(类)这个抽象概念。 还有其他与类和对象相关的主题,例如:属性访问以及私有/公共/受保护属性,但你不必在这篇文章中关注这些。

我为什么要深入研究面向对象编程、类和对象?因为它们是解释 NVDA 对象如何工作和组织的关键概念。 回想一下,我在之前的一篇《内幕故事》文章中提到过,NVDA 对象是 NVDA 的重要组成部分,这使得屏幕阅读器在设计上完全面向对象。 这是必要的,因为 NVDA 必须以易于理解的方式表示 GUI 控件, 并且还必须与用户会遇到的各种控件以及开发人员希望以某种方式进行调整的控件兼容(这是插件开发的一项主要任务)。 由于 Python 可以定义和使用类和对象,因此将 GUI 控件表示为类是很自然的, 这些类将以 NVDA 对象及其子类的形式存在(我稍后会详细解释我刚才所说的内容),以便用户可以与屏幕上的各种控件进行交互。

NVDA 对象类​

正如我上面提到的,基础 NVDA 对象类代表着理想的 GUI 控件。 实际上,该类派生自两个类:可脚本化对象(scriptable object)和文本容器对象(text container object)。 可脚本化对象提供了定义控件或 NVDA 控件表示的输入手势的方法。 许多类都继承自该类,包括全局插件和应用模块(现在您知道为什么全局插件和应用模块有时会包含键盘命令了吧)。 换句话说,可脚本化对象不仅是 NVDA 对象的基类或父类(有时称为“超类(superclass)”), 而且是任何可以接受键盘、盲文显示硬件、触摸屏和其他输入机制输入的对象, 而这些类则成为可脚本化对象类的“子类(child class)”(有时称为“子类(subclass)”)。 相比之下,文本容器对象类的一项关键职责是:提供基本的表格导航命令, 以及处理对象提供的文本(例如控件名称)所需的其他基本功能。

即使基础 NVDA 对象派生自上述两个类,它仍然是一个“理想”类,因为 NVDA 不会直接实例化它。 这是因为许多提供的方法(函数)以 raise NotImplementedError 结尾。 这迫使程序员定义子类(继承自此类),然后子类将提供标记为“未实现”的方法的具体实现。

例如,提供控件的宽度和高度等位置信息的 _get_location 方法, 其基础实现在基础 NVDA 对象中定义如下:

def _get_location(self):
raise NotImplementedError

将其与 IAccesible 对象实现进行比较 (IAccessible 派生自窗口对象,而窗口对象本身派生自 NVDA 对象):

def _get_location(self):
try:
return RectLTWH(*self.IAccessibleObject.accLocation(self.IAccessibleChildID))
except COMError:
return None

如果处理 UIA 对象,则调用以下实现:

def _get_location(self):
try:
r = self._getUIACacheablePropertyValue(UIAHandler.UIA_BoundingRectanglePropertyId)
except COMError:
return None

if r is None:
return
# `r` 是代表左、上、宽和高的浮点数元组
return locationHelper.RectLTWH.fromFloatCollection(*r)

无需关注 IAccessible 和 UIA 版本的实现细节。 重点在于证明基础 NVDA 对象是屏幕控件的理想表示,而实际的实现则来自表示无障碍 API 的对象。

NVDA 对象的属性​

NVDA 对象有哪些共同的属性? 它们有许多共同的属性,其中最关键的属性如下:

  • 名称 name:控件标签/名称
  • 角色 role:控件角色(类型)
  • 状态 states:可聚焦、选中、离屏等状态
  • 位置 location:控件在屏幕上的位置,如果是文档的一部分,则指光标的位置(如果有具体的实现)
  • 描述 description:控件的描述文本(如果应用程序已经提供)
  • 值 value:控件的显示值(如果有的话)
  • appModule:表示控件所属应用程序的 ap 模块
  • TextInfo:对象的文本信息,用于导航显示为控件一部分的文本
  • windowHandle:有时称为 hwnd,控件的窗口句柄
  • windowClassName:屏幕控件定义的窗口类名称
  • windowStyle:表示使用中的窗口样式(如窗口边框)的按位集合

此外,NVDA 对象(主要是具体实现)将包括可访问性 API 特定属性, 例如:IAccessible/MSAA 子类 ID、UIA 类名和框架等等。

那么事件呢?是的,基础 NVDA 对象确实定义了一组事件及其基本实现, 包括实时区域更改事件(用于以语音和盲文播报实时区域文本)、获得焦点事件(用于播报焦点控制信息)等等。 继承自基础 NVDA 对象的对象可以覆盖或扩展事件行为(对于某些插件开发来说,这也是一项主要任务)。

NVDA 对象的继承层次​

我将通过回顾 NVDA 对象的继承层次结构来结束这篇文章:

  1. 可脚本化的对象(Scriptable object) + 文本容器对象(text container object)
  2. 基本 NVDA 对象(NVDAObjects.NVDAObject)
  3. 窗口(NvDAObjects.window.Window)和行为(NVDAObjects.behaviors), 例如:表格单元格、进度条、可编辑文本、通知
  4. API 类/对象(NVDAObjects.IAccessible.IAccessible、NVDAObjects.UIA.UIA 等)
  5. 覆盖类(Overlay classes)。有内置于 NVDA的,也有来自附加组件的

其中,这篇文章讨论了第 2 项,下一篇《内幕故事》文章将讨论第 4 项和第 5 项的部分内容。 虽然完全可以在基础 NVDA 对象之上直接创建自定义覆盖类,但大多数覆盖类的功能都来自 API 类。

对象的底层表示​

最后需要注意的是: 在机器层面(从计算机处理器的角度来看),它不处理类的方法和属性;它只是将世界视为整数和内存地址。 实际上,类被组织为一个内存地址表,每个表条目对应一个类属性或方法。 当您指示 CPU 从类(对象)执行某项操作时,它只需查找该类方法的内存地址并执行找到的任何指令。 CPU 无法理解您希望 NVDA 响应来自各种屏幕控件的事件—— 它只关心执行位于内存地址中的任何指令,而该地址恰好指示 CPU 响应处理事件或执行完全不同操作的二进制代码。 这就是 NVDA 对象的“实际”结构 —— 一组以人类可读名称标记的整数。

小结​

我知道上面的帖子比我希望的要复杂得多(抱歉)。 我希望我写的内容能让你一窥 NVDA 的内部结构,以及它如何提供平等的技术访问权(至少在技术层面)。 从某种程度上来说,这篇文章是必要的,因为它解释了为什么人们会给你这样的答案: “事情取决于应用程序开发人员的设计”和“除了屏幕抓取之外,还有其他方法可以获取控件名称和类型”。

我没有涵盖 NVDA 对象的其他部分, 例如:关系/导航属性(上一个/下一个/父级/第一个子级/最后一个子级)、 呈现类型(未知(unknown)/内容(content)/布局(layout))及其对简单审阅模式的影响, 以及 NVDA 部分使用的其他属性,这是故意的。 这些内容可能会让人不知所措;我想给你一个关于 NVDA 对象实际上是什么的概述,以便你更好地理解 NVDA 的各个部分 (从某种程度上来说,复杂的部分是必要的)。

在下一篇《内幕故事》文章中,我将讨论什么是覆盖类,它们是如何实现的,并提供一些 NVDA 内置覆盖类的示例。

在此之前,祝您节日快乐,平安健康。

Cheers, Joseph

评论与回复​

Janet Brandly​

感谢您分享这些有趣且信息丰富的帖子。 我工作和其他所有事情都使用 NVDA。 很高兴今年终于能为 NVDA 项目贡献一点力量。继续努力吧。

Brian​

是的,谢谢, 作为一个曾经在基于 z80 的机器上使用 Basic 和一些机器代码进行编程的人,我开始看到当时和面向对象系统之间的一些裂缝。

很多时候,当我使用链接数组中带有属性的基本定义对象时,我几乎是在创建一个对象等等。 我怀疑在我这个年纪,我是否还能做这么多,因为旧的脑细胞不像以前那么有可塑性了,但这值得深思。 我一直觉得汇编语言很抽象,因为你实际上在处理内存位置和处理器寄存器, 而且由于硬件限制,你无法真正知道你认为应该发生的事情是否真的会发生。

无论如何,祝您假期愉快,身体健康, 现在看来,如果我们都接种了疫苗,那么在没有任何促成因素的情况下,疫情的问题就会很小。

Sarah k Alawami​

我还记得那些日子,我读着 VB 的书,然后决定编程不适合我。这很有意思。 我记得很久很久以前,我甚至读过一些 C sharp 和 C plus 的资料。 程序员们,继续努力吧,我一点也不羡慕你们。

祝大家节日快乐。

Mark​

再次感谢 Joseph 解释 NVDA 对象的构造。 我感到疑惑的一点是,NVDA 在用户导航到 NVDA 对象时,会根据什么原则来读出它们的属性。 按 Tab 键,你会先听到“标签”,然后是“控件类型”。 按 b 键移动到按钮,你会先听到“标签”,然后是“控件类型”。 按下箭头键,你会先听到“角色”,然后是“标签”。 JAWS 也是这样做的。那么,指导这个决定的原则,是否有具体名称和依据呢?

作者回复:

这个原则没有具体名称,但我认为实际发生的是,在浏览模式或类似使用树形拦截器的情况下 (树形拦截器是一棵对象树,被视为单个对象), NVDA 会将您视为正在移动到“文档”的不同部分。 例如,在浏览模式下,网页内容会被归类到“文档对象”下,NVDA 会将箭头键视为“文档导航命令”,并会首先读出角色, 因为您遇到的是文档“内部”的内容,为了避免将角色文本与文档内容混淆。 这与浏览器中的 Tab 和 Shift+Tab 键不同,因为 Tab 键旨在移动系统(键盘)焦点,因此会首先读出标签。

对原始帖子的一个小附录:

基本 NVDA 对象:

  • 可以定义一个树拦截器(tree interceptor)或对它的引用, 这在处理一组对象必须被视为单个对象或文档的情况时非常有用。 这就是为什么存在一种称为“文档审阅(document review)”模式的东西, 审阅游标的范围是“文档”,即给定对象树的平面视图。
  • 将事件视为方法。
  • 需要进程 ID,否则对 NVDA 来说没有意义,因为它用于识别 NVDA 对象所代表的给定屏幕控件位于哪个应用程序中。

译注​

译自 Joseph Lee - The Inside Story of NVDA: the anatomy of an NVDA object (2022-12-28)