Jetpack Compose 之 Modifier(中)

关于Modifier的其余篇章: Jetpack Compose 之 Modifier(上) Jetpack Compose 之 Modifier(下)

Modifier

PointerInputModifier 的功能介绍和原理简析

PointerInputModifier 是负责拦截和处理原始触摸/鼠标等「指针事件」的底层机制。它并不是你直接拿来用的 API,而是通过一系列高级手势和交互(如 clickable { ... }draggable { ... }pointerInput { ... } 等)统一落到它头上来执行。下面分两部分:功能简介 & 原理简析。

Modifier 自带的点击事件的触发方式有两种:

kotlin 复制代码
Modifier.clickable {  } // 单击

fun Modifier.combinedClickable(
    enabled: Boolean = true,                      // 是否启用
    onClickLabel: String? = null,                 // 无障碍描述
    onLongClickLabel: String? = null,             // 无障碍描述
    onClick: () -> Unit,                          // 单击回调
    onLongClick: () -> Unit = {},                 // 长按回调
    onDoubleClick: () -> Unit = {},               // 双击回调
    role: Role? = Role.Button,                    // Accessibility Role
    indication: Indication? = rememberRipple(),   // 点击时的水波纹效果
    interactionSource: MutableInteractionSource    // 用于跟踪按下、释放、焦点等交互状态
)

// 使用:

@Composable
fun CombinedClickableSample() {
    // 用于指示器(Ripple)的状态追踪
    val interactionSource = remember { MutableInteractionSource() }

    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.LightGray)
            .combinedClickable(
                enabled = true,
                onClickLabel = "点击",
                onLongClickLabel = "长按",
                onClick = { Log.d("Demo", "单击了") },
                onLongClick = { Log.d("Demo", "长按了") },
                onDoubleClick = { Log.d("Demo", "双击了") },
                role = Role.Button,
                indication = rememberRipple(bounded = true), 
                interactionSource = interactionSource
            ),
        contentAlignment = Alignment.Center
    ) {
        Text("点我", color = Color.Black)
    }
}

combinedClickableclickable 的区别:

  • clickable 只支持单击;如果同时添加多个手势(如拖拽、滑动)可能会冲突;
  • combinedClickable 同时集成了长按和双击检测,且内部已经做了手势冲突解决,更加健壮。

一、功能简介

  1. 捕获原始指针事件

    • PointerInputModifier 能拦截最底层的触摸事件(MotionEvent)、鼠标事件,甚至滚轮、手写笔等,统一转换成 Compose 内部的 PointerInputEvent
  2. 支持自定义手势

    • 你可以在 Modifier 链上调用

      kotlin 复制代码
      Modifier.pointerInput(key1, key2, ...) { // 类似于自带GestureDetector的onTouchEvent
        // 在协程里 awaitPointerEventScope { ... } / detectTapGestures { ... }  // 监听与点击有关的事件
      }
      // 还可以这样使用:
      Modifier.pointerInput(Unit) {// 监听与点击有关的
        ,detectTapGestures(
            onTap = {}, 
            onDoubleTap = {},
            onLongPress = {},
            onPress = {}) // 摸到屏幕的监听
      
        forEachGesture { // 循环侦测,一般要用,否则只能侦测一次,detectTapGestures 等一系列都是这样实现的。
          awaitPointerEventScope { // 监听每一个触摸事件,例如按下抬起。
            val down = awaitFirstDown()
            // 监听抬起
          }
        }
      }

      Modifier.pointerInput(Unit) 与 combinedClickable 有什么区别呢?combinedClickable 的内部其实也是使用 detectTapGestures 来实现的,安卓和Compose 都支持鼠标,鼠标的点击事件不会触发 onTap ,但是会触发 onClick,另外 combinedClickable 也是没有这个回调的,但是内部其实是利用了这个回调。

上边写到的代码逻辑,这其实会在背后添加一个 PointerInputModifier,启动一个协程来不断接收、处理和消费事件。PointerInputModifier 一共有两个实现类,一个是SuspendingPointerInputFilter, 另一个是 PointerInteropFilter,后者是用来和android中传统的 View 系统交互的, 我们填写的 PointerInputModifier 去哪里呢?其实和之前的 DrawModifier 一样,也是在androidx.compose.ui.node.LayoutNode#modifier 中处理的,因此可以知道,PointerInputModifier 也是对挨着它最近的右侧 LayoutModifier 生效的。

kotlin 复制代码
val outerWrapper = modifier.foldOut(innerLayoutNodeWrapper) { mod, toWrap ->
    if (mod is RemeasurementModifier) {
        mod.onRemeasurementAvailable(this)
    }

    toWrap.entities.addBeforeLayoutModifier(toWrap, mod) // 在这里边处理的。

    if (mod is OnGloballyPositionedModifier) {
        getOrCreateOnPositionedCallbacks() += toWrap to mod
    }

下边这个代码是如何生效的?

kotlin 复制代码
Modifier.pointerInput().pointerInput().size(??) // 左侧的pointerInput是右侧pointerInput的父点击

hitTest就是事件处理的核心机制,

kotlin 复制代码
internal fun hitTest(
    pointerPosition: Offset,
    hitTestResult: HitTestResult<PointerInputFilter>,
    isTouchEvent: Boolean = false,
    isInLayer: Boolean = true
) {
    val positionInWrapped = outerLayoutNodeWrapper.fromParentPosition(pointerPosition)
    outerLayoutNodeWrapper.hitTest(
        LayoutNodeWrapper.PointerInputSource,
        positionInWrapped,
        hitTestResult,
        isTouchEvent,
        isInLayer
    )
}

fun <T : LayoutNodeEntity<T, M>, C, M : Modifier> hitTest(
    hitTestSource: HitTestSource<T, C, M>,
    pointerPosition: Offset,
    hitTestResult: HitTestResult<C>,
    isTouchEvent: Boolean,
    isInLayer: Boolean
) {
    val head = entities.head(hitTestSource.entityType()) // 为什么不直接通过Index去取,而是使用hitTestSource.entityType(),因为hitTest会被两种Modifier都会去使用的,处理 PointerInputModifier 还有 `SemanticsModifier(使用无障碍功能)` 也会使用。
    // 省略其余代码......
        head.hit(
            hitTestSource,
            pointerPosition,
            hitTestResult,
            isTouchEvent,
            isInLayer
        )
    // 省略其余代码......
    }
}

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
) {
    if (this == null) {
        hitTestChild(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer)
    } else {
        hitTestResult.hit(hitTestSource.contentFrom(this), isInLayer) { // hitTestSource.contentFrom(this来源于下边的 contentFrom

            next.hit(hitTestSource, pointerPosition, hitTestResult, isTouchEvent, isInLayer) // 递归调用。
        }
    }
}

