一文读懂 Compose 支持 Accessibility 无障碍的原理

10 篇文章 0 订阅
9 篇文章 1 订阅

Compose-base-accessibility.png

前言

众所周知,Compose 作为一种 UI 工具包,向开发者提供了实现 UI 的基本功能。但其实它还默默提供了很多其他能力,其中之一便是今天需要讨论的:Android 特色的 Accessibility 功能。

采用 Compose 搭建的界面,完美地支持了 Accessibility 功能:它的 UI 变化能正确地发出无障碍事件 AccessibilityEvent 并响应来自无障碍服务的操作 AccessibilityAction

那 Compose 是如何做到完美兼容传统的 Accessibility 机制的,本文将按照无障碍事件、无障碍节点、无障碍操作等几个方向为你剖析 Compose 默默做了哪些事情。

目录:

  1. 为 Compose 适配 contentDescription
  2. Compose 收集 Accessibility 语义信息
  3. Compose 特殊的 Accessibility 代理
  4. Compose 中 AccessibilityEvent 的产生和发送
  5. Compose 中 AccessibilityNode 的生成和提供
  6. 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
         }
     }
     ...
 }

2-2. for click

我们知道通过 Modifier 可以设置 click Unit,供执行 UI 上的单击操作。对于 Accessibility 功能来说,也需要能够支持通过 AccessibilityService 输入触发点击操作。

我们以设置组合 click 的 CombinedClickableNode() 方式为例,查看其 click 信息是如何和 Accessibility 交互的。

如下代码可以看到 CombinedClickableNode() 对外提供了点击语义节点 ClickableSemanticsNode,其复写了 applySemantics() 方法,而该方法则调用 SemanticsPropertyReceiver 的 onClick() 传递了 click Unit。

 private class CombinedClickableNode( ...): ... {
     override val clickableSemanticsNode = delegate(
         ClickableSemanticsNode(
             ...
         )
     )
 }private class ClickableSemanticsNode(
     ...
 ) : SemanticsModifierNode, Modifier.Node() {
     ...
     override fun SemanticsPropertyReceiver.applySemantics() {
         ...
         onClick(
             action = { onClick(); true },
             label = onClickLabel
         )
         ...
     }
 }

onClick() 则是将 label 和 click Unit 封装成 AccessibilityAction 实例,并以 SemanticsActions.OnClick 为 key 存放在实现了 SemanticsConfiguration map 里。

 fun SemanticsPropertyReceiver.onClick(label: String? = null, action: (() -> Boolean)?) {
     this[SemanticsActions.OnClick] = AccessibilityAction(label, action)
 }

至此,click Unit 通过 AccessibilityAction 的形式收集好了。

3. Compose 特殊的 Accessibility 代理

收集到了 SemanticsConfiguration 后,如何被 Accessibility 使用的呢?

首先,我们得了解一下 AccessibilityDelegate:它是 Android 传统 View 提供的,允许给某个 View 自定义 Accessibility 处理逻辑的机制。

本质上仍属于 ViewGroup 的 AndroidComposeView 在 init 的时候,利用原生提供的 setAccessibilityDelegate() 接口设置了 AccessibilityDelegate 为 AndroidComposeViewAccessibilityDelegateCompat

     private val accessibilityDelegate = AndroidComposeViewAccessibilityDelegateCompat(this)init {
         ...
         ViewCompat.setAccessibilityDelegate(this, accessibilityDelegate)
         ...
     }

该代理类需要处理的逻辑非常多,包括:

  1. 处理 Compose 下 AccessibilityEvent 的产生和发送
  2. 处理 Compose 下所有 Node 对应的 AccessibilityNodeInfo 实例的生成和提供
  3. 处理 Compose 下 AccessibilityAction 的响应和执行

下面我们按照这 3 点逐步展开。

4. Compose 中 AccessibilityEvent 的产生和发送

