通过无障碍控制 Compose 界面滚动的实战和原理剖析

2,274 阅读7分钟

Compose-base-accessibility-action-compose.png

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

针对 Compose UI 工具包,开发者不仅需要掌握如何使用新的 UI 组件达到 design 需求,更需要了解和实现与 UI 的交互逻辑。

比如 touch 事件、Accessibility 事件等等。

那么将两个话题相结合,不禁要好奇:利用 Accessibility 针对 Compose 界面模拟 touch 交互,是否真的有效,个中原理又如何?

本文将通过无障碍 DEMO 对 Google Compose 项目 Accompanist 中的 Horizontal Pager sample 模拟注入 Scroll 滚动事件,看下实际效果,并对原理链路进行剖析。

向 Compose 模拟滚动事件

无障碍 DEMO,本来想直接复用曾经红极一时的 AccessibilityTool 开源项目。奈何代码太老编译不过,遂直接写了个 DEMO 来捕捉 AccessibilityEvent 然后分析 AccessibilityNodeInfo。

当发现是节点属于 Accompanist 的包名(com.google.accompanist.sample),且可滚动 scrollable 的话,通过无障碍模拟注入 ACTION_SCROLL_FORWARD 的 action。

 public class MyAccessibilityService extends AccessibilityService {
     ...
     @Override
     public void onAccessibilityEvent(AccessibilityEvent event) {
         Log.i(TAG, "onAccessibilityEvent() event: " + event);
 ​
         AccessibilityNodeInfo root;
         ArrayList<AccessibilityNodeInfo> roots = new ArrayList<>();
         ArrayList<AccessibilityNodeInfo> nodeList = new ArrayList<>();
 ​
         try {
             switch (event.getEventType()) {
                 case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
                     Log.i(TAG, "TYPE_WINDOW_STATE_CHANGED()");
                     
                     roots.add(service.getRootInActiveWindow());
                     findAllNode(roots, nodeList);
 ​
                     printComposeNode(nodeList);
 ​
                     roots.clear();
                     nodeList.clear();
                     break;
                 ...
             }
         } catch (Throwable e) {
             e.printStackTrace();
         }
     }
     
     private void printComposeNode(ArrayList<AccessibilityNodeInfo> root) {
         for (AccessibilityNodeInfo node : root) {
             if (node.getPackageName().equals("com.google.accompanist.sample")
                     && node.getClassName().equals("android.view.View")) {
                 node.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
             }
         }
     }
     ...
 }

《一文读懂 Compose 支持 Accessibility 无障碍的原理》 里我们介绍过,Compose 通过无障碍代理 AccessibilityDelegate 依据 UI 组件的类型、情况,进行 AccessibilityNodeInfo 实例的构造。

为了兼容传统 View 的内容,会针对实例里的 className 属性进行一定程度的了改写,但范围有限。

像 LazyColumn 这种的组件,并没有和传统的可滚动的 ListView、ScrollView、RecylerView 的名称进行转换,用的仍然是默认的 View 名称。

所以咱们的无障碍 DEMO 不能像以前那样在判断 isScrollable 之外再额外判断 ListView 等传统可滚动 View 的名称了。

话不多说,我们将无障碍 DEMO 在系统的无障碍设置中启用,选择 "allow" 即可。

ddd

然后运行下 Accompanist 的 Horizontal Pager 界面,打印下收集到的 AccessibilityNodeInfo 信息。

android.view.accessibility.AccessibilityNodeInfo@1cfed; ... 
packageName: com.google.accompanist.sample; className: android.view.View; ... 
enabled: true; ... scrollable: true; ...  
actions: [AccessibilityAction: ... AccessibilityAction: ACTION_SCROLL_FORWARD - null]...

可以看到:

  • className 果然是 android.view.View
  • scrollable 是 true
  • 支持的 AccessibilityAction 有 ACTION_SCROLL_FORWARD 等

模拟滚动的效果如下,可以看到一打开 Horizontal Pager 的界面,就自动往右进行了翻页。

ddd

Compose 支持模拟滚动的原理

滚动界面 Horizontal Pager

想了解 Compose 支持通过无障碍模拟滚动的原理,首先需要了解一下 Horizontal Pager 界面的布局和物理手势上触发滚动的一些背景知识。

ddd

