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

news2025/2/26 21:22:25

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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

二分+模拟,CF1461D - Divide and Summarize

一、题目 1、题目描述 2、输入输出 2.1输入 2.2输出 3、原题链接 Problem - 1461D - Codeforces 二、解题报告 1、思路分析 我们发现每次分裂操作结果都是固定的 我们从初始序列分裂出两个确定的子序列&#xff0c;两个确定的子序列又分裂出4个确定的子序列 那么也就是说…

【Python】解决Python报错:ZeroDivisionError: division by zero

​​​​ 文章目录 引言1. 错误详解2. 常见的出错场景2.1 直接除零2.2 变量导致的间接除零 3. 解决方案3.1 检查除数3.2 使用异常处理 4. 预防措施4.1 数据验证4.2 编写防御性代码 结语 引言 在Python中&#xff0c;尝试将一个数字除以零时&#xff0c;会抛出ZeroDivisionErr…

【Rust】——面向对象设计模式的实现

&#x1f3bc;个人主页&#xff1a;【Y小夜】 &#x1f60e;作者简介&#xff1a;一位双非学校的大二学生&#xff0c;编程爱好者&#xff0c; 专注于基础和实战分享&#xff0c;欢迎私信咨询&#xff01; &#x1f386;入门专栏&#xff1a;&#x1f387;【MySQL&#xff0…

C++第二十五弹---从零开始模拟STL中的list(下)

✨个人主页&#xff1a; 熬夜学编程的小林 &#x1f497;系列专栏&#xff1a; 【C语言详解】 【数据结构详解】【C详解】 目录 1、函数补充 2、迭代器完善 3、const迭代器 总结 1、函数补充 拷贝构造 思路&#xff1a; 先构造一个头结点&#xff0c;然后将 lt 类中的元…

使用亮数据代理IP爬取PubMed文章链接和邮箱地址

&#x1f482; 个人网站:【 摸鱼游戏】【神级代码资源网站】【工具大全】&#x1f91f; 一站式轻松构建小程序、Web网站、移动应用&#xff1a;&#x1f449;注册地址&#x1f91f; 基于Web端打造的&#xff1a;&#x1f449;轻量化工具创作平台&#x1f485; 想寻找共同学习交…

2024上海初中生古诗文大会倒计时4个多月:单选题真题和独家解析

现在距离2024年初中生古诗文大会还有4个多月时间&#xff0c;我们继续来看10道选择题真题和详细解析&#xff0c;以下题目截取自我独家制作的在线真题集&#xff0c;都是来自于历届真题&#xff0c;去重、合并后&#xff0c;每道题都有参考答案和解析。 为帮助孩子自测和练习&…

Html/HTML5常用标签的学习

课程目标 项目实战&#xff0c;肯定就需要静态网页。朝着做项目方式去学习静态网页。 01、编写第一个html工程结构化 cssjsimages/imgindex.html 归档存储和结构清晰就可以。 02、HTML标签分类 认知&#xff1a;标签为什么要分类&#xff0c;原因因为&#xff1a;分门别类…

【经验】Ubuntu上离线安装VsCode插件浏览Linux kernel源码

1、下载VsCode离线安装包 1.1 下载 下载地址:https://marketplace.visualstudio.com/vscode 本人安装的插件: C/C++ checkpatch Chinese clangd kconfig Makefile Tools Perl Perl Toolbox注意:C/C++插件要安装Linux 64版本 1.2 安装 将离线安装包拷贝到Ubuntu中,执…

Opencv 色彩空间

一 核心知识 色彩空间变换&#xff1b; 像素访问&#xff1b; 矩阵的、-、*、、&#xff1b; 基本图形的绘制 二 颜色空间 RGB&#xff1a;人眼的色彩空间&#xff1b; OpenCV默认使用BGR&#xff1b; HSV/HSB/HSL; YUV(视频); 1 RGB 2 BGR 图像的多种属性 1 访问图像(Ma…

【CS.CN】优化HTTP传输:揭示Transfer-Encoding: chunked的奥秘与应用