我们以最常见的 window 内容变化 TYPE_WINDOW_CONTENT_CHANGEDAccessibilityEvent为例,阐述 Compose 如何产生和发送它们。

首先,当 AndroidComposeView 添加到 ViewGroup 之后,会启动 LaunchedEffect 监听该 Compose 下所有 Node 在 bounds 上的变化。

 private class WrappedComposition(
     ...
 ) : Composition, LifecycleEventObserver {
     ...
     override fun setContent(content: @Composable () -> Unit) {
         owner.setOnViewTreeOwnersAvailable {
             if (!disposed) {
                 ...
                 if (addedToLifecycle == null) {
                     ...
                 } else if (lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
                     original.setContent {
                         ...
                         // 监听 bounds 变化
                         LaunchedEffect(owner) { owner.boundsUpdatesEventLoop() }
                         ...
                     }
                 }
             }
         }
     }
     ...
 }

监听的具体逻辑由上述设置的 AccessibilityDelegate 完成。

     suspend fun boundsUpdatesEventLoop() {
         accessibilityDelegate.boundsUpdatesEventLoop()
     }

AccessibilityDelegate 会判断系统的 Accessibility 开关是否开启,并在 ON 的时候 post 一个叫 semanticsChangeChecker 的 runnable。

     suspend fun boundsUpdatesEventLoop() {
         try {
             val subtreeChangedSemanticsNodesIds = ArraySet<Int>()
             for (notification in boundsUpdateChannel) {
                 ...
                 if (isEnabledForAccessibility) {
                     ...
                     if (!checkingForSemanticsChanges) {
                         checkingForSemanticsChanges = true
                         handler.post(semanticsChangeChecker)
                     }
                 }
                 ...
             }
         } finally {
             subtreeChangedLayoutNodes.clear()
         }
     }

semanticsChangeChecker runnable 会将 Node 相关的 property change 下发,交给 sendSemanticsPropertyChangeEvents() 统一处理。

     private val semanticsChangeChecker = Runnable {
         ...
         checkForSemanticsChanges()
         ...
     }private fun checkForSemanticsChanges() {
         ...
         // Property change
         sendSemanticsPropertyChangeEvents(currentSemanticsNodes)
         updateSemanticsNodesCopyAndPanes()
     }

sendSemanticsPropertyChangeEvents() 会遍历新的 Node 里发生变化的 property,并产生对应 type 的 AccessibilityEvent。

比如:

  • 发现是 Progress 控件的 range 信息发生了变化,则产生 TYPE_WINDOW_CONTENT_CHANGED
  • 发现是通用的 ContentDescription 发生了变化,也产生 TYPE_WINDOW_CONTENT_CHANGED

还有很多其他的 property 变化会产生 AccessibilityEvent,类型也各不相同,这里不再一一展开。

 internal fun sendSemanticsPropertyChangeEvents(
         newSemanticsNodes: Map<Int, SemanticsNodeWithAdjustedBounds>
     ) {
         ...
         for (id in newSemanticsNodes.keys) {
             val oldNode = previousSemanticsNodes[id] ?: continue
             val newNode = newSemanticsNodes[id]?.semanticsNode
             var propertyChanged = falsefor (entry in newNode!!.unmergedConfig) {
                 ...
                 when (entry.key) {
                     ...
                     SemanticsProperties.ProgressBarRangeInfo -> {
                         sendEventForVirtualView(
                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
                             AccessibilityEventCompat.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
                         )
                         ...
                     }
 ​
                     SemanticsProperties.ContentDescription -> {
                         sendEventForVirtualView(
                             semanticsNodeIdToAccessibilityVirtualNodeId(id),
                             AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
                             AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION,
                             entry.value as List<String>
                         )
                     }
                     ...
                 }
             }
             ...
         }
     }

