【Compose】Compose中的动画

【Compose】Compose中的动画

本文介绍了Jetpack Compose中一系列动画API的使用

出现消失动画

AnimatedVisibility 是Jetpack Compose中一个非常有用的动画API,它可以让我们在Composable函数中实现元素的出现和消失动画。它的使用非常简单,只需要在需要添加动画效果的元素上使用AnimatedVisibility即可。

AnimatedVisibility 的方法签名如下:

@Composable
fun ColumnScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandVertically(),
    exit: ExitTransition = fadeOut() + shrinkVertically(),
    label: String = "AnimatedVisibility",
    content: @Composable AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visible, label)
    AnimatedVisibilityImpl(transition, { it }, modifier, enter, exit, content = content)
}

visible是一个布尔值,用于控制元素的出现和消失。 modifier是一个Modifier对象,用于修改元素的外观。 enter是一个EnterTransition对象,用于控制元素出现时的动画效果。 exit是一个ExitTransition对象,用于控制元素消失时的动画效果。 label是一个字符串,用于标识元素。 content是一个Composable函数,用于定义元素的内容。

举例,设置两个text,上面那个text点击后开始退场,2s后重新出现。

源码如下:

@Composable
fun AnimatedVisibilityDemo() {
    Column(modifier = Modifier.width(IntrinsicSize.Max)) {

        val scope = rememberCoroutineScope()

        val isShow = remember { mutableStateOf(true) }

        AnimatedVisibility(visible = isShow.value) {
            Text(
                text = "How are you?",
                color = Color.White,
                modifier = Modifier
                    .fillMaxWidth(1f)
                    .background(Color.Red)
                    .clip(RoundedCornerShape(10))
                    .clickable {
                        isShow.value = false // 点击后消失
                        scope.launch {
                            delay(2000)
                            isShow.value = true // 2秒后重新出现
                        }
                    }
                    .padding(20.dp)
            )
        }

        Text(
            text = "I'm fine, thank you! And you?",
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .background(Color.Blue)
                .clip(RoundedCornerShape(10))
                .padding(20.dp)
        )
    }
}

可以看到 Compose 为了简化使用,已经预设了进出场的动画,进场是fadeIn() + expandVertically(),出场是fadeOut() + shrinkVertically()。如果需要自定义动画效果,可以手动声明并传入 AnimatedVisibility 的enter和exit参数。enter参数是一个EnterTransition对象,用于控制元素出现时的动画效果。

可以像官方例程里那样,自定义这两个参数传入:

AnimatedVisibility(visible = isShow.value,
            enter = slideInVertically {
                // Slide in from 40 dp from the top.
                with(density) { -40.dp.roundToPx() }
            } + expandVertically(
                // Expand from the top.
                expandFrom = Alignment.Top
            ) + fadeIn(
                // Fade in with the initial alpha of 0.3f.
                initialAlpha = 0.3f
            ),
            exit = slideOutVertically() + shrinkVertically() + fadeOut()) {

}

进场参数自定义,设定以top为基准,扩张的时候从上往下,滑动的时候从上往下。

更改动画时长

AnimatedVisibility 中的动画时长可以通过修改 enter 和 exit 中的动画参数来控制。例如,我们可以使用 fadeIn() 动画,并将持续时间设置为 2 秒:

AnimatedVisibility(
    visible = isShow.value,
    enter = fadeIn(
        animationSpec = tween(2000)
    ),
    exit = fadeOut(
        animationSpec = tween(2000)
    )
)

使用MutableTransitionState控制动画

AnimatedVisibility 还可以使用 MutableTransitionState 来控制动画。MutableTransitionState 是一个可变的 TransitionState,它可以在运行时更改其目标状态。通常可以用作在一开始就触发动画,还可以实时地 观察到动画状态

@Composable
fun AnimatedVisibilityDemo() {
    Column(modifier = Modifier.width(IntrinsicSize.Max)) {

        val scope = rememberCoroutineScope()

        val state = remember {
            MutableTransitionState(false).apply {
                // Start the animation immediately.
                targetState = true
            }
        }

        AnimatedVisibility(visibleState = state) {
            Text(
                text = "How are you?",
                color = Color.White,
                modifier = Modifier
                    .fillMaxWidth(1f)
                    .background(Color.Red)
                    .clip(RoundedCornerShape(10))
                    .clickable {
                        state.targetState = !state.targetState
                        scope.launch {
                            delay(2000)
                            state.targetState = !state.targetState
                        }
                    }
                    .padding(20.dp)
            )
        }

        Text(
            text = when {
                state.isIdle && state.currentState -> "Visible"
                !state.isIdle && state.currentState -> "Disappearing"
                state.isIdle && !state.currentState -> "Invisible"
                else -> "Appearing"
            },
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .background(Color.Blue)
                .clip(RoundedCornerShape(10))
                .padding(20.dp)
        )
    }
}

观察效果:

给子项添加动画

有时候我们需要给子项单独添加动画,以获得更灵活的效果。AnimatedVisibility 里面的子项可以使用 animateEnterExit 修饰符,来添加更精细的动画效果。

    AnimatedVisibility(
        visible = isShow.value,
        enter = EnterTransition.None,
        exit = ExitTransition.None
    ) {
        Text(
            text = "How are you?",
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .background(Color.Red)
                .clip(RoundedCornerShape(10))
                .clickable {
                    isShow.value = false // 点击后消失
                    scope.launch {
                        delay(2000)
                        isShow.value = true // 2秒后重新出现
                    }
                }
                .padding(20.dp)
        )
    }

需要注意的是AnimatedVisibility和其子项设置的动画效果是叠加的,我们如果不想要外面父组合项自带的动画效果,可以显示的传入EnterTransition.Noneh和ExitTransition.None。

添加自定义动画

通过 AnimatedVisibility 的内容 lambda 内的 transition 属性访问底层 Transition 实例。添加到 Transition 实例的所有动画状态都将与 AnimatedVisibility 的进入和退出动画同时运行。AnimatedVisibility 会等到 Transition 中的所有动画都完成后再移除其内容。对于独立于 Transition(例如使用 animate*AsState)创建的退出动画,AnimatedVisibility 将无法解释这些动画,因此可能会在完成之前移除内容可组合项。

例如在原有进场动画的基础上,添加一个颜色的动画:

 AnimatedVisibility(
) {
     val background by transition.animateColor(label = "color") { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
        Text(
            text = "How are you?",
            color = Color.White,
            modifier = Modifier
               .fillMaxWidth(1f)
               .background(background)
               .clip(RoundedCornerShape(10))
        )
    }

使用 Crossfade 在两个布局之间添加动画效果

Crossfade 可使用淡入淡出动画在两个布局之间添加动画效果。通过切换传递给 current 参数的值,可以使用淡入淡出动画来切换内容。

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage, label = "cross fade") { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

使用 AnimatedContent 根据状态切换内容

AnimatedContent 可以观测状态,并在状态更改时添加动画效果。

基础使用:

Row {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(
        targetState = count,
        label = "animated content"
    ) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

其方法签名中,可以传入一个 animationSpec 参数,用于控制动画效果。默认的效果还是渐入渐出。

    transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
        (fadeIn(animationSpec = tween(220, delayMillis = 90)) +
            scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)))
            .togetherWith(fadeOut(animationSpec = tween(90)))
    },

自定义动画效果:

transitionSpec = {
    // Compare the incoming number with the previous number.
    if (targetState > initialState) {
        // If the target number is larger, it slides up and fades in
        // while the initial (smaller) number slides up and fades out.
        slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
    } else {
        // If the target number is smaller, it slides down and fades in
        // while the initial number slides down and fades out.
        slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
    }.using(
        // Disable clipping since the faded slide-in/out should
        // be displayed out of bounds.
        SizeTransform(clip = false)
    )
}

上面运行之后可以看到,当目标值大于初始值时,会有一个向上滑动加上渐出的动画效果。

尺寸改变动画

使用 animateContentSize 修饰符可以让Composable函数在大小发生变化时进行动画效果。注意需要添加在任何尺寸修饰符之前,以防止动画效果被错误地应用。

@Composable
fun AnimatedVisibilityDemo() {
    Column(modifier = Modifier.width(IntrinsicSize.Max)) {

        val scope = rememberCoroutineScope()

        var state by remember { mutableStateOf(false) }

        Text(
            text = "How are you?",
            color = Color.White,
            modifier = Modifier
                .animateContentSize()
                .fillMaxWidth(1f)
                .height(if (state) 160.dp else 80.dp)
                .background(Color.Red)
                .clip(RoundedCornerShape(10))
                .clickable {
                    state = true
                    scope.launch {
                        delay(2000)
                        state = false
                    }
                }
                .padding(20.dp)
        )

        Text(
            text = "I am fine.",
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .background(Color.Blue)
                .clip(RoundedCornerShape(10))
                .padding(20.dp)
        )
    }
}

列表项动画

为列表的每个项添加动画效果,使用 animateItem 修饰符。

@Composable
fun ListItemAnimateDemo() {
    val listState = remember { mutableStateListOf<ListItem>() }

    LaunchedEffect(Unit) {
        repeat(10) {
            listState.add(ListItem(it, "Item $it"))
            delay(1000)
        }
        delay(1000)
        listState.removeAt(5)
    }

    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(listState, key = { it.id }) { item ->
            Text(
                text = item.title,
                color = Color.White,
                modifier = Modifier
                    .fillMaxWidth(1f)
                    .animateItem()
                    .background(Color.Blue)
                    .clip(RoundedCornerShape(10))
            )
        }
    }
}

data class ListItem(
    val id: Int,
    val title: String,
)

基于Value的动画

animate*AsState系列函数

这个系列函数的用法类似ValueAnimator,通过定义两个端点目标值,当使用标志位触发两端的值切换时,会自动进行动画效果。

例如改变Box的高度

这里的效果和上面提到的 animateContentSize() 是一样的。

@Composable
fun ValueAnimation() {
    var enable by remember {
        mutableStateOf(false)
    }
    val heightValueAnim by animateIntAsState(if (enable) 400 else 200, label = "box height anim")
    Box(
        modifier = Modifier
            .width(200.dp)
            .height(heightValueAnim.dp)
            .clickable {
                enable = !enable
            }
            .background(Color.Red),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Hello")
    }
}

heightValueAnim 设定了400和200两个端点值,通过 enable 标志位来切换。

Box 可组合项被点击后,更改 enable ,就会自动触发动画效果。

注意,无需创建任何动画类的实例,也不必处理中断。在后台,系统会在调用点创建并记录一个动画对象(即 Animatable 实例),并将第一个目标值设为初始值。此后,只要您为此可组合项提供不同的目标值,系统就会自动向该值播放动画。如果已有动画在播放,系统将 从其当前值(和速度)开始向目标值 播放动画。在播放动画期间,这个可组合项会重组,并且每帧都会返回一个已更新的动画值。

再例如对背景颜色的值添加动画

触发之后,会按照色阶上的值平滑过渡。

@Composable
fun ValueAnimation() {
    var animateBackgroundColor by remember {
        mutableStateOf(false)
    }
    val animatedColor by animateColorAsState(
        if (animateBackgroundColor) Color.Green else Color.Blue,
        label = "color"
    )
    Box(
        modifier = Modifier
            .drawBehind {
                drawRect(animatedColor)
            }
            .fillMaxSize(1f)
            .clickable {
                animateBackgroundColor = !animateBackgroundColor
            },
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Hello")
    }
}

更改偏移量来移动可组合项

更改offset偏移量不会影响其布局测量的参数,只会变更它的绘制流程。因此不算做真正的移动,不会对其父组件或者平级组件产生影响。

@Composable
fun ValueAnimation() {
    var moved by remember { mutableStateOf(false) }
    val pxToMove = with(LocalDensity.current) {
        100.dp.toPx().roundToInt()
    }
    val offset by animateIntOffsetAsState(
        targetValue = if (moved) {
            IntOffset(pxToMove, pxToMove)
        } else {
            IntOffset.Zero
        },
        label = "offset"
    )

    Box(
        modifier = Modifier
            .offset {
                offset
            }
            .background(Color.Blue)
            .size(100.dp)
            .clickable {
                moved = !moved
            }
    )
}

要实现真正的移动动画效果,则需要重写Modifier的layout方法,来改变其测量的流程。假如是在一个Column中,这个Box下面的子控件就会被其挤下去了。

@Composable
fun ValueAnimation() {
    var toggled by remember { mutableStateOf(false) }
    
    val offsetTarget = if (toggled) IntOffset(150, 150) else IntOffset.Zero

    val offset = animateIntOffsetAsState(targetValue = offsetTarget, label = "offset")

    Column(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxSize()
            .clickable {
                toggled = !toggled
            }
    ) {
        Box(
            modifier = Modifier
                .layout { measurable, constraints ->
                    val offsetValue = if (isLookingAhead) offsetTarget else offset.value
                    val placeable = measurable.measure(constraints)
                    layout(placeable.width + offsetValue.x, placeable.height + offsetValue.y) {
                        placeable.placeRelative(offsetValue)
                    }
                }
                .size(100.dp)
                .background(Color.Green)
        )
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(Color.Blue)
        )
    }
}

添加阴影动画

想要为可组合项添加阴影动画,需要使用graphicsLayer方法来修改其阴影的大小。

@Composable
fun ValueAnimation() {
    val mutableInteractionSource = remember {
        MutableInteractionSource()
    }
    val pressed = mutableInteractionSource.collectIsPressedAsState()
    val elevation = animateDpAsState(
        targetValue = if (pressed.value) {
            32.dp
        } else {
            8.dp
        },
        label = "elevation"
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .graphicsLayer {
                this.shadowElevation = elevation.value.toPx()
            }
            .clickable(interactionSource = mutableInteractionSource, indication = null) {
            }
            .background(Color.Green)
    )
}

这里用到了 MutableInteractionSource ,这是一个可观察的交互源,它可以用于监听用户与可组合项的交互事件,例如点击、长按、拖拽等。它提供了一个 collectIsPressedAsState() 方法,用于收集用户是否正在与可组合项进行交互的状态。

