【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

深入浅出 Kotlin 协程

  • 2020-02-03
  • 本文字数:9848 字

    阅读完需:约 32 分钟

深入浅出Kotlin协程

本文要点


  • JVM 并没有提供对协程的原生支持

  • Kotlin 在编译器中实现协程是通过将其转换为一个状态机实现的

  • Kotlin 为实现使用了一个关键字,其余都是通过库来完成的

  • Kotlin 使用连续传递风格(Continuation Passing Style,CPS)来实现协程

  • 协程会使用到 Dispatcher,所以在 JavaFX、Android、Swing 等场景下,用法略有差异。


尽管协程并不是一个新的话题,但它是一个很吸引人的话题。正如其他地方的文档所述,协程在这些年里被重新发现了很多次,特别是当需要某种形式的轻量级线程和/或寻找“回调地狱”的解决方案时。


最近,协程已经成为 JVM 上反应式编程的一个流行的替代方案。像RxJavaReactor项目这样的框架为客户端提供了一种渐进式处理传入信息的方式,并对节流和并行提供了广泛的支持。但是,我们必须围绕反应流的函数式操作来重构代码,在许多情况下,这么做的成本大于收益


举例来讲,这是 Android 社区需要更简单替代方案的原因。Kotlin 语言引入协程作为实验性的特性以满足该需求,在一些改进之后,它们成为该语言 1.3 版本的官方特性。Kotlin 协程的应用已经从 UI 开发扩展到服务器端框架(如Spring 5中添加的支持),甚至包括像 Arrow(通过Arrow Fx)这样的函数式框架。

理解协程的挑战

令人遗憾的是,理解协程并不是一件容易的任务。尽管有许多 Kotlin 专家所做的关于协程的演讲,其中很多都是启发性的并且包含非常多的信息,但是想要简单地知道协程是什么(或者它该怎样使用)并不容易。你可能会说协程就是并行编程的等价品。


导致该问题的部分原因在于底层实现。在 Kotlin 协程中,编译器只实现了一个 suspend 关键字,其他的事情都是由协程库处理的。因此,Kotlin 协程非常强大和灵活,但在结构上却不那么固定。对于初学者来说,这是一个学习的障碍,因为他们学习的最好方法是遵循坚实的指导方针和严格的原则。本文会从底层开始介绍协程,希望能够为读者提供这样的基础知识。

我们的示例应用(服务器端)

我们的应用程序将建立在安全有效地对 RESTful 服务进行多次调用的规范性(canonical)问题之上。我们将播放Where’s Waldo的一个文本版本——在这个版本中,用户遵循一系列的名称,直到他们找到“Waldo”。


下面是完整的 RESTful 服务,它是使用Http4k编写的。Http4k 是由 Marius Eriksen 撰写的著名论文中描述的函数式服务器架构的 Kotlin 版本。该实现存在许多其他语言的版本,包括 Scala(Http4s)和 Java 8 或更高版本(Http4j)。


这里只有一个端点,它通过 Map 实现了一个名字的链。给定一个名字,我们要么以 200 状态码返回匹配的值,要么以 404 状态码返回一条错误信息。


fun main() {   val names = mapOf(       "Jane" to "Dave",       "Dave" to "Mary",       "Mary" to "Pete",       "Pete" to "Lucy",       "Lucy" to "Waldo"   )   val lookupName = { request: Request ->       val name = request.path("name")       val headers = listOf("Content-Type" to "text/plain")       val result = names[name]       if (result != null) {           Response(OK)               .headers(headers)               .body(result)       } else {           Response(NOT_FOUND)               .headers(headers)               .body("No match for $name")       }   }   routes(       "/wheresWaldo" bind routes(           "/{name:.*}" bind Method.GET to lookupName       )   ).asServer(Netty(8080))       .start()}
复制代码


实际上,我们希望客户端发送如下的请求链:


我们的示例应用(客户端)

