【openKylin 读屏实测・第 1 篇】openKylin 3.0 无障碍底座实测:AT-SPI 事件总线与屏幕阅读器开发实践

专栏:《openKylin 3.0 无障碍开发实测》・第 1 篇(共 9 篇:1 篇总序 + 8 篇专题)

实测环境:openKylin Desktop 3.0(UKUI 4.24 /kylin-wlcom 1.3.1-ok36 / Wayland 会话)、libatspi 2.60.0、Qt 6.10.2、glib 2.80.0、speech-dispatcher 0.12.1(输出模块 sd_kylin_speech)

实测时间:2026 年 9 月

版本声明:本文所有结论均绑定上述版本与真机实测,命令中的会话号、输入设备号(如 /dev/input/event4)以各自机器实际值为准;标注「待核实」处为尚未定论项,不代表其它版本或架构。

代码说明:文中 C++/Python 片段均取自作者自研的开源屏幕阅读器项目(GPL-3.0+,下文统称 “读屏守护进程”),可自由参考复现。

摘要:本文记录在 openKylin 3.0(Wayland /kylin-wlcom 会话)上对 AT-SPI 无障碍底座的完整实测:组件版本(libatspi 2.60.0 / Qt 6.10.2 /glib 2.80.0)、事件订阅与实测行为、Qt 应用入树的真实前提、回调线程纪律,以及四款系统应用的无障碍暴露质量普查。所有结论均有真机证据,代码片段取自项目自有实现,可直接复现。


1. 背景

开发桌面屏幕阅读器,第一件要做对的事是拿到 “焦点在哪、控件是什么” 的事件流。openKylin 3.0 的桌面栈是 UKUI 4.24 + kylin-wlcom(Wayland 合成器),其无障碍底座与主流桌面一致:应用通过 AT-SPI 总线暴露控件树,读屏进程作为 AT-SPI 客户端订阅事件。实测环境组件版本:

组件 版本 确认方式
openKylin Desktop V3.0(wayland /kylin-wlcom 会话) loginctl show-session <会话号>
libatspi 2.60.0 pkg-config --modversion atspi-2 + 运行时确认
Qt 6.10.2(qt6-base-dev) 程序启动自报
glib / gio 2.80.0 pkg-config --modversion glib-2.0
speech-dispatcher 0.12.1(输出模块 sd_kylin_speech) 进程列表确认

1.1 AT-SPI 架构速览

AT-SPI(Assistive Technology Service Provider Interface)是 Linux 桌面的无障碍标准接口,在 openKylin 3.0 上的形态:

  • 会话总线:应用把无障碍对象树注册到会话 D-Bus 的 org.a11y.Bus(由 at-spi2-registryd 提供服务);

  • 事件流:应用通过 org.a11y.atspi 事件总线发布事件(focus、state-changed、property-change 等),读屏注册监听器接收;

  • 组件接口:读屏可以同步查询对象树(AtspiAccessible)、文本内容(AtspiText)、值接口(AtspiValue)等。

读屏只需要做两件事:订阅事件(被动)与查询对象(主动)。本文的读屏守护进程采用「事件源层 → 归一化层 → 优先级队列 → 语音会话」的四层架构,AtspiSource 是事件源层核心模块。

2. 实测方法

所有探测在用户会话内完成。第一条硬教训:SSH 远程拉起 GUI 应用不可靠(会话环境不全时应用会 core dump 且不进无障碍树),正确姿势是「用户在会话内手动打开应用 + SSH 只做观测」。

# 1) 确认会话类型与桌面(会话号以实际为准)loginctl list-sessionsloginctl show-session <会话号> | grep -E "Type|Desktop"   # 期望 wayland / kylin-wlcom# 2) 检查应用进程的会话环境(读屏开关打开后,peony/ukcc 进程应有 QT_ACCESSIBILITY=1)tr '\0' '\n' < /proc/<pid>/environ | grep -E "QT_ACCESSIBILITY|XDG_RUNTIME|WAYLAND|DBUS_SESSION"# 3) 安装普查工具sudo apt install accerciser at-spi2-tools# 4) 逐应用遍历 AT-SPI 树,记录:控件数 / 无名控件数 / 角色分布

