Compose编程思想 -- 触摸事件和嵌套滑动事件处理

前言

在上一篇文章中,我介绍了如何在Compose中完成自定义View和自定义ViewGroup,相较于原生的View体系,Compose的自定义View会更加简单方便。那么在这篇文章中,我将会介绍在Compose中如何完成触摸事件和嵌套滑动的处理。

1 Compose中的触摸事件

在原生的View体系中,常见的触摸事件有:ACTION_DOWN、ACTION_MOVE、ACTION_UP,当手指按下时,会遍历View树型结构拿到mFirstTouchTarget,以此将后续的MOVE事件和UP事件都交给这个组件消费,在View中消费事件是通过onTouchEvent方法处理的。

如果我们想要对事件进行拦截,通常会重写onInterceptTouchEvent,根据具体的业务场景来判断是否拦截事件,以及在嵌套的滑动组件中,对于事件冲突的处理尤为重要,所以本节我将会介绍在Compose中如何完成触摸事件的处理。

1.1 Compose中的点击事件

在Compose当中的Modifier提供了clickable函数用于处理点击事件;

kotlin 复制代码
Text(text = "点击我", Modifier.clickable {
    Log.d(TAG, "TestTouchEvent: 单击事件")
})

而对于双击,长按,则是另一个函数combinedClickable来完成。

kotlin 复制代码
fun Modifier.combinedClickable(
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onLongClickLabel: String? = null,
    onLongClick: (() -> Unit)? = null,
    onDoubleClick: (() -> Unit)? = null,
    onClick: () -> Unit
){
    // ......
}

其实从源码中可以看到(这里我不带大家看了,可以自行查看,很简单的源码),像点击手势的处理,是通过Modifier.pointInput来处理的。

kotlin 复制代码
Text(text = "点击我", Modifier.pointerInput(Unit) {
    awaitEachGesture {
        val event = awaitPointerEvent()
        when(event.type){
            PointerEventType.Press ->{
                Log.d(TAG, "TestTouchEvent: Press")
            }
            PointerEventType.Move->{
                Log.d(TAG, "TestTouchEvent: Move")
            }
            PointerEventType.Exit->{
                Log.d(TAG, "TestTouchEvent: Exit")
            }
            PointerEventType.Scroll->{
                Log.d(TAG, "TestTouchEvent: Scroll")
            }
        }
    }
})

Modifier.pointInput算是Compose对于触摸反馈最底层的处理了,通过awaitPointerEvent可以获取用户输入的事件,根据类型判断是Press(点击)、Move(移动)、Scroll(滑动)等事件类型。

kotlin 复制代码
Text(text = "点击我", Modifier.pointerInput(Unit) {
    detectTapGestures {
        Log.d(TAG, "TestTouchEvent: 点击了")
    }
})

或者直接在pointInputScope中通过detectTapGestures来监测点击事件。

这是我之前在介绍Compose时,已经使用过点击事件,这里是简单的对点击事件的底层实现做了介绍,接下来我要介绍一下滑动事件。

1.2 Compose中的滑动事件 - draggable

在Compose中,提供了draggablescrollable函数,用于处理滑动事件,先看下draggable函数。

kotlin 复制代码
fun Modifier.draggable(
    state: DraggableState,
    orientation: Orientation,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    startDragImmediately: Boolean = false,
    onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
    onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
    reverseDirection: Boolean = false
): Modifier {
    // ......
}

draggable函数中,有两个必填的值,stateorientation在滑动组件中,例如LazyColumnPager等,必须要有一个state对象。

1.2.1 LazyColumn中的state对象

LazyColumn中,state默认是执行了rememberLazyListState函数,利用其返回值,也就是得到了LazyListState对象。

