0%

一、什么是Side-Effect

wiki:在计算机科学中,当函数、表达式、操作会修改它作用域之外的状态变量值时,我们就说它具有副作用。也就是说除返回值之外其他的对外修改都叫副作用。
常见的例子包括:修改非本地变量、修改参数传进来的可变引用、执行I/O等。

二、Compose中的Side-Effect

因为Jetpack Compose是由一系列的@Composable函数组成的,@Composable函数在执行时可能会被跳过(出于优化的角度)或执行多次(recomposition)。
那对于函数内部的网络请求、对外部状态的修改怎么办?有可能执行多次?有可能不执行?这就产生了某种不可预期的错误状态,或者泄露。
为了解决此类问题,Compose提供了相应的API,这些API主要聚焦于对这些副作用的生命周期进行管理。

这些API可以分成两大类:

  • SuspendedEffect
    • rememberCoroutineScope
    • launchedEffect
  • Non-Suspended Side Effects
    • DisposableEffect
    • SideEffect

三、SuspendedEffect

3.1 LaunchedEffect

在首次composition的时候被调用。recomposition时不会再次调用。可以通过改变Key让其重新调用。
它是一个协程作用域,我们可以执行一些挂起函数,当composable函数退出时,协程会被取消。

下面的例子中,启动即执行循环逻辑,每秒修改一次状态值并触发recomposition,但recomposition并不影响LaunchedEffect内的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun LaunchedEffect() {
var timer by remember { mutableStateOf(0) }
Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center) {
Text("Time $timer")
}

LaunchedEffect(key1 = Unit) {
while (true) {
delay(1000)
timer++
}
}
}
3.2 RememberCoroutineScope

LaunchedEffect的启动和取消跟随Composable函数的生命周期。如果想自己控制生命周期可以使用RememberCoroutineScope

下面的例子中,协程的控制权转移到我们自己手中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Composable
fun JustRememberCoroutineScope() {
val scope = rememberCoroutineScope()
var timer by remember { mutableStateOf(0) }
var timerStartStop by remember { mutableStateOf(false) }
var job: Job? by remember { mutableStateOf(null) }

Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Time $timer")
Button(onClick = {
timerStartStop = !timerStartStop

if (timerStartStop) {
job?.cancel()
job = scope.launch {
while (true) {
delay(1000)
timer++
}
}
} else {
job?.cancel()
}

}) {
Text(if (timerStartStop) "Stop" else "Start")
}
}
}
}

四、Non-Suspended Side Effects

4.1 DisposableEffect

LaunchedEffect一样,立即启动,通过修改key可以再次启动。它提供了一个销毁时的回调,当再次启动时,前一个的onDispose方法会被回调。

下面的例子每次点击按钮改变状态进行recompose,同时会改变DisposableEffect的key,销毁上一个Effect并收到回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Composable
fun JustDisposableEffect() {
var timerStartStop by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
timerStartStop = !timerStartStop
}) {
Text(if (timerStartStop) "Stop" else "Start")
}
}
}

val context = LocalContext.current

DisposableEffect(key1 = timerStartStop) {
val x = (1..10).random()
Toast.makeText(context, "Start $x", LENGTH_SHORT).show()

onDispose {
Toast.makeText(context, "Stop $x", LENGTH_SHORT).show()
}
}
}
4.2 SideEffect

使用场景:

  • 每次composition / recomposition成功后被调用
  • 用于对外部状态的更新
  • 无需做清理回收操作时

例1:对外部状态的更新

1
2
3
4
5
6
7
8
9
10
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
val drawerState = rememberDrawerState(DrawerValue.Closed)

SideEffect {
drawerTouchHandler.enabled = drawerState.isOpen
}

// ...
}

例2:

1
2
3
4
5
6
7
8
9
10
var i = 0
@Composable
fun MyComposable(){
SideEffect { //this will handle the side effect that may occur
i++
}
Button(onClick = {}){
Text(text = "Click")
}
}

五、参考

Jetpack Compose Effect Handlers
SideEffects and Effects Handling in Jetpack Compose
Jetpack Compose Side Effects Made Easy

一、问题

为了方便Java代码调用Kotlin的object类,我们通常会对object类的方法添加@JvmStatic注解。

然而、当object类实现某个接口时,对应的方法却不能添加@JvmStatic注解。

二、解决

要想解决上述问题,只需要将object类的实现做一些转换:
1
2
3
4
5
6
7
8
9
10
11
12
13

interface Play2 {
fun play()
}

class Singleton2 {

private companion object : Play2 {
@JvmStatic
override fun play() {
}
}
}

三、思考

为什么第一种方式会报错,而第二种方式没问题呢?

