弄马哥 nomag

There is no magic in code.

0%

Kotlin 协程与挂起方法

协程可以做啥?

  1. 处理耗时任务。耗时任务放在主线程中会导致 UI 卡顿或者 ANR。作用等同于启动一个线程池执行耗时任务
  2. 把回调式 API 变成顺序式,增加可读性,降低理解成本

因为处理耗时任务的用法,跟 java 的线程池比较类似,也比较简单,本文不做过多概述。后面着重聊聊利用协程以及挂起方法,把回调式 API 封装成顺序式 API。

协程和挂起方法的关系

说到协程,不得不提挂起方法。

挂起方法,是用suspend关键字修饰的方法。他俩的关系是,挂起方法必须在协程中执行,但是协程中执行的不要求必须是挂起方法。挂起方法执行的时候,看起来像是代码「阻塞」在了挂起方法上,这就是「挂起」。后面的代码必须等挂起方法执行完成后,才能执行。

上面说,代码像是「阻塞」,更多是表示「代码停在当前这条语句上」。实际上,他跟线程的「阻塞」不一样。线程阻塞,对应的底层 CPU 也在等待。但是协程挂起的时候,对应的线程依然在工作,底层的 CPU 也依然在运转。这也是为什么我们说,基于协程的调度任务,会比基于线程的调度任务,效率高。

挂起方法的原理

原理上,编译器在编译阶段,会为挂起方法,生成有限状态机,来处理协程。

###接口声明

与协程交互,需要通过interface Continuation 对象。Continuation接口,从功能上,更像是有了更多信息、更多上下文的 callback。

查看文档Continuation相关定义如下 :

属性:

abstract val context: CoroutineContext

包含了协程上下文信息。

方法:

abstract fun resumeWith(result: Result)

表示协程结束。result 代表返回的结果 ,可以是成功的,也可以是失败的。

拓展方法:

  1. fun Continuation.resume(value: T)
  2. fun Continuation.resumeWithException(
    exception: Throwable)

两个拓展方法其实是增加了语法糖,让代码编写更加灵活高效。

###Kotlin Compiler 对挂起函数的处理

编译期间,编译器会改变挂起方法的签名,为他后面添加一个参数:completion: Continuation<Any?>,并修改返回类型为 void。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 我们写的 Kotlin 代码
suspend fun loginUser (userId: String, password: String): User (
val user = userRemoteDataSource.loginUser (userId, password)
val userDb = userLocalDataSource.logUserIn(user)
return userDo
}

// Kotlin Compiler 生成的代码的等效 Kotlin 代码
fun loginUser (userId: String, password: String, completion: Continuation<Any?>) {
val user = userRemoteDataSource. loginUser (userId, password)
val userDb = userLocalDataSource.logUserIn(user)
completion.resume(userDb)
}

这时我们可能有个疑问:函数签名中返回值类型变成了 void,那结果怎么返回呢?注意这句话:

1
completion.resume(userDb)

返回值还在,只不过是通过 Continuation 对象包装了一下。

有限状态机

回到我们刚才说的,Kotlin 编译器会生成一个有限状态机,在哪呢?

如下,编译器会识别里面的可以挂起的方法,然后生成状态。状态的数量 = 挂起方法个数 + 1。后面有个加一,类似于小学时候学的植树问题。

1
2
3
4
5
6
7
8
9
10
11
12
fun loginUser (userId: String, password: String, completion: Continuation<Any?>) {
// Label 0
-> first execution
val user
= userRemoteDataSource. loginUser (userId, password)
// Label 1
-> resumes from userRemoteDataSource
val userDb = userLocalDataSource.logUserIn(user)
// Label 2
-> resumes from userLocalDataSource
completion.resume (userDb)
}