普查脚本用 pyatspi(系统自带,无需 accerciser 也可以跑):

# census.py(pyatspi 普查脚本骨架,SSH 只观测、不拉起应用)import pyatspidesktop = pyatspi.Registry.getDesktop(0)MAX_DEPTH = 8   # 必须设最大深度,否则循环引用可能让脚本无法结束def walk(acc, depth=0):    if depth > MAX_DEPTH:        return    name = acc.name or ""    role = acc.getRoleName()    if not name:        # 记录无名控件(角色 + 路径)        pass    for child in acc:        walk(child, depth + 1)for app in desktop:    print("APP:", app.name, "children:", app.childCount)    walk(app)

3. AT-SPI 连接与事件订阅

3.1 初始化与功能校验

连接 AT-SPI 之前必须做一次功能性校验:取 desktop 对象并数应用数。这一步能立刻暴露总线不可达、应用未入树等问题,而不是等到事件静默缺失才排查。

// atspi_source.cpp(初始化,节选)bool AtspiSource::init() {    if (initialized_) return true;    if (atspi_init() < 0) return false;    // 功能性校验:取 desktop 并数应用数    AtspiAccessible *desktop = atspi_get_desktop(0);    if (!desktop) return false;    const int n = atspi_accessible_get_child_count(desktop, nullptr);    appCount_ = n < 0 ? 0 : n;    g_object_unref(desktop);    initialized_ = true;    return true;}

真机启动日志示例(apps=6:内置读屏 / 远程桌面 / 终端 / 文件管理器 / 设置 / 编辑器都在无障碍树中):

ukui-screen-reader v0.1 (main chain)Qt 6.10.2 | glib 2.80.0settings: enabled=false rate=200 volume=60atspi ready, apps=6hotkey listening on /dev/input/event4   # 设备号以实际机器为准main chain running; Win+Alt+S toggles, SIGTERM exits

3.2 事件订阅清单与实测行为

// atspi_source.cpp(订阅事件清单,节选)const char *types[] = {    "focus:",    "object:state-changed:focused",    "object:text-caret-moved",    "object:property-change:accessible-name",    "window:activate",    "object:children-changed:add", "object:children-changed:remove",    "object:visible-data-changed",    "mouse:abs",};for (const char *t : types) {    GError *err = nullptr;    atspi_event_listener_register(listener, t, &err);    if (err) {        g_error_free(err);        return false;    }}

实测中各类事件的真实行为(真机观察,2026-09 期间多次验证):

事件类型 实测行为 处理
focus: / object:state-changed:focused 正常触发,焦点切换的主数据源 播报核心输入
object:text-caret-moved 编辑器中光标移动触发,detail1 为偏移量 光标位置朗读
object:property-change:accessible-name 控件名称变化触发,高频 归一化后按优先级裁决
window:activate 窗口激活时触发(frame 级) 「窗口已打开 / 激活」播报候选
object:children-changed:add/remove 高频—— 编辑器启动即产生一簇 静默监控事件(只记元数据不播报)
object:visible-data-changed 当前场景未触发,保留订阅 静默监控候选
mouse:abs 视合成器而定 鼠标坐标候选源【待核实】

4. 事件回调线程纪律(最重要的设计决策)

AT-SPI 回调运行在 glib 主循环线程里。绝不能在回调里同步发言—— 同步调用 TTS 会阻塞无障碍总线,并破坏优先级裁决。纪律是:

  • 回调线程:只把 AtspiEvent 转成内部统一事件并入队,加锁保护;

  • 主线程(Qt 事件循环 + 100ms 定时 tick):统一 drain → 归一化 → 入队 → 消费出声,唯一允许调用语音会话的线程。

