在这里插入图片描述

📖 引言

想象一下这个场景:

一位视障用户打开「民族图鉴」,手指轻轻划过屏幕,手机的屏幕朗读器用清晰的声音播报道:“苗族——人口约1100万——主要分布在贵州、湖南、云南、广西等省区”。他不需要看,就能"听"到五十六个民族的故事。

又或者:

一位七十多岁的老人打开「民族图鉴」,关怀模式自动开启,字体变得又大又清晰,按钮隔得足够远不会误触,高对比度的配色让文字在任何光线下都清晰可辨。他戴上老花镜,就能轻松浏览各民族的图片和介绍。

这就是无障碍与适老化的价值——让每个人,无论年龄、无论能力,都能平等地享受科技带来的便利

你可能会问:

  • 什么是无障碍服务?鸿蒙的 @kit.AccessibilityKit 能做什么?
  • 屏幕朗读器是怎么工作的?我们的应用如何适配它?
  • 如何为组件添加语义化描述,让视障用户"听得懂"?
  • 无障碍焦点是什么?怎么让它正确遍历界面元素?
  • 关怀模式是什么?API 26 Beta 新增了哪些适老化能力?
  • 如何实现"听民族"功能——让视障用户用语音探索民族文化?
  • 大字体、高对比度、大间距——这些适老化适配怎么做?

这些问题非常关键。据中国残联统计,我国残疾人总数超过 8500 万,其中视力障碍人群约 1700 万。与此同时,国家统计局数据显示,我国 60 岁以上人口已超过 2.9 亿,占总人口的 20% 以上。无障碍与适老化,不是"可选的加分项",而是应用应有的社会责任和技术底线

鸿蒙7 在无障碍与适老化方面做了大量升级:@kit.AccessibilityKit 提供了完整的无障碍服务框架,支持屏幕朗读、焦点控制、主动播报等能力;API 26 Beta 新增的关怀模式,让应用可以快速适配老年群体的使用习惯。

本文将带你深入理解无障碍服务的核心原理,系统学习 @kit.AccessibilityKit 的使用方法,掌握关怀模式的适配策略,并以「民族图鉴」项目为例,完成从"可看"到"可听"、从"通用"到"关怀"的全面升级。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 深入理解鸿蒙无障碍服务框架的架构与原理
  • ✅ 掌握 @kit.AccessibilityKit 的核心 API 使用
  • ✅ 学会为组件添加无障碍属性(accessibilityText、accessibilityLevel、accessibilityGroup)
  • ✅ 理解无障碍焦点的遍历机制,正确配置焦点顺序
  • ✅ 掌握屏幕朗读器适配技巧,为视障用户提供流畅的听觉体验
  • ✅ 实现主动语音播报功能,构建"听民族"交互模式
  • ✅ 理解 API 26 Beta 关怀模式的设计理念与技术细节
  • ✅ 学会关怀模式下的 UI 适配——大字体、高对比度、大间距
  • ✅ 掌握图片的语义化 alt 文本编写规范
  • ✅ 能够独立完成「民族图鉴」的无障碍与适老化全面适配

💡 需求分析

无障碍与适老化的背景与意义

无障碍设计的核心理念

无障碍设计(Accessibility,简称 A11y)的核心理念是:让产品和服务能够被尽可能多的人使用,无论他们是否有残疾或特殊需求

这不是一个技术问题,而是一个设计哲学问题。在移动应用开发中,无障碍设计主要关注以下几个方面:

关注维度 目标用户 核心需求
视觉无障碍 视障用户、低视力用户 屏幕朗读、高对比度、大字体
听觉无障碍 听障用户 字幕、视觉反馈、震动提示
运动无障碍 肢体障碍用户 大触控区域、语音控制、简化交互
认知无障碍 认知障碍用户、老年人 简洁界面、清晰导航、减少记忆负担

对于「民族图鉴」这类以图文内容为主的应用,视觉无障碍适老化是最核心的两个方向。

适老化——不只是"放大字体"

很多人以为适老化就是"把字体放大"。这是一个非常片面的理解。

真正的适老化是一个系统工程,需要考虑:

  1. 视觉层面:字体大小、对比度、间距、图标大小
  2. 交互层面:触控区域、操作复杂度、手势简化
  3. 认知层面:信息架构清晰、操作反馈明确、减少学习成本
  4. 情感层面:尊重用户、不贴标签、不制造"被特殊对待"的感觉

💡 有一个重要的设计原则叫"包容性设计"(Inclusive Design):好的无障碍设计,不仅帮助残障人士,也让所有用户受益。比如,大字体在阳光下也看得清,语音播报在开车时也可以"听"内容。

鸿蒙7 的无障碍与适老化能力全景

鸿蒙7 在无障碍与适老化方面提供了两个核心框架:

1. @kit.AccessibilityKit —— 无障碍服务框架

这是鸿蒙系统级的无障碍能力集合,主要包含:

  • 屏幕朗读器(Screen Reader):自动朗读界面上的文字内容
  • 无障碍焦点管理:控制焦点遍历顺序、焦点状态
  • 主动播报:应用主动触发语音播报(不依赖焦点切换)
  • 无障碍事件:监听和处理无障碍相关事件
  • 辅助功能配置:获取系统无障碍设置状态

2. 关怀模式(API 26 Beta)—— 适老化专项能力

这是鸿蒙 API 26 Beta 新增的适老化能力,主要包含:

  • 关怀模式开关检测:查询系统是否开启了关怀模式
  • 关怀模式配置适配:根据关怀模式状态调整 UI 参数
  • 系统级适老化设置:字体缩放、对比度增强、触控区域放大等

这两个框架相互配合,形成了鸿蒙完整的无障碍与适老化技术体系。


「民族图鉴」无障碍需求分析

场景1:视障用户浏览民族信息

用户画像:全盲或严重低视力用户,完全依赖屏幕朗读器使用手机。

核心需求

  • 进入民族列表页时,屏幕朗读器能正确播报每个民族的名称
  • 点击进入民族详情页后,自动朗读民族名称、人口、分布等关键信息
  • 图片区域能播报"苗族服饰图片"等语义化描述,而不是"图片"两个字
  • 朗读顺序合理,先读标题再读内容,符合信息层次
  • 提供"听民族"功能——一键触发对该民族完整介绍的语音朗读

技术挑战

  • 如何为每个组件配置正确的无障碍文本?
  • 如何处理自定义组件的无障碍焦点?
  • 列表滚动时,焦点如何正确跟随?
  • 图片如何提供有意义的描述?
场景2:老年用户使用关怀模式

用户画像:60 岁以上老年人,视力下降,手指灵活性降低,对科技产品不熟悉。

核心需求

  • 字体足够大,不需要戴老花镜也能看清
  • 按钮足够大,间距足够宽,不会误触
  • 颜色对比度足够高,在不同光线下都清晰
  • 界面简洁,操作步骤少,不需要复杂手势
  • 有明确的操作反馈,知道每一步发生了什么

技术挑战

  • 如何检测系统是否开启了关怀模式?
  • 关怀模式下,字体、间距、按钮大小如何动态调整?
  • 如何保证关怀模式下的界面布局不崩溃?
  • 如何处理好关怀模式与普通模式的切换?
场景3:图片的文化描述

用户画像:所有视障用户,以及使用屏幕朗读器的用户。

核心需求

  • 民族服饰图片要有详细的文字描述,不能只写"图片"
  • 描述要包含文化特征:颜色、款式、纹样、配饰等
  • 描述要简洁但不失重点,让用户"听"完就能在脑海中形成画面

「民族图鉴」图片描述示例

苗族服饰图片:苗族女性身着盛装,头戴银角冠,银冠上镶嵌着精美的蝴蝶纹样和凤凰图案。
颈戴多层银项圈,胸前佩戴银锁。上衣为交领右衽,袖口刺绣彩色花鸟纹样。
下穿百褶裙,裙摆绣有几何纹样,色彩以红、蓝、黑为主。

💡 好的图片描述就像"用文字画一幅画",让听者能在脑海中构建出画面。

适配优先级矩阵

根据用户需求紧迫性和技术实现难度,我们为「民族图鉴」的无障碍与适老化适配制定了优先级:

优先级 适配内容 影响用户 实现难度 预期收益
P0(最高) 组件无障碍属性配置 视障用户 极高
P0(最高) 关怀模式字体/间距适配 老年用户 极高
P0(最高) 图片 alt 文本 视障用户
P1(高) 无障碍焦点顺序优化 视障用户
P1(高) "听民族"语音播报 视障用户
P1(高) 关怀模式高对比度 老年用户
P2(中) 主动播报关键信息 视障用户
P2(中) 关怀模式简化交互 老年用户

无障碍服务框架原理

屏幕朗读器的工作流程

在深入代码之前,先理解屏幕朗读器是如何工作的,这有助于我们做出正确的适配决策。

