在介绍 LayoutModifier 时我们看过 LayoutNode 的 modifier 属性的源码,它以 LayoutModifier 为分界,将一些 Modifier 在处理 LayoutModifier 之前添加到前一个 LayoutNodeWrapper 的 entities 数组中:
fun addBeforeLayoutModifier(layoutNodeWrapper: LayoutNodeWrapper, modifier: Modifier) {
if (modifier is DrawModifier) {
add(DrawEntity(layoutNodeWrapper, modifier), DrawEntityType.index)
}
if (modifier is PointerInputModifier) {
add(PointerInputEntity(layoutNodeWrapper, modifier), PointerInputEntityType.index)
}
if (modifier is SemanticsModifier) {
add(SemanticsEntity(layoutNodeWrapper, modifier), SemanticsEntityType.index)
}
if (modifier is ParentDataModifier) {
add(SimpleEntity(layoutNodeWrapper, modifier), ParentDataEntityType.index)
}
}
而另一些 Modifier 则在处理 LayoutModifier 之后,添加到新生成的 ModifiedLayoutNode 的 entities 数组中。
由于 DrawModifier 在上一篇已经介绍过,本篇我们来介绍 Before 阶段的 PointerInputModifier 与 SemanticsModifier,二者的源码使用了同一套结构,具有一定的关联性。
1、PointerInputModifier
PointerInputModifier 用于定制触摸(包括手指、鼠标、悬浮)反馈算法,实现手势识别。
1.1 基本用法
最简单的使用方式就是通过 Modifier.clickable() 响应点击事件:
Box(Modifier.size(40.dp).background(Color.Blue).clickable { println("点击事件") })
稍微复杂一点,功能更全面的是使用 Modifier.combinedClickable(),支持单击、双击、长按事件 :
Box(
Modifier
.size(40.dp)
.background(Color.Blue)
.combinedClickable(
onDoubleClick = { println("双击事件") },
onLongClick = { println("长按事件") }
) { println("单击事件") }
)
最后的 lambda 表达式 onClick 表示的单击事件必须要传:
@ExperimentalFoundationApi
fun Modifier.combinedClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
)
再更加底层一点的 API 是 pointerInput(),在它内部调用 detectTapGestures() 可以检测到单击、双击、长按与按压:
Box(modifier = Modifier
.size(40.dp)
.background(Color.Blue)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { println("双击事件") },
onLongPress = { println("长按事件") },
onPress = { println("检测到按压") }
) {
println("单击事件")
}
})
onPress() 是只要有屏幕触碰就会触发,比如分别进行单击、长按与双击,输出如下:
检测到按压
单击事件
检测到按压
长按事件
检测到按压
检测到按压
双击事件
detectTapGestures() 与 combinedClickable() 都提供了对单击、双击、长按的检测,二者有哪些区别?其实从 tap 与 click 这两个单词的含义中可以窥见一二:
- tab 一般是指物理上的触摸,在 detectTapGestures() 中就是真实发生在屏幕上的触摸事件才会触发相应的事件回调,而不会反馈通过鼠标触发的事件
- click 这个点击,除了在物理屏幕上的点击,也可以是系统指令发出的点击。也就是说它既能反馈物理屏幕触发的事件,也能响应诸如鼠标这类的,没有物理触碰,但由系统发出的指令事件
combinedClickable() 的底层是通过 detectTapGestures() 实现的,并且一些效果是通过 onPress 这个触摸反馈达成的。
PointerInputScope 还提供了更更底层的 API —— awaitPointerEventScope(),连事件监听都是自己做的:
Modifier.pointerInput(Unit) {
// 循环监听事件,就是监听一个手势中的所有事件,不加的话检测到一个事件协程就退出了
forEachGesture {
awaitPointerEventScope {
val down = awaitFirstDown()
}
}
}
1.2 基本原理
pointerInput() 使用 composed() 连接了一个 SuspendingPointerInputFilter:
fun Modifier.pointerInput(
key1: Any?,
block: suspend PointerInputScope.() -> Unit
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "pointerInput"
properties["key1"] = key1
properties["block"] = block
}
) {
val density = LocalDensity.current
val viewConfiguration = LocalViewConfiguration.current
remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->
LaunchedEffect(filter, key1) {
filter.coroutineScope = this
filter.block()
}
}
}
SuspendingPointerInputFilter 实现了 PointerInputModifier 接口。PointerInputModifier 除了 SuspendingPointerInputFilter 还有一个实现类 PointerInteropFilter 是负责 Compose 与 View 系统交互的触摸反馈的,与 Compose 本身的内部结构没什么关系,因此我们主要看 SuspendingPointerInputFilter。
原理主要分两部分,一是看遍历 Modifier 链时如何处理 PointerInputModifier,二是看 PointerInputModifier 本身是如何工作的。
PointerInputModifier 的存储
关于遍历 Modifier 链时对 PointerInputModifier 的处理,实际上与 DrawModifier 几乎一致:
fun addBeforeLayoutModifier(layoutNodeWrapper: LayoutNodeWrapper, modifier: Modifier) {
if (modifier is DrawModifier) {
add(DrawEntity(layoutNodeWrapper, modifier), DrawEntityType.index)
}
if (modifier is PointerInputModifier) {
add(PointerInputEntity(layoutNodeWrapper, modifier), PointerInputEntityType.index)
}
if (modifier is SemanticsModifier) {
add(SemanticsEntity(layoutNodeWrapper, modifier), SemanticsEntityType.index)
}
if (modifier is ParentDataModifier) {
add(SimpleEntity(layoutNodeWrapper, modifier), ParentDataEntityType.index)
}
}
PointerInputModifier 也是被添加到 LayoutNodeWrapper 的 entities 数组中,只不过 DrawModifier 是生成 DrawEntity 添加到 entities[0] 的链表头。而 PointerInputModifier 则是生成包含它的 PointerInputEntity,放到 entities[1] 的链表头。
因此,PointerInputModifier 都是对它右侧,距离它最近的那个 LayoutModifier 生效的。并且,如果有多个 PointerInputModifier 作用于同一个 LayoutModifier,那么会按照从左到右的顺序逐个执行 PointerInputModifier。
因为遍历是从右至左,但是将 PointerInputModifier 放入链表时采用的是头插法,这样左侧虽然后被遍历到,但是它会被插入到链表头,执行时从链表头开始执行,从 Modifier 链的角度看就是从左到右的顺序执行。这些结论在讲 DrawModifier 时已经详细说明过。
PointerInputModifier 工作原理
接下来看被存起来的 PointerInputModifier 链表是如何响应触摸事件的。
入口代码是 LayoutNode 的 hitTest():
internal fun hitTest(
pointerPosition: Offset,
hitTestResult: HitTestResult<PointerInputFilter>,
isTouchEvent: Boolean = false,
isInLayer: Boolean = true
) {
val positionInWrapped = outerLayoutNodeWrapper.fromParentPosition(pointerPosition)
// 调用最外层 LayoutNodeWrapper 的 hitTest()
outerLayoutNodeWrapper.hitTest(
LayoutNodeWrapper.PointerInputSource,
positionInWrapped,
hitTestResult,
isTouchEvent,
isInLayer
)
}
LayoutNodeWrapper 的 hitTest() 会取出 entities 中 PointerInputEntity 的链表头,并且在点击事件发生在组件内部时调用链表头的 hit():
fun <T : LayoutNodeEntity<T, M>, C, M : Modifier> hitTest(
// 因为有多种类型都会有取链表头的需求(PointerInputEntityType 与
// SemanticsEntityType),所以这里用了参数让调用者指定具体类型
hitTestSource: HitTestSource<T, C, M>,
pointerPosition: Offset,
hitTestResult: HitTestResult<C>,
isTouchEvent: Boolean,
isInLayer: Boolean
) {
// hitTestSource 参数传的 PointerInputSource,因此这里取的是 PointerInputModifier 的链表头
val head = entities.head(hitTestSource.entityType())
if (!withinLayerBounds(pointerPosition)) {
...
} else if (head == null) {
hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)
} else if (isPointerInBounds(pointerPosition)) {
// A real hit
// 重点
head.hit(
hitTestSource,
pointerPosition,
hitTestResult,
isTouchEvent,
isInLayer
)
} else {
...
}
}
调用 PointerInputEntity 链表头节点的 hit():
private fun <T : LayoutNodeEntity<T, M>, C, M : Modifier> T?.hit(
hitTestSource: HitTestSource<T, C, M>,
pointerPosition: Offset,
hitTestResult: HitTestResult<C>,
isTouchEvent: Boolean,
isInLayer: Boolean
) {
// this 指代调用者/接收者,在当前流程中就是 PointerInputEntity 链表头节点
if (this == null) {
hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)
} else {
hitTestResult.hit(hitTestSource.contentFrom(this), isInLayer) {
// 调用 PointerInputEntity 链表下一个节点的 hit()
next.hit(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)
}
}
}
看头节点不为空的情况,调用参数 hitTestResult 的 hit(),我们要关注第 1 个和第 3 个参数。
第 1 个参数 hitTestSource.contentFrom(this)
点进去看是 HitTestSource 接口,然后通过 ctrl + alt + B 找不到它的实现类,因为当前 Android Studio 无法通过这种方式找到接口的匿名实现类,只能在接口 HitTestSource 的名字上点 alt + F7 找到它的匿名对象。有两个,分别是 PointerInputSource 和 SemanticsSource,通过泛型参数可以确定 PointerInputSource 是我们要找的实现对象,看它的 contentFrom():
val PointerInputSource =
object : HitTestSource<PointerInputEntity, PointerInputFilter, PointerInputModifier> {
override fun entityType() = EntityList.PointerInputEntityType
@Suppress("ModifierFactoryReturnType", "ModifierFactoryExtensionFunction")
override fun contentFrom(entity: PointerInputEntity) = entity.modifier.pointerInputFilter
}
}
entity 是 PointerInputEntity 链表的头节点,modifier 就是 PointerInputEntity 所在的 PointerInputModifier,最后的 pointerInputFilter 点进去看是接口属性:
interface PointerInputModifier : Modifier.Element {
val pointerInputFilter: PointerInputFilter
}
接口有两个实现类,我们要的是处理 Compose 内部逻辑的 SuspendingPointerInputFilter:
internal class SuspendingPointerInputFilter(
override val viewConfiguration: ViewConfiguration,
density: Density = Density(1f)
) : PointerInputFilter(),
PointerInputModifier,
PointerInputScope,
Density by density {
override val pointerInputFilter: PointerInputFilter
get() = this
}
这个属性就是返回自己,因此 hitTestSource.contentFrom(this)
就是拿到了头节点的 SuspendingPointerInputFilter。
而第 3 个参数就是将调用 PointerInputEntity 链表下一个节点的 hit() 的逻辑封装到函数参数中了,在后续的流程中会被调用。
然后再回头看 HitTestResult 的 hit():
fun hit(node: T, isInLayer: Boolean, childHitTest: () -> Unit) {
hitInMinimumTouchTarget(node, -1f, isInLayer, childHitTest)
}
fun hitInMinimumTouchTarget(
node: T,
distanceFromEdge: Float,
isInLayer: Boolean,
childHitTest: () -> Unit
) {
val startDepth = hitDepth
hitDepth++
ensureContainerSize()
// 将调用此函数的 node,也就是 PointerInputEntity 按序添加到 values 数组中
values[hitDepth] = node
distanceFromEdgeAndInLayer[hitDepth] =
DistanceAndInLayer(distanceFromEdge, isInLayer).packedValue
resizeToHitDepth()
// 调用前面被封装的 next.hit()
childHitTest()
hitDepth = startDepth
}
node 就是 PointerInputEntity 链表头节点,hitDepth 累加说明 node 是按照顺序被存入 values 数组中的。放入数组后,执行参数的 childHitTest 函数,也就是前面提到的第 3 个参数:
hitTestResult.hit(hitTestSource.contentFrom(this), isInLayer) {
// 调用这个 lambda,执行链表下一个节点的 hit()
next.hit(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)
}
next 是链表的下一个节点,调用 hit() 就形成了递归,不断地做下面两件事:
// 将 PointerInputEntity 链表的节点存入 values 数组
values[hitDepth] = node
// 调用下一个节点的 hit()
childHitTest()
这样一来,我们就能明确,PointerInputModifier 是按照 Modifier 链从左至右的顺序被存储的,执行时也是按照从左至右的先后顺序执行的。
2、SemanticsModifier
SemanticsModifier 用于提供 SemanticsTree —— 语义树。本节将解释什么是语义树,SemanticsModifier 的使用与原理。
2.1 语义树
在 Jetpack Compose 中,语义树(Semantics Tree) 是组合树(Composition Tree)的“增强版”,用于描述 UI 元素的含义和交互属性。
语义树是对组合树进行修剪(对节点进行删除或合并)简化为实际有意义的节点,实际有意义是指可以供用户查看和操作的独立组件。比如以用户角度看 LinearLayout,它不像一个图片那样可以查看或一个按钮那样可以点击,它只是负责布局,对用户而言不可见也没有意义,不被用户关注,这也可被称为没有语义。同理,Compose 中的 Column 如果本身没有设置监听器,那么它所创建出的 LayoutNode 也就是无语义的,这样的节点会被合并或删除,最终形成一个有语义的树,也就是语义树 Semantics Tree。
语义树的核心作用是为无障碍服务(如 TalkBack)、自动化测试框架(如 Espresso)和 UI 分析工具提供结构化信息。
在进行传统的 UI 开发时,一些组件,比如 ImageView、TextView 等,都会有一个 contentDescription 属性,这个属性就是在开启 TalkBack 功能后,视障用户点击到某个组件后,会选中该组件(可以被选中的组件就是语义树中的节点)并以语音方式读出 contentDescription 设置的内容,以帮助视障用户方便地使用 Android 设备。
2.2 基本使用
setContent {
Column {
Text("Jetpack Compose")
Box(
Modifier
.width(100.dp)
.height(60.dp)
.background(Color.Magenta)
)
}
}
上面是一个 Text 和一个 Box,开启 TalkBack 功能后,Text 组件是可被选中的,选中时语音会读出 Text 的文字内容,但 Box 不可被选中:

现在用 semantics() 为 Box 增加 contentDescription 属性:
setContent {
Column {
Text("Jetpack Compose")
Box(
Modifier
.width(100.dp)
.height(60.dp)
.background(Color.Magenta)
.semantics {
contentDescription = "品红色方块"
}
)
}
}
这样 Box 也可以在点击时被选中了,并且 TalkBack 会读出 contentDescription 设置的内容:

semantics() 有两个参数:
/**
* 向布局节点添加语义键值对(key/value pairs),用于测试、无障碍服务等场景。
* 在提供的 lambda 接收者作用域(SemanticsPropertyReceiver)中,可以通过 key = value 的形式
* 为任何 SemanticsPropertyKey 赋值。此外,也支持链式调用多个 semantics 修饰符的写法。
* 最终会生成两棵语义树:
* 未合并的树(Unmerged Tree): 根节点为 SemanticsOwner.unmergedRootSemanticsNode,每个带有
* SemanticsModifier 的布局节点会生成一个对应的 SemanticsNode,该 SemanticsNode 包含该节点上
* 所有 SemanticsModifier 设置的属性
* 合并的树(Merged Tree):根节点为 SemanticsOwner.rootSemanticsNode,节点数量更少
*(基于 mergeDescendants 和 clearAndSetSemantics 进行简化)。大多数场景(尤其是无障
* 碍服务或无障碍测试)应使用合并后的语义树。
*
* 参数说明:
* mergeDescendants(合并子节点语义):是否将当前组件及其子组件的语义信息合并为一个逻辑实体。
* 通常用于可被屏幕阅读器聚焦的组件(如按钮、表单字段)。在合并树中:所有子节点(除非子节点自身也标记了
* mergeDescendants)会从树中移除。子节点的属性会通过特定合并算法合并到父节点(例如,文本属性用逗号拼接)。
* 在未合并树中:仅标记 SemanticsConfiguration.isMergingSemanticsOfDescendants。
* properties(语义属性):通过 SemanticsPropertyReceiver 作用域添加语义属性,支持访问常用属性及
* 其值(如 contentDescription、role 等)。
*/
fun Modifier.semantics(
mergeDescendants: Boolean = false,
properties: (SemanticsPropertyReceiver.() -> Unit)
): Modifier
我们需要了解第一个参数 mergeDescendants 的含义与用法。从名字上能看出,它表示是否合并后代,也就是它的子组件。我们来看个例子:
Button(onClick = { /*TODO*/ }) {
Text("测试文字")
}
上面这个按钮,在开启 TalkBack 点击它后,会语音播放“测试文字,按钮”,它会把 Text 内的文字作为按钮内容被读出,并且你想点击到 Text 上也不行,TalkBack 的选中框只能选中 Button:
发生这样情况的原因是,对于 Button 这种可点击的组件,Compose 在底层实现时,会自动给该组件调用 Modifier.semantics() 并给第一个参数传 true 使得其子组件被合并到该组件中。那假如我现在就想选中 Button 中的 Text,只读取 Text 的内容,那么可以给 Text 设置 Modifier.semantics(true):
Button(onClick = { /*TODO*/ }) {
Text("测试文字", Modifier.semantics(true) { })
}
这样 TalkBack 就可以选中 Text 并读出它的内容了:
与 semantics() 类似的还有一个 clearAndSetSemantics(),它会清除当前组件的所有后代节点的语义,并设置新的语义。比如说:
setContent {
Box(
Modifier
.width(100.dp)
.background(Color.Magenta)
.semantics(true) {
contentDescription = "Jetpack"
}
) {
Text("Compose")
}
}
当前,点击按钮,TalkBack 会读出 “Jetpack Compose”,而不设置 semantics() 的第一个参数为 true 时,即使用默认的 false 时,Box 与 Text 可以分开点击,TalkBack 会读出它们各自的内容,这是我们讲 semantics() 时已经说过的。
现在,将 semantics() 替换成 clearAndSetSemantics():
setContent {
Box(
Modifier
.width(100.dp)
.background(Color.Magenta)
.clearAndSetSemantics {
contentDescription = "Jetpack"
}
) {
Text("Compose")
}
}
那么你就只能选中 Box,无法通过点击选中 Text,并且,选中时只读出 Box 的 contentDescription 属性的内容 “Jetpack”,这意味着 Box 内的后代节点都因为 clearAndSetSemantics() 而从语义树中被移除了,现在语义树中只有 Box 节点本身,语义内容为它设置的 contentDescription 的内容,相当于后代组件的语义被父组件设置的语义吞掉了。
2.3 原理简析
我们前面在讲 PointerInputModifier 的时候,其实有提到过 SemanticsModifier,说它们两个用的是一套结构,如果 PointerInputModifier 的原理你看明白了,那就意味着 SemanticsModifier 的原理也基本拿下了。
还是从两个角度来分析:SemanticsModifier 的底层是如何存储的,以及取出 SemanticsModifier 后如何工作的。
SemanticsModifier 的底层的存储还是在 EntityList 的 addBeforeLayoutModifier() 中:
fun addBeforeLayoutModifier(layoutNodeWrapper: LayoutNodeWrapper, modifier: Modifier) {
if (modifier is DrawModifier) {
add(DrawEntity(layoutNodeWrapper, modifier), DrawEntityType.index)
}
if (modifier is PointerInputModifier) {
add(PointerInputEntity(layoutNodeWrapper, modifier), PointerInputEntityType.index)
}
if (modifier is SemanticsModifier) {
add(SemanticsEntity(layoutNodeWrapper, modifier), SemanticsEntityType.index)
}
if (modifier is ParentDataModifier) {
add(SimpleEntity(layoutNodeWrapper, modifier), ParentDataEntityType.index)
}
}
就是将 SemanticsModifier 封装进 SemanticsEntity,然后把 SemanticsEntity 存入到 EntityList 的成员属性 entities 数组的 SemanticsEntity 链表的头部。
使用是在 LayoutNodeWrapper 的 SemanticsSource 中:
val SemanticsSource =
object : HitTestSource<SemanticsEntity, SemanticsEntity, SemanticsModifier> {
override fun entityType() = EntityList.SemanticsEntityType
override fun contentFrom(entity: SemanticsEntity) = entity
override fun interceptOutOfBoundsChildEvents(entity: SemanticsEntity) = false
override fun shouldHitTestChildren(parentLayoutNode: LayoutNode) =
parentLayoutNode.outerSemantics?.collapsedSemanticsConfiguration()
?.isClearingSemantics != true
override fun childHitTest(
layoutNode: LayoutNode,
pointerPosition: Offset,
hitTestResult: HitTestResult<SemanticsEntity>,
isTouchEvent: Boolean,
isInLayer: Boolean
) = layoutNode.hitTestSemantics(
pointerPosition,
hitTestResult,
isTouchEvent,
isInLayer
)
}
SemanticsSource 与 PointerInputSource 都用了 HitTestSource,也就是 HitTest 进行触摸点测试,通过触摸的点判断触摸到的是哪一个组件。
SemanticsSource 在 LayoutNode 的 hitTestSemantics() 被使用:
@Suppress("UNUSED_PARAMETER")
internal fun hitTestSemantics(
pointerPosition: Offset,
hitSemanticsEntities: HitTestResult<SemanticsEntity>,
isTouchEvent: Boolean = true,
isInLayer: Boolean = true
) {
val positionInWrapped = outerLayoutNodeWrapper.fromParentPosition(pointerPosition)
// hitTest() 用于判断摸到了哪一个组件
outerLayoutNodeWrapper.hitTest(
LayoutNodeWrapper.SemanticsSource,
positionInWrapped,
hitSemanticsEntities,
isTouchEvent = true,
isInLayer = isInLayer
)
}
再向上,AndroidComposeViewAccessibilityDelegateCompat 的 hitTestSemanticsAt() 用到了 hitTestSemantics():
@OptIn(ExperimentalComposeUiApi::class)
@VisibleForTesting
internal fun hitTestSemanticsAt(x: Float, y: Float): Int {
view.measureAndLayout()
val hitSemanticsEntities = HitTestResult<SemanticsEntity>()
view.root.hitTestSemantics(
pointerPosition = Offset(x, y),
hitSemanticsEntities = hitSemanticsEntities
)
val wrapper = hitSemanticsEntities.lastOrNull()?.layoutNode?.outerSemantics
var virtualViewId = InvalidId
if (wrapper != null) {
// The node below is not added to the tree; it's a wrapper around outer semantics to
// use the methods available to the SemanticsNode
val semanticsNode = SemanticsNode(wrapper, false)
val wrapperToCheckAlpha = semanticsNode.findWrapperToGetBounds()
// Do not 'find' invisible nodes when exploring by touch. This will prevent us from
// sending events for invisible nodes
if (!semanticsNode.unmergedConfig.contains(SemanticsProperties.InvisibleToUser) &&
!wrapperToCheckAlpha.isTransparent()
) {
val androidView = view.androidViewsHandler.layoutNodeToHolder[wrapper.layoutNode]
if (androidView == null) {
virtualViewId = semanticsNodeIdToAccessibilityVirtualNodeId(wrapper.modifier.id)
}
}
}
return virtualViewId
}
hitTestSemanticsAt() 在同一个类的 dispatchHoverEvent() 中被调用,而后者在原生的 AndroidComposeView 中被调用:
public override fun dispatchHoverEvent(event: MotionEvent): Boolean {
if (hoverExitReceived) {
// Go ahead and send it now
removeCallbacks(sendHoverExitEvent)
sendHoverExitEvent.run()
}
if (isBadMotionEvent(event) || !isAttachedToWindow) {
return false // Bad MotionEvent. Don't handle it.
}
if (event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN) &&
event.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER
) {
// Accessibility touch exploration
return accessibilityDelegate.dispatchHoverEvent(event)
}
...
}
说明无障碍的触摸是在 dispatchHoverEvent() 中处理的。