通过反编译查看Java代码我们发现:object类的方法会被直接编译成Java的static方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton implements Play {
public static final Singleton INSTANCE;

@JvmStatic
public static void play() {
}

private Singleton() {
}

static {
Singleton var0 = new Singleton();
INSTANCE = var0;
}
}

而companion object则会被编译成静态内部类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public final class Singleton2 {
/** @deprecated */
@Deprecated
public static final Singleton2.Companion Companion = new Singleton2.Companion((DefaultConstructorMarker)null);

@JvmStatic
public static void play() {
Companion.play();
}

private static final class Companion implements Play2 {
@JvmStatic
public void play() {
}

private Companion() {
}

// $FF: synthetic method
public Companion(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}

由于static方法是不能重写(override)的,所以同一个关键字不能作用于一个函数。而通过静态内部类的方式,在类的层次增加静态能力,从而消除了方法的static关键字。

参考

issue-KT-21342

Kotlin提供了一些注解可以帮助开发者更好地兼容Java。下面探索一下Kotlin中JVM注解的使用,以及在Java中使用Kotlin类时注解对我们的影响。

1、@JvmName

用于文件、函数、属性、getter和setter。

1.1 给文件添加@JvmName注解

默认情况下,一个Kotlin文件中的functionproperties会被编译成filenameKt.class,其中的class会被编译成classname.class:

1.2 给函数名添加@JvmName注解
1.3 给getter和setting添加@JvmName
1
2
3
4
5

@get:JvmName("getContent")
@set:JvmName("setContent")
var text = ""

2、@JvmDefault

和Java8一样,Kotlin的接口也支持默认方法实现。即使针对Java7及以下版本,他也能正常编译,因为Kotlin使用静态内部类实现默认方法。

当在Java中实现此接口时,需要复写对应的方法,否则将报错。

如果我们希望它在Java8中不报错,可以使用@JvmDefault注解标识方法:

1
2
3
4
interface Document {
@JvmDefault
fun getType() = "document"
}

3、@JvmStatic

针对object classcompanion object使用此注解,可以避免Java访问时的INSTANCE调用

4、@JvmOverloads

Kotlin的默认参数可以帮助我们减少函数重载,简化方法调用参数。在Java中调用含默认参数的Kotlin函数时,需要提供全部参数。

5、@Throws

Kotlin没有受检测异常,try-catch是非必须的。如果希望在Java调用中检测到异常,可以使用@Throws注解

6、@JvmWildcard & @JvmSuppressWildcard

7、@JvmMultifileClass

当在多个文件中定义的顶层函数或属性想要合并到一个编译的class中时,可以使用此注解。

8、@JvmPackageName

和@JvmName一样,此注解可以修改包名,但是他被标记为internal,只能在kotlin库内部使用,这里不做过多介绍。

9、注解一览

[参考]

1.Guide to JVM Platform Annotations in Kotlin

一、初始化流程

1、WorkManager集成了androidx.startup 库,通过startup库的ContentProvider在应用启动时进行初始化。
work-runtime的Manifest文件:

1
2
3
4
5
6
7
8
9
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge" >
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup" />
</provider>

其初始化类为WorkManagerInitializer,其中调用了WorkManager#initialize,然后调用WorkManager的构造函数。
整个流程初始化了如下一些对象:

二、任务

2.1 定义任务

当我们自定义任务时,需要继承Work类,WorkManager内部对Work的结构定义如下:

其中DiagnosticsWorkerCombineContinueationsWorker是内部使用的两个任务。

2.2 提交任务

我们将任务以请求(WorkRequest)的形式提交(enqueue)给WorkManager。WorkManager会将请求装换成内部的WorkContinuation对象。

WorkContinuation可以将多个OneTimeWorkrequest构建成任意依赖关系的无环图。而WorkContinuation的enqueue方法则是任务被执行调度的入口。

2.3 执行任务

对任务进行不同的操作,如:取消、结束、开始等。WorkManager内部会将这些行为转换成对应的Runnable交给调度系统进行执行。

三、任务的调度

提交给WorkManager的任务,会封装成EnqueueRunnable交给默认的SerialExecutor去执行。
EnqueueRunnable的run方法会先将任务加入数据库中保存。然后进行任务调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public void run() {
try {
if (mWorkContinuation.hasCycles()) {
throw new IllegalStateException(
String.format("WorkContinuation has cycles (%s)", mWorkContinuation));
}
//加入数据库
boolean needsScheduling = addToDatabase();
if (needsScheduling) {
// Enable RescheduleReceiver, only when there are Worker's that need scheduling.
final Context context =
mWorkContinuation.getWorkManagerImpl().getApplicationContext();
PackageManagerHelper.setComponentEnabled(context, RescheduleReceiver.class, true);
//进行任务调度
scheduleWorkInBackground();
}
mOperation.setState(Operation.SUCCESS);
} catch (Throwable exception) {
mOperation.setState(new Operation.State.FAILURE(exception));
}
}

四、其他

4.1 任务的约束

五、总结

WorkManager库并不算复杂。这种对任务的管理和调度框架的源码,我们可以多看,学习一下里面的设计思路。有个大概的印象,后续写相关逻辑时可以借鉴。

  • 借助startup初始化你的框架
  • 构建有依赖关系的任务
  • 通过Runnable解耦任务的请求和执行。

一、通过RoomDatabase#Callback预填充数据

如果需要在数据库创建或数据库打开的时候添加一些默认数据,可以使用RoomDatabase#Callback接口并复写它的onCreateonOpen方法。由于DAO对象只能在这两个方法返回后使用,所以我们创建一个新线程来插入数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Room.databaseBuilder(context.applicationContext,
DataDatabase::class.java, "Sample.db")
// prepopulate the database after onCreate was called
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// moving to a new thread
ioThread {
getInstance(context).dataDao()
.insert(PREPOPULATE_DATA)
}
}
})
.build()