kotlin 复制代码
@Composable
fun TestScrollableState() {
    val list = remember {
        mutableStateListOf("A", "B", "C", "D", "E", "F")
    }
    val scope = rememberCoroutineScope()
    val state = rememberLazyListState()
    LazyColumn(state = state) {
        items(list) {
            Text(
                text = "当前字母:$it",
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
        }
    }
    Button(onClick = {
        scope.launch {
            state.scrollToItem(list.size - 1)
        }
    }) {
        Text(text = "定位")
    }
}

那么这个state是干什么用的呢?其实就是为了用来处理列表的滑动,或者监听列表滑动。 我们常见的一个需求就是,当进到某个页面时,需要定位到列表中的某个元素,那么如果使用RecyclerView,那么可以通过scrollToPosition(index)来完成。

但是Compose是声明式的UI,无法拿到组件的实例对象,因此就是通过state来完成滑动的控制,例如点击按钮滑动到列表最后一位,那么就调用state的scrollToItem函数来完成。

1.2.2 draggable函数分析

再回到draggable函数,除了state之外,还需要设置orientation,就是滑动的方向。因为draggable是监听一维方向的滑动, 因此只能拿到x轴或者y轴方向上滑动偏移量。

kotlin 复制代码
@Composable
fun TestDraggable() {

    Text(
        text = "悬浮窗",
        Modifier
            .size(200.dp)
            .background(Color.Blue)
            .draggable(rememberDraggableState {
                Log.d(TAG, "TestDraggable: $it")
            }, Orientation.Horizontal)
    )

}

因为draggable也需要一个state,一般情况下都是会使用rememberDraggableState来生成一个DraggableState,我们看其回调值其实就是一个float类型的参数,意味着draggable就是用来检测一维方向上的偏移量。

kotlin 复制代码
@Composable
fun rememberDraggableState(onDelta: (Float) -> Unit): DraggableState {
    val onDeltaState = rememberUpdatedState(onDelta)
    return remember { DraggableState { onDeltaState.value.invoke(it) } }
}

还有一个参数,不是必填项,就是interactionSource,能够反映此时的用户与界面的交互状态,假设有个需求,当组件拖拽的时候,需要显示某个文案;停止拖拽之后显示另一个文案。

kotlin 复制代码
@Composable
fun TestDraggable() {

    val interaction = remember {
        MutableInteractionSource()
    }
    Column {

        Text(
            text = "悬浮窗",
            Modifier
                .size(200.dp)
                .background(Color.Blue)
                .draggable(rememberDraggableState {
                    Log.d(TAG, "TestDraggable: $it")
                }, Orientation.Horizontal, interactionSource = interaction)
        )
        val isDragged by interaction.collectIsDraggedAsState()
        if (isDragged) {
            Text(text = "正在拖拽")
        } else {
            Text(text = "静止状态中")
        }
    }

}

可以将MutableInteractionSource转换为可监听的State,当拖动状态发生变化时,可以监听到。

1.2.3 通过draggable实现拖拽效果

在前面我提到,所有的滑动组件都会使用到state,像draggable中使用到的rememberDraggableState可以拿到一维方向的偏移量,那么肯定能够在偏移量上做文章,实现拖拽效果。

kotlin 复制代码
@Composable
fun TestDraggable() {

    val interaction = remember {
        MutableInteractionSource()
    }
    // x轴的滑动距离
    var scrollX = remember {
        mutableStateOf(0)
    }
    Column {

        Text(
            text = "悬浮窗",
            Modifier
                .draggable(rememberDraggableState {
                    // 发起重组
                    scrollX.value += it.toInt()
                }, Orientation.Horizontal, interactionSource = interaction)
                .layout { measurable, constraints ->
                    val placeable = measurable.measure(constraints = constraints)
                    layout(placeable.width, placeable.height) {
                        //摆放位置
                        placeable.placeRelative(IntOffset(scrollX.value, 0))
                    }
                }
                .size(200.dp)
                .background(Color.Blue)
        )
        val isDragged by interaction.collectIsDraggedAsState()
        if (isDragged) {
            Text(text = "正在拖拽")
        } else {
            Text(text = "静止状态中")
        }
    }

}

例如记录一个scrollX,用于记录水平方向的偏移量,这个值是累加的,每次拖拽都会触发重组重新测量布局,在Modifier.layout函数中进行布局的重新摆放逻辑。

1.3 Compose中的滑动事件 - scrollable

在上一节中,介绍了draggable的使用,这一节将会介绍scrollable的使用,其实如果看过scrollable的源码,会发现它在底层还是通过draggable实现的。

kotlin 复制代码
@ExperimentalFoundationApi
fun Modifier.scrollable(
    state: ScrollableState,
    orientation: Orientation,
    overscrollEffect: OverscrollEffect?,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    flingBehavior: FlingBehavior? = null,
    interactionSource: MutableInteractionSource? = null
): Modifier {
       // ......
}

那么为什么不统一用draggable,而是要单独加了一个scrollable?原因就是通过scrollable要做一些精细化的效果处理,例如:惯性滑动、嵌套滑动nestscroll、边界回弹等。

其实很好理解,draggable从字面意思上看就是拖拽,虽然滑动也是拖拽的一种,但是并不意味着所有的拖拽场景都需要所谓的惯性滑动、嵌套滑动,例如设置中的进度条。

来看下使用,我下面是用scrollable实现了组件的横向移动能力,

kotlin 复制代码
@Composable
fun TestScrollable() {

    val currentX = remember {
        mutableStateOf(0)
    }

    Text(
        text = "悬浮窗",
        Modifier
            .offset {
                IntOffset(currentX.value, 0)
            }
            .scrollable(rememberScrollableState {
                currentX.value += it.toInt()
                it
            }, Orientation.Horizontal)
            .size(200.dp)
            .background(Color.Blue)
    )
}

draggable一样,scrollable也需要一个state,Compose给提供好了就是rememberScrollableState函数。

kotlin 复制代码
@Composable
fun rememberScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState {
    val lambdaState = rememberUpdatedState(consumeScrollDelta)
    return remember { ScrollableState { lambdaState.value.invoke(it) } }
}

但是需要注意的是,和rememberDraggableState不同的是,它需要一个返回值,这个返回值代表当前横滑的距离被某个组件消费了多少。 以便将剩余的滑动距离交给父容器或者子组件消费,这就是嵌套滑动的原理所在。

除此之外,scrollable中还提供了overscrollEffect参数,用于处理触边后的回弹效果,Compose中提供了默认的实现ScrollableDefaults.overscrollEffect()

flingBehavior则是用于处理惯性滑动,默认可以不传值,在底层使用默认值。

kotlin 复制代码
// 如果没有特殊的惯性滑动需求,底层使用默认值。
val fling = flingBehavior ?: ScrollableDefaults.flingBehavior()

所以scrollabledraggable的基础之上,增加了几种滑动效果的逻辑处理。

1.4 Compose的二维滑动

前面我在介绍draggablescrollable的时候说过,他们只支持一维方向的滑动,所以需要设置orientation属性,如果想要监听二维的滑动,Compose没有提供直接使用的API,需要Modifier.pointerInput来配合完成。

在1.1 小节中,我介绍点击事件的时候,提到过detectTapGestures可以从底层监听点击事件,那么如果想要监听二维滑动,那么可以通过detectDragGestures来完成。

kotlin 复制代码
Text(text = "二维滑动",
    Modifier
        .size(200.dp)
        .background(Color.Blue)
        .pointerInput(Unit){
            detectDragGestures { change, dragAmount -> 
                
            }
        })

detectDragGestures中,有两个参数,第一个参数:PointerInputChange,代表手指点按的信息,每一个手指按下都有对应的id和位置信息等,以此来处理手势的抬起和按下;第二个参数:代表的是手指滑动的位置信息,是一个Offset类型的数据,记录x和y轴的偏移量。

kotlin 复制代码
@Composable
fun TestMultiScroll() {

    val currentX = remember {
        mutableStateOf(0)
    }
    val currentY = remember {
        mutableStateOf(0)
    }

    Text(text = "二维滑动",
        Modifier
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                layout(placeable.width, placeable.height) {
                    placeable.placeRelative(currentX.value, currentY.value)
                }
            }
            .size(200.dp)
            .background(Color.Blue)
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    currentX.value += dragAmount.x.roundToInt()
                    currentY.value += dragAmount.y.roundToInt()
                }
            })

}