该布局主要采用 TopAppBar 展示 Title 栏,内容区域由 Column 组件堆叠。其中:

  • ScrollableTabRow 负责可以横向滚动的 Tab 栏的内容展示
  • HorizontalPager 负责各 Tab 对应内容的展示,会依据 page index 展示对应的 Text 文本,还需要监听 scroll 手势进行横向滚动

ScrollableTabRow 还需要监听 Tab 的点击事件进行 PagerState 的滚动,采用 animateScrollToPage() 进行。

     class HorizontalPagerTabsSample : ComponentActivity() {
         override fun onCreate(savedInstanceState: Bundle?) {
             ...
             setContent {
                 AccompanistSampleTheme {
                     Surface {
                         Sample()
                     }
                 }
             }
         }
     }
     ​
     @Composable
     private fun Sample() {
         Scaffold(
             topBar = {
                 TopAppBar(
                     title = { Text(stringResource(R.string.horiz_pager_title_tabs)) },
                     backgroundColor = MaterialTheme.colors.surface,
                 )
             },
             modifier = Modifier.fillMaxSize()
         ) { padding ->
             val pages = remember {
                 listOf("Home", "Shows", "Movies", "Books", "Really long movies", "Short audiobooks")
             }
     ​
             Column(Modifier.fillMaxSize().padding(padding)) {
                 ...
                 ScrollableTabRow(
                     selectedTabIndex = pagerState.currentPage,
                     ...
                 ) {
                     pages.forEachIndexed { index, title ->
                         Tab(
                             ...
                             onClick = {
                                 coroutineScope.launch {
                                     pagerState.animateScrollToPage(index)
                                 }
                             }
                         )
                     }
                 }
     ​
                 HorizontalPager(
                     ...
                 ) { page ->
                     Card {
                         Box(Modifier.fillMaxSize()) {
                             Text(
                                 text = "Page: ${pages[page]}",
                                 ...
                             )
                         }
                     }
                 }
             }
         }
     }

animateScrollToPage() 的实现如下,主要是依据 page 计算滚动的 index 和 scrollOffset。然后调用通用的 LazyListState 的 animateScrollToItem() 执行 smooth 的滚动操作。

         public suspend fun animateScrollToPage(
             @IntRange(from = 0) page: Int,
             @FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f,
         ) {
             requireCurrentPage(page, "page")
             requireCurrentPageOffset(pageOffset, "pageOffset")
             try {
                 ...
                 if (pageOffset.absoluteValue <= 0.005f) {
                     lazyListState.animateScrollToItem(index = page)
                 } else {
                     lazyListState.scroll { }
                     ...
     ​
                     if (target != null) {
                         lazyListState.animateScrollToItem(
                             index = page,
                             scrollOffset = ((target.size + itemSpacing) * pageOffset).roundToInt()
                         )
                     } else if (layoutInfo.visibleItemsInfo.isNotEmpty()) {
                         ...
                     }
                 }
             } finally {
                 onScrollFinished()
             }
         }

animateScrollToItem() 由 LazyLayoutAnimateScrollScope 完成。

首先需要通过 LazyListState 的 scroll() 挂起函数请求准备执行 scroll 处理,获得调度之后通过 lambda 回调最重要的步骤:ScrollScope 的 scrollBy()。

     internal suspend fun LazyLayoutAnimateScrollScope.animateScrollToItem(
         ...
     ) {
         scroll {
             try {
                 ...
                 while (loop && itemCount > 0) {
                     ...
                     anim.animateTo(
                         target,
                         sequentialAnimation = (anim.velocity != 0f)
                     ) {
                         if (!isItemVisible(index)) {
                             // Springs can overshoot their target, clamp to the desired range
                             val coercedValue = if (target > 0) {
                                 value.coerceAtMost(target)
                             } else {
                                 value.coerceAtLeast(target)
                             }
                             val delta = coercedValue - prevValue
                             val consumed = scrollBy(delta)
                             ...
                         }
     ​
                         if (isOvershot()) {
                             snapToItem(index = index, scrollOffset = scrollOffset)
                             loop = false
                             cancelAnimation()
                             return@animateTo
                         } ...
                     }
     ​
                     loops++
                 }
             } catch (itemFound: ItemFoundInScroll) {
                 ...
             }
         }
     }

在内容区域手动滚动触发 scroll 的入口和点击 Tab 不同,来自 scroll gesture,但后续都是调用 ScrollScope 的 scrollBy() 完成。

