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

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 等操作

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/691749.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

将二叉排序树转换成双向链表--c++【做题记录】

【问题描述】 编写程序在不增加结点的情况下&#xff0c;将二叉排序树转换成有序双向链表&#xff08;如下图&#xff09;。 链表创建结束后&#xff0c;按照从前往后的顺序输出链表中结点的内容。 【输入输出】 【输入形式】 第一行输入数字n&#xff0c;第二行输入n个整数…

车载诊断架构 - 引导诊断

我是穿拖鞋的汉子,魔都中坚持长期主义的汽车电子工程师。 老规矩,分享一段喜欢的文字,避免自己成为高知识低文化的工程师: 屏蔽力是信息过载时代一个人的特殊竞争力,任何消耗你的人和事,多看一眼都是你的不对。非必要不费力证明自己,无利益不试图说服别人,是精神上的节…

八爪鱼现金流-018,持续打磨

八爪鱼,被动收入,财务自由,现金流,现金流游戏,各银行利率,money,资产负债表,财务自由,资产管理,个人理财,管理个人资产,理财,打造被动收入,躺着赚钱,让钱为我打工

Cell-在十字花科植物中年生和多次开花多年生开花行为的互相转化-文献精读21

Reciprocal conversion between annual and polycarpic perennial flowering behavior in the Brassicaceae 在十字花科植物中年生和多次开花多年生开花行为的互相转化 亮点 喜马拉雅须弥芥 和 内华达糖芥 是两个多年生植物模型 MADS-box 基因的剂量效应决定了一年生、二年生…

使用OpenCV dnn c++加载YOLOv8生成的onnx文件进行实例分割

在网上下载了60多幅包含西瓜和冬瓜的图像组成melon数据集&#xff0c;使用 EISeg 工具进行标注&#xff0c;然后使用 eiseg2yolov8 脚本将.json文件转换成YOLOv8支持的.txt文件&#xff0c;并自动生成YOLOv8支持的目录结构&#xff0c;包括melon.yaml文件&#xff0c;其内容如下…

【Python教程】1-注释、变量、标识符与基本操作

在整理自己的笔记的时候发现了当年学习python时候整理的笔记&#xff0c;稍微整理一下&#xff0c;分享出来&#xff0c;方便记录和查看吧。个人觉得如果想简单了解一名语言或者技术&#xff0c;最简单的方式就是通过菜鸟教程去学习一下。今后会从python开始重新更新&#xff0…

人工智能--教育领域的运用

文章目录 &#x1f40b;引言 &#x1f40b;个性化学习 &#x1f988;体现&#xff1a; &#x1f988;技术解析&#xff1a; &#x1f40b;智能辅导与虚拟助手 &#x1f988;体现&#xff1a; &#x1f988;技术解析&#xff1a; &#x1f40b;自动评分与评估 &#x1f…

AI大模型在广告领域的应用

深度对谈&#xff1a;广告创意领域中AIGC的应用_生成式 AI_Tina_InfoQ精选文章

【python】OpenCV GUI——Mouse(14.1)

参考学习来自 文章目录 背景知识cv2.setMouseCallback 介绍小试牛刀 背景知识 GUI&#xff08;Graphical User Interface&#xff0c;图形用户界面&#xff09; 是一种允许用户通过图形元素&#xff08;如窗口、图标、菜单和按钮&#xff09;与电子设备进行交互的界面。与传统…

【Mtk Camera开发学习】06 MTK 和 Qcom 平台支持通过 Camera 标准API 打开 USBCamera

本专栏内容针对 “知识星球”成员免费&#xff0c;欢迎关注公众号&#xff1a;小驰行动派&#xff0c;加入知识星球。 #MTK Camera开发学习系列 #小驰私房菜 Google 官方介绍文档&#xff1a; https://source.android.google.cn/docs/core/camera/external-usb-cameras?hlzh-…

【React】classnames 优化类名控制