屏幕朗读器的核心工作流程:

用户触摸屏幕
    ↓
系统捕获触摸事件
    ↓
确定触摸位置的 UI 组件
    ↓
读取组件的无障碍属性(accessibilityText)
    ↓
如果组件没有无障碍文本 → 尝试读取组件文本内容
    ↓
如果都没有 → 播报组件类型(如"按钮")
    ↓
通过 TTS(文本转语音)引擎朗读
    ↓
用户听到语音播报

由此可见,无障碍文本(accessibilityText) 是屏幕朗读器最核心的数据来源。如果开发者没有配置,系统会尝试自动推断,但推断结果往往不理想。

无障碍焦点树

鸿蒙的无障碍服务会为每个页面构建一棵无障碍焦点树(Accessibility Focus Tree),类似于 Android 的 AccessibilityNodeInfo 树。

页面根节点
├── 导航栏(accessibilityGroup)
│   ├── 返回按钮(accessibilityText: "返回")
│   └── 标题文字(accessibilityText: "苗族详情")
├── 内容区域
│   ├── 民族图片(accessibilityText: "苗族服饰图片...")
│   ├── 民族名称(accessibilityText: "苗族")
│   ├── 人口信息(accessibilityText: "人口约1100万")
│   ├── 分布区域(accessibilityText: "主要分布在贵州、湖南...")
│   └── 详细介绍(accessibilityText: "苗族是一个历史悠久的...")
└── 底部操作栏
    ├── 收藏按钮(accessibilityText: "收藏苗族")
    ├── 分享按钮(accessibilityText: "分享苗族")
    └── 听民族按钮(accessibilityText: "语音朗读苗族介绍")

焦点遍历规则:

  • 默认按照视觉布局从上到下、从左到右的顺序遍历
  • 可以通过 accessibilityGroup 将多个子组件合并为一个焦点
  • 可以通过 accessibilityLevel 控制组件是否参与焦点遍历
主动播报 vs 焦点播报

屏幕朗读器有两种播报模式:

模式 触发方式 适用场景
焦点播报 用户触摸/滑动到某个组件 浏览界面、探索内容
主动播报 应用主动调用 API 触发 重要通知、动态内容变化、连续朗读

「民族图鉴」中,两种模式都需要使用:

  • 焦点播报:用户浏览民族列表、详情页时
  • 主动播报:用户点击"听民族"按钮后,连续朗读完整介绍

🛠️ 核心实现

步骤1:无障碍属性配置——让组件"会说话"

1.1 三个核心无障碍属性

鸿蒙为组件提供了三个核心的无障碍属性,它们共同决定了屏幕朗读器如何理解和播报这个组件。

属性 类型 说明 默认值
accessibilityText string 屏幕朗读器播报的文本内容 自动推断(组件文本或类型)
accessibilityLevel AccessibilityLevel 组件在无障碍焦点树中的级别 AccessibilityLevel.AUTO
accessibilityGroup boolean 是否将子组件合并为一个焦点组 false

accessibilityLevel 的取值说明

说明
AccessibilityLevel.AUTO 系统自动决定(默认值)
AccessibilityLevel.YES 强制参与无障碍焦点遍历
AccessibilityLevel.NO 不参与无障碍焦点遍历(装饰性元素)
AccessibilityLevel.NO_HIDE_DESCENDANTS 不参与,且隐藏所有子节点
1.2 基础无障碍配置示例

让我们从最简单的例子开始——为「民族图鉴」的组件添加无障碍属性。

示例:民族列表页的无障碍适配

/*
 * 文件用途:民族列表页——无障碍适配示例
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:无障碍属性需配合屏幕朗读器测试,开发者工具无法完全模拟
 */

import { accessibility } from '@kit.AccessibilityKit';

// 民族数据接口定义
interface EthnicGroup {
    id: string;
    name: string;
    population: string;
    region: string;
    imageDescription: string;
    thumbnail: Resource;
}

@Entry
@Component
struct EthnicListPage {
    @State ethnicList: EthnicGroup[] = [];

    aboutToAppear(): void {
        // 加载民族列表数据
        this.loadEthnicList();
    }

    private loadEthnicList(): void {
        // 模拟数据加载
        this.ethnicList = [
            {
                id: 'miao',
                name: '苗族',
                population: '约1100万',
                region: '贵州、湖南、云南、广西',
                imageDescription: '苗族女性身着盛装,头戴银角冠',
                thumbnail: $r('app.media.miao_thumb')
            },
            {
                id: 'zhuang',
                name: '壮族',
                population: '约1900万',
                region: '广西、云南、广东',
                imageDescription: '壮族女性身着蓝色对襟上衣,头戴绣花头巾',
                thumbnail: $r('app.media.zhuang_thumb')
            }
        ];
    }

    build() {
        Column() {
            // 页面标题——无障碍文本清晰标注
            Text('民族图鉴')
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .accessibilityText('民族图鉴,共五十六个民族')
                .accessibilityLevel('yes')

            List() {
                ForEach(this.ethnicList, (item: EthnicGroup, index: number) => {
                    ListItem() {
                        Row({ space: 12 }) {
                            // 民族缩略图——提供有意义的描述
                            Image(item.thumbnail)
                                .width(60)
                                .height(60)
                                .borderRadius(8)
                                .accessibilityText(item.imageDescription)
                                // 图片的无障碍文本使用详细的描述,而不是"图片"

                            Column({ space: 4 }) {
                                // 民族名称
                                Text(item.name)
                                    .fontSize(18)
                                    .fontWeight(FontWeight.Medium)
                                    .accessibilityText(`${item.name}`)

                                // 人口信息
                                Text(`人口:${item.population}`)
                                    .fontSize(14)
                                    .fontColor('#666666')
                                    .accessibilityText(`${item.name}人口${item.population}`)

                                // 分布区域
                                Text(`分布:${item.region}`)
                                    .fontSize(14)
                                    .fontColor('#666666')
                                    .accessibilityText(`${item.name}主要分布在${item.region}`)
                            }
                            .alignItems(HorizontalAlign.Start)
                            .layoutWeight(1)

                            // 箭头图标——装饰性元素,不需要无障碍焦点
                            Image($r('app.media.ic_arrow_right'))
                                .width(20)
                                .height(20)
                                .accessibilityLevel('no')
                                // 装饰性图标禁用无障碍焦点
                        }
                        .width('100%')
                        .padding(12)
                        .accessibilityGroup(true)
                        // 将整行合并为一个焦点组,避免分开朗读
                        .accessibilityText(
                            `${item.name},人口${item.population},主要分布在${item.region}`
                        )
                        // 焦点组的无障碍文本——整合了所有关键信息
                    }
                    .onClick(() => {
                        // 点击进入详情页
                        this.navigateToDetail(item.id);
                    })
                }, (item: EthnicGroup) => item.id)
            }
            .width('100%')
            .layoutWeight(1)
        }
        .width('100%')
        .height('100%')
    }

    private navigateToDetail(ethnicId: string): void {
        // 路由跳转到详情页
        console.log(`导航到民族详情页: ${ethnicId}`);
    }
}

代码解读

  1. 图片的无障碍文本:使用 item.imageDescription 而不是简单的"图片"或"缩略图",让视障用户也能了解图片内容。
  2. 装饰性图标:右侧箭头图标使用 accessibilityLevel('no'),因为它只是视觉装饰,不需要被朗读。
  3. 焦点组:整行列表项使用 accessibilityGroup(true) 合并为一个焦点,让用户一次触摸就能听到完整信息,而不是要分别触摸图片、名称、人口、分布。
  4. 焦点组的无障碍文本:整合了所有关键信息,格式为"苗族,人口约1100万,主要分布在贵州、湖南、云南、广西"。
1.3 无障碍文本的编写规范

无障碍文本不是随便写写就行的。好的无障碍文本和差的无障碍文本,用户体验天差地别。

编写规范

规范 说明 好例子 坏例子
简洁完整 一句话说清楚,不啰嗦不遗漏 “苗族,人口约1100万” “苗族”(缺信息)或"这是一个关于苗族的列表项,苗族的人口大约有1100万…"(啰嗦)
语义明确 让用户听完就知道是什么 “收藏苗族” “按钮”(什么按钮?)
状态描述 包含当前状态 “收藏苗族,已收藏” “收藏”(不知道当前状态)
图片描述 描述内容和特征 “苗族银饰,银角冠镶嵌蝴蝶纹样” “图片”(毫无意义)
避免类型词 系统会自动播报类型 “删除” “删除按钮”(系统会多读"按钮")
操作提示 提示用户可执行的操作 “苗族详情,双击进入” “苗族”(不知道可以点击)

💡 核心原则:无障碍文本应该让用户"听完就知道这是什么、能做什么、当前状态如何",就像明眼人看一眼界面就能理解的一样。


步骤2:无障碍焦点管理——让遍历顺序合情合理