详细链路不再赘述,感兴趣的同学可以 debug 跟一下。

     private class ScrollDraggableState(
         var scrollLogic: ScrollingLogic
     ) : DraggableState, DragScope {
         var latestScrollScope: ScrollScope = NoOpScrollScope
         ...
     ​
         override suspend fun drag(dragPriority: MutatePriority, block: suspend DragScope.() -> Unit) {
             scrollLogic.scrollableState.scroll(dragPriority) {
                 latestScrollScope = this
                 block()
             }
         }
         ...
     }

收集滚动的无障碍语义

Compose 界面所需的 Accessibility 信息,都是通过 Semantics 语义机制来收集的,包括:AccessibilityEvent、AccessibilityNodeInfo 和 AccessibilityAction 信息。

Horizontal Pager 界面里负责主体内容展示的 HorizontalPager 组件,本质上是扩展 LazyRow 而来的,而 LazyRow 和 LazyColumn 一样最终经由 LazyList 抵达 LazyLayout 组件。

     internal fun LazyList(
         ...
     ) {
         ...
         LazyLayout(
             modifier = modifier
                 .then(state.remeasurementModifier)
                 .then(state.awaitLayoutModifier)
                 // 收集语义
                 .lazyLayoutSemantics(
                     itemProviderLambda = itemProviderLambda,
                     state = semanticState,
                     orientation = orientation,
                     userScrollEnabled = userScrollEnabled,
                     reverseScrolling = reverseLayout
                 )
                 .clipScrollableContainer(orientation)
                 .lazyListBeyondBoundsModifier(
                     state,
                     beyondBoundsItemCount,
                     reverseLayout,
                     orientation
                 )
                 .overscroll(overscrollEffect)
                 ...
             ...
         )
     }

而 LazyLayout 初始化的时候会调用 lazyLayoutSemantics() 收集语义。

     internal fun Modifier.lazyLayoutSemantics(
         ...
     ): Modifier {
         val coroutineScope = rememberCoroutineScope()
         return this.then(
             remember(
                 itemProviderLambda,
                 state,
                 orientation,
                 userScrollEnabled
             ) {
                 val isVertical = orientation == Orientation.Vertical
                 ...
     ​
                 val scrollByAction: ((x: Float, y: Float) -> Boolean)? = if (userScrollEnabled) {
                     { x, y ->
                         ...
                         coroutineScope.launch {
                             state.animateScrollBy(delta)
                         }
                         true
                     }
                 } else {
                     null
                 }
     ​
                 ...
     ​
                 Modifier.semantics {
                     ...
                     if (scrollByAction != null) {
                         scrollBy(action = scrollByAction)
                     }
                     ...
                 }
             }
         )
     }
     ​
     fun SemanticsPropertyReceiver.scrollBy(
         label: String? = null,
         action: ((x: Float, y: Float) -> Boolean)?
     ) {
         this[SemanticsActions.ScrollBy] = AccessibilityAction(label, action)
     }

lazyLayoutSemantics() 会定义一个 scrollByAction 名称的 AccessibilityAction 实例,然后以 ScrollBy 为 key 存放到语义 map 中等待 Accessibility 机制查找和回调。

无障碍回调滚动 action

当其他 App 通过 AccessibilityNodeInfo 执行了 Action 之后,通过 AIDL 最终会进入目标 App 的 performActionHelper()。