所以要实现悬浮窗的拖动,就可以使用detectDragGestures来实现。

2 Compose中的嵌套滑动

相关文章:

Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果

在之前的文章中,我详细介绍过在传统的View体系中,如何通过嵌套滑动机制完成一些需求,它的实现还是比较复杂的,需要实现NestScrollingParentNestScrollingChild接口。

而在Compose中实现嵌套滑动,在1.3小节中,我介绍过了Modifier.scrollable,其实就是在其基础之上实现。

2.1 Compose自有的嵌套滑动组件

在传统的View体系中,像RecyclerViewNestScrollView等,都具备嵌套滑动的能力,例如RecyclerView:

java 复制代码
public class RecyclerView extends ViewGroup implements ScrollingView,
        NestedScrollingChild2, NestedScrollingChild3 { // ......}

它实现了NestedScrollingChild2接口,所以它具备嵌套滑动的能力。所以在Compose当中通过Modifier.scrollable实现的滑动组件,大概率都具备嵌套滑动的能力,比如LazyColumn

gif可能看不太清楚,目前效果就是两个LazyColumn嵌套在一起,当滑动内部的LazyColumn的时候,外部的LazyColumn不会滑动,只有当内部的LazyColumn到底之后,外部的LazyColumn才可以继续滑动。

kotlin 复制代码
@Composable
fun TestNestScroll() {
    LazyColumn {
        item {
            LazyColumn(
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            ) {
                items(10) {
                    Text(
                        text = "child $it",
                        Modifier
                            .fillMaxWidth()
                            .height(30.dp)
                            .background(Color.Red)
                    )

                }
            }
        }
        items(20) {
            Text(
                text = "parent $it",
                Modifier
                    .fillMaxWidth()
                    .height(30.dp)
                    .background(Color.Blue)
            )

        }

    }
}

当然,作为程序员,面对一些定制化的需求,还是需要自己实现的。

2.2 自定义实现嵌套滑动

在Compose当中,提供了Modifier.nestedScroll来实现嵌套滑动,既然我要讲嵌套滑动,首先需要明确一下,嵌套滑动的原理:

其实嵌套滑动很简单,在Compose当中对于父容器是不会主动处理滑动事件,是子组件通过回调通知父容器是否需要滑动,通常是在子组件滑动之前「询问」父容器是否要消费滑动距离,以及在子组件滑动完成之后,也要询问父容器是否需要消费剩余的滑动距离。

ok,知道原理之后,就知道该做哪些事了!

  • 通知父容器是否消费事件,分两次进行;
  • 父容器接收到回调之后,选择是否处理事件消费

那么如何通知父容器是否消费事件,就是采用NestedScrollDispatcher来进行嵌套滑动的事件分发,也就是nestedScroll函数的第二个参数。

kotlin 复制代码
fun Modifier.nestedScroll(
    connection: NestedScrollConnection,
    dispatcher: NestedScrollDispatcher? = null
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "nestedScroll"
        properties["connection"] = connection
        properties["dispatcher"] = dispatcher
    }
) {
    val scope = rememberCoroutineScope()
    // provide noop dispatcher if needed
    val resolvedDispatcher = dispatcher ?: remember { NestedScrollDispatcher() }
    remember(connection, resolvedDispatcher, scope) {
        resolvedDispatcher.originNestedScrollScope = scope
        NestedScrollModifierLocal(resolvedDispatcher, connection)
    }
}

