LaunchedEffect到底为我们处理了什么问题?

2,333 阅读7分钟

在《Compose之文本编辑及输入法相关》一文中,意图在启动编辑界面的时候自动显示输入法,我们使用了LaunchedEffect来帮助实现。今天就来探讨一下它为我们做了什么以及为什么要用到它

起因

为了在启动编辑界面后自动显示输入法,最开始的做法是:

val input = LocalTextInputService.current
val focusRequester = remember { FocusRequester() }
val (text, setText) = remember { mutableStateOf("Hello $name!") }
TextField(value = text, onValueChange = setText, modifier = Modifier.focusRequester(focusRequester))
input?.showSoftwareKeyboard()
focusRequester.requestFocus()

而这段代码并不能正确如预期,它导致崩溃如下:

java.lang.IllegalStateException: 
   FocusRequester is not initialized. Here are some possible fixes:

   1. Remember the FocusRequester: val focusRequester = remember { FocusRequester() }
   2. Did you forget to add a Modifier.focusRequester() ?
   3. Are you attempting to request focus during composition? Focus requests should be made in
   response to some event. Eg Modifier.clickable { focusRequester.requestFocus() }

    at androidx.compose.ui.focus.FocusRequester.requestFocus(FocusRequester.kt:54)
    ......

崩溃日志十分清晰,连可能的解决办法都一条条列出了。关键点就在于第3条(前两条都没问题嘛):

在compose过程中,是不允许请求焦点的

而解决此问题的可行办法之一就是:用LaunchedEffect包裹任务

LaunchedEffect("ime") {
    input?.showSoftwareKeyboard()
    focusRequester.requestFocus()
}

那,这是什么原理呢?

LaunchedEffect

首先要知道,LaunchedEffect是有多个重载的,而它们之间的区别就在于key的数量:

fun LaunchedEffect(
    block: suspend CoroutineScope.() -> Unit
): Unit = error(LaunchedEffectNoParamError)

fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
// ...
}

fun LaunchedEffect(
    key1: Any?,
    key2: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
// ...
}

fun LaunchedEffect(
    key1: Any?,
    key2: Any?,
    key3: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
// ...
}

fun LaunchedEffect(
    vararg keys: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
// ...
}

key1, key2, key3 ... 嗯,key到可变数量 —— 真是很Kotlin啊!

不带key的版本已经deprecated了,所以,来直接讨论带一个key参数的版本,其他的就很类似了

单key版本

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

代码很是简单,其注释倒很长:

When LaunchedEffect enters the composition it will launch block into the composition's CoroutineContext. The coroutine will be cancelled and re-launched when LaunchedEffect is recomposed with a different key1. The coroutine will be cancelled when the LaunchedEffect leaves the composition. This function should not be used to (re-)launch ongoing tasks in response to callback events by way of storing callback data in MutableState passed to key1. Instead, see rememberCoroutineScope to obtain a CoroutineScope that may be used to launch ongoing jobs scoped to the composition in response to event callbacks.

总结一下,要点就俩:

  • block块是运行在composition的协程上下文的
  • key1值变化时,此协程会被取消并重新执行

还说了一条禁忌:不要使用MutableState的key1值的变化,来触发(重复)执行任务

什么意思呢?接下来一一探明

Composer和currentComposer

首先,利用Composer的实现,拿到context。而Composer是编译器定义的插件接口,currentComposer就是它的一个当前实现:

interface Composer {
    // ...
    @InternalComposeApi
    val applyCoroutineContext: CoroutineContext
        @TestOnly
        get
    // ...
}

val currentComposer: Composer
    @ReadOnlyComposable
    @Composable get() { throw NotImplementedError("Implemented as an intrinsic") }

这个applyCoroutineContextCoroutineContext类型,是供composition使用的协程上下文

LaunchedEffectImpl

接着,就要构造LaunchedEffectImpl对象了:

internal class LaunchedEffectImpl(
    parentCoroutineContext: CoroutineContext,
    private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
    private val scope = CoroutineScope(parentCoroutineContext)
    private var job: Job? = null

    override fun onRemembered() {
        job?.cancel("Old job was still running!")
        job = scope.launch(block = task)
    }

    override fun onForgotten() {
        job?.cancel()
        job = null
    }

    override fun onAbandoned() {
        job?.cancel()
        job = null
    }
}

/**
Objects implementing this interface are notified when they are initially used in a composition and when they are no longer being used.
*/
interface RememberObserver {
    fun onRemembered()
    fun onForgotten()
    fun onAbandoned()
}