val PointerInputSource =
    object : HitTestSource<PointerInputEntity, PointerInputFilter, PointerInputModifier> {
        override fun entityType() = EntityList.PointerInputEntityType

        @Suppress("ModifierFactoryReturnType", "ModifierFactoryExtensionFunction")
        override fun contentFrom(entity: PointerInputEntity) = 
            entity.modifier.pointerInputFilter //  entity.modifier 链表中的头节点

        override fun interceptOutOfBoundsChildEvents(entity: PointerInputEntity) =
            entity.modifier.pointerInputFilter.interceptOutOfBoundsChildEvents

        override fun shouldHitTestChildren(parentLayoutNode: LayoutNode) = true

        override fun childHitTest(
            layoutNode: LayoutNode,
            pointerPosition: Offset,
            hitTestResult: HitTestResult<PointerInputFilter>,
            isTouchEvent: Boolean,
            isInLayer: Boolean
        ) = layoutNode.hitTest(pointerPosition, hitTestResult, isTouchEvent, isInLayer)
    }
  1. 手势冲突 & 事件消费

    • 多个 PointerInputModifier(或更高层的 clickabledraggable)同时存在时,Compose 会按布局树深度与声明顺序,把事件分发到各个 PointerInputModifier,支持「先消费,后阻止向下/向上传递」的逻辑,从而处理好手势冲突。
  2. 与 Compose 渲染解耦

    • 它只关心「事件」,不参与布局测量和绘制,确保手势处理不会阻塞 UI 树的重排或重绘。

二、原理简析

  1. Modifier 树中插入 PointerInputModifierNode

    • 当你调用 Modifier.pointerInput { ... },Compose 会在该节点的 Modifier 列表中插入一个 PointerInputModifierNode,并在布局阶段把它装配到对应的 LayoutNode 里的 pointerInputFilters 列表。
  2. PointerInputDispatcher & Dispatcher Loop

    • 每个 Owner(通常是最顶层的 ComposeView)维护一个 PointerInputDispatcher,它订阅 Android 系统的原始触摸回调(onTouchEvent(MotionEvent))。
    • 系统一旦有触摸事件到来,ComposeView 会把它封装成 PointerInputEvent,交给 dispatcher.dispatch(event)
  3. 事件分发

    • PointerInputDispatcher 会遍历所有注册在各个 LayoutNode 上的 PointerInputModifierNode(按从最里到最外、从深到浅的顺序),依次调用它们的 onPointerEvent()
    • 每个 PointerInputModifierNode 内部会把事件发给自身启动的协程(pointerInput block)里的 awaitPointerEventScope,或调用 pointerInputFilter.pointerInputFilter() 方法。
  4. 协程 & Await 系统

    • pointerInput { ... } 本质上是一个被 LaunchedEffect 管理的协程,它在一个 forEachGestureawaitPointerEventScope 循环中挂起和恢复:

      kotlin 复制代码
      pointerInput(key) {
        awaitPointerEventScope {
          while (true) {
            val event = awaitPointerEvent()
            // 对 event.positions 做判定、消费 (event.changes.consume())
          }
        }
      }
    • onPointerEvent 到来时,会 resume 相应协程,把新事件交给你。

  5. 事件消费 & 迭代

    • 你可以在处理完某些指针变化后,调用 change.consume()(或更细粒度的 consumePositionChange()consumeDownChange())来告诉框架「这个事件部分已经被我消费,不要再分发给更上层或其他 handler」。
  6. 生命周期管理

    • 当对应的 Composable 或 Modifier 离开 Composition,后台会自动取消那个协程并移除 PointerInputModifierNodePointerInputDispatcher 也就不会再给它分发事件,保证不会泄漏。

总结

  • PointerInputModifier = Compose 最底层的「指针事件过滤器」,所有手势 API 都是建立在它之上。
  • 事件流 :Android MotionEventPointerInputEventPointerInputDispatcher → 各 PointerInputModifierNode.onPointerEvent() → 你的 pointerInput { ... } 协程。
  • 它的核心优势在于:基于协程的等待/恢复模型可消费/可拦截与布局/绘制解耦,从而让手势逻辑既灵活又高效。

ParentDataModifier

ParentDataModifier 是一种特殊的 Modifier,它不直接参与子组件的测量或绘制,而是用来将"父布局需要的额外数据"从子组件"上传"到父布局。设置在子组件,但是却是给父组件用的,在布局过程中用于辅助测量的。在经典的 Android View 世界里,这个作用就相当于 LayoutParams(比如 LinearLayout.LayoutParams 中的 weightgravity 等属性);在 Compose 里,就是 ParentDataModifierweightgravity 这些属性用 LayoutModifier 是实现不了的, 因为LayoutModifier只是关注自己的实现。


一、为什么需要 ParentDataModifier

很多布局组件(如 RowColumnBox)在摆放子项时,需要额外的"子项元数据"来决定如何测量或定位它们:

  • RowColumn 里的 weight

  • Box 里的 align 或者 bias

  • 自定义布局里某些子项可能需要提供"占位优先级"或"某个方向上的偏移量"

  • Modifier.layoutId("") 与 传统View 中的 ID 不同,只用用来做辅助测量的,例如下边的自定义 Compose 组件的示例,通过不同的ID触发不同的布局方式。
    *

    kotlin 复制代码
    CustomLayout(Modifier.size(40.dp)) {
        Text(text = "rengwuxian", Modifier.layoutId("big"))
        Text(text = "扔物线", Modifier.layoutId("small"))
        Box(Modifier.size(20.dp).background(Color.Red))
    }
    
    @Composable
    fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
        Layout(content, modifier) { measurables, constraints ->
            measurables.forEach { it: Measurable
                when (it.layoutId) {
                    "big" -> it.measure(constraints.xxxx)
                    "small" -> it.measure(constraints.yyyy)
                    else -> it.measure(constraints)
                }
            }
        }
    }
  • Layout(content = , measurePolicy = ) 用来在最底层做最完整的测量。这是所有 Compose 函数最底层的实现。