生成的状态机,核心是一个 label + 一个 when。根据执行进度,label 值会被更改,然后根据 label 值,选择不同的分支,执行对应的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun loginUser (userId: String, password: String, completion: Continuation<Any?>) {
when (label) {
0 -> { // Label 0 -> first execution
userRemoteDataSource.loginUser(userId,password)
}
1 -> { // Label 1 -> resumes from userRemoteDataSource
userLocalDataSource.logUserIn(user)
}
2 -> { // Label 2 -> resumes from userLocalDataSource
completion. resume (userDb)
}
else -> throw IllegalStateException(...)
}
}

不过执行的过程中,数据是如何传输的?

数据有两个来源

  1. 函数的参数
  2. 执行过程中产生的中间变量
1
2
3
4
5
6
7
8
9
10
11
12
fun loginUser (userId: String?, password: String?, completion: Continuation<Any?>) {
class LoginUserStateMachine(completion: Continuation<Anv?>) : CoroutineImpl (completion) {
var user: User? = null
var userDb: UserDb? = null
var result: Anv? = null
var label: Int = 0
override fun invokeSuspend(result: Any?) {
this.result = result
loginUser(null, null, this)
}
}
}

编译器会帮我们生成一个内部类,名字叫 XxxStateMachine,也就是状态机的真容。所有参数,都以「内部类的 成员变量的形式」来表示。随着挂起方法的运行,不断的对各个成员变量进行赋值。同时,内部类中还有一个invokeSuspend方法,用于执行状态机。其中,前面所有参数都是 null,最后是 this,用于协程退出。

这里有两个关键点:

  1. 参数用内部类的成员变量来表示
  2. invokeSuspend 方法作为入口,loginUser 前面的参数都是 null

这里我们可能有疑问,如果参数都是 null,那参数不是相当于都没传吗?参数都没有,得到的运算结果能正确吗?
且看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
val continuation = completion as? LoginUserStateMachine?: LoginUserStateMachine(completion)
when (continuation.label) {
0 -> {
throwOnFailure(continuation.result)
continuation.label = 1
userRemoteDataSource.loginUser(userId, password, continuation)
}
1 -> {
throwOnFailure(continuation.result)
continuation.user = continuation.result as User
continuation. label = 2
userLocalDataSource.logUserIn(continuation.user, continuation)
}
2 -> {
throwOnFailure(continuation.result)
continuation.userDb = continuation.result as UserDb
continuation.cont.resume(continuation.userDb)
}
else -> throw IllegalStateException(...)
}

挂起函数在挂起和恢复的时候,数据都保存在了 continuation 中了(如果没执行过,continuation 为 null,则 new 一个新的)。所以,表面上看,所有的参数都是 null,好像没传参。其实是换了一种形式,以成员变量的方式,通过 completion 对象传过去的。

1
val continuation = completion as? LoginUserStateMachine?: LoginUserStateMachine(completion)

总结

我们首先聊了聊协程的两个常见用途,然后引出挂起方法以及状态机,最后通过展示 Kotlin 编译器生成代码的等价 Kotlin 代码,揭示了挂起方法暂停和恢复底层实现原理。

npm

npm 是 NodeJs 的包管理工具。现代语言基本都有一个包管理工具。比如:

  • Python: pip
  • Ruby: gem
  • Java: maven, gradle
  • C#: nuget
  • PHP: composer
  • Go: go mod, dep
  • Rust: cargo
  • Scala: sbt, ivy
  • Swift: cocoapods, carthage

npx

npx 是 npm 的一个包运行工具,它可以帮助你在本地运行一个 npm 包中的命令行工具。它会在运行命令时自动安装该工具,并在运行完后自动删除,使得不用全局安装就能使用。

比如你可以使用 npx create-react-app my-app 在本地运行 create-react-app 包中的命令,来创建一个新的 React 应用而不用全局安装 create-react-app。

结论

简单来说,npm 是用来管理安装和卸载包,npx 是用来运行这些安装好的包中的命令。

cat && touch

cat 和 touch 是工作中用的比较多的命令,linux、macos 上基本都能用。本人总是非常神奇的会把这两兄弟的作用记混。