我们的客户端应用将会基于 JavaFX 库来创建用户界面。但是,为了简化我们的任务并避免不必要的细节,我们将会使用TornadoFX,它在 JavaFX 之上提供了一个 Kotlin DSL。


如下是客户端视角的完整定义:


class HelloWorldView: View("Coroutines Client UI") {   private val finder: HttpWaldoFinder by inject()   private val inputText = SimpleStringProperty("Jane")   private val resultText = SimpleStringProperty("")   override val root = form {       fieldset("Lets Find Waldo") {           field("First Name:") {               textfield().bind(inputText)               button("Search") {                   action {                       println("Running event handler".addThreadId())                       searchForWaldo()                   }               }           }           field("Result:") {               label(resultText)           }       }   }   private fun searchForWaldo() {       GlobalScope.launch(Dispatchers.Main) {           println("Doing Coroutines".addThreadId())           val input = inputText.value           val output = finder.wheresWaldo(input)           resultText.value = output       }   }}class HelloWorldView: View("Coroutines Client UI") {   private val finder: HttpWaldoFinder by inject()   private val inputText = SimpleStringProperty("Jane")   private val resultText = SimpleStringProperty("")   override val root = form {       fieldset("Lets Find Waldo") {           field("First Name:") {               textfield().bind(inputText)               button("Search") {                   action {                       println("Running event handler".addThreadId())                       searchForWaldo()                   }               }           }           field("Result:") {               label(resultText)           }       }   }   private fun searchForWaldo() {       GlobalScope.launch(Dispatchers.Main) {           println("Doing Coroutines".addThreadId())           val input = inputText.value           val output = finder.wheresWaldo(input)           resultText.value = output       }   }}
复制代码


我们还会使用如下的辅助函数作为 String 类型的扩展:


fun String.addThreadId() = "$this on thread ${Thread.currentThread().id}"
复制代码


在运行的时候,UI 如下所示:



当用户点击按钮的时候,我们会创建一个新的协程,并通过“HttpWaldoFinder”类型的服务对象访问 RESTful 端点。


Kotlin 协程存在于一个“CoroutineScope”中,而后者又会反过来和某种 Dispatcher 关联,Dispatcher 代表了底层的并发模型。并发模型通常是一个线程池,但是会有所差异。


具体哪些 Dispatcher 可用取决于 Kotlin 代码运行的环境。Main Dispatcher 表示 UI 库的事件处理线程,因此(在 JVM 上)只能在 Android、JavaFX 和 Swing 中使用。最初,Kotlin Native 根本不支持协程的多线程处理,但这种情况正在改变。在服务器端,我们可以自己引入协程,但是在越来越多的情况中,协程默认就是可用的,比如在Spring 5中。


在调用挂起(suspending)方法之前,我们必须将协程、“CoroutineScope”和“Dispatche”准备就绪。如果这是初始调用的话(如上面的代码所示),我们可以通过“协程构造者”函数,如“launch”和“async”,启动该过程。


不管是调用协程构建者函数,还是像“withContext”这样的作用域函数,都会创建一个新的“CoroutineScope”。在该作用域中,任务体现为“Job”实例的层级结构。


它们有一些很有意思的属性,即:


  • Job 会等待其块内所有协程完成后才能完成自己。

  • 取消一个 Job 会导致其所有的子 Job 都被取消。

  • 子 Job 的失败或取消会传播至父 Job。


这样设计的目的是避免并发编程中的常见问题,比如杀死父 Job 的时候没有终结其子 Job。

访问 REST 端点的服务

如下是 HttpWaldoFinder 服务的完整代码:


class HttpWaldoFinder : Controller(), WaldoFinder {   override suspend fun wheresWaldo(starterName: String): String {       val firstName = fetchNewName(starterName)       println("Found $firstName name".addThreadId())       val secondName = fetchNewName(firstName)       println("Found $secondName name".addThreadId())       val thirdName = fetchNewName(secondName)       println("Found $thirdName name".addThreadId())       val fourthName = fetchNewName(thirdName)       println("Found $fourthName name".addThreadId())       return fetchNewName(fourthName)   }   private suspend fun fetchNewName(inputName: String): String {       val url = URI("http://localhost:8080/wheresWaldo/$inputName")       val client = HttpClient.newBuilder().build()       val handler = HttpResponse.BodyHandlers.ofString()       val request = HttpRequest.newBuilder().uri(url).build()       return withContext<String>(Dispatchers.IO) {           println("Sending HTTP Request for $inputName".addThreadId())           client               .send(request, handler)               .body()       }   }}
复制代码


“fetchNewName”函数会接收一个已知的名字,并查询端点获取关联的名字。这是通过使用“HttpClient”类型来实现的,该类型是从 Java 11 开始作为标准的。实际的 HTTP GET 会在一个新的子协程中运行,该子协程会使用 IO Dispatcher。它的表现形式是一个为长时间运行的活动(如网络调用)优化的线程池。


“wheresWaldo”函数会根据名字链执行五次,以便于(尽力)找到 Waldo。我们随后将会分解生成的字节码,所以我们让实现尽可能地简单。我们感兴趣的是,每次调用“fetchNewName”都会导致当前协程在子协程运行时被挂起。在这个特殊场景下,父 Job 运行在 Main Dispatcher 上,而子 Job 运行在 IO Dispatcher 上。因此,当子 Job 执行 HTTP 请求时,UI 事件处理线程会被释放出来处理与视图的用户交互。如下图所示。



IntelliJ 会为我们展示何时发起挂起调用,因此会在协程间转换控制。需要注意,如果我们没有切换 Dispatcher 的话,那发起调用不一定会创建新的协程。当一个挂起函数调用另一个挂起函数时,可能会在相同的协程中继续,如果我们真的希望保持在同一个线程上,那么这的确就是我们想要的行为。



当我们执行客户端的时候,如下就是控制台的输出:



我们可以看到,在这个特殊的场景下,Main Dispatcher/UI 事件处理器运行在 17 号线程上,而 IO Dispatcher 运行在线程池上,包括 24 号和 26 号线程。

开始我们的调查

借助 IntelliJ 自带的字节码分解工具,我们可以看出到底发生了什么。另外,我们也可以使用 JDK 提供的标准“javap”工具。



我们可以看到“HttpWaldoFinder”的方法改变了其签名,所以它们可以接受一个 Continuation 对象作为其额外的参数,并且返回某种通用的对象。


public final class HttpWaldoFinder extends Controller implements WaldoFinder {  public Object wheresWaldo(String a, Continuation b)  final synthetic Object fetchNewName(String a, Continuation b)}
复制代码


现在,我们深入代码看一下这些方法都添加了些什么,并阐述“Continuation”是什么以及现在返回的是什么。

连续传递风格(Continuation Passing Style,CPS)

按照 Kotlin 标准化过程对协程提议的文档描述,协程的实现是基于连续传递风格(Continuation Passing Style,CPS)的。会有一个 continuation 对象来存储函数在挂起阶段所需的状态。


在本质上,挂起函数的每个局部变量都会成为 continuation 的一个字段。另外,还需要创建字段来存储所有的参数和当前对象(如果函数是方法的话)。所以,一个挂起方法如果有四个参数和五个局部变量的话,continuation 至少要有 10 个字段。


在“HttpWaldoFinder”的“wheresWaldo”方法中,有一个参数和四个本地变量,所以我们预期 continuation 实现类型会有六个字段。如果我们将 Kotlin 编译器生成的字节码分解成 Java 源码的话,就会发现事实确实如此:


$continuation = new ContinuationImpl($completion) {  Object result;  int label;  Object L$0;  Object L$1;  Object L$2;  Object L$3;  Object L$4;  Object L$5;  @Nullable  public final Object invokeSuspend(@NotNull Object $result) {     this.result = $result;     this.label |= Integer.MIN_VALUE;     return HttpWaldoFinder.this.wheresWaldo((String)null, this);  }};
复制代码