2.1 为什么需要管理焦点顺序?

默认的无障碍焦点遍历顺序是按照组件的视觉布局从上到下、从左到右排列的。但在实际开发中,这个默认顺序不一定合理。

例如,在「民族图鉴」的详情页中:

默认焦点顺序(可能不合理):
1. 返回按钮
2. 民族图片
3. 收藏按钮 ← 提前了!
4. 民族名称
5. 人口信息
6. 分布区域

合理焦点顺序:
1. 返回按钮
2. 民族图片
3. 民族名称 ← 应该先读标题
4. 人口信息
5. 分布区域
6. 收藏按钮 ← 操作按钮放最后
2.2 使用 accessibilityGroup 优化焦点层级

当多个子组件组成一个逻辑单元时,应该使用 accessibilityGroup 将它们合并为一个焦点。

/*
 * 文件用途:民族详情页——无障碍焦点管理
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:焦点组设置后,子组件的无障碍属性将被忽略,统一使用焦点组的 accessibilityText
 */

@Entry
@Component
struct EthnicDetailPage {
    @State ethnicName: string = '苗族';
    @State population: string = '约1100万';
    @State region: string = '贵州、湖南、云南、广西、四川、重庆、海南、湖北';
    @State isFavorite: boolean = false;
    @State imageDescription: string = '苗族女性身着盛装,头戴银角冠,银冠上镶嵌着精美的蝴蝶纹样和凤凰图案。颈戴多层银项圈,胸前佩戴银锁。上衣为交领右衽,袖口刺绣彩色花鸟纹样。下穿百褶裙,裙摆绣有几何纹样,色彩以红、蓝、黑为主。';

    build() {
        Column() {
            // 顶部导航栏
            Row() {
                // 返回按钮——无障碍文本清晰
                Button() {
                    Image($r('app.media.ic_back'))
                        .width(24)
                        .height(24)
                }
                .width(44)
                .height(44)
                .backgroundColor(Color.Transparent)
                .accessibilityText('返回民族列表')

                // 页面标题
                Text(`${this.ethnicName}详情`)
                    .fontSize(20)
                    .fontWeight(FontWeight.Bold)
                    .layoutWeight(1)
                    .textAlign(TextAlign.Center)
                    .accessibilityText(`${this.ethnicName}详情页`)

                // 占位——保持布局对称
                Row().width(44).height(44)
            }
            .width('100%')
            .padding({ left: 12, right: 12 })
            .accessibilityGroup(true)
            .accessibilityText(`当前页面:${this.ethnicName}详情`)

            Scroll() {
                Column({ space: 16 }) {
                    // 民族图片区域——详细的语义化描述
                    Image($r('app.media.miao_detail'))
                        .width('100%')
                        .height(240)
                        .objectFit(ImageFit.Cover)
                        .accessibilityText(this.imageDescription)
                        .accessibilityLevel('yes')

                    // 基本信息卡片——合并为一个焦点组
                    Column({ space: 8 }) {
                        // 民族名称
                        Text(this.ethnicName)
                            .fontSize(28)
                            .fontWeight(FontWeight.Bold)

                        // 人口信息
                        Row({ space: 8 }) {
                            Text('人口数量')
                                .fontSize(14)
                                .fontColor('#999999')
                            Text(this.population)
                                .fontSize(16)
                                .fontColor('#333333')
                        }
                        .accessibilityLevel('no')
                        // 子元素不单独参与焦点遍历

                        // 分布区域
                        Row({ space: 8 }) {
                            Text('主要分布')
                                .fontSize(14)
                                .fontColor('#999999')
                            Text(this.region)
                                .fontSize(16)
                                .fontColor('#333333')
                        }
                        .accessibilityLevel('no')
                        // 子元素不单独参与焦点遍历
                    }
                    .width('100%')
                    .padding(16)
                    .backgroundColor('#FFFFFF')
                    .borderRadius(12)
                    .accessibilityGroup(true)
                    // 将基本信息合并为一个焦点组
                    .accessibilityText(
                        `${this.ethnicName},人口${this.population},主要分布在${this.region}`
                    )

                    // 详细介绍文本
                    Text('苗族是一个历史悠久的民族,其先民可追溯到五千多年前的蚩尤部落...')
                        .fontSize(16)
                        .lineHeight(24)
                        .padding(16)
                        .accessibilityText('苗族详细介绍:苗族是一个历史悠久的民族...')
                }
                .padding(16)
            }
            .layoutWeight(1)

            // 底部操作栏
            Row({ space: 16 }) {
                // 收藏按钮——状态感知的无障碍文本
                Button() {
                    Row({ space: 6 }) {
                        Image(this.isFavorite
                            ? $r('app.media.ic_favorite_filled')
                            : $r('app.media.ic_favorite_outline'))
                            .width(20)
                            .height(20)
                        Text(this.isFavorite ? '已收藏' : '收藏')
                            .fontSize(16)
                    }
                }
                .width('45%')
                .height(48)
                .borderRadius(24)
                .backgroundColor(this.isFavorite ? '#FFF0F0' : '#F5F5F5')
                .accessibilityText(
                    this.isFavorite
                        ? `取消收藏${this.ethnicName}`
                        : `收藏${this.ethnicName}`
                )
                // 动态的无障碍文本,反映当前状态

                // 听民族按钮
                Button() {
                    Row({ space: 6 }) {
                        Image($r('app.media.ic_audio'))
                            .width(20)
                            .height(20)
                        Text('听民族')
                            .fontSize(16)
                    }
                }
                .width('45%')
                .height(48)
                .borderRadius(24)
                .backgroundColor('#E8F4FD')
                .accessibilityText(`语音朗读${this.ethnicName}的详细介绍,双击开始`)
                .onClick(() => {
                    this.startVoiceReading();
                })
            }
            .width('100%')
            .padding(16)
            .backgroundColor('#FFFFFF')
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#F5F5F5')
    }

    private startVoiceReading(): void {
        // 触发语音朗读——详见步骤3
        console.log('开始语音朗读');
    }
}

关键设计点

  1. 导航栏焦点组:将返回按钮、标题合并为一个焦点,让用户先了解"我在哪个页面"。
  2. 基本信息卡片焦点组:将名称、人口、分布合并为一个焦点,一次播报完整信息,而不是让用户分三次触摸。
  3. 子元素禁用焦点:人口和分布的 Row 设置 accessibilityLevel('no'),避免它们单独出现在焦点遍历中,造成重复播报。
  4. 状态感知文本:收藏按钮的无障碍文本根据状态动态变化,让用户知道当前是否已收藏。
  5. 操作提示:听民族按钮的无障碍文本包含"双击开始",引导用户正确操作。
2.3 处理动态内容的无障碍焦点

当页面内容动态变化时(比如列表数据加载完成),需要通知无障碍服务更新焦点树。

/*
 * 文件用途:动态内容无障碍焦点更新
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 */

// 在数据加载完成后,通知无障碍服务
private onDataLoaded(): void {
    // 数据加载完成后的处理
    this.ethnicList = [...this.loadedData];

    // 通知无障碍服务内容已更新
    // 这样屏幕朗读器会重新构建焦点树
    try {
        // accessibility.sendEvent({
        //     type: accessibility.EventType.CONTENT_CHANGED
        // });
        console.log('[Accessibility] 数据加载完成,通知焦点树更新');
    } catch (error) {
        console.error('[Accessibility] 焦点树更新通知失败:', JSON.stringify(error));
    }
}

// 在列表项删除后,将焦点移到合适的位置
private onItemDeleted(deletedIndex: number): void {
    // 删除数据
    this.ethnicList.splice(deletedIndex, 1);

    // 通知无障碍服务内容变化,焦点会自动调整
    try {
        // accessibility.sendEvent({
        //     type: accessibility.EventType.CONTENT_CHANGED
        // });
        console.log(`[Accessibility] 第${deletedIndex}项已删除,焦点已更新`);
    } catch (error) {
        console.error('[Accessibility] 焦点更新失败:', JSON.stringify(error));
    }
}

步骤3:“听民族”——主动语音播报实现

3.1 主动播报的设计思路

"听民族"是「民族图鉴」为视障用户设计的核心功能。它的交互流程是:

用户点击"听民族"按钮
    ↓
应用检测屏幕朗读器是否开启
    ↓
如果未开启 → 提示用户"请先开启屏幕朗读器"
    ↓
如果已开启 → 开始朗读
    ↓
朗读顺序:
  1. 民族名称 + "欢迎语"
  2. 人口与分布信息
  3. 图片描述
  4. 详细介绍(分段朗读)
    ↓
朗读过程中提供暂停/继续/停止控制
    ↓
朗读完成 → 播报"朗读结束"
3.2 主动播报核心实现
/*
 * 文件用途:主动语音播报工具——"听民族"功能核心实现
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:主动播报依赖于系统 TTS 引擎,不同设备 TTS 质量可能不同
 */

import { accessibility } from '@kit.AccessibilityKit';

/**
 * 语音播报段落接口
 */
interface SpeechSegment {
    /** 播报内容 */
    text: string;
    /** 播报前延迟(毫秒) */
    delay?: number;
    /** 段落标签(用于日志) */
    tag?: string;
}

/**
 * 语音播报状态枚举
 */
enum SpeechState {
    IDLE = 'idle',           // 空闲
    SPEAKING = 'speaking',   // 正在播报
    PAUSED = 'paused',       // 已暂停
    STOPPED = 'stopped'      // 已停止
}

/**
 * 语音播报管理器
 * 封装主动语音播报的完整逻辑,支持分段播报、暂停、继续、停止
 */
export class VoiceReadingManager {
    private static instance: VoiceReadingManager;
    private currentState: SpeechState = SpeechState.IDLE;
    private segments: SpeechSegment[] = [];
    private currentIndex: number = 0;
    private pauseTimer: number = -1;
    private onStateChangeCallback: ((state: SpeechState) => void) | null = null;