我们以 ACTION_SCROLL_FORWARD 为例,关注下处理逻辑。

     internal class AndroidComposeViewAccessibilityDelegateCompat ... {
         ...    
         private fun performActionHelper(
             ...
         ): Boolean {
             val node = currentSemanticsNodes[virtualViewId]?.semanticsNode ?: return false
             ...
     ​
             when (action) {
                 ...
                 AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD,
                 AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD,
                 android.R.id.accessibilityActionScrollDown,
                 android.R.id.accessibilityActionScrollUp,
                 android.R.id.accessibilityActionScrollRight,
                 android.R.id.accessibilityActionScrollLeft -> {
                     // Introduce a few shorthands:
                     val scrollForward = action == AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD
                     val scrollBackward = action == AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD
                     ...
                     val scrollHorizontal = scrollLeft || scrollRight || scrollForward || scrollBackward
                     val scrollVertical = scrollUp || scrollDown || scrollForward || scrollBackward
                     ...
                     val scrollAction =
                         node.unmergedConfig.getOrNull(SemanticsActions.ScrollBy) ?: return false
     ​
                     val xScrollState =
                         node.unmergedConfig.getOrNull(SemanticsProperties.HorizontalScrollAxisRange)
                     if (xScrollState != null && scrollHorizontal) {
                         var amountToScroll = viewport.width
                         if (scrollLeft || scrollBackward) {
                             amountToScroll = -amountToScroll
                         }
                         if (xScrollState.reverseScrolling) {
                             amountToScroll = -amountToScroll
                         }
                         if (node.isRtl && (scrollLeft || scrollRight)) {
                             amountToScroll = -amountToScroll
                         }
                         if (xScrollState.canScroll(amountToScroll)) {
                             return scrollAction.action?.invoke(amountToScroll, 0f) ?: false
                         }
                     }
     ​
                     val yScrollState =
                         node.unmergedConfig.getOrNull(SemanticsProperties.VerticalScrollAxisRange)
                     if (yScrollState != null && scrollVertical) {
                         ...
                         if (yScrollState.canScroll(amountToScroll)) {
                             return scrollAction.action?.invoke(0f, amountToScroll) ?: false
                         }
                     }
     ​
                     return false
                 }
                 ...
             }
         }
  1. 当 Action 类型为 ACTION_SCROLL_FORWARD 的时候,赋值 scrollForward 变量
  2. 从 node 里获取是否支持 x 轴滚动:xScrollState
  3. 两者皆 OK 的话,从语义 map 里以 ScrollBy 为 key 查到的 AccessibilityAction 实例并回调

该 Action 即回到了语义收集时注入的 lambda:

     coroutineScope.launch {
         state.animateScrollBy(delta)
     }

State 的实现为 LazyLayoutSemanticState。

     internal fun LazyLayoutSemanticState(
         state: LazyListState,
         isVertical: Boolean
     ): LazyLayoutSemanticState = object : LazyLayoutSemanticState {
         ...
         override suspend fun animateScrollBy(delta: Float) {
             state.animateScrollBy(delta)
         }
         ...
     }

其 animateScrollBy() 实际通过 LazyListState 的 animateScrollBy() 进行,其最终调用 ScrollScope 的 scrollBy()。

虽然入口稍稍不同,但最后的逻辑便和物理上手动点击 Tab 或者横向 scroll 一样,完成滚动操作,殊途同归。

     suspend fun ScrollableState.animateScrollBy(
         value: Float,
         animationSpec: AnimationSpec<Float> = spring()
     ): Float {
         var previousValue = 0f
         scroll {
             animate(0f, value, animationSpec = animationSpec) { currentValue, _ ->
                 previousValue += scrollBy(currentValue - previousValue)
             }
         }
         return previousValue
     }

结语

compose_accessibility_scroll.drawio.png

《一文读懂 Compose 支持 Accessibility 无障碍的原理》 里已经介绍过 Compose 和 Accessibility 交互的大体原理,这里只将重点的 scroll 差异体现出来。

  1. Compose 启动的时候根据可滚动组件收集对应语义,以 ScrollBy key 存到整体的 SemanticsConfiguration 中
  2. 接着在 Accessibility 激活需要准备 Accessibility 信息的时候,将数据提取到 AccessibilityNode 里发送出去
  3. 当 AccessibilityService 发送了 scroll Action 的时候,经由 AccessibilityDelegate 从 SemanticsConfiguration 里查找到对应的 AccessibilityAction 并执行
  4. scrool 的执行由 ScrollScope 的 scrollBy() 完成,这和物理上执行滚动操作是一样的逻辑。

看了上述的 Compose 原理剖析之后,读者或许能感受到:除了开发者需要留意 UI 以外的交互细节,Compose 实现者更需要考虑如何将 UI 的各方各面和原生的 Android View 进行兼容。

不仅仅包括本文提到的 touch、accessibility,还包括大家不常关注到的相关开发细节。比如:

  • 如何 AndroidView 兼容?
  • 如何嵌套的 AndroidView?
  • 如何支持的 UIAutomator 自动化?
  • 如何支持的 Layout Inspector dump?
  • 如何支持的 Android 视图的性能检查?
  • 如何支持的 AndroidTest 机制?
  • 等等

待 Compose 愈加成熟,对于这些相关的开发能力的支持也会更加完善,后期笔者仍会针对其他部分进行持续的分析和介绍。

推荐阅读