鉴于所有的字段都是 Object 类型的,所以它们该如何使用并不明显。随着进一步探索,我们将会看到:


  • “L$0”持有对“HttpWaldoFinder”实例的引用。它始终都会存在。

  • “L$1”持有“starterName”参数的值。它始终都会存在。

  • “L5”持有本地变量的值。随着代码的执行,它们将会渐进式地填充进来。“L$2”会持有“firstName”的值,以此类推。


我们还有额外的字段存储最终结果,有一个名为“label”的有趣的整型字段。

挂起还是不挂起——这是一个问题

当我们检查生成的代码时,需要记住它要处理两个场景。每当一个挂起的函数调用另一个函数时,它可能会挂起当前的协程(这样另外一个函数可以在相同的线程上运行),也可能在当前协程会继续执行。


我们考虑一个从数据存储中读取值的挂起函数。当 I/O 发生的时候,它很可能会被挂起,但是它也可能会缓存结果。后续调用可以同步返回缓存的值,不需要任何的挂起。Kotlin 编译器所生成的代码必须要同时支持这两种路径。


Kotlin 会调整每个挂起函数的返回类型,这样的话,它要么返回真正的结果,要么返回特殊的值 COROUTINE_SUSPENDED。如果是后者的话,当前的协程会被挂起。这也是为什么挂起函数的返回类型从结果类型变成了“Object”。


在我们的样例应用中,“wheresWaldo”会重复调用“fetchNewName”。在理论上,每次这样的调用都可能挂起或不挂起当前的协程。按照我们编写“fetchNewName”的方式,可以知道,挂起始终都会发生。但是,为了理解生成的代码,我们必须记住它需要处理所有的可能性。

大的 Switch 语句和 Label

如果我们进一步查看分解后的代码,会发现一个隐藏在多个嵌套 label 中的 switch 语句。这是一个状态机的实现,用来在 wheresWaldo()方法中控制不同的挂起点。下面是整体的结构:


// 程序清单1:生成的switch语句和labelString firstName;String secondName;String thirdName;String fourthName;Object var11;Object var10000;label48: {  label47: {     label46: {        Object $result = $continuation.result;        var11 = IntrinsicsKt.getCOROUTINE_SUSPENDED();        switch($continuation.label) {        case 0:            // 省略代码        case 1:            // 省略代码        case 2:            // 省略代码        case 3:            // 省略代码        case 4:            // 省略代码        case 5:            // 省略代码        default:           throw new IllegalStateException(                "call to 'resume' before 'invoke' with coroutine");        } // 结束 switch        // 省略代码    } // 结束 label 46    // 省略代码  } // 结束 label 47  // 省略代码} // 结束 label 48// 省略代码
复制代码


我们现在可以看到 continuation 中“label”字段的目的了。当完成“wheresWaldo”的不同阶段时,我们将会变更“label”中的值。嵌套的 label 代码块中包含了原始 Kotlin 代码里面挂起点之间的代码块。这个“label”值允许重新进入该代码,跳到最后挂起的地方(适当的 case 语句),以便于从 continuation 中抽取数据,然后跳转到正确的 label 代码块。


但是,如果所有的挂起点都没有真正挂起的话,整个代码块可以同步执行。在生成的代码中,我们经常会看到这样的片段:


// 程序清单2:确定当前的协程是否应该挂起if (var10000 == var11) {  return var11;}
复制代码


从上面我们可以看到,“var11”被设置成了 CONTINUATION_SUSPENDED 的值,而“var10000”保存了对另一个挂起函数的调用的返回值。因此,当挂起发生时,代码将返回(稍后会重新进入),如果没有发生挂起,则代码将通过切换到适当的 label 块继续执行函数的下一部分。