    private constructor() {}

    public static getInstance(): VoiceReadingManager {
        if (!VoiceReadingManager.instance) {
            VoiceReadingManager.instance = new VoiceReadingManager();
        }
        return VoiceReadingManager.instance;
    }

    /**
     * 检查屏幕朗读器是否已开启
     * @returns true 表示已开启
     */
    public isScreenReaderEnabled(): boolean {
        try {
            // 调用无障碍服务 API 检查屏幕朗读器状态
            // const isEnabled = accessibility.isScreenReaderEnabled();
            // return isEnabled;
            // 开发阶段返回 true 用于测试
            return true;
        } catch (error) {
            console.error('[VoiceReading] 检查屏幕朗读器状态失败:', JSON.stringify(error));
            return false;
        }
    }

    /**
     * 开始分段播报
     * @param segments 播报段落数组
     * @param onStateChange 状态变化回调
     */
    public startSpeaking(
        segments: SpeechSegment[],
        onStateChange?: (state: SpeechState) => void
    ): void {
        if (!this.isScreenReaderEnabled()) {
            console.warn('[VoiceReading] 屏幕朗读器未开启,无法播报');
            return;
        }

        if (this.currentState === SpeechState.SPEAKING) {
            // 如果正在播报,先停止再重新开始
            this.stopSpeaking();
        }

        this.segments = segments;
        this.currentIndex = 0;
        this.currentState = SpeechState.SPEAKING;
        this.onStateChangeCallback = onStateChange || null;

        console.log(`[VoiceReading] 开始播报,共 ${segments.length}`);
        this.notifyStateChange();
        this.speakNext();
    }

    /**
     * 播报下一段
     */
    private speakNext(): void {
        if (this.currentState !== SpeechState.SPEAKING) {
            return;
        }

        if (this.currentIndex >= this.segments.length) {
            // 全部播报完毕
            this.onSpeakingComplete();
            return;
        }

        const segment = this.segments[this.currentIndex];
        const delay = segment.delay || 0;

        // 延迟后播报当前段落
        this.pauseTimer = setTimeout(() => {
            if (this.currentState !== SpeechState.SPEAKING) {
                return;
            }

            try {
                console.log(`[VoiceReading] 播报第 ${this.currentIndex + 1} 段: ${segment.tag || '无标签'}`);
                // 调用系统 API 进行主动播报
                // accessibility.speak(segment.text);
                // 模拟播报完成
                this.onSegmentComplete();
            } catch (error) {
                console.error('[VoiceReading] 播报失败:', JSON.stringify(error));
                this.onSegmentComplete();
            }
        }, delay);
    }

    /**
     * 当前段落播报完成
     */
    private onSegmentComplete(): void {
        if (this.currentState !== SpeechState.SPEAKING) {
            return;
        }

        this.currentIndex++;
        this.pauseTimer = -1;
        this.speakNext();
    }

    /**
     * 全部播报完成
     */
    private onSpeakingComplete(): void {
        this.currentState = SpeechState.IDLE;
        this.currentIndex = 0;
        this.segments = [];
        console.log('[VoiceReading] 播报完成');
        this.notifyStateChange();
    }

    /**
     * 暂停播报
     */
    public pauseSpeaking(): void {
        if (this.currentState !== SpeechState.SPEAKING) {
            return;
        }

        this.currentState = SpeechState.PAUSED;
        if (this.pauseTimer !== -1) {
            clearTimeout(this.pauseTimer);
            this.pauseTimer = -1;
        }
        console.log('[VoiceReading] 播报已暂停');
        this.notifyStateChange();
    }

    /**
     * 继续播报
     */
    public resumeSpeaking(): void {
        if (this.currentState !== SpeechState.PAUSED) {
            return;
        }

        this.currentState = SpeechState.SPEAKING;
        console.log('[VoiceReading] 继续播报');
        this.notifyStateChange();
        this.speakNext();
    }

    /**
     * 停止播报
     */
    public stopSpeaking(): void {
        if (this.currentState === SpeechState.IDLE ||
            this.currentState === SpeechState.STOPPED) {
            return;
        }

        this.currentState = SpeechState.STOPPED;
        if (this.pauseTimer !== -1) {
            clearTimeout(this.pauseTimer);
            this.pauseTimer = -1;
        }
        this.currentIndex = 0;
        this.segments = [];
        console.log('[VoiceReading] 播报已停止');
        this.notifyStateChange();
    }

    /**
     * 获取当前播报状态
     */
    public getCurrentState(): SpeechState {
        return this.currentState;
    }

    /**
     * 通知状态变化
     */
    private notifyStateChange(): void {
        if (this.onStateChangeCallback) {
            this.onStateChangeCallback(this.currentState);
        }
    }

    /**
     * 销毁管理器
     */
    public destroy(): void {
        this.stopSpeaking();
        this.onStateChangeCallback = null;
    }
}

/**
 * 构建民族介绍的播报段落
 * @param ethnicName 民族名称
 * @param population 人口数量
 * @param region 分布区域
 * @param imageDesc 图片描述
 * @param detailText 详细介绍
 * @returns 播报段落数组
 */
export function buildEthnicSpeechSegments(
    ethnicName: string,
    population: string,
    region: string,
    imageDesc: string,
    detailText: string
): SpeechSegment[] {
    const segments: SpeechSegment[] = [];

    // 段落1:欢迎语
    segments.push({
        text: `欢迎收听${ethnicName}的介绍`,
        delay: 0,
        tag: 'welcome'
    });

    // 段落2:基本信息
    segments.push({
        text: `${ethnicName},人口${population},主要分布在${region}`,
        delay: 500,
        tag: 'basic_info'
    });

    // 段落3:图片描述
    segments.push({
        text: `图片描述:${imageDesc}`,
        delay: 800,
        tag: 'image_description'
    });

    // 段落4:详细介绍(如果太长可以分段)
    if (detailText.length > 200) {
        // 长文本分段播报
        const chunkSize = 200;
        for (let i = 0; i < detailText.length; i += chunkSize) {
            const chunk = detailText.substring(i, i + chunkSize);
            segments.push({
                text: chunk,
                delay: 600,
                tag: `detail_part_${Math.floor(i / chunkSize) + 1}`
            });
        }
    } else {
        segments.push({
            text: `${ethnicName}的详细介绍:${detailText}`,
            delay: 600,
            tag: 'detail'
        });
    }

    // 段落5:结束语
    segments.push({
        text: `${ethnicName}的介绍播放完毕,感谢收听`,
        delay: 500,
        tag: 'ending'
    });

    return segments;
}
3.3 "听民族"页面集成
/*
 * 文件用途:民族详情页——"听民族"功能集成
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 */

import { VoiceReadingManager, buildEthnicSpeechSegments } from '../utils/VoiceReadingManager';

@Entry
@Component
struct EthnicDetailVoicePage {
    @State ethnicName: string = '苗族';
    @State population: string = '约1100万';
    @State region: string = '贵州、湖南、云南、广西';
    @State imageDesc: string = '苗族女性身着盛装,头戴银角冠,银冠上镶嵌着蝴蝶纹样';
    @State detailText: string = '苗族是一个历史悠久的民族,其先民可追溯到五千多年前的蚩尤部落。苗族有自己的语言,苗语属于汉藏语系苗瑶语族苗语支。苗族人民创造了丰富多彩的文化艺术,苗族的服饰、银饰、刺绣、蜡染等工艺享誉世界...';
    @State isSpeaking: boolean = false;
    @State isPaused: boolean = false;

    private voiceManager: VoiceReadingManager = VoiceReadingManager.getInstance();

    aboutToDisappear(): void {
        // 页面销毁时停止播报
        this.voiceManager.stopSpeaking();
    }

