第1736篇:AI应用的无障碍设计——让视障用户也能用好AI功能
第1736篇:AI应用的无障碍设计——让视障用户也能用好AI功能
说实话,在我做AI产品之前,无障碍设计对我来说是一个"有时间再搞"的事情。
直到有一次,我们产品上线后收到一封邮件,一位视障用户说:他用屏幕阅读器打开了我们的AI助手,发现根本用不了——AI正在输出内容的时候,屏幕阅读器不断被打断,他完全没法听到完整的回答。
那封邮件让我第一次认真想这个问题。
做AI产品的工程师,很容易只关注准确率、延迟、吞吐量这些技术指标,而忘记了有一部分用户的使用方式跟我们想象的完全不同。
今天聊聊AI产品在无障碍设计上的特殊挑战,以及Java后端工程师能做哪些事。
AI产品的无障碍挑战为什么特别难
传统网页的无障碍设计有很完善的规范——WCAG(Web内容无障碍指南),基本上照着做就行。
但AI产品引入了几个传统无障碍规范没有充分考虑的问题:
问题1:内容是动态生成的。传统页面内容是静态的,屏幕阅读器可以从头读到尾。AI流式输出的内容在不断追加,屏幕阅读器不知道什么时候应该开始读、什么时候应该停下来。
问题2:对话的非线性结构。一个多轮对话,用户可能想跳回去听第三轮对话的内容,或者只想听AI的部分不想听自己的问题。怎么设计键盘导航、怎么设计读取顺序,需要专门设计。
问题3:富文本内容。AI经常输出表格、代码块、数学公式。这些内容屏幕阅读器要么完全跳过,要么把格式标记一起读出来("星号星号加粗星号星号"),体验很差。
问题4:操作反馈模糊。AI在"思考中"、AI在"生成"、AI遇到错误——这些状态变化视觉用户一眼能看出来,但视障用户没有相应的听觉/触觉反馈。
后端能做什么:无障碍优化的 API 层支持
很多工程师觉得无障碍是纯前端的事,后端不需要管。这个想法是错的。
后端至少要做以下几件事:
1. 提供语义化的内容结构
AI 回复的内容,后端应该不只是返回原始文本,还要提供结构化的语义标注:
@Data
@Builder
public class AccessibleAIResponse {
private String rawContent; // 原始文本(前端渲染用)
private String plainText; // 纯文本版本(屏幕阅读器用)
private List<ContentBlock> blocks; // 结构化内容块
private String summary; // 简短摘要(用于快速预览)
private ResponseType responseType; // 回复类型
@Data
@Builder
public static class ContentBlock {
private BlockType type; // PARAGRAPH / CODE / TABLE / LIST / HEADING
private String content;
private String ariaLabel; // ARIA标签,用于屏幕阅读器
private int order; // 阅读顺序
public enum BlockType {
PARAGRAPH, CODE, TABLE, LIST, HEADING, FORMULA
}
}
}@Service
public class AccessibleResponseBuilder {
/**
* 解析 AI 回复,构建语义化结构
*/
public AccessibleAIResponse build(String rawResponse) {
List<AccessibleAIResponse.ContentBlock> blocks = parseBlocks(rawResponse);
// 生成纯文本版本(去除Markdown格式)
String plainText = generatePlainText(blocks);
// 生成简短摘要(取前两个段落或前200字)
String summary = generateSummary(plainText, 200);
return AccessibleAIResponse.builder()
.rawContent(rawResponse)
.plainText(plainText)
.blocks(blocks)
.summary(summary)
.responseType(detectResponseType(rawResponse))
.build();
}
private List<AccessibleAIResponse.ContentBlock> parseBlocks(String content) {
List<AccessibleAIResponse.ContentBlock> blocks = new ArrayList<>();
// 简化的 Markdown 解析
String[] lines = content.split("\n");
StringBuilder currentParagraph = new StringBuilder();
boolean inCodeBlock = false;
StringBuilder codeBuffer = new StringBuilder();
int order = 0;
for (String line : lines) {
if (line.startsWith("```")) {
if (!inCodeBlock) {
// 代码块开始,先保存之前的段落
if (currentParagraph.length() > 0) {
blocks.add(buildParagraphBlock(currentParagraph.toString(), order++));
currentParagraph = new StringBuilder();
}
inCodeBlock = true;
} else {
// 代码块结束
String codeContent = codeBuffer.toString();
blocks.add(AccessibleAIResponse.ContentBlock.builder()
.type(AccessibleAIResponse.ContentBlock.BlockType.CODE)
.content(codeContent)
.ariaLabel("代码块,共" + codeContent.split("\n").length + "行")
.order(order++)
.build());
codeBuffer = new StringBuilder();
inCodeBlock = false;
}
} else if (inCodeBlock) {
codeBuffer.append(line).append("\n");
} else if (line.startsWith("# ")) {
blocks.add(AccessibleAIResponse.ContentBlock.builder()
.type(AccessibleAIResponse.ContentBlock.BlockType.HEADING)
.content(line.substring(2))
.ariaLabel("标题:" + line.substring(2))
.order(order++)
.build());
} else if (line.startsWith("- ") || line.startsWith("* ")) {
// 列表项
blocks.add(AccessibleAIResponse.ContentBlock.builder()
.type(AccessibleAIResponse.ContentBlock.BlockType.LIST)
.content(line.substring(2))
.ariaLabel("列表项:" + line.substring(2))
.order(order++)
.build());
} else {
currentParagraph.append(line).append(" ");
if (line.isEmpty() && currentParagraph.length() > 0) {
blocks.add(buildParagraphBlock(currentParagraph.toString().trim(), order++));
currentParagraph = new StringBuilder();
}
}
}
if (currentParagraph.length() > 0) {
blocks.add(buildParagraphBlock(currentParagraph.toString().trim(), order));
}
return blocks;
}
private String generatePlainText(List<AccessibleAIResponse.ContentBlock> blocks) {
StringBuilder sb = new StringBuilder();
for (AccessibleAIResponse.ContentBlock block : blocks) {
switch (block.getType()) {
case CODE:
sb.append("[代码块] ").append(block.getContent()).append("\n");
break;
case TABLE:
sb.append("[表格内容] ").append(flattenTable(block.getContent())).append("\n");
break;
default:
// 移除 Markdown 符号
sb.append(stripMarkdown(block.getContent())).append("\n");
}
}
return sb.toString().trim();
}
private String stripMarkdown(String text) {
return text
.replaceAll("\\*\\*(.*?)\\*\\*", "$1") // 去除加粗
.replaceAll("\\*(.*?)\\*", "$1") // 去除斜体
.replaceAll("`(.*?)`", "$1") // 去除行内代码
.replaceAll("\\[(.*?)\\]\\(.*?\\)", "$1") // 去除链接
.trim();
}
}2. 流式输出的分块控制
视障用户使用屏幕阅读器时,不适合接收每个字符的流式更新——这会让屏幕阅读器不断被打断。更好的体验是按语义单元发送,比如一句话发完了再发下一句:
@Service
public class AccessibleStreamSplitter {
private StringBuilder buffer = new StringBuilder();
private static final Pattern SENTENCE_END = Pattern.compile("[。!?.!?]");
/**
* 把 token 流按句子边界拆分
* 每积累一个完整句子才发送,而不是逐 token 发送
*/
public Optional<String> processToken(String token) {
buffer.append(token);
String current = buffer.toString();
Matcher matcher = SENTENCE_END.matcher(current);
int lastEnd = -1;
while (matcher.find()) {
lastEnd = matcher.end();
}
if (lastEnd > 0) {
String sentence = current.substring(0, lastEnd);
buffer = new StringBuilder(current.substring(lastEnd));
return Optional.of(sentence);
}
// 缓冲区太大时,强制在标点处拆分(防止等太久)
if (current.length() > 200) {
// 找最近的逗号或分号
int splitAt = findLastPunctuation(current, 100);
if (splitAt > 0) {
String chunk = current.substring(0, splitAt + 1);
buffer = new StringBuilder(current.substring(splitAt + 1));
return Optional.of(chunk);
}
}
return Optional.empty();
}
/**
* 生成屏幕阅读器友好的流式事件
*/
public SseEmitter.SseEventBuilder buildAccessibleEvent(String content, boolean isComplete) {
return SseEmitter.event()
.name(isComplete ? "sentence_complete" : "token")
.data(Map.of(
"content", content,
"ariaLive", "polite", // 前端用这个值设置 aria-live 属性
"isComplete", isComplete
));
}
}3. 状态变化的语义化通知
AI 的各种状态(思考中、生成中、出错了)需要有明确的语义化标注,而不仅仅是视觉图标:
@Service
public class AccessibleStateNotifier {
public enum AIState {
IDLE("空闲"),
THINKING("正在理解你的问题"),
GENERATING("正在生成回答"),
DONE("回答完成"),
ERROR("回答遇到了问题"),
CANCELLED("已取消");
private final String screenReaderText;
AIState(String text) {
this.screenReaderText = text;
}
}
/**
* 构建状态变化的 SSE 事件,包含屏幕阅读器文本
*/
public Map<String, Object> buildStateEvent(AIState state, String detail) {
Map<String, Object> event = new HashMap<>();
event.put("state", state.name());
event.put("screenReaderAnnouncement", state.getScreenReaderText());
event.put("ariaLive", state == AIState.ERROR ? "assertive" : "polite");
// assertive 会打断当前阅读器的朗读,polite 会等当前内容读完
if (detail != null) {
event.put("detail", detail);
}
return event;
}
}代码块和表格的无障碍处理
这是最难的部分。代码块直接给视障用户朗读是灾难性的体验。
后端可以提供多种内容形态:
@Service
public class CodeBlockAccessibilityHandler {
/**
* 对代码块生成无障碍说明
*/
public CodeBlockAccessibility processCodeBlock(String code, String language) {
// 生成描述性文字(用 LLM)
String description = generateCodeDescription(code, language);
// 分析代码结构
CodeStructure structure = analyzeCodeStructure(code, language);
return CodeBlockAccessibility.builder()
.originalCode(code)
.language(language)
.description(description)
.lineCount(code.split("\n").length)
.structure(structure)
.screenReaderSummary(buildScreenReaderSummary(structure, description))
.build();
}
private String generateCodeDescription(String code, String language) {
// 调用 LLM 用自然语言描述代码做了什么
String prompt = String.format(
"用一两句话描述以下%s代码的功能,语言要简洁,适合朗读给听众:\n\n%s",
language, code.length() > 500 ? code.substring(0, 500) + "..." : code
);
return llmService.complete(prompt);
}
private String buildScreenReaderSummary(CodeStructure structure, String description) {
return String.format("这是一段%s代码,共%d行。%s。%s",
structure.getLanguage(),
structure.getLineCount(),
structure.hasComments() ? "代码包含注释" : "代码无注释",
description
);
}
}键盘导航的后端支持
无障碍用户大量使用键盘而不是鼠标。这对后端的影响是:API 要支持分步获取内容,而不是只有全量获取。
比如一个多轮对话,用户希望用 Tab 键在不同的对话轮次之间导航:
@RestController
@RequestMapping("/api/chat/accessible")
public class AccessibleChatController {
@Autowired
private ConversationService conversationService;
/**
* 获取对话概要列表(适合键盘导航的列表视图)
*/
@GetMapping("/sessions/{sessionId}/summary")
public ConversationSummary getConversationSummary(
@PathVariable String sessionId,
@RequestHeader("X-User-Id") String userId) {
List<MessageSummary> messages = conversationService.getMessages(sessionId, userId)
.stream()
.map(msg -> MessageSummary.builder()
.messageId(msg.getId())
.role(msg.getRole())
.shortPreview(msg.getContent().length() > 100
? msg.getContent().substring(0, 100) + "..."
: msg.getContent())
.contentLength(msg.getContent().length())
.hasCode(msg.getContent().contains("```"))
.hasTable(msg.getContent().contains("|"))
.timestamp(msg.getCreatedAt())
.build())
.collect(Collectors.toList());
return ConversationSummary.builder()
.sessionId(sessionId)
.totalMessages(messages.size())
.messages(messages)
.ariaLabel(String.format("对话包含%d条消息", messages.size()))
.build();
}
/**
* 获取单条消息的完整无障碍版本
*/
@GetMapping("/messages/{messageId}/accessible")
public AccessibleMessageContent getAccessibleMessage(
@PathVariable String messageId,
@RequestHeader("X-User-Id") String userId,
@RequestParam(defaultValue = "full") String mode) {
// mode: full(完整内容)/ summary(摘要)/ plain(纯文本)
Message msg = conversationService.getMessage(messageId, userId);
return switch (mode) {
case "summary" -> buildSummaryContent(msg);
case "plain" -> buildPlainTextContent(msg);
default -> buildFullAccessibleContent(msg);
};
}
}语音输入的支持
很多视障用户不用键盘而是用语音输入。这对后端的意义是:
- 语音输入的文字可能有转录错误,需要更好的意图识别容错
- 语音输入不会有标点,需要服务端做句子边界检测
- 某些语音输入法会把同音词转录错,需要语义级别的纠错
@Service
public class VoiceInputPreprocessor {
@Autowired
private LLMService llmService;
/**
* 对语音转文字的输入做预处理
*/
public ProcessedVoiceInput preprocess(String rawVoiceText, String context) {
// 检测是否像语音输入(没有标点、口语化)
boolean looksLikeVoice = isLikelyVoiceInput(rawVoiceText);
if (!looksLikeVoice) {
return ProcessedVoiceInput.noChange(rawVoiceText);
}
// 用 LLM 做语义纠错和句子规范化
String corrected = correctVoiceInput(rawVoiceText, context);
return ProcessedVoiceInput.builder()
.original(rawVoiceText)
.processed(corrected)
.wasModified(!corrected.equals(rawVoiceText))
.confidence(calculateCorrectionConfidence(rawVoiceText, corrected))
.build();
}
private boolean isLikelyVoiceInput(String text) {
// 没有标点的长文本很可能是语音输入
long punctuationCount = text.chars()
.filter(c -> ",。!?,./!?".indexOf(c) >= 0)
.count();
double punctuationRate = (double) punctuationCount / text.length();
return text.length() > 20 && punctuationRate < 0.03;
}
private String correctVoiceInput(String text, String context) {
String prompt = String.format("""
以下是语音输入的文字(可能有转录错误和口语化表达),请在保持原意的前提下:
1. 修正明显的语音识别错误
2. 补充必要的标点符号
3. 不要改变意思,尽量少改
上下文:%s
原文:%s
只输出修正后的文字,不要解释:
""", context != null ? context : "无", text);
return llmService.complete(prompt);
}
}颜色对比度和视觉辅助
虽然这主要是前端的工作,但后端可以提供配置支持:
@Service
public class AccessibilityPreferenceService {
@Autowired
private UserPreferenceRepository preferenceRepo;
/**
* 获取用户的无障碍偏好设置
*/
public AccessibilityPreference getUserPreferences(String userId) {
return preferenceRepo.findByUserId(userId)
.map(p -> p.getAccessibilityPreferences())
.orElse(AccessibilityPreference.defaults());
}
@Data
@Builder
public static class AccessibilityPreference {
private boolean highContrast; // 高对比度模式
private boolean reducedMotion; // 减少动画(包括打字机效果)
private boolean largerText; // 大字体
private boolean screenReaderMode; // 屏幕阅读器模式(按句发送而非逐字)
private boolean voiceInputMode; // 语音输入优化模式
private String preferredLanguage; // 首选语言(影响TTS发音)
public static AccessibilityPreference defaults() {
return AccessibilityPreference.builder()
.highContrast(false)
.reducedMotion(false)
.largerText(false)
.screenReaderMode(false)
.voiceInputMode(false)
.preferredLanguage("zh-CN")
.build();
}
}
}当 screenReaderMode 为 true 时,流式接口改为按句子边界发送而不是逐 token 发送:
@Service
public class AdaptiveStreamService {
@Autowired
private AccessibilityPreferenceService prefService;
@Autowired
private AccessibleStreamSplitter accessibleSplitter;
public void stream(String userId, String query, SseEmitter emitter) {
AccessibilityPreference prefs = prefService.getUserPreferences(userId);
if (prefs.isScreenReaderMode() || prefs.isReducedMotion()) {
// 按语义单元发送
streamBySentences(query, emitter);
} else {
// 标准流式输出
streamByTokens(query, emitter);
}
}
}如何验证无障碍设计是否到位
最重要的一条:必须找真实的视障用户来测试,不能只靠工程师自己模拟。我们自己用眼睛闭着测试,和真正依赖屏幕阅读器的用户体验是完全不同的。
一些真实的踩坑
坑1:我们早期的流式输出,每个 token 都触发一次 DOM 更新,屏幕阅读器(VoiceOver)在不停地播报新内容,完全无法听清楚。修复方式是加 aria-live="polite" 延迟,等一句话完整了再播报。
坑2:代码块里我们用了 aria-hidden="true" 把代码隐藏掉(为了避免屏幕阅读器朗读代码),但忘记提供替代说明,结果视障用户完全不知道回复里有代码。后来加了"这里有一段代码,可以按回车进入代码区域"的提示。
坑3:我们的"复制"按钮只有图标没有文字标签,屏幕阅读器读到的是"按钮",不知道是干什么的。简单加了 aria-label="复制代码" 就解决了,但这种细节需要专门review。
无障碍设计不是可选项,是工程师的责任。而且做好了,往往会让所有用户的体验都变好——分句发送的响应,比一股脑的逐字输出,其实普通用户也更容易跟上。