这些元数据并不属于子组件本身的绘制或测量逻辑,而是由父布局在布局阶段去读取并据此调整位置/尺寸。ParentDataModifier 就是实现这一传输的桥梁。


二、ParentDataModifier 的写法

写法
  • 前提:首先是要用 Layout() 组件去写一个自定义布局,才会用得到它;

  • 自定义布局的测量和布局的算法里,用 Measurable.parentData 拿到开发者给子组件设置的属性,然后去计算,Measurable.parentData 是用来对多个不同属性进行融合的,而不是对同一个属性做融合。

  • 写一个自定义的 Modifier 函数,让它的内部创建一个 ParentDataModifier,并且实现它的 modifyParentData() 函数,在里面提供对应的 parentData

    • modifyParentData() 的函数参数,是下一个 ParentDataModifier 所提供的数据,可以和自己的数据进行融合后提供。
    • 可以把 Modifier 函数写专门的接口或 object 对象,来避免 API 污染的问题(参照Row的写法)。
kotlin 复制代码
// 使用示例:在 CustomLayout2 中,你可以为子元素添加权重(weightData)和"大"标记(bigData)
CustomLayout2 {
    // 下面这行,编译器知道这是处在 CustomLayout2Scope 的接收者范围内,
    // 可以无冲突地调用 weightData()、bigData()。
    Text(
        "1",
        Modifier
            .weightData(1f)   // 为这个子项设置权重
            .bigData(true)    // 标记为"大"布局
    )
    // 第二个 Text:没有额外的 parentData
    Text("2")
    // 第三个子元素在一个 Box 中,也可以使用 weightData
    Box {
        Text("3", Modifier.weightData(1f)) // 报错,weightData 没有直接在 CustomLayout2 中使用,而是在 CustomLayout2 下的 Box 中使用。
    }
}

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(content, modifier) { measurables, constraints ->
        // 遍历每个可测量项,根据它的 layoutId 决定测量方式
        measurables.forEach { measurable ->
            when (measurable.layoutId) {
                "big" -> measurable.measure(constraints.xxxx)    // "big" 专用约束
                "small" -> measurable.measure(constraints.yyyy)  // "small" 专用约束
                else -> measurable.measure(constraints)          // 默认约束
            }
        }
        // 最终布局大小与放置逻辑
        layout(width = 100, height = 100) {
            // 在这里调用 place() 将子元素放入具体位置
            ...
        }
    }
}

@Composable
fun CustomLayout2(
    modifier: Modifier = Modifier,
    content: @Composable CustomLayout2Scope.() -> Unit
) {
    // 构造一个受 CustomLayout2Scope 扩展的 content
    Layout(
        content = { CustomLayout2Scope.content() },
        modifier = modifier
    ) { measurables, constraints ->

        // ------ 第一步:遍历所有 measurables
        measurables.forEach { measurable ->
            // ------ 第二步:从 measurable.parentData 中取出前面设置的 Layout2Data
            val data = measurable.parentData as? Layout2Data
            // 然后就可以读取出两个自定义属性
            val big = data?.big           // 是否标记为"大"
            val weight = data?.weight     // 权重值
            // 根据 big、weight 进行测量逻辑
            ...
        }

        // 最终布局
        layout(width = 100, height = 100) {
            ...
        }
    }
}

// 用来承载综合数据的类,可以同时使用多个自定义的属性。
class Layout2Data(var weight: Float = 0f, var big: Boolean = false)

@LayoutScopeMarker // 这个扩展函数只在 `CustomLayout2Scope` 内可用, 且必须是直接在里边,而不能是间接的。
object CustomLayout2Scope {
    /**
     * 为当前元素添加一个 weightData(parentData),
     * 该 parentData 会被 CustomLayout2 在测量阶段读取。
     */
    fun Modifier.weightData(weight: Float) = then(object : ParentDataModifier {
        override fun Density.modifyParentData(parentData: Any?): Any? {
            // ------ 第三步:如果已有 parentData,就更新 weight,否则创建新的 Layout2Data
            return if (parentData == null) {
                Layout2Data(weight = weight)
            } else {
                (parentData as Layout2Data).apply {// 参数融合,Modifier.weightData(1f).bigData(true)),因为默认的 bigData 为 false

                    this.weight = weight
                }
            }
        }
    })

    /**
     * 为当前元素添加一个 bigData(parentData),
     * 标记它是否是"大"元素。
     */
    fun Modifier.bigData(big: Boolean) = then(object : ParentDataModifier {
        override fun Density.modifyParentData(parentData: Any?): Any? {
            // 如果已有 Layout2Data,就更新 big;否则新建一个
            return ((parentData as? Layout2Data) ?: Layout2Data()).also {// 参数融合,Modifier.weightData(1f).bigData(true)),因为默认的 weight 为 0f

                it.big = big
            }
        }
    })
}
Row/Column 的 weight
kotlin 复制代码
Row(modifier = Modifier.fillMaxWidth()) {
    Box(
        modifier = Modifier
            .weight(1f) // 这里其实就是添加了一个 ParentDataModifier
            .height(50.dp)
            .background(Color.Red)
    )
    Box(
        modifier = Modifier
            .weight(2f)
            .height(50.dp)
            .background(Color.Blue)
    )
}
  • Modifier.weight(...) 底层插入了一个 ParentDataModifier,把 weight 值"存"在子节点上。
  • Row 在测量时,会遍历它所有子节点,读取每个 child 的 weight(通过 child.parentData as? WeightParentData),然后根据权重分配可用宽度。