    /**
     * 开始/继续语音朗读
     */
    private handleVoiceReading(): void {
        if (this.isSpeaking && !this.isPaused) {
            // 正在播报中,暂停
            this.voiceManager.pauseSpeaking();
            this.isPaused = true;
            return;
        }

        if (this.isPaused) {
            // 已暂停,继续
            this.voiceManager.resumeSpeaking();
            this.isPaused = false;
            return;
        }

        // 检查屏幕朗读器是否开启
        if (!this.voiceManager.isScreenReaderEnabled()) {
            // 提示用户开启屏幕朗读器
            console.warn('[VoiceReading] 请先开启屏幕朗读器');
            // 实际项目中弹出提示框
            return;
        }

        // 构建播报段落
        const segments = buildEthnicSpeechSegments(
            this.ethnicName,
            this.population,
            this.region,
            this.imageDesc,
            this.detailText
        );

        // 开始播报
        this.voiceManager.startSpeaking(segments, (state) => {
            // 状态变化回调
            if (state === 'idle' || state === 'stopped') {
                this.isSpeaking = false;
                this.isPaused = false;
            }
        });

        this.isSpeaking = true;
        this.isPaused = false;
    }

    /**
     * 停止语音朗读
     */
    private handleStopReading(): void {
        this.voiceManager.stopSpeaking();
        this.isSpeaking = false;
        this.isPaused = false;
    }

    build() {
        Column() {
            // ... 页面内容(民族图片、基本信息、详细介绍等)

            // 底部播报控制栏
            Row({ space: 12 }) {
                // 播报/暂停按钮
                Button() {
                    Row({ space: 6 }) {
                        Image(this.isPaused
                            ? $r('app.media.ic_play')
                            : (this.isSpeaking
                                ? $r('app.media.ic_pause')
                                : $r('app.media.ic_audio')))
                            .width(20)
                            .height(20)
                        Text(this.isPaused
                            ? '继续'
                            : (this.isSpeaking ? '暂停' : '听民族'))
                            .fontSize(16)
                    }
                }
                .width('55%')
                .height(48)
                .borderRadius(24)
                .backgroundColor(this.isSpeaking ? '#E8F4FD' : '#007DFF')
                .fontColor(this.isSpeaking ? '#007DFF' : '#FFFFFF')
                .accessibilityText(
                    this.isPaused
                        ? `继续朗读${this.ethnicName}介绍`
                        : (this.isSpeaking
                            ? `暂停朗读${this.ethnicName}介绍`
                            : `开始朗读${this.ethnicName}介绍`)
                )
                .onClick(() => {
                    this.handleVoiceReading();
                })

                // 停止按钮——仅在播报时显示
                if (this.isSpeaking) {
                    Button() {
                        Row({ space: 6 }) {
                            Image($r('app.media.ic_stop'))
                                .width(20)
                                .height(20)
                            Text('停止')
                                .fontSize(16)
                        }
                    }
                    .width('35%')
                    .height(48)
                    .borderRadius(24)
                    .backgroundColor('#F5F5F5')
                    .accessibilityText(`停止朗读${this.ethnicName}介绍`)
                    .onClick(() => {
                        this.handleStopReading();
                    })
                }
            }
            .width('100%')
            .padding(16)
            .backgroundColor('#FFFFFF')
        }
        .width('100%')
        .height('100%')
    }
}

"听民族"播报效果示例

屏幕朗读器播报顺序:

[欢迎语] "欢迎收听苗族的介绍"
  ↓ 停顿 500ms
[基本信息] "苗族,人口约1100万,主要分布在贵州、湖南、云南、广西"
  ↓ 停顿 800ms
[图片描述] "图片描述:苗族女性身着盛装,头戴银角冠,银冠上镶嵌着精美的蝴蝶纹样..."
  ↓ 停顿 600ms
[详细介绍-1] "苗族是一个历史悠久的民族,其先民可追溯到五千多年前的蚩尤部落..."
  ↓ 停顿 600ms
[详细介绍-2] "苗族人民创造了丰富多彩的文化艺术,苗族的服饰、银饰、刺绣..."
  ↓ 停顿 500ms
[结束语] "苗族的介绍播放完毕,感谢收听"

💡 停顿设计:段与段之间的停顿不是随意的——500ms 是"句子结束"的停顿,800ms 是"话题切换"的停顿。这个节奏让用户能自然地区分不同信息块。


步骤4:关怀模式适配——让老年用户用得舒心

4.1 关怀模式概述

关怀模式是鸿蒙 API 26 Beta 新增的适老化能力,位于 @kit.AbilityKit 中。它提供了一套系统级的适老化设置,应用可以检测并适配。

关怀模式的核心能力

能力 说明 系统行为
字体缩放 系统级字体放大 应用字体跟随系统缩放比例
高对比度 增强颜色对比度 系统自动调整配色
触控区域放大 增大可点击区域 系统自动扩大触控热区
动画简化 减少或禁用动画 减少视觉干扰
4.2 检测关怀模式状态

在适配之前,首先需要检测系统是否开启了关怀模式。

/*
 * 文件用途:关怀模式检测与适配管理
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:关怀模式 API 为 API 26 Beta 新增,需判断 API 版本兼容性
 */

import { abilityAccessCtrl } from '@kit.AbilityKit';

/**
 * 关怀模式配置接口
 */
interface CareModeConfig {
    /** 是否开启关怀模式 */
    enabled: boolean;
    /** 字体缩放比例(1.0 为基础,1.5 表示放大 50%) */
    fontScale: number;
    /** 是否开启高对比度 */
    highContrast: boolean;
    /** 是否简化动画 */
    reduceMotion: boolean;
}

/**
 * 关怀模式管理器
 * 封装关怀模式检测、监听、适配逻辑
 */
export class CareModeManager {
    private static instance: CareModeManager;
    private currentConfig: CareModeConfig = {
        enabled: false,
        fontScale: 1.0,
        highContrast: false,
        reduceMotion: false
    };
    private listeners: Array<(config: CareModeConfig) => void> = [];

    private constructor() {
        this.initCareMode();
    }

    public static getInstance(): CareModeManager {
        if (!CareModeManager.instance) {
            CareModeManager.instance = new CareModeManager();
        }
        return CareModeManager.instance;
    }

    /**
     * 初始化关怀模式检测
     */
    private initCareMode(): void {
        try {
            // 检测系统关怀模式状态
            // 实际 API 调用方式(API 26 Beta):
            // const config = abilityAccessCtrl.getCareModeConfig();
            // this.currentConfig = {
            //     enabled: config.enabled,
            //     fontScale: config.fontScale || 1.0,
            //     highContrast: config.highContrast || false,
            //     reduceMotion: config.reduceMotion || false
            // };

            // 注册关怀模式变化监听
            // abilityAccessCtrl.on('careModeChange', (config) => {
            //     this.updateConfig(config);
            // });

            console.log('[CareMode] 关怀模式初始化完成,当前状态:', JSON.stringify(this.currentConfig));
        } catch (error) {
            console.error('[CareMode] 关怀模式初始化失败:', JSON.stringify(error));
            // 低版本 API 不支持的降级处理
            this.currentConfig.enabled = false;
        }
    }

    /**
     * 更新关怀模式配置
     */
    private updateConfig(config: CareModeConfig): void {
        this.currentConfig = config;
        console.log('[CareMode] 关怀模式配置更新:', JSON.stringify(config));
        // 通知所有监听器
        this.notifyListeners();
    }

    /**
     * 获取当前关怀模式配置
     */
    public getConfig(): CareModeConfig {
        return { ...this.currentConfig };
    }

    /**
     * 是否开启关怀模式
     */
    public isCareModeEnabled(): boolean {
        return this.currentConfig.enabled;
    }

    /**
     * 获取字体缩放比例
     */
    public getFontScale(): number {
        return this.currentConfig.enabled ? this.currentConfig.fontScale : 1.0;
    }

    /**
     * 是否开启高对比度
     */
    public isHighContrast(): boolean {
        return this.currentConfig.enabled && this.currentConfig.highContrast;
    }

    /**
     * 是否简化动画
     */
    public isReduceMotion(): boolean {
        return this.currentConfig.enabled && this.currentConfig.reduceMotion;
    }

    /**
     * 注册关怀模式变化监听
     * @param listener 回调函数
     * @returns 取消监听的函数
     */
    public addListener(listener: (config: CareModeConfig) => void): () => void {
        this.listeners.push(listener);
        return () => {
            const index = this.listeners.indexOf(listener);
            if (index >= 0) {
                this.listeners.splice(index, 1);
            }
        };
    }

