1. 为什么无障碍适配值得每个iOS开发者认真对待很多iOS开发者第一次接触Accessibility(无障碍)这个概念,是在App Store审核被拒的时候——审核团队反馈说VoiceOver用户无法完成某个核心流程。这时候才手忙脚乱地去查文档、加label,然后发现整个页面的无障碍逻辑几乎是从零开始补。我自己第一次遇到这个问题是在一个内容列表页上,VoiceOver读出来的内容完全乱套,焦点顺序跟视觉顺序对不上,用户根本没法正常浏览。Accessibility在iOS开发里不是一个可选项,它是App质量的一部分。全球有超过十亿人存在不同程度的残障,其中视觉障碍、运动障碍、听觉障碍用户都在使用iPhone和iPad。iOS系统内置了VoiceOver(屏幕朗读)、Voice Control(语音控制)、Switch Control(开关控制)、AssistiveTouch(辅助触控)等一整套辅助功能。如果你的App没有做适配,这些用户打开你的App就是一片混乱——按钮读不出来、图片没有描述、焦点乱跳、自定义手势完全不可用。Swift 5在这个领域带来的变化不算翻天覆地,但几个关键特性让无障碍代码写起来更舒服了。@dynamicMemberLookup让DSL风格的属性配置更自然,Result类型让错误处理更清晰,而@unknown default在枚举的switch中强制你考虑未来可能新增的case——这在处理系统辅助功能通知时特别有用。更重要的是,Swift 5的ABI稳定意味着你可以更放心地使用系统框架提供的无障碍API,不用担心运行时兼容性问题。这篇文章面向的是已经会写iOS App、但对Accessibility还比较陌生的开发者。我会从最基础的概念讲起,一直讲到复杂自定义控件的无障碍适配。每一段代码你都可以直接复制到项目里跑,每一个坑我都踩过。读完你至少能做到:知道什么时候该加什么无障碍属性、怎么测试、怎么排查常见问题。注意:Accessibility的适配不是一次性工作,它应该像写单元测试一样融入日常开发流程。每写一个新页面,顺手把label和trait加上,比事后补要轻松十倍。2. 先把这几个核心概念吃透,后面才不会晕2.1 Accessibility Element:VoiceOver眼里的“可交互单元”VoiceOver不是像人眼那样“看”屏幕的。它把界面拆解成一个个Accessibility Element(无障碍元素),然后按顺序朗读这些元素。一个UIView默认不一定是无障碍元素,但UILabel、UIButton、UIImageView(有accessibilityLabel时)这些系统控件通常会自动成为无障碍元素。关键点在于:不是所有视图都需要成为无障碍元素。比如一个纯装饰性的分割线、一个背景渐变层,它们不应该出现在VoiceOver的焦点序列里。你需要做的是把有意义的内容暴露出去,把无意义的装饰隐藏掉。// 隐藏装饰性视图 decorationView.isAccessibilityElement = false // 让一个容器视图成为无障碍元素 containerView.isAccessibilityElement = true containerView.accessibilityLabel = "用户信息卡片"这里有个容易搞混的地方:isAccessibilityElement = true的视图,它的子视图默认就不会被VoiceOver单独聚焦了。这跟accessibilityElementsHidden不一样,后者是隐藏整个子树但不影响自身。2.2 Accessibility Label / Value / Hint / Trait:四件套各管什么这四个属性是Accessibility适配的核心工具,但很多人分不清它们各自的职责。我用一个生活化的类比来解释:Label:这是什么?比如“提交按钮”、“用户头像”。Value:它的当前值是什么?比如滑块当前是“50%”,开关当前是“开启”。Hint:对它操作会发生什么?比如“双击提交表单”、“向上滑动增加音量”。Trait:它是什么类型?比如按钮、标题、图片、选中的标签页。submitButton.accessibilityLabel = "提交" submitButton.accessibilityHint = "双击提交表单" submitButton.accessibilityTraits = .button volumeSlider.accessibilityLabel = "音量" volumeSlider.accessibilityValue = "\(Int(volume))%" volumeSlider.accessibilityTraits = .adjustableTrait特别重要,因为它决定了VoiceOver如何与元素交互。.adjustable的滑块可以用上下滑动来调整值,.button会提示用户双击激活,.header会让VoiceOver在快速导航时把该元素作为标题跳转点。2.3 Accessibility Container:自定义视图的焦点管理当你写了一个完全自定义的控件,比如一个用UICollectionView实现的标签选择器,或者一个用CAShapeLayer画的图表,系统不知道该怎么处理它的无障碍焦点。这时候你需要实现UIAccessibilityContainer协议,手动管理子元素的焦点顺序。class CustomChartView: UIView, UIAccessibilityContainer { private var accessibilityElementsArray: [Any] = [] override var isAccessibilityElement: Bool { get { return false } set { } } func accessibilityElementCount() -> Int { return accessibilityElementsArray.count } func accessibilityElement(at index: Int) -> Any? { guard index < accessibilityElementsArray.count else { return nil } return accessibilityElementsArray[index] } func index(ofAccessibilityElement element: Any) -> Int { return accessibilityElementsArray.firstIndex(where: { $0 as AnyObject === element as AnyObject }) ?? NSNotFound } }这个协议在Swift 5里的写法跟Objective-C时代略有不同,主要是NSNotFound的处理和可选值的返回方式。实际项目中,更常见的做法是直接用UIAccessibilityElement类来创建虚拟元素,然后塞进数组里。2.4 通知机制:界面变化时怎么告诉VoiceOver当界面发生动态变化时——比如加载完成、弹窗出现、列表刷新——你需要主动发通知给VoiceOver,否则它可能还在读旧内容。// 页面布局完成 UIAccessibility.post(notification: .screenChanged, argument: titleLabel) // 局部刷新 UIAccessibility.post(notification: .layoutChanged, argument: nil) // 弹窗出现 UIAccessibility.post(notification: .announcement, argument: "加载完成").screenChanged会强制VoiceOver重新扫描整个屏幕,.layoutChanged只更新布局变化的部分。.announcement则是直接朗读一段文字,不改变焦点。用错了通知类型会导致焦点乱跳或者朗读中断,这个后面会详细讲。3. 从零开始:给一个真实页面加上完整的无障碍支持3.1 场景设定:一个商品详情页假设我们有一个电商App的商品详情页,结构大概是:顶部商品图片轮播、商品标题、价格、规格选择按钮、加入购物车按钮、用户评价列表。这个页面在没做适配之前,VoiceOver用户会遇到什么问题?图片轮播:VoiceOver会逐个读图片的文件名或者什么都不读价格:如果价格是用多个Label拼出来的,会读成“¥”“1”“2”“9”“9”这样规格按钮:如果用的是自定义View,可能完全无法聚焦评价列表:如果Cell里嵌套了复杂布局,焦点顺序可能完全错乱3.2 图片轮播的适配:UIPageViewController + UIAccessibility轮播图通常用UIPageViewController或者UICollectionView实现。核心问题是:VoiceOver用户怎么知道当前是第几张图、怎么切换?class ProductImageCell: UICollectionViewCell { @IBOutlet weak var imageView: UIImageView! func configure(with image: UIImage, index: Int, total: Int) { imageView.image = image isAccessibilityElement = true accessibilityLabel = "商品图片,第\(index + 1)张,共\(total)张" accessibilityTraits = .image accessibilityHint = "左右滑动切换图片" } }对于轮播容器本身,你需要设置accessibilityContainerType为.semanticGroup(iOS 13+),这样VoiceOver会把整个轮播当作一个组来处理,而不是逐个聚焦每张图片。if #available(iOS 13.0, *) { carouselView.accessibilityContainerType = .semanticGroup }实测下来,.semanticGroup在iOS 13和14上的行为略有差异。iOS 13上它会把子元素合并成一个焦点,iOS 14上则允许用户进入组内逐个浏览。如果你的App要支持iOS 13,建议在组级别加一个accessibilityLabel说明这是图片轮播,然后让子图片各自可聚焦。3.3 价格拼接的坑:如何让VoiceOver读出一个完整的金额很多设计师喜欢把价格拆成“¥”、“129”、“99”三个Label,视觉上确实好看,但VoiceOver会读成“人民币符号,一百二十九,九十九”,完全不知所云。解决方案有两种:方案一:合并成一个Label。用NSAttributedString在一个Label里实现不同字号和颜色。let priceString = NSMutableAttributedString(string: "¥", attributes: [ .font: UIFont.systemFont(ofSize: 14) ]) priceString.append(NSAttributedString(string: "129", attributes: [ .font: UIFont.boldSystemFont(ofSize: 24) ])) priceString.append(NSAttributedString(string: ".99", attributes: [ .font: UIFont.systemFont(ofSize: 16) ])) priceLabel.attributedText = priceString priceLabel.accessibilityLabel = "价格129.99元"方案二:保留多个Label,但把父容器设为无障碍元素。priceContainer.isAccessibilityElement = true priceContainer.accessibilityLabel = "价格129.99元" priceContainer.accessibilityTraits = .staticText方案二更灵活,但要注意把子Label的isAccessibilityElement设为false,否则VoiceOver还是会读到它们。3.4 规格选择按钮:自定义控件的Trait与状态管理规格按钮通常是这样的:一个圆角矩形,选中时边框变色,未选中时灰色。用UIButton可以轻松实现,但如果你用的是自定义View,就需要手动管理Trait。class SpecButton: UIView { var isSelected: Bool = false { didSet { updateAccessibility() } } private func updateAccessibility() { isAccessibilityElement = true accessibilityLabel = titleLabel.text accessibilityTraits = isSelected ? [.button, .selected] : .button accessibilityHint = isSelected ? "已选中,双击取消" : "双击选中" } override func accessibilityActivate() -> Bool { isSelected.toggle() sendActions(for: .touchUpInside) return true } }.selected这个Trait会让VoiceOver在朗读时加上“已选中”的提示。accessibilityActivate()是双击激活的回调,必须返回true表示已处理。注意:如果你重写了accessibilityActivate()但没有调用sendActions,业务逻辑可能不会触发。我见过有开发者只改了UI状态但忘了发事件,结果VoiceOver用户双击后界面变了但数据没更新。3.5 评价列表:UITableViewCell的焦点顺序优化评价列表的每个Cell通常包含:头像、用户名、评分星星、评价内容、时间。默认情况下,VoiceOver会按子视图的添加顺序逐个聚焦,这往往跟视觉顺序不一致。class ReviewCell: UITableViewCell { @IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var ratingView: StarRatingView! @IBOutlet weak var contentLabel: UILabel! @IBOutlet weak var timeLabel: UILabel! override func awakeFromNib() { super.awakeFromNib() setupAccessibility() } private func setupAccessibility() { // 头像不单独聚焦,作为装饰 avatarImageView.isAccessibilityElement = false // 用户名 usernameLabel.isAccessibilityElement = true usernameLabel.accessibilityTraits = .header // 评分 ratingView.isAccessibilityElement = true ratingView.accessibilityLabel = "评分" // 内容 contentLabel.isAccessibilityElement = true // 时间 timeLabel.isAccessibilityElement = true // 设置焦点顺序 accessibilityElements = [usernameLabel, ratingView, contentLabel, timeLabel] } }accessibilityElements数组的顺序就是VoiceOver的聚焦顺序。这个属性在Swift 5里是[Any]?类型,你可以放任何NSObject子类。3.6 加入购物车按钮:动态状态与反馈加入购物车按钮在点击后通常会变成“已加入”或者显示一个加载状态。这时候需要更新无障碍信息并发送通知。@IBAction func addToCartTapped(_ sender: UIButton) { sender.isEnabled = false sender.accessibilityLabel = "正在加入购物车" CartService.add(product: product) { [weak self] result in DispatchQueue.main.async { sender.isEnabled = true switch result { case .success: sender.accessibilityLabel = "已加入购物车" UIAccessibility.post(notification: .announcement, argument: "已成功加入购物车") case .failure(let error): sender.accessibilityLabel = "加入购物车失败" UIAccessibility.post(notification: .announcement, argument: "加入失败:\(error.localizedDescription)") } } } }这里用.announcement而不是.layoutChanged,因为按钮本身没有移动或消失,只是状态变了。.announcement会直接朗读提示,不会打断用户当前的焦点。4. 进阶实战:复杂自定义控件的无障碍适配4.1 用UIAccessibilityElement构建虚拟焦点有些控件在视觉上是一个整体,但逻辑上包含多个可交互区域。比如一个用UIBezierPath画的饼图,每个扇区都可以点击查看详情。这种场景下,你不能把每个扇区做成UIView,但可以用UIAccessibilityElement创建虚拟焦点。class PieChartView: UIView { private var slices: [PieSlice] = [] private var accessibilityElementsArray: [UIAccessibilityElement] = [] func reloadData(slices: [PieSlice]) { self.slices = slices rebuildAccessibilityElements() } private func rebuildAccessibilityElements() { accessibilityElementsArray = slices.enumerated().map { index, slice in let element = UIAccessibilityElement(accessibilityContainer: self) element.accessibilityLabel = slice.title element.accessibilityValue = "\(Int(slice.percentage * 100))%" element.accessibilityTraits = .button element.accessibilityHint = "双击查看详情" // 计算扇区的屏幕坐标 let frame = frameForSlice(at: index) element.accessibilityFrameInContainerSpace = frame return element } } override func accessibilityElementCount() -> Int { return accessibilityElementsArray.count } override func accessibilityElement(at index: Int) -> Any? { guard index < accessibilityElementsArray.count else { return nil } return accessibilityElementsArray[index] } override func index(ofAccessibilityElement element: Any) -> Int { return accessibilityElementsArray.firstIndex(where: { $0 === element as? UIAccessibilityElement }) ?? NSNotFound } }accessibilityFrameInContainerSpace是iOS 11引入的,它让你用容器坐标系的frame来定位虚拟元素。在这之前,你需要用accessibilityFrame配合UIAccessibility.convertToScreenCoordinates来转换,麻烦很多。4.2 手势冲突:当VoiceOver遇上自定义手势VoiceOver开启后,很多自定义手势会失效。比如你给一个View加了UIPanGestureRecognizer来实现拖拽排序,VoiceOver用户用三指滑动来滚动页面,根本触发不了你的手势。解决方案是使用UIAccessibilityCustomAction。它会在VoiceOver的转子菜单里添加一个自定义操作,用户可以通过上下滑动选择“排序”操作,然后双击激活。class SortableItemView: UIView { override init(frame: CGRect) { super.init(frame: frame) setupCustomActions() } private func setupCustomActions() { let moveUpAction = UIAccessibilityCustomAction( name: "上移", target: self, selector: #selector(moveUp) ) let moveDownAction = UIAccessibilityCustomAction( name: "下移", target: self, selector: #selector(moveDown) ) accessibilityCustomActions = [moveUpAction, moveDownAction] } @objc private func moveUp() -> Bool { // 执行上移逻辑 return true } @objc private func moveDown() -> Bool { // 执行下移逻辑 return true } }UIAccessibilityCustomAction的selector必须返回Bool,表示操作是否成功。如果返回false,VoiceOver会播放一个错误提示音。4.3 模态弹窗的焦点陷阱模态弹窗出现时,VoiceOver的焦点应该被限制在弹窗内部,不能跑到后面的页面上。这个行为叫“焦点陷阱”(Focus Trap)。class ModalViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // 保存之前的焦点 previousFocusedElement = UIAccessibility.focusedElement(using: .voiceOver) // 把焦点移到弹窗标题 UIAccessibility.post(notification: .screenChanged, argument: titleLabel) // 设置焦点陷阱 view.accessibilityViewIsModal = true } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 恢复之前的焦点 UIAccessibility.post(notification: .screenChanged, argument: previousFocusedElement) } }accessibilityViewIsModal = true会让VoiceOver忽略该View同级及以下的所有元素。注意是同级,不是子级。所以如果你的弹窗是加在UIWindow上的,需要把window.accessibilityViewIsModal设为true。4.4 动态字体与无障碍的配合Dynamic Type(动态字体)和VoiceOver是两套独立的辅助功能,但它们经常一起使用。低视力用户可能同时开启大字体和VoiceOver。// 监听字体变化 NotificationCenter.default.addObserver( self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil ) @objc private func contentSizeCategoryDidChange() { // 更新布局 tableView.reloadData() // 通知VoiceOver布局变化 UIAccessibility.post(notification: .layoutChanged, argument: nil) }在UITableViewCell里,如果字体变大导致内容被截断,VoiceOver读出来的内容也会不完整。所以numberOfLines = 0和preferredMaxLayoutWidth这些设置要配合好。5. 测试与排查:怎么知道你的适配到底行不行5.1 Accessibility Inspector:最直接的调试工具Xcode自带的Accessibility Inspector是排查无障碍问题的首选工具。打开方式:Xcode → Open Developer Tool → Accessibility Inspector。它的核心功能:Inspection:鼠标悬停在模拟器或真机界面上,直接显示该元素的label、value、trait、frameAudit:自动扫描当前页面,列出所有潜在的无障碍问题(比如缺少label的按钮、对比度不足的文字)Settings:可以模拟不同的字体大小、颜色滤镜、减少动画等辅助功能设置我通常的工作流是:写完一个页面 → 打开Audit → 修复所有红色警告 → 手动用VoiceOver过一遍核心流程。5.2 VoiceOver真机测试的完整流程模拟器上的VoiceOver跟真机有差异,特别是手势操作和语音反馈的延迟。真机测试是必须的。开启VoiceOver:设置 → 辅助功能 → 旁白 → 打开。或者用辅助功能快捷键(连按三次侧边按钮)。核心手势:单指滑动:移动到下一个/上一个元素双击:激活当前元素三指滑动:滚动页面四指点击上半屏/下半屏:选择第一个/最后一个元素转子(两指旋转):切换导航模式(按标题、按链接、按表单等)测试时重点关注:焦点顺序是否跟视觉顺序一致每个可交互元素是否都有清晰的label和hint动态内容变化后焦点是否合理模态弹窗是否限制了焦点范围自定义手势是否有替代的无障碍操作5.3 常见问题排查表问题现象可能原因排查方法解决方案VoiceOver读不出某个按钮isAccessibilityElement为false用Accessibility Inspector检查设为true并添加label焦点顺序错乱子视图添加顺序与视觉顺序不一致逐个聚焦观察顺序设置accessibilityElements数组图片没有描述accessibilityLabel为空Inspector显示label为空添加有意义的描述弹窗后焦点跑到背景未设置accessibilityViewIsModal弹窗后滑动焦点设置accessibilityViewIsModal = true自定义手势无法使用VoiceOver拦截了手势开启VoiceOver后测试添加UIAccessibilityCustomAction动态内容更新后朗读旧内容未发送通知观察更新后焦点发送.layoutChanged或.announcement价格读成多个数字多个Label拼接Inspector查看元素数量合并Label或设置容器元素5.4 自动化测试:把无障碍检查加入CIXCTest提供了无障碍相关的API,可以在UI测试中检查元素的无障碍属性。func testAccessibilityLabels() { let app = XCUIApplication() app.launch() let submitButton = app.buttons["submitButton"] XCTAssertTrue(submitButton.exists) XCTAssertEqual(submitButton.label, "提交") XCTAssertTrue(submitButton.isEnabled) // 检查图片是否有label let productImage = app.images["productImage"] XCTAssertFalse(productImage.label.isEmpty) }更进一步的,你可以用XCUIElement的accessibilityTraits属性来验证Trait是否正确。不过XCTest对无障碍的支持有限,复杂的焦点顺序和自定义操作还是需要手动测试。6. 几个让我踩过坑的细节6.1 accessibilityLabel不要写“按钮”两个字VoiceOver会自动根据Trait朗读“按钮”、“图片”、“文本字段”这些类型。如果你在label里写了“提交按钮”,VoiceOver会读成“提交按钮,按钮”,重复且冗余。正确做法:label只写内容,Trait负责类型。// 错误 button.accessibilityLabel = "提交按钮" // 正确 button.accessibilityLabel = "提交" button.accessibilityTraits = .button6.2 隐藏元素的正确姿势isHidden = true和alpha = 0都会让元素从无障碍树中消失,但isAccessibilityElement = false只是让元素本身不被聚焦,子元素仍然可能被聚焦。如果你想让整个子树都从无障碍树中消失,用accessibilityElementsHidden = true。// 只隐藏自己 view.isAccessibilityElement = false // 隐藏自己和所有子元素 view.accessibilityElementsHidden = true // 隐藏自己但保留子元素 view.isAccessibilityElement = false view.accessibilityElementsHidden = false6.3 通知发送的时机很关键UIAccessibility.post(notification:argument:)如果在viewDidLoad里调用,可能不会生效,因为此时视图还没有出现在屏幕上。最佳时机是viewDidAppear或者布局完成后的回调里。另外,.screenChanged的argument参数应该传一个可以聚焦的元素,而不是nil。传nil会导致VoiceOver把焦点重置到屏幕左上角。override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UIAccessibility.post(notification: .screenChanged, argument: firstResponderElement) }6.4 自定义字体对VoiceOver的影响如果你用了自定义字体,VoiceOver的朗读不受影响,但Dynamic Type的缩放会失效。因为自定义字体不会自动响应UIContentSizeCategory的变化。解决方案是用UIFontMetrics来缩放自定义字体:let customFont = UIFont(name: "CustomFont", size: 17)! let scaledFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont) label.font = scaledFont label.adjustsFontForContentSizeCategory = true6.5 SwiftUI与UIKit的无障碍差异虽然这篇文章主要讲UIKit,但如果你在用SwiftUI,几个关键差异需要知道:SwiftUI的.accessibilityLabel()是View Modifier,不是属性.accessibilityElement(children: .combine)可以合并子元素.accessibilityAddTraits()添加TraitSwiftUI的焦点管理用@AccessibilityFocusState// SwiftUI示例 VStack { Text("商品名称") Text("¥129.99") } .accessibilityElement(children: .combine) .accessibilityLabel("商品名称,价格129.99元")SwiftUI在无障碍方面做了很多自动化的工作,但复杂场景下还是需要手动干预。如果你的项目是UIKit和SwiftUI混用,注意两套系统的无障碍树是打通的,但焦点顺序可能需要额外调整。7. 把无障碍适配变成开发习惯我现在的做法是:每创建一个新的ViewController,先花五分钟把基本的无障碍属性加上。具体清单:所有UILabel检查是否需要accessibilityLabel(如果文本本身已经清晰,通常不需要额外设置)所有UIButton检查label是否明确所有UIImageView检查是否有描述,装饰性图片设为isAccessibilityElement = false自定义控件检查Trait和accessibilityActivate()列表页面检查accessibilityElements顺序模态弹窗检查accessibilityViewIsModal动态内容检查通知发送这七个检查点花不了多少时间,但能避免90%的常见问题。剩下的10%——复杂自定义控件、手势冲突、焦点陷阱——需要根据具体情况单独处理。最后分享一个我常用的调试技巧:在开发阶段,给所有需要无障碍适配的视图加一个红色边框,这样一眼就能看出哪些元素被遗漏了。#if DEBUG extension UIView { func debugAccessibility() { if isAccessibilityElement { layer.borderColor = UIColor.red.cgColor layer.borderWidth = 1 } } } #endif在viewDidAppear里遍历所有子视图调用这个方法,就能快速定位问题。上线前记得把这段代码去掉或者用#if DEBUG包起来。Accessibility适配这件事,做一次可能觉得麻烦,但形成习惯之后就是顺手的事。而且它带来的收益是实实在在的——你的App能被更多人使用,App Store审核少一个被拒的理由,代码质量也更高。我见过太多团队在发版前一周才开始补无障碍,结果手忙脚乱还漏掉关键流程。不如从现在开始,每写一个页面就顺手做了。
1. 为什么无障碍适配值得每个iOS开发者认真对待很多iOS开发者第一次接触Accessibility(无障碍)这个概念,是在App Store审核被拒的时候——审核团队反馈说VoiceOver用户无法完成某个核心流程。这时候才手忙脚乱地去查文档、加label,然后发现整个页面的无障碍逻辑几乎是从零开始补。我自己第一次遇到这个问题是在一个内容列表页上,VoiceOver读出来的内容完全乱套,焦点顺序跟视觉顺序对不上,用户根本没法正常浏览。Accessibility在iOS开发里不是一个可选项,它是App质量的一部分。全球有超过十亿人存在不同程度的残障,其中视觉障碍、运动障碍、听觉障碍用户都在使用iPhone和iPad。iOS系统内置了VoiceOver(屏幕朗读)、Voice Control(语音控制)、Switch Control(开关控制)、AssistiveTouch(辅助触控)等一整套辅助功能。如果你的App没有做适配,这些用户打开你的App就是一片混乱——按钮读不出来、图片没有描述、焦点乱跳、自定义手势完全不可用。Swift 5在这个领域带来的变化不算翻天覆地,但几个关键特性让无障碍代码写起来更舒服了。@dynamicMemberLookup让DSL风格的属性配置更自然,Result类型让错误处理更清晰,而@unknown default在枚举的switch中强制你考虑未来可能新增的case——这在处理系统辅助功能通知时特别有用。更重要的是,Swift 5的ABI稳定意味着你可以更放心地使用系统框架提供的无障碍API,不用担心运行时兼容性问题。这篇文章面向的是已经会写iOS App、但对Accessibility还比较陌生的开发者。我会从最基础的概念讲起,一直讲到复杂自定义控件的无障碍适配。每一段代码你都可以直接复制到项目里跑,每一个坑我都踩过。读完你至少能做到:知道什么时候该加什么无障碍属性、怎么测试、怎么排查常见问题。注意:Accessibility的适配不是一次性工作,它应该像写单元测试一样融入日常开发流程。每写一个新页面,顺手把label和trait加上,比事后补要轻松十倍。2. 先把这几个核心概念吃透,后面才不会晕2.1 Accessibility Element:VoiceOver眼里的“可交互单元”VoiceOver不是像人眼那样“看”屏幕的。它把界面拆解成一个个Accessibility Element(无障碍元素),然后按顺序朗读这些元素。一个UIView默认不一定是无障碍元素,但UILabel、UIButton、UIImageView(有accessibilityLabel时)这些系统控件通常会自动成为无障碍元素。关键点在于:不是所有视图都需要成为无障碍元素。比如一个纯装饰性的分割线、一个背景渐变层,它们不应该出现在VoiceOver的焦点序列里。你需要做的是把有意义的内容暴露出去,把无意义的装饰隐藏掉。// 隐藏装饰性视图 decorationView.isAccessibilityElement = false // 让一个容器视图成为无障碍元素 containerView.isAccessibilityElement = true containerView.accessibilityLabel = "用户信息卡片"这里有个容易搞混的地方:isAccessibilityElement = true的视图,它的子视图默认就不会被VoiceOver单独聚焦了。这跟accessibilityElementsHidden不一样,后者是隐藏整个子树但不影响自身。2.2 Accessibility Label / Value / Hint / Trait:四件套各管什么这四个属性是Accessibility适配的核心工具,但很多人分不清它们各自的职责。我用一个生活化的类比来解释:Label:这是什么?比如“提交按钮”、“用户头像”。Value:它的当前值是什么?比如滑块当前是“50%”,开关当前是“开启”。Hint:对它操作会发生什么?比如“双击提交表单”、“向上滑动增加音量”。Trait:它是什么类型?比如按钮、标题、图片、选中的标签页。submitButton.accessibilityLabel = "提交" submitButton.accessibilityHint = "双击提交表单" submitButton.accessibilityTraits = .button volumeSlider.accessibilityLabel = "音量" volumeSlider.accessibilityValue = "\(Int(volume))%" volumeSlider.accessibilityTraits = .adjustableTrait特别重要,因为它决定了VoiceOver如何与元素交互。.adjustable的滑块可以用上下滑动来调整值,.button会提示用户双击激活,.header会让VoiceOver在快速导航时把该元素作为标题跳转点。2.3 Accessibility Container:自定义视图的焦点管理当你写了一个完全自定义的控件,比如一个用UICollectionView实现的标签选择器,或者一个用CAShapeLayer画的图表,系统不知道该怎么处理它的无障碍焦点。这时候你需要实现UIAccessibilityContainer协议,手动管理子元素的焦点顺序。class CustomChartView: UIView, UIAccessibilityContainer { private var accessibilityElementsArray: [Any] = [] override var isAccessibilityElement: Bool { get { return false } set { } } func accessibilityElementCount() -> Int { return accessibilityElementsArray.count } func accessibilityElement(at index: Int) -> Any? { guard index < accessibilityElementsArray.count else { return nil } return accessibilityElementsArray[index] } func index(ofAccessibilityElement element: Any) -> Int { return accessibilityElementsArray.firstIndex(where: { $0 as AnyObject === element as AnyObject }) ?? NSNotFound } }这个协议在Swift 5里的写法跟Objective-C时代略有不同,主要是NSNotFound的处理和可选值的返回方式。实际项目中,更常见的做法是直接用UIAccessibilityElement类来创建虚拟元素,然后塞进数组里。2.4 通知机制:界面变化时怎么告诉VoiceOver当界面发生动态变化时——比如加载完成、弹窗出现、列表刷新——你需要主动发通知给VoiceOver,否则它可能还在读旧内容。// 页面布局完成 UIAccessibility.post(notification: .screenChanged, argument: titleLabel) // 局部刷新 UIAccessibility.post(notification: .layoutChanged, argument: nil) // 弹窗出现 UIAccessibility.post(notification: .announcement, argument: "加载完成").screenChanged会强制VoiceOver重新扫描整个屏幕,.layoutChanged只更新布局变化的部分。.announcement则是直接朗读一段文字,不改变焦点。用错了通知类型会导致焦点乱跳或者朗读中断,这个后面会详细讲。3. 从零开始:给一个真实页面加上完整的无障碍支持3.1 场景设定:一个商品详情页假设我们有一个电商App的商品详情页,结构大概是:顶部商品图片轮播、商品标题、价格、规格选择按钮、加入购物车按钮、用户评价列表。这个页面在没做适配之前,VoiceOver用户会遇到什么问题?图片轮播:VoiceOver会逐个读图片的文件名或者什么都不读价格:如果价格是用多个Label拼出来的,会读成“¥”“1”“2”“9”“9”这样规格按钮:如果用的是自定义View,可能完全无法聚焦评价列表:如果Cell里嵌套了复杂布局,焦点顺序可能完全错乱3.2 图片轮播的适配:UIPageViewController + UIAccessibility轮播图通常用UIPageViewController或者UICollectionView实现。核心问题是:VoiceOver用户怎么知道当前是第几张图、怎么切换?class ProductImageCell: UICollectionViewCell { @IBOutlet weak var imageView: UIImageView! func configure(with image: UIImage, index: Int, total: Int) { imageView.image = image isAccessibilityElement = true accessibilityLabel = "商品图片,第\(index + 1)张,共\(total)张" accessibilityTraits = .image accessibilityHint = "左右滑动切换图片" } }对于轮播容器本身,你需要设置accessibilityContainerType为.semanticGroup(iOS 13+),这样VoiceOver会把整个轮播当作一个组来处理,而不是逐个聚焦每张图片。if #available(iOS 13.0, *) { carouselView.accessibilityContainerType = .semanticGroup }实测下来,.semanticGroup在iOS 13和14上的行为略有差异。iOS 13上它会把子元素合并成一个焦点,iOS 14上则允许用户进入组内逐个浏览。如果你的App要支持iOS 13,建议在组级别加一个accessibilityLabel说明这是图片轮播,然后让子图片各自可聚焦。3.3 价格拼接的坑:如何让VoiceOver读出一个完整的金额很多设计师喜欢把价格拆成“¥”、“129”、“99”三个Label,视觉上确实好看,但VoiceOver会读成“人民币符号,一百二十九,九十九”,完全不知所云。解决方案有两种:方案一:合并成一个Label。用NSAttributedString在一个Label里实现不同字号和颜色。let priceString = NSMutableAttributedString(string: "¥", attributes: [ .font: UIFont.systemFont(ofSize: 14) ]) priceString.append(NSAttributedString(string: "129", attributes: [ .font: UIFont.boldSystemFont(ofSize: 24) ])) priceString.append(NSAttributedString(string: ".99", attributes: [ .font: UIFont.systemFont(ofSize: 16) ])) priceLabel.attributedText = priceString priceLabel.accessibilityLabel = "价格129.99元"方案二:保留多个Label,但把父容器设为无障碍元素。priceContainer.isAccessibilityElement = true priceContainer.accessibilityLabel = "价格129.99元" priceContainer.accessibilityTraits = .staticText方案二更灵活,但要注意把子Label的isAccessibilityElement设为false,否则VoiceOver还是会读到它们。3.4 规格选择按钮:自定义控件的Trait与状态管理规格按钮通常是这样的:一个圆角矩形,选中时边框变色,未选中时灰色。用UIButton可以轻松实现,但如果你用的是自定义View,就需要手动管理Trait。class SpecButton: UIView { var isSelected: Bool = false { didSet { updateAccessibility() } } private func updateAccessibility() { isAccessibilityElement = true accessibilityLabel = titleLabel.text accessibilityTraits = isSelected ? [.button, .selected] : .button accessibilityHint = isSelected ? "已选中,双击取消" : "双击选中" } override func accessibilityActivate() -> Bool { isSelected.toggle() sendActions(for: .touchUpInside) return true } }.selected这个Trait会让VoiceOver在朗读时加上“已选中”的提示。accessibilityActivate()是双击激活的回调,必须返回true表示已处理。注意:如果你重写了accessibilityActivate()但没有调用sendActions,业务逻辑可能不会触发。我见过有开发者只改了UI状态但忘了发事件,结果VoiceOver用户双击后界面变了但数据没更新。3.5 评价列表:UITableViewCell的焦点顺序优化评价列表的每个Cell通常包含:头像、用户名、评分星星、评价内容、时间。默认情况下,VoiceOver会按子视图的添加顺序逐个聚焦,这往往跟视觉顺序不一致。class ReviewCell: UITableViewCell { @IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var ratingView: StarRatingView! @IBOutlet weak var contentLabel: UILabel! @IBOutlet weak var timeLabel: UILabel! override func awakeFromNib() { super.awakeFromNib() setupAccessibility() } private func setupAccessibility() { // 头像不单独聚焦,作为装饰 avatarImageView.isAccessibilityElement = false // 用户名 usernameLabel.isAccessibilityElement = true usernameLabel.accessibilityTraits = .header // 评分 ratingView.isAccessibilityElement = true ratingView.accessibilityLabel = "评分" // 内容 contentLabel.isAccessibilityElement = true // 时间 timeLabel.isAccessibilityElement = true // 设置焦点顺序 accessibilityElements = [usernameLabel, ratingView, contentLabel, timeLabel] } }accessibilityElements数组的顺序就是VoiceOver的聚焦顺序。这个属性在Swift 5里是[Any]?类型,你可以放任何NSObject子类。3.6 加入购物车按钮:动态状态与反馈加入购物车按钮在点击后通常会变成“已加入”或者显示一个加载状态。这时候需要更新无障碍信息并发送通知。@IBAction func addToCartTapped(_ sender: UIButton) { sender.isEnabled = false sender.accessibilityLabel = "正在加入购物车" CartService.add(product: product) { [weak self] result in DispatchQueue.main.async { sender.isEnabled = true switch result { case .success: sender.accessibilityLabel = "已加入购物车" UIAccessibility.post(notification: .announcement, argument: "已成功加入购物车") case .failure(let error): sender.accessibilityLabel = "加入购物车失败" UIAccessibility.post(notification: .announcement, argument: "加入失败:\(error.localizedDescription)") } } } }这里用.announcement而不是.layoutChanged,因为按钮本身没有移动或消失,只是状态变了。.announcement会直接朗读提示,不会打断用户当前的焦点。4. 进阶实战:复杂自定义控件的无障碍适配4.1 用UIAccessibilityElement构建虚拟焦点有些控件在视觉上是一个整体,但逻辑上包含多个可交互区域。比如一个用UIBezierPath画的饼图,每个扇区都可以点击查看详情。这种场景下,你不能把每个扇区做成UIView,但可以用UIAccessibilityElement创建虚拟焦点。class PieChartView: UIView { private var slices: [PieSlice] = [] private var accessibilityElementsArray: [UIAccessibilityElement] = [] func reloadData(slices: [PieSlice]) { self.slices = slices rebuildAccessibilityElements() } private func rebuildAccessibilityElements() { accessibilityElementsArray = slices.enumerated().map { index, slice in let element = UIAccessibilityElement(accessibilityContainer: self) element.accessibilityLabel = slice.title element.accessibilityValue = "\(Int(slice.percentage * 100))%" element.accessibilityTraits = .button element.accessibilityHint = "双击查看详情" // 计算扇区的屏幕坐标 let frame = frameForSlice(at: index) element.accessibilityFrameInContainerSpace = frame return element } } override func accessibilityElementCount() -> Int { return accessibilityElementsArray.count } override func accessibilityElement(at index: Int) -> Any? { guard index < accessibilityElementsArray.count else { return nil } return accessibilityElementsArray[index] } override func index(ofAccessibilityElement element: Any) -> Int { return accessibilityElementsArray.firstIndex(where: { $0 === element as? UIAccessibilityElement }) ?? NSNotFound } }accessibilityFrameInContainerSpace是iOS 11引入的,它让你用容器坐标系的frame来定位虚拟元素。在这之前,你需要用accessibilityFrame配合UIAccessibility.convertToScreenCoordinates来转换,麻烦很多。4.2 手势冲突:当VoiceOver遇上自定义手势VoiceOver开启后,很多自定义手势会失效。比如你给一个View加了UIPanGestureRecognizer来实现拖拽排序,VoiceOver用户用三指滑动来滚动页面,根本触发不了你的手势。解决方案是使用UIAccessibilityCustomAction。它会在VoiceOver的转子菜单里添加一个自定义操作,用户可以通过上下滑动选择“排序”操作,然后双击激活。class SortableItemView: UIView { override init(frame: CGRect) { super.init(frame: frame) setupCustomActions() } private func setupCustomActions() { let moveUpAction = UIAccessibilityCustomAction( name: "上移", target: self, selector: #selector(moveUp) ) let moveDownAction = UIAccessibilityCustomAction( name: "下移", target: self, selector: #selector(moveDown) ) accessibilityCustomActions = [moveUpAction, moveDownAction] } @objc private func moveUp() -> Bool { // 执行上移逻辑 return true } @objc private func moveDown() -> Bool { // 执行下移逻辑 return true } }UIAccessibilityCustomAction的selector必须返回Bool,表示操作是否成功。如果返回false,VoiceOver会播放一个错误提示音。4.3 模态弹窗的焦点陷阱模态弹窗出现时,VoiceOver的焦点应该被限制在弹窗内部,不能跑到后面的页面上。这个行为叫“焦点陷阱”(Focus Trap)。class ModalViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // 保存之前的焦点 previousFocusedElement = UIAccessibility.focusedElement(using: .voiceOver) // 把焦点移到弹窗标题 UIAccessibility.post(notification: .screenChanged, argument: titleLabel) // 设置焦点陷阱 view.accessibilityViewIsModal = true } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 恢复之前的焦点 UIAccessibility.post(notification: .screenChanged, argument: previousFocusedElement) } }accessibilityViewIsModal = true会让VoiceOver忽略该View同级及以下的所有元素。注意是同级,不是子级。所以如果你的弹窗是加在UIWindow上的,需要把window.accessibilityViewIsModal设为true。4.4 动态字体与无障碍的配合Dynamic Type(动态字体)和VoiceOver是两套独立的辅助功能,但它们经常一起使用。低视力用户可能同时开启大字体和VoiceOver。// 监听字体变化 NotificationCenter.default.addObserver( self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil ) @objc private func contentSizeCategoryDidChange() { // 更新布局 tableView.reloadData() // 通知VoiceOver布局变化 UIAccessibility.post(notification: .layoutChanged, argument: nil) }在UITableViewCell里,如果字体变大导致内容被截断,VoiceOver读出来的内容也会不完整。所以numberOfLines = 0和preferredMaxLayoutWidth这些设置要配合好。5. 测试与排查:怎么知道你的适配到底行不行5.1 Accessibility Inspector:最直接的调试工具Xcode自带的Accessibility Inspector是排查无障碍问题的首选工具。打开方式:Xcode → Open Developer Tool → Accessibility Inspector。它的核心功能:Inspection:鼠标悬停在模拟器或真机界面上,直接显示该元素的label、value、trait、frameAudit:自动扫描当前页面,列出所有潜在的无障碍问题(比如缺少label的按钮、对比度不足的文字)Settings:可以模拟不同的字体大小、颜色滤镜、减少动画等辅助功能设置我通常的工作流是:写完一个页面 → 打开Audit → 修复所有红色警告 → 手动用VoiceOver过一遍核心流程。5.2 VoiceOver真机测试的完整流程模拟器上的VoiceOver跟真机有差异,特别是手势操作和语音反馈的延迟。真机测试是必须的。开启VoiceOver:设置 → 辅助功能 → 旁白 → 打开。或者用辅助功能快捷键(连按三次侧边按钮)。核心手势:单指滑动:移动到下一个/上一个元素双击:激活当前元素三指滑动:滚动页面四指点击上半屏/下半屏:选择第一个/最后一个元素转子(两指旋转):切换导航模式(按标题、按链接、按表单等)测试时重点关注:焦点顺序是否跟视觉顺序一致每个可交互元素是否都有清晰的label和hint动态内容变化后焦点是否合理模态弹窗是否限制了焦点范围自定义手势是否有替代的无障碍操作5.3 常见问题排查表问题现象可能原因排查方法解决方案VoiceOver读不出某个按钮isAccessibilityElement为false用Accessibility Inspector检查设为true并添加label焦点顺序错乱子视图添加顺序与视觉顺序不一致逐个聚焦观察顺序设置accessibilityElements数组图片没有描述accessibilityLabel为空Inspector显示label为空添加有意义的描述弹窗后焦点跑到背景未设置accessibilityViewIsModal弹窗后滑动焦点设置accessibilityViewIsModal = true自定义手势无法使用VoiceOver拦截了手势开启VoiceOver后测试添加UIAccessibilityCustomAction动态内容更新后朗读旧内容未发送通知观察更新后焦点发送.layoutChanged或.announcement价格读成多个数字多个Label拼接Inspector查看元素数量合并Label或设置容器元素5.4 自动化测试:把无障碍检查加入CIXCTest提供了无障碍相关的API,可以在UI测试中检查元素的无障碍属性。func testAccessibilityLabels() { let app = XCUIApplication() app.launch() let submitButton = app.buttons["submitButton"] XCTAssertTrue(submitButton.exists) XCTAssertEqual(submitButton.label, "提交") XCTAssertTrue(submitButton.isEnabled) // 检查图片是否有label let productImage = app.images["productImage"] XCTAssertFalse(productImage.label.isEmpty) }更进一步的,你可以用XCUIElement的accessibilityTraits属性来验证Trait是否正确。不过XCTest对无障碍的支持有限,复杂的焦点顺序和自定义操作还是需要手动测试。6. 几个让我踩过坑的细节6.1 accessibilityLabel不要写“按钮”两个字VoiceOver会自动根据Trait朗读“按钮”、“图片”、“文本字段”这些类型。如果你在label里写了“提交按钮”,VoiceOver会读成“提交按钮,按钮”,重复且冗余。正确做法:label只写内容,Trait负责类型。// 错误 button.accessibilityLabel = "提交按钮" // 正确 button.accessibilityLabel = "提交" button.accessibilityTraits = .button6.2 隐藏元素的正确姿势isHidden = true和alpha = 0都会让元素从无障碍树中消失,但isAccessibilityElement = false只是让元素本身不被聚焦,子元素仍然可能被聚焦。如果你想让整个子树都从无障碍树中消失,用accessibilityElementsHidden = true。// 只隐藏自己 view.isAccessibilityElement = false // 隐藏自己和所有子元素 view.accessibilityElementsHidden = true // 隐藏自己但保留子元素 view.isAccessibilityElement = false view.accessibilityElementsHidden = false6.3 通知发送的时机很关键UIAccessibility.post(notification:argument:)如果在viewDidLoad里调用,可能不会生效,因为此时视图还没有出现在屏幕上。最佳时机是viewDidAppear或者布局完成后的回调里。另外,.screenChanged的argument参数应该传一个可以聚焦的元素,而不是nil。传nil会导致VoiceOver把焦点重置到屏幕左上角。override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UIAccessibility.post(notification: .screenChanged, argument: firstResponderElement) }6.4 自定义字体对VoiceOver的影响如果你用了自定义字体,VoiceOver的朗读不受影响,但Dynamic Type的缩放会失效。因为自定义字体不会自动响应UIContentSizeCategory的变化。解决方案是用UIFontMetrics来缩放自定义字体:let customFont = UIFont(name: "CustomFont", size: 17)! let scaledFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont) label.font = scaledFont label.adjustsFontForContentSizeCategory = true6.5 SwiftUI与UIKit的无障碍差异虽然这篇文章主要讲UIKit,但如果你在用SwiftUI,几个关键差异需要知道:SwiftUI的.accessibilityLabel()是View Modifier,不是属性.accessibilityElement(children: .combine)可以合并子元素.accessibilityAddTraits()添加TraitSwiftUI的焦点管理用@AccessibilityFocusState// SwiftUI示例 VStack { Text("商品名称") Text("¥129.99") } .accessibilityElement(children: .combine) .accessibilityLabel("商品名称,价格129.99元")SwiftUI在无障碍方面做了很多自动化的工作,但复杂场景下还是需要手动干预。如果你的项目是UIKit和SwiftUI混用,注意两套系统的无障碍树是打通的,但焦点顺序可能需要额外调整。7. 把无障碍适配变成开发习惯我现在的做法是:每创建一个新的ViewController,先花五分钟把基本的无障碍属性加上。具体清单:所有UILabel检查是否需要accessibilityLabel(如果文本本身已经清晰,通常不需要额外设置)所有UIButton检查label是否明确所有UIImageView检查是否有描述,装饰性图片设为isAccessibilityElement = false自定义控件检查Trait和accessibilityActivate()列表页面检查accessibilityElements顺序模态弹窗检查accessibilityViewIsModal动态内容检查通知发送这七个检查点花不了多少时间,但能避免90%的常见问题。剩下的10%——复杂自定义控件、手势冲突、焦点陷阱——需要根据具体情况单独处理。最后分享一个我常用的调试技巧:在开发阶段,给所有需要无障碍适配的视图加一个红色边框,这样一眼就能看出哪些元素被遗漏了。#if DEBUG extension UIView { func debugAccessibility() { if isAccessibilityElement { layer.borderColor = UIColor.red.cgColor layer.borderWidth = 1 } } } #endif在viewDidAppear里遍历所有子视图调用这个方法,就能快速定位问题。上线前记得把这段代码去掉或者用#if DEBUG包起来。Accessibility适配这件事,做一次可能觉得麻烦,但形成习惯之后就是顺手的事。而且它带来的收益是实实在在的——你的App能被更多人使用,App Store审核少一个被拒的理由,代码质量也更高。我见过太多团队在发版前一周才开始补无障碍,结果手忙脚乱还漏掉关键流程。不如从现在开始,每写一个页面就顺手做了。