事件的初始化和发出,还需要 sendEventForVirtualView() 具体完成。

  1. 检查下目标 View ID 是否合法,以及 Accessibility 系统是否开启
  2. 调用 createEvent() 构建 AccessibilityEvent 实例
  3. 调用 sendEvent() 发送给 Accessibility 系统
    private fun sendEventForVirtualView(
         ...
     ): Boolean {
         if (virtualViewId == InvalidId || !isEnabled) {
             return false
         }val event: AccessibilityEvent = createEvent(virtualViewId, eventType)
         if (contentChangeType != null) {
             event.contentChangeTypes = contentChangeType
         }
         if (contentDescription != null) {
             event.contentDescription = contentDescription.fastJoinToString(",")
         }return sendEvent(event)
     }

createEvent() 通过 obtain() 拿到 AccessibilityEvent 新实例之后,进行 className 等属性的初始化。并进行最重要的一步:将该事件的 source 和 View ID 绑定,便于后续从该事件中查找发生变化的 AccessibilityNodeInfo。

     internal fun createEvent(virtualViewId: Int, eventType: Int): AccessibilityEvent {
         val event: AccessibilityEvent = AccessibilityEvent.obtain(eventType)
         event.isEnabled = true
         event.className = ClassName
         event.packageName = view.context.packageName
         
         event.setSource(view, virtualViewId)
         ...return event
     }

sendEvent() 将再次确保 Accessibility 系统的开启,通过之后通过 AndroidComposeView 的 parent(一般来说是 id 为 content 的 ViewGroup)向 Accessibility 系统请求该 AccessibilityEvent 的最终发出。

     private fun sendEvent(event: AccessibilityEvent): Boolean {
         if (!isEnabledForAccessibility) {
             return false
         }return view.parent.requestSendAccessibilityEvent(view, event)
     }

后续便是利用 Android 传统 View 的链路向 AccessibilityManagerService 请求,并经过 AccessibilityServiceConnection 的调度向活跃的 AccessibilityService 发出 AccessibilityEvent 变化的 callback。

因其不属于 Compose 的处理范畴了,就不再具体展开了。

5. Compose 中 AccessibilityNode 的生成和提供

AccessibilityEvent 抵达 AccessibilityService App 之后,它们需要从 AccessibilityEvent 里获取 source 对应的 AccessibilityNodeInfo 实例。

所以,和 Android 传统 View 一样,Compose 需要为目标 View ID 提供各层级所对应的 AccessibilityNodeInfo 实例。

当获取到 AndroidComposeView 的时候,就会调度到上述设置的代理 AndroidComposeViewAccessibilityDelegateCompat。该代理通过 MyNodeProvider 类具体负责 AccessibilityNodeInfo 的构建。

     private var nodeProvider: AccessibilityNodeProviderCompat =
         AccessibilityNodeProviderCompat(MyNodeProvider())override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProviderCompat {
         return nodeProvider
     }inner class MyNodeProvider : AccessibilityNodeProvider() {
     }

MyNodeProvider 构建 AccessibilityNodeInfo 的入口是 createAccessibilityNodeInfo(),其会交给内部的 createNodeInfo() 继续。