使用Transition

Transition 可管理一个或多个动画作为其子项,并在多个状态之间同时运行这些动画。

@Composable
fun TransitionAnimation() {
    var boxState by remember { mutableStateOf(BoxState.Collapsed) }
    val transition = updateTransition(targetState = boxState, label = "box")

    val color by transition.animateColor(
        label = "color",
        targetValueByState = { state ->
            when (state) {
                BoxState.Collapsed -> Color.Red
                BoxState.Expanded -> Color.Green
            }
        }
    )

    val height by transition.animateDp(
        label = "height",
        targetValueByState = { state ->
            when (state) {
                BoxState.Collapsed -> 100.dp
                BoxState.Expanded -> 300.dp
            }
        }
    )

    Box(modifier = Modifier
        .fillMaxWidth(1f)
        .height(height)
        .background(color)
        .clickable {
            boxState = if (boxState == BoxState.Collapsed) {
                BoxState.Expanded
            } else {
                BoxState.Collapsed
            }
        })
}

enum class BoxState {
    Collapsed,
    Expanded
}

首先定义一个enum类,来表示可组合项的两种状态。然后使用 updateTransition 方法来创建一个Transition实例。

在Transition实例上调用 animateColoranimateDp 方法来创建两个动画。这两个动画会在 Collapsed 和 Expanded 状态之间切换。

通过点击触发状态变化,可以看到可组合项的颜色和高度会随着状态的变化而变化。

【Compose】附带作用

【Compose】附带作用

本文介绍了Jetpack Compose里附带作用的使用及特性

Side Effect, 官方有一段时间翻译为副作用,后来终于改为了更为精准的附带作用。

设计目的

官方定义:

附带效应是指发生在可组合函数作用域之外的应用状态的变化。由于可组合项的生命周期和属性(例如不可预测的重组、以不同顺序执行可组合项的重组或可以舍弃的重组),可组合项在理想情况下应该是无附带效应的。不过,有时附带效应是必要的,例如,触发一次性事件(例如显示信息提示控件),或在满足特定状态条件时进入另一个屏幕。这些操作应从能感知可组合项生命周期的受控环境中调用。在本页中,您将了解 Jetpack Compose 提供的不同附带效应 API。

简单点说,就是Composable函数设计之初就已经规定好了,每一次Composable的函数的重组会尽量以最小重组范围来进行。而且,顺序写在一起的Composable函数,他们之间的调用顺序不是确定的。理想情况不需要跳脱到这个规则之外,否则可能会产生未知的数据问题。

但是很多情况,我们用这个设计理念来写代码,会产生很多不优雅的情况,比如超长的变量传递链,不合理不直观的架构设计。

所以就设计了一些附带作用的API,在某些情况下代码块里面的跳脱到重组的范围之外,比如只有初次进入时调用一次,后面都不参与重组;还比如每次退出Composable函数时调用一次。

LauncheEffect

如需在可组合项的 生命周期内执行工作并能够调用挂起函数 ,请使用 LaunchedEffect 可组合项。

当它的内部逻辑要触发时,它会启动一个协程,并将代码块作为参数传递。

如果 LaunchedEffect 退出组合,协程将取消。

源码:

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
  • key参数是其监听变化状态的值
  • block就是丢给协程执行的代码块。

当这个外部的key的值发生变化时,协程会被取消,重新启动。并且,当LaunchedEfftect的所属可组合项退出时,协程会被直接取消。

最常用用法是用来触发网络数据获取,在挂起函数执行完毕数据返回来之后,更新界面的状态。

如下面例子所示:

@Composable
fun LaunchedEffectComposable() {
    val isLoading = remember { mutableStateOf(false) }
    val data = remember { mutableStateOf(listOf<String>()) }

    // 定义一个 LaunchedEffect 来异步执行长时间运行的操作,
    // 如果 isLoading.value 发生变化,LaunchedEffect 将取消并重新启动
    LaunchedEffect(isLoading.value) {
        if (isLoading.value) {
            val newData = fetchData()  // 执行长时间运行的操作,例如从网络获取数据
            data.value = newData       // 使用新数据更新状态
            isLoading.value = false
        }
    }

    Column {
        Button(onClick = { isLoading.value = true }) {
            Text("Fetch Data")
        }
        if (isLoading.value) {
            CircularProgressIndicator()  // 显示加载指示器
        } else {
            LazyColumn {
                items(data.value.size) { index ->
                    Text(text = data.value[index])
                }
            }
        }
    }
}

remeberCoroutineScope

由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。

remeberCoroutineScope 可以在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消,请使用 rememberCoroutineScope。 此外,如果您需要手动控制一个或多个协程的生命周期,请使用 rememberCoroutineScope,例如在用户事件发生时取消动画。

rememberCoroutineScope 是一个可组合函数,会返回一个 CoroutineScope,该 CoroutineScope 绑定到调用它的组合点。调用退出组合后,作用域将取消。

@Composable
fun SideEffectPage() {

    val isShowButtton = remember {
        mutableStateOf(true)
    }
    LaunchedEffect(Unit) {
        delay(3000)
        isShowButtton.value = false
    }
    
    if (isShowButtton.value) {
        val scope = rememberCoroutineScope()
        Button(onClick = {

            scope.launch {
                delay(2000)
                Log.i("rememberCoroutineScope", "fdgbfv")
            }
        }) {
            Text(text = "SidsfgvdcsPage ")
        }
    }
}

在isShowButtton作用域代码块里面使用这个附加作用获取一个协程对象,在点击按钮时,启动一个协程,在协程里面执行一些耗时操作。

同时外部的 LaunchedEffect 作用下,3s后,这个协程所属的可组合项会退出生命周期,这个协程里的所有任务也会被取消。

SideEffect

想象这样一个需求,要求用户进入某个界面之后,记录他们的某个操作的埋点数据。

如果直接在Composable方法中执行这个记录工作,那么即使重组失败,数据仍然会更新。需要确保Composable重组成功的情况下,才会记录埋点数据。

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

DisposableEffect

DisposableEffect 可组合项会在每次重新组合时调用其效果,并且在每次重新组合时,都会在退出组合时调用其 onDispose 回调。可以在 onDispose 回调中执行清理工作。回收该Composable函数中使用的所有资源。

看这样一个场景,在isShowButton.value为true时,在作用域内启动一个协程任务,在3s后更新Text的数据。

注意直接在Composable函数中启动协程任务,会导致协程任务的生命周期和Composable函数的生命周期不一致,导致协程任务无法被正确的回收。这里仅作演示。

@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun SideEffectPage() {
    val isShowButton = remember { mutableStateOf(true) }
    
    LaunchedEffect (Unit){
        delay(1000)
        isShowButton.value = false
    }

    if(isShowButton.value) {
        val text = remember { mutableStateOf("Hello") }
        val scope = CoroutineScope(Dispatchers.Main)
        val job = scope.launch {
            delay(3000)
            Log.d("SideEffectPage", "SideEffectPage: ")
            text.value = "Hello World"
        }
        Text(text = text.value)
    }
}

1s后尽管已经隐藏了Button,退出了可组合项的生命周期,但是协程任务依然在运行,导致内存泄漏。

这时候我们可以使用DisposableEffect,在onDispose回调中,取消协程任务。

更改后如下:

@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun SideEffectPage() {
    val isShowButton = remember { mutableStateOf(true) }
//
    LaunchedEffect (Unit){
        delay(1000)
        isShowButton.value = false
    }

    if(isShowButton.value) {
        val text = remember { mutableStateOf("Hello") }
        DisposableEffect(Unit) {
            val scope = CoroutineScope(Dispatchers.Main)
            val job = scope.launch {
                delay(3000)
                Log.d("SideEffectPage", "SideEffectPage: ")
                text.value = "Hello World"
            }
            onDispose {
                job.cancel()
            }
        }

        Text(text = text.value)
    }
}

rememberUpdateState

如果我们使用LaunchedEffect时要引用一个值,但是如果该值发生更改,则不应重新启动,请使用 rememberUpdatedState。

当关键参数的值之一更新时, LaunchedEffect 会重新启动,但有时我们希望在不重新启动的情况下捕获效果中更改的值。

如果我们有长时间运行的选项,重新启动成本很高,则此过程会很有帮助。

@Composable
fun SideEffectPage() {

    val netText = remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        delay(3000L)
        netText.value = "got a new String from network"
    }

    UpdateText(netText.value)
}

@Composable
fun UpdateText(test: String) {
    val uiText = remember { mutableStateOf("initial local text") }

    val updateText = rememberUpdatedState(test)

    LaunchedEffect(Unit) {
        delay(5000L)
        uiText.value = updateText.value
    }
    
    Box(
        modifier = Modifier.fillMaxSize(1f),
        contentAlignment = Alignment.Center
    ) {
        Text(text = uiText.value)
    }
}

例如在5s后更新一次Text的数据,但是在3s后,网络请求已经返回了新的数据,这个时候我们就可以使用 rememberUpdateState 先将这个更新的数据存储起来。5s后就可以使用更新的正确的数据。

另外还有几个附带作用的函数,我日常使用的时候几乎没有使用过,暂时先不做记录。

【Python】Python环境报错记录

【Python】Python环境报错记录

本文记录了我在使用Python过程中的一些环境报错记录。

类似于之前的一篇Desktop环境配置记录:

Desktop系统环境适配等操作记录

这篇文章记录了我在配置Python环境时遇到的一些问题。以供日后查阅。

pip安装库报错CERTIFICATE_VERIFY_FAILED

此错误通常是因为 Python 无法验证 SSL 证书的颁发者,导致无法建立安全的 HTTPS 连接。这在 macOS 上尤其常见,通常是由于缺少或配置不正确的 SSL 证书链。

1. 运行证书安装脚本 (推荐)

最简单且最有效的解决方法是运行 Python 自带的证书安装脚本。

  • Python 3.6 及更高版本: 在 Finder 中,前往“应用程序” -> “Python 3.x”文件夹,然后双击运行 Install Certificates.command 文件。

  • 通过命令行运行: 你也可以在终端中运行以下命令。

    /Applications/Python\ 3.x/Install\ Certificates.command
    

    请将 3.x 替换为你安装的 Python 版本号,例如 3.10

这个脚本会下载并安装用于验证 SSL 连接所需的证书链,通常能解决大多数问题。

配置环境变量 (备用方案)

如果前两种方法都不可行,可以尝试设置 SSL_CERT_FILE 环境变量来指定正确的证书路径。

  1. 找到证书文件: 通常在 /etc/ssl/certs//usr/local/etc/openssl/ 目录下。

  2. 设置环境变量: 在你的终端配置文件(如 ~/.zshrc~/.bash_profile)中添加以下行,然后重启终端或运行 source ~/.zshrc 使其生效。

    export SSL_CERT_FILE=/usr/local/etc/openssl/cert.pem
    

发现均未解决问题。未安装openssl,使用homebrew来安装一下。

brew install openssl

找到证书目录:

export LDFLAGS="-L/opt/homebrew/opt/openssl/lib"
export CPPFLAGS="-I/opt/homebrew/opt/openssl/include"

重启电脑后,可以正常安装库了。

虚拟环境 .venv 配置

到项目根目录下执行

python3 -m venv .venv

激活虚拟环境:

source .venv/bin/activate

安装库:

pip install requests

退出:

deactivate

##

【Python】Python基础扫盲

【Python】Python基础扫盲

本文记录了Python里的基础知识,方便调试AI工具代码

经常有一些电脑端的批量的小任务,比如后台截屏,轮询检查文件变化,批量处理文件等。

之前可能会专门搜一个软件安装下来,或者找一个工具网站,再去使用其提供的功能。现在第一时间想到的是打开 PyCharm ,然后把需求描述给AI,再不断地运行测试与优化。大部分情况下,都是可以一次解决问题的。

小例子——后台截屏

比如上周想对一个软件界面截个图,但是各个截图快捷键按下时,都会导致这个软件界面发生变化。就想能不能先让这个软件保持界面,弄一个服务在后台自动截图并存文件。AI实现的功能如下:

from time import sleep

from PIL import ImageGrab

# 捕获整个屏幕
print("等待10秒")
sleep(10)
print("开始截图")
screenshot = ImageGrab.grab()
screenshot.save("full_screen_capture.png")
print("已捕获整个屏幕并保存为 full_screen_capture.png")

问题

在不能一次搞定的任务中,除了不断地更改需求,向AI粘贴报错信息(这也不一定能获得完美的结果)之外,我希望自己也具备基础的调适和开发能力,一些简单的问题点可以立即发现,并精准地提出修改需求和意见,这样可以大大提高AI开发这个模式的完成效率。

目前对Python的了解尚少,比如看到项目中的:

if __name__ == '__main__':

还有

__init__.py

不知道它们的含义和运行时机。此文作为基础性的学习记录,语法层面快速带过,主要是项目中的一些最佳实践,运行环境的配置,代码的组织架构,了解实际的工程中是如何书写的,因为AI的训练数据就来自实际的工程项目代码,所以它生成的代码应该是更符合实际工程项目的风格。

下面大部分例子来自菜鸟教程的Python基础教程:菜鸟教程

Python代码运行方式

Python 代码的运行可以分为两个主要阶段:编译解释。但与传统的编译型语言(如 C++)不同,Python 的编译过程更轻量、更灵活。

C++的编译阶段,编译完成就是面向特定平台可直接运行的二进制文件。Python的编译成果也是平台无关的,但是是给Python虚拟机使用的,由Python虚拟机来逐行解释成机器码,然后由计算机 CPU 执行。这个过程就是解释执行

py文件的直接运行和作为模块运行

开头背景中的那个问题,if __name__ == '__main__': 是做什么用的?这是Python 中一个非常常见的结构,它的主要作用是控制代码块的执行时机

简单来说,它的意思是:“如果当前文件是作为单个脚本直接被运行的,那么就执行这个判断语句下面的代码。”

