在《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
LaunchedEffectenters the composition it will launchblockinto 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") }
这个applyCoroutineContext是CoroutineContext类型,是供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()
}
从源码可以看到,LaunchedEffectImpl是RememberObserver接口的实现类。因此,在composition过程中,初次使用和不再使用时,它都会收到事件通知,反映到代码上就是:
- 利用传入的applyCoroutineContext构造一个scope
- 初次使用时,回调onRemembered,启动构造传入的协程任务。如果有老的任务,则先行取消之
- 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的疑问,也明白了它的使用场景及基本原理。只有搞清楚了何时用、怎么用,才能恰如其分地解决开发中的难题。好了,下篇见~