需要注意的是:当app首次启动时,在create和insert之间崩溃的话,数据将永远不会被插入。

二、使用DAO的继承能力

很多DAO里存在一样的InsertUpdateDelete方法。我们可以使用继承来避免这些重复的代码。

1
2
3
4
5
6
7
8
9
interface BaseDao<T> {
@Insert
fun insert(vararg obj: T)
}
@Dao
abstract class DataDao : BaseDao<Data>() {
@Query("SELECT * FROM Data")
abstract fun getData(): List<Data>
}

三、通过@Transaction减少事务查询的模板代码

@Transaction注解的方法会在一个事务里面执行。当这些方法执行异常时,事务也会失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Dao
abstract class UserDao {

@Transaction
open fun updateData(users: List<User>) {
deleteAllUsers()
insertAll(users)
}
@Insert
abstract fun insertAll(users: List<User>)
@Query("DELETE FROM Users")
abstract fun deleteAllUsers()
}

@Query方法带有select语句时,在下列情况下你可能会使用@Transation注解

  • 当查询的结果集很大时,让查询在一次事务里完成能够保证:如果查询结果不满足单个游标窗口,它不会因为在游标窗口交换之间的数据库更改而中断。
  • 如果查询结果是一个带@Relation字段的POJO,此字段会单独查询,所以让他们运行在一个事务中可以确保结果一致。

DeleteUpdateInsert方法包含多个参数时会自动在事务里执行。

四、只读需要的数据

关注App的内存消耗,只加载需要使用的字段,这能提高查询速度减少IO消耗。

1
2
3
4
5
6
7
8
9
@Entity(tableName = "users")
data class User(@PrimaryKey
val id: String,
val userName: String,
val firstName: String,
val lastName: String,
val email: String,
val dateOfBirth: Date,
val registrationDate: Date)

上面的类中,很多字段我们用不到,定义一个类,只包含我们需要的字段:

1
2
3
data class UserMinimal(val userId: String,
val firstName: String,
val lastName: String)

在DAO中,定义方法只查询所需字段:

1
2
3
4
5
@Dao
interface UserDao {
@Query(“SELECT userId, firstName, lastName FROM Users)
fun getUsersMinimal(): List<UserMinimal>
}

五、使用外键

即使Room不直接支持关系,但它允许你定义外键来约束实体之间的关系。

Room的@ForeignKey@Entity注解的一部分,用来支持Sqlite的外键特性。它强制表之间的约束,当你修改表时,保证关系一致性。

看一下UserPet类,Pet有主人,以userId关联。

1
2
3
4
5
6
7
8
@Entity(tableName = "pets",
foreignKeys = arrayOf(
ForeignKey(entity = User::class,
parentColumns = arrayOf("userId"),
childColumns = arrayOf("owner"))))
data class Pet(@PrimaryKey val petId: String,
val name: String,
val owner: String)

你可以定义一些行为,当例子中的User在数据库中被删除或修改。你可以做以下一些操作:NO_ACTIONRESTRICTSET_NULLSET_DEFAULTCASCADE,这些操作和Sqlite保持一致。

注意: 在Room中SET_DEFAULTSET_NULL效果一致,因为Room还不允许为数据列设置默认值。

六、使用@Relation简化一对多查询

在前面User-Pet的例子中,我们有个一对多的关系,一个人可以有多个宠物。当我们想获取主人和宠物集合时:

1
2
data class UserAndAllPets (val user: User,
val pets: List<Pet> = ArrayList())

需要两个查询:

1
2
3
4
5
@Query(“SELECT * FROM Users”)
public List<User> getUsers();

@Query(“SELECT * FROM Pets where owner = :userId”)
public List<Pet> getPetsForUser(String userId);

我们需要遍历用户来查宠物。