    /**
     * 通知所有监听器
     */
    private notifyListeners(): void {
        const config = this.getConfig();
        this.listeners.forEach((listener) => {
            try {
                listener(config);
            } catch (error) {
                console.error('[CareMode] 监听器回调异常:', JSON.stringify(error));
            }
        });
    }
}
4.3 关怀模式下的 UI 适配策略

关怀模式下的 UI 适配不是简单的"全部放大",而是需要有针对性的调整。以下是「民族图鉴」的适配策略:

/*
 * 文件用途:关怀模式 UI 适配常量与工具函数
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 */

import { CareModeManager } from './CareModeManager';

/**
 * 关怀模式下的设计常量
 * 所有尺寸基于关怀模式基准调整
 */
export class CareModeDesignTokens {
    private static careModeManager: CareModeManager = CareModeManager.getInstance();

    // ============ 字体大小 ============

    /** 页面标题字号 */
    static get titleFontSize(): number {
        return this.careModeManager.isCareModeEnabled() ? 28 : 22;
    }

    /** 内容正文字号 */
    static get bodyFontSize(): number {
        return this.careModeManager.isCareModeEnabled() ? 20 : 16;
    }

    /** 辅助文字字号 */
    static get captionFontSize(): number {
        return this.careModeManager.isCareModeEnabled() ? 16 : 12;
    }

    /** 按钮文字字号 */
    static get buttonFontSize(): number {
        return this.careModeManager.isCareModeEnabled() ? 20 : 16;
    }

    // ============ 间距 ============

    /** 页面内边距 */
    static get pagePadding(): number {
        return this.careModeManager.isCareModeEnabled() ? 24 : 16;
    }

    /** 卡片间距 */
    static get cardSpacing(): number {
        return this.careModeManager.isCareModeEnabled() ? 20 : 12;
    }

    /** 列表项间距 */
    static get listItemSpacing(): number {
        return this.careModeManager.isCareModeEnabled() ? 16 : 8;
    }

    /** 行间距 */
    static get lineHeight(): number {
        return this.careModeManager.isCareModeEnabled() ? 32 : 24;
    }

    // ============ 按钮与触控区域 ============

    /** 按钮最小高度 */
    static get buttonMinHeight(): number {
        return this.careModeManager.isCareModeEnabled() ? 56 : 44;
    }

    /** 按钮最小宽度 */
    static get buttonMinWidth(): number {
        return this.careModeManager.isCareModeEnabled() ? 120 : 88;
    }

    /** 图标大小 */
    static get iconSize(): number {
        return this.careModeManager.isCareModeEnabled() ? 28 : 20;
    }

    // ============ 颜色 ============

    /** 正文颜色 */
    static get textPrimary(): string {
        return this.careModeManager.isHighContrast() ? '#000000' : '#333333';
    }

    /** 辅助文字颜色 */
    static get textSecondary(): string {
        return this.careModeManager.isHighContrast() ? '#333333' : '#999999';
    }

    /** 背景颜色 */
    static get backgroundColor(): string {
        return this.careModeManager.isHighContrast() ? '#FFFFFF' : '#F5F5F5';
    }

    /** 分割线颜色 */
    static get dividerColor(): string {
        return this.careModeManager.isHighContrast() ? '#CCCCCC' : '#EEEEEE';
    }

    // ============ 动画 ============

    /** 动画时长(毫秒) */
    static get animationDuration(): number {
        return this.careModeManager.isReduceMotion() ? 0 : 300;
    }

    /** 是否启用动画 */
    static get animationEnabled(): boolean {
        return !this.careModeManager.isReduceMotion();
    }
}
4.4 关怀模式 UI 适配实战
/*
 * 文件用途:关怀模式适配的民族列表页
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:关怀模式适配需在真机上测试,模拟器可能无法完全模拟效果
 */

import { CareModeDesignTokens } from '../utils/CareModeDesignTokens';
import { CareModeManager } from '../utils/CareModeManager';

@Entry
@Component
struct EthnicListCareModePage {
    @State ethnicList: EthnicGroup[] = [];
    @State isCareMode: boolean = false;
    private careModeManager: CareModeManager = CareModeManager.getInstance();
    private removeListener: (() => void) | null = null;

    aboutToAppear(): void {
        this.isCareMode = this.careModeManager.isCareModeEnabled();

        // 监听关怀模式变化
        this.removeListener = this.careModeManager.addListener((config) => {
            this.isCareMode = config.enabled;
            console.log('[CareMode] 页面关怀模式状态更新:', config.enabled);
        });

        this.loadEthnicList();
    }

    aboutToDisappear(): void {
        // 移除监听
        if (this.removeListener) {
            this.removeListener();
            this.removeListener = null;
        }
    }

    private loadEthnicList(): void {
        // 加载数据
        this.ethnicList = [];
    }

    build() {
        Column() {
            // 页面标题——关怀模式下更大
            Text('民族图鉴')
                .fontSize(CareModeDesignTokens.titleFontSize)
                .fontWeight(FontWeight.Bold)
                .padding({
                    left: CareModeDesignTokens.pagePadding,
                    right: CareModeDesignTokens.pagePadding,
                    top: 12,
                    bottom: 12
                })
                .accessibilityText('民族图鉴,共五十六个民族')
                .accessibilityLevel('yes')

            // 关怀模式提示条
            if (this.isCareMode) {
                Row() {
                    Image($r('app.media.ic_care_mode'))
                        .width(CareModeDesignTokens.iconSize)
                        .height(CareModeDesignTokens.iconSize)
                    Text('已开启关怀模式,字体和间距已放大')
                        .fontSize(CareModeDesignTokens.captionFontSize)
                        .fontColor(CareModeDesignTokens.textSecondary)
                }
                .width('100%')
                .padding(12)
                .backgroundColor('#FFF8E1')
                .accessibilityText('关怀模式已开启')
            }

            List({ space: CareModeDesignTokens.cardSpacing }) {
                ForEach(this.ethnicList, (item: EthnicGroup, index: number) => {
                    ListItem() {
                        Row({ space: CareModeDesignTokens.listItemSpacing }) {
                            // 民族缩略图——关怀模式下更大
                            Image(item.thumbnail)
                                .width(this.isCareMode ? 80 : 60)
                                .height(this.isCareMode ? 80 : 60)
                                .borderRadius(8)
                                .accessibilityText(item.imageDescription)

                            Column({ space: 6 }) {
                                Text(item.name)
                                    .fontSize(CareModeDesignTokens.bodyFontSize)
                                    .fontWeight(FontWeight.Medium)
                                    .fontColor(CareModeDesignTokens.textPrimary)

                                Text(`人口:${item.population}`)
                                    .fontSize(CareModeDesignTokens.captionFontSize)
                                    .fontColor(CareModeDesignTokens.textSecondary)

                                Text(`分布:${item.region}`)
                                    .fontSize(CareModeDesignTokens.captionFontSize)
                                    .fontColor(CareModeDesignTokens.textSecondary)
                            }
                            .alignItems(HorizontalAlign.Start)
                            .layoutWeight(1)
                        }
                        .width('100%')
                        .padding(CareModeDesignTokens.pagePadding)
                        // 关怀模式下增大内边距
                        .backgroundColor(CareModeDesignTokens.backgroundColor)
                        .borderRadius(12)
                        .accessibilityGroup(true)
                        .accessibilityText(
                            `${item.name},人口${item.population},主要分布在${item.region}`
                        )
                    }
                    .onClick(() => {
                        this.navigateToDetail(item.id);
                    })
                }, (item: EthnicGroup) => item.id)
            }
            .width('100%')
            .layoutWeight(1)
            .padding({
                left: CareModeDesignTokens.pagePadding,
                right: CareModeDesignTokens.pagePadding
            })
        }
        .width('100%')
        .height('100%')
        .backgroundColor(CareModeDesignTokens.backgroundColor)
    }

    private navigateToDetail(ethnicId: string): void {
        console.log(`导航到民族详情页: ${ethnicId}`);
    }
}

关怀模式适配效果对比

元素 普通模式 关怀模式 变化
页面标题字号 22px 28px +27%
正文字号 16px 20px +25%
辅助文字字号 12px 16px +33%
页面内边距 16px 24px +50%
卡片间距 12px 20px +67%
按钮最小高度 44px 56px +27%
缩略图尺寸 60x60 80x80 +33%
文字颜色 #333333 #000000 高对比度
辅助文字颜色 #999999 #333333 高对比度

💡 设计原则:关怀模式下的调整不是"简单粗暴地放大",而是有节奏地放大——标题比正文放大更多,间距比字体放大更多。这样放大的效果是"宽松舒适"而不是"拥挤混乱"。

4.5 关怀模式下的动画简化

老年人对快速变化的动画可能感到不适,关怀模式下应该简化或禁用动画。

/*
 * 文件用途:关怀模式动画适配
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 */

import { CareModeManager } from './CareModeManager';

/**
 * 关怀模式动画适配工具
 */
export class CareModeAnimation {
    private static careModeManager: CareModeManager = CareModeManager.getInstance();