1. 介绍 classnames是一个简单的JS库&#xff0c;可以非常方便的通过条件动态的控制class类名的显示 ClassNames是一个用于有条件处理classname字符串连接的库 简单来说就是动态地去操作类名&#xff0c;把符合条件的类名粘在一起 现在的问题&#xff1a;字符串的拼接方式不…

VMware导入小白分享的MacOS版本之后,无法开机的解决方案

前言 这段时间陆续有小伙伴找到小白&#xff0c;说&#xff1a;导入小白分享的MacOS版本之后&#xff0c;出现无法开机的问题。 遇到这个问题&#xff0c;并不是说明分享版本有问题&#xff0c;因为大部分小伙伴导入之后都没有出现类似的问题&#xff0c;都是导入之后开机&…

记录项目使用ts时引入js文件后导致项目运行空白问题

主要原因&#xff1a; 使用ts后开启了eslint检测&#xff0c;而js压缩文件引入的位置在eslint检测的文件内。导致eslint检测认为该文件为很大的文件&#xff0c;或eslint认为此文件内存在无法处理的语法结构等问题。 解决方法&#xff1a; 1、把文件移到eslint检测外的文件引入…

居家实用类词汇,柯桥俄语培训

Посудомоечная машина 洗碗机 Холодильник 冰箱 Микроволновая печь 微波炉 Мультиварка 多功能电饭煲 Электронные весы для продуктов 食品电子秤 Электрическая мяс…

前端开发之中svg图标的使用和实例

svg图标的使用和实例 前言效果图1、安装插件2、vue3中使用2.1、 在components文件夹中,创建公共类SvgIcon/index.vue2.2、创建icons文件,存放svg图标和将所有的svg图标进行引用并注册成全局组件2.3、在man.js 中注册2.4、在vue.config.js中配置svg2.5、在vue中的调用svg图标3…

【JS实战03】学生信息的添加与删除

说明&#xff1a;本文章提供相应源码&#xff0c;需要到主页资源栏下载&#xff0c;并搭配源码看本文档&#xff1b;重点阐述每个JS模块实现过程中的重难点问题。 一&#xff1a;录入模块 1 渲染数据思路 减少DOM相关操作&#xff0c;避免因过多的DOM操作造成程序运行速度的…

计网总结☞物理层

五层协议体系结构->各层的功能有&#xff1a; 物理层 物理层的任务就是尽可能地屏蔽传输媒体的差异&#xff0c;透明地传送比特流&#xff08;注意&#xff1a;传递信息的物理媒体&#xff0c;如双绞线、同轴电缆、光缆等&#xff0c;是在物理层的下面&#xff0c;当做第 0…

【Vue】声明式导航-自定义类名(了解)

问题 router-link的两个高亮类名 太长了&#xff0c;我们希望能定制怎么办 解决方案 我们可以在创建路由对象时&#xff0c;额外配置两个配置项即可。 linkActiveClass和linkExactActiveClass const router new VueRouter({routes: [...],linkActiveClass: "类名1&quo…

WinForms 应用(.NET 8.0)使用ReportViewerCore.WinForms显示打印RDLC报表

在要WinForms 应用&#xff08;.NET 8.0&#xff09;中&#xff0c;显示RDLC报表&#xff0c;就要使用ReportViewerCore.WinForms。原来的ReportViewer只能在.NET Framework框架下运行。 1.ReportViewerCore.WinForms 程序包说明 SQL Server Reporting Services ReportViewer…

万字长文|OpenAI模型规范(全文)

本文是继《OpenAI模型规范概览》之后对OpenAI Model Spec的详细描述&#xff0c;希望能对各位从事大模型及RLHF研究的朋友有帮助。万字长文&#xff0c;建议收藏后阅读。 一、概述 在AI的世界里&#xff0c;确保技术的行为符合我们的期望至关重要。OpenAI最近发布了一份名为Mo…