Kotlin 协程复习笔记

本文整理自一轮关于 Kotlin 协程的问答,覆盖:组合挂起函数、调度器(Dispatchers)、启动模式(CoroutineStart)、以及一段 launch 代码实际运行现象的分析。 适合面试复习 / 概念回顾。每个知识点都配了通俗类比和可运行示例。


目录

  1. [组合挂起函数(顺序 / 并发 / 结构化并发)](#组合挂起函数(顺序 / 并发 / 结构化并发) "#1-%E7%BB%84%E5%90%88%E6%8C%82%E8%B5%B7%E5%87%BD%E6%95%B0")
  2. [launch() 与 launch(Dispatchers.Default) 的区别](#launch() 与 launch(Dispatchers.Default) 的区别 "#2-launch-%E4%B8%8E-launchdispatchersdefault")
  3. [Dispatchers 源码深度解读](#Dispatchers 源码深度解读 "#3-dispatchers-%E6%BA%90%E7%A0%81%E6%B7%B1%E5%BA%A6%E8%A7%A3%E8%AF%BB")
  4. [CoroutineStart 四种启动模式](#CoroutineStart 四种启动模式 "#4-coroutinestart-%E5%9B%9B%E7%A7%8D%E5%90%AF%E5%8A%A8%E6%A8%A1%E5%BC%8F")
  5. [实战:一段 launch 代码的运行现象分析(竞态)](#实战:一段 launch 代码的运行现象分析(竞态) "#5-%E5%AE%9E%E6%88%98%E4%B8%80%E6%AE%B5-launch-%E4%BB%A3%E7%A0%81%E7%9A%84%E8%BF%90%E8%A1%8C%E7%8E%B0%E8%B1%A1%E5%88%86%E6%9E%90")
  6. [LAZY 模式下 cancel 的用法](#LAZY 模式下 cancel 的用法 "#6-lazy-%E6%A8%A1%E5%BC%8F%E4%B8%8B-cancel-%E7%9A%84%E7%94%A8%E6%B3%95")
  7. [start() 是"入队"还是"执行"?DEFAULT 下是否多余](#start() 是"入队"还是"执行"?DEFAULT 下是否多余 "#7-start-%E6%98%AF%E5%85%A5%E9%98%9F%E8%BF%98%E6%98%AF%E6%89%A7%E8%A1%8C")

1. 组合挂起函数

来源文档:《组合挂起函数》。核心:多个挂起函数怎么跑------顺序跑、并发跑、注意坑、遵守结构化并发。

示例中的两个函数 doSomethingUsefulOne() / doSomethingUsefulTwo() 各自 delay(1000),约耗时 1 秒。

1.1 顺序调用(串行)

kotlin 复制代码
val one = doSomethingUsefulOne() // 等1秒
val two = doSomethingUsefulTwo() // 再等1秒
println(one + two)
  • 先执行完第一个,完全结束后才跑第二个。
  • 总耗时:约 2000ms。
  • 适用:第二个任务依赖第一个的结果。
  • 要点:协程不会自动并发 。直接写两行挂起函数默认就是顺序执行,并发必须显式写 async。

1.2 async 实现并发

kotlin 复制代码
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
println(one.await() + two.await())
  • async:开启子协程,立刻开始干活,返回 Deferred(未来结果的占位符)。
  • .await():挂起,等待协程完成并取结果。
  • 两个 async 写完,两个任务同时开始跑 ,总耗时约 1000ms。
  • ⚠️ 注意:不是写了 await 才开始任务,async 那行已经启动任务了;await 只是等结果。

launch vs async:

  • launch:启动协程,返回 Job,无返回值。
  • async:启动协程,返回 Deferred,可 await() 取结果。

1.3 惰性启动 async(start = CoroutineStart.LAZY)

kotlin 复制代码
val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
one.start()
two.start()
println(one.await() + two.await())
  • LAZY:声明 async 时不会自动启动任务,只是保存。
  • 两种触发方式:
    1. 调用 .start() 手动启动;
    2. 直接调用 .await() 会顺带启动。
  • 坑:如果不写 start() 直接 one.await() + two.await() → 逐个启动,变回串行,耗时 2 秒。

1.4 不推荐:GlobalScope.async(反面教材)

kotlin 复制代码
@OptIn(DelicateCoroutinesApi::class)
fun somethingUsefulOneAsync() = GlobalScope.async { doSomethingUsefulOne() }

问题:

  1. GlobalScope 启动的协程是"孤儿协程",脱离父协程管控。
  2. 调用方异常退出,后台协程还在继续跑,不会被取消。
  3. 容易内存泄漏;破坏结构化并发。

业务代码尽量别碰 GlobalScope。Android 用 viewModelScope / lifecycleScope。

1.5 正确做法:coroutineScope 结构化并发(重点)

kotlin 复制代码
suspend fun concurrentSum(): Int = coroutineScope {
    val one = async { doSomethingUsefulOne() }
    val two = async { doSomethingUsefulTwo() }
    one.await() + two.await()
}

结构化并发保障:

  1. coroutineScope 继承外部协程上下文。
  2. 域内任何一个子协程抛异常,整个 scope 内所有子协程全部自动取消。
  3. 父协程被取消,所有 async 子协程跟着取消。
  4. coroutineScope 要等所有子协程完成才返回。

文档报错示例:two 抛异常 → 还在跑的 one 被自动 cancel,打印 First child was cancelled。

1.6 快速总结表

方式 总耗时 特点 适用场景
直接顺序调用 ~2000ms 串行 任务有依赖
async 默认模式 ~1000ms 声明即启动,await 拿结果 无依赖想并发提速
async(LAZY)+start() ~1000ms 手动 start 触发 需延迟决定何时开启
GlobalScope.async ~1000ms 孤儿协程 禁止业务使用
coroutineScope{async{}} ~1000ms 结构化并发,异常自动取消 ✅ 生产推荐

1.7 面试关键点

  1. 协程不会自动并发 ,必须显式 async。
  2. async{} 那行就启动协程,await() 只是等待拿结果。
  3. LAZY 模式只 await 不 start 会变串行。
  4. 不要用 GlobalScope.async;并发写在 coroutineScope 里获得保护。
  5. 一个子协程异常,coroutineScope 内其他子协程全被取消。

1.8 生活类比

两个任务:烧水、洗菜,各 1 分钟。

  • 顺序执行:先烧水 1 分钟,再洗菜 1 分钟,共 2 分钟。
  • async 并发:同时烧水洗菜,1 分钟搞定。
  • LAZY:计划写好但不干活,start 才开始同时干;不 start 直接等,变回 2 分钟。
  • GlobalScope:你出门了(函数退出),烧水还在烧,没人管,危险。
  • coroutineScope:在厨房干活,厨房出事(某任务报错),烧水洗菜全部停止。

2. launch() 与 launch(Dispatchers.Default)

一句话结论:launch() 不传调度器 = 继承父协程上下文(包括 Dispatcher) ;launch(Dispatchers.Default) = 强制指定用 Default,覆盖父协程的 Dispatcher 。 本质:launch 第一个参数是 context: CoroutineContext = EmptyCoroutineContext,默认是空上下文,不是默认调度器。

2.1 launch() // 不传参

  • 传入 EmptyCoroutineContext,不新增任何东西。
  • 新协程上下文 = 父协程上下文(Job、Dispatcher、异常处理器全部继承)。
    • 父是 Main → 子跑主线程;父是 IO → 子跑 IO;父是 Default → 子跑 Default。
  • ✅ 结构化并发推荐:尽量不手动写 Dispatcher,让上下文自动传递。

2.2 launch(Dispatchers.Default)

  • 显式把 Dispatchers.Default 塞进子协程上下文,Dispatcher 强制替换成 Default。
  • Job 仍继承父 Job(父取消,子跟着取消)。

Dispatchers.Default:CPU 密集任务线程池,默认线程数 = CPU 核心数,适合计算、解析大 JSON、排序。

2.3 重点坑

❌ 误区:launch() 默认就是 Dispatchers.Default。 ✅ 错!launch 没有默认 Dispatcher,它继承父上下文。

kotlin 复制代码
viewModelScope.launch {
    // 这里直接跑主线程!不是 Default
}
viewModelScope.launch(Dispatchers.Default) {
    // 切到 Default 后台线程
}

2.4 三个调度器对比

  • Dispatchers.IO:IO 密集(网络、数据库、文件读写),线程池可扩容,适合阻塞 IO。
  • Dispatchers.Main:主线程,Android 更新 UI。
  • Dispatchers.Default:CPU 密集,线程数 = 核心数。

2.5 什么时候选哪个

  1. launch{}:业务逻辑不需要手动切线程,沿用外层上下文。最佳实践:suspend 函数内部不要指定 Dispatcher,由调用方决定。
  2. launch(Dispatchers.Default):里面做大量 CPU 计算(循环、解析、压缩),需切到 CPU 线程池,避免卡主线程。

注意:改 Dispatcher 不会破坏结构化并发,Dispatcher 只是"在哪条线程执行",Job 树还是同一个。

2.6 极简可运行 Demo

kotlin 复制代码
import kotlinx.coroutines.*

fun main() = runBlocking {
    println("外层线程:${Thread.currentThread().name}")
    launch {
        println("launch() 无参: ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Default) {
        println("launch(Default): ${Thread.currentThread().name}")
    }
}

3. Dispatchers 源码深度解读

来源:kotlinx.coroutines 包下 Dispatchers 的 JVM 源码。核心常量、四个调度器、shutdown、IO/Default 共用线程池。

3.1 IO 并发上限常量

kotlin 复制代码
public const val IO_PARALLELISM_PROPERTY_NAME: String = "kotlinx.coroutines.io.parallelism"
  • 可通过 JVM 系统属性 kotlinx.coroutines.io.parallelism 自定义 Dispatchers.IO 的最大并发线程数。
  • 默认上限:64 和 CPU 核心数两者取较大的值。

3.2 四个内置调度器

调度器 底层 说明
Default DefaultScheduler CPU 密集,线程池大小默认=CPU核心数
Main MainDispatcherLoader.dispatcher 主线程调度器,懒加载
Unconfined kotlinx.coroutines.Unconfined 不切线程,当前线程执行
IO DefaultIoScheduler 阻塞 IO,默认并发上限 64

3.3 核心知识点:IO 与 Default 共用同一套线程池(高频坑)

  • Dispatchers.IO 和 Dispatchers.Default 底层共用同一个调度器(DefaultScheduler),不是两套独立线程池。
  • 在 Default 线程里写 withContext(Dispatchers.IO) 经常不切换线程,只是修改并发配额限制。
  • 由于线程共享,IO 相关操作过程中可能创建超过 64 个线程(只是创建,不一定同时使用)。

老误区:IO 线程池无限大 → 新版默认上限 64。

3.4 IO 的弹性(limitedParallelism)

kotlin 复制代码
val myMysqlDbDispatcher = Dispatchers.IO.limitedParallelism(100) // MySQL 专用
val myMongoDbDispatcher = Dispatchers.IO.limitedParallelism(60) // MongoDB 专用
  • 由 limitedParallelism(N) 生成的调度器视图,不受 IO 的 64 上限限制,有独立配额。
  • 峰值时可达到 64 + 100 + 60 个线程;平稳时共享少量线程。
  • 误区纠正:limitedParallelism 不是从 IO 的 64 个里分一部分,而是独立配额,底层线程复用。

3.5 shutdown()(高危 API)

kotlin 复制代码
@DelicateCoroutinesApi
public fun shutdown() {
    DefaultExecutor.shutdown()
    DefaultScheduler.shutdown()
}
  • 关闭内置调度器(Default、IO),停止所有关联线程,拒绝新任务。
  • 普通 App 绝对不要调用,调用后不可逆,协程框架变成不可用状态。
  • 必须在无待执行任务、无活跃协程时调用;否则行为未定义(可能抛异常或任务永久休眠)。
  • 仅推荐在容器环境(OSGi、Gradle 插件、IDEA 插件)生命周期末尾调用。

3.6 actual / expect(KMP)

  • actual 是多平台关键字:common 模块用 expect 声明,JVM 平台用 actual 提供真实实现。
  • 文件里那个 actual val Dispatchers.IO 是兼容桩代码,业务不用管。

4. CoroutineStart 四种启动模式

来源:CoroutineStart 枚举源码。用于 launch / async 的 start 参数。

四种模式一句话总结:

  1. DEFAULT:入队排队,调用线程继续跑,未执行前可取消。【日常默认】
  2. LAZY:只保存任务不入队,需 start/await 触发。【手动控制何时开始】
  3. UNDISPATCHED:当前线程直接执行到第一个挂起点,恢复后走 Dispatcher。【第一段原地执行】
  4. ATOMIC:入队排队,入队成功之后排队阶段不能取消。【实验 API】

4.1 DEFAULT

  • 立刻把任务提交到调度器队列,launch 返回,调用线程继续往下跑。
  • isDispatchNeeded 返回 true(Default/IO/Main 都是)→ 任务排队后执行。
  • ⚠️ 若 Job 在协程开始执行之前被取消 → 协程不执行,直接异常结束。
  • 注:Dispatchers.Unconfined 的 isDispatchNeeded 恒为 false,所以 Unconfined+DEFAULT 等价于 UNDISPATCHED。

4.2 LAZY

  • 只保存 suspend 代码块,不入队,不调用 start/await/join 永远不跑。
  • 触发方式:.start() 或 .await() / .join()。
  • 坑:多个 LAZY async 逐个 await → 串行执行。
  • 取消规则:启动前 cancel,协程不执行。

4.3 ATOMIC(@ExperimentalCoroutinesApi)

  • 和 DEFAULT 类似,但排队阶段不能被取消:任务一旦成功入队,就算立刻 cancel 也会执行到第一个挂起点。
  • 实验 API,业务几乎不用。

4.4 UNDISPATCHED

  • 在当前调用线程立即执行协程,直到第一个挂起点;挂起恢复后再按协程上下文的 Dispatcher 调度(这点和 Unconfined 不同)。
  • 类似 ATOMIC:就算 job 已 cancel 也会先执行首段代码,区别是 UNDISPATCHED 在当前线程直接跑。
  • 嵌套 UNDISPATCHED 协程有栈溢出风险,不要大量嵌套。

UNDISPATCHED vs Unconfined:

  • UNDISPATCHED:只保证第一段同步代码原地执行,挂起恢复后遵守你指定的 Dispatcher。
  • Unconfined:恢复时沿用挂起恢复时所在的线程,不遵守协程 Dispatcher。

4.5 四个枚举横向对比(面试高频)

模式 排队阶段能否取消 首段代码执行位置 挂起恢复后调度 业务常用?
DEFAULT(默认) ✅ 可取消 提交调度队列,不原地执行 遵循 Dispatcher ✅ 最常用
LAZY ✅ 启动前可取消 触发后按 Dispatcher 调度 遵循 Dispatcher ✅ 偶尔用
ATOMIC ❌ 排队阶段无法取消 提交队列,不原地执行 遵循 Dispatcher ❌ 实验 API
UNDISPATCHED ❌ 就算 cancel 也先跑首段 当前调用线程直接执行 遵循 Dispatcher ❌ 特殊场景慎用

4.6 高频坑点

  1. DEFAULT 不是"立刻在当前线程执行",只是把任务丢进调度队列。
  2. UNDISPATCHED 只对第一个挂起点前的代码生效;delay 恢复后切到协程指定 Dispatcher。
  3. LAZY 协程不调用 start/await 永不执行。
  4. ATOMIC 是实验 API。
  5. UNDISPATCHED 嵌套协程有栈溢出风险。

5. 实战:一段 launch 代码的运行现象分析

这是最容易困惑的部分------CoroutineStart.DEFAULT + 马上 cancel() 的竞态问题。

5.1 原始代码

kotlin 复制代码
fun main()  = runBlocking {
    println("runBlocking: start thread ${Thread.currentThread().name}")
    val job = launch(Dispatchers.IO,start = CoroutineStart.DEFAULT) {
        println("launch: begin thread ${Thread.currentThread().name}")
    }
    job.cancel()
    println("runBlocking: end thread ${Thread.currentThread().name}")
}

5.2 关键规则

DEFAULT 的取消规则(源码原文翻译):如果协程 Job 在【获得调度、开始执行之前】被取消 → 任务不跑;如果调度器已经取出任务开始执行 → 代码正常跑,cancel 只能影响后续挂起点,不能抹掉正在跑的同步代码。

5.3 竞态的本质

launch 之后立刻 cancel,存在两个并行操作赛跑:

  1. main 线程 :提交任务 → 立刻执行 job.cancel()。
  2. IO 线程池 worker:从队列取任务 → 执行协程体。

两种可能结果(随机,看 OS 线程调度):

  • 场景 A(常复现) :worker 抢先取任务开始执行,打印 launch: begin...;随后 cancel 无法取消已执行的同步代码(协程块只有一行 println,无挂起点,直接跑完)。
  • 场景 B:main 抢先 cancel,任务被丢弃,协程体不执行。

我一开始直接说"不会打印"是过度简化,忽略了线程竞争,特此修正。

5.4 观察到的实际输出(多次运行)

不带 cancel:

arduino 复制代码
runBlocking: start thread main
runBlocking: end thread main
launch: begin thread DefaultDispatcher-worker-1

这个顺序也非严格固定!只是你的机器上 worker 调度较快。理论可能出现 end 在 launch 之前。打印顺序由 OS 调度决定。

带 cancel:

arduino 复制代码
runBlocking: start thread main
launch: begin thread DefaultDispatcher-worker-1
runBlocking: end thread main

反直觉:加了 cancel,launch 日志反而更早打印。 原因:不是 cancel 加速协程,而是 cancel 内部有同步操作(CAS 标记),让主线程短暂失去 CPU 时间片,触发线程切换,给了 worker 线程执行窗口。

5.5 验证方法

稳定复现【不打印】:launch 和 cancel 之间加无阻塞自旋,让主线程不交出 CPU:

kotlin 复制代码
val job = launch(Dispatchers.IO,start = DEFAULT) { println("...") }
repeat(100000) {} // 占 CPU,不给 worker 机会
job.cancel()

稳定复现【一定打印】:launch 和 cancel 之间加 delay,让 worker 有机会执行:

kotlin 复制代码
val job = launch(Dispatchers.IO,start = DEFAULT) { println("...") }
delay(1) // 主线程挂起,worker 有机会执行
job.cancel()

5.6 各启动模式在这个竞态场景的行为

  1. DEFAULT:赛跑,结果随机。
  2. LAZY:无竞态!不自动入队,没调 start/await 时 cancel 后永不执行。
  3. UNDISPATCHED :无竞态!launch 调用时就在当前线程执行完协程块,后面 cancel 无作用,一定打印。
  4. ATOMIC:任务一旦入队,立刻 cancel 也会执行到第一个挂起点。

5.7 补充:runBlocking 的特性

  • runBlocking 会阻塞主线程,等待所有子协程全部完成才退出 main。
  • 即使 main 提前打印了 end,main 函数不会立刻结束,会等 launch 子协程完成。

6. LAZY 模式下 cancel 的用法

6.1 结论

LAZY 协程完全可以调用 cancel,但要区分两种状态:【还没启动】和【已经启动】。

6.2 场景 1:启动前(未 start/await/join)就 cancel

kotlin 复制代码
val job = launch(start = CoroutineStart.LAZY) { println("LAZY 协程执行") }
job.cancel() // 在 start 之前
// 不调用 start()
  • Job 直接标记取消;从未启动,协程体永不执行。
  • ✅ 这是 LAZY 非常常用的用法:预定义任务,随时可放弃,完全没有竞态(这是 LAZY 相对 DEFAULT 的最大优势)。

6.3 场景 2:已经 start() 启动后再 cancel

  • 一旦调用 .start(),LAZY 协程行为就和普通 DEFAULT 一样(入队、调度)。
  • 之后 cancel 遵循 DEFAULT 规则:未执行则丢弃;已执行则在后续挂起点取消。

6.4 经典使用场景

预先创建多个 LAZY async,中途决定不执行:

kotlin 复制代码
val task1 = async(start=LAZY) { ... }
val task2 = async(start=LAZY) { ... }

if (someCondition) {
    task1.cancel(); task2.cancel() // 作废,防止后续误 start/await 意外启动
} else {
    task1.start(); task2.start()
    val res = awaitAll(task1, task2)
}

提前 cancel 相当于给任务打"作废标记":就算后续误调用 start/await,启动后直接结束,不执行业务代码。

6.5 坑:LAZY + await() 特殊行为

kotlin 复制代码
val a = async(start=LAZY){ 100 }
a.cancel()
val x = a.await() // Job 已取消,await 不启动协程,直接抛 CancellationException

6.6 LAZY cancel vs DEFAULT cancel 核心区别

  • LAZY:启动前 cancel → 无竞态,100% 不会执行协程体。
  • DEFAULT:启动前 cancel → 存在竞态,协程可能已经跑起来。

7. start() 是"入队"还是"执行"?

7.1 一句话答案

  1. LAZY 的 .start() = 把任务提交(入调度队列),不是直接执行。入队后行为完全等同 DEFAULT。
  2. DEFAULT 本身含义就是构造器调用时自动入队 ,所以 DEFAULT 下不需要手动 .start(),写了也无害但多余。

7.2 区分两个概念

  • 入队列:任务交给调度器排队,等待被调度执行。
  • 执行:worker 线程取出任务,运行大括号内代码。
  • .start() 只负责入队;真正执行代码是调度器后续的事。

7.3 LAZY 下 await()/join() 内部逻辑

  1. 判断若还没启动 → 先入队(等价内部调用 start)。
  2. 然后等待协程完成。

这就是 LAZY async 逐个 await 会串行的原因:await 一边入队一边等它跑完,再处理下一个。

7.4 横向对比

start 模式 launch 创建协程时 job.start() 作用
DEFAULT 自动入调度队列 无效,多余
LAZY 不入队列,仅保存任务 触发任务入调度队列

补充:UNDISPATCHED / ATOMIC 也一样,构建器创建时就启动了,手动 .start() 多余。.start() 设计初衷就是专门给 LAZY 用。

7.5 误区纠正

❌ job.start() = "直接执行协程代码" ✅ start() = 入队列,交给调度器排队,不是同步立刻执行。只有 UNDISPATCHED 这种模式才会在 launch 调用期间原地直接执行到第一个挂起点,和 start() 无关。

7.6 经典例子回顾

kotlin 复制代码
val one = async(start = CoroutineStart.LAZY) { task1() }
val two = async(start = CoroutineStart.LAZY) { task2() }
one.start()
two.start() // 先后入队,并发执行
awaitAll(one, two)
  • 若不 start 直接 one.await(); two.await() → one 跑完才启动 two → 串行。

附:全文串联图(概念关系)

yaml 复制代码
组合挂起函数
  ├─ 顺序调用(依赖关系)→ 耗时累加
  ├─ async 并发 → 需无依赖
  ├─ LAZY 惰性 → 需 start/await 触发
  └─ 结构化并发 coroutineScope → 子协程异常/取消自动级联

launch 参数
  ├─ context: 决定在哪条线程跑(不传=继承父上下文;Dispatchers.Default 覆盖)
  └─ start: CoroutineStart 决定何时开始执行
        ├─ DEFAULT: 入队排队,可提前取消(有竞态)
        ├─ LAZY: 仅保存,需 start/await(无竞态)
        ├─ ATOMIC: 入队后不可取消
        └─ UNDISPATCHED: 当前线程执行到第一个挂起点

Dispatchers
  ├─ Default: CPU密集,线程=核心数
  ├─ IO: 阻塞IO,默认上限64(与 Default 共用线程池!)
  ├─ Main: 主线程(懒加载)
  └─ Unconfined: 不切线程

Job 生命周期
  ├─ 创建(LAZY 不启动 / 其它启动)
  ├─ start() → 入调度队列
  ├─ 调度执行
  ├─ cancel() → 未开始则丢弃 / 已开始则仅影响挂起点
  └─ 完成
相关推荐
不可能片场2 小时前
工程实践 原子替换改了 ctime 怎么修
前端·electron
暧暧内含光2 小时前
HTTP 文件传输体系全解:从协议标头、OSS 架构到流媒体分片
前端
静默回滚2 小时前
浏览器能显示AVIF,却导不出一张AVIF
前端
小皮虾2 小时前
小程序全局状态还能这么简单?只需初始化一次,页面自动同步
前端·javascript·微信小程序
用户818618028002 小时前
RESTful API 设计规范与版本演进
前端
补码缩写2 小时前
前端AVIF编码开多线程快两三倍,speed调到8就不快了
前端
cidy_982 小时前
企业级 AI Agent 工作台前端实战教程
前端
deli0072 小时前
换个 DNS 网速真的变快吗?解析 6 步逐跳实测,快慢差 45 倍
前端
葡萄城技术团队2 小时前
接一个前端库:以前要啃几天文档,现在三步——活字格 V12.1 AI辅助开发上手
前端