    /**
     * 执行动画——关怀模式下跳过动画直接设置最终值
     * @param animateFn 动画执行函数
     * @param finalStateFn 直接设置最终状态的回调
     */
    static execute(animateFn: () => void, finalStateFn: () => void): void {
        if (this.careModeManager.isReduceMotion()) {
            // 关怀模式 + 简化动画 → 直接设置最终状态
            finalStateFn();
        } else {
            // 正常模式 → 执行动画
            animateFn();
        }
    }
}

// 使用示例:页面切换动画
// CareModeAnimation.execute(
//     // 动画函数
//     () => {
//         animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
//             this.pageOpacity = 0;
//         });
//     },
//     // 直接设置最终状态(关怀模式)
//     () => {
//         this.pageOpacity = 0;
//     }
// );

步骤5:图片语义化描述——让视障用户"看见"图片

5.1 图片描述的艺术

对于视障用户来说,图片的唯一信息来源就是 accessibilityText。写好图片描述,就是"用文字画画"。

「民族图鉴」图片描述分级体系

级别 长度 内容 适用场景
简短描述 10-30字 核心内容概括 列表缩略图
标准描述 30-80字 主要特征描述 详情页图片
详细描述 80-200字 完整文化解读 听民族功能
5.2 图片描述数据管理
/*
 * 文件用途:民族图片语义化描述数据
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 * 风险提示:图片描述需由熟悉民族文化的人员审核,确保准确性
 */

/**
 * 图片描述接口
 */
interface ImageDescription {
    /** 简短描述——用于列表缩略图 */
    short: string;
    /** 标准描述——用于详情页大图 */
    standard: string;
    /** 详细描述——用于语音播报 */
    detailed: string;
}

/**
 * 民族图片描述数据
 * 每个民族配三套描述,按场景使用
 */
export const ETHNIC_IMAGE_DESCRIPTIONS: Record<string, ImageDescription> = {
    miao: {
        short: '苗族女性身着盛装,头戴银角冠',
        standard: '苗族女性身着盛装,头戴银角冠,银冠上镶嵌着精美的蝴蝶纹样和凤凰图案。颈戴多层银项圈,胸前佩戴银锁。上衣为交领右衽,袖口刺绣彩色花鸟纹样。',
        detailed: '苗族女性身着盛装,头戴银角冠,银冠上镶嵌着精美的蝴蝶纹样和凤凰图案,象征苗族先祖蝴蝶妈妈和民族图腾。颈戴多层银项圈,每层银圈上刻有精细的几何纹样。胸前佩戴银锁,锁面刻有龙凤呈祥图案。上衣为交领右衽,面料为手工织造的蓝靛布,袖口刺绣彩色花鸟纹样,针法细腻。下穿百褶裙,裙摆绣有几何纹样,色彩以红、蓝、黑为主,搭配绿色和黄色点缀。整体造型华丽庄重,体现了苗族银饰工艺的精湛和服饰文化的深厚底蕴。'
    },
    zhuang: {
        short: '壮族女性身着蓝色对襟上衣,头戴绣花头巾',
        standard: '壮族女性身着深蓝色对襟上衣,头戴白色绣花头巾,头巾上绣有彩色几何纹样。颈间佩戴银质项圈和长命锁。下身穿着宽大的黑色长裤,腰间系有彩色织锦腰带。',
        detailed: '壮族女性身着深蓝色对襟上衣,面料为手工织造的棉布,衣襟和袖口镶有彩色织锦花边。头戴白色绣花头巾,头巾上绣有彩色几何纹样,有菱形、三角形和花卉图案,色彩以红、黄、绿为主。颈间佩戴银质项圈和长命锁,银饰上刻有壮族传统图腾。下身穿着宽大的黑色长裤,腰间系有壮族特有的彩色织锦腰带,腰带上织有精美的图案。整体造型端庄大方,体现了壮族服饰的实用性与艺术性的完美结合。'
    },
    // 其他 54 个民族...
};

/**
 * 根据场景获取图片描述
 * @param ethnicId 民族ID
 * @param level 描述级别
 * @returns 图片描述文本
 */
export function getImageDescription(
    ethnicId: string,
    level: 'short' | 'standard' | 'detailed'
): string {
    const descriptions = ETHNIC_IMAGE_DESCRIPTIONS[ethnicId];
    if (!descriptions) {
        return '民族图片';
    }
    return descriptions[level] || descriptions.standard;
}

图片描述编写的核心原则

  1. 先说主题,再说细节:先告诉用户"这是苗族服饰",再描述服饰的细节
  2. 颜色要具体:不要说"彩色的",要说"红色、蓝色、黑色"
  3. 描述图案和纹样:视障用户可能对"蝴蝶纹样"、"几何纹样"有概念
  4. 包含文化背景:银角冠象征什么,为什么是蝴蝶图案
  5. 避免主观评价:不要写"美丽的"、“好看的”,只描述客观特征
  6. 长度适中:列表缩略图 10-30 字,详情页 30-80 字,语音播报 80-200 字

步骤6:全局无障碍配置——统一管理

6.1 无障碍配置中心

为了方便管理,我们创建一个全局的无障碍配置中心,统一处理所有无障碍相关的设置。

/*
 * 文件用途:无障碍配置中心——全局无障碍设置管理
 * 创建时间:2026-07-23
 * 兼容环境:macOS/Linux/Docker/TRAE 云端
 * 版本:v1.0
 */

import { CareModeManager } from './CareModeManager';

/**
 * 无障碍配置中心
 * 统一管理无障碍相关的全局配置,包括:
 * - 屏幕朗读器状态
 * - 关怀模式状态
 * - 字体缩放
 * - 高对比度
 * - 动画简化
 * - 无障碍文本生成规则
 */
export class AccessibilityConfigCenter {
    private static instance: AccessibilityConfigCenter;
    private careModeManager: CareModeManager;

    private constructor() {
        this.careModeManager = CareModeManager.getInstance();
    }

    public static getInstance(): AccessibilityConfigCenter {
        if (!AccessibilityConfigCenter.instance) {
            AccessibilityConfigCenter.instance = new AccessibilityConfigCenter();
        }
        return AccessibilityConfigCenter.instance;
    }

    /**
     * 获取当前字体大小(考虑关怀模式)
     * @param baseSize 基准字号
     * @returns 实际字号
     */
    public getFontSize(baseSize: number): number {
        const scale = this.careModeManager.getFontScale();
        return Math.round(baseSize * scale);
    }

    /**
     * 获取当前间距(考虑关怀模式)
     * @param baseSpacing 基准间距
     * @returns 实际间距
     */
    public getSpacing(baseSpacing: number): number {
        if (this.careModeManager.isCareModeEnabled()) {
            return Math.round(baseSpacing * 1.5);
        }
        return baseSpacing;
    }

    /**
     * 获取当前动画时长(考虑关怀模式)
     * @param baseDuration 基准时长(毫秒)
     * @returns 实际时长
     */
    public getAnimationDuration(baseDuration: number): number {
        if (this.careModeManager.isReduceMotion()) {
            return 0;
        }
        return baseDuration;
    }

    /**
     * 是否启用无障碍增强模式
     * 当屏幕朗读器开启或关怀模式开启时返回 true
     */
    public isAccessibilityEnhanced(): boolean {
        return this.careModeManager.isCareModeEnabled();
    }

    /**
     * 生成列表项的无障碍文本
     * @param name 民族名称
     * @param population 人口
     * @param region 分布区域
     * @returns 格式化的无障碍文本
     */
    public generateListItemAccessibilityText(
        name: string,
        population: string,
        region: string
    ): string {
        return `${name},人口${population},主要分布在${region}`;
    }

    /**
     * 生成操作按钮的无障碍文本
     * @param action 操作名称
     * @param target 操作目标
     * @param state 当前状态(可选)
     * @returns 格式化的无障碍文本
     */
    public generateActionAccessibilityText(
        action: string,
        target: string,
        state?: string
    ): string {
        if (state) {
            return `${action}${target}${state}`;
        }
        return `${action}${target}`;
    }
}

⚠️ 常见问题与解决方案

问题1:屏幕朗读器播报的内容不连贯,断断续续

现象
用户在民族列表页使用屏幕朗读器,手指划过每个列表项时,播报内容断断续续——先读图片描述,再读名称,再读人口,中间有明显的停顿。

原因分析
每个子组件都单独配置了 accessibilityText,但没有使用 accessibilityGroup 将它们合并为一个焦点。屏幕朗读器把它们当作独立的焦点,导致用户需要多次触摸才能听完一个民族的信息。

解决方案