接下来带大家实现一个嵌套滑动组件。

kotlin 复制代码
@Composable
fun TestNestScroll2() {

    var currentY = remember {
        mutableStateOf(0)
    }
    

    Column(
        Modifier
            .fillMaxWidth()
            .offset {
                IntOffset(0, currentY.value)
            }
            .draggable(rememberDraggableState {
                
                currentY.value += it.roundToInt()
            }, Orientation.Vertical)
    ) {

        for (index in 1..20) {
            Text(
                text = "第 $index 个组件",
                Modifier
                    .fillMaxWidth()
                    .height(20.dp)
            )
        }
    }
}

这个组件具备了上下滑动的能力,接下来会处理嵌套滑动的逻辑。

2.2.1 NestedScrollDispatcher

伙伴们重点看下NestedScrollDispatcher关于滑动事件分发函数的注释:

kotlin 复制代码
class NestedScrollDispatcher {

   
    // ......
    
    /**
     * 用于子组件处理滑动之前,回调通知父容器是否需要消费事件
     *
     * @param available 一次滑动事件的距离
     * @param source 滑动事件的来源
     *
     * @return 祖先节点,或者说父容器消费的滑动距离
     */
    fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return parent?.onPreScroll(available, source) ?: Offset.Zero
    }

    /**
     * 子组件滑动完成之后,再次通知父容器是否需要消费事件
     *
     * @param consumed 当前子组件消费的距离
     * @param available 当前父容器可以再次消费的剩余距离
     * @param source 滑动事件的来源
     *
     * @return the amount of scroll that was consumed by all ancestors
     */
    fun dispatchPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
    }

    // 下面两个惯性函数其实是一样的。
    
    suspend fun dispatchPreFling(available: Velocity): Velocity {
        return parent?.onPreFling(available) ?: Velocity.Zero
    }

    
    suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
        return parent?.onPostFling(consumed, available) ?: Velocity.Zero
    }
}

