HarmonyOS开发:无障碍测试——Accessibility测试

举报
Jack20 发表于 2026/06/24 15:49:42 2026/06/24
【摘要】 HarmonyOS开发:无障碍测试——Accessibility测试📌 核心要点:无障碍测试验证App是否对所有用户可用,核心检查无障碍节点完整性、TalkBack兼容性和标签覆盖率,自动化审计+手动验证双管齐下。 背景与动机你闭上眼睛,能用自己的App吗?这不是开玩笑。中国有超过1700万视障人士,他们靠屏幕朗读(TalkBack)来使用手机。你的App如果没有正确设置无障碍属性,这些...

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对无障碍测试做了以下增强:

  1. 内置无障碍审计APIdriver.auditAccessibility()一键扫描当前页面的无障碍问题
  2. TalkBack模拟:测试框架可以模拟TalkBack的焦点导航,验证朗读顺序
  3. 对比度检测:新增颜色对比度自动检测,输出WCAG合规报告
  4. 无障碍事件验证:可以验证组件是否正确触发了无障碍事件(如announcement、selection等)
  5. 多语言无障碍标签:支持检查不同语言环境下的无障碍标签是否完整

迁移注意:getAccessibilityText()方法在API 13中返回值从string改为string | undefined,旧代码如果依赖空字符串判断需要适配。

总结

无障碍测试不是"锦上添花",是"基本责任"。你的App对视障用户不可用,就等于把一部分用户拒之门外。

维度 评价
学习难度 ⭐⭐ 检查逻辑简单,理解无障碍规范需要学习
使用频率 ⭐⭐⭐ 越来越重要,未来可能成为上架要求
重要程度 ⭐⭐⭐⭐⭐ 关乎用户平等使用权

核心建议:从项目一开始就设好无障碍属性,别等最后补。开发时多花1分钟设个accessibilityText,比测试时花1小时修无障碍问题划算得多。把无障碍审计集成到CI流水线,每次提交自动检查,问题零容忍。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。