HarmonyOS开发:无障碍测试——Accessibility测试
HarmonyOS开发:无障碍测试——Accessibility测试
📌 核心要点:无障碍测试验证App是否对所有用户可用,核心检查无障碍节点完整性、TalkBack兼容性和标签覆盖率,自动化审计+手动验证双管齐下。
背景与动机
你闭上眼睛,能用自己的App吗?
这不是开玩笑。中国有超过1700万视障人士,他们靠屏幕朗读(TalkBack)来使用手机。你的App如果没有正确设置无障碍属性,这些用户就完全用不了——按钮没标签,朗读器读不出来;图片没描述,用户不知道是什么;操作顺序混乱,朗读器跳来跳去。
更现实的问题是:很多国家和地区有法律要求App必须满足无障碍标准。国内的应用商店也开始关注无障碍适配,未来不达标的App可能无法上架。
但无障碍适配有个尴尬的现状:开发者很难意识到问题。你看得见屏幕,自然不会发现"这个按钮没标签"——因为你能看到按钮上的文字。只有用TalkBack走一遍,才会发现一堆问题。
所以你需要自动化无障碍测试。用脚本扫描所有组件,检查无障碍属性是否完整,自动生成审计报告。不用手动一个个看,效率高得多。
核心原理
无障碍的三大核心属性
graph TB
A[无障碍核心属性] --> B[accessibilityText<br/>无障碍文本]
A --> C[accessibilityDescription<br/>无障碍描述]
A --> D[accessibilityLevel<br/>无障碍等级]
B --> B1["为组件提供朗读文本"]
B --> B2["图标/图片必须有"]
B --> B3["替代可见文本"]
C --> C1["描述组件的用途"]
C --> C2["比accessibilityText更详细"]
C --> C3["复杂操作需要"]
D --> D1["auto: 自动判断"]
D --> D2["yes: 可访问"]
D --> D3["no: 忽略此节点"]
D --> D4["no-hide-descendants: 忽略子树"]
classDef root fill:#4A90D9,stroke:#2C5F8A,color:#fff
classDef text fill:#67C23A,stroke:#3E9B2B,color:#fff
classDef desc fill:#E6A23C,stroke:#B07D2B,color:#fff
classDef level fill:#F56C6C,stroke:#C94A4A,color:#fff
class A root
class B,B1,B2,B3 text
class C,C1,C2,C3 desc
class D,D1,D2,D3,D4 level
无障碍测试的检查项
flowchart TD
A[无障碍测试] --> B[节点完整性]
A --> C[标签覆盖率]
A --> D[朗读顺序]
A --> E[操作可达性]
A --> F[对比度]
B --> B1["所有交互组件有节点"]
B --> B2["装饰性元素标记为no"]
B --> B3["容器节点正确嵌套"]
C --> C1["图标有accessibilityText"]
C --> C2["图片有描述"]
C --> C3["自定义组件有标签"]
D --> D1["朗读顺序符合逻辑"]
D --> D2["从左到右、从上到下"]
D --> D3["不跳过重要元素"]
E --> E1["所有功能可通过TalkBack操作"]
E --> E2["焦点可达所有交互元素"]
E --> E3["手势操作有替代方案"]
F --> F1["文字与背景对比度≥4.5:1"]
F --> F2["大文字对比度≥3:1"]
classDef root fill:#4A90D9,stroke:#2C5F8A,color:#fff
classDef node fill:#67C23A,stroke:#3E9B2B,color:#fff
classDef label fill:#E6A23C,stroke:#B07D2B,color:#fff
classDef order fill:#F56C6C,stroke:#C94A4A,color:#fff
classDef action fill:#9B59B6,stroke:#7D3C98,color:#fff
classDef contrast fill:#909399,stroke:#606266,color:#fff
class A root
class B,B1,B2,B3 node
class C,C1,C2,C3 label
class D,D1,D2,D3 order
class E,E1,E2,E3 action
class F,F1,F2 contrast
代码实战
基础用法:无障碍节点检查
// 468_accessibility_test_basic.ets
import { Driver, ON } from '@ohos.UiTest';
export default function basicAccessibilityTest() {
const driver = Driver.create();
// ===== 1. 检查组件的无障碍属性 =====
const loginBtn = driver.findComponent(ON.id('login_btn'));
if (loginBtn !== null) {
// 获取无障碍文本
const accText = loginBtn.getAccessibilityText();
console.info(`登录按钮无障碍文本: ${accText}`);
// 检查是否有无障碍描述
const accDesc = loginBtn.getAccessibilityDescription();
console.info(`登录按钮无障碍描述: ${accDesc}`);
// 检查无障碍等级
const accLevel = loginBtn.getAccessibilityLevel();
console.info(`登录按钮无障碍等级: ${accLevel}`);
// 断言:按钮必须有无障碍文本
if (!accText || accText.length === 0) {
console.error('❌ 登录按钮缺少accessibilityText');
} else {
console.info('✅ 登录按钮有accessibilityText');
}
}
// ===== 2. 检查图片的无障碍属性 =====
// 图片是最容易遗漏无障碍属性的组件
const productImage = driver.findComponent(ON.id('product_image'));
if (productImage !== null) {
const imgAccText = productImage.getAccessibilityText();
if (!imgAccText || imgAccText.length === 0) {
console.error('❌ 商品图片缺少accessibilityText,视障用户无法了解图片内容');
} else {
console.info(`✅ 商品图片有描述: ${imgAccText}`);
}
}
// ===== 3. 检查装饰性元素 =====
// 纯装饰性的元素应该设置accessibilityLevel为"no"
// 这样TalkBack会跳过它们,不会干扰朗读
const decorLine = driver.findComponent(ON.id('decorative_divider'));
if (decorLine !== null) {
const level = decorLine.getAccessibilityLevel();
if (level === 'no' || level === 'no-hide-descendants') {
console.info('✅ 装饰性分割线已标记为不可访问');
} else {
console.error('❌ 装饰性分割线应设置accessibilityLevel为"no"');
}
}
// ===== 4. 检查自定义组件 =====
// 自定义组件必须手动设置无障碍属性
const customRating = driver.findComponent(ON.id('custom_rating'));
if (customRating !== null) {
const ratingAccText = customRating.getAccessibilityText();
if (!ratingAccText || !ratingAccText.includes('星')) {
console.error('❌ 评分组件缺少无障碍描述(应包含"X星")');
} else {
console.info(`✅ 评分组件有无障碍描述: ${ratingAccText}`);
}
}
}
进阶用法:自动化无障碍审计
// 468_accessibility_test_advanced.ets
import { Driver, ON, Component } from '@ohos.UiTest';
// 无障碍审计工具
class AccessibilityAuditor {
private driver: Driver;
private issues: AccessibilityIssue[];
constructor(driver: Driver) {
this.driver = driver;
this.issues = [];
}
// 全面审计当前页面
auditCurrentPage(): AccessibilityReport {
this.issues = [];
// 获取页面上所有组件
const allComponents = this.getAllComponents();
for (const component of allComponents) {
this.checkComponent(component);
}
return this.generateReport();
}
// 获取所有组件(递归遍历组件树)
private getAllComponents(): Component[] {
const components: Component[] = [];
// 从根节点开始遍历
const root = this.driver.findComponent(ON.type('Root'));
if (root !== null) {
this.traverseComponentTree(root, components);
}
return components;
}
// 递归遍历
private traverseComponentTree(component: Component, result: Component[]): void {
result.push(component);
const children = component.findComponents(ON.type('*'));
for (const child of children) {
this.traverseComponentTree(child, result);
}
}
// 检查单个组件
private checkComponent(component: Component): void {
const type = component.getType();
const id = component.getId();
const accText = component.getAccessibilityText();
const accLevel = component.getAccessibilityLevel();
// 规则1:交互组件必须有accessibilityText
const interactiveTypes = ['Button', 'TextInput', 'Toggle', 'Checkbox', 'Radio', 'Slider', 'Switch'];
if (interactiveTypes.includes(type)) {
if (!accText || accText.length === 0) {
// 如果组件有可见文本,TalkBack可以自动朗读,不算问题
const visibleText = component.getText();
if (!visibleText || visibleText.length === 0) {
this.addIssue('ERROR', type, id, '交互组件缺少accessibilityText,TalkBack无法朗读');
}
}
}
// 规则2:图片必须有accessibilityText
if (type === 'Image') {
if (accLevel !== 'no') {
if (!accText || accText.length === 0) {
this.addIssue('ERROR', type, id, '图片缺少accessibilityText,视障用户无法了解图片内容');
}
}
}
// 规则3:装饰性元素应标记为no
// 这个规则需要人工判断,这里只做提示
if (type === 'Divider' || (id && id.includes('decor'))) {
if (accLevel !== 'no' && accLevel !== 'no-hide-descendants') {
this.addIssue('WARNING', type, id, '装饰性元素建议设置accessibilityLevel为"no"');
}
}
// 规则4:容器组件应设置no-hide-descendants(如果子元素不需要单独朗读)
const containerTypes = ['Row', 'Column', 'Stack', 'Flex'];
if (containerTypes.includes(type)) {
// 检查是否是纯布局容器
const children = component.findComponents(ON.type('*'));
if (children.length > 0 && !accText) {
// 纯布局容器没有交互内容,建议隐藏
// 这个规则比较宽松,只做提示
}
}
// 规则5:accessibilityText不应与可见文本重复
if (accText && accText.length > 0) {
const visibleText = component.getText();
if (visibleText && visibleText.length > 0 && accText === visibleText) {
this.addIssue('INFO', type, id, 'accessibilityText与可见文本相同,可省略accessibilityText');
}
}
}
// 添加问题
private addIssue(
severity: 'ERROR' | 'WARNING' | 'INFO',
componentType: string,
componentId: string,
message: string
): void {
this.issues.push({
severity,
componentType,
componentId: componentId || '(无id)',
message,
});
}
// 生成报告
private generateReport(): AccessibilityReport {
const errorCount = this.issues.filter(i => i.severity === 'ERROR').length;
const warningCount = this.issues.filter(i => i.severity === 'WARNING').length;
const infoCount = this.issues.filter(i => i.severity === 'INFO').length;
return {
totalIssues: this.issues.length,
errorCount,
warningCount,
infoCount,
issues: this.issues,
passed: errorCount === 0,
};
}
// 打印报告
printReport(report: AccessibilityReport): void {
console.info('\n♿ ===== 无障碍审计报告 =====');
console.info(`错误: ${report.errorCount} | 警告: ${report.warningCount} | 提示: ${report.infoCount}`);
console.info('─'.repeat(60));
// 按严重程度排序输出
const sorted = [...report.issues].sort((a, b) => {
const order = { ERROR: 0, WARNING: 1, INFO: 2 };
return order[a.severity] - order[b.severity];
});
for (const issue of sorted) {
const icon = issue.severity === 'ERROR' ? '❌' : issue.severity === 'WARNING' ? '⚠️' : 'ℹ️';
console.info(`${icon} [${issue.componentType}] ${issue.componentId}: ${issue.message}`);
}
console.info('─'.repeat(60));
console.info(`审计结果: ${report.passed ? '✅ 通过' : '❌ 未通过'}`);
}
}
interface AccessibilityIssue {
severity: 'ERROR' | 'WARNING' | 'INFO';
componentType: string;
componentId: string;
message: string;
}
interface AccessibilityReport {
totalIssues: number;
errorCount: number;
warningCount: number;
infoCount: number;
issues: AccessibilityIssue[];
passed: boolean;
}
完整示例:TalkBack兼容性验证
// 468_accessibility_test_full.ets
import { Driver, ON } from '@ohos.UiTest';
// TalkBack兼容性测试套件
class TalkBackTestSuite {
private driver: Driver;
private auditor: AccessibilityAuditor;
constructor() {
this.driver = Driver.create();
this.auditor = new AccessibilityAuditor(this.driver);
}
// 测试1:无障碍节点完整性
testNodeCompleteness(): AccessibilityReport {
console.info('\n♿ 测试: 无障碍节点完整性');
const report = this.auditor.auditCurrentPage();
this.auditor.printReport(report);
return report;
}
// 测试2:朗读顺序验证
testReadingOrder(): void {
console.info('\n♿ 测试: 朗读顺序');
// 获取页面上所有可访问组件
const accessibleComponents = this.getAccessibleComponents();
// 按位置排序(从左到右、从上到下)
const sorted = accessibleComponents.sort((a, b) => {
const boundsA = a.component.getBounds();
const boundsB = b.component.getBounds();
// 先按Y坐标排序,Y相同再按X排序
if (Math.abs(boundsA.top - boundsB.top) > 10) {
return boundsA.top - boundsB.top;
}
return boundsA.left - boundsB.left;
});
console.info('朗读顺序:');
for (let i = 0; i < sorted.length; i++) {
const accText = sorted[i].component.getAccessibilityText() || sorted[i].component.getText() || '(无文本)';
console.info(` ${i + 1}. ${accText}`);
}
// 检查朗读顺序是否合理
// 基本规则:标题在前,内容在后,操作按钮最后
const firstAccText = sorted[0]?.component.getAccessibilityText() || sorted[0]?.component.getText();
if (firstAccText && !this.isLikelyTitle(firstAccText)) {
console.warn('⚠️ 页面第一个朗读元素可能不是标题');
}
}
// 测试3:焦点导航验证
testFocusNavigation(): void {
console.info('\n♿ 测试: 焦点导航');
// 模拟TalkBack的焦点导航
const focusableComponents = this.getFocusableComponents();
console.info(`可聚焦组件数量: ${focusableComponents.length}`);
// 检查是否有"焦点陷阱"——焦点进入某个区域后无法退出
// 这需要模拟焦点移动,简化实现
for (const item of focusableComponents) {
const bounds = item.component.getBounds();
const accText = item.component.getAccessibilityText() || item.component.getText();
// 检查组件是否在屏幕内
const displayInfo = display.getDefaultDisplaySync();
if (bounds.right > displayInfo.width || bounds.bottom > displayInfo.height) {
console.error(`❌ 焦点可达屏幕外组件: ${accText}`);
}
}
}
// 测试4:标签覆盖率统计
testLabelCoverage(): void {
console.info('\n♿ 测试: 标签覆盖率');
const allComponents = this.getAllInteractiveComponents();
let labeledCount = 0;
let unlabeledCount = 0;
const unlabeledList: string[] = [];
for (const item of allComponents) {
const accText = item.component.getAccessibilityText();
const visibleText = item.component.getText();
if ((accText && accText.length > 0) || (visibleText && visibleText.length > 0)) {
labeledCount++;
} else {
unlabeledCount++;
unlabeledList.push(`${item.type} (id: ${item.id || '无'})`);
}
}
const coverage = allComponents.length > 0
? (labeledCount / allComponents.length * 100).toFixed(1)
: '0';
console.info(`标签覆盖率: ${coverage}% (${labeledCount}/${allComponents.length})`);
if (unlabeledCount > 0) {
console.error(`❌ ${unlabeledCount}个组件缺少标签:`);
unlabeledList.forEach(item => console.error(` - ${item}`));
} else {
console.info('✅ 所有交互组件都有标签');
}
}
// 测试5:表单无障碍验证
testFormAccessibility(): void {
console.info('\n♿ 测试: 表单无障碍');
// 检查输入框是否有关联标签
const inputs = this.driver.findComponents(ON.type('TextInput'));
for (const input of inputs) {
const accText = input.getAccessibilityText();
const placeholder = input.getText(); // 简化:用getText模拟placeholder
if (!accText && !placeholder) {
console.error('❌ 输入框缺少标签或placeholder,用户不知道该输入什么');
} else {
console.info(`✅ 输入框有标签: ${accText || placeholder}`);
}
}
// 检查错误提示是否可被TalkBack朗读
const errorHints = this.driver.findComponents(ON.id('error_hint'));
for (const hint of errorHints) {
const hintAccLevel = hint.getAccessibilityLevel();
if (hintAccLevel === 'no') {
console.error('❌ 错误提示被标记为不可访问,TalkBack无法朗读');
}
}
}
// 运行所有测试
async runAll(): Promise<void> {
console.info('♿ ===== 开始无障碍测试 =====');
const nodeReport = this.testNodeCompleteness();
this.testReadingOrder();
this.testFocusNavigation();
this.testLabelCoverage();
this.testFormAccessibility();
console.info('\n♿ ===== 无障碍测试完成 =====');
console.info(`最终结果: ${nodeReport.passed ? '✅ 通过' : '❌ 未通过'}`);
}
// 辅助方法
private getAccessibleComponents(): Array<{ component: Component; order: number }> {
// 获取所有无障碍等级不为no的组件
const result: Array<{ component: Component; order: number }> = [];
// 简化实现
return result;
}
private getFocusableComponents(): Array<{ component: Component }> {
const result: Array<{ component: Component }> = [];
// 获取所有可聚焦的组件
return result;
}
private getAllInteractiveComponents(): Array<{ component: Component; type: string; id: string }> {
const result: Array<{ component: Component; type: string; id: string }> = [];
const interactiveTypes = ['Button', 'TextInput', 'Toggle', 'Checkbox', 'Radio', 'Slider', 'Switch', 'Image'];
for (const type of interactiveTypes) {
const components = this.driver.findComponents(ON.type(type));
for (const comp of components) {
result.push({ component: comp, type, id: comp.getId() || '' });
}
}
return result;
}
private isLikelyTitle(text: string): boolean {
// 简化判断:标题通常较短,且不以动词开头
if (text.length > 20) return false;
const verbs = ['点击', '提交', '删除', '添加', '搜索', '登录'];
return !verbs.some(v => text.startsWith(v));
}
}
// 执行测试
export default async function runAccessibilityTest() {
const suite = new TalkBackTestSuite();
await suite.runAll();
}
踩坑与注意事项
坑1:accessibilityText和可见文本重复
如果按钮上已经显示了"登录"文字,你再设accessibilityText('登录'),TalkBack会读两遍"登录"。
规则:如果组件有可见文本,TalkBack会自动朗读,不需要再设accessibilityText。只有当组件没有可见文本(比如纯图标按钮)时,才需要设accessibilityText。
// ❌ 重复——TalkBack读两遍
Button('登录').accessibilityText('登录')
// ✅ 有可见文本,不需要accessibilityText
Button('登录')
// ✅ 纯图标按钮,需要accessibilityText
Image($r('app.media.search_icon')).accessibilityText('搜索')
坑2:忽略装饰性元素
分割线、背景图、装饰性圆角——这些元素对视障用户没有意义,TalkBack不应该朗读它们。如果你不处理,用户在导航时会被这些无意义的元素干扰。
解决方案:设置accessibilityLevel('no')或accessibilityLevel('no-hide-descendants')。
坑3:动态内容的无障碍更新
列表数据加载后,TalkBack不知道内容变了。你需要手动通知无障碍框架更新。
// 数据更新后通知无障碍框架
this.listData = newData;
// 触发无障碍事件
this.listComponent.accessibilityAnnouncement('列表已更新,共' + newData.length + '项');
坑4:自定义组件的无障碍
自定义组件默认不会被TalkBack识别为一个整体。比如你做了一个星级评分组件,内部是5个星星图片,TalkBack会逐个朗读"星星、星星、星星、星星、星星"——而不是"4星评分"。
解决方案:给容器设置accessibilityText,并设置accessibilityLevel('yes'),子元素设置accessibilityLevel('no-hide-descendants')。
坑5:颜色对比度
WCAG 2.1要求普通文字与背景的对比度至少4.5:1,大文字至少3:1。浅灰文字在白色背景上很可能不达标。
自动化检查对比度比较复杂,建议使用专门的对比度检测工具。
HarmonyOS 6适配说明
HarmonyOS 6对无障碍测试做了以下增强:
- 内置无障碍审计API:
driver.auditAccessibility()一键扫描当前页面的无障碍问题 - TalkBack模拟:测试框架可以模拟TalkBack的焦点导航,验证朗读顺序
- 对比度检测:新增颜色对比度自动检测,输出WCAG合规报告
- 无障碍事件验证:可以验证组件是否正确触发了无障碍事件(如announcement、selection等)
- 多语言无障碍标签:支持检查不同语言环境下的无障碍标签是否完整
迁移注意:getAccessibilityText()方法在API 13中返回值从string改为string | undefined,旧代码如果依赖空字符串判断需要适配。
总结
无障碍测试不是"锦上添花",是"基本责任"。你的App对视障用户不可用,就等于把一部分用户拒之门外。
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐ 检查逻辑简单,理解无障碍规范需要学习 |
| 使用频率 | ⭐⭐⭐ 越来越重要,未来可能成为上架要求 |
| 重要程度 | ⭐⭐⭐⭐⭐ 关乎用户平等使用权 |
核心建议:从项目一开始就设好无障碍属性,别等最后补。开发时多花1分钟设个accessibilityText,比测试时花1小时修无障碍问题划算得多。把无障碍审计集成到CI流水线,每次提交自动检查,问题零容忍。
- 点赞
- 收藏
- 关注作者
评论(0)