Box 的 align
kotlin 复制代码
Box(modifier = Modifier.size(200.dp)) {
    Text(
        "Bottom End",
        modifier = Modifier
            .align(Alignment.BottomEnd)  // 添加了 ParentDataModifier,告诉 Box 在右下角绘制
    )
}
  • Modifier.align() 也是个 ParentDataModifier,它把对齐信息(Alignment)附加给子布局。
  • Box 在布局时,就会检查每个子项的 parentData(BoxParentData),据此决定子项的 x,y 坐标。

三、原理简析

  1. 插入节点

    当你在 Modifier 链里调用如 weight()align() 等 API 时,Compose 会往这个子节点的 Modifier 列表中插入一个实现了 ParentDataModifier 接口的节点。

  2. 数据携带

    这个 Modifier 内部会保存一份针对当前子节点的"父数据"(例如 weight = 1f),并重写 modifyParentData 方法返回一个相应的 ParentData 对象。

  3. 父布局读取

    在父布局的 MeasurePolicy 里(measurelayout 阶段),它会调用类似 child.parentData(或在 DSL 中通过 data = childData())来获取 ParentData

    • Compose 会自动把子节点的所有 ParentDataModifier 累积起来,形成最终的 LayoutNode.parentData
  4. 依据父数据布局

    父布局根据读取到的 parentData 来调整测量规则、分配空间、或者定位坐标。


四、自定义 ParentDataModifier

如果在写自定义布局,也可以定义自己的 ParentDataModifier

kotlin 复制代码
// 1. 定义存储的数据类型
data class MyParentData(val priority: Int)

// 2. 定义 Modifier
fun Modifier.priority(value: Int) = this.then(
    object : ParentDataModifier {
        override fun Density.modifyParentData(parentData: Any?): Any {
            // parentData 可能是上一个 ParentDataModifier 的结果
            return (parentData as? MyParentData)?.copy(priority = value)
                ?: MyParentData(value)
        }
    }
)

// 3. 自定义布局读取
@Composable
fun MyCustomLayout(content: @Composable () -> Unit) {
    Layout(content) { measurables, constraints ->
        // 先测量并获得每个子项的 parentData
        val placeablesWithData = measurables.map { measurable ->
            val placeable = measurable.measure(constraints)
            val pd = measurable.parentData as? MyParentData ?: MyParentData(0)
            placeable to pd.priority
        }
        // 按 priority 排序再布局
        val sorted = placeablesWithData.sortedByDescending { it.second }
        layout(constraints.maxWidth, constraints.maxHeight) {
            var x = 0
            sorted.forEach { (placeable, priority) ->
                placeable.placeRelative(x, 0)
                x += placeable.width
            }
        }
    }
}

这样,我们就通过 Modifier.priority(...) 把一个自定义的"优先级"上传给父布局,并在布局阶段读取并使用它。


ParentDataModifier 的原理

在测量与布局时候起作用,且与右边最近的 LayoutModifier 存在一起,被放进了装入链表的数组:

kotlin 复制代码
Modifier.then(ParentDataModifier1).then(ParentDataModifier2).then(LayoutModifier1)
    .then(ParentDataModifier3).then(ParentDataModifier4).then(LayoutModifier2)
    .then(ParentDataModifier5).then(ParentDataModifier6)

// 上边的代码对应的结构:
ModifiedLayoutNode(
    LayoutModifier1,
    [
        ...
        ParentDataModifier1 -> ParentDataModifier2,
        ...
    ],
    ModifiedLayoutNode(
        LayoutModifier2,
        [
            ...
            ParentDataModifier3 -> ParentDataModifier4,
            ...
        ],
        InnerPlaceable(
            [
                ...
                ParentDataModifier5 -> ParentDataModifier6
                ...
            ]
        )
    )
)

// 完整示例:
@Composable
@Preview
fun ParentDataModifierChainExample() {
  // 定义 6 个 ParentDataModifier,分别返回不同的标记
  val parentDataModifier1 = object : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?): Any? = "PD1"
  }
  val parentDataModifier2 = object : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?): Any? = "PD2"
  }
  val parentDataModifier3 = object : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?): Any? = "PD3"
  }
  val parentDataModifier4 = object : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?): Any? = "PD4"
  }
  val parentDataModifier5 = object : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?): Any? = "PD5"
  }
  val parentDataModifier6 = object : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?): Any? = "PD6"
  }

  // 第一个 LayoutModifier(ModifiedLayoutNode)
  val layoutModifier1 = object : LayoutModifier {
    override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
      // 这里 measurabe.parentData 会是 PD1、PD2 串联的结果
      Log.d("zxc LM1", "parentData = ${measurable.parentData}")  // → "PD3"
      val placeable = measurable.measure(constraints)
      return layout(placeable.width, placeable.height) {
        placeable.place(0, 0)
      }
    }
  }

  // 第二个 LayoutModifier(Nested ModifiedLayoutNode)
  val layoutModifier2 = object : LayoutModifier {
    override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
      // 这里 measurable.parentData 会是 PD3、PD4 串联的结果
      Log.d("zxc LM2", "parentData = ${measurable.parentData}")  // → "PD5"
      val placeable = measurable.measure(constraints)
      return layout(placeable.width, placeable.height) {
        placeable.place(0, 0)
      }
    }
  }

  Box(
    Modifier
      // → ModifiedLayoutNode(LM1) 拥有 [PDM1, PDM2]
      .then(parentDataModifier1)
      .then(parentDataModifier2)
      .then(layoutModifier1)
      // → ModifiedLayoutNode(LM2) 拥有 [PDM3, PDM4]
      .then(parentDataModifier3)
      .then(parentDataModifier4)
      .then(layoutModifier2)
      // → InnerPlaceable 拥有 [PDM5, PDM6]
      .then(parentDataModifier5)
      .then(parentDataModifier6)
  ) {
    Text("看 Logcat")
  }
}
// 结果:
LM1                 com...n.coursecomposemodifierlayout  D  parentData = PD3
LM2                 com...n.coursecomposemodifierlayout  D  parentData = PD5
源码分析

获取数据的起源就在 parentData 中