createNodeInfo() 在进行 AccessibilityNodeInfo 实例的初始化、边界 Rect 赋值等基本操作之后,执行最核心的信息填充:populateAccessibilityNodeInfoProperties()。

     inner class MyNodeProvider : AccessibilityNodeProvider() {
         override fun createAccessibilityNodeInfo(virtualViewId: Int):
             AccessibilityNodeInfo? {
             return createNodeInfo(virtualViewId)
         }
         ...
     }
           
     private fun createNodeInfo(virtualViewId: Int): AccessibilityNodeInfo? {
         ...
         val info: AccessibilityNodeInfoCompat = AccessibilityNodeInfoCompat.obtain()
         ...
         val semanticsNode: SemanticsNode = semanticsNodeWithAdjustedBounds.semanticsNode
         ...
         info.setSource(view, virtualViewId)
         val boundsInRoot = semanticsNodeWithAdjustedBounds.adjustedBounds
         val topLeftInScreen =
             view.localToScreen(Offset(boundsInRoot.left.toFloat(), boundsInRoot.top.toFloat()))
         val bottomRightInScreen =
             view.localToScreen(Offset(boundsInRoot.right.toFloat(), boundsInRoot.bottom.toFloat()))
         // 设置该 info 在 UI 上的范围 Rect
         info.setBoundsInScreen(
             android.graphics.Rect(
                 floor(topLeftInScreen.x).toInt(),
                 floor(topLeftInScreen.y).toInt(),
                 ceil(bottomRightInScreen.x).toInt(),
                 ceil(bottomRightInScreen.y).toInt()
             )
         )// 将 Compose Node 和目标 Info 传入,进行进一步的信息填充
         populateAccessibilityNodeInfoProperties(virtualViewId, info, semanticsNode)return info.unwrap()
     }

我们知道,AccessibilityNodeInfo 通常要明确它代表的 UI 控件类型,当 App 通过 AccessibilityService 拿到该 info 时,便于他们通过该类型准确理解其目标控件的作用和特点。该类型以 className 属性的形式存在于 AccessibilityNodeInfo 中。

在为 Compose UI 创建 AccessibilityNodeInfo 的时候,一样需要进行这样的类型赋值。可是 Compose 内部的控件并不是传统的 View 控件,所以需要依据 SemanticsNode 内记录的 Property 情况去差异化赋值。同时为了兼容传统 View 控件的命名方式,具体赋值的内容还得借用和遵照 Android 传统 View 的类名。