// atspi_source.cpp(回调入口,节选)void AtspiSource::onEvent(void *eventPtr, void *userData) {    AtspiEvent *event = static_cast<AtspiEvent *>(eventPtr);    AtspiSource *self = static_cast<AtspiSource *>(userData);    if (!event || !self) return;    AtspiAccessible *src = event->source;    if (!src) return;    const std::string type = event->type ? event->type : "";    const std::string name = safeText(atspi_accessible_get_name(src, nullptr));    const std::string role = safeText(atspi_accessible_get_role_name(src, nullptr));    AtspiAccessible *app = atspi_accessible_get_application(src, nullptr);    const std::string appName = app ? safeText(atspi_accessible_get_name(app, nullptr)) : "unknown";    if (app) g_object_unref(app);    // 对象标识:libatspi 2.60 未提供 accessible path 接口,    // 退化为 app+role+name 三元组(仅内存使用,不落日志)    const std::string path = appName + "|" + role + "|" + name;    UnifiedEvent ev;    ev.tsMonoMs = nowMonoMs();    ev.tsWallMs = nowWallMs();    ev.source = Source::Atspi;    if (type.rfind("focus", 0) == 0) {        ev.type = EventType::FocusChanged;        FocusPayload p;        p.role = role;        p.name = name;        p.states.push_back("focused");        ev.payload = p;    } else if (type.find("state-changed") != std::string::npos) {        ev.type = EventType::StateChanged;        // ... 按 event->detail1 区分 checked/focused/expanded 等子状态    } else if (type.find("property-change") != std::string::npos) {        ev.type = EventType::PropertyChanged;        PropertyPayload p;        p.property = type.substr(type.find_last_of(':') + 1);  // accessible-name        p.value = event->any_data ? safeText(event->any_data) : "";        ev.payload = p;    }    // ... 其余事件类型分支    {        std::lock_guard<std::mutex> lk(self->mtx_);        ev.eventId = self->nextId_++;        self->pending_.push_back(ev);    }}
// atspi_source.cpp(drain:主线程唯一取事件入口,节选)std::vector<UnifiedEvent> AtspiSource::drain() {    std::lock_guard<std::mutex> lk(mtx_);    std::vector<UnifiedEvent> out;    out.swap(pending_);    return out;}

设计取舍:回调线程只入队、主线程统一发言,代价是一跳额外延迟(≤100ms tick),换来可预测的调度 —— 这是后续防抖、优先级裁决能成立的前提。

5. Qt 应用入树:一个被误判为 “平台缺陷” 的真问题

开发初期通过 SSH 拉起应用,发现 Qt 应用(文件管理器、设置中心)不在 AT-SPI 树里,一度怀疑平台缺陷。逐项排查后定位根因:SSH 拉起时会话环境不全。在用户会话内手动打开后,全部应用正常入树。

进一步检查两个关键事实:

  1. 文件管理器 / 设置进程环境均含 QT_ACCESSIBILITY=1—— 这是 UKUI 会话在读屏开关打开时自动注入的;

  2. 系统级(/etc/environment、/etc/profile.d/)没有 QT_LINUX_ACCESSIBILITY_ALWAYS_ON 配置也能工作。

// atspi_source.cpp(Qt 无障碍开关检查)bool qtAccessibilityEnabled() {    const char *v = std::getenv("QT_ACCESSIBILITY");    return v != nullptr && std::strcmp(v, "1") == 0;}

对读屏开发者的启示:自研守护进程启动时,必须保证会话级 QT_ACCESSIBILITY=1(与 UKUI 现行为对齐),否则第三方 Qt 应用不入树 —— 这不是合成器的问题,是环境变量问题。

5.1 排障清单:应用不进树

现象 排查步骤 结论
应用不在 atspi_get_desktop 子项里 ① SSH 还是用户会话拉起的?② 进程 environ 有无 QT_ACCESSIBILITY=1?③ 读屏开关是否打开? SSH 拉起 = 会话环境不全;应用内打开 + 环境变量在 = 可达
atspi_init 成功但事件收不到 检查订阅是否注册成功、glib 主循环是否在跑 订阅失败要报错退出,不能静默
回调触发了但主线程无反应 检查 tick 定时器是否启动、drain 是否被调用 线程纪律:回调只入队,主线程统一处理