kotlin 复制代码
measurables.forEach { measurable ->
    // ------ 第二步:从 measurable.parentData 中取出前面设置的 Layout2Data
    val data = measurable.parentData as? Layout2Data
    // 然后就可以读取出两个自定义属性
    val big = data?.big           // 是否标记为"大"
    val weight = data?.weight     // 权重值
    // 根据 big、weight 进行测量逻辑
    ...
}

一切的起源来自:androidx.compose.ui.node.LayoutNodeWrapper#parentData

kotlin 复制代码
override val parentData: Any?
    // 拿到装有 parentData 的链表的表头节点,然后获取 parentData 
    get() = entities.head(EntityList.ParentDataEntityType).parentData

// SimpleEntity 的一个拓展属性就是 parentData
private val SimpleEntity<ParentDataModifier>?.parentData: Any?
    get() = if (this == null) { // 表示这个 ModifiedLayoutNode 的 ParentDataModifier 链表为空
        wrapped?.parentData // 返回它的内部的 ModifiedLayoutNode 的 ParentDataModifier ,这个就是一次递归。
    } else {
        with(modifier) { // 头节点不为空,
            /**
             * ParentData provided through the parentData node will override the data provided
             * through a modifier.
             */
            measureScope.modifyParentData(next.parentData) // 这里就是执行自定义的函数的位置, 比如下边代码的 modifyParentData 。
            // next.parentData 表示链表中的下一个节点。又是一次递归。
        }
    }

/* fun Modifier.priority(value: Int) = this.then(
    object : ParentDataModifier {
        override fun Density.modifyParentData(parentData: Any?): Any {
            // parentData 可能是上一个 ParentDataModifier 的结果
            return (parentData as? MyParentData)?.copy(priority = value)
                ?: MyParentData(value)
        }
    }
) */

对于这个例子:

kotlin 复制代码
Modifier.then(ParentDataModifier1).then(ParentDataModifier2).then(LayoutModifier1)
    .then(ParentDataModifier3).then(ParentDataModifier4).then(LayoutModifier2)
    .then(ParentDataModifier5).then(ParentDataModifier6)

得出的他最终的遍历顺序就是:

kotlin 复制代码
ModifiedLayoutNode
  → ParentDataModifier1
  → ParentDataModifier2
  → ModifiedLayoutNode
     → ParentDataModifier3
     → ParentDataModifier4
     → InnerPlaceable
        → ParentDataModifier5
        → ParentDataModifier6

因此,即便顺序调整之后,最终的显示效果还是一样的,也正因为是提供个父组件用的,所以下边这种写法是没有效果的:

kotlin 复制代码
Box(
  Modifier
    .size(40.dp)
    .background(Color.Green)
    .padding(20.dp)
    .layout { measurable, constraints -> // 无效
      measurable.parentData
    })

这是因为: Modifier.layout { ... } 并不是在写一个「自定义布局」Composable,它只是给当前这个 单独元素 (这里是 Box)挂了一个 LayoutModifier,它的 measure 回调里:

  1. 只能拿到这个元素自己measurable 就代表 Box 本身)的 parentData,而不是你想取的 Box 里子组件parentData

  2. 它的回调签名是

kotlin 复制代码
MeasureScope.(measurable: Measurable, constraints: Constraints) -> MeasureResult

必须在里面调用

kotlin 复制代码
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
  placeable.place(0, 0)
}

否则整个布局根本就不会走测量/放置流程,等同于"空操作"。

小结

  • 作用:在子组件的 Modifier 上携带"父布局需要的元数据",供父布局在测量/布局阶段读取使用。
  • 本质 :Compose 里的 "布局参数" ------ 就像 View 中的 LayoutParams,但更灵活可组合。
  • 使用方式 :内置的 weightalignbaselinePaddinggravity 等;也可以自定义 ParentDataModifier 搭配自定义布局。

SemanticsModifier 的作用、写法和原理

SemanticsModifier 是 Compose 中用来为 UI 元素提供可访问性(Accessibility)以及测试标签信息的关键手段。它会在布局树中生成或修改对应的 SemanticsNode,把**语义(role、state、action、contentDescription、testTag 等)**暴露给无障碍服务(TalkBack)、UI 测试框架或其它需要"看懂"界面结构的工具。


写法

1. 使用 semantics 或高阶 API
  • 最常见 :用 Modifier.semantics { ... }

    kotlin 复制代码
    Box(Modifier
      .size(100.dp)
      .semantics(/*true*/) { // 改为true之后,会与子组件进行合并,同时让自己不会被合并到外部去。
        contentDescription = "用户头像"
        role = Role.Image
        stateDescription = if (selected) "已选中" else "未选中"
        onClick { /* 点击行为 */ true }
      }
    )
  • 专用便捷 API

    Compose 也提供一些常见语义修饰符,如:

    kotlin 复制代码
    Modifier.clickable { ... }           // 自动添加 onClick、role=Button
    Modifier.toggleable(checked) { ... } // 添加 onClick, role=Switch, stateDescription
    Modifier.clearAndSetSemantics {    // 清空下层语义,设置全新语义,绝大多数时候都不需要设置。
      testTag = "LoginButton"
    }
2. 自定义 SemanticsModifier

如果需要更细粒度控制,可以直接实现 SemanticsModifier 接口,本质上它继承自 Modifier.Element

kotlin 复制代码
// 1) 定义一个 PropertyKey
val CountKey = SemanticsPropertyKey<Int>("Count")
// 2) 给 SemanticsConfiguration 的接收者增加一个委托属性
var SemanticsPropertyReceiver.count by CountKey

// 3) 写个 Modifier 扩展,用官方的 semantics{} DSL
fun Modifier.countSemantics(count: Int): Modifier =
    this.then(
        semantics {
            this.count = count
        }
    )
// 4) 使用示例
@Composable
fun Demo() {
    Box(
        Modifier
            .size(80.dp)
            .countSemantics(42)  // 无障碍和测试就能读到 CountKey = 42
    ) {
        Text("Tap me")
    }
}

然后在 Modifier 链中使用:

kotlin 复制代码
Box(Modifier.then(MySemanticsModifier("自定义标签")))

原理