所以,populateAccessibilityNodeInfoProperties() 会执行如下处理:

  1. 先统一地设置 className“android.view.View”

  2. 如果对应的 SemanticsNode 是支持 setText Action 的类型,则将 className 设置为 “android.widget.EditText”

  3. 反之,如果是支持 Text Action 的类型,设置为 “android.widget.TextView”

  4. packageName 则统一地被设置为 ComposeView 持有的 context 包名

  5. 后面则是各种各样的属性填充和所支持的 AccessibilityAction 的声明,比如:

    • 检查当前的 View 和已 focus 的 View 是否一致,以决定让该 info 支持清除还是添加无障碍的 focus action;
    • 通过 x、y 方向上是否支持滚动以决定是否支持上下、左右滑动的 action 等等;
    • 从 SemanticsNode 中取出 text 信息填充到 text 属性中(这个属性是无障碍机制里最常用的);
    • 检查 SemanticsNode 是否是 check 类型,并赋值 Checkable 属性;其他的还有 checked、selected 等常用属性;
  6. 这里需要强调的 1 个非常重要的属性,即 contentDescription,它来自于代码里给控件指定的 contentDescription 信息。第三方的无障碍服务 App 非常依赖该属性进行朗读提示

  7. 还有 1 个重要的 Action 即 ACTION_CLICK,当发现 SemanticsNode 里支持 OnClick 的时候,需要标记该 info 可以处理 ACTION_CLICK 的 action 操作

 internal class AndroidComposeViewAccessibilityDelegateCompat ... {
     ...
     fun populateAccessibilityNodeInfoProperties( ... ) {
         // 先默认赋值一个 className,后面依据具体类型再替换为对应的 View 包名
         info.className = ClassName
         ...
         if (semanticsNode.isTextField) {
             info.className = TextFieldClassName
         }
         if (semanticsNode.config.contains(SemanticsProperties.Text)) {
             info.className = TextClassName
         }// packageName 统一用一份 context 包名即可
         info.packageName = view.context.packageName
         ...// 依据 focus 的 View ID 决定支持清除还是添加 ACCESSIBILITY_FOCUS
         if (focusedVirtualViewId == virtualViewId) {
             info.isAccessibilityFocused = true
             info.addAction(AccessibilityActionCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS)
         } else {
             info.isAccessibilityFocused = false
             info.addAction(AccessibilityActionCompat.ACTION_ACCESSIBILITY_FOCUS)
         }// 从 SemanticsNode 中取出 text 信息填充到 text 属性中
         setText(semanticsNode, info)
         ...
         // 检查 SemanticsNode 是否是 check 类型,并赋值 Checkable 属性
         setIsCheckable(semanticsNode, info)// 后面还有 checked、selected 等属性
         val toggleState = semanticsNode.unmergedConfig.getOrNull(
         semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Selected)?.let {
         ...// 赋值最重要的一个属性 contentDescription
         if (!semanticsNode.unmergedConfig.isMergingSemanticsOfDescendants ||
             semanticsNode.replacedChildren.isEmpty()
         ) {
             info.contentDescription = semanticsNode.infoContentDescriptionOrNull
         }// 其他的还有 isPassword、isEditable、isEnabled、isFocusable 等属性的填充
         info.isPassword = semanticsNode.isPassword
         info.isEditable = semanticsNode.isTextField
         info.isEnabled = semanticsNode.enabled()
         ...// 同样的,检查是否支持 OnClick Action
         // YES 的话,结合 enabled 和 isSelected 状态
         // 决定 info 的 isClickable 属性以及是否支持 ACTION_CLICK 操作
         info.isClickable = false
         semanticsNode.unmergedConfig.getOrNull(SemanticsActions.OnClick)?.let {
             val isSelected =
                 semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Selected) == true
             info.isClickable = !isSelected
             if (semanticsNode.enabled() && !isSelected) {
                 info.addAction(
                     AccessibilityActionCompat(
                         AccessibilityNodeInfoCompat.ACTION_CLICK,
                         it.label
                     )
                 )
             }
         }
         ...// 其他的还有很多属性的填充和 Action 的是否支持
         ...
         if (xScrollState != null && scrollAction != null) {
             ...
             if (semanticsNode.enabled()) {
                 if (xScrollState.canScrollForward()) {
                     info.addAction(AccessibilityActionCompat.ACTION_SCROLL_FORWARD)
                     info.addAction(
                         if (!semanticsNode.isRtl) {
                             AccessibilityActionCompat.ACTION_SCROLL_RIGHT
                         } else {
                             AccessibilityActionCompat.ACTION_SCROLL_LEFT
                         }
                     )
                 }
                 ...
             }
         }
         ...
         // 以及很多为了 OS 兼容性的处理
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
             Api29Impl.addPageActions(info, semanticsNode)
         }
         ...
     }
 }

6. Compose 中 AccessibilityAction 的响应和执行

除了负责 Compose 自己的 AccessibilityEvent 和 AccessibilityNodeInfo 以外,Compose 还得为各控件去响应来自于 View 系统的、Accessibility 系统的 AccessibilityAction 操作。

省去 AccessibilityAction 发送到 AccessibilityDelegate 的通用流程,我们直接看 Compose 收到该 Action 的入口:仍然是上面提及的 MyNodeProvider 类,对应的方法是 performAction()

     inner class MyNodeProvider : AccessibilityNodeProvider() {
         ...
         override fun performAction(
             virtualViewId: Int,
             action: Int,
             arguments: Bundle?
         ): Boolean {
             return performActionHelper(virtualViewId, action, arguments)
         }
     }

performAction() 直接调用 performActionHelper() 进行内部的 Action 分发。