6. 系统应用无障碍暴露质量普查

用 pyatspi 自动遍历应用的无障碍树(用户会话内打开 + SSH 观测),结果:

统计口径(务必先读):本表是应用主窗口的单快照样例—— 会话内打开默认窗口、SSH 只读遍历一次。控件总数与无名数随当前打开的目录、对话框、侧边栏 / 面板展开状态而变,只反映采样时刻,不是稳定常量。另一次不同范围普查(2026-09-18,递归含更多二级页 / 对话框)得到 peony 260 项 / 有名 143(无名约 117)、ukcc 155 项 / 有名 115,与本表(peony 234 / 无名 72、ukcc 141 / 无名 1)遍历深度不同,两组数字不可直接相减或互引,各自只对当次采样负责。

应用 遍历控件数 无名控件数 主要角色分布
文件管理器 peony 4.24 234 72 button×56、list item×37、filler×32、table cell×28、menu item×24、check box×8
设置 ukcc 4.24 141 1(scroll bar) label×43、filler×31、list item×30、button×16、image×13
终端 mate-terminal(对照) 106 33 separator/tearoff 居多(良性)
编辑器 pluma 1.26 430 156 check menu item/separator 居多

结论:

  1. GTK 与 Qt 应用走同一 AT-SPI 通路—— 归一化层无需按工具包分叉(ukcc 是 Qt、mate-terminal 是 GTK,读屏拿到的事件结构一致);

  2. ukcc 的无障碍质量极好(无名仅 1 个滚动条);而文件管理器存在 72 个无名控件(button、table cell 为主)—— 这类控件播报时只能降级读角色名,属于应用侧可访问名称缺口(角色补名策略在归一化层处理);

  3. 终端 / 编辑器的 “无名” 多为 separator、tearoff、check menu item 等非交互装饰控件,属良性,不应误报为缺口。

7. 数据流:一次焦点切换的完整时序

用户按 Tab  → AT-SPI 发 state-changed:focused  → AtspiSource 回调线程:转 UnifiedEvent{type=FOCUS_CHANGED, payload=FocusPayload{name,role}},入队  → 主线程 tick(100ms):drain → 归一化判为 P1  → 防抖窗口(trailing,最终态不丢)→ 到点 flush → 优先级队列裁决  → 当前空闲 → PLAY_NOW  → SpeechSession::speak("按钮 确定", P1) → SSIP → sd_kylin_speech → 出声  → 播报完成通知 → 队列推进下一条

其中「防抖窗口」解决的是 AT-SPI 事件洪峰问题 —— 真实事件管线里 PROPERTY_CHANGED 一个类型就占约 66% 的事件量,详见本系列第 6 篇《AT-SPI 事件风暴与防抖》。

8. 结论与建议

  1. openKylin 3.0 的 AT-SPI 底座完整可用:libatspi 2.60.0 提供事件监听、组件接口(含命中测试)、文本接口(AtspiText)全链路,读屏可以只依赖这一条通路拿到焦点与控件信息。

  2. Qt 应用无障碍质量取决于会话环境变量:QT_ACCESSIBILITY=1 是硬前提,读屏守护进程必须保证会话级注入。

  3. 回调线程纪律不可妥协:事件回调只入队、主线程统一发言,是后续所有调度优化(防抖、优先级、打断)的地基。

  4. 对应用开发者的建议:文件管理器的 72 个无名控件说明控件命名仍是 UKUI 应用无障碍的短板;button、table cell 这类高频交互控件没有可访问名称,读屏只能播报角色。建议应用侧为可交互控件补充可访问名称,并保持 role 与 AT-SPI 规范一致。

9. 附录 A:AT-SPI 常用组件接口速查

读屏最常用的组件接口(libatspi 2.60 实测可用):

接口 关键函数 用途
AtspiAccessible get_name / get_role_name / get_child_count / get_application 控件树遍历
AtspiText get_character_count / get_text / get_caret_offset 文本内容读取
AtspiValue get_current_value 滑块 / 进度条朗读
AtspiComponent get_extents 控件几何位置
AtspiAction get_n_actions / get_name 控件动作名