kotlin 复制代码
val PointerInputSource =
    object : HitTestSource<PointerInputEntity, PointerInputFilter, PointerInputModifier> {
        override fun entityType() = EntityList.PointerInputEntityType

        @Suppress("ModifierFactoryReturnType", "ModifierFactoryExtensionFunction")
        override fun contentFrom(entity: PointerInputEntity) =
            entity.modifier.pointerInputFilter

        override fun interceptOutOfBoundsChildEvents(entity: PointerInputEntity) =
            entity.modifier.pointerInputFilter.interceptOutOfBoundsChildEvents

        override fun shouldHitTestChildren(parentLayoutNode: LayoutNode) = true

        override fun childHitTest(
            layoutNode: LayoutNode,
            pointerPosition: Offset,
            hitTestResult: HitTestResult<PointerInputFilter>,
            isTouchEvent: Boolean,
            isInLayer: Boolean
        ) = layoutNode.hitTest(pointerPosition, hitTestResult, isTouchEvent, isInLayer)
    }

/**
 * Hit testing specifics for semantics.
 */
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
        )
    }

internal fun hitTest(
    pointerPosition: Offset,
    hitTestResult: HitTestResult<PointerInputFilter>,
    isTouchEvent: Boolean = false,
    isInLayer: Boolean = true
) {
    val positionInWrapped = outerLayoutNodeWrapper.fromParentPosition(pointerPosition)
    outerLayoutNodeWrapper.hitTest(
        LayoutNodeWrapper.PointerInputSource,
        positionInWrapped,
        hitTestResult,
        isTouchEvent,
        isInLayer
    )
}