从源码可以看到,LaunchedEffectImplRememberObserver接口的实现类。因此,在composition过程中,初次使用不再使用时,它都会收到事件通知,反映到代码上就是:

  1. 利用传入的applyCoroutineContext构造一个scope
  2. 初次使用时,回调onRemembered,启动构造传入的协程任务。如果有老的任务,则先行取消之
  3. composition失效且不再使用时,回调onForgotten,取消协程任务

所以,总体上看来,LaunchedEffect的作用就是:从composition中拿到协程context,并保证在composition完成后,按需要执行(或忽略)相应任务

这么说的话,开篇提到的输入法显示问题,用LaunchedEffect自然就是可以解决的了,因为其根本诉求就是:要像View系统一样,在View完成初始化后,就“给焦点并显示输入法”

实验

下面,通过实验来验证一下前面的要点和结论。

界面只一个Button,上面显示点击次数;点击后,次数加1,十分简单

fun Greeting2(name: String) {
    val counts = remember { mutableStateOf(0) }
    Box {
        Button(onClick = { counts.value++ }, modifier = Modifier.padding(16.dp)) {
            Text(text = "Hello $name! ${counts.value}")
        }
    }
}
1. 单次任务

添加一个LaunchedEffect, 任务是打一个日志:

@Composable
fun Greeting2(name: String) {
    Box {
       // ...
    }
    LaunchedEffect(key1 = "1") {
        Log.d("effect-test", "launched-effect called: ${counts.value}")
    }
}

无论点Button多少次,日志就打印一次,即启动时的值。因为key1值固定的

2022-01-18 21:18:37.237 3687-3687/com.jacee.example.composer D/effect-test: launched-effect called: 0
2. 重复任务

将key1的值换为变化的count:

// ...
LaunchedEffect(key1 = counts.value) {
    Log.d("effect-test", "launched-effect called: ${counts.value}")
}
// ...

这下就不一样了,因为每点击一下,counts.value 的值都会变,这就使得effect会再生效执行,日志就和点击如影随形了:

2022-01-18 21:24:57.921 3936-3936/com.jacee.example.composer D/effect-test: launched-effect called: 0
2022-01-18 21:25:00.962 3936-3936/com.jacee.example.composer D/effect-test: launched-effect called: 1
2022-01-18 21:25:01.740 3936-3936/com.jacee.example.composer D/effect-test: launched-effect called: 2
2022-01-18 21:25:02.307 3936-3936/com.jacee.example.composer D/effect-test: launched-effect called: 3
2022-01-18 21:25:02.822 3936-3936/com.jacee.example.composer D/effect-test: launched-effect called: 4
2022-01-18 21:25:03.507 3936-3936/com.jacee.example.composer D/effect-test: launched-effect called: 5
3. composition和effect的先后?

前面说到,LaunchedEffect的任务都是在(re)composition之后进行的,是这样吗?加个日志呢

@Composable
fun Greeting2(name: String) {
    val counts = remember { mutableStateOf(0) }
    Box {
        Button(onClick = { counts.value++ }, modifier = Modifier.padding(16.dp)) {
            Log.d("effect-test", "button compose [${counts.value}] start")
            Text(text = "Hello $name! ${counts.value}")
            Log.d("effect-test", "button compose [${counts.value}] end")
        }
    }

    LaunchedEffect(key1 = counts.value) {
        Log.d("effect-test", "launched-effect called: ${counts.value}")
    }
}

点击三次,包括首次运行在内,block都是在composition之后执行的,嗯,没有问题

2022-01-18 21:28:06.686 4029-4029/com.jacee.example.composer D/effect-test: button compose [0] start
2022-01-18 21:28:06.689 4029-4029/com.jacee.example.composer D/effect-test: button compose [0] end
2022-01-18 21:28:06.734 4029-4029/com.jacee.example.composer D/effect-test: launched-effect called: 0
2022-01-18 21:28:28.192 4029-4029/com.jacee.example.composer D/effect-test: button compose [1] start
2022-01-18 21:28:28.196 4029-4029/com.jacee.example.composer D/effect-test: button compose [1] end
2022-01-18 21:28:28.208 4029-4029/com.jacee.example.composer D/effect-test: launched-effect called: 1
2022-01-18 21:28:29.782 4029-4029/com.jacee.example.composer D/effect-test: button compose [2] start
2022-01-18 21:28:29.786 4029-4029/com.jacee.example.composer D/effect-test: button compose [2] end
2022-01-18 21:28:29.795 4029-4029/com.jacee.example.composer D/effect-test: launched-effect called: 2
2022-01-18 21:28:30.915 4029-4029/com.jacee.example.composer D/effect-test: button compose [3] start
2022-01-18 21:28:30.917 4029-4029/com.jacee.example.composer D/effect-test: button compose [3] end
2022-01-18 21:28:30.924 4029-4029/com.jacee.example.composer D/effect-test: launched-effect called: 3
4. effect的取消