cat 命令用于将文件的内容显示在终端上。它可以将一个或多个文件的内容按顺序输出到标准输出。例如,在终端中输入 cat file.txt 将会显示 file.txt 文件的内容。
touch 命令用于更新文件的时间戳。如果文件不存在,它会创建一个新文件。例如,在终端中输入 touch file.txt 将会创建一个新文件名为 file.txt 的文件或更新 file.txt 文件的时间戳。

所以,输出内容的时候,使用 cat,创建文件的时候,使用 touch

背景

做开发工作几年了,平时也有记录一些坑、经验的习惯,但是都是在个人的笔记上。最近有一个感觉越来越强烈:虽然内容记录都记录在了大象(印象笔记)和 Notion 中,但是依然不是自己的。能通过博客用自己的语言输出的内容,才算是真正掌握了。于是开始搞 Github Pages 搭建博客。
遇到的第一问题就是:如何选择博客引擎?众所周知,有时候最难的不是解决问题本身,而是做选择。

1
2
而是尽量找一种,最好是唯一种明显的解决方案
There should be one-- and preferably only one --obvious way to do it.

jeklly, hugo, hexo 如何选择?

jeklly, hugo, hexo 都是比较常见的静态博客引擎。一般搭建个人博客的时候会用到。

  • Jekyll 是一个简单的静态博客引擎,它是用 Ruby 编写的。它支持 Markdown 和 Liquid 模板语言。GitHub Pages 默认使用 Jekyll 来构建静态博客。
  • Hexo 是一个用 Node.js 编写的静态博客引擎。它支持 Markdown 和 EJS 模板语言。
  • Hugo:是用Go语言编写的静态博客引擎,速度快,主题丰富,简单易用。

我的博客选择了 Hexo,记录一下决策过程。主要从语言、社区活跃度的角度出发。

  1. 为什么不用 Jeklly?因为是 Ruby 写的。最近几年个人越来越感觉,互联网上 Ruby 的声音少了。相对应的社区活跃度对比 Js、Go 等,会差一点。
  2. 为什么不用 Hugo?Hugo 在静态博客引擎中,算得上是后起之秀。开发社区比较活跃。但是考虑到博客更多是输出内容,美化、插件等,对于我来说能用就行,有解决方案就行。同时,因为是 Go 开发的,而我过往的开发经验在 Java、Kotlin 这块,对 Go 是零基础,没必要新开一个坑。Hugo 还有一个优势:编译速度快。在文章比较多的时候(100 以上)会比较明显。后续积累了比较多的内容后,可能会迁移到 Hugo,但是在此之前,还是聚焦在内容输出上。

相比之下,Hexo 的 NodeJs,开发社区比较活跃,资料多,有问题的话,在 Stack Overflow 里面也能搜到许多信息(面向 Stack Overflow 编程)。Js 语言跟 Java、Kotlin 也基本差不多,语言成本不高。

结论

所以,最终我选择了 Hexo(配合比较常用的 Next 主题)。并计划在输出内容很多构建速度慢的问题比较突出并且 Hugo 的主题已经发展的非常成熟的情况下,切换成 Hugo。

关键字:
hexo,next,启动,访问,乱码,hexo-renderer-swig

hexo + next 显示乱码

在使用 hexo + next 搭建静态网站的过程中,发现启动成功,但是访问 http://localhost:4000 的时候显示乱码

1
2
3
{% extends '_layout.swig' %} {% import '_macro/post.swig' as post_template %} {% import '_macro/sidebar.swig' as sidebar_template %} {% block title %}{{ config.title }}{% if theme.index_with_subtitle and config.subtitle %} - {{config.subtitle }}{% endif %}{% endblock %} {% block page_class %} {% if is_home() %}page-home{% endif -%} {% endblock %} {% block content %}
{% for post in page.posts %} {{ post_template.render(post, true) }} {% endfor %}
{% include '_partials/pagination.swig' %} {% endblock %} {% block sidebar %} {{ sidebar_template.render(false) }} {% endblock %}

原因是 hexo 在 5.0 之后把 swig 给删除了,需要自己手动安装,执行下面命令:

1
npm i hexo-renderer-swig

重启后正常展示网页了。