专栏:《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 拉起时会话环境不全。在用户会话内手动打开后,全部应用正常入树。
进一步检查两个关键事实:
-
文件管理器 / 设置进程环境均含 QT_ACCESSIBILITY=1—— 这是 UKUI 会话在读屏开关打开时自动注入的;
-
系统级(/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 居多 |
结论:
-
GTK 与 Qt 应用走同一 AT-SPI 通路—— 归一化层无需按工具包分叉(ukcc 是 Qt、mate-terminal 是 GTK,读屏拿到的事件结构一致);
-
ukcc 的无障碍质量极好(无名仅 1 个滚动条);而文件管理器存在 72 个无名控件(button、table cell 为主)—— 这类控件播报时只能降级读角色名,属于应用侧可访问名称缺口(角色补名策略在归一化层处理);
-
终端 / 编辑器的 “无名” 多为 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. 结论与建议
-
openKylin 3.0 的 AT-SPI 底座完整可用:libatspi 2.60.0 提供事件监听、组件接口(含命中测试)、文本接口(AtspiText)全链路,读屏可以只依赖这一条通路拿到焦点与控件信息。
-
Qt 应用无障碍质量取决于会话环境变量:QT_ACCESSIBILITY=1 是硬前提,读屏守护进程必须保证会话级注入。
-
回调线程纪律不可妥协:事件回调只入队、主线程统一发言,是后续所有调度优化(防抖、优先级、打断)的地基。
-
对应用开发者的建议:文件管理器的 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 | |
| STATE_CHANGED | “复选框 已勾选” | P1 | |
| TEXT_CARET | “第 5 行 第 12 列” | P1 | |
| PROPERTY_CHANGED(children) | (静默) | P2 监控 | — |
| NOTIFICATION | “应用名:标题” | P1 | 免抖 |
| USER_CMD | “停止” | P1 | 免抖 |
| OCR_RESULT | 识别文本 | P1 | 免抖 |
| SYSTEM_FAULT | “语音服务不可用” | P0 | 免抖 |
映射规则来源:真机事件管线实测 + 归一化层单测(详见本系列第 6 篇《AT-SPI 事件风暴与防抖》)。
13. 附录 E:给应用开发者的可访问名称建议
基于四应用普查(peony 72 个无名控件是重灾区),对 UKUI 应用开发者的三条落地建议:
-
可交互控件必须有可访问名称:button、table cell、check box 是读屏高频目标,无名时读屏只能播报角色;
-
role 与语义一致:可点击文本用 link 而非 text,密码框用 password text(真实播报中已见正确样例);
-
图标按钮补 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 版本口径。