再次强调,请记住,生成的代码不能假设所有调用都将挂起,或者所有调用都将继续使用当前的协程。它必须能够处理任何可能的组合。

跟踪执行

当我们开始执行时,continuation 中“label”的值将会被置为零。如下是对应的 switch 语句分支:


// 程序清单3:switch的第一个分支case 0:  ResultKt.throwOnFailure($result);  $continuation.L$0 = this;  $continuation.L$1 = starterName;  $continuation.label = 1;  var10000 = this.fetchNewName(starterName, $continuation);  if (var10000 == var11) {     return var11;  }  break;
复制代码


我们将实例和参数存储到了 continuation 对象中,然后将 continuation 对象传递给“fetchNewName”。如前文所述,编译器所生成的“fetchNewName”版本要么返回实际的结果,要么返回 COROUTINE_SUSPENDED 值。


如果协程挂起的话,那么我们会从函数中返回,并且当我们恢复的时候,会跳入“case 1”分支。如果我们继续使用当前的协程,那么会跳出 switch 到某一个 label 代码块,进入如下的代码:


// 程序清单4:第二次调用“fetchNewName”firstName = (String)var10000;secondName = UtilsKt.addThreadId("Found " + firstName + " name");boolean var13 = false;System.out.println(secondName);$continuation.L$0 = this;$continuation.L$1 = starterName;$continuation.L$2 = firstName;$continuation.label = 2;var10000 = this.fetchNewName(firstName, $continuation);if (var10000 == var11) {  return var11;}
复制代码


因为我们知道“var10000”包含了我们期望的返回值,所以我们可以将其转换成正确的类型并存储到局部变量“firstName”中。随后,生成的代码会使用“secondName”来存储线程 id 连接的结果,并且将其打印了出来。


我们更新了 continuation 中的字段,添加了服务器检索到的值。注意,“label”的值现在已经是 2 了。随后,我们第三次调用“fetchNewName”。

第三次调用“fetchNewName”——无挂起

我们需要再次基于“fetchNewName”返回的值做出选择,如果返回的值是 COROUTINE_SUSPENDED 的话,我们会从当前函数返回。当下一次调用时,我们要遵循 switch 的“case 2”分支。


如果我们继续使用当前协程的话,那么将会执行如下的代码块。我们可以看到它与上面的代码是相同的,只不过我们现在要有更多的数据存储到 continuation 中。


// 程序清单4:第三次调用“fetchNewName”secondName = (String)var10000;thirdName = UtilsKt.addThreadId("Found " + secondName + " name");boolean var14 = false;System.out.println(thirdName);$continuation.L$0 = this;$continuation.L$1 = starterName;$continuation.L$2 = firstName;$continuation.L$3 = secondName;$continuation.label = 3;var10000 = this.fetchNewName(secondName, (Continuation)$continuation);if (var10000 == var11) {  return var11;}
复制代码


这种模式在后续的调用中会重复(假定始终没有返回 COROUTINE_SUSPENDED),直到到达终点为止。

第三次调用“fetchNewName”——带有挂起

或者,如果协程已经被挂起的话,那么将会运行如下的代码块:


// 程序清单5:switch的第三个分支case 2:  firstName = (String)$continuation.L$2;  starterName = (String)$continuation.L$1;  this = (HttpWaldoFinder)$continuation.L$0;  ResultKt.throwOnFailure($result);  var10000 = $result;  break label46;
复制代码


我们将 continuation 中的值抽取到函数的局部变量中。随后使用一个 label 形式的 break 使执行跳转至前述的程序清单 4 中。所以最终,我们会在相同的地方结束。

总结执行过程

现在,我们可以重新看一下程序清单的代码结构,并在整体上描述一下每个区域中都发生了什么:


// 程序清单6:深度解析生成的switch语句和labelString firstName;String secondName;String thirdName;String fourthName;Object var11;Object var10000;label48: {  label47: {     label46: {        Object $result = $continuation.result;        var11 = IntrinsicsKt.getCOROUTINE_SUSPENDED();        switch($continuation.label) {       case 0:            // 将label设置为1,如果返回挂起的话,第一次调用“fetchNewName”            // 否则,从switch中break退出        case 1:            // 从continuation中抽取参数            // 从switch中break退出        case 2:            // 从continuation中抽取参数和第一个结果            // 跳转到外边的“label46”        case 3:            // 从continuation中抽取参数,第一个和第二个结果            // 跳转到外边的“label47”        case 4:            // 从continuation中抽取参数,第一个、第二个和第三个结果            // 跳转到外边的“label48”        case 5:            // 从continuation中抽取参数,第一个、第二个、第三个和第四个结果            // 返回最后的结果        default:           throw new IllegalStateException(                "call to 'resume' before 'invoke' with coroutine");        } // 结束 switch        // 存储参数和第一个结果到continuation中        // 如果返回挂起的话,将label设置为2并对“fetchNewName”进行第二次调用        // 否则的话继续进行    } // 结束label 46        // 存储参数、第一个结果和第二个结果到continuation中        // 如果返回挂起的话,将label设置为3并对“fetchNewName”进行第三次调用        // 否则的话继续进行  } // 结束label 47        //  存储参数、第一个结果、第二个结果和第三个结果到continuation中        //  如果返回挂起的话,将label设置为4并对“fetchNewName”进行第四次调用        // 否则的话继续进行} // 结束label 48// 存储参数、第一个结果、第二个结果、第三个结果和第四个结果到continuation中// 将label设置为5并对“fetchNewName”进行第五次调用// 返回最终结果或COROUTINE_SUSPENDED
复制代码

结果

这个代码库理解起来并不简单。我们分析了字节码分解得到的 Java 代码,这些字节码是由 Kotlin 编译器中的代码生成器所产生的。这个代码生成器的输出在设计时考虑的是效率和最小化,而不是可理解性。


但是,我们可以得出一些有用的结论:


  1. 并没有什么魔法。当开发人员第一次开始学习协程时,很容易会认为有些特殊的“魔法”将所有的这些事情连接在了一起。我们可以看到,生成的代码只使用了一些过程式编程的基本构建块,比如条件语句和带有 label 的 break。

  2. 实现是基于 continuation 的。在最初的 KEEP 提议中,函数挂起和恢复的方法是将函数的状态缓存在一个对象中。因此,对于每个挂起函数,编译器将创建一个包含 N 个字段的 continuation 类型,其中 N 是参数的数量加上字段数再加上 3。最后的三个字段保存当前对象、最终结果和索引。

  3. 执行始终遵循一个标准的模式。如果从挂起中恢复,那么我们要使用 continuation 的“label”字段跳转到 switch 语句的适当分支。在这个分支中,我们从 continuation 对象检索到目前为止已经找到的数据,然后使用一个带有 label 的 break 跳转到没有发生挂起的代码,这些代码本来是要直接执行的。


作者简介


Garth Gilmour是 Instil 的学习主管(Head of Learning)。早在 1999 年,他就放弃了全职的开发工作,开始讲授 C++给 C 程序员,然后讲授 Java 给 C++程序员,然后是讲授 C#给 Java 程序员,现在他什么都教,但更喜欢 Kotlin 相关的工作。如果计算学员数量的话,很久之前就超过 1000 人了。他是 20 多门课程的作者,经常在技术会议上发言,在国内和国际会议上演讲,共同组织了贝尔法斯特 BASH 的开发人员系列活动,最近成立了贝尔法斯特 Kotlin 用户组。不在白板之前的时候,他还担任近身格斗和举重的教练。


Eamonn Boyle有超过 15 年的开发、架构和团队领导经验。在过去的 4 年里,他一直担任全职培训师和教练,为各种各样的客户撰写和讲授各种主题的课程。其中包括范式和技术,从核心语言技能、框架到工具和过程。他还在一系列会议、活动和技术聚会上发表演讲并举办 workshop,其中包括 Goto 和 KotlinConf。