在 Python 中,一个 .py 文件有两种用途:

  1. 作为脚本直接运行: 你在命令行中输入 python your_script.py 来运行它。
  2. 作为模块导入: 你在另一个文件中,使用 import your_script 来导入它的功能。

当你直接运行一个文件时,Python 解释器会给 一个特殊的内置变量 __name__ 赋值为 '__main__'

而当你将它作为模块导入时,__name__ 的值会被设置为模块的名字(也就是文件名,比如 'your_script')。

因此,if __name__ == '__main__': 这个条件判断就成了区分这两种情况的“开关”。

总结下来,这个结构的主要作用是:

  • 保护代码: 确保某些代码(通常是程序的主体逻辑、测试代码或示例调用)只在文件被作为主程序运行时才执行。
  • 提高模块化: 让你的文件既可以作为独立的脚本运行,也可以作为一个可导入的库,提供函数和类给其他程序使用,而不会在导入时意外执行主程序逻辑。

这个习惯在 Python 项目开发中非常重要,可以帮助你更好地组织和复用代码。

__init__.py 的作用

__init__.py 是 Python 包(Package)中一个特殊的文件,它的主要作用是将一个目录变成一个可被导入的 Python 包

简单来说,只要一个目录下存在 __init__.py 文件,Python 解释器就会把这个目录当作一个包来处理。

__init__.py 文件也可以为空,它在包的导入机制和初始化中扮演着关键角色:

  1. 标识包: 这是它最基础的作用。当 Python 解释器看到一个带有 __init__.py 文件的目录时,它就知道这个目录不是普通的文件夹,而是一个可以包含多个模块的包。这允许你通过 import 语句来访问包内的模块。

  2. 包的初始化: 当你导入一个包时,比如 import my_package,这个包下的 __init__.py 文件会自动执行。你可以利用这个特性来做一些初始化工作,例如:

    • 设置包级别的变量。
    • 执行某些初始化代码。
    • 导入包内的常用模块,以简化外部调用。
  3. 简化导入: 这是 __init__.py 最实用的功能之一。假设你有一个包结构如下:

    my_package/
    ├── __init__.py
    ├── module_a.py
    └── module_b.py
    

    通常情况下,如果你想导入 module_a 中的函数 func_a,你需要写 from my_package.module_a import func_a

    如果你在 __init__.py 中添加一行代码:

    from . import module_afrom .module_a import func_a

    那么外部就可以直接通过 from my_package import func_a 来导入,从而简化了调用路径。

简化外部导入的示例

假设你有如下文件结构:

animals/
├── __init__.py
├── cat.py
└── dog.py

dog.py 的内容:

def bark():
    print("Woof!")

cat.py 的内容:

def meow():
    print("Meow!")

如果你想在 main.py 中使用这两个函数,你可以这样做:

main.py

import animals.dog
import animals.cat

animals.dog.bark()
animals.cat.meow()

现在,如果你想让导入更方便,你可以在 __init__.py 中做些手脚:

animals/__init__.py

# 这会将 dog 和 cat 模块导入到 animals 包的命名空间中
from . import dog
from . import cat

# 也可以直接导入具体的函数,让它们成为包的直接成员
from .dog import bark
from .cat import meow

现在,你的 main.py 就可以写得更简洁了:

main.py

from animals import bark, meow

bark()
meow()

__init__.py 还可以控制包的 __all__ 变量,来明确定义 from animals import * 这种写法可以导入哪些模块,这有助于提高代码的清晰度和安全性。

__all__ 是 Python 中一个特殊的列表,它定义了当用户使用 from <module> import * 语句时,可以从模块或包中导入哪些名称(比如变量、函数、类等)。简单来说,__all__ 就像一个“白名单”,它明确地告诉 Python 解释器:“请只导出这个列表中列出的东西。”

命名风格

在Python中,主流的命名规范通常遵循 PEP 8(Python Enhancement Proposal 8),这是Python社区公认的代码风格指南。遵循这些规范能让你的代码更具可读性,也更符合Python生态的习惯。

文件命名

Python文件名(模块名)应该全部使用小写,并可以用下划线连接单词。

  • 正确示例: my_module.py, data_processing.py
  • 错误示例: MyModule.py, data-processing.py

方法和函数命名

方法和函数名也应该全部使用小写,并用下划线连接单词。这是最常见和推荐的风格。

  • 正确示例: calculate_total_price(), get_user_info()
  • 错误示例: calculateTotalPrice(), GetUser_Info()

变量命名

变量名同样使用小写,并用下划线连接单词。

  • 正确示例: user_name, total_count, is_active
  • 错误示例: userName, TotalCount, is-active

类和常量命名

  • 类名 (Class Names): 使用 驼峰式命名法 (CamelCase)。每个单词的首字母都大写,不使用下划线。
    • 正确示例: MyClass, UserInfo, HttpClient
  • 常量名 (Constants): 全部使用大写字母,并用下划线连接单词。
    • 正确示例: MAX_SIZE, PI_VALUE, DEFAULT_TIMEOUT

总的来说,PEP 8 的核心思想是让代码清晰易读。作为一名Android开发者,可能习惯了Java的驼峰命名法(CamelCase),但在Python中,下划线命名法(snake_case)才是更主流的约定。

注释风格

在Python中,主流的注释风格也遵循 PEP 8 规范,主要分为两种类型:行内注释文档字符串(Docstrings)

行内注释 (Inline Comments)

行内注释用于解释代码中特定行或段落的用途。

  • 使用一个井号 # 开始。
  • 通常用于解释为什么这样做,而不是做什么。例如:
    • 好的注释:
      x = x + 1  # 增加计数器,以防止无限循环。
      
    • 不好的注释(解释了显而易见的事情):
      x = x + 1  # x加1
      
  • 注释内容和井号 # 之间应至少有一个空格。
  • 如果行内注释和代码在同一行,通常在 # 前面留两个空格。

文档字符串 (Docstrings)

文档字符串(简称 docstrings)是Python特有的,用于为模块、函数、类和方法提供详细说明。它们不是简单的代码注释,而是可以被工具自动解析和提取的。