为了简化此过程,Room的@Relation注解自动关联实体。此注解只能用于ListSet对象。

1
2
3
4
5
6
7
8
class UserAndAllPets {
@Embedded
var user: User? = null

@Relation(parentColumn = “userId”,
entityColumn = “owner”)
var pets: List<Pet> = ArrayList()
}

在DAO中,我们定义一次查询:

1
2
3
@Transaction
@Query(“SELECT * FROM Users”)
List<UserAndAllPets> getUsers();

七、避免可观察查询的假通知

可观察查询,只关心对应ID的对象:

1
2
3
4
5
6
7
@Query(“SELECT * FROM Users WHERE userId = :id)
fun getUserById(id: String): LiveData<User>

// or

@Query(“SELECT * FROM Users WHERE userId = :id)
fun getUserById(id: String): Flowable<User>

每次此用户更新的时候你都得到一个新通知。但是其他的一些不影响此ID的修改(delete、update、insert)会发送通知。

这种场景背后的原因是什么:
1、Sqlite支持触发器,DELETEUPDATEINSERT发生时或触发。
2、Room创建了一个InvalidationTracker来跟踪观察表的变化。
3、LiveDataFlowable查询依赖InvalidationTracker.Observer#onInvalidated通知,收到通知就做一次再查询操作。

Room只知道表被修改了,不知道为什么修改也不知道什么被修改。因此,再查询操作会重新通知。由于Room不在内存保存数据也不能保证object的equals方法所以他不知道对象是否变化了。

你需要自己保证DAO过滤收到的事件,只对关心的数据做出响应。

如果观察的是Flowable,使用Flowable#distinctUntilChanged

1
2
3
4
5
6
7
8
9
10
11
12
@Dao
abstract class UserDao : BaseDao<User>() {
/**
* Get a user by id.
* @return the user from the table with a specific id.
*/
@Query(“SELECT * FROM Users WHERE userid = :id”)
protected abstract fun getUserById(id: String): Flowable<User>

fun getDistinctUserById(id: String):
Flowable<User> = getUserById(id).distinctUntilChanged()
}

如果观察的是LiveData,可以用MediatorLiveData处理只让有变化的数据发送事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun <T> LiveData<T>.getDistinct(): LiveData<T> {
val distinctLiveData = MediatorLiveData<T>()
distinctLiveData.addSource(this, object : Observer<T> {
private var initialized = false
private var lastObj: T? = null
override fun onChanged(obj: T?) {
if (!initialized) {
initialized = true
lastObj = obj
distinctLiveData.postValue(lastObj)
} else if ((obj == null && lastObj != null)
|| obj != lastObj) {
lastObj = obj
distinctLiveData.postValue(lastObj)
}
}
})
return distinctLiveData
}

在DAO中,让真正关心变化的方法使用public修饰,查询方法用protected修饰

1
2
3
4
5
6
7
@Dao
abstract class UserDao : BaseDao<User>() {
@Query(“SELECT * FROM Users WHERE userid = :id”)
protected abstract fun getUserById(id: String): LiveData<User>

fun getDistinctUserById(id: String): LiveData<User> = getUserById(id).getDistinct()
}

注意: 如果你返回列表用于展示,考虑使用Paging库,它能帮你处理数据的变化。

一、使用

1.1 定义API方法
1
2
3
4
interface RemoteApi {
@GET("timeline")
suspend fun timeline(): RemoteLaunchesResponse
}
1.2 获取服务
1
2
3
4
5
6
7
8
9
10
11
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()

val retrofit = Retrofit.Builder()
.client(client)
.baseUrl("http://10.0.0.2:8080/")
.addConverterFactory(GsonConverterFactory.create())
.build()

val api = retrofit.create(RemoteApi::class.java) //1、create做了什么?
1.3 发起请求
1
api.timeline() //2、调用接口方法怎么发起请求?

二、原理

2.1 create做了什么

Retrofit的网络请求方法都是定义在接口中的,它会在运行时利用Java的动态代理产生代理对象,所以create()就是创建代理对象的方法。

2.2 调用接口方法怎么发起请求

当调用接口方法时,实际上是调用了代理对象的方法。它会去Retrofit中找该方法对应的ServiceMethod,如果找到缓存直接使用,否则解析后再使用。

解析的结果是ServiceMethod的子类CallAdaptedSuspendForResponseSuspendForBody三者之一。

解析结束后调用返回对象的invoke方法初始化OkHttpCall对象发起真正的请求。

三、总结

阅读源码的过程当中,画了详细的代码执行流程图供后续回顾。另外源码中还有两个重要的对外拓展接口CallAdapterConverter没有详细分析。

一、概述

此文是对Github开源项目SpaceX-prepare-for-Clean-Architecture-liftoff 的源码阅读记录。它是一个为了阐述对Clean Architecture的理解而作的Demo项目,是一个值得学习的项目。

