一文读懂 Compose 支持 Accessibility 无障碍的原理
前言
众所周知,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 = configreturn config}
SemanticsNode 需要复写各自的 applySemantics()
方法,此后便被按照类型进行收集。比如负责提供核心语义的 CoreSemanticsModifierNode
、提供点击相关语义的 ClickableSemanticsNode
等等。
事实上,SemanticsConfiguration 本质上是 Map,各类型语义在收集的时候,会按照对应的 key 进行存储。
接下来,我们以 contentDescription 和 click 两种语义信息为例,阐述 Compose 是如何收集它们到 SemanticsConfiguration 中以供 Accessibility 系统调用的。
2-1. for contentDescription
先来看下 Image 控件的源码,跟一下设置的 contentDescription 会如何传递。
@Composablefun Image(...contentDescription: String?,...) {val semantics = if (contentDescription != null) {Modifier.semantics {this.contentDescription = contentDescriptionthis.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: Stringget() = 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)...}
该代理类需要处理的逻辑非常多,包括:
- 处理 Compose 下
AccessibilityEvent
的产生和发送 - 处理 Compose 下所有 Node 对应的
AccessibilityNodeInfo
实例的生成和提供 - 处理 Compose 下
AccessibilityAction
的响应和执行
下面我们按照这 3 点逐步展开。
4. Compose 中 AccessibilityEvent 的产生和发送
我们以最常见的 window 内容变化 TYPE_WINDOW_CONTENT_CHANGED 的 AccessibilityEvent
为例,阐述 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 = truehandler.post(semanticsChangeChecker)}}...}} finally {subtreeChangedLayoutNodes.clear()}}
semanticsChangeChecker
runnable 会将 Node 相关的 property change 下发,交给 sendSemanticsPropertyChangeEvents() 统一处理。
private val semanticsChangeChecker = Runnable {...checkForSemanticsChanges()...}private fun checkForSemanticsChanges() {...// Property changesendSemanticsPropertyChangeEvents(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] ?: continueval newNode = newSemanticsNodes[id]?.semanticsNodevar 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()
具体完成。
- 检查下目标 View ID 是否合法,以及 Accessibility 系统是否开启
- 调用 createEvent() 构建 AccessibilityEvent 实例
- 调用 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 = trueevent.className = ClassNameevent.packageName = view.context.packageNameevent.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.adjustedBoundsval topLeftInScreen =view.localToScreen(Offset(boundsInRoot.left.toFloat(), boundsInRoot.top.toFloat()))val bottomRightInScreen =view.localToScreen(Offset(boundsInRoot.right.toFloat(), boundsInRoot.bottom.toFloat()))// 设置该 info 在 UI 上的范围 Rectinfo.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()
会执行如下处理:
-
先统一地设置 className 为 “android.view.View”
-
如果对应的 SemanticsNode 是支持 setText Action 的类型,则将 className 设置为 “android.widget.EditText”
-
反之,如果是支持 Text Action 的类型,设置为 “android.widget.TextView”
-
packageName 则统一地被设置为 ComposeView 持有的 context 包名
-
后面则是各种各样的属性填充和所支持的 AccessibilityAction 的声明,比如:
- 检查当前的 View 和已 focus 的 View 是否一致,以决定让该 info 支持清除还是添加无障碍的 focus action;
- 通过 x、y 方向上是否支持滚动以决定是否支持上下、左右滑动的 action 等等;
- 从 SemanticsNode 中取出 text 信息填充到 text 属性中(这个属性是无障碍机制里最常用的);
- 检查 SemanticsNode 是否是 check 类型,并赋值 Checkable 属性;其他的还有 checked、selected 等常用属性;
-
这里需要强调的 1 个非常重要的属性,即 contentDescription,它来自于代码里给控件指定的
contentDescription
信息。第三方的无障碍服务 App 非常依赖该属性进行朗读提示 -
还有 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_FOCUSif (focusedVirtualViewId == virtualViewId) {info.isAccessibilityFocused = trueinfo.addAction(AccessibilityActionCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS)} else {info.isAccessibilityFocused = falseinfo.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 {...// 赋值最重要的一个属性 contentDescriptionif (!semanticsNode.unmergedConfig.isMergingSemanticsOfDescendants ||semanticsNode.replacedChildren.isEmpty()) {info.contentDescription = semanticsNode.infoContentDescriptionOrNull}// 其他的还有 isPassword、isEditable、isEnabled、isFocusable 等属性的填充info.isPassword = semanticsNode.isPasswordinfo.isEditable = semanticsNode.isTextFieldinfo.isEnabled = semanticsNode.enabled()...// 同样的,检查是否支持 OnClick Action// YES 的话,结合 enabled 和 isSelected 状态// 决定 info 的 isClickable 属性以及是否支持 ACTION_CLICK 操作info.isClickable = falsesemanticsNode.unmergedConfig.getOrNull(SemanticsActions.OnClick)?.let {val isSelected =semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Selected) == trueinfo.isClickable = !isSelectedif (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?) {...}
结语
最后我们用一张图把整个流程串起来。
-
首先,
AndroidComposeView
的各 LayoutNode 初始化的时候通过applySemantics()
将各 SemanticsNode 语义节点收集必要的信息并按照类型(OnClick、ContentDescription 等)的 key 存放到 SemanticsConfiguration 中 -
AndroidComposeView 初始化的时候设置特殊的
AccessibilityDelegate
代理类,以告知 View 系统 Compose 下所有的 Accessibility 相关逻辑由该代理完成 -
接着监听
SemanticsConfiguration
里各信息的变化 Property Change- 当某项信息变更的时候,通过上述代理构建相应类型的
AccessibilityEvent
并发送到 Accessibility 系统
- 当某项信息变更的时候,通过上述代理构建相应类型的
-
当 AccessibilityEvent 经过 AccessibilityManagerService 抵达 AccessibilityService 之后,
- AccessibilityService 从 Event 里获取目标的 AccessibilityNode 时,上述代理会依据 View ID 从
SemanticsNode
里获取该控件的信息,以创建合适的AccessibilityNodeInfo
实例
- AccessibilityService 从 Event 里获取目标的 AccessibilityNode 时,上述代理会依据 View ID 从
-
AccessibilityService 对 AccessibilityNodeInfo 数据进行分析之后,可以按需发送
AccessibilityAction
。经过 AccessibilityManagerService 抵达 AndroidComposeView 后,依据通过上述代理进行performAction()
,- 此后会依据 Action 的类型 key 去 SemanticsConfiguration 里查找到对应的 Action Unit 和执行
简单来说,Compose 布局上所有的 Accessibility 逻辑都是通过特殊的 AccessibilityDelegate 完成,包括:
- 监听语义信息的变化发送无障碍事件 AccessibilityEvent
- 接收无障碍节点 AccessibilityNode 的查找,封装节点并返回
- 响应无障碍操作 AccessibilityAction 的请求,去找到对应的 Compose Node 执行 Click、Focus 等操作
相关文章:
一文读懂 Compose 支持 Accessibility 无障碍的原理
前言 众所周知,Compose 作为一种 UI 工具包,向开发者提供了实现 UI 的基本功能。但其实它还默默提供了很多其他能力,其中之一便是今天需要讨论的:Android 特色的 Accessibility 功能。 采用 Compose 搭建的界面,完美…...
Redis到底支不支持事务?
文章目录 一、概述二、使用1、正常执行:2、主动放弃事务3、全部回滚:4、部分支持事务:5、WATCH: 三、事务三阶段四、小结 redis是支持事务的,但是它与传统的关系型数据库中的事务是有所不同的 一、概述 概念: 可以一次执行多个命令,本质是一…...
美颜相机「BeautyCam」v12.0.80 祛广告解索会员版(美妆相机功能,展现女神魅力)
软件介绍 美颜相机,一款由知名移动互联网企业Meitu Inc.开发的移动设备照片编辑与美化应用,起初主要针对娱乐消费市场,随后集成了商业营销功能。目前,它已跻身全球最受欢迎的手机摄影应用程序之列。在中国,美颜相机和…...
Oracle的优化器
sql优化第一步:搞懂Oracle中的SQL的执行过程 从图中我们可以看出SQL语句在Oracle中经历了以下的几个步骤: 语法检查:检查SQL拼写是否正确,如果不正确,Oracle会报语法错误。 语义检查:检查SQL中的访问对象…...
[线程与网络] 网络编程与通信原理(六):深入理解应用层http与https协议(网络编程与通信原理完结)
🌸个人主页:https://blog.csdn.net/2301_80050796?spm1000.2115.3001.5343 🏵️热门专栏:🍕 Collection与数据结构 (92平均质量分)https://blog.csdn.net/2301_80050796/category_12621348.html?spm1001.2014.3001.5482 🧀Java …...
个人博客的未来出路在哪里?
说起个人博客的未来这就是个悲伤的话题,估计不少个人博客站长们都在苦苦的坚持和挣扎着吧,反正明月这两年感受最深刻的就是又有不少个人博客站点停更和 404 了都。自从坚持写博客这近十来年这种情况也都见怪不怪了,但这两年最突出的就是很多站长都是迷茫和悲观。 明月去年在…...
【TensorFlow深度学习】实现Actor-Critic算法的关键步骤
实现Actor-Critic算法的关键步骤 实现Actor-Critic算法的关键步骤:强化学习中的双剑合璧Actor-Critic算法简介关键实现步骤代码示例(使用TensorFlow)结语 实现Actor-Critic算法的关键步骤:强化学习中的双剑合璧 在强化学习的广阔…...
微服务架构-可见可观测与量化分析体系
目录 一、可见可观测 1.1 概述 1.2 服务可见性 1.2.1 概述 1.2.2 服务描述 1.2.3 服务所有权 1.2.4 服务对外接口 1.2.5 服务SLA 1.2.6 服务的上下游拓扑 1.2.7 服务变更 1.2.8 服务接入和资源配额管理 1.2.9 服务线上部署和线下测试环境信息 1.3 变更可见性 1.4 …...
PostgreSQL的视图pg_indexes
PostgreSQL的视图pg_indexes 基础信息 OS版本:Red Hat Enterprise Linux Server release 7.9 (Maipo) DB版本:16.2 pg软件目录:/home/pg16/soft pg数据目录:/home/pg16/data 端口:5777pg_indexes 是 PostgreSQL 中的一…...
暂停系统更新
电脑左下角搜索注册表编辑器 计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings 找到这个目录 打开FlightSettingsMaxPauseDays,没找到的话就创建一个同名文件夹然后选择10进制填入3550 最后进入系统暂停更新界面选择最下面…...
Python离线查询IP地址对应的国家和城市
使用场景: 在没网的情况下使用python代码实现对ip地址进行查询国家和地市 代码实现: 需要安装 pip install geoip2 库 import geoip2.databasedef get_location_by_ip(ip_address, db_path):reader geoip2.database.Reader(db_path)try:response r…...
使用Aspose技术将Excel/Word转换为PDF
简介:本文将介绍如何使用Aspose技术将Excel文件转换为PDF格式。我们将使用Aspose-Cells-8.5.2.jar包,并演示Java代码以及进行测试。 一、Aspose技术概述 Aspose是一款强大的文档处理库,支持多种编程语言,如Java、C#、Python等。…...
Opencv 色彩空间
一 核心知识 色彩空间变换; 像素访问; 矩阵的、-、*、、; 基本图形的绘制 二 颜色空间 RGB:人眼的色彩空间; OpenCV默认使用BGR; HSV/HSB/HSL; YUV(视频); 1 RGB 2 BGR 图像的多种属性 1 访问图像(Ma…...
FileZilla:不安全的服务器,不支持 FTP over TLS 原因与解决方法
今天在用FileZilla Client连接某个主机的FTP的时候,主机地址、账号、密码、端口确定百分之百正确的情况下,结果报错如下: 状态: 正在解析 x.x.x 的地址 状态: 正在连接 x.x.x.x:21... 状态: 连接建立,等待欢迎消息... 状态: 不安全…...
自定义注解实现Excel 导出
概述 一个用自定义注解实现导出字段定义的工具实现。 1. 注解定义,定义导出Excel的字段 Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface PoiExportField {// Label of the columnString label();// Order of the column,default 0,means t…...
先求生存,再谋发展:俞敏洪的创业哲学与产品创新之路
引言: 在创业的道路上,每一个创业者都面临着无数的挑战和选择。俞敏洪,新东方教育科技集团的创始人,以其独特的创业哲学和坚韧不拔的精神,带领新东方从一个小小的培训机构成长为全球知名的教育品牌。他的成功经验告诉…...
【Spark】直接从DataFrame的schema创建表
// 基于DataFrame创建表 def createTable(dataFrame: DataFrame,partitionColumns: Array[String],databaseName: String,tableName: String): Unit = {...
Decimal要从str转换以避免精度问题
最近遇到一个python的小数的问题,本来应该很简单的小于判断,无论如何都不正确,而且浮点小数都没问题,但decimal小数有问题,给我整蒙了,后来才发现是对decimal不了解所致,如果你还用float转decim…...
STM32项目分享:智能家居安防系统
目录 一、前言 二、项目简介 1.功能详解 2.主要器件 三、原理图设计 四、PCB硬件设计 1.PCB图 2.PCB板及元器件图 五、程序设计 六、实验效果 七、资料内容 项目分享 一、前言 项目成品图片: 哔哩哔哩视频链接: https://www.bilibili.c…...
qt c++类继承QWidget和不继承有什么区别
class CheckBoxSetting {Q_OBJECT public:CheckBoxSetting(); };和 class CheckBoxSettingsEditor : public QWidget {Q_OBJECTpublic:explicit CheckBoxSettingsEditor(QWidget *parent 0);~CheckBoxSettingsEditor();有什么区别? 这两个类 CheckBoxSetting 和 C…...
什么是SIEM
SIEM 解决方案是一种企业级应用程序,可集中和自动化与网络安全相关的操作,该工具通过收集、分析和关联从组织 IT 基础设施中的各种实体聚合的网络事件来帮助应对网络威胁。 与帮助监控和评估组织物理空间中的危险的监视控制台相比,SIEM解决方…...
浅谈一下实例化
实例化对象是面向对象编程中非常重要的概念,它允许我们根据类的定义创建具体的对象,并操作这些对象的属性和方法。下面具体谈一下实例化对象的一些特点和用途: 封装性和复用性:实例化对象可以将数据和行为封装在一起,从…...
【人工智能】第三部分:ChatGPT的应用场景和挑战
人不走空 🌈个人主页:人不走空 💖系列专栏:算法专题 ⏰诗词歌赋:斯是陋室,惟吾德馨 目录 🌈个人主页:人不走空 💖系列专栏:算法专题 ⏰诗词歌…...
FLV 文件格式
FLV 总体结构 FLV 文件由 FLV文件头(FLV Header)和 FLV文件体(FLV Body)组成。 FLV 文件体由若干级联的 FLV标签(FLV Tag)组成。标签使用一个 PreviousTagSize(uint32_t)来保存前一个 FLV 标签的大小,第一个 PreviousTagSize 值为0。 一个 FLV 文件中的所有数据,如 视频…...
FENDI CLUB精酿啤酒品鉴体验
当提及“品质卓越,口感非凡”的啤酒时,FENDI CLUB精酿啤酒无疑是一个值得一试的选择。这款啤酒以其独特的酿造工艺和优质的原料,为消费者带来了与众不同的味觉享受。 一、独特的酿造工艺 FENDI CLUB精酿啤酒在酿造过程中,严格遵循…...
前端 CSS 经典:水波进度样式
前言:简单实现水波进度样式,简单好看。 效果图: 代码实现: <!DOCTYPE html> <html lang"en"><head><meta charset"utf-8" /><meta http-equiv"X-UA-Compatible" cont…...
深入解析CSS中的块级元素
块级元素在CSS中是一种常见的元素类型,具有一些特定的表现和行为特征。了解块级元素的定义和特点对于掌握CSS布局和样式设计至关重要。本文将从多个角度深入解析CSS中的块级元素,探讨其含义、特点以及在页面布局中的应用。 什么是块级元素? …...
PDF裁剪网站
裁剪 PDF – 修剪 PDF 文件中不需要的空白...
数据结构复习指导之外部排序
目录 外部排序 复习提示 1.外部排序的基本概念 2.外部排序的方法 2.1对大文件排序时使用的排序算法(2016) 3.多路平衡归并与败者树 4.置换-选择排序(生成初始归并段) 4.1置换-选择排序生成初始归并段的实例(2023) 5.最佳…...
【Python报错】已解决TypeError: can only concatenate str (not “int“) to str
解决Python报错:TypeError: can only concatenate str (not “int”) to str 在Python中,字符串连接是常见的操作,但如果你尝试将整数(int)与字符串(str)直接连接,会遇到TypeError: …...
免费的网站程序哪里好/公众号引流推广平台
Error ORA-03113: 通信通道的文件结尾进程 ID: 2232会话 ID: 1250 序列号: 这是oracle 报的错误, 可能这个03113这个编码的错误有很多。 但是要找到是什么原因就需要根据2232这个编码找到 这个日志文件 D:\app\Administrator\diag\rdbms\orcl\orcl\trace\orcl_ora_2…...
weui.css做网站/学生个人网页制作成品代码
液晶画面模式用 播放用(基本信息显示) 取景器模式用 在自动模式或场景选择模式下 在P/A/S/M/扫描全景模式下 1.上部图示 照相模式 存储号码 场景识别图标 存储卡/上传状态 100 剩余可拍摄影像数 静止影像的纵横比 ILCE-7: 24M&#x…...
什么是网站源码/深圳龙岗区优化防控措施
结构体初阶一、结构体类型的声明1、什么是结构?2、结构的声明3.结构成员的类型:二、结构体变量的初始化三、结构体成员访问四、结构体传参case1:传值访问case2:传址访问一、结构体类型的声明 1、什么是结构? 数组是一…...
网站开发流程博客/广告营销策划
引入模块的方式: 1. import 模块 2. from xxx import 模块 一、collections 模块 1.Counter() counter是一个计数器,主要用来计数,计算一个字符串中每个字符出现的次数 1 from collections import Counter 2 s "我要从南走到北…...
重庆忠县网站建设公司哪家好/seo和点击付费的区别
文章目录七、再谈初始化八、protected关键字九、继承方式十、final关键字1.修饰变量或字段,表示常量(即不可修改)2.修饰类:表示此类不能被继承十一、继承和组合七、再谈初始化 继承关系上的执行顺序 代码如下(示例&a…...
网站设计费用志/网络营销的几种模式
域名泛解析什么意思 在域名前添加任何子域名,均可访问到所指向的网站。也就是客户的域名yfi6.com之下所设的*.yfi6.com全部域名均可访问。 域名泛解析怎么设置 泛域名解析是指将*.域名解析到同一IP。 泛域名解析和域名解析有何不同? 泛域名解析是指&a…...