在Python中,单引号 ' ' 和双引号 " " 在定义字符串字面量时是完全相同的,它们没有功能上的区别。Python提供了两种引号,这主要是为了方便处理字符串中包含引号的特殊情况,可以避免使用转义字符。如果你想在字符串中包含一个单引号(例如 'It's a great day!'),使用双引号来定义字符串,就无须使用转义符。反之亦然,如果字符串中包含双引号,使用单引号来定义会更方便。并且,Python 没有单独的字符类型,一个字符就是长度为 1 的字符串。

反斜杠可以用来转义,使用 r 可以让反斜杠不发生转义。 如 r”this is a line with \n” 则 \n 会显示,并不是换行。

  • 使用三引号 """''' 包围。
  • 用途
    • 函数/方法:解释函数的功能,参数(Args),返回值(Returns)和可能引发的异常(Raises)。
    • :解释类的用途,属性和使用方法。
    • 模块:解释模块的整体功能。
  • 风格
    • 对于简单的单行文档字符串,可以这样写:
      def add(a, b):
          """返回两个数字的和。"""
          return a + b
      
    • 对于多行文档字符串,通常在第一行写简短摘要,然后空一行,再写详细说明。这在大型项目中非常常见。
      def complex_calculation(value, factor=1.0):
          """对输入值执行复杂的数学计算。
      
          此函数首先将值乘以一个因子,然后进行平方,
          最后加上一个预设的常量。
      
          Args:
              value (int): 需要计算的整数值。
              factor (float, optional): 乘法因子。默认为 1.0。
      
          Returns:
              float: 计算后的结果。
          """
          result = (value * factor) ** 2 + 10
          return result
      

有意思的区别,Java的注释写在方法的上面,Python的注释写在方法的下面。

行与缩进

python最具特色的就是使用缩进来表示代码块,不需要使用大括号 { }

缩进的空格数是可变的,但是同一个代码块的语句必须包含相同的缩进空格数。实例如下:

if True:
    print ("True")
else:
    print ("False")

以下代码最后一行语句缩进数的空格数不一致,会导致运行错误:

if True:
    print ("Answer")
    print ("True")
else:
    print ("Answer")
  print ("False")    # 缩进不一致,会导致运行错误

Python的 print() 函数功能很强大,特别是在处理多个参数和格式化输出方面,它提供了更多灵活的选项。

在Python中,print() 不是一个语句,而是一个内置函数。它是学习Python的第一个重要工具,用于在控制台输出信息。

1. 基本用法

print() 函数最简单的用法是直接传入你想要输出的对象。它可以是字符串、数字、变量,甚至更复杂的对象。

print("Hello, Python!")
name = "Gemini"
age = 2
print("My name is", name, "and I'm", age, "years old.")

上面的代码会输出:Hello, Python!My name is Gemini and I'm 2 years old.

print() 函数会自动在每个参数之间添加一个空格。

2. 参数详解

print() 函数有很多可选参数,可以帮助你更好地控制输出的格式。最常用的有:

  • sep (separator):分隔符。用来指定多个参数之间的分隔符,默认是空格。

    print("apple", "banana", "cherry", sep=", ")
    # 输出: apple, banana, cherry
    
    print("2025", "09", "05", sep="-")
    # 输出: 2025-09-05
    
  • end:结尾符。用来指定输出结束后在行尾添加的字符,默认是换行符 \n

    print("Hello", end=" ")
    print("world!")
    # 输出: Hello world!
    # 注意,两个 print 语句的输出在同一行
    
  • file:指定输出到哪个文件对象。默认是标准输出 sys.stdout,也就是控制台。

    with open("output.log", "w") as f:
        print("This message will be written to a file.", file=f)
    
  • flush:布尔值,是否强制刷新缓冲区。当你将内容输出到文件或管道时,内容可能会先缓存在内存中。flush=True 会立即将内容写入目标。在通常的控制台输出中,你很少需要用到这个参数。

3. 高级格式化输出

除了 print() 函数本身,Python还提供了几种强大的字符串格式化方法,可以和 print() 配合使用。

  • % 运算符(旧式):类似C语言的 printf()

    print("My name is %s and I'm %d years old." % (name, age))
    

    这种方式在现代Python代码中已经不那么常见了,但你可能会在一些老项目中看到它。

  • str.format() 方法:更灵活和可读性更好的方式。

    print("My name is {} and I'm {} years old.".format(name, age))
    
  • f-string (推荐):从Python 3.6开始引入,语法简洁,可读性极高,是目前最主流的格式化方式。

    print(f"My name is {name} and I'm {age} years old.")
    

类型问题

Python中变量的类型是动态的,也就是说,你不需要在定义变量时指定它的类型,Python会根据赋值自动确定。并且在运行过程中,变量类型是可以随时更改的:

a = int(0)
print(f"a: {a}, type: {type(a)}, address: {id(a)}")
a = "test"
print(f"a: {a}, type: {type(a)}, address: {id(a)}")
a = {"keyOne": "valueOne"}
print(f"a: {a}, type: {type(a)}, address: {id(a)}")

变量a可以指向不同类型的对象,这就是动态类型的含义。

判断类型的方法,isinstance 和 type 的区别在于:

  • type()不会认为子类是一种父类类型。
  • isinstance()会认为子类是一种父类类型。

字符串截取

索引值以 0 为开始值,-1 为从末尾的开始位置。

a = "hello"
print(a[-4:-1])
# 输出: ell

print(a[1:3])
# 输出: el

不管索引方向是哪一个,这个区间都是左闭右开的。即含头不含尾。

List类型

List(列表) 是 Python 中使用最频繁的数据类型。

列表可以完成大多数集合类的数据结构实现。 列表中元素的类型可以不相同 ,它支持数字,字符串甚至可以包含列表(所谓嵌套)。

列表是写在方括号 [] 之间、用逗号分隔开的元素列表。

和字符串一样,列表同样可以被索引和截取,列表被截取后返回一个包含所需元素的新列表。

列表截取的语法格式如下:

变量[头下标:尾下标]

索引值以 0 为开始值,-1 为从末尾的开始位置。

list = [ 'abcd', 786 , 2.23, 'runoob', 70.2 ]  # 定义一个列表
tinylist = [123, 'runoob']

print (list)            # 打印整个列表
print (list[0])         # 打印列表的第一个元素
print (list[1:3])       # 打印列表第二到第四个元素(不包含第四个元素)
print (list[2:])        # 打印列表从第三个元素开始到末尾
print (tinylist * 2)    # 打印tinylist列表两次
print (list + tinylist)  # 打印两个列表拼接在一起的结果

Python 列表截取可以接收第三个参数,参数作用是截取的步长,以下实例在索引 1 到索引 4 的位置并设置为步长为 2(间隔一个位置)来截取字符串。如果是负数就表示逆向截取。

def reverseWords(input): 
      
    # 通过空格将字符串分隔符,把各个单词分隔为列表
    inputWords = input.split(" ") 
  
    # 翻转字符串
    # 假设列表 list = [1,2,3,4],  
    # list[0]=1, list[1]=2 ,而 -1 表示最后一个元素 list[-1]=4 ( 与 list[3]=4 一样) 
    # inputWords[-1::-1] 有三个参数
    # 第一个参数 -1 表示最后一个元素
    # 第二个参数为空,表示移动到列表末尾
    # 第三个参数为步长,-1 表示逆向
    inputWords=inputWords[-1::-1] 
  
    # 重新组合字符串
    output = ' '.join(inputWords) 
      
    return output 
  
if __name__ == "__main__": 
    input = 'I like runoob'
    rw = reverseWords(input) 
    print(rw)

Tuple(元组)

元组(tuple)与列表类似,不同之处在于元组的元素不能修改。元组写在小括号 () 里,元素之间用逗号隔开。元组中的元素类型也可以不相同。

#!/usr/bin/python3

tuple = ( 'abcd', 786 , 2.23, 'runoob', 70.2  )
tinytuple = (123, 'runoob')

print (tuple)             # 输出完整元组
print (tuple[0])          # 输出元组的第一个元素
print (tuple[1:3])        # 输出从第二个元素开始到第三个元素
print (tuple[2:])         # 输出从第三个元素开始的所有元素
print (tinytuple * 2)     # 输出两次元组
print (tuple + tinytuple) # 连接元组

构造包含 0 个或 1 个元素的元组比较特殊,所以有一些 额外的语法规则

tup1 = ()    # 空元组
tup2 = (20,) # 一个元素,需要在元素后添加逗号

如果你想创建只有一个元素的元组,需要注意 在元素后面添加一个逗号 ,以区分它是一个元组而不是一个普通的值,这是因为在没有逗号的情况下,Python会将括号 解释为数学运算中的括号 ,而不是元组的表示。

如果不添加逗号,如下所示,它将被解释为一个普通的值而不是元组:

not_a_tuple = (42)

【网络】使用Reqable抓包Https请求记录

【网络】使用Reqable抓包Https请求记录

本文记录了使用Reqable抓包Https请求的过程。

在集成DeepSeek的Chat对话API时,我遇到了一个典型的用户体验问题:启用流式输出(streaming response)后,服务器返回的数据过于密集且速度快,导致前端页面渲染出现卡顿,文字滚动不连贯,严重影响交互流畅度。

为了精准定位问题根源,我需要分析实际网络传输过程中的数据包特征——包括响应头、数据分块频率、SSE协议实现细节等。此时,专业的抓包工具成为不可或缺的助手。经过对比,我选择了 Reqable 这款支持跨平台、全协议(尤其是HTTPS解密)的抓包工具,它不仅能捕获明文和加密流量,还提供直观的过滤和可视化分析功能。

本文将详细记录从环境搭建到HTTPS抓包配置的全过程,并解析DeepSeek流式接口返回的 text/event-stream 数据特征。

抓包工具工作原理

我们都知道HTTPS协议是加密传输,是安全的,抓包工具是如何插到客户端和服务器中间,来明文地查看通信报文的呢?

本文介绍的Reqable,或者其他工具如 Fiddler、Charles 等抓包工具能够拦截并显示 HTTPS 报文的通信信息的原理,核心在于利用了 “中间人攻击”(Man-in-the-Middle, MITM) 技术,但它是一种 受控的、合法的、且需要用户授权的操作

本质上,抓包工具扮演了一个 “代理服务器”(Proxy Server) 的角色,将自己插入到客户端(浏览器、App)和目标服务器之间。

以下是抓包工具拦截和解密 HTTPS 报文的详细步骤:

1. 代理和证书的安装

这是实现 HTTPS 抓包的前提条件。必须让APP信任抓包工具提供的证书,才可能往下推进。

  1. 设置代理: 您需要将客户端设备(如手机、电脑)的网络配置指向抓包工具运行的机器的 IP 和端口,让所有的网络流量都流经抓包工具。
  2. 安装根证书(Root Certificate): 抓包工具会生成一个自己的根证书。您必须将这个证书安装到客户端设备(手机或电脑)的系统信任证书列表中。这是最关键的一步,它让客户端设备“相信”抓包工具是合法的证书颁发机构(CA)。

1.1 需要注意的情况

从 Android 7 开始,App 默认不再信任用户安装的 CA 证书(即抓包工具的根证书),除非 App 的 network_security_config.xml 文件中显式配置允许,Reqable就是采用了这个方案。如果缺少证书的验证,只能看到客户端的所有网络请求地址,在校验初期就会失败而中断连接。

另外,有些高度安全的 App 会将目标服务器的真实证书公钥硬编码到 App 代码中。在这种情况下,即使您安装了抓包工具的根证书,App 也会发现抓包工具发送的假证书与硬编码的真实证书不匹配,从而立即终止连接,防止抓包。要绕过证书固定,通常需要使用 Hook 工具在 App 验证证书的代码执行前将其拦截和修改,这也是移动安全研究的一个重要领域。

2. TLS 握手时的“中间人”行为

当客户端(App 或浏览器)尝试通过 HTTPS 连接目标服务器时,抓包工具开始扮演“中间人”的角色。

1. 客户端问候 ,客户端向抓包工具发送 Client Hello。抓包工具将 Client Hello 转发给目标服务器。 2. 服务器响应 ,抓包工具收到目标服务器的 Server Hello真实证书。目标服务器发送真实证书3. 伪造证书 ,抓包工具动态生成一个“假证书”,其域名与目标服务器的域名(如 www.google.com)一致。然后,抓包工具使用自己安装在客户端上的“根证书”来签名这个假证书。 4. 抓包工具问候,抓包工具将这个伪造的、已签名的假证书发送给客户端。 5. 客户端信任 ,客户端收到证书后,发现这个假证书的签发者(抓包工具)的根证书在自己的信任列表里(因为您手动安装了),因此客户端认为这个假证书是合法的

3. 双重加密和解密

握手完成后,抓包工具实际上建立了两个独立的 TLS 连接:

  • 客户端 $\leftrightarrow$ 抓包工具** ,抓包工具是服务器的角色,使用假证书协商出的对称密钥 $K_A$ ,报文被加密(用 $K_A$)
  • 抓包工具 $\leftrightarrow$ 目标服务器,抓包工具是客户端的角色,使用真实证书协商出的对称密钥 $K_B$ ,报文被加密(用 $K_B$)

数据流转过程:

  1. 客户端发送数据: 客户端用 $K_A$ 加密数据 $\rightarrow$ 发给抓包工具。
  2. 抓包工具解密(第一次): 抓包工具收到数据后,用自己的 $K_A$ 将报文解密成明文
  3. 显示与修改: 此时,Reqable 等工具就能以明文形式显示通信信息,甚至允许您在转发前修改报文内容。
  4. 抓包工具加密(第二次): 抓包工具用 $K_B$ 重新加密报文 $\rightarrow$ 发给目标服务器。
  5. 目标服务器解密: 目标服务器用自己的 $K_B$ 将报文解密。

通过建立并控制这两个独立的 TLS 通道,抓包工具成功地看到了 中间的明文数据。

Reqable 的安装

Reqable是一款集API抓包调试与测试于一体的高效工具,支持以下核心能力:

  • 多平台覆盖:Windows/macOS/Linux/Android/iOS全主流操作系统
  • HTTPS解密:通过安装CA证书实现加密流量的明文查看
  • 协议支持全面:HTTP/1.1、HTTP/2、WebSocket、gRPC、SSE等
  • 过滤与分析强大:支持按域名、状态码、内容类型等条件快速筛选目标请求

在下载页面下载并安装Reqable的MACOS和Android的安装包,地址为:

Reqable下载页面

要抓取HTTPS请求,必须解决中间人攻击防护机制导致的加密流量不可见问题。Reqable采用标准CA证书信任机制,需要在客户端(Mac和Android)分别安装其根证书并信任。

Android平台需要手动下载并配置Android端的证书,下载Reqable的证书后,在设置的安全页面,点击安装证书。

Android证书安装

关键步骤为手动配置证书信任:

  1. 手机与电脑处于同一局域网,连接Wi-Fi后进入 Wi-Fi设置 → 选择当前网络 → 修改网络 → 高级 → 代理,设置为 手动代理
  2. 输入PC的本地IP(如 192.168.1.5)和Reqable默认监听端口 9000(可在Reqable的「设置→代理」中确认)。
  3. 在Reqable中导出CA证书(设置 → 证书 → 导出),或直接通过手机浏览器访问证书下载链接(若失败则手动导出并传输到手机):链接2。
  4. 在手机 设置 → 安全 → 加密与凭据 → 安装证书 → CA证书,选择下载的证书文件完成安装(需输入锁屏密码授权)。
  5. 最后进入 设置 → 安全 → 加密与凭据 → 信任的凭据 → 用户,确认证书已成功安装。

📌 注意:若你的Android项目未自动信任用户证书(例如非Debug包或Flutter项目),需额外配置网络安全策略(详见下文)。

添加网络安全配置文件开发者在项目源码中配置网络安全文件用户证书,并重新打包,选择下面任意一种方式即可。注意,网络安全配置不适用于Flutter项目。

build.gradle中配置依赖(推荐)

dependencies {
    debugImplementation 'com.reqable:reqable-android:1.0.0'
}

Debug包将自动集成网络安全配置文件,如果无法连接到Maven中央仓库,请按照下方的指引手动创建并配置网络安全文件。

手动创建网络安全文件新建文件 res/xml/network_security_config.xml

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <base-config cleartextTrafficPermitted="true">
    <trust-anchors>
      <certificates src="system" />
      <certificates src="user" />
    </trust-anchors>
  </base-config>
</network-security-config>

配置到 AndroidManifest.xml

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>
    ...
</application>

完成以上步骤后,重新打包应用,进行双端互联,即可开始抓包。

抓包配置

完成双端配置后,启动Reqable的抓包功能(默认开启代理监听)。界面会实时显示所有经过代理的网络请求,包括请求方法、URL、状态码、响应时间等基础信息。

初始状态下,列表可能包含大量无关请求(如广告、资源加载)。为聚焦目标,我们通过 域名过滤 快速定位DeepSeek相关流量。

点击启动按钮后,在列表中可以看到所有的网络请求的情况。

对于Deepseek的调试,我将服务器地址这里的 https://api.deepseek.cn 添加到Reqable的筛选中。

在AI助手页面内,输入对应的功能,触发网络请求之后,即可看到服务器返回的情况。

根据原始数据可以看出,服务器返回的类型确实是一个流式的数据 text/event-stream 。选中某一条目标请求,展开详情面板。从响应头中可以看到关键信息:

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
...

重点字段解析:

  • Content-Type: text/event-stream:表明服务器返回的是Server-Sent Events(SSE)格式的流式数据,专为实时推送设计。
  • Transfer-Encoding: chunked:数据以分块(chunked)形式传输,适合不确定长度的流式内容。

进一步切换到 SSE 标签页(Reqable针对SSE协议提供的专用解析视图),可以清晰看到服务端推送的数据块结构。根据DeepSeek官方文档说明,当参数 stream=true 时,API会以SSE形式持续返回增量内容,每条数据以 data: {...} 格式发送,最终以 data: [DONE] 标志结束。

通过观察原始数据流,我们发现服务端推送频率较高(例如每秒多次),且单次数据块较小——这正是前端渲染卡顿的直接原因。

根据Deepseek官方文档,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。SSE(Server-Sent Events)返回类型是一种用于实现服务器向客户端单向实时推送数据的技术。它基于 HTTP 协议,允许服务器通过一个长连接持续向客户端发送数据流。

优化思路

基于抓包分析结果,我们可以针对性地优化用户体验。对接收到的SSE数据块进行缓冲,合并短时间内的多次推送后再更新UI,减少频繁重绘。或者在每一个数据包推送过来之后,手动加一段100-200ms的延时,让界面有一定的响应时间,避免用户体验上的卡顿。或者与DeepSeek团队沟通,探讨是否支持调整推送频率或合并小数据块(例如按语义段落聚合)。

【网络】Retrofit设计模式解析

【网络】Retrofit设计模式解析

本文介绍了流行框架retrofit的设计理念和实现原理

Retrofit 是一个由 Square 公司(现在叫 Block)开发的 类型安全 (Type-safe) 的 HTTP 客户端,专门用于 Android 应用开发。

它的核心思想非常巧妙:将一个 HTTP API 抽象成一个 Java 接口。你只需要通过 注解(Annotations) 来描述网络请求的各个部分(如请求方法、URL、请求头、请求体等),Retrofit 就会在运行时自动为你生成实现这个接口的 动态代理对象 ,然后你就可以像调用一个普通的 Java 方法一样来发起网络请求了。

Retrofit解决的痛点

在 Retrofit 出现之前,开发者通常使用 HttpURLConnection 或 Apache HttpClient,代码冗长、易错且难以维护。后来出现的 OkHttp 极大地改善了底层通信,但直接使用 OkHttp 仍然需要手动构建 Request 和处理 Response,不够直观。

声明式 API

接口的定义设计为了声明式 API,代码简洁优雅,你只需定义一个接口和一些方法,用注解来描述 API,完全无需关心如何构建 Request 对象、如何解析 Response 等繁琐细节。

传统方式需要手动拼接 URL,设置请求头,创建请求体,执行请求,检查状态码,读取输入流,然后用 Gson/Jackson 手动解析 JSON 字符串。整个过程可能需要几十行代码。使用Retrofit只需要定义一个接口方法,一行代码调用即可。

类型安全

类型安全也是 Retrofit 最强大的特性之一。网络请求的返回数据会通过转换器(Converter)自动反序列化成你指定的 Java 或 Kotlin 对象(如一个 UserList<Repo>)。

如果在接口中定义的返回类型与服务器实际返回的 JSON 结构不匹配,会在运行时通过转换器抛出异常,而不是得到一个 null 或导致 ClassCastException,这使得错误能够被快速定位和处理。请求参数的类型也是确定的,减少了手动拼写等错误的概率。

高度集成与可扩展性

Retrofit 底层默认且推荐使用 OkHttp 来执行实际的网络请求。因此,它自动继承了 OkHttp 的所有优点,如连接池、Gzip 压缩、请求重试和响应缓存等。你还可以自定义 OkHttpClient 来配置拦截器(Interceptor)、超时时间等。

通过可插拔的 Converter,Retrofit 不仅支持 JSON (通过 Gson, Moshi, Jackson),还支持 XML, Protobuf 等多种数据格式。

除了直接使用默认的 Call<T> 回调方式。通过 CallAdapter,Retrofit 可以将请求的返回类型适配成不同的异步工具,完美支持 Kotlin 协程 (Coroutines)、RxJava、Flow、Guava 等。

性能优异

由于底层依赖于高性能的 OkHttp,Retrofit 本身的开销非常小。它主要是在初始创建时通过反射生成接口实现,一旦创建完成,后续调用的性能非常高。

Retrofit 的核心组件

下面这几个组件是掌握 Retrofit 的关键。

API 接口 (The API Interface)

定义一个 interface 接口类,在接口里定义网络请求方法,然后使用注解来描述每个请求方法。

常用注解

  • 请求方法: @GET, @POST, @PUT, @DELETE, @HEAD, @PATCH
  • URL 处理:
    • @Path: 替换 URL 中的路径段,如 /users/{id} 中的 id
    • @Query: 添加查询参数,如 /users?sort=desc 中的 sort
    • @Url: 当需要动态指定完整 URL 时使用。
  • 请求体:
    • @Body: 指定一个对象作为请求体,通常用于 POSTPUT,会被 Converter 序列化。
    • @Field: 用于表单提交 (application/x-www-form-urlencoded),需要与 @FormUrlEncoded 配合使用。
  • 请求头: @Header, @Headers
// api declaration GithubApiService.kt
interface GithubApiService {
  @GET("users/{username}")
  fun getUser(@Path("username") username: String): Call<User>
}

Retrofit 类 (The Retrofit Class)

这是整个框架的入口,通过建造者模式 Retrofit.Builder 来配置和构建一个 Retrofit 实例。

主要配置项一般为:

  • baseUrl(): API 的基础 URL,必须以 / 结尾。
  • addConverterFactory(): 添加数据转换器工厂,用于序列化和反序列化。
  • addCallAdapterFactory(): 添加调用适配器工厂,用于支持不同的异步库。
  • client(): 传入一个自定义的 OkHttpClient 实例。
// create retrofit instance
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(CoroutineCallAdapterFactory())
    .build()

// create api service instance
val githubApiService = retrofit.create(GithubApiService::class.java)

转换器 (Converter)

负责将 Java 对象(POJO)与网络请求的 Body(如 JSON, XML)进行相互转换。 * 请求时:将 @Body 注解的对象序列化成网络请求体。 * 响应时:将网络响应体反序列化成接口方法定义的返回类型对象。 * 常用转换器: * converter-gson: 使用 Google 的 Gson 库。 * converter-moshi: 使用 Square 的 Moshi 库,性能更高,对 Kotlin 更友好。 * converter-jackson: 使用流行的 Jackson 库。

调用适配器 (Call Adapter)

这个配置决定了你接口方法的返回类型。默认情况下,方法返回 Call<T> 类型,通过添加适配器,你可以让方法直接返回其他类型,从而与现代异步编程范式结合。

示例

  • 不加适配器:fun getUser(): Call<User>
  • 使用协程:suspend fun getUser(): User (需要 CallAdapter 在幕后处理)
  • 使用 RxJava:fun getUser(): Single<User>

一个完整的 Kotlin + 协程示例

下面这个具体的例子,从 GitHub API 获取一个用户的信息。

第 1 步:添加依赖 (build.gradle.kts)

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0") // 请使用最新版本
// Gson Converter
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
// OkHttp (可选,用于自定义配置和日志拦截器)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

第 2 步:定义数据模型 (Data Class/POJO)

data class User(
    val login: String,
    val id: Int,
    val avatar_url: String,
    val name: String?,
    val company: String?
)

第 3 步:定义 API 接口

import retrofit2.http.GET
import retrofit2.http.Path

interface GithubApiService {
    /**
     * 使用 suspend 关键字,使其成为一个协程挂起函数
     * Retrofit 会自动处理线程切换和结果返回
     */
    @GET("users/{username}")
    suspend fun getUser(@Path("username") username: String): User
}

第 4 步:创建 Retrofit 实例 通常我们会把它放在一个单例或者依赖注入的模块中。

import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitClient {

    private const val BASE_URL = "https://api.github.com/"

    // 创建一个日志拦截器,用于打印网络请求和响应的日志
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY // 打印最详细的日志
    }

    // 创建一个 OkHttpClient,并添加日志拦截器
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .build()

    val instance: GithubApiService by lazy {
        val retrofit = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient) // 设置自定义的 OkHttpClient
            .addConverterFactory(GsonConverterFactory.create())
            // 注意:对于 suspend 函数,Retrofit 内部已经自动处理了 CallAdapter,无需显式添加
            .build()
        
        retrofit.create(GithubApiService::class.java)
    }
}

第 5 步:发起网络请求 在 ViewModel 或者其他有协程作用域的地方调用。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class MyViewModel : ViewModel() {

    fun fetchGithubUser(username: String) {
        // 在 ViewModel 的协程作用域中启动一个新协程
        viewModelScope.launch {
            try {
                // 直接调用接口方法,就像调用一个普通函数一样
                val user = RetrofitClient.instance.getUser(username)
                
                // 请求成功,处理 user 数据
                println("User Name: ${user.name}")
                println("User Company: ${user.company}")
                
            } catch (e: Exception) {
                // 请求失败,处理异常
                println("Request failed: ${e.message}")
            }
        }
    }
}

这个例子清晰地展示了 Retrofit 的威力:定义接口、创建实例、然后直接调用方法。所有复杂的网络细节都被完美地隐藏了起来。

小结

Retrofit 不是一个网络请求库,而是一个网络请求的“封装”或“适配”库。 它站在巨人 OkHttp 的肩膀上,通过注解和动态代理技术,将开发者从繁琐的网络请求实现中解放出来,让我们能够用更符合直觉、更健壮、更易于维护的方式与 REST API 进行交互。

在今天的 Android 开发中,Retrofit + OkHttp + Kotlin Coroutines + Moshi/Gson 的组合已经成为应用架构网络层的“黄金标准”,是每个 Android 开发者都应该熟练掌握的技能。

Ktor

Ktor 是一个由 JetBrains 开发的,用于在 Kotlin 中构建连接应用的异步框架。它旨在提供一个轻量级、灵活且高度可扩展的网络应用框架,既可以用于构建服务器端应用(如 RESTful API、微服务、Web 网站),也可以用于构建多平台 HTTP 客户端应用。

Android平台上的基础使用

首先添加gradle依赖:

implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version"

之后就可以使用HttpClient定制化,例如添加日志、内容协商、序列化等功能。然后就可以使用HttpClient发送请求了。

class KtorClient {

    companion object {
        const val TAG = "KtorClient"
    }

    private val client = HttpClient(CIO) {
        install(Logging) {
            level = LogLevel.ALL
        }
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
            })
        }
    }

    suspend fun getGithubRepos(userName: String) = withContext(Dispatchers.IO) {
        client.get("https://api.github.com/users/${userName}/repos")
            .body<List<GithubRepoItem>>()
    }

    fun release() {
        client.close()
    }
}

CIO 是 Ktor 自己的纯 Kotlin 实现的 I/O 引擎。它的设计目标是轻量级、无额外依赖、并完全基于 Kotlin 协程构建。这意味着它能最大程度地利用 Kotlin 协程的异步特性,提供高效且非阻塞的 I/O 操作。它直接利用 Kotlin 协程的调度和挂起机制来处理网络事件。它的内部实现尽可能地避免了阻塞操作,并且通过协程调度来管理并发连接。

设计理念

纯 Kotlin 和协程优先(Kotlin and Coroutines First)

Ktor 完全基于 Kotlin 语言构建,充分利用 Kotlin 的语言特性,例如 DSL(领域特定语言)、扩展函数、协程等。

Ktor 的异步编程模型是基于 Kotlin 协程实现的。这意味着开发者可以使用看似 同步的命令式代码来编写异步逻辑 ,极大地简化了并发编程的复杂性,避免了回调地狱,并提高了代码的可读性和可维护性。每个请求都会在 Ktor 中启动一个新的协程来处理,从而实现高效的并发。

轻量级和非侵入式(Lightweight and Unopinionated)

Ktor 不强加固定的项目结构或技术栈。它允许开发者根据自己的需求选择日志、模板引擎、消息队列、持久化、序列化、依赖注入等各种技术。

它提供了一个松散耦合的架构,你可以只使用你需要的功能,而不是一个庞大的全功能框架。这种灵活性使得 Ktor 非常适合构建微服务或需要高度定制化的应用。

Ktor 的 API 大多是函数调用和 Lambda 表达式,结合 Kotlin 的 DSL 能力,使代码看起来声明式且简洁。

高度可扩展性(Highly Extensible via Plugins/Features)

Ktor 采用 “插件” 机制来实现其核心功能和可扩展性。诸如内容协商、身份验证、日志、会话管理、压缩等功能都是通过安装插件来实现的。

这种统一的拦截机制允许在请求/响应处理管道的不同阶段插入自定义逻辑。开发者可以轻松地编写自己的插件来扩展 Ktor 的功能,或者集成第三方库。

多平台支持(Multiplatform Support)

Ktor 不仅仅局限于 JVM。Ktor Client 模块支持 Kotlin Multiplatform Mobile (KMM),允许在 Android、iOS、桌面以及服务器端共享网络逻辑。这使得 Ktor 成为构建跨平台应用的理想选择。

类型安全路由(Type-Safe Routing)

Ktor 提供了类型安全的路由机制,允许通过类而不是字符串来定义 API 结构和路由参数。这在编译时就可以验证路由参数和路径,减少了常见的运行时错误,并使得重构更加安全和可管理。

【网络】OkHttp的设计理念和原理

【网络】OkHttp的设计理念和原理

本文介绍了流行框架okhttp的设计理念和实现原理

OkHttp 是一个由 Square 公司开发的、功能强大且高效的 HTTP 客户端库,广泛用于 Java 和 Android 应用中进行网络请求。它被认为是 Android 生态系统中进行网络通信的首选库,许多其他高级网络库(如 Retrofit)都是基于 OkHttp 构建的。

之前的网络请求

OkHttp 通过其高效的连接管理、强大的拦截器机制和对现代 HTTP 协议的支持,极大地简化了 Android 网络通信的开发。OkHttp出来之后,可以说是迅速抢占了大部分市场,在之后,更是成为了Android应用网络请求的标准范例。

我记得刚开始学习Android时,使用的还是原生的 HttpURLConnection 。在使用 HttpURLConnection 的时候,我们需要 手动管理连接、输入输出流、设置请求头、处理响应 等。这一过程繁琐且容易出错,特别是在处理复杂的网络请求时。

public class HttpUtil {
    public static String sendGetRequest(String url) {
        HttpURLConnection connection = null;
        BufferedReader reader = null;
        String result = null;
        try {
            // 打开连接
            URL requestUrl = new URL(url);
            connection = (HttpURLConnection) requestUrl.openConnection();
            connection.setRequestMethod("GET");
            connection.setConnectTimeout(5000);
            connection.setReadTimeout(5000);

            // 读取响应
            reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
            StringBuilder response = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                response.append(line);
            }
            result = response.toString(); 
        } catch (IOException e) {
            e.printStackTrace(); 
        } finally {
            // 关闭连接和流
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            } 
        }
        return result;
    } 
}

一次完整的网络请求流程

在安卓应用中完成一次 HTTP 请求与响应的完整流程,会涉及多个网络分层,下面按照 TCP/IP 四层模型,结合 OSI 模型对应层次来详细介绍各层在请求和响应阶段完成的工作。

请求过程

  • 应用层 ,开发者使用 HTTP 客户端构建 HTTP 请求,指定请求方法(GET、POST 等)、URL、请求头和请求体等信息。按照 HTTP 规范组织请求数据,生成符合格式的 HTTP 请求报文,之后将请求报文传递给传输层。
  • 传输层 ,首先建立连接,客户端和服务器通过三次握手建立 TCP 连接。客户端发送 SYN 报文,服务器返回 SYN + ACK 报文,客户端再发送 ACK 报文完成连接建立。连接连理之后,将应用层的 HTTP 请求报文分割成合适大小的报文段,并为每个报文段添加 TCP 首部(包含源端口、目标端口、序列号等信息)。通过序列号和确认应答机制保证数据可靠传输,若发送的报文段未收到确认应答,会进行重传。
  • 网络层 ,基于 IP 协议,将传输层的 TCP 报文段封装成 IP 数据报,添加 IP 首部(包含源 IP 地址和目标 IP 地址),根据目标 IP 地址进行路由选择,确定数据报从客户端到服务器的传输路径。将目标 IP 地址解析为对应的 MAC 地址,以便数据在链路层传输。
  • 数据链路层 ,将网络层的 IP 数据报封装成帧,添加帧首部(包含源 MAC 地址和目标 MAC 地址)和帧尾部(包含校验信息)。控制设备对物理介质的访问,避免数据冲突。例如,以太网使用 CSMA/CD(载波监听多路访问/冲突检测)协议。通过物理介质(如 Wi-Fi、移动网络)将帧发送到下一个节点。
  • 物理层 ,信号转换,将数据链路层的二进制数据转换为物理信号(如电信号、光信号),通过物理介质进行传输。

响应过程

  • 物理层 ,在接收端,将物理信号转换为二进制数据,传递给数据链路层。
  • 数据链路层 ,接收物理层传来的帧,检查帧的校验信息,若校验正确,则去掉帧首部和帧尾部,将 IP 数据报传递给网络层。
  • 网络层 ,接收数据链路层传来的 IP 数据报,检查 IP 首部信息,若目标 IP 地址是本机,则去掉 IP 首部,将 TCP 报文段传递给传输层。ICMP 协议,在网络出现错误(如网络不可达、超时等)时,发送错误报告和控制消息。
  • 传输层 ,接收TCP的响应报文段,对报文段进行排序和重组。向服务器发送确认应答,告知服务器数据已成功接收。数据传输完成后,客户端和服务器通过四次挥手断开 TCP 连接。
  • 应用层 ,接收传输层传来的 HTTP 响应报文,解析响应状态码、响应头和响应体。从 HTTP 客户端获取解析后的响应数据,根据业务逻辑进行处理。

OkHttp设计理念和核心特性

OkHttp 的设计目标是让 HTTP 网络请求 更快、更稳定、更易用 ,并且提供丰富的可配置性。

高效的网络传输

连接池 (Connection Pooling) 设计。OkHttp 维护一个连接池,对同一主机的多个请求可以 复用已建立的 TCP 连接 。这大大减少了连接建立和断开的开销,尤其是在进行大量小请求时,能显著提高性能。

OkHttp 完全支持 HTTP/2协议 。HTTP/2 允许多个请求和响应在单个 TCP 连接上进行 多路复用 ,解决了 HTTP/1.x 中队头阻塞(Head-of-Line Blocking)的问题,进一步提高了并发性和效率。

OkHttp 默认支持透明的 GZIP 压缩。当服务器返回 GZIP 压缩的数据时,OkHttp 会自动解压,减少了传输的数据量,节省了带宽。

OkHttp 支持响应缓存,可以将服务器返回的响应缓存到本地磁盘。对于重复的请求,如果缓存有效,OkHttp 可以直接从缓存中读取数据,而无需再次进行网络请求,从而加速响应并减少网络流量。

OkHttp 还能够通过拦截器自动处理常见的网络问题,如连接失败的重试和 HTTP 重定向 (3xx 状态码)。这使得网络请求更加健壮。

简洁易用的 API

链式构建器 (Builder Pattern) 的设计:OkHttp 使用构建器模式来配置 OkHttpClientRequest 对象,使得 API 调用非常流畅和直观。

OkHttpClient client = new OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .addInterceptor(new LoggingInterceptor())
        .build();

Request request = new Request.Builder()
        .url("https://api.example.com/data")
        .header("User-Agent", "OkHttp Demo")
        .get() // GET, POST, PUT, DELETE, PATCH
        .build();

OkHttp 同时支持同步和异步请求。

  • 同步请求:通过 client.newCall(request).execute() 方法执行,会阻塞当前线程直到收到响应。注意:在 Android 主线程中严禁执行同步网络请求,会引发 ANR (Application Not Responding)!
  • 异步请求:通过 client.newCall(request).enqueue(callback) 方法执行,请求在后台线程进行,结果通过回调接口 Callback 返回到指定线程(通常是主线程)。

强大的可扩展性:拦截器 (Interceptors)

Interceptors 拦截器是 OkHttp 最强大的特性之一,它基于责任链设计模式。你可以在请求发送前和响应接收后插入自定义的逻辑。

主要在以下方面应用:

  • 日志记录 (Logging):打印请求和响应的详细信息,方便调试。
  • 身份验证 (Authentication):自动添加认证头,如 OAuth token。
  • 离线缓存 (Offline Caching):在没有网络时从缓存中获取数据。
  • 重试机制 (Retry Logic):自定义失败请求的重试策略。
  • 参数添加/修改:统一添加公共参数或修改请求头。
  • 数据压缩/加密:对请求或响应数据进行额外的处理。

拦截器主要分为两种:

  • 应用拦截器 (Application Interceptors):通过 addInterceptor() 添加。它们运行在网络请求之前,并且在重定向、缓存和重试操作之间只调用一次。适用于应用级别的逻辑。
  • 网络拦截器 (Network Interceptors):通过 addNetworkInterceptor() 添加。它们直接操作网络请求,可以观察到底层的网络连接、重定向和重试。适用于监控网络行为或底层协议的修改。

与 Okio 的紧密集成

OkHttp 的底层 I/O 操作是基于 Okio 库实现的。Okio 提供了高效的 Buffer (分段缓冲区) 和 Source/Sink (读写流) 抽象,使得 OkHttp 在处理数据流时能够避免不必要的内存拷贝,从而提高了性能。

安全性

OkHttp 原生支持 HTTPS,并可以配置自定义的 SSLSocketFactoryHostnameVerifier 来进行证书信任和主机名验证。

证书固定 (Certificate Pinning)特性,允许开发者指定信任的服务器证书,防止中间人攻击。即使根证书被篡改,也能识别出伪造的服务器。

WebSocket 支持

OkHttp 不仅支持传统的 HTTP/HTTPS 请求,还提供了对 WebSocket 的完整支持,用于实现双向、持久的通信。

OkHttp 的架构概览

一个典型的 OkHttp 请求流程通常涉及以下几个关键组件:

OkHttpClient

HTTP 请求的执行者。它是线程安全的。

val client = OkHttpClient.Builder().xxx.build()

由上述调用方式,我们便可以猜出,这里使用了 构建者模式 去配置默认的参数,所以直接去看 OkHttpClient.Builder 支持的参数即可。

需要注意的是,在使用过程中,对于 OkHttpClient 我们还是应该缓存下来或者使用单例模式以便后续复用,因为其相对而言还是比较重,可以充分利用连接池和其他资源。

Request

指客户端发送到服务器的 HTTP请求。在 OkHttp 中,可以使用 Request 对象来构建请求,然后使用 OkHttpClient 对象来发送请求。

通常情况下,一个请求包括了 请求头、请求方法、请求路径、请求参数、url地址 等信息。主要是用来请求服务器返回某些资源,如网页、图片、数据等。

val request = Request.Builder()
    .url("https://api.example.com/data") // 请求的 URL
    .header("User-Agent", "OkHttp Demo") // 添加请求头
    .get() // HTTP 方法,这里  是 GET
   .build() // 构建请求对象

Call and Response

当我们使用 OkHttpClient.newCall() 方法时,实际是创建了一个新的 RealCall 对象,它代表了一个准备好执行的 HTTP 请求。用于 应用层与网络层之间的桥梁,用于处理连接、请求、响应以及流 ,其默认构造函数中需要传递 okhttpClient 对象以及 request 。Call 可以同步执行 (execute()) 或异步执行 (enqueue())。

Response是服务器返回的HTTP响应,包含状态码、响应头和响应体。我们从中可以获取服务器返回的数据。

同步执行

调用 execute() 方法开始发起同步请求,该方法内部会将当前的 call 加入我们 Dispatcher 分发器内部的 runningSyncCalls 队列中取,等待被执行。接着调用 getResponseWithInterceptorChain() ,使用拦截器获取本次请求响应的内容,这也即我们接下来要关注的步骤。

异步执行

当我们调用 RealCall.enqueue() 执行异步请求时,会先将本次请求加入 Dispather.readyAsyncCalls 队列中等待执行,如果当前请求是 webSocket 请求,则查找与当前请求是同一个 host 的请求,如果存在一致的请求,则复用先前的请求。

接下来调用 promoteAndExecute() 将所有符合条件可以请求的 Call 从等待队列中添加到 可请求队列 中,再遍历该请求队列,将其添加到 线程池 中去执行。当我们将任务添加到线程池后,当任务被执行时,即触发 run() 方法的调用。该方法中会去调用 getResponseWithInterceptorChain() 从而使用拦截器链获取服务器响应,从而完成本次请求。请求成功后则调用我们开始时的 callback对象的 onResponse() 方法,异常(即失败时)则调用 onFailure() 方法。

Interceptor

请求在发送到网络之前,会依次经过一系列的拦截器处理。这些拦截器可以修改请求、处理缓存、记录日志等。响应返回时,也会逆序经过拦截器链。

val client = OkHttpClient.Builder()
  .addInterceptor { chain -> // 添加一个简单的日志拦截器
        val request = chain.request()
        println("Sending request: ${request.url}")
        val response = chain.proceed(request)
        println("Received response for: ${response.request.url} with code: ${response.code}")
        response
    }
 .build()

ConnectionPool

连接池,用于管理 TCP 连接的复用。维护一个连接的缓存池,当请求相同主机的资源时,可以重复使用已建立的连接,从而减少连接建立和销毁的开销。

Dispatcher

Dispatcher 是一个线程安全的类,用于管理异步请求的执行。它维护了一个请求队列和一个线程池,用于执行请求。

Dispatcher 中的主要方法有:

  • enqueue(RealCall call):将一个 RealCall 对象添加到请求队列中。
  • promoteAndExecute():从请求队列中取出符合条件的 Call 对象,并将其添加到线程池中执行。
  • cancelAll():取消所有的请求。
  • finished(RealCall call):当一个请求完成时,调用该方法。
  • runningCallsCount():获取当前正在执行的请求数量。

责任链模式

责任链模式(Chain of Responsibility)是一种处理请求的模式,它让多个处理器都有机会处理该请求,直到其中某个处理成功为止。责任链模式把多个处理器串成链,然后让请求在链上传递。

blogs_RealInterceptorChain.png

上述逻辑如下:

  • 当 getResponseWithInterceptorChain() 方法内部最终调用 RealInterceptorChain.proceed() 时,内部传入了一个默认的index ,这个 index 就代表了当前要调用的 拦截器item ,并在方法内部每次创建一个新的 RealInterceptorChain 链,index+1,再调用当前拦截器 intercept() 方法时,然后将下一个链传入;

  • 最开始调用的是用户自定义的 普通拦截器,如果上述我们添加了一个 CustomLogInterceptor 的拦截器,当获取 response 时,我们需要调用 Interceptor.Chain.proceed() ,而此时的 chain 正是下一个拦截器对应的 RealInterceptorChain;

  • 上述流程里,index从0开始,以此类推,一直到链条末尾,即 拦截器集合长度-1处;

  • 当遇到最后一个拦截器 CallServerInterceptor 时,此时因为已经是最后一个拦截器,链条肯定要结束了,所以其内部肯定也不会调用 proceed() 方法。

相应的,为什么我们在前面说 它 是真正执行与服务器建立实际通讯的拦截器?

因为这个里会获取与服务器通讯的 response ,即最初响应结果,然后将其返回上一个拦截器,即我们的网络拦截器,再接着又向上返回,最终返回到我们的普通拦截器处,从而完成整个链路的路由。

常用拦截器

RetryAndFollowUpInterceptor

这是 OkHttp 中的一个拦截器,用于处理 HTTP 重定向和重试。当服务器返回一个 3xx 状态码时,该拦截器会根据服务器的指示进行重定向。如果服务器返回一个 4xx 或 5xx 状态码,该拦截器会根据配置的重试策略进行重试。即用于 请求失败 的 重试 工作以及 重定向 的后续请求工作,同时还会对 连接 做一些初始化工作。

BridgeInterceptor

这是 客户端和服务器 之间的沟通 桥梁 ,负责将用户构建的请求转换为服务器需要的请求。比如添加 content-type、cookie 等,再将服务器返回的 response 做一些处理,转换为客户端所需要的 response,比如移除 Content-Encoding 等。

CacheInterceptor

管理缓存相关,比如 读取缓存、写入缓存 等。开发者可以通过 OkHttpClient.cache() 方法来配置缓存,在底层的实现处,缓存拦截器通过 CacheStrategy 来判断是使用网络还是缓存来构建 response。具体的 cache 策略采用的是 DiskLruCache 。

具体如下:

blogs_okhttp_cache_interceptor

ConnectInterceptor

负责 建立连接、发送请求、接收响应 等。在底层的实现处,连接拦截器通过 StreamAllocation 来管理连接的复用,通过 RealConnection 来管理连接的生命周期。

CallServerInterceptor

负责 发送请求、接收响应 等。在底层的实现处,调用服务器拦截器通过 HttpCodec 来管理连接的读写流,通过 RealConnection 来管理连接的生命周期。

示例用法(Kotlin)

import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit

fun main() {
    // 1. 创建 OkHttpClient 实例
    val client = OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS) // 连接超时
        .readTimeout(30, TimeUnit.SECONDS)    // 读取超时
        .addInterceptor { chain -> // 添加一个简单的日志拦截器
            val request = chain.request()
            println("Sending request: ${request.url}")
            val response = chain.proceed(request)
            println("Received response for: ${response.request.url} with code: ${response.code}")
            response
        }
        .build()

    // 2. 构建 Request 对象
    val request = Request.Builder()
        .url("https://api.github.com/users/octocat") // 请求的 URL
        .get() // HTTP 方法,这里是 GET
        .header("User-Agent", "OkHttp-Example/1.0") // 添加请求头
        .build()

    // 3. 执行异步请求
    client.newCall(request).enqueue(object : Callback {
        override fun onFailure(call: okhttp3.Call, e: IOException) {
            println("Request failed: ${e.message}")
            e.printStackTrace()
        }

        override fun onResponse(call: okhttp3.Call, response: Response) {
            response.use { // 确保响应体被关闭
                if (!response.isSuccessful) {
                    println("Request failed with code: ${response.code}")
                    return
                }
                val responseBody = response.body?.string()
                println("Response Body: $responseBody")
            }
        }
    })

    // 为了让主线程不立即退出,给异步请求一些时间
    Thread.sleep(5000)
}

在 Android 中使用 OkHttp:

虽然上述示例是通用的 Kotlin 代码,但在 Android 应用中,你需要注意以下几点:

  • 权限:在 AndroidManifest.xml 中添加 INTERNET 权限。
  • 异步处理:务必在后台线程执行网络请求。如果使用同步 execute() 方法,必须在 ThreadAsyncTask (不推荐)、ExecutorService 或 Kotlin 协程 (Dispatchers.IO) 中调用。使用 enqueue() 方法则会将回调放到线程池中执行,你可以利用 Handler 或协程将结果切换回主线程更新 UI。
  • 单例模式:推荐将 OkHttpClient 创建为单例,并在整个应用中复用,以最大化连接池的效益。
  • 与 Retrofit 结合:对于更复杂的 RESTful API 交互,通常会将 OkHttp 与 Retrofit 结合使用。Retrofit 提供了一个声明式的 API 来定义网络请求,并利用 OkHttp 作为底层的 HTTP 引擎。

Okio部分

Okio 是 Square 公司(也是 OkHttp 的开发者)开源的一个 I/O 库,它旨在补充和改进 Java 平台原生的 java.iojava.nio API,使其在处理数据访问、存储和处理时更易用、更高效。Okio 的设计理念可以用以下几个核心点来概括。

1. 统一和简化 I/O API

Java 原生的 java.iojava.nio 在处理流式数据时,API 有些复杂和零散。例如,InputStreamOutputStream 是基于字节的,而 ReaderWriter 是基于字符的,它们的错误处理和缓冲区管理方式各不相同。java.nio 虽然提供了非阻塞 I/O,但使用起来更繁琐,需要手动管理 ByteBuffer

为此,Okio 引入了两个核心接口:

  • Source (源): 用于读取数据,类似于 InputStream
  • Sink (槽): 用于写入数据,类似于 OutputStream

这两个接口提供了一个统一的、直观的 API 来处理字节流,无论数据是来自文件、网络还是内存。

例如,使用 Okio 的 SourceSink 接口,从电脑上读取一个文件的流程如下:

// 1. 创建一个文件输入流
File file = new File("path/to/file");
FileInputStream fis = new FileInputStream(file);

// 2. 将文件输入流包装为 Okio 的 Source
Source source = Okio.source(fis);

// 3. 读取数据
try (BufferedSource bufferedSource = Okio.buffer(source)) {
    String data = bufferedSource.readUtf8(); // 读取 UTF-8 编码的字符串
    System.out.println("File content: " + data); 
} catch (IOException e) {
    e.printStackTrace(); 
} finally {
    // 4. 确保资源被关闭
    source.close(); 
}

// 5. 关闭文件输入流
fis.close();

2. 强大的缓冲区 (Buffer)

在传统的 Java I/O 中,频繁的小读写操作会导致大量的系统调用和内存分配,性能较低。开发者需要手动管理 byte[] 缓冲区,容易出错。

Okio 的解决方案:

Buffer 类: 这是 Okio 的核心,一个可变、动态大小的字节序列,类似 ByteArrayOutputStream 但更高效。

分段缓冲区: Buffer 不是一个连续的 byte[],而是由一个双向链表连接的多个小段 (Segment) 组成。每个 Segment 是一个固定大小的 byte[]。这种设计带来了显著的优势:

避免内存拷贝: 当数据从一个 Source 读入 Buffer,或者从 Buffer 写入 Sink 时,通常只需要在 Segment 之间进行引用传递,而不是复制整个字节数组。这大大减少了内存拷贝,提升了性能。

高效的数据追加和移除: 链表结构使得在 Buffer 的头部或尾部添加/移除数据非常高效,而不需要移动整个数组。

内存池(内部机制): Segment 可以被回收并重复利用,减少了垃圾回收的压力,尤其是在频繁进行 I/O 操作的场景下。

3. 可组合性和可扩展性

传统的 I/O 操作往往是线性的,很难在中间插入额外的处理逻辑(如压缩、加密、哈希)。

Okio 的解决方案:

装饰器模式: Okio 的 SourceSink 设计允许你轻松地将它们包装(decorate)起来,形成一个处理链。例如,你可以将一个 GzipSource 包装在一个文件 Source 上,或者将一个 HashingSink 包装在一个网络 Sink 上。

// 读取一个经过 Gzip 压缩的文件
Source fileSource = Okio.source(new File("compressed.gz"));
Source gzipSource = new GzipSource(fileSource);
BufferedSource bufferedSource = Okio.buffer(gzipSource);
String data = bufferedSource.readUtf8();

// 写入数据并计算 SHA-256 校验和
Sink fileSink = Okio.sink(new File("output.txt"));
HashingSink hashingSink = HashingSink.sha256(fileSink);
BufferedSink bufferedSink = Okio.buffer(hashingSink);
bufferedSink.writeUtf8("Hello, Okio!");
bufferedSink.close();
ByteString hash = hashingSink.hash();

这种设计使得 I/O 转换(如压缩、加密、编码解码、哈希)变得非常模块化和可插拔。

4. 易于测试

由于 BufferSource/Sink 是纯 Java/Kotlin 对象,它们可以在没有任何文件系统或网络依赖的情况下进行单元测试。这使得 I/O 相关的业务逻辑更容易测试。

总结

Okio 的核心设计理念可以归结为:提供一个更强大、更高效、更易用的 I/O 抽象层,以解决 Java 原生 java.iojava.nio 的痛点。 它通过引入 Buffer(分段缓冲区)、SourceSink 的统一接口以及强大的装饰器模式,实现了高性能、可组合、易于测试的 I/O 操作。它的目标是让开发者能够更专注于业务逻辑,而不是底层繁琐的 I/O 细节和性能优化。

【网络】编码与加密

【网络】编码与加密

本文介绍了网络传输过程中的加密和编码的相关内容

加密

对称加密

对称加密是指使用相同的密钥进行加密和解密的加密算法。在对称加密中,发送方和接收方使用相同的密钥进行加密和解密操作,因此加密和解密的过程都是基于同一个密钥。

常见的对称加密算法包括:

  1. DES(Data Encryption Standard):一种对称加密算法,由IBM公司于1975年提出。
  2. AES(Advanced Encryption Standard):一种对称加密算法,由美国国家标准与技术研究所(NIST)于2001年提出。
  3. 3DES(Triple DES):一种对称加密算法,由IBM公司于1978年提出。
  4. RC4(Rivest Cipher 4):一种流加密算法,由Ron Rivest于1987年提出。

对称加密的优点是加密和解密的速度快,适用于对大量数据进行加密的场景。然而,对称加密的缺点是密钥的分发和管理比较复杂,需要确保密钥的安全性。

破解思路: 拿到⼀组或多组原⽂-密⽂对,设法找到⼀个密钥,这个密钥可以将这些原⽂-密⽂对中的原⽂加密为密⽂,以及将密⽂解密为原⽂的组合,即为成功破解。

反破解: ⼀种优秀的对称加密算法的标准是,让破解者找不到⽐穷举法(暴⼒破解法)更有效的破解⼿段,并且穷举法的破解时间⾜够⻓(例如数千年)。

非对称加密

非对称加密是指使用不同的密钥进行加密和解密的加密算法。在非对称加密中,发送方和接收方使用不同的密钥进行加密和解密操作,因此加密和解密的过程都是基于不同的密钥。

原理:使用公钥对数据加密得到密文,使用私钥对数据解密得到原数据。⾮对称加密使⽤的是复杂的数学技巧,在古典密码学中没有对应的原型。

常见的非对称加密算法包括:

  1. RSA(Rivest-Shamir-Adleman):一种非对称加密算法,由Ron Rivest、Adi Shamir和Leonard Adleman于1977年提出。
  2. ECC(Elliptic Curve Cryptography):一种非对称加密算法,由Koblitz在1985年提出。

blogs_asymmetric_encryption.png

用同样的算法,不同密钥再算一遍,就得到原数据。

举例:

规定以下字符:0123456789 需要发110 加密算法:对一个字符都进行加法运算 加密密钥,4 密文:554 解密密钥:6 还原数据:11 11 10,溢出后,只保留后一位,就还原了110

其实由此可以知道,非对称加密最关键的点: 溢出

对称加密的难点:无法用安全高效的方式将加密的密钥给到对方,而非对称加密则无需担心这个问题。

非对称加密的示意图:公钥可以在网络上公开,而私钥一定要掌握在自己手里。A要给B发消息,首先用A的公钥来进行加密,密文发给B后,B使用自己的私钥来解密密文。在这个过程中,就算C能看到密文,却无法拿到私钥B来解密密文。

blogs_asymmetric_encryption_2.png

以上是公钥加密,私钥来解,即私钥解公钥。

问:公钥可以解私钥吗?

可以的。即私钥X, 公钥Y,原数据data, 密文secret,加密算法为algorithm。 非对称加密符合以下过程:

data*algorithm*Y = secret
secret*algorithm*X = data即data*algorithm*Y*algorithm*X = data

"*algorithm*Y*algorithm*X " 这一段其实就相当于还原了数据。

即使用私钥加密公钥解密也是可行的。

但是,公钥和私钥的位置是不可以互换的。因为公钥是可以被计算出来的。比如比特币的公钥就是拿私钥算出来的。还比如RSA加密算法,它的公钥关键部分都是一样的。

非对称加密的优点是密钥的分发和管理相对简单,适用于对少量数据进行加密的场景。然而,非对称加密的缺点是加密和解密的速度相对较慢,不适用于对大量数据进行加密的场景。

破解思路

和对称加密不同之处在于,⾮对称加密的公钥很容易获得,因此制造原⽂-密⽂对是没有困难的事。所以,⾮对称加密的关键只在于,如何找到⼀个正确的私钥,可以解密所有经过公钥加密过的密⽂。找到这样的私钥即为成功破解。由于⾮对称加密的⾃身特性,怎样通过公钥来推断出私钥通常是⼀种思路(例如 RSA),但往往最佳⼿段依然是穷举法,只是和对称加密破解的区别在于,对称加密破解是不断尝试⾃⼰的新密钥是否可以将自己拿到的原⽂-密⽂对进⾏加密和解密,⽽⾮对称加密是不断尝试⾃⼰的新私钥是否和公钥互相可解。

反破解

和对称加密⼀样,⾮对称加密算法优秀的标准同样在于,让破解者找不到⽐穷举法更有效的破解⼿段,并且穷举法的破解时间⾜够⻓。

混合加密

混合加密是指使用对称加密和非对称加密相结合的加密算法。在混合加密中,发送方和接收方使用不同的密钥进行加密和解密操作,因此加密和解密的过程都是基于不同的密钥。 常见的混合加密算法包括:

  1. SSL/TLS:一种基于非对称加密和对称加密相结合的加密算法,由Netscape公司于1995年提出。

混合加密的优点是加密和解密的速度相对较快,适用于对大量数据进行加密的场景。然而,混合加密的缺点是密钥的分发和管理比较复杂,需要确保密钥的安全性。

签名与验证

签名:用于让任何人都知道这个内容确实是来自我的。用自己的私钥来加密,那么任何人去用对应的公钥来解密,都可以得到原数据。加密后的那个密文只有我造得出来,只有我造的加密数据可以用我的公钥解密出原数据,就可以证明这个东西是属于我的。

blogs_sign.png

往往会把原数据与签名数据一起放出,让其他节点可以用公钥去解密对比。

加密+签名

用两方的公钥私钥配合,可以制造签了名的加密数据。

blogs_sign_and_encryption

防伪造

坏人C用B的公钥来制造加密假数据,B拿到后,用自己的私钥一解密也可以得到正常的数据。

blogs_fake_data.png

用上面的签名+加密的方式就可以避免这种伪造的情况。

A使用B的公钥发消息,同时用A的私钥签名,那么B拿到消息后,用A的公钥去验证,用自己的私钥来解密数据。就可以确切的知道这个数据是来自A的。而中间的C既看不懂,也无法伪造与篡改。

编码

Base64

Base64是一种基于64个可打印字符来表示二进制数据的表示方法。它通常用于在URL、Cookie、HTML等文本数据中传输二进制数据。

什么是二进制数据? ⼴义:所有计算机数据都是⼆进制数据 狭义:⾮⽂本数据即⼆进制数据(图片,视频,,,) 文本数据:字符串

码表

image-20230707171520902

例如将字符串”Man”进行Base64编码:

image-20230707171609806

转换后的位数不是6的整数时,会对末尾进行补0:

image-20230707171722022

在Java中,可以使用java.util.Base64类来进行Base64编码和解码。

import java.util.Base64;

public class Base64Example {

    public static void main(String[] args) {
        String originalString = "Hello, World!";
        byte[] originalBytes = originalString.getBytes();  
    }  
}

缺点:

因为⾃身的原理(6 位变 8 位),因此每次 Base64 编码之后,数据都会增⼤约 1/3,所以会影响存储和传输性能。

为了性能,那为什么不用八位对八位进行编码呢? 八位对八位需要256个字符,现在没有那么多字符可以来制作编码集。

用途

  1. 将⼆进制数据扩充了储存和传输途径(例如可以把数据保存到⽂本⽂件、可以通过聊天对话框或 短信形式发送⼆进制数据、可以在 URL 中加⼊简单的⼆进制数据)
  2. 普通的字符串在经过 Base64 编码后的结果会变得⾁眼不可读,因此可以适⽤于⼀定条件下的防 偷窥(较少⽤)

问:Base加密传输图片可以更安全高效吗? Base64无任何加密效果,只是重新编了个码;他把数据变长了1/3,也不算高效。

变种:Base58

把Base64去掉了几个字符(看起来容易混淆的字符): ⽐特币使⽤的编码⽅式,去掉了 Base64 中的数字 “0”,字⺟⼤写 “O”,字⺟⼤写 “I”,和字⺟⼩写 “l”,以及 “+” 和 “/” 符号,⽤于⽐特币地址的表示。因为这些虚拟货币的地址,很大可能会被手抄,去掉容易混淆的几个字符。而➕和/去掉是为了便于双击复制。

变种:URL encoding

在 URL 的字符串中,对⼀些不⽤于特殊⽤途的保留字符,使⽤百分号 “%” 为前缀进⾏单ᇿ编码,以避免出现解析错误。比如中文等字符。

压缩与解压缩

压缩:将数据使⽤更具有存储优势的编码算法进⾏编码。 解压缩:将压缩数据解码还原成原来的形式,以⽅便使⽤。

常见压缩算法:DEFLATE, JPEG, MP3

压缩是编码吗?是的

例如:

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb

使用某种压缩算法处理之后:

compress:a:1032;b:105

图片与音视频的编解码

含义:将图像、⾳频、视频数据通过编码来转换成存档形式(编码),以及从存档形式转换回来(解码)。 目的:方便存储和压缩媒体数据

一张图片的压缩举例:

blogs_compress_picture.png

目前的图片压缩算法,google的Webp比JPEG和PNG更优。

Hash

含义:把任意数据转换成指定⼤⼩范围(通常很⼩,例如 256 字节以内)的数据。

就是从元数据里抽离出特征值,作为Hash值。

例如:把每个学院都编号,001, 002, 003,,,这就是一个Hash的过程。

作用:相当于从数据中提出摘要信息,因此最主要⽤途是数字指纹

经典算法:MD5(容易破解,已经遗弃)、SHA1、SHA256

好的算法需要碰撞率低,即重复率。

实际用途:

  • 数据完整性验证,原数据与现有数据hash值对比。
  • 快速查找:hashCode()和HashMap()。 重写了equals,同时也要重写HashCode。
class HashDemo {
    int age;
    String name;

    public boolean equals(Object obj) {
        age == obj.age &&
        name.equals(obj.name);
    }

    public long hashCode() {
        return age * 100 + name.length();
    }

}

为什么要重写HashCode()?

使用HashMap时,对于HashMap里的元素,有元素要进来时,比较这个元素key的HashCode和现存地址里的key的HashCode,如果不同就添加,如果相同就替换原元素的value。 HashCode没有写好时,发生碰撞时,会把原来那个元素的value给替换掉。 即重写HashCode是为了使用HashMap时不会出错,在equals不相等时,需要HashCode也不相等。

可以用于隐私保护,在储存用户密码时,不可以保存明文,而是将用户密码Hash一次,再存储。数据泄露时因为Hash不可逆,所以仍然不能获取关键数据。

  • 破解:彩虹表,将常用密码与Hash值一一对应,反推出
  • 加盐:做数据Hash时,提前在数据里加入其他的salt,这个盐是不可以泄露的。例如,在密码末尾加333,变成zhukai333,再进行Hash,那彩虹表也会失效。

注意:Hash不属于编码,也不属于加密,它是单向的不可逆转的。

用法举例:联系非对称加密,对数据签名时,会先进行Hash,大幅缩小数据体量,然后再对这个Hash值进行签名。

blogs_hash.webp

字符集

含义:⼀个由整数向现实世界中的⽂字符号的 Map 分支: • ASCII:128 个字符,1 字节 • ISO-8859-1:对 ASCII 进⾏扩充,1 字节 • Unicode:13 万个字符,多字节 • UTF-8:Unicode 的编码分⽀ • UTF-16 :Unicode 的编码分⽀ • GBK / GB2312 / GB18030:中国⾃研标准,多字节,字符集 + 编码

【网络】WebSocket通信基础

【网络】WebSocket通信基础

本文介绍了WebSocket协议相关内容,背景知识和协议规范

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它在 Web 应用中提供了一种持久连接,允许客户端和服务器之间进行实时、双向的数据传输,而无需像传统的 HTTP 请求那样每次通信都建立新的连接。这使得 WebSocket 非常适合需要低延迟和高吞吐量的应用,如在线游戏、实时聊天、协作工具、股票行情更新和物联网数据传输等。

核心特性

  1. 全双工通信
    客户端和服务器可以同时主动发送数据,无需等待请求-响应模式,适合实时交互场景(如聊天、游戏、股票行情推送)。

  2. 低延迟与高效性
    • 建立连接后,数据帧直接传输,省去 HTTP 请求的头部开销(如 Cookie、User-Agent 等重复字段)。
    • 相比 HTTP 轮询(频繁建立/断开连接),WebSocket 维持长连接,减少延迟和资源消耗。
  3. 基于 TCP 的可靠传输
    继承 TCP 的可靠性(数据有序、不丢失、自动重传),同时通过应用层协议实现消息边界管理。

为什么需要 WebSocket?

在 WebSocket 出现之前,Web 浏览器和服务器之间的实时通信主要依赖于以下几种技术:

  • 轮询 (Polling): 客户端定时向服务器发送 HTTP 请求,询问是否有新的数据。这种方式效率低下,会产生大量不必要的请求,并增加服务器负载。
  • 长轮询 (Long Polling): 客户端发送一个 HTTP 请求,服务器保持连接打开,直到有新数据可用或超时。数据发送后,连接关闭,客户端立即发起新的请求。这种方式比轮询有所改进,但仍然是单向的,并且每次数据传输后都需要重新建立连接,依然存在延迟和开销。
  • Comet (Streaming): 服务器长时间保持 HTTP 连接打开,并不断向客户端发送数据。客户端收到数据后,连接保持打开。虽然实现了单向实时,但仍然是基于 HTTP 请求-响应模式的变种,而且客户端难以向服务器发送实时数据。
  • Flash Sockets/Java Applets: 这些技术需要浏览器插件,兼容性差,且逐渐被淘汰。

WebSocket 的出现正是为了解决这些问题,提供一种原生、高效的双向通信机制。

二、协议分层与握手过程

1. 握手阶段(HTTP 升级)

WebSocket 连接的建立始于一个特殊的 HTTP 握手。客户端向服务器发送一个特殊的 HTTP 请求,请求升级到 WebSocket 协议

客户端请求示例:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub24gY2U=
Origin: http://example.com
Sec-WebSocket-Version: 13

服务器响应示例:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9GUfnjUMzdzbCYg=

握手过程的关键点:

  • Upgrade: websocketConnection: Upgrade: 这两个头部字段明确表示客户端请求将协议从 HTTP 升级到 WebSocket。
  • Sec-WebSocket-Key: 这是一个随机生成的 Base64 编码的字符串。客户端发送此键,服务器会将其与一个固定的 GUID (“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”) 拼接,然后计算 SHA-1 散列并进行 Base64 编码,生成 Sec-WebSocket-Accept
  • Sec-WebSocket-Version: 指定客户端使用的 WebSocket 协议版本。目前最新的标准版本是 13。
  • Sec-WebSocket-Accept: 服务器的响应,证明服务器理解并同意升级请求。它的值是根据 Sec-WebSocket-Key 计算得出的,客户端会验证这个值,确保握手成功。

一旦握手成功,HTTP 连接就会“升级”为 WebSocket 连接,此后所有通信都将使用 WebSocket 协议定义的帧格式,不再是 HTTP 请求/响应模式。

2. 数据传输阶段 (Data Transfer)

握手成功后,客户端和服务器之间就可以通过这个持久的 TCP 连接进行双向、全双工的数据传输。数据以 帧 (Frames) 的形式发送,而不是像 HTTP 那样以完整的消息发送。

数据帧格式

WebSocket 数据帧每个帧包含以下字段(简化版):

字段作用
FIN标识是否为消息的最后一帧(1 bit)
Opcode帧类型(4 bits):如 0x1 文本、0x2 二进制、0x8 关闭连接等
Mask是否对负载数据掩码(1 bit,客户端必须设为 1)
Payload Length负载长度(7/16/64 bits,动态扩展)
Masking Key掩码密钥(4 bytes,客户端生成)
Payload Data实际数据(可能被掩码处理)

掩码机制:客户端发送的数据必须掩码,服务器发送的数据不能掩码,防止缓存代理干扰。

WebSocket 帧的特点:

  • 轻量级: WebSocket 帧头非常小,通常只有几字节,这减少了协议开销。
  • 支持多种数据类型: 可以传输文本数据 (UTF-8 编码) 和二进制数据。
  • 分片 (Fragmentation): 大的数据可以被分成多个帧进行传输,接收方再将其重组。这对于处理大型文件或需要分批发送的数据非常有用。
  • 掩码 (Masking): 从客户端发送到服务器的数据帧必须进行掩码处理,以防止中间代理服务器缓存响应(因为 WebSocket 帧可能看起来像有效的 HTTP 请求)。服务器发送给客户端的数据不需要掩码。
  • 心跳机制 (Ping/Pong): WebSocket 内置了 PINGPONG 帧,用于客户端和服务器之间发送心跳包,检测连接是否仍然活跃,类似于 MQTT 的 Keep Alive 机制。如果一端发送 PING 帧,另一端必须回复 PONG 帧。

3. 连接生命周期管理

  1. 关闭连接
    任一方发送 Opcode=0x8 的帧,携带 2 字节的状态码(如 1000 正常关闭)。对方需回复相同帧确认关闭。

  2. 心跳机制(Ping/Pong)

    • 客户端或服务器可发送 Opcode=0x9 的 Ping 帧探测连接活性。
    • 对方需回复 Opcode=0xA 的 Pong 帧,超时未响应则视为连接断开。

安全性

WebSocket 协议支持加密通信,即 WebSocket Secure (WSS)。WSS 连接通过 TLS/SSL 进行加密,类似于 HTTPS。这意味着数据在传输过程中是加密的,可以防止窃听和篡改。

  • ws://: 非加密的 WebSocket 连接。
  • wss://: 加密的 WebSocket 连接,基于 TLS/SSL。

WebSocket 的应用场景

基于WebSocket上述工作特点,常见的应用领域有下面这些:

  • 实时聊天应用: 即时消息的发送和接收。
  • 在线游戏: 多人游戏的实时玩家状态同步和交互。
  • 股票行情、金融数据推送: 实时股价、市场数据更新。
  • 协作工具: 多个用户同时编辑文档、共享屏幕等。
  • 物联网 (IoT): 设备与服务器之间的小型、频繁数据交换。
  • 实时通知和报警: 服务器向客户端推送即时通知。
  • 在线白板/绘图应用: 实时共享用户绘图操作。

Android平台进行WebSocket连接开发

在 Android 端进行 WebSocket 开发非常常见,特别是在需要实时数据传输的应用中,比如聊天、实时通知或物联网仪表盘。我们可以基于okhttp库的功能来实现。

OkHttp 是 Square 公司开发的一个流行的 HTTP 和 WebSocket 客户端。它功能强大、易于使用,并且是 Android 开发中最推荐的网络库之一。

dependencies {
    implementation 'com.squareup.okhttp3:okhttp:4.12.0' 
}

基本开发步骤

  1. 创建 OkHttpClient 实例: 通常,你会在应用中创建一个单例 OkHttpClient 实例。

    OkHttpClient client = new OkHttpClient();
    
  2. 创建 WebSocketRequest: 构建一个 Request 对象,指定 WebSocket 服务器的 URL。

    String webSocketUrl = "ws://echo.websocket.org"; // 替换为你的 WebSocket 服务器地址
    Request request = new Request.Builder().url(webSocketUrl).build();
    
  3. 实现 WebSocketListener: 创建一个实现 WebSocketListener 接口的类,用于处理 WebSocket 事件(打开、关闭、接收消息、失败等)。

    public class MyWebSocketListener extends WebSocketListener {
    
        private static final int NORMAL_CLOSURE_STATUS = 1000;
    
        @Override
        public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) {
            super.onOpen(webSocket, response);
            // 连接成功建立时调用
            System.out.println("WebSocket Connected!");
            // 连接成功后可以发送消息
            webSocket.send("Hello from Android!");
            // 也可以发送二进制数据
            // webSocket.send(ByteString.decodeHex("deadbeef"));
        }
    
        @Override
        public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
            super.onMessage(webSocket, text);
            // 接收到文本消息时调用
            System.out.println("Receiving Text: " + text);
        }
    
        @Override
        public void onMessage(@NonNull WebSocket webSocket, @NonNull ByteString bytes) {
            super.onMessage(webSocket, bytes);
            // 接收到二进制消息时调用
            System.out.println("Receiving Bytes: " + bytes.hex());
        }
    
        @Override
        public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
            super.onClosing(webSocket, code, reason);
            // WebSocket 即将关闭时调用
            System.out.println("Closing: " + code + " / " + reason);
            webSocket.close(NORMAL_CLOSURE_STATUS, null); // 告知服务器正常关闭
        }
    
        @Override
        public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable t, @Nullable Response response) {
            super.onFailure(webSocket, t, response);
            // 连接失败时调用,例如网络问题、握手失败等
            System.err.println("Error: " + t.getMessage());
            if (response != null) {
                System.err.println("Error Response: " + response.code() + " " + response.message());
            }
        }
    
        @Override
        public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
            super.onClosed(webSocket, code, reason);
            // WebSocket 完全关闭时调用
            System.out.println("WebSocket Closed: " + code + " / " + reason);
        }
    }
    
  4. 建立 WebSocket 连接: 在你的 Activity 或 Service 中,创建 MyWebSocketListener 实例,并通过 OkHttpClient 发起连接。

    public class MyActivity extends AppCompatActivity {
    
        private WebSocket webSocket;
        private OkHttpClient client;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
    
            client = new OkHttpClient(); // 初始化一次,最好作为单例
    
            String webSocketUrl = "ws://echo.websocket.org"; // 公共测试 WebSocket 服务器
            Request request = new Request.Builder().url(webSocketUrl).build();
            MyWebSocketListener listener = new MyWebSocketListener();
    
            webSocket = client.newWebSocket(request, listener); // 发起连接
    
            // 示例:可以在连接建立后发送消息,但在实际应用中通常在 onOpen 回调中发送
            // webSocket.send("Hello World!");
        }
    
        @Override
        protected void onDestroy() {
            super.onDestroy();
            if (webSocket != null) {
                webSocket.close(MyWebSocketListener.NORMAL_CLOSURE_STATUS, "Goodbye!"); // 在 Activity 销毁时关闭连接
            }
            if (client != null) {
                client.dispatcher().executorService().shutdown(); // 关闭 OkHttpClient 的线程池
            }
        }
    
        // 可以添加一个方法来发送消息
        public void sendMessage(String message) {
            if (webSocket != null) {
                webSocket.send(message);
            }
        }
    }
    

注意事项

  • 权限: 确保在 AndroidManifest.xml 中添加网络权限:
    <uses-permission android:name="android.permission.INTERNET" />
    
  • 线程: OkHttp 的 WebSocket 回调是在其内部的线程池中执行的。如果你需要在这些回调中更新 UI,请确保切换到主线程(例如使用 runOnUiThread() 或 Handler)。
  • 生命周期管理: 妥善管理 WebSocket 连接的生命周期。在 Activity/Fragment 销毁时关闭连接,避免内存泄漏或不必要的网络活动。对于需要后台持久连接的应用,应该考虑使用 Android Service 来管理 WebSocket 连接。
  • 重连机制: 生产环境中,你需要实现一套健壮的重连机制。当 onFailureonClosed 被调用时,根据错误类型和网络状态进行指数退避或其他策略的重连尝试。
  • 心跳/Keep Alive: WebSocket 协议自带 PING/PONG 帧,OkHttp 会自动处理这部分。但如果你服务器有特殊的超时设置,可能需要调整 OkHttp 的 readTimeout 或自己实现更精细的心跳逻辑(通常不需要)。
  • 安全性 (WSS): 如果你的服务器使用 wss:// (WebSocket Secure),OkHttp 会自动处理 SSL/TLS 加密。确保你的服务器证书是可信的,或者配置 OkHttpClient 来信任自签名证书(生产环境不推荐)。

Pagination