二、源码分析

2.1 模块之间的依赖关系

项目包含以下library:

  • app
  • buildSrc
  • core
  • core-android-test
  • data
  • data-api
  • domain
  • navigation
  • presentation

模块之间的依赖关系可用下图简述:

2.2 类和模块之间的关系

三、总结

项目重在阐述架构思想所以比较简单,其中很多东西值得我们在项目中借鉴。数据层的架构模式参看martin fowler 的文章。

简介:

本文是 Roman Elizarov 关于CSP中产生死锁问题的讨论,解释了其中的原因,并逐步给出了一种解决方案。部分翻译不恰当的可参考原文

原文:

众所周知,使用锁保护共享可变状态可能会导致死锁,但很少有人知道,使用无共享可变状态的CSP或actor模型也会导致死锁,尽管它们并不使用锁。它的死锁以另一种方式表现:通信死锁。

要知道,死锁的正式定义并没有直接与锁联系在一起。当每个进程都在循环等待下一个进程时,这组进程(线程/协程/参与者)就处于死锁状态。后面我们会看个例子,这里先道个歉,之前没强调这个问题。

The stage

在2018年的KotlinConf我发表了题为”Kotlin协程实践”的演讲,其中讲到了对于构建可信赖软件来说结构性并发是重要的,且介绍了CSP的一些高级概念。其中一个部分有点问题。如果你按照演讲中一字不差的写完代码会导致一个死锁。不知何故,我掉进了这个陷阱,以为它对我来说是安全的,但不是。让我们来分析一下

首先来讲,如果应用被组织成数据处理管道,消息进入系统,被不同的协程顺序处理,最后被送出系统,那么这种CSP风格的架构,包括actor模式,都是安全的。更广泛地说,只要代码具有协程的层次结构(或有向无环图),上游协程只向下游发送消息,且每个协程在同一个地方接收传过来的消息并向下游传递响应消息,代码就不会出现死锁。

然而在我的演讲中,我草拟了一个数据处理循环的架构,下载器协程向任务池发送locations,并从任务池获取结果:

我演讲中的例子的架构基于3个channel:referenceslocationscontents。下载协程包含以下逻辑:

1
2
3
4
5
6
7
8
9
10
while (true) {
select<Unit> {
references.onReceive { ref -> // (1)
val loc = ref.resolveLocation()
...
locations.send(loc) // (2)
}
contents.onReceive { (loc, content) -> ... } // (3)
}
}

Downloader从references通道(1)接收,解析对位置的引用并将它们发送到locations通道(2)。它还通过从contents通道(3)接收来自workers的结果来更新其状态。另外,works有以下代码:

1
2
3
4
for (loc in locations) {                             // (4)
val content = downloadContent(loc)
contents.send(LocContent(loc, content)) // (5)
}

locations通道接收消息(4),并发送下载后的内容去contents通道(5)

即使在两段通信的代码中也很难遵循逻辑,但在CSP中有一种方便的方法来可视化它。我们可以用通信有限状态机(CFSM)表示我们的系统。这些状态相当于挂起点和向通信的转变过程:

为了简洁起见,上图中通道名简写。receive -> rcv、send -> snd 。下载器主循环中的select语句相当于D0状态,它能接收来自references通道的消息(1)或者来自contents通道的消息(3),当接收到来自references消息,切换到状态D1,等待发送消息去locations通道,如图中:(2)l.snd

Deadlock Demo

我用模拟数据类的方式完善了演讲中的代码,加入了delay(10)downloadContent方法。main函数则持续发送请求给下载器的references通道,它有4个workers且带3秒超时限制。此项目可以完整的运行,正常情况下3s应该处理1000次请求。

当你运行该项目时,你会发现只处理了4次请求(和我们定义的worker数量一样)就挂起了,直到3s超时。此外,它没有任何随机性,因为它是在单线程的runBlocking上下文中运行的。

为了说服你(还有我自己),这是CSP内在的行为而不是Kotlin实现的bug,我用Go语言实现了一遍。Go语言内建的死锁检测器立即给出了提示:all goroutines are asleep — deadlock ,到底发生了什么呢?

开始时所有的worker完成初始化处于W1状态,然后尝试发送回复下载器的消息去contents通道(5),但它是一个对接通道,下载器不能立马收到。下载器处于D1正尝试发送消息去locations通道(2),但由于worker都在尝试发送所以无法接收。死锁发生了,所有的协程都在等待。

Solutions that do not work

问题好像出在select表达式。修复它很简单。相对于由下载器通过select处理来自referencescontents的消息,我们可以使用actor模型作为协程重写下载器,它有独立的mailbox通道来处理发送过来的消息。

