HarmonyOS应用《民族图鉴》开发第87篇:无障碍与适老化——语音播报与关怀模式

📖 引言
想象一下这个场景:
一位视障用户打开「民族图鉴」,手指轻轻划过屏幕,手机的屏幕朗读器用清晰的声音播报道:“苗族——人口约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)的核心理念是:让产品和服务能够被尽可能多的人使用,无论他们是否有残疾或特殊需求。
这不是一个技术问题,而是一个设计哲学问题。在移动应用开发中,无障碍设计主要关注以下几个方面:
| 关注维度 | 目标用户 | 核心需求 |
|---|---|---|
| 视觉无障碍 | 视障用户、低视力用户 | 屏幕朗读、高对比度、大字体 |
| 听觉无障碍 | 听障用户 | 字幕、视觉反馈、震动提示 |
| 运动无障碍 | 肢体障碍用户 | 大触控区域、语音控制、简化交互 |
| 认知无障碍 | 认知障碍用户、老年人 | 简洁界面、清晰导航、减少记忆负担 |
对于「民族图鉴」这类以图文内容为主的应用,视觉无障碍和适老化是最核心的两个方向。
适老化——不只是"放大字体"
很多人以为适老化就是"把字体放大"。这是一个非常片面的理解。
真正的适老化是一个系统工程,需要考虑:
- 视觉层面:字体大小、对比度、间距、图标大小
- 交互层面:触控区域、操作复杂度、手势简化
- 认知层面:信息架构清晰、操作反馈明确、减少学习成本
- 情感层面:尊重用户、不贴标签、不制造"被特殊对待"的感觉
💡 有一个重要的设计原则叫"包容性设计"(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}`);
}
}
代码解读:
- 图片的无障碍文本:使用
item.imageDescription而不是简单的"图片"或"缩略图",让视障用户也能了解图片内容。 - 装饰性图标:右侧箭头图标使用
accessibilityLevel('no'),因为它只是视觉装饰,不需要被朗读。 - 焦点组:整行列表项使用
accessibilityGroup(true)合并为一个焦点,让用户一次触摸就能听到完整信息,而不是要分别触摸图片、名称、人口、分布。 - 焦点组的无障碍文本:整合了所有关键信息,格式为"苗族,人口约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('开始语音朗读');
}
}
关键设计点:
- 导航栏焦点组:将返回按钮、标题合并为一个焦点,让用户先了解"我在哪个页面"。
- 基本信息卡片焦点组:将名称、人口、分布合并为一个焦点,一次播报完整信息,而不是让用户分三次触摸。
- 子元素禁用焦点:人口和分布的 Row 设置
accessibilityLevel('no'),避免它们单独出现在焦点遍历中,造成重复播报。 - 状态感知文本:收藏按钮的无障碍文本根据状态动态变化,让用户知道当前是否已收藏。
- 操作提示:听民族按钮的无障碍文本包含"双击开始",引导用户正确操作。
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;
}
图片描述编写的核心原则:
- 先说主题,再说细节:先告诉用户"这是苗族服饰",再描述服饰的细节
- 颜色要具体:不要说"彩色的",要说"红色、蓝色、黑色"
- 描述图案和纹样:视障用户可能对"蝴蝶纹样"、"几何纹样"有概念
- 包含文化背景:银角冠象征什么,为什么是蝴蝶图案
- 避免主观评价:不要写"美丽的"、“好看的”,只描述客观特征
- 长度适中:列表缩略图 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 将它们合并为一个焦点。屏幕朗读器把它们当作独立的焦点,导致用户需要多次触摸才能听完一个民族的信息。
解决方案:
- 使用
accessibilityGroup合并焦点:将列表项的所有子组件合并为一个焦点组。 - 在焦点组上设置完整的无障碍文本:把名称、人口、分布等信息整合成一句话。
- 子组件禁用无障碍焦点:设置
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%,如果布局使用了固定宽度,或者没有考虑内容溢出的情况,就容易出现布局错乱。
解决方案:
- 使用弹性布局:优先使用
layoutWeight、百分比宽度,避免固定像素宽度。 - 设置最大宽度:对文本设置
maxLines和textOverflow,防止超长文本溢出。 - 使用 Scroll 组件:当内容可能超出屏幕时,用 Scroll 包裹。
- 测试验证:开发时模拟关怀模式下的字号和间距,验证布局是否正常。
// 关怀模式安全的布局
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,但屏幕朗读器播报的还是"图片"两个字。
原因分析:
可能的原因有几种:
- 组件的
accessibilityLevel被设置为'no'或'no-hide-descendants' - 父组件设置了
accessibilityGroup(true),覆盖了子组件的无障碍文本 - 图片的
accessibilityText设置在条件渲染后才生效,但渲染时机有问题 - 系统无障碍服务缓存未更新
解决方案:
- 检查
accessibilityLevel是否为'yes'或'auto' - 如果使用了焦点组,确保焦点组的
accessibilityText包含了图片描述 - 在数据加载完成后手动通知无障碍服务更新
- 在真机上测试,不要依赖模拟器
// 确保图片无障碍文本生效
Image(item.thumbnail)
.width(200)
.height(200)
.accessibilityLevel('yes') // 明确设置参与无障碍
.accessibilityText('苗族服饰图片:苗族女性头戴银角冠,身着盛装')
// 文本要以"XX图片"开头,让用户知道这是图片
问题4:"听民族"功能播报到一半就停了
现象:
用户点击"听民族"按钮后,语音播报了一段就停止了,没有播完完整的介绍。
原因分析:
- 播报过程中页面被销毁(如用户返回上一页)
- 播报内容过长,系统 TTS 引擎超时
- 播报过程中发生了异常,但异常被静默捕获
- 分段播报的延迟逻辑有问题,导致后面段落没有触发
解决方案:
- 在
aboutToDisappear中停止播报,但记录进度,下次可以继续 - 将长文本分成多个小段(每段不超过 200 字),分段播报
- 增加播报异常处理,记录失败日志
- 添加播报进度回调,让用户知道播到哪里了
// 分段播报,每段之间加入适当的停顿
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 字
提供暂停、继续、停止控制
下一步预告
在下一篇文章中,我们将:
- ⚡ 深入学习应用快启技术原理
- 🚀 了解冷启动、温启动、热启动的优化新方案
- 🛠️ 掌握预加载、进程保活、热启动优化技巧
- 📱 学习系统级快启能力的使用
- ⚖️ 理解内存与快启的平衡之道
- 🎯 实战优化「民族图鉴」启动速度,实现秒开
🔗 相关链接
- 项目源码: GitCode 仓库
- 鸿蒙无障碍开发指南: 官方文档
- @kit.AccessibilityKit API 参考: 官方文档
- 关怀模式开发指南: 官方文档
- 无障碍设计指南(WCAG 2.1 中文版): W3C
- 中国残疾人联合会数据: 官方统计
💡 提示:无障碍与适老化适配,不是"做完就结束了"的一次性工作,而是需要持续关注和迭代的长期工程。每次新增功能、修改 UI、调整交互时,都要问自己:视障用户能用吗?老年用户能用吗?如果答案是否定的,那就需要继续优化。最好的无障碍设计,是让所有用户都感觉不到"特殊设计"的存在——它自然地融入产品,让每个人都能平等地享受科技带来的便利。
更多推荐



所有评论(0)