  1. 使用 accessibilityGroup 合并焦点:将列表项的所有子组件合并为一个焦点组。
  2. 在焦点组上设置完整的无障碍文本:把名称、人口、分布等信息整合成一句话。
  3. 子组件禁用无障碍焦点:设置 accessibilityLevel('no') 避免重复播报。
// 正确的做法
Row() {
    Image(item.thumbnail)
        .accessibilityLevel('no')  // 子组件不参与焦点遍历
    Column() {
        Text(item.name)
            .accessibilityLevel('no')  // 子组件不参与焦点遍历
        Text(item.population)
            .accessibilityLevel('no')
    }
    .accessibilityLevel('no')
}
.accessibilityGroup(true)  // 合并为一个焦点组
.accessibilityText('苗族,人口约1100万,主要分布在贵州、湖南、云南、广西')
// 焦点组上设置完整的无障碍文本

问题2:关怀模式下,界面布局错乱

现象
开启关怀模式后,字体和间距放大,导致原本一行能显示的内容换行,按钮被挤出屏幕,界面布局完全错乱。

原因分析
关怀模式下的字体和间距放大了 25%-50%,如果布局使用了固定宽度,或者没有考虑内容溢出的情况,就容易出现布局错乱。

解决方案

  1. 使用弹性布局:优先使用 layoutWeight、百分比宽度,避免固定像素宽度。
  2. 设置最大宽度:对文本设置 maxLinestextOverflow,防止超长文本溢出。
  3. 使用 Scroll 组件:当内容可能超出屏幕时,用 Scroll 包裹。
  4. 测试验证:开发时模拟关怀模式下的字号和间距,验证布局是否正常。
// 关怀模式安全的布局
Row() {
    Image(item.thumbnail)
        .width(60)
        .height(60)
        .flexShrink(0)  // 图片不缩小

    Column() {
        Text(item.name)
            .fontSize(20)
            .maxLines(1)           // 限制一行
            .textOverflow({ overflow: TextOverflow.Ellipsis })  // 溢出省略
        Text(item.region)
            .fontSize(14)
            .maxLines(2)           // 最多两行
            .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .layoutWeight(1)  // 弹性宽度,自动分配剩余空间
}
.width('100%')
.padding(16)

问题3:图片的无障碍文本写了,但屏幕朗读器还是读"图片"

现象
明明给 Image 组件设置了 accessibilityText,但屏幕朗读器播报的还是"图片"两个字。

原因分析
可能的原因有几种:

  1. 组件的 accessibilityLevel 被设置为 'no''no-hide-descendants'
  2. 父组件设置了 accessibilityGroup(true),覆盖了子组件的无障碍文本
  3. 图片的 accessibilityText 设置在条件渲染后才生效,但渲染时机有问题
  4. 系统无障碍服务缓存未更新

解决方案

  1. 检查 accessibilityLevel 是否为 'yes''auto'
  2. 如果使用了焦点组,确保焦点组的 accessibilityText 包含了图片描述
  3. 在数据加载完成后手动通知无障碍服务更新
  4. 在真机上测试,不要依赖模拟器
// 确保图片无障碍文本生效
Image(item.thumbnail)
    .width(200)
    .height(200)
    .accessibilityLevel('yes')  // 明确设置参与无障碍
    .accessibilityText('苗族服饰图片:苗族女性头戴银角冠,身着盛装')
    // 文本要以"XX图片"开头,让用户知道这是图片

问题4:"听民族"功能播报到一半就停了

现象
用户点击"听民族"按钮后,语音播报了一段就停止了,没有播完完整的介绍。

原因分析

  1. 播报过程中页面被销毁(如用户返回上一页)
  2. 播报内容过长,系统 TTS 引擎超时
  3. 播报过程中发生了异常,但异常被静默捕获
  4. 分段播报的延迟逻辑有问题,导致后面段落没有触发

解决方案

  1. aboutToDisappear 中停止播报,但记录进度,下次可以继续
  2. 将长文本分成多个小段(每段不超过 200 字),分段播报
  3. 增加播报异常处理,记录失败日志
  4. 添加播报进度回调,让用户知道播到哪里了
// 分段播报,每段之间加入适当的停顿
const segments = buildEthnicSpeechSegments(
    ethnicName, population, region, imageDesc, detailText
);

// 确保每段长度合理
segments.forEach((segment, index) => {
    if (segment.text.length > 200) {
        console.warn(`[VoiceReading] 第${index}段过长(${segment.text.length}字),建议拆分`);
    }
});

// 监听播报完成事件
voiceManager.startSpeaking(segments, (state) => {
    if (state === 'idle') {
        // 播报正常完成
        console.log('[VoiceReading] 播报完成');
    } else if (state === 'stopped') {
        // 播报被中断
        console.log('[VoiceReading] 播报被中断');
    }
});

问题5:关怀模式下,弹窗和对话框还是原来的大小

现象
页面已经适配了关怀模式,但弹窗(AlertDialog、CustomDialog)的字体和尺寸没有变化,显得格格不入。

原因分析
弹窗和对话框是独立渲染的,它们的样式不会自动跟随页面的关怀模式设置。需要单独为弹窗做适配。

解决方案

// 关怀模式适配的弹窗
private showCareModeDialog(): void {
    const isCareMode = CareModeManager.getInstance().isCareModeEnabled();

    AlertDialog.show({
        title: '确认删除',
        message: '删除后无法恢复,确定要删除这个民族的收藏吗?',
        // 关怀模式下增大字体
        titleFont: {
            size: isCareMode ? 22 : 18,
            weight: FontWeight.Bold
        },
        // 关怀模式下增大按钮
        buttonHeight: isCareMode ? 56 : 44,
        primaryButton: {
            value: '确定删除',
            fontColor: '#FF4444',
            action: () => {
                // 执行删除操作
            }
        },
        secondaryButton: {
            value: '取消',
            fontColor: '#666666',
            action: () => {
                // 取消操作
            }
        }
    });
}

📝 本章小结

核心知识点

本文系统讲解了鸿蒙7 无障碍与适老化的技术体系与适配方法,核心内容包括:

1. 无障碍服务框架(@kit.AccessibilityKit)

  • 屏幕朗读器工作流程:触摸事件 → 焦点定位 → 读取无障碍文本 → TTS 播报
  • 无障碍焦点树:页面组件按层级组织,默认按视觉布局遍历
  • 主动播报 vs 焦点播报:前者用于连续朗读,后者用于浏览探索
  • 三个核心属性:accessibilityText(播报内容)、accessibilityLevel(焦点级别)、accessibilityGroup(焦点合并)

2. 组件无障碍属性配置

  • 为图片提供有意义的描述,而不是"图片"两个字
  • 装饰性元素使用 accessibilityLevel('no') 禁用焦点
  • 逻辑相关的子组件使用 accessibilityGroup(true) 合并焦点
  • 无障碍文本要简洁完整,包含状态信息

3. "听民族"主动语音播报

  • 构建分段播报内容,段落之间适当停顿
  • 长文本分段处理,每段不超过 200 字
  • 支持暂停、继续、停止控制
  • 播报前检测屏幕朗读器是否开启

4. 关怀模式适配(API 26 Beta)

  • 检测关怀模式开启状态,监听模式变化
  • 字体放大 25-50%,间距放大 50-67%
  • 高对比度:文字颜色从 #333 → #000,辅助色从 #999 → #333
  • 简化动画:动画时长设为 0,直接设置最终状态
  • 使用弹性布局,避免关怀模式下布局错乱

5. 图片语义化描述

  • 三级描述体系:简短(10-30字)、标准(30-80字)、详细(80-200字)
  • 编写原则:先主题后细节、颜色具体、描述图案、包含文化背景
  • 数据集中管理,按场景获取对应级别的描述

最佳实践总结

焦点组优先

将逻辑相关的子组件合并为一个焦点组
在焦点组上设置完整的无障碍文本
子组件禁用无障碍焦点,避免重复播报

图片描述要"用文字画画"

说清楚图片里有什么、什么颜色、什么图案
让视障用户听完能在脑海中构建画面
列表缩略图用简短描述,详情页用标准描述

关怀模式是系统工程

不只是放大字体,还有间距、颜色、动画、交互
使用弹性布局,预留放大空间
弹窗、对话框也要单独适配

语音播报要有节奏感

段落之间停顿 500-800ms
长文本分段,每段不超过 200 字
提供暂停、继续、停止控制

下一步预告

在下一篇文章中,我们将:

  • ⚡ 深入学习应用快启技术原理
  • 🚀 了解冷启动、温启动、热启动的优化新方案
  • 🛠️ 掌握预加载、进程保活、热启动优化技巧
  • 📱 学习系统级快启能力的使用
  • ⚖️ 理解内存与快启的平衡之道
  • 🎯 实战优化「民族图鉴」启动速度,实现秒开

🔗 相关链接


💡 提示:无障碍与适老化适配,不是"做完就结束了"的一次性工作,而是需要持续关注和迭代的长期工程。每次新增功能、修改 UI、调整交互时,都要问自己:视障用户能用吗?老年用户能用吗?如果答案是否定的,那就需要继续优化。最好的无障碍设计,是让所有用户都感觉不到"特殊设计"的存在——它自然地融入产品,让每个人都能平等地享受科技带来的便利。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