原文链接:


A Bottom-Up View of Kotlin Coroutines


2020-02-03 12:535615

评论

发布
暂无评论
发现更多内容

活动预约报名系统怎么做?快来借鉴「苍山徒步之家」的经验做法

天天预约

微信小程序 SaaS 数据统计 预约工具 活动报名

上云节省 35%计算资源,420 个运维人天:运满满实时计算实践和思考

Apache Flink

大数据 flink 实时计算

MVP、原型、概念验证,傻傻分不清楚?

敏捷开发

开发 MVP 原型

容器安全的三大挑战

SEAL安全

容器安全 12 月 PK 榜

龙蜥开发者说:众人拾柴火焰高!聊一聊我在社区的所思所想 | 第 14期

OpenAnolis小助手

开发者 开源社区 龙蜥开发者说 LoongArch 架构 红旗软件

数字化时代,校园生活还可以这样过

华为云开发者联盟

数据库 后端 华为云 12 月 PK 榜

【Java技术专题】「原理专题」深入分析Java中finalize方法的作用和底层原理

洛神灬殇

Java JVM Finalizer 12月月更

Triple 协议支持 Java 异常回传的设计与实现

Apache Dubbo

Java 开源 微服务 gRPC dubbo

浅谈权限系统在多利熊业务应用

百度Geek说

百度 权限系统 12 月 PK 榜 多利熊

参与Getaverse早期建设,成为节点验证者!!

Geek_Web3

#区块链# did #Web3

信息安全等级测评师证书有效期是多久?谁能告诉一下?

行云管家

等保

非正式全面解析 NebulaGraph 中 Session 管理

NebulaGraph

图数据库 会话管理 session管理

使用 ClusterResourceSet 为 Cluster API 集群自动安装 CNI 插件

Se7en

掌握这5大功能,解锁鲲鹏开发新发现

华为云开发者联盟

开发 华为云 12 月 PK 榜

软件测试|HTTPS 的通信

霍格沃兹测试开发学社

时序引擎架构和实例演练

KaiwuDB

MASA MAUI Plugin (七)应用通知角标(小红点)Android+iOS

MASA技术团队

blazor MASA MAUI MASA Blazor

百家号奇妙未来季创作大赛落幕!AIGC开启内容创作新征程

科技热闻

uniapp 在微信小程序中图片宽度显示问题

ModStart

如何通过Java应用程序将Word转为Excel

在下毛毛雨

Java Excel word 文档 转换

Python图像处理丨详解图像去雾处理方法

华为云开发者联盟

Python 人工智能 华为云 12 月 PK 榜

华为云助推武水集团项目成功入选住建部“智慧水务”典型案例!

华为云开发者联盟

云计算 后端 华为云 12 月 PK 榜

软件测试简历书写 | 简历中应该如何描述才能体现出软技能的实力 ?

测试人

软件测试 自动化测试 简历 软技能 测试开发

2023年内蒙古等保测评公司有哪些?五家还是6家?

行云管家

等保 等保测评 内蒙古

诚迈科技董事长王继平赴国创中心交流车用操作系统合作事宜

科技热闻

说透IO多路复用模型

京东科技开发者

开发 系统 epoll poll Lunix

软件测试面试 | 一面试就紧张,怎么办?

霍格沃兹测试开发学社

PHP 中类自动加载相关文件分析

ModStart

火山引擎DataTester:如何做A/B实验的假设检验

字节跳动数据平台

大数据 AB testing实战 12 月 PK 榜

多模态交互在数智化营销服中的技术实践

中关村科金

人工智能 算法 全栈 数字化转型 多模态

智能低代码平台,驱动企业数字化转型

元年技术洞察

低代码 数字化转型

深入浅出Kotlin协程_移动_Eamonn Boyle_InfoQ精选文章