@Suppress("UNUSED_PARAMETER")
internal fun hitTestSemantics( // 判断触摸的视图点在哪里。
    pointerPosition: Offset,
    hitSemanticsEntities: HitTestResult<SemanticsEntity>,
    isTouchEvent: Boolean = true,
    isInLayer: Boolean = true
) {
    val positionInWrapped = outerLayoutNodeWrapper.fromParentPosition(pointerPosition)
    outerLayoutNodeWrapper.hitTest(
        LayoutNodeWrapper.SemanticsSource,
        positionInWrapped,
        hitSemanticsEntities,
        isTouchEvent = true,
        isInLayer = isInLayer
    )
}
  1. 语义节点树构建

    • Compose 在布局测量与放置阶段,会同时收集所有 SemanticsModifier(及其它产生语义的 Modifier,如 clickabletoggleable
    • 它们会被包装成一个或多个 SemanticsNode,构成一棵平行于布局树的"语义树"(AccessibilityNodeInfo 在底层映射自这里)。
  2. SemanticsConfiguration 聚合

    • 每个 SemanticsModifier 都提供一个 SemanticsConfiguration,里面是键值对形式的语义属性。
    • 当多个修饰符应用在同一个 UI 节点上,系统会 合并 它们的配置:同一 key 后面的会覆盖前面的,action 列表会累加。
  3. 无障碍桥接

    • 最终框架会把这棵语义树交给 Android Accessibility Framework(AccessibilityNodeInfo),或给测试库(Espresso Compose)解析。
    • TalkBack、UI Automator 等工具就可以读取 contentDescriptionrolestateDescriptiontestTag,甚至触发 onClickcustomActions 等。
  4. 优化与合并策略

    • clearAndSetSemantics { ... } :可清空所有下层语义,防止父组件意外继承子组件的无障碍信息。
    • 合并策略 :如果一个容器和子项都定义了语义,默认会向上合并,生成整体的可访问性焦点区域。

小结

  • 为什么要用:让 Compose UI 对无障碍读屏和自动化测试友好------提供文字描述、角色信息、状态、操作回调。
  • 怎么用 :最常见是 Modifier.semantics { ... } 或者使用框架提供的 clickabletoggleabletestTag 等便捷语义 Modifier。
  • 底层原理 :Compose 会把所有 SemanticsModifier 收集进平行的"语义树",并在渲染时转换成 Android AccessibilityNodeInfo,创建真正的无障碍节点。

addBeforeLayoutModifier-() 和 addAfterLayoutModifier-() 的区别

惟一的区别就是:对于同一个Modifier,当它有多重身份的时候,应该先处理它的哪个身份?例如下边这个例子,这也是目前唯一具有双重身份的Modifier:

kotlin 复制代码
private class PainterModifier(
    val painter: Painter,
    val sizeToIntrinsics: Boolean,
    val alignment: Alignment = Alignment.Center,
    val contentScale: ContentScale = ContentScale.Inside,
    val alpha: Float = DefaultAlpha,
    val colorFilter: ColorFilter? = null,
    inspectorInfo: InspectorInfo.() -> Unit
) : LayoutModifier, DrawModifier, InspectorValueInfo(inspectorInfo) {

但是实际上,

  • addBeforeLayoutModifier: addBefore-() 会让⾥⾯的 Modifier 身份早于 LayoutModifier 身份被处理,导致被包含在 LayoutModifier 所处的 ModifiedLayoutNode 的更内部的⼀层,从⽽受到更内部的layoutNodeWrapper 的尺⼨限制。

  • addAfterLayoutModifier:addAfter-() 会让⾥⾯的 Modifier 身份在 LayoutModifier 身份之后被处理,这样就和 LayoutModifier 处于同⼀个 ModifiedLayoutNode ,从⽽和 LayoutModifier 的尺⼨范围是⼀样的。

kotlin 复制代码
Modifier.then(parentDataModifier1).then(parentDataModifier2).then(onRemeasuredModifier1).then(onRemeasuredModifier2).then(layoutModifier1)
.then(parentDataModifier3).then(parentDataModifier4).then(onRemeasuredModifier3).then(onRemeasuredModifier4).then(layoutModifier2)
.then(parentDataModifier5).then(parentDataModifier6).then(onRemeasuredModifier5)

// 得到的结果:
ModifiedLayoutNode(
    LayoutModifier1,
    [
        ParentDataModifier1 -> ParentDataModifier2,
        onRemeasuredModifier1 -> onRemeasuredModifier2
    ],
    ModifiedLayoutNode(
        LayoutModifier2,
        [
            ParentDataModifier3 -> ParentDataModifier4,
            onRemeasuredModifier3 -> onRemeasuredModifier4
        ],
        InnerPlaceable(
            [
                ParentDataModifier5 -> ParentDataModifier6,
                onRemeasuredModifier5
            ]
        )
    )
)

OnRemeasuredModifier 的作用、写法和原理

作用

OnRemeasuredModifier 对应传统 View 的 onMeasure(),当它修饰的 最近右侧 那个 LayoutModifier(或最内层内容)完成测量后就会回调一次 onRemeasured(size: IntSize),让我们拿到测量出来的宽高信息,以便做接下来的逻辑(比如驱动动画、收集日志或更新状态)。

写法

  1. 直接实现

    kotlin 复制代码
    Text(
      "Hello",
      Modifier
        .padding(20.dp)
        .then(object : OnRemeasuredModifier {
          override fun onRemeasured(size: IntSize) {
            // size 就是右侧 padding(...) 或者紧挨它的 LayoutModifier 测量后的最终尺寸
            Log.d("Demo", "Measured size: $size")
          }
        })
        .padding(40.dp)
    )

    在上面例子里,onRemeasured 会回调 40.dp 这个 padding 测量后的大小,因为它总是与它右侧最近的那个 LayoutModifier 关联。​CSDN博客

  2. 内置封装 onSizeChanged

    kotlin 复制代码
    Text(
      "World",
      Modifier.onSizeChanged { newSize ->
        // 只有当尺寸发生"变化"时才回调一次
        println("Size changed to $newSize")
      }
    )

    Modifier.onSizeChanged { ... } 底层就是一个 OnSizeChangedModifier,它实现了 OnRemeasuredModifier,并且会在每次测量完成后,只有当 newSize != previousSize 时才真正触发。

原理

kotlin 复制代码
internal val nodes = NodeChain(this)
internal val innerCoordinator: NodeCoordinator // 对应旧版本的InnerPlaceable
    get() = nodes.innerCoordinator
internal val layoutDelegate = LayoutNodeLayoutDelegate(this)
internal val outerCoordinator: NodeCoordinator // 对应旧版本的outWrapper,最外层的装着 LayoutModifier 的 用于测量的 LayoutNodeWrapper,它便是最外层的,对应着最左边的 LayoutModifier 的 NodeCoordinator。 在新版本的 Modifier 统一都被放在了 nodes 里边了 ,而不是  放在 NodeCoordinator 的里边了,有了这种存储关系的调整,但是对应关系还是有的。一个 Node 还是对应一个 NodeCoordinator ,NodeCoordinator 依然是布局进行分层的工具。里边会关联着 LayoutModifier 

NodeChain 是用来存放Modifier 的, 而且 这些 Modifier 会被封装进 一个个 Node 对象里边 ,这些 Node存放在哪里呢?就是 head 和 tail ,分别是双向链表的头节点和尾结点。下边就是 Modifier 的初始化保存原理的源码:

kotlin 复制代码
private fun syncCoordinators() {
    var coordinator: NodeCoordinator = innerCoordinator
    var node: Modifier.Node? = tail.parent
    while (node != null) {
        if (node.isKind(Nodes.Layout) && node is LayoutModifierNode) {
            val next = if (node.isAttached) {
                val c = node.coordinator as LayoutModifierNodeCoordinator
                val prevNode = c.layoutModifierNode
                c.layoutModifierNode = node
                if (prevNode !== node) c.onLayoutModifierNodeChanged()
                c
            } else {
                // 处理多层嵌套,与旧版本一样,处理 LayoutModifier 都需要创建 ModifierLayoutModifier 包裹 LayoutModifier 和 与他相关的 非 LayoutModifier
                val c = LayoutModifierNodeCoordinator(layoutNode, node)
                node.updateCoordinator(c)
                c
            }
            coordinator.wrappedBy = next
            next.wrapped = coordinator
            coordinator = next
        } else {
            // 挂载同一个 NodeCoordinator 下边
            node.updateCoordinator(coordinator)
        }
        node = node.parent
    }
    coordinator.wrappedBy = layoutNode.parent?.innerCoordinator
    outerCoordinator = coordinator
}

大逻辑没有变,只是细节上的调整。syncCoordinators() 就是把

kotlin 复制代码
InnerPlaceable  ← Modifier.Node1 ← Modifier.Node2 ← ... ← Modifier.NodeN

这样一条 Modifier.Node 链,映射到

kotlin 复制代码
InnerCoordinator ← LMCoordinator1 ← LMCoordinator2 ← ... ← LMCoordinatorM ← OuterCoordinator

这样一条 NodeCoordinator 链上,其中只有那些实现了 LayoutModifier 的节点会真正生成一个新的 LayoutModifierNodeCoordinator,其它节点则共享它们最近的上层 Coordinator。这样就建立起了「谁在哪层测量/放置/绘制」的清晰对应关系。

onRemeasured 触发的原始逻辑有两处,逻辑几乎相同,他们都是自测量完成之后,再调用内部的onMeasured()。 Compose的自己测量调用方式是

kotlin 复制代码
override fun measure(constraints: Constraints): Placeable = performingMeasure(constraints) {
    // before rerunning the user's measure block reset previous measuredByParent for children
    layoutNode.forEachChild {
        it.measuredByParent = LayoutNode.UsageByParent.NotUsed
    }

    measureResult = with(layoutNode.measurePolicy) {
        measure(layoutNode.childMeasurables, constraints)
    }
    onMeasured()
    return this
}

例如下边的代码:

kotlin 复制代码
@Composable
@Preview
fun MyComposable() {
    Text(text = "Hello World!", Modifier.onSizeChanged { })
    Text("床前明月光", Modifier.then(object : OnRemeasuredModifier {
        override fun onRemeasured(size: IntSize) {
            Log.d("zxc1 ", "onRemeasured: size = $size")
        }
    }))
    Text(
        text = "Hello World!",
      Modifier
        .padding(20.dp)
        // 它只和右边有关,因此在 padding(40.dp) 调用后就会触发这里。且它的 size 是只包含 padding(40.dp) 的尺寸。
        .then(object : OnRemeasuredModifier {
          override fun onRemeasured(size: IntSize) {
            Log.d("zxc2 ", "onRemeasured: size = $size")
          }
        })
        .padding(40.dp)
    )
}
// 输出:
onRemeasured: size = 225 x 61
onRemeasured: size = 513 x 321
kotlin 复制代码
internal val tail: Modifier.Node = innerCoordinator.tail
internal var head: Modifier.Node = tail
  • Compose 在内部会将每个 LayoutModifier 包装成一个 ModifiedLayoutNode,每遇到一个 LayoutModifier 就会划分一个新的"布局节点层";

  • 同时,所有非 LayoutModifier(包括 OnRemeasuredModifier)会分成"Before" 或 "After" 两类链表,分别记录在 LayoutNodeWrapper.entities 里。

  • 当某层的 LayoutNodeWrapper.measure(...) 完成(无论是最内层的 InnerPlaceable 还是某个 ModifiedLayoutNode)时,都会调用它的 onMeasured()

    kotlin 复制代码
    fun onMeasured() {
      if (entities.has(RemeasureEntityType)) {
        Snapshot.withoutReadObservation {
          entities.forEach(RemeasureEntityType) {
            it.modifier.onRemeasured(measuredSize)
          }
        }
      }
    }

这样就保证所有挂在"After LayoutModifier"阶段的 OnRemeasuredModifier 都能准确且及时地拿到 本层 测量结果

OnPlacedModifier 的作用、写法和原理

OnPlacedModifier 是一个 布局"放置"阶段 的回调入口。当你给某个 Composable 加上它,就能在这个节点完成放置(place(...))后、绘制前,拿到它的 LayoutCoordinates ------ 包括它在父布局坐标系/根布局坐标系中的位置、尺寸、父子层级关系等。典型用途有:

  • 读出最终位置或大小,驱动后续动画;
  • 把布局结果暴露给外部状态(比如保存某个控件的屏幕位置);
  • 结合 LocalView 做全局坐标映射,触发业务逻辑。

写法

1. 内置扩展:Modifier.onPlaced { }
kotlin 复制代码
Box(
  Modifier
    .size(100.dp)
    .onPlaced { coords ->
      // coords.positionInParent() / positionInWindow() / size / parent etc.
      Log.d("Demo", "placed at ${coords.positionInWindow()}")
    }
) {
  /*...*/
}
2. 自定义 OnPlacedModifier

如果需要更定制化,或想把逻辑封装成复用组件,可以自己实现:

kotlin 复制代码
class MyOnPlacedModifier(
  val onPlacedAction: (LayoutCoordinates) -> Unit
) : OnPlacedModifier {
  override fun onPlaced(coordinates: LayoutCoordinates) {
    onPlacedAction(coordinates)
  }
}

fun Modifier.onMyPlaced(action: (LayoutCoordinates) -> Unit): Modifier =
  this.then(MyOnPlacedModifier(action))

// 使用
Box(
  Modifier.onMyPlaced { coords ->
    // ...
  }
)

原理

kotlin 复制代码
@OptIn(ExperimentalComposeUiApi::class)
fun onPlaced() {
    val lookahead = lookaheadDelegate
    if (lookahead != null) {
        visitNodes(Nodes.LayoutAware) {
            it.onLookaheadPlaced(lookahead.lookaheadLayoutCoordinates)
        }
    }
    visitNodes(Nodes.LayoutAware) { // 遍历layoutNode那一根保存好 Node 的双链表,只会去遍历指定的类型(Nodes.LayoutAware)。
        it.onPlaced(this)
    }
}
  1. Modifier 链到 LayoutNodeWrapper

    Compose 会把所有实现了 LayoutModifierOnPlacedModifierOnRemeasuredModifier 等接口的 Modifier.Element,收集到内部的 LayoutNodeWrapper 上。

  2. 测量阶段

    每一层 LayoutNodeWrapper.measure(...) 负责给子节点测量尺寸。

  3. 放置阶段

    在最内层完成 place() 并返回后,框架沿着 LayoutNodeWrapper从内到外 调用:

    kotlin 复制代码
    // 伪代码示意
    wrapper.placeChildren { childPlaceable -> 
      childPlaceable.place(...)
    }
    // 放置完本层所有子项后
    if (wrapper.entities.has(PositioningEntityType)) {
      wrapper.entities.forEach(PositioningEntityType) { entity ->
        (entity.modifier as OnPlacedModifier)
          .onPlaced(wrapper.coordinates)
      }
    }

    也就是说:所有挂在这一层的 OnPlacedModifier (以及它们的扩展接口如 onPlacement, onPositioned)都会在本层放置结束后被回调一次。


小结
  • 何时用 :当你需要知道一个 Composable 最终"被放到哪里"或"占了多大区间"时,用 onPlaced 拦截。
  • 用法简单Modifier.onPlaced { coords -> ... } 或自定义 OnPlacedModifier
  • 底层原理 :属于 PositioningModifier 一类,Compose 在放置流程后自动遍历并 invoke。
相关推荐
青皮桔26 分钟前
CSS实现百分比水柱图
前端·css
影子信息31 分钟前
vue 前端动态导入文件 import.meta.glob
前端·javascript·vue.js
青阳流月33 分钟前
1.vue权衡的艺术
前端·vue.js·开源
样子201837 分钟前
Vue3 之dialog弹框简单制作
前端·javascript·vue.js·前端框架·ecmascript
kevin_水滴石穿38 分钟前
Vue 中报错 TypeError: crypto$2.getRandomValues is not a function
前端·javascript·vue.js
孤水寒月2 小时前
给自己网站增加一个免费的AI助手,纯HTML
前端·人工智能·html
CoderLiu2 小时前
用这个MCP,只给大模型一个figma链接就能直接导出图片,还能自动压缩上传?
前端·llm·mcp
伍哥的传说2 小时前
鸿蒙系统(HarmonyOS)应用开发之实现电子签名效果
开发语言·前端·华为·harmonyos·鸿蒙·鸿蒙系统
海的诗篇_2 小时前
前端开发面试题总结-原生小程序部分
前端·javascript·面试·小程序·vue·html
uncleTom6663 小时前
前端地图可视化的新宠儿:Cesium 地图封装实践
前端