NestedScrollDispatcher就是用来通知父容器是否需要消费事件的工具,所以我对draggable的内部逻辑进行了修改。

kotlin 复制代码
Modifier.draggable(rememberDraggableState { duration ->
    //滑动前,通知父容器,有duration长度的滑动距离,要不要消费?
    val parentConsumed =
        dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
    //那么子组件能够消费的距离,需要减去父容器消费的距离,具体父容器消费多少,不需要关心
    val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
    currentY.value += availableDuration
    // 滑动结束之后,再次通知父容器,要不要消费?
    dispatch.dispatchPostScroll(
        Offset(0f, availableDuration.toFloat()), // 子组件消费了全部的剩余距离
        Offset.Zero, // 父容器可消费的滑动距离为0
        NestedScrollSource.Drag
    )
}, Orientation.Vertical)

2.2.2 NestedScrollConnection

那么子组件通过NestScrollDispatcher发起的回调,父容器在哪接收到呢?就是通过NestedScrollConnection,它是一个接口,所以在用的时候需要自己实现一个实例,或者创建一个匿名内部类都可以。

接口函数的注释可以阅读一下:

kotlin 复制代码
@JvmDefaultWithCompatibility
interface NestedScrollConnection {

    /**
     * Pre scroll event chain. 它会在子组件允许父容器消费滑动事件的时候回调,是在子组件滑动之前接收到的回调。
     *
     * @param available 父容器可以消费的滑动距离,即dispatch.dispatchPreScroll传入的第一个参数
     * @param source 滑动事件来源
     *
     * @return 当前组件消费多少的滑动事件
     */
    fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

    /**
     * Post scroll event pass. 子组件完成滑动之后会回调
     * @param consumed 子组件消费的滑动距离,即dispatch.dispatchPostScroll传入的第一个参数
     * @param available 父容器可以消费的滑动距离,即dispatch.dispatchPostScroll传入的第二个参数
     * @param source 滑动事件来源
     *
     * @return the amount that was consumed by this connection
     */
    fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset = Offset.Zero

    // 惯性的我先不管了
    suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero

    suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return Velocity.Zero
    }
}

所以总体的嵌套滑动处理,在理解了其中的原理之后,其实就大概能写出来其中的核心逻辑了,当然这个demo不存在嵌套滑动的逻辑,我在内部再加一个LazyColumn。

