NVDA 插件开发实践第五篇: 实现功能了吗?还差点啥?有哪些调试技巧?
这是 NVDA 插件开发实践系列文章的第五篇。你可以通过下面的“传送门”查看之前的章节:
前言
在上一篇中,我们成功地将之前在 NVDA Python 控制台中编写的实验性代码,迁移到一个独立的 Python 文件中,实现了代码的 持久化。这意味着我们创建了一个可复用的 NVDA 插件脚本,而不再仅仅是执行一些临时的代码片段。
然而,我们当时的脚本功能非常有限,仅仅是简单地读取了整个状态栏的文本内容,与 NVDA 原生的状态栏朗读功能并无本质差异。我们的目标是读取 VS Code 代码编辑区域的行号,显然还需要进一步的开发。
在本篇中,我们将深入剖析现有的代码,并逐步对其进行改进,使其能够更精准地实现我们的需求。此外,我们还将学习一些重要的调试技巧,帮助我们更有效地排查和解决开发过程中遇到的问题。
应用模块的代码结构分析
回顾上一篇的代码,尽管总共不到 10 行,但它已经构成了一个 NVDA 应用模块插件的基本框架。为了更好地理解,我们将其分解为三个关键部分:
1. 导入必要的模块
import api
import appModuleHandler
import ui
from scriptHandler import script
import appModuleHandler
:appModuleHandler
模块是开发任何 NVDA 应用模块(AppModule)插件的基石。它提供了 NVDA 与特定应用程序交互的基础能力,定义了应用模块的生命周期和事件处理机制。from scriptHandler import script
:script
装饰器从scriptHandler
模块导入,用于将普通的 Python 函数声明为可被 NVDA 执行的脚本,并将其与特定的输入手势(例如键盘快捷键)关联起来。script
装饰器是定义 NVDA 插件功能的核心组件之一。
2. 定义插件的 AppModule 类
class AppModule(appModuleHandler.AppModule):
class AppModule(appModuleHandler.AppModule):
: 定义一个名为AppModule
的类,该类继承自appModuleHandler.AppModule
。每个应用模块插件都必须定义这样一个类,它充当 NVDA 与特定应用程序之间的桥梁,用于封装与该应用程序相关的 NVDA 功能。通过继承appModuleHandler.AppModule
,我们的AppModule
类具备了处理特定应用程序事件的能力,并能响应用户的操作。
3. 定义脚本函数并绑定手势
@script(gesture="kb:NVDA+Shift+Control+L", description="Report current line in vscode.")
def script_reportLine(self, gesture):
statusBarText = api.getStatusBarText(api.getStatusBar())
ui.message(statusBarText)
@script(gesture="kb:NVDA+Shift+Control+L", description="Report current line in vscode.")
:@script
装饰器将script_reportLine
函数转换为一个 NVDA 脚本命令。gesture="kb:NVDA+Shift+Control+L"
: 指定了触发此脚本的键盘快捷键为NVDA+Shift+Control+L
。kb:
前缀表示这是一个键盘手势,NVDA 支持多种类型的手势(输入来源),包括键盘 (kb)、触摸屏 (ts) 和盲文显示器 (br)等。description="Report current line in vscode."
: 为该脚本添加了描述信息,此描述将会在 NVDA 的输入帮助模式(NVDA+1
)和“按键与手势”对话框中显示,便于用户理解该脚本命令的功能。
def script_reportLine(self, gesture):
: 定义了一个名为script_reportLine
的方法。该方法会在用户执行指定手势时被调用。self
: 是对AppModule
类实例的引用,允许访问当前模块的属性和方法。gesture
: 是一个InputGesture
对象,包含触发脚本命令的输入手势信息,比如我们可以获得执行当前脚本命令的手势名称。
注意: 所有使用 @script
装饰器定义的方法,其名称都必须以 script_
开头,这是 NVDA 脚本机制的要求。
当前阶段,我们不必过分强调使用高级代码编辑器。我们的首要目标是确保代码能够成功运行。通过以上几行代码,你就能让 NVDA 为你服务,这是一件非常令人兴奋的事。强烈建议在阅读完本节内容后,你能够亲自将这些代码敲一遍,加深理解。
插件的调试过程:日志、日志片段和重载插件
在软件开发过程中,出现错误是不可避免的。而调试 (debug) 是一个找出并修复这些错误的过程。对于 NVDA 插件开发,日志系统、日志片段和插件重载是我们常用的调试工具。
启用日志记录和错误提示音
NVDA 具有完善的日志记录系统,用于记录 NVDA 运行过程中的各种事件,包括错误信息。这些信息对于分析错误原因至关重要,也是开发者排查问题的关键依据。此外,为了在代码出现错误时及时得到提醒,我们需要开启错误提示音。
- 启用日志记录: 按下
NVDA+Ctrl+G
打开“常规”设置面板。在“日志记录级别”下拉菜单中,选择“调试 (DEBUG)”。“调试”级别会输出最详细的日志信息,方便我们排查错误。在日常使用中,为了安全和性能考量,通常将日志级别设置为“禁用”或者“信息 (INFO)”。 - 开启错误提示音: 切换到“高级”设置面板,勾选“我清楚更改这些设置可能导致 NVDA 无法正常运行。” 随后找到“日志记录后播放错误提示音”,下拉选择“是”。 这样设置后,当 NVDA 或插件代码发生错误时,会发出短促的提示音。
定位错误并解读日志
为了演示如何利用日志进行调试,我们可以在代码中人为地制造一个 Typo (拼写错误)。例如,将 import ui
改为 import uo
,保存修改后,重新启动 NVDA 并切换到 VS Code。
你会发现,NVDA 没有读出任何内容,反而发出了错误提示音。此时,打开日志查看器(NVDA+F1
),并将日志保存到本地。然后使用你常用的文本编辑器打开日志文件,你会看到类似下面的信息:
INFO - __main__ (19:56:29.461) - MainThread (20544):
Starting NVDA version 2024.4.1 x86
INFO - core.main (19:56:29.532) - MainThread (20544):
Config dir: C:\Users\cary\AppData\Roaming\nvda
INFO - config.ConfigManager._loadConfig (19:56:29.538) - MainThread (20544):
Loading config: C:\Users\cary\AppData\Roaming\nvda\nvda.ini
...
继续往后看,你还会看到更多环境信息:包括NVDA 的用户设置;操作系统版本;Python 版本;各个模块的初始化状态;从哪里,加载了哪些插件,是否加载成功等等。
其中有一项你可能会熟悉: Developer Scratchpad mode enabled
这就是我们在上一篇启用的“允许从开发者实验目录加载自定义代码”选项。
我们往往希望快速定位到出错原因,上述导致 NVDA 无声的严重错误,会被 ERROR 这个级别的日志捕获。所以,我们以 "Error" 为关键词在日志内查找,前面可能会有包含 "Error" 的匹配结果,比如你有可能会看到一些带有 "Error" 字样的 NVDA 的配置 Key/Value 比如 "playErrorSound" 这显然就不是一个错误,而是 NVDA 的一个设置项,也就是前面我们设置过的的“日志记录后播放错误提示音”。
如果按照上述我提供的方法修改(制造了一个拼写错误),你一定会找到类似下面的日志片段,其中涉及到路径的部分可能不同:
File "C:\Users\cary\AppData\Roaming\nvda\scratchpad\appModules\code.py", line 4, in <module>
import uo
ModuleNotFoundError: No module named 'uo'
这段日志清晰地显示了错误发生的位置:code.py
文件的第 4 行,错误类型是 ModuleNotFoundError
,错误信息是 No module named 'uo'
,表示找不到名为 uo
的模块。这正是因为我们将 ui
误写成了 uo
。
捕获日志片段
现在,请将 import uo
改回 import ui
,然后我们再制造另一个错误,将最后一行的 ui.message(statusBarText)
改成 UI.message(statusBarText)
(将 ui
改为大写)。保存修改后重新加载插件。
与上面一样,重新启动 NVDA 使更改生效,再切换到 VS Code 窗口。
你可能会发现,当你切换到 VS Code 后,NVDA 并没有报错,但当你按下先前定义的快捷键 NVDA+Ctrl+Shift+L
的时候,错误出现了。
养成一个好习惯,当 NVDA 报错的时候,不要置之不理,随手看一下日志。当然,我们可以按照上面的步骤打开日志查看器并在日志中搜索错误信息。
然而,NVDA 还提供了一个更方便的功能——捕获日志片段。它可以帮助我们快速地获取特定阶段的日志信息。这种方法适用于那些可以稳定复现错误的调试场景。
、操作步骤如下:
- 标记日志片段开始点: 按下
NVDA+Shift+Ctrl+F1
,NVDA 将朗读“日志片段开始点已标记,再按一次复制到剪贴板”。 - 复现错误: 切换到 VS Code,按下
NVDA+Ctrl+Shift+L
快捷键,触发错误。 - 复制日志片段: 再次按下
NVDA+Shift+Ctrl+F1
,将日志片段复制到剪贴板。 - 粘贴到文本编辑器: 打开文本编辑器,粘贴剪贴板内容。
你会看到类似下面的错误信息(路径和行号可能不同,错误类型也可能不同,至于原因,请往后看):
IO - speech.speech.speak (19:53:04.549) - MainThread (7136):
Speaking ['日志片段开始点已标记,再按一次复制到剪贴板']
DEBUG - speech.manager.SpeechManager._handleIndex (19:53:05.195) - MainThread (7136):
Unknown index 5201, speech probably cancelled from main thread.
IO - inputCore.executeGesture (19:53:05.318) - winInputHook (1344):
Input: kb(laptop):NVDA+control+shift+l
ERROR - scriptHandler.executeScript (19:53:05.461) - MainThread (7136):
error executing script: <bound method AppModule.script_reportLine of AppModule(code, appName='code', processID=28640)> with gesture 'NVDA+Ctrl+Shift+l'
Traceback (most recent call last):
File "scriptHandler.pyc", line 300, in executeScript
File "C:\Users\cary\AppData\Roaming\nvda\scratchpad\appModules\code.py", line 12, in script_reportLine
UI.message(statusBarText)
^^
NameError: name 'UI' is not defined
IO - inputCore.executeGesture (19:53:06.186) - winInputHook (1344):
Input: kb(laptop):control+shift+NVDA+f1
这段日志告诉我们,script_reportLine
函数的第 12 行发生了 AttributeError
,错误信息是 name 'UI' is not defined
。
特殊说明:VS Code 中的一个特殊 Bug
你可能还记得,我在上一篇文章中强调过,在检验的时候“反复按” NVDA+Ctrl+Shift+L
,实际上,如果你是按照我上面的步骤操作的,第一次捕捉到的日志错误类型可能与上面不同,而是:
IO - speech.speech.speak (19:52:02.363) - MainThread (7136):
Speaking ['日志片段开始点已标记,再按一次复制到剪贴板']
IO - inputCore.executeGesture (19:52:03.857) - winInputHook (1344):
Input: kb(laptop):NVDA+control+shift+l
ERROR - scriptHandler.executeScript (19:52:03.894) - MainThread (7136):
error executing script: <bound method AppModule.script_reportLine of AppModule(code, appName='code', processID=28640)> with gesture 'NVDA+Ctrl+Shift+l'
Traceback (most recent call last):
File "scriptHandler.pyc", line 300, in executeScript
File "C:\Users\cary\AppData\Roaming\nvda\scratchpad\appModules\code.py", line 11, in script_reportLine
statusBarText = api.getStatusBarText(api.getStatusBar())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "api.pyc", line 471, in getStatusBarText
AttributeError: 'NoneType' object has no attribute 'appModule'
IO - inputCore.executeGesture (19:52:04.901) - winInputHook (1344):
Input: kb(laptop):control+shift+NVDA+f1
这样,你应该就不慌了,不是你的问题,这是 VS Code 中的一个错误,见Microsoft/vscode#96392(comment)。
对于该错误,我们将在后续的开发中进行特殊处理。
使用捕捉日志片段的方法能够快速补货错误发声时产生的日志,降低了我们在日志中的寻找成本。
快速重载插件
每次修改代码后都重启 NVDA 显然效率较低。NVDA 允许我们快速重新加载插件,而无需完全重启。
在“NVDA 菜单”(NVDA+N
)中,选择“工具”菜单,然后选择“重新加载插件”,
或者直接按下 NVDA+Ctrl+F3
两种操作方式是等价的。
修复错误后,使用这个功能可以快速使修改生效。
请注意:个别插件的 terminate 做的并不是很好,容易在重载插件后导致 NVDA 出现一些奇怪的行为,如果遇到此类问题,且暂时没有足够的经验去处理,请依然使用重新启动的方式来重新加载修改后的插件。
我个人实际上更推荐你用一个干净可控的环境来开发/测试插件,比如使用一个不包含三方插件的便携版。
对不同辅助功能接口的统一抽象——NVDAObject
我们之前实现的代码只是简单地读取了状态栏的全部文本,而我们真正想要的是包含行号的那部分。
回顾一下 api.getStatusBar
函数的返回值:
>>> help(api.getStatusBar)
Help on function getStatusBar in module api:
getStatusBar() -> Optional[NVDAObjects.NVDAObject]
Obtain the status bar for the current foreground object.
@return: The status bar object or C{None} if no status bar was found.
可以看到,这个函数返回的是一个 NVDAObjects.NVDAObject
对象。
NVDAObject
是 NVDA 对屏幕上各种 UI 元素进行的统一抽象。无论这些元素来自 MSAA、UIAutomation、JAB 还是其他辅助功能接口,NVDA 都会将其转换为 NVDAObject
。这种抽象使得我们可以使用一套统一的 API 来访问和操作不同的屏幕元素,而无需关心底层辅助功能接口的差异。
如果上面的表述你暂时无法完全理解也没关系,此处如果你对 NVDA 的对象导航操作很熟悉,那么后面的内容理解起来就不难了。
在 VS Code 中,状态栏不是一个简单的文本区域,它是一个容器对象,也就是说它由多个子对象构成,例如用于显示/更改当前文件编码的按钮、显示/切换当前 git 分支的按钮。我们需要的行号信息只是其中一个子对象的文本。
那么,如何获取特定子对象的文本呢?这需要我们深入了解 NVDAObject
的属性。
探索 NVDAObject
的属性
首先我们按 F6
找到状态栏,使用对象导航查看一下状态栏这个对象中包含哪些子对象,。以我当前的状态栏为例,对象导航过程中 NVDA 将朗读:
- "remote 按钮"
- "nvda (Git) - TypedWords, Checkout Branch/Tag... 按钮"
- "nvda (Git) - Synchronize Changes 按钮"
- "git-pull-request Pull Request #17505 按钮"
- "Ln 1000, Col 53 按钮"
- ...
随后,我们可以使用 NVDA 的 Python 控制台来探索 NVDAObject
的属性,以刚刚我们查看的状态栏为例。
依然使用对象导航找到“状态栏”这个对象,不是其子对象哦!
-
转到 NVDA Python 控制台: 按下
NVDA+Control+Z
打开。 -
获取状态栏对象: 输入以下代码并回车,获取状态栏对象:
statusBar = nav
-
检查状态栏对象是否有效: 输入
statusBar
并回车。如果返回<NVDAObjects.IAccessible.ia2Web.Ia2Web object at ...>
,说明获取成功。 -
获取状态栏对象的子对象: 输入以下代码,获取状态栏对象的子对象:
children = statusBar.children print(children)
控制台将输出一个列表,其中包含了状态栏的所有子对象,每个子对象都是一个
NVDAObject
。
记住哦!NVDAObject.children
会返回一个 list,当然,不记得也没关系,使用 type() 可以告诉你。 -
循环遍历子对象并打印其文本: 输入以下代码,遍历子对象并打印其文本:
for child in children: print(child.name)
查看输出结果,你会发现正式我们使用对象导航看到的那些,也就是状态栏上实际显示的文本,我们要的行号信息也在其中。
-
筛选包含行号的子对象
既然 statusBar.children
返回一个列表,那么得到其中包含行列信息的列表项索引就很容易了。
比如,你可以使用列表推导式把子项及其索引打印出来:
print(*enumerate([child.name for child in children]))
在我这里,包含行列信息的子项索引是 12,那么:
statusBar.children[12].name
通过以上步骤,我们就成功地获取到了包含行号的文本信息。
当然,这里不够健壮,在这里埋一个伏笔,随着学习的深入让我们逐步来改进它。
代码修改
现在,我们已经知道如何获取行号文本了,让我们修改代码:
import api
import appModuleHandler
import ui
from scriptHandler import script
class AppModule(appModuleHandler.AppModule):
@script(gesture="kb:NVDA+Shift+Control+L", description="Report current line in vscode.")
def script_reportLine(self, gesture):
statusBar = api.getStatusBar()
ui.message(statusBar.children[12].name)
这段代码主要做了以下修改:
- 获取状态栏对象。
- 通过索引得到包含行列信息的子对象,进一步得到子对象的
name
属性。 - 通过
ui.message
将其朗读出来。
保存修改后的代码,重新加载插件,再次按下定义的快捷键,NVDA 现在应该只会读出包含行列信息的那部分文本了。
总结
在本篇中,我们对上一篇的代码进行了改进,使其能够更精确地读取 VS Code 状态栏中的行列信息。同时,我们还学习了如何使用 NVDA 的日志系统进行调试,以及如何利用 NVDAObject
及其属性来获取屏幕元素的信息。
尽管我在第一篇中表示这不是针对初学者的手把手教程,然而在叙述中我还是尽力写的详细,足够初学者友好。代码的进度很慢,但内容依然丰富,除了阅读以外,你一定需要动手实践一下。
一如既往,如果你从第一篇看到这里了。别忘了留下你的反馈。
在下一篇中,我们将继续深入探讨 NVDAObject
,学习如何使用 NVDAObject
的属性和方法,同时穿插一些 code review 的建议,从而进一步改进我们的插件。