文章目录 0 序言0.1 由来0.2 使用场景 1 Transfer-Encoding: chunked的机制2 语法 && 通过设置Transfer-Encoding: chunked优化性能3 总结References 0 序言 0.1 由来 Transfer-Encoding头部字段在HTTP/1.1中被引入&#xff0c;用于指示数据传输过程中使用的编码方式…

赚钱而已,你又不是宠物,干嘛让所有人都喜欢你?

* 大家好&#xff0c;我是前端队长。前端程序员&#xff0c;2023年开始玩副业。做过AI绘画&#xff0c;公众号 AI 爆文&#xff0c;AI代写项目&#xff0c;累计变现五位数。 — 今天看到一句话说的真好&#xff1a; 太多人总想让别人喜欢自己了。有什么用&#xff0c;你又不是宠…

C++的线性回归模型

线性回归模型是数理统计中的一种回归分析方法&#xff0c;其核心思想是通过建立一个线性方程来描述因变量与自变量之间的关系。这种关系可以表示为y wx e&#xff0c;其中y是因变量&#xff0c;x是自变量&#xff0c;w是回归系数向量&#xff0c;e是误差项&#xff0c;服从均…

【TB作品】MSP430F5529 单片机,简单电子琴

使用MSP430制作一个简单电子琴 作品功能 这个项目基于MSP430单片机&#xff0c;实现了一个简单的电子琴。通过按键输入&#xff0c;电子琴可以发出对应的音符声音。具体功能包括&#xff1a; 按下按键时发出对应音符的声音。松开按键时停止发声。支持C调低音、中音和高音。 …

抖抖分析师和抖音分析有什么区别?

"抖抖分析师"和"抖音分析"虽然都与抖音这个平台有关&#xff0c;但是二者的含义有很大的区别。 首先&#xff0c;抖抖分析师通常指的是专门对抖音平台进行各种数据分析、用户行为研究、内容趋势预测等工作的人员。他们可能会洞察用户在抖音上的行为习惯&a…

OpenAI模型规范概览

这是OpenAI对外分享的模型规范文档&#xff08;Model Spec&#xff09;&#xff0c;它定义了OpenAI希望在API接口和ChatGPT&#xff08;含GPT系列产品&#xff09;中模型的行为方式&#xff0c;这也是OpenAI超级对齐团队奉行的行为准则&#xff0c;希望能对国内做RLHF的同学有帮…

Soildworks学习笔记(一)

1.如何添加M3,M4等螺丝孔&#xff1a; 有时候异形孔向导显示灰色是因为没有退出草图选项卡&#xff0c;选择异形孔向导就可以进行异形孔的设定和放置&#xff1a; solidwork放置螺丝孔以及显示螺纹的问题_.16-m3 solidwork-CSDN博客 2.如何修剪线条&#xff1a; 如何倒圆角或…

“薅羊毛”到被“割韭菜”,警惕网络副业陷井

本想“薅羊毛”却被“割韭菜”&#xff0c;这些现象在网络副业领域中尤为常见。许多人寻求在网络上开展副业以增加收入&#xff0c;但往往因为缺乏足够的警惕和了解&#xff0c;而陷入各种陷阱和风险中。 “薅羊毛”在副业领域通常指的是利用平台优惠、漏洞或规则&#xff0c;…

操作系统的启动过程和初始化

参考来源&#xff1a; Linux的启动过程&#xff0c;作者&#xff1a;阮一峰 第一步、加载内核 操作系统接管硬件以后&#xff0c;首先读入 /boot 目录下的内核文件。 rootub1804:/boot# ls -l 总用量 120636 -rw-r--r-- 1 root root 237767 5月 19 2023 config-5.4.0-15…

数据结构--实验

话不多说&#xff0c;直接启动&#xff01;&#x1f44c;&#x1f923; 目录 一、线性表&#x1f60e; 1、建立链表 2、插入元素 3、删除特定位置的元素 4、输出特定元素值的位置 5、输出特定位置的元素值 6、输出整个链表 实现 二、栈和队列&#x1f618; 栈 顺序栈 …

LeetCode | 1470.重新排列数组

class Solution(object):def shuffle(self, nums, n):""":type nums: List[int]:type n: int:rtype: List[int]"""result []for i in range(n):result.append(nums[i])result.append(nums[i n])return result这题很容易想到的就是遍历整个数组…