前言
众所周知,Compose
作为一种 UI 工具包,向开发者提供了实现 UI 的基本功能。但其实它还默默提供了很多其他能力,其中之一便是今天需要讨论的:Android 特色的 Accessibility
功能。
采用 Compose 搭建的界面,完美地支持了 Accessibility 功能:它的 UI 变化能正确地发出无障碍事件 AccessibilityEvent
并响应来自无障碍服务的操作 AccessibilityAction
。
那 Compose 是如何做到完美兼容传统的 Accessibility 机制的,本文将按照无障碍事件、无障碍节点、无障碍操作等几个方向为你剖析 Compose 默默做了哪些事情。
目录:
- 为 Compose 适配 contentDescription
- Compose 收集 Accessibility 语义信息
- Compose 特殊的 Accessibility 代理
- Compose 中 AccessibilityEvent 的产生和发送
- Compose 中 AccessibilityNode 的生成和提供
- Compose 中 AccessibilityAction 的响应和执行
1. 为 Compose 后面适配 contentDescription
对采用 Compose 开发的 App 来说,几乎不需要做什么适配,就可以支持 Accessibility 功能。
但为了给使用障碍人士更好的体验,最好给使用到的 Compose 控件明确它们的 contentDescription 属性。这便于使用 AccessibilityService
的 App 拿到清晰的控件描述。
以 Image
控件为例,使用它的时候,通过 contentDescription 描述清楚它具体的作用。
Image(
...
contentDescription = "This is a image for artist",
...
)
这便于比如 Talkback 之类的 App 可以利用该信息进行明确的提示:“This is a image for road”。不至于因为信息不够,只能对 user 进行“Image”的无用播报。
如何适配 Accessibility、适配得更好,详细的细节可以参考官方文档:使用 Jetpack Compose 改进应用的无障碍功能。
当然,contentDescription 可不是 Accessibility 唯一关心的属性,还有很多控件所特有的属性,比如 click、text、progress 等等。
那这些属性信息是如何被通知到 Accessibility 系统的呢?
2. Compose 收集 Accessibility 语义信息
首先 Compose 专门设计了供 LayoutInspector、test 和 Accessibility 等场景读取和使用的语义系统 SemanticsConfiguration
。
在各 UI 控件进行初始化的时候,LayoutNode
会去收集各语义节点 SemanticsNode 提供的具体信息,综合到上述 SemanticsConfiguration中。
internal val collapsedSemantics: SemanticsConfiguration?
get() {
...
var config = SemanticsConfiguration()
requireOwner().snapshotObserver.observeSemanticsReads(this) {
nodes.tailToHead(Nodes.Semantics) {
...
with(config) {
with(it) {
applySemantics() } }
}
}
_collapsedSemantics = config
return config
}
SemanticsNode 需要复写各自的 applySemantics()
方法,此后便被按照类型进行收集。比如负责提供核心语义的 CoreSemanticsModifierNode
、提供点击相关语义的 ClickableSemanticsNode
等等。
事实上,SemanticsConfiguration 本质上是 Map,各类型语义在收集的时候,会按照对应的 key 进行存储。
接下来,我们以 contentDescription 和 click 两种语义信息为例,阐述 Compose 是如何收集它们到 SemanticsConfiguration 中以供 Accessibility 系统调用的。
2-1. for contentDescription
先来看下 Image 控件的源码,跟一下设置的 contentDescription 会如何传递。
@Composable
fun Image(
...
contentDescription: String?,
...
) {
val semantics = if (contentDescription != null) {
Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
}
}
...
}
Modifier 的 semantics()
扩展函数直接交给了 AppendedSemanticsElement()。
fun Modifier.semantics(
mergeDescendants: Boolean = false,
properties: (SemanticsPropertyReceiver.() -> Unit)
): Modifier = this then AppendedSemanticsElement(
mergeDescendants = mergeDescendants,
properties = properties
)
AppendedSemanticsElement
的 create() 则创建了 CoreSemanticsModifierNode 类型,并将包裹了 contentDescription 的 Unit 继续下发。
internal data class AppendedSemanticsElement(
...
val properties: (SemanticsPropertyReceiver.() -> Unit)
) : ModifierNodeElement<CoreSemanticsModifierNode>(), SemanticsModifier {
...
override fun create(): CoreSemanticsModifierNode {
return CoreSemanticsModifierNode(
mergeDescendants = mergeDescendants,
isClearingSemantics = false,
properties = properties
)
}
...
}
CoreSemanticsModifierNode
复写了 applySemantics(),即此处将执行 contentDescription 的收集。
internal class CoreSemanticsModifierNode(
...
var properties: SemanticsPropertyReceiver.() -> Unit
) : Modifier.Node(), SemanticsModifierNode {
...
override fun SemanticsPropertyReceiver.applySemantics() {
properties()
}
}
收集的操作是将 contentDescription 的内容按照 SemanticsProperties.ContentDescription 为 key 存入实现了 SemanticsPropertyReceiver
接口的 SemanticsConfiguration
map 里。
至此,contentDescription 信息就收集好了。
var SemanticsPropertyReceiver.contentDescription: String
get() = throwSemanticsGetNotSupported()
set(value) {
set(SemanticsProperties.ContentDescription, listOf(value))
}
class SemanticsConfiguration :
SemanticsPropertyReceiver,
Iterable<Map.Entry<SemanticsPropertyKey<*>, Any?>> {
...
override fun <T> set(key: SemanticsPropertyKey<T>, value: T) {
if (value is AccessibilityAction<*> && contains(key)) {
val prev = props[key] as AccessibilityAction<*>
props[key] = AccessibilityAction(
value.label ?: prev.label,
value.action ?: prev.action
)
} else {
props[key] = value
}
}
..