1
2
3
4
5
6
for (msg in mailbox) {
when (msg) {
is Reference -> ...
is LocContent -> ...
}
}

然而,actor模型也不能避免死锁,这种方式和原来一样代码很快被挂起。他们的通讯状态机是相同的。

另一个可能被指责的是集合信道——没有缓冲区的信道,因为它们在另一端没有接收器的情况下会暂停发送。即使我们给contentslocations添加buffer也不能解决此问题,只是减少了问题出现的概率或延迟出现。buffer越大出现的概率越低,但无法完全避免死锁的发生,一旦buffer满了发送器还是会挂起并发生死锁。

Unlimited-capacity channels

一种明确的避免死锁的解决方案是对被通讯死锁影响的channel中的至少一个使用不限大小的buffer,这里 的代码对contents通道使用了无限制buffer,似乎能正常工作。

然而,通过取消通道缓冲区的限制,我们丧失了CPS编程风格的利润丰厚的属性-自动背压传播.如果来自通道的消息的接收方比发送方慢,则发送方将挂起在全缓冲区上以自动减慢速度。有了无限容量的通道,就不会发生这种情况,管理背压的任务完全由应用程序开发人员承担。如果不能管理背压,系统最终可能会耗尽内存,在其缓冲区中收集越来越多的消息。

在我们的例子中,把locations通道设置成无限buffer会完全移除对传入引用的背压管理,因为即使所有的worker都处于忙碌状态,下载器也将发送所有的消息去locations通道。
把contents通道设置成无限buffer会更安全,因为它只影响下载内容的最终处理。然而,在无限容量的情况下,我们面临的风险是,下载器会被传入的references淹没,永远无法处理下载的内容。这使我们离最终解决方案更近一步。

Solutions that do work

我们调整一下下载器协程中select表达式的顺序,以便先检查contents通道,也就是说contents通道的消息优先级高于references通道:

1
2
3
4
5
6
7
8
select<Unit> {
contents.onReceive { ... }
references.onReceive {
...
locations.send(loc) // (2)
}
}

它本身并没有解决这个问题(你可以在这里验证代码),但它给了一个有用的属性——
下载器只有在没有worker被挂起且worker准备发送消息去contents通道时,才发送消息去locations通道(2)。现在,提供至少一个buffer给contents通道就足够了,以确保至少有一个worker可以在(5)发送其内容,并在(4)再次开始从locations接收,从而允许下载程序继续进行:

1
val contents = Channel<LocContent>(1)

这里 是能运行的代码。

注意,它是如何在3秒内处理比之前的无限渠道“解决方案”更多的下载的。此外,由于contents通道处理具有最高优先级,现在可以安全地拥有无限容量的contents通道。它在缓冲区中保存的消息永远不会超过工作人员的数量加1(为什么是加1 ?这是留给读者的练习)。

还有一种替代解决方案,它完全不使用缓冲通道,可以完美地与任何容量的通道一起工作。它加倍select,以避免在locations.send(loc)上挂起下载程序,将这个发送操作折叠为一个select。它实际上是CFSM模型中表示和分析的最简单的一个,但是我现在不会详细介绍它,把它留给将来的故事。你可以看一下这里 对应的代码并在playground中运行它。

总结

通信循环(非DAG模式)可能并将导致通道容量有限的通信死锁,除非使用一些死锁预防策略。如果在应用程序中遇到死锁,在了解发生了什么之前,不要尝试使用缓冲通道解决它,否则可能会把问题掩盖起来。

请在本文的评论中分享更多解决这种特殊通信循环死锁的有效解决方案。Kotlin和其他CSP/actor运行时中的代码都是受欢迎的(请提供链接,不要将代码粘贴到评论中)。

Credits

我要感谢Alexey Semin,他通过这个 github-issue报告了这个问题,并感谢Alexander Gorynin在Kotlin Slack里联系了我。还要感谢Simon Wirtz和Sean McQuillan对本文草稿的有用评论。

一、概述:

利用Kotlin的语言特性可以创建可配置的领域语言-DSL(Domain Specific Language)。这种DSL用于表示复杂对象和对象继承结构很有用,它能隐藏模板代码和复杂性,让使用者更好的表达自己的意图。

例如,用Kotlin DSL的方式表示HTML的结构:

1
2
3
4
5
6
7
8
9
body {
div {
a("https://kotlinlang.org") {
target = ATarget.blank
+"Main site"
}
}
+"Some content"
}

其他平台的视图也可以使用DSL来定义。下面是Android上使用Anko库定义的界面:

1
2
3
4
5
6
verticalLayout {
val name = editText()
button("Say Hello") {
onClick { toast("Hello, ${name.text}!") }
}
}

同样的,使用TornadorFX库定义的桌面应用如下:

1
2
3
4
5
6
7
8
9
10
11
class HelloWorld : View() {
override val root = hbox {
label("Hello world") {
addClass(heading)
}

textfield {
promptText = "Enter your name"
}
}
}

DSL通常也被用于定义数据和配置。下面是Ktor定义的API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun Routing.api() {
route("news") {
get {
val newsData = NewsUseCase.getAcceptedNews()
call.respond(newsData)
}
get("propositions") {
requireSecret()
val newsData = NewsUseCase.getPropositions()
call.respond(newsData)
}
}
// ...
}

这是在Kotlin Test中定义的测试用例:

1
2
3
4
5
6
7
8
class MyTests : StringSpec({
"length should return size of string" {
"hello".length shouldBe 5
}
"startsWith should test for a prefix" {
"world" should startWith("wor")
}
})

我们甚至可以使用Gradle DSL定义Gradle配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
plugins {
`java-library`
}

dependencies {
api("junit:junit:4.12")
implementation("junit:junit:4.12")
testImplementation("junit:junit:4.12")
}

configurations {
implementation {
resolutionStrategy.failOnVersionConflict()
}
}

sourceSets {
main {
java.srcDir("src/core/java")
}
}

java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}

tasks {
test {
testLogging.showExceptions = true
}
}

使用DSL简化了层次结构和复杂对象的创建。在DSL构建块中,你可以使用Kotlin语法和代码提示。也许你用过Kotlin DSL,但你更应该学会如何定义它。

二、定义自己的DSL

在理解如何定义DSL前,先明白带接收者的函数类型。首先什么是函数类型呢?它是一个能被当成函数使用的对象。例如:filter函数,参数predicate决定集合是否包含此元素。

1
2
3
4
5
6
7
8
9
10
11
inline fun <T> Iterable<T>.filter(
predicate: (T) -> Boolean
): List<T> {
val list = arrayListOf<T>()
for (elem in this) {
if (predicate(elem)) {
list.add(elem)
}
}
return list
}

函数类型的例子:

  • ()->Unit - 无参函数,返回Unit
  • (Int)->Unit - 单参Int函数,返回Unit
  • (Int)->Int - 单参Int函数,返回Int
  • (Int, Int)->Int - 双参Int函数,返回Int。
  • (Int)->()->Unit - 单参Int函数,返回无参函数。
  • (()->Unit)->Unit - 参数为函数的函数,返回Unit

创建函数类型实例的方式有:

  • 使用lambda表达式
  • 使用匿名函数
  • 使用函数引用

例如,下面的函数

1
fun plus(a: Int, b: Int) = a + b

同样可以声明成这样:

1
2
3
val plus1: (Int, Int) -> Int = { a, b -> a + b }
val plus2: (Int, Int) -> Int = fun(a, b) = a + b
val plus3: (Int, Int) -> Int = ::plus

上面的例子中,由于指明了属性的类型,所以lambda和匿名函数中的参数能被推导出来。换一种方式说:如果指定参数类型,函数类型可以被推导。

1
2
val plus4 = { a: Int, b: Int -> a + b }
val plus5 = fun(a: Int, b: Int) = a + b

函数类型以对象来表达函数,匿名函数也是一个普通函数只是没名字而已。lambda则是匿名函数的简短书写方式。

我们可以使用函数类型去表示函数,那拓展函数呢?

1
fun Int.myPlus(other: Int) = this + other

之前提到匿名函数只是没名字而已,匿名拓展函数也一样:

1
fun Int.myPlus(other: Int) = this + other

那此函数的类型是什么呢?一种表示拓展函数的特殊类型,叫:带接收者的函数类型。它看起来和普通函数很像,只是在参数之前额外的指定了接收者的类型,他们之间用一个点分割开来。

1
2
val myPlus: Int.(Int) -> Int =
fun Int.(other: Int) = this + other

这种函数能通过lambda表达式定义,尤其是一个带接收者的lambda表达式,因为作用域内部的this关键字引用了拓展接收者。(此例中是一个Int类型的实例):

1
val myPlus: Int.(Int)->Int = { this + it }

用匿名拓展函数或带接收者的lambda表达式创建的对象能被三种方式调用:

  • 像对象一样使用invoke方法调用
  • 像非拓展函数一样
  • 和普通的拓展函数一样
1
2
3
myPlus.invoke(1, 2)
myPlus(1, 2)
1.myPlus(2)

带接收者的函数类型最大的特点是,它改变了this的指向。这种特点如何使用呢?假设一个类需要依次设置属性:

1
2
3
4
5
6
7
8
9
10
11
12
class Dialog {
var title: String = ""
var text: String = ""
fun show() { /*...*/ }
}

fun main() {
val dialog = Dialog()
dialog.title = "My dialog"
dialog.text = "Some text"
dialog.show()
}