kotlin 复制代码
@Composable
fun TestNestScroll2() {

    var currentY = remember {
        mutableStateOf(0)
    }

    //分发给父容器
    val dispatch = remember {
        NestedScrollDispatcher()
    }
    val connection = remember {
        object : NestedScrollConnection{
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // 假设全消费了
                Log.d(TAG, "onPreScroll: $available ")
                return available
            }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                Log.d(TAG, "onPostScroll: consumed $consumed available $available")
                //子组件滑动完成之后, 如果还有可用的距离,那么父组件就消费
                return super.onPostScroll(consumed, available, source)
            }
        }
    }


    Column(
        Modifier
            .fillMaxWidth()
            .offset {
                IntOffset(0, currentY.value)
            }
            .nestedScroll(connection, dispatch)
            .draggable(rememberDraggableState { duration ->
                //滑动前,通知父容器,有duration长度的滑动距离,要不要消费?
                val parentConsumed =
                    dispatch.dispatchPreScroll(Offset(0f, duration), NestedScrollSource.Drag)
                //那么子组件能够消费的距离,需要减去父容器消费的距离,具体父容器消费多少,不需要关心
                val availableDuration = duration.roundToInt() - parentConsumed.y.roundToInt()
                Log.d(TAG, "TestNestScroll2: availableDuration $availableDuration")
                currentY.value += availableDuration
                // 滑动结束之后,再次通知父容器,要不要消费?
                dispatch.dispatchPostScroll(
                    Offset(0f, availableDuration.toFloat()), // 子组件消费了全部的剩余距离
                    Offset.Zero, // 父容器可消费的滑动距离为0
                    NestedScrollSource.Drag
                )
            }, Orientation.Vertical)
    ) {

        for (index in 1..20) {
            Text(
                text = "第 $index 个组件",
                Modifier
                    .fillMaxWidth()
                    .height(20.dp)
            )
        }
        //内部嵌套一个LazyColumn。
        LazyColumn(Modifier.height(80.dp)){
            items(10){
                Text(
                    text = "第 $it 个内部组件",
                    Modifier
                        .fillMaxWidth()
                        .height(20.dp)
                )
            }
        }
    }
}

先看下这个布局结构

当子组件滑动的时候,自定义的ScrollView(父容器)会首先收到子组件的回调,在onPreScroll中处理决定是否要消费事件:

  • 假设在onPreScroll中,父容器消费了全部的滑动距离,那么内部的LazyColumn就不能滑动了;
  • 默认不处理onPreScroll,在子组件不能再滑动的时候,就继续滑动父容器,需要做下面的逻辑。
kotlin 复制代码
val connection = remember {
    object : NestedScrollConnection{

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            Log.d(TAG, "onPostScroll: consumed $consumed available $available")
            //子组件滑动完成之后, 如果还有可用的距离,那么父组件就消费
            currentY.value += available.y.toInt()
            return available
        }
    }
}

因为子组件已经无法滑动了,因此所有的事件子组件不再消费,从而在onPostScroll中会将事件原封不动的回调给父容器,父容器从而消费事件继续滑动。

如果把定义的ScrollView放在其他的容器中,那么其自身就会成为子组件,会执行draggable中的嵌套滑动逻辑。

相关推荐
刘龙超5 小时前
如何应对 Android 面试官 -> 运用 Jetpack 写一个音乐播放器(一)基础搭建
android jetpack
小悟空6 小时前
[AI 生成] Flink 面试题
大数据·面试·flink
Jackilina_Stone8 小时前
【faiss】用于高效相似性搜索和聚类的C++库 | 源码详解与编译安装
android·linux·c++·编译·faiss
Sherry0078 小时前
CSS Grid 交互式指南(译)(下)
css·面试
一只毛驴8 小时前
浏览器中的事件冒泡,事件捕获,事件委托
前端·面试
一只叫煤球的猫9 小时前
你真的处理好 null 了吗?——11种常见但容易被忽视的空值处理方式
java·后端·面试
棒棒AIT9 小时前
mac 苹果电脑 Intel 芯片(Mac X86) 安卓虚拟机 Android模拟器 的救命稻草(下载安装指南)
android·游戏·macos·安卓·mac
KarrySmile9 小时前
Day04–链表–24. 两两交换链表中的节点,19. 删除链表的倒数第 N 个结点,面试题 02.07. 链表相交,142. 环形链表 II
算法·链表·面试·双指针法·虚拟头结点·环形链表
fishwheel9 小时前
Android:Reverse 实战 part 2 番外 IDA python
android·python·安全
一只毛驴10 小时前
谈谈浏览器的DOM事件-从0级到2级
前端·面试