可以看到它需要处理的 AccessibilityAction 非常多(这也是预料之中的,毕竟原生的 Accessibility Action 太多了)。

     private fun performActionHelper(
         ...
     ): Boolean {
         val node = currentSemanticsNodes[virtualViewId]?.semanticsNode ?: return false
         ...
         if (!node.enabled()) {
             return false
         }when (action) {
             AccessibilityNodeInfoCompat.ACTION_CLICK -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_LONG_CLICK -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD,
             AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD,
             android.R.id.accessibilityActionScrollDown,
             android.R.id.accessibilityActionScrollUp,
             android.R.id.accessibilityActionScrollRight,
             android.R.id.accessibilityActionScrollLeft -> {
                 ...
             }
 ​
             android.R.id.accessibilityActionPageUp -> { ... }
 ​
             android.R.id.accessibilityActionPageDown -> { ... }
 ​
             android.R.id.accessibilityActionPageLeft -> { ... }
 ​
             android.R.id.accessibilityActionPageRight -> { ... }
 ​
             android.R.id.accessibilityActionSetProgress -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_FOCUS -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_SET_TEXT -> { ... }
 ​
             android.R.id.accessibilityActionImeEnter -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_PASTE -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_CUT -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_EXPAND -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_COLLAPSE -> { ... }
 ​
             AccessibilityNodeInfoCompat.ACTION_DISMISS -> { ... }
 ​
             android.R.id.accessibilityActionShowOnScreen -> { ... }
             
             ...
         }
     }

我们以最常见的 ACTION_CLICK 操作为例,看下后续逻辑。

先从 View ID 对应的 SemanticsNode 里拿到存放各种语义信息的 SemanticsConfiguration map,然后拿负责点击的 OnClick 为 key 进行查找。

     private fun performActionHelper(
         ...
     ): Boolean {
         ...
         when (action) {
             AccessibilityNodeInfoCompat.ACTION_CLICK -> {
                 val result =
                     node.unmergedConfig.getOrNull(SemanticsActions.OnClick)?.action?.invoke()
                 sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_CLICKED)
                 return result ?: false
             }
             ...
         }
         ...
     }

可以得到在控件初始化时存入的 AccessibilityAction 实例,之后直接 invoke 封装在其 action 属性里的 onClick Unit,即可完成 click 操作的执行。

 class AccessibilityAction<T : Function<Boolean>>(val label: String?, val action: T?) {
     ...
 }

结语

最后我们用一张图把整个流程串起来。

compose_accessibility.drawio.png

  1. 首先,AndroidComposeView 的各 LayoutNode 初始化的时候通过 applySemantics() 将各 SemanticsNode 语义节点收集必要的信息并按照类型(OnClick、ContentDescription 等)的 key 存放到 SemanticsConfiguration 中

  2. AndroidComposeView 初始化的时候设置特殊的 AccessibilityDelegate 代理类,以告知 View 系统 Compose 下所有的 Accessibility 相关逻辑由该代理完成

  3. 接着监听 SemanticsConfiguration 里各信息的变化 Property Change

    • 当某项信息变更的时候,通过上述代理构建相应类型的 AccessibilityEvent 并发送到 Accessibility 系统
  4. 当 AccessibilityEvent 经过 AccessibilityManagerService 抵达 AccessibilityService 之后,

    • AccessibilityService 从 Event 里获取目标的 AccessibilityNode 时,上述代理会依据 View ID 从 SemanticsNode 里获取该控件的信息,以创建合适的 AccessibilityNodeInfo 实例
  5. AccessibilityService 对 AccessibilityNodeInfo 数据进行分析之后,可以按需发送 AccessibilityAction。经过 AccessibilityManagerService 抵达 AndroidComposeView 后,依据通过上述代理进行 performAction()

    • 此后会依据 Action 的类型 key 去 SemanticsConfiguration 里查找到对应的 Action Unit 和执行

简单来说,Compose 布局上所有的 Accessibility 逻辑都是通过特殊的 AccessibilityDelegate 完成,包括:

  • 监听语义信息的变化发送无障碍事件 AccessibilityEvent
  • 接收无障碍节点 AccessibilityNode 的查找,封装节点并返回
  • 响应无障碍操作 AccessibilityAction 的请求,去找到对应的 Compose Node 执行 Click、Focus 等操作
  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TechMerger

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值