一个容易踩的差异:libatspi 2.60 的 atspi_accessible_get_text 是单参数(无 GError 出参),2.40 时代的老示例代码传两个参数会编译失败 —— 写代码前先确认头文件签名。

10. 附录 B:常见问题

问题 回答
应用不在 AT-SPI 树里? 确认用户会话内打开(SSH 拉起会缺会话环境)+ QT_ACCESSIBILITY=1(Qt 应用硬前提)
事件收到但播报乱? 检查回调线程纪律:回调只入队、主线程统一 drain + 归一化
回调触发了主线程没反应? 检查 100ms tick 定时器是否启动、drain 是否被调用
控件名是空的? 应用侧可访问名称缺口(peony 72 个无名控件是实例),读屏降级读角色名
atspi_init 成功但无事件? 检查订阅注册返回值,失败要报错退出,不能静默继续

11. 附录 C:启动冒烟清单

# 1) 确认会话loginctl show-session <会话号> | grep -E "Type|Desktop"# 2) 确认读屏守护进程环境tr '\0' '\n' < /proc/<pid>/environ | grep QT_ACCESSIBILITY# 3) 确认 atspi ready 日志#   期望:atspi ready, apps=N(N ≥ 1 表示桌面已在树中)# 4) 手动切换焦点,确认事件日志出现 FOCUS_CHANGED 行

12. 附录 D:事件类型 → 播报映射(归一化口径)

事件类型 播报示例 优先级 防抖
FOCUS_CHANGED “按钮 打开” P1 :white_check_mark: trailing 100ms
STATE_CHANGED “复选框 已勾选” P1 :white_check_mark:
TEXT_CARET “第 5 行 第 12 列” P1 :white_check_mark:
PROPERTY_CHANGED(children) (静默) P2 监控 —
NOTIFICATION “应用名:标题” P1 免抖
USER_CMD “停止” P1 免抖
OCR_RESULT 识别文本 P1 免抖
SYSTEM_FAULT “语音服务不可用” P0 免抖

映射规则来源:真机事件管线实测 + 归一化层单测(详见本系列第 6 篇《AT-SPI 事件风暴与防抖》)。

13. 附录 E:给应用开发者的可访问名称建议

基于四应用普查(peony 72 个无名控件是重灾区),对 UKUI 应用开发者的三条落地建议:

  1. 可交互控件必须有可访问名称:button、table cell、check box 是读屏高频目标,无名时读屏只能播报角色;

  2. role 与语义一致:可点击文本用 link 而非 text,密码框用 password text(真实播报中已见正确样例);

  3. 图标按钮补 tooltip/accessible-name:image 类控件无名率与可交互性无关,但图标按钮(如 “刷新”)必须可命名。

一条实测佐证:设置中心(ukcc)无名控件仅 1 个(滚动条),读屏体验明显优于文件管理器 —— 控件命名质量直接决定读屏可用性。

14. 待核实清单(诚实边界)

  • mouse:abs 事件在 kylin-wlcom 下是否实发尚未定论(预留订阅、链路静默降级);

  • window:activate 在窗口管理场景下的触发完整性待专项观察;

  • 控件普查数据随应用版本变化(peony 4.24 /ukcc 4.24 /pluma 1.26 /mate-terminal 为本次实测版本),引用时请连同采样口径一起引用。


本系列(共 9 篇):00 总序 | 01 AT-SPI 底座(本文) | 02 SSIP 语音 | 03 Wayland 快捷键 | 04 端侧 OCR | 05 系统通知 | 06 事件防抖 | 07 隐私日志 | 08 版本与环境

下一篇:第 2 篇《speech-dispatcher SSIP 协议实战:连接、实时参数与 “下一句才变” 的语义》 | 返回专栏总序(待发布后补链接)

作者:xie-linfeng-666(openKylin 社区 / Gitee)。原创内容,转载请注明出处与 openKylin 版本口径。

3 个赞