重复引用此dialog很不方便,如果我们使用带接收者的lambda,this就指向dialog,我们可以省略this,因为接收者能够隐式调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Dialog {
var title: String = ""
var text: String = ""
fun show() { /*...*/ }
}

fun main() {
val dialog = Dialog()
val init: Dialog.() -> Unit = {
title = "My dialog"
text = "Some text"
}
init.invoke(dialog)
dialog.show()
}

按照这个思路,你可以定义一个包含创建此dialog对象所需的所有公共部分的逻辑,只把需要变化的属性的设置给调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Dialog {
var title: String = ""
var text: String = ""
fun show() { /*...*/ }
}

fun showDialog(init: Dialog.() -> Unit) {
val dialog = Dialog()
init.invoke(dialog)
dialog.show()
}

fun main() {
showDialog {
title = "My dialog"
text = "Some text"
}
}

这就是最简单的DSL例子。因为大部分这些构建函数都是重复的,已经被抽取到一个apply函数内,能被直接使用,不用再定义DSL构建器去设置属性了。

1
2
3
4
5
6
7
8
9
inline fun <T> T.apply(block: T.() -> Unit): T {
this.block()
return this
}

Dialog().apply {
title = "My dialog"
text = "Some text"
}.show()

对于DSL来说,带接收者的函数类型是最基本的构建块。我们一起来写一个简单的DSL用于创建下面的HTML表格:

1
2
3
4
5
6
7
8
9
fun createTable(): TableBuilder = table {
tr {
for (i in 1..2) {
td {
+"This is column $i"
}
}
}
}

在DSL开头我们定义了table函数。由于是最外层定义,所以它没用接收者。在table的内部可以使用tr,所以tr函数只能用于table内部。同理,td只能用于tr内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun table(init: TableBuilder.()->Unit): TableBuilder {
//...
}

class TableBuilder {
fun tr(init: TrBuilder.() -> Unit) { /*...*/ }
}

class TrBuilder {
fun td(init: TdBuilder.()->Unit) { /*...*/ }
}

class TdBuilder {
var text = ""

operator fun String.unaryPlus() {
text += this
}
}

现在,我们定义好了DSL。为了让它运行,每一步我们都需要创建一个builder并用参数里面的函数初始化它(下面例子中的init)。builder将包含所有init函数指定的数据。这些数据是我们需要的。我们可以直接返回这个构建器也可以返回另一个包含这些数据的新对象。此例中我们直接返回构建器。下面是table函数的定义:

1
2
3
4
5
fun table(init: TableBuilder.()->Unit): TableBuilder {
val tableBuilder = TableBuilder()
init.invoke(tableBuilder)
return tableBuilder
}

我们可以使用apply函数简化函数:

1
2
fun table(init: TableBuilder.()->Unit) =
TableBuilder().apply(init)

其他函数也使用apply简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TableBuilder {
var trs = listOf<TrBuilder>()

fun tr(init: TrBuilder.()->Unit) {
trs = trs + TrBuilder().apply(init)
}
}

class TrBuilder {
var tds = listOf<TdBuilder>()

fun td(init: TdBuilder.()->Unit) {
tds = tds + TdBuilder().apply(init)
}
}

三、何时使用?

DSL给了我们一种定义信息的方式。它能用于表达你所想表达的东西。但是对于用户而言这些信息之后如何使用不是很清晰。在Anko、TornadoFX或HTML DSL中,我们相信视图会根据我们的定义形式被构建,但很难跟踪准确跟踪如何构建。一些复杂的使用方式很难发现。用法也会让那些不习惯的人感到困惑。更不用说维护了。它们的定义方式可能是一种成本——在开发人员困惑和性能方面都是如此。当我们可以使用其他更简单的特性时,dsl就太过了。虽然当我们需要表达下面的内容时,它们真的很有用:

  • 复杂的数据结构
  • 继承结构
  • 大量数据

描述事物不止DSL方式,还可以用builder、或只用构造器代替。dsl是关于此类结构的模板代码消除。当您看到可重复的样板代码,并且没有更简单的Kotlin特性可以提供帮助时,您应该考虑使用DSL。

四、总结:

DSL是语言中的一种特殊形式。它可以非常简单地创建复杂的对象,甚至整个对象层次结构,如HTML代码或复杂的配置文件。另一方面,DSL实现可能会让新开发人员感到困惑或困难。它们也很难定义。这就是为什么只有当它们提供真正的价值时才应该使用它们的原因。例如,用于创建一个真正复杂的对象,或者可能用于复杂的对象层次结构。这就是为什么最好在库中而不是在项目中定义它们的原因。制作一个好的DSL并不容易,但是一个定义良好的DSL可以让我们的项目做得更好。

参考:
Effective Kotlin Item 35: Consider defining a DSL for complex object creation