LaunchedEffect任务如果耗时,那是有概率在recomposition后取消掉的

LaunchedEffect(key1 = counts.value) {
    Log.d("effect-test", "launched-effect called: ${counts.value}")
    // 加个delay模拟延时任务
    delay(2000)
    Log.d("effect-test", "launched-effect called: ${counts.value} done")
}

看看:

2022-01-18 22:18:54.939 4197-4197/com.jacee.example.composer D/effect-test: button compose [0] start
2022-01-18 22:18:54.941 4197-4197/com.jacee.example.composer D/effect-test: button compose [0] end
2022-01-18 22:18:55.101 4197-4197/com.jacee.example.composer D/effect-test: launched-effect called: 0
2022-01-18 22:18:57.103 4197-4197/com.jacee.example.composer D/effect-test: launched-effect called: 0 done
2022-01-18 22:19:02.733 4197-4197/com.jacee.example.composer D/effect-test: button compose [1] start
2022-01-18 22:19:02.737 4197-4197/com.jacee.example.composer D/effect-test: button compose [1] end
2022-01-18 22:19:02.747 4197-4197/com.jacee.example.composer D/effect-test: launched-effect called: 1
2022-01-18 22:19:03.683 4197-4197/com.jacee.example.composer D/effect-test: button compose [2] start
2022-01-18 22:19:03.685 4197-4197/com.jacee.example.composer D/effect-test: button compose [2] end
2022-01-18 22:19:03.695 4197-4197/com.jacee.example.composer D/effect-test: launched-effect called: 2
2022-01-18 22:19:05.698 4197-4197/com.jacee.example.composer D/effect-test: launched-effect called: 2 done

launched-effect called: 1 done 并没有打印出来,因为在点击变成1后,快速地再次点击,将值变成了2,所以1的任务虽然执行了,但未完成的情况下,就只能被cancel掉了

rememberCoroutineScope()

前面实验2中的案例,确实很像是一个监听器,key一变,就是一次新的回调执行。但是呢,官方注释里还明确说到,这不应该这么用的,而应该用rememberCoroutineScope()来实现同等效果

为什么呢?摊手,不知道……这里猜测一下,一个LaunchedEffect只能定义一个行为,如果有多个任务事件,就需要写多个effect,而且对应关系和可控性都将变得复杂且难以维护。大概是这个原因?

rememberCoroutineScope()LaunchedEffect的相似之处在于,都提供了一个协程环境,都能保证其协程任务能在composition完成后执行;但它优于effect的点就是,只要有需要,就能直接launch一个任务出来,各个任务独立且就在各自的触发位置,清晰明了

源码分析

作为工具类顶层函数,其实相对简单,分析就直接写在代码里了

@Composable
inline fun rememberCoroutineScope(
    // 获取协程的lambda,默认为EmptyCoroutineContext
    getContext: @DisallowComposableCalls () -> CoroutineContext = { EmptyCoroutineContext } 
): CoroutineScope {
    val composer = currentComposer
    // 这里remember了此对象,保证其不变性
    val wrapper = remember {
        CompositionScopedCoroutineScopeCanceller(
            createCompositionCoroutineScope(getContext(), composer)
        )
    }
    return wrapper.coroutineScope
}

/**
又是一个RememberObserver监听,重点在于任务的取消
毕竟onRemembered是没有管的
*/
@PublishedApi
internal class CompositionScopedCoroutineScopeCanceller(
    val coroutineScope: CoroutineScope
) : RememberObserver {
    override fun onRemembered() {
        // Nothing to do
    }

    override fun onForgotten() {
        coroutineScope.cancel()
    }

    override fun onAbandoned() {
        coroutineScope.cancel()
    }
}

/**
创建scope的方法,
*/
@PublishedApi
@OptIn(InternalComposeApi::class)
internal fun createCompositionCoroutineScope(
    coroutineContext: CoroutineContext,
    composer: Composer
) = if (coroutineContext[Job] != null) {
    CoroutineScope(
        Job().apply {
            completeExceptionally(
                IllegalArgumentException(
                    "CoroutineContext supplied to " +
                        "rememberCoroutineScope may not include a parent job"
                )
            )
        }
    )
} else {
    val applyContext = composer.applyCoroutineContext
    CoroutineScope(applyContext + Job(applyContext[Job]) + coroutineContext)
}

实验

同样,用实验来验证学习成果

改一下前面的例子,去掉LaunchedEffect,利用rememberCoroutineScope直接在点击事件处响应处理任务:

// ...
val counts = remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
Box {
    Button(onClick = { counts.value++ }, modifier = Modifier.padding(16.dp)) {
        Log.d("effect-test", "button compose [${counts.value}] start")
        Text(text = "Hello $name! ${counts.value}")
        Log.d("effect-test", "button compose [${counts.value}] end")
        // 启动一个日志打印任务
        scope.launch {
            Log.d("effect-test", "inner scope called: ${counts.value}")
        }
    }
}

Text(text = "multi: ${counts.value * counts.value}", modifier = Modifier.padding(start = 16.dp, top = 60.dp))
Log.d("effect-test", "labeled done")
// ...

结果:

2022-01-19 00:46:54.641 4928-4928/com.jacee.example.composer D/effect-test: button compose [0] start
2022-01-19 00:46:54.643 4928-4928/com.jacee.example.composer D/effect-test: button compose [0] end
2022-01-19 00:46:54.646 4928-4928/com.jacee.example.composer D/effect-test: labeled done
2022-01-19 00:46:54.708 4928-4928/com.jacee.example.composer D/effect-test: inner scope called: 0
2022-01-19 00:46:59.385 4928-4928/com.jacee.example.composer D/effect-test: button compose [1] start
2022-01-19 00:46:59.389 4928-4928/com.jacee.example.composer D/effect-test: button compose [1] end
2022-01-19 00:46:59.395 4928-4928/com.jacee.example.composer D/effect-test: labeled done
2022-01-19 00:46:59.420 4928-4928/com.jacee.example.composer D/effect-test: inner scope called: 1
2022-01-19 00:47:01.998 4928-4928/com.jacee.example.composer D/effect-test: button compose [2] start
2022-01-19 00:47:02.001 4928-4928/com.jacee.example.composer D/effect-test: button compose [2] end
2022-01-19 00:47:02.004 4928-4928/com.jacee.example.composer D/effect-test: labeled done
2022-01-19 00:47:02.016 4928-4928/com.jacee.example.composer D/effect-test: inner scope called: 2

日志上看来,确实达到了前面实验2的LaunchedEffect功能

再添回LaunchedEffect

2022-01-19 00:50:06.803 5002-5002/com.jacee.example.composer D/effect-test: button compose [0] start
2022-01-19 00:50:06.808 5002-5002/com.jacee.example.composer D/effect-test: button compose [0] end
2022-01-19 00:50:06.813 5002-5002/com.jacee.example.composer D/effect-test: labeled done
2022-01-19 00:50:06.875 5002-5002/com.jacee.example.composer D/effect-test: inner scope called: 0
2022-01-19 00:50:06.877 5002-5002/com.jacee.example.composer D/effect-test: launched-effect called: 0
2022-01-19 00:50:08.879 5002-5002/com.jacee.example.composer D/effect-test: launched-effect called: 0 done
2022-01-19 00:50:09.866 5002-5002/com.jacee.example.composer D/effect-test: button compose [1] start
2022-01-19 00:50:09.867 5002-5002/com.jacee.example.composer D/effect-test: button compose [1] end
2022-01-19 00:50:09.870 5002-5002/com.jacee.example.composer D/effect-test: labeled done
2022-01-19 00:50:09.879 5002-5002/com.jacee.example.composer D/effect-test: inner scope called: 1
2022-01-19 00:50:09.880 5002-5002/com.jacee.example.composer D/effect-test: launched-effect called: 1
2022-01-19 00:50:11.882 5002-5002/com.jacee.example.composer D/effect-test: launched-effect called: 1 done

依然没问题

小结

今天算是解答了Compose文本框一文中关于LaunchedEffect的疑问,也明白了它的使用场景及基本原理。只有搞清楚了何时用、怎么用,才能恰如其分地解决开发中的难题。好了,下篇见~