본문으로 바로가기
반응형

Photo by unsplash

side-effectCompose function 외부에서 발생하는 앱 상태의 변화를 말합니다. 이전에 언급했듯이 Composable function은 side-effect에 free 해야 합니다. 다르게 말하면 compose를 변화시키기 위해서는 변경된 param을 통해서 재호출하는 형태만 존재해야 하며, compose 함수 내부에서 외부에 있는 변수나, 동작에 영향을 주도록 구성되어서는 안 됩니다. (이는 compose의 lifecycle에 의해서 recompose가 수시로 발생할 수 있고, 여러 thread에서 호출될 수 있기 때문입니다.)

하지만 Compose 내부의 동작으로 인하여 외부의 상태가 바뀌어야 하는 경우가 발생할 수 있기 때문에 이때는 Compose의 lifecycle을 인식하고, 이에 맞게 동작하는 형태로 구현 되어야 합니다.

따라서 이런 지원을 하기 위해서 compostion이 완료될때 side-effect을 처리하는 composable function을 지원합니다. 이러한 composable function을 effect라는 단어로 정의하며, effect란, UI를 방출하지 않는 composable function입니다.

반응형 UI는 기본적으로 비동기로 처리됩니다. Compose는 이런 처리를 위하여 callback을 사용하지 않고 API level에서 corouitnes으로 감싸서 처리합니다.

Compose는 lifecycle과 여러 특성에 따라서 API레벨에서 여러 effects들을 제공합니다.

이 글은 Android developer 공식 사이트에서 제공하는 문서를 기반으로 의역, 번역 하였습니다.[1]

LaunchedEffect

Composable 함수 중에 suspend function이 존재합니다. 예를 들어 이전에 LazyColumn에서 scroll 하기 위한 함수였던 scrollToItem() 은 suspend function입니다. [2] compose에서 scrolling에 대한 rendering에 방해를 주지 않도록 별개로 동작해야 하기 때문에 suspend function으로 되어 있고, coroutine scope에서 호출하도록 강제해 놓았습니다.

이러한 suspend function을 Compose내에서 호출하기 위해서 LaunchedEffect API를 제공합니다. 

  • 기본 동작
실행시점 종료시점 재실행 Tigger 조건
Compostion enter Composition leave block param의 변경

LaunchedEffect로 생성된 coroutine scopde은 위와 같이 기본적으로 해당 Composable의 생성 주기에 따릅니다. 즉 Compose 생성 시 launch 되고, Compose가 화면에서 사라지면 같이 cancel 됩니다. 즉 coroutine의 structured concurrnecy가 Composable의 lifecyle에 같이 묶입니다.

아래 예제는 ScaffoldSnakbarHostState.showSnackbar 함수를 이용하여 snackbar를 띄우는 코드입니다. (여기서 showSnackbarsuspend function입니다.)

먼저 화면 구성은 상태 관리 포스팅에서 사용했던 HelloContent를 재사용합니다. [3]

먼저 ViewModel에서는 text를 입력을 제공받아 상단의 TextView의 문구를 바꿉니다.

@HiltViewModel
class MainViewModel @Inject constructor(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    private val _error = MutableLiveData(false)
    val error: LiveData<Boolean> = _error

    fun onNameChange(newName: String) {
        _name.value = newName
        _error.value = newName == "error"
    }
}

만약 "error"이라는 문구가 입력되면 error 변수의 값이 true로 바뀌고 이외의 값에서는 false로 처리됩니다.

이해를 돕기 위해 화면에 대한 HolleContent는 아래와 같이 구성됩니다.

@Composable
fun HelloScreen(mainViewModel: MainViewModel = viewModel()) {
    val name: String by mainViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { mainViewModel.onNameChange(it) })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

그리고 이 HelloScreen을 호출하고 Snackbar를 그리는 부분은 아래와 같습니다.

@Composable
fun MyScreen(scaffoldState: ScaffoldState = rememberScaffoldState(),
             mainViewModel: MainViewModel = viewModel()) {
    Log.e("composeTest", "MyScreen called")

    val isError by mainViewModel.error.observeAsState()

    if (isError == true) {
        LaunchedEffect(isError) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `isError` is false, and only start when `isError` is true            
            try {
                Log.e("composeTest", "show snackbar")
                scaffoldState.snackbarHostState.showSnackbar(
                    message = "Error message",
                    actionLabel = "Retry message"
                )
            } catch (ce: CancellationException) {
                Log.e("composeTest", "canceled!!")
            }
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        HelloScreen()
    }
}

위와 같이 함수를 구성하면 isError가 true일 때 if문 안으로 들어와 LaunchedEffect가 수행되며 LaunchedEffect는 기본적으로 param으로 넘겨진 isError가 변경되면 기존 coroutine은 취소되고 재시작됩니다.

snackBar의 경우 cancel 되면 dismiss 되도록 되어 있습니다. 

- case 1: 아무 문구나 입력한 경우

처음 화면이 보일 때 isError의 값은 false이니 snackBar는 표시되지 않습니다. 물론 아무 문구나 입력 시 if (isError == true) 문구 안으로 들어올 수 없기 때문에 LaunchedEffect는 수행되지 않습니다.

- case 2: "error" 문구를 입력한 경우

입력과 동시에 isError은 true로 변경되면서 if문 안으로 진입합니다. 따라서 LaunchedEffect가 호출되며 isError가 변경되었으므로 snackbar가 표시됩니다.

이때 재빠르게 문구를 바꾼다면 isError의 값이 false로 변경되고 if문에 의해서 snackBar는 Compose에서 사라져야 합니다. 따라서  LaunchedEffect 역시 취소되면서 화면에서 제거됩니다. 물론 이때는 CancellationException이 발생하게 되므로 "canceled" 로그가 출력됩니다.

LaunchedEffect는 Composable의 enter 시점에 생성 / leave 시점에 취소되며, 추가적으로 param으로 넘어간 값이 변경되는 경우에도 취소되고 새로운 coroutine이 재시작됩니다.

rememberCoroutineScope

LaunchedScope은 composable function으로 composable function 안에서만 호출이 가능하기 때문에 이외의 부분에서 coroutine scope을 얻기 위해서는 rememberCoroutineScope을 사용해야 합니다. rememberCoroutineScope은 선언한 부분 (call site)의 lifecycle에 따라 시작/취소됩니다.

  • 기본 동작
실행시점 종료시점 재실행 Tigger 조건
Compostion enter Composition leave 없음

Button을 클릭 시 snackbar를 띄우려면 onClick = {..} 내부에서 snackBar를 show 해야 합니다. 이는 composable function의 내부는 아니기 때문에 rememberCoroutineScope으로 coroutine scope을 생성하여 호출합니다.

@Composable
fun MyScreen(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    mainViewModel: MainViewModel = viewModel()
) {
   ...
    Scaffold(scaffoldState = scaffoldState) {
        Column(modifier = Modifier.fillMaxWidth().padding(10.dp)) {
            HelloScreen()
            // 버튼 추가
            Box(modifier = Modifier.align(Alignment.CenterHorizontally)) {
                ShowSnackBarButton(scaffoldState)
            }
        }
    }
}

@Composable
fun ShowSnackBarButton(scaffoldState: ScaffoldState = rememberScaffoldState()) {
...
}

먼저 Button을 추가하기 위해 Box로 화면 중간 위치를 지정해 줍니다. 그리고 실제 Button을 생성하고 onClick시 SnackBar를 띄우도록 합니다.

@Composable
fun ShowSnackBarButton(scaffoldState: ScaffoldState = rememberScaffoldState()) {
    // ShowSnackBarButton's lifecycle과 함꼐하는 CoroutineScope을 생성
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                scaffoldState.snackbarHostState
                    .showSnackbar("Something happened!")
            }
        }
    ) {
        Text("Show SnackBar")
    }
}

rememberUpdatedState

LaunchedEffect의 내부 block에서 접근해야 하는 하는 변수는 Key(param)로 넘겨져야 합니다. LaunchedEffect의 param은 변경이 발생하면 해당 coroutine을 취소하고 재시작합니다. 만약 LaunchedEffect의 block 내부에서 접근은 해야 하나, 변경되더라도 LaunchedEffect를 재시작시키지 않도록 하기 위해서는 해당 값을 rememberUpdatedState로 한번 wrapping 해야 합니다.

rememberUpdatedState는 해당 값의 reference를 만들며, 실제의 값을 capture 하고 수정이 되면 수정된 값이 capture 됩니다.

이 API는 effect가 오래 기다려야 하는 작업을 유지해야 하거나, 무거운 연산이나, recomposition에 의해서 restart 되지 않도록 할 때 유용합니다. 예를 들어 특정 시간을 대기한 이후에 어떠한 작업을 수행해야 한다면 아래와 같이 코드를 구현할 수 있습니다.

@Composable
fun ShowLazySnackBar(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    timeoutText: String
) {
    Log.d("composeTest", "ShowLazySnackBarButton()")
    // timeoutText은 LaunchedEffect내부에서 접근하나, 재시작 trigger 요건이 아님.
    val currentTimeoutText by rememberUpdatedState(timeoutText)

    // ShowLazySnackBar의 lifecycle이 동일
    // key가 true 이므로 recompose시에 재시작되지 않음. timeoutText은 바뀌어도
    // LaunchedEffect는 재시작 되지 않음.
    LaunchedEffect(true) {
        Log.i("composeTest", "ShowLazySnackBarButton() - timeout started!")
        try {
            delay(3000L)
            scaffoldState.snackbarHostState.showSnackbar(currentTimeoutText)
        } catch (ce: CancellationException) {
            Log.d("composeTest", "ShowLazySnackBarButton() - canceled!")
        }
    }
}

timeoutText가 변하더라도 LaunchedEffect는 재시작하지 않고 3초 이후에 snackbar를 띄웁니다. 따라서 로그 역시 아래와 같이 찍힙니다.

ShowLazySnackBarButton()
ShowLazySnackBarButton() - timeout started!

실제로 테스트를 위해서 아래와 같이 코드를 추가 구성해 보겠습니다.

먼저 ViewModel에 timeoutText의 상태(값)를 갖도록 MutableStateFlow와 이 값을 변경하는 changeTimeoutText()를 추가합니다.

@HiltViewModel
class MainViewModel @Inject constructor(private val savedStateHandle: SavedStateHandle) : ViewModel() {
  ...

    private val _timeoutText = MutableStateFlow("Timeout!!")
    val timeoutText: StateFlow<String> = _timeoutText
  ...

    suspend fun changeTimeoutText(timeoutText:String) {
        withContext(Dispatchers.Main) {
            _timeoutText.emit(timeoutText)
        }
    }
}

추가적으로 화면에 timeoutText를 변경하는 버튼을 추가합니다.

@Composable
fun MyScreen(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    mainViewModel: MainViewModel = viewModel()
) {
   ...
    Scaffold(scaffoldState = scaffoldState) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(10.dp)
        ) {
            val onTimeout by mainViewModel.timeoutText.collectAsState()

            HelloScreen()
             Box {
                Column(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.spacedBy(4.dp)) {
                    ShowSnackBarButton(scaffoldState)
                    StartTimeoutButton()
                    ShowLazySnackBar(scaffoldState, onTimeout)
                }
            }
        }
    }
}

// 문구 변경을 위한 버튼
@Composable
fun StartTimeoutButton(mainViewModel: MainViewModel = viewModel()) {
    val scope = rememberCoroutineScope()
    Button(onClick = {
        scope.launch {
            mainViewModel.changeTimeoutText("Timeout changed by button")
        }
    }) {
        Text("Change Timeout Text!!")
    }
}

@Composable
fun ShowLazySnackBar(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    timeoutText: String
) {
  ...
}

앱을 구동후 3초 후에 위 화면과 같은 Snackbar가 출력됩니다. 만약 Snackbar가 출력되기 전에 "Change Timeout Text!!" 버튼을 클릭하면 문구가 변경되면서 다른 문구가 표시됩니다..

즉, ShowLazySnackBar()은 recompose 되지만 LaunchedEffect의 param은 true이므로 재시작되지 않으며, LaunchedEffect block 에서 사용하는 error 문구는 변경된 업데이트된 새로운 text로 치환됩니다. param에 true 또는 Unit을 넣는다면, 해당 coroutine을 compose가 leave 될 때까지 유지시키기 위함이나 이는 while(true) 구문처럼 사용하는 것과 유사하기 때문에 명확하게 필요한 경우에 사용해야 합니다.

로그는 아래와 같이 출력됩니다.

ShowLazySnackBarButton()
ShowLazySnackBarButton() - timeout started!
ShowLazySnackBarButton()  // 버튼 클릭 시 recompose -> "timeout started!"는 다시 호출되지 않음.

만약 rememberUpdatedState 없이 아래와 같이 바로 block 내부에서 timeoutText를 사용한다면, 버튼을 누르더라도 변경된 문구가 아닌, launch 시점의 문구로 고정됩니다. (lambda capturing으로 final 형태로 복사되어 들어가게 되므로.)

@Composable
fun ShowLazySnackBar(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    timeoutText: String
) {
   ...
    LaunchedEffect(true) {
        Log.i("composeTest", "ShowLazySnackBarButton() - timeout started!")
        try {
            delay(3000L)
//          scaffoldState.snackbarHostState.showSnackbar(currentTimeoutText)
            scaffoldState.snackbarHostState.showSnackbar(timeoutText)
        } catch (ce: CancellationException) {
            Log.d("composeTest", "ShowLazySnackBarButton() - canceled!")
        }
    }
}

DisposableEffect

만약 side effects를 처리 도중 재시작되거나 (param 이 변경되거나), compose가 leave 상태가 되는 경우 (화면에서 사라지는 경우) 특정 resource에 대한 해제가 필요할 때 DisposableEffect를 사용합니다.

  • 기본 동작
실행시점 종료시점 재실행 Tigger 조건
Compostion enter Composition leave block param의 변경

LaunchedEffect와 동일하나, 재시작으로 인한 취소나 Compose leave로 인한 종료 시 onDispose {..} 구문이 항상 호출됩니다.

Activity가 아닌 곳에서 Back key의 동작을 정의하기 위해서는 OnBackPressedCallback을 구현하고 이를 OnBackPressedDispatcher에 add 합니다. 동작이 완료되면 다시 callback의 remove()를 호출하여 동작을 제거할 수 있습니다.

즉 Listener를 Compose의 lifecycle에 맞춰 뗐다 붙였다 할 때 사용할 수 있습니다.

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {

    // back key 클릭시 처리할 최신 동작을 저장 (onBack이 여러번 변경되어 호출되는 경우 대비)
    val currentOnBack by rememberUpdatedState(onBack)

    // OnBackPressedCallback을 구현하여 remember로 저장
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for
        // a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // `backDispatcher` 변경시 기존 콜백 remove후 재시작
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

위와 같이 구현하면 backDispatcher가 변경 시 기존 Dispatcher에 등록된 onBackPressedCallback을 제거하고 새로운 Dispatcher에 callback을 다시 등록하게 됩니다.

실제 동작인 onBack param은 remeberUpdatedState로 처리되었으니 변경되는 경우 새로운 값이 동작 하나, DisposableEffect를 재시작시키지는 않습니다.

DisposableEffect는 반드시 onDispose 구문을 구현해야 하며, 미구현시 Compile error가 발생합니다. 만약 onDispose의 block을 비운채로 구현하는 형태의 코드가 생긴다면 이는 다른 effect로 교체하는 게 좋습니다.

SideEffect

Compose에서 관리되는 객체가 아닌 다른 객체에 compose의 상태를 공유하기 위한 용도로, Composition이 성공적으로 완료되면 진행할 동작을 예약할 때 SideEffect를 사용합니다. Compose가 관리하지 않는 객체의 속성을 Compose 내부에서 무조건 변경하게 한다면 Composition 실패 시에도 해당 값이 적용되므로 일관성이 깨질 수 있습니다.

다만 이 함수는 recompose 마다 호출되므로 효율적인 사용을 위해서 특정 시점에만 실행되어야 하는 경우에는 LaunchedEffect를 필요에 따라 자원 해제가 필요한 경우에는 DisposableEffect로 대체할 수 있습니다.

  • 기본 동작
실행시점 종료시점 재실행 Tigger 조건
Compostion complete successfully  NA recompose

 

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher,
                isEnable: Boolean = false,
                onBack: () -> Unit) {

  ..
    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
       ..
    }

    SideEffect {
        backCallback.isEnabled = isEnable
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
     ..
    }
}

SideEffect의 Block은 coroutine scope이 아닙니다. 따라서 recomposition 된다고 기존 실행이 취소되지 않습니다.

produceState

produceState는 return값으로 State <T>를 반환할 수 있습니다. 따라서 일반적인 값을 Compose의 상태로 만들어 반환할 수 있습니다.

예를 들어 5. 상태 관리 포스팅에서 사용했던 예제 중에 아래와 같은 loadNetworkImage(url) 같은 함수가 있었습니다. [5]

@Composable
fun MovieOverview(movie: Movie) {
    Column {     
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)
    }
}

이 함수에서 반환하는 image는 State <Image>로써 아래와 같이 구현할 수 있습니다.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {

    // 초기값으로 Result.Loading을 세팅하고, url, imageRepository가 변경되면
    // 실행중인 producer(coroutine)이 취소되고 재시작됨
    return produceState(initialValue = Result.Loading, url, imageRepository) {

        // coroutine scope 내부이므로 suspend function을 호출할 수 있습니다.
        val image = imageRepository.load(url)

        // 진행 결과에 따라 value에 값을 세팅하여 emit 합니다.
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

produceState는 마치 coroutine에서 channel을 만드는 produce coroutine builder처럼 동작합니만 실제로 구현부를 보면 아래와 같습니다.

@Composable
fun <T> produceState(
    initialValue: T,
    key1: Any?,
    key2: Any?,
    @BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(key1, key2) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

실제 내부에서는 LaunchedEffect를 사용하기 때문에 lifecycle 역시 LaunchedEffect 동일하게 compose enter시 launch 되고, leave 상태가 되면 cancel 됩니다.

  • 기본 동작
실행시점 종료시점 재실행 Tigger 조건
Compostion enter Composition leave block param의 변경

추가적으로 반환되는 State는 conflates 합니다. 즉. 동일한 값이 출력되는 경우 state가 trigger 되지 않습니다.

block 내부는 coroutine scope이지만 suspending source가 아닌 data도 observing 할 수 있으며, observing을 중지하려면 내부적으로 awaitDispose()를 사용합니다. 실제로 LiveData나 flow를 State로 변환하는 api들을 열어보면 아래와 같습니다.

@Composable
fun <T : R, R> Flow<T>.collectAsState(
    initial: R,
    context: CoroutineContext = EmptyCoroutineContext
): State<R> = produceState(initial, this, context) {
    if (context == EmptyCoroutineContext) {
        collect { value = it }
    } else withContext(context) {
        collect { value = it }
    }
}
@Composable
fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
    val lifecycleOwner = LocalLifecycleOwner.current
    val state = remember { mutableStateOf(initial) }
    DisposableEffect(this, lifecycleOwner) {
        val observer = Observer<T> { state.value = it }
        observe(lifecycleOwner, observer)
        onDispose { removeObserver(observer) }
    }
    return state
}

Flow를 State로 변경 시 produceState를 사용하고 있으며, LiveData의 경우 observer를 붙이고 떼는 작업을 위해 DisposableEffect를 사용하고 있습니다.

derivedStateOf

여러 개의 상태의 변경을 trigger 삼아 자신의 상태를 변경할 수 있습니다. LiveData를 사용할 때 MediatorLiveData의 기능과 유사하다고 생각하면 이해하기 매우 쉽습니다. 개인적으로는 MediatorLiveData의 Compose 버전이라고 생각됩니다.^^a

derivedStateOf는 trigger 요건으로 사용할 항목들을 param으로 지정하고, 해당 param의 항목이 변경되면 derivedStateOf로 감싸진 block을 처리한 후 상태를 변경합니다. 이 API는 설명보다는 예제로의 이해가 더 쉽습니다. 따라서 예제에 대한 설명으로 대체합니다.

@Composable
fun TodoList(
    highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")
) {
    val todoTasks = remember { mutableStateListOf<String>() }

    // high priority keyword의 계산은 todoTasks 또는 highPriorityKeywords 변경시에만 수행
    // recomposition 되더라도 해당 param의 변경이 없다면 수행되지 않음.
    val highPriorityTasks by remember(todoTasks, highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter { it.containsWord(highPriorityKeywords) }
        }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

highPriorityTasksremember로 처리되어 recompose시에도 값을 유지합니다. 다만 param으로 넘어가는 todoTasks, hightPriorityKeywords가 변경되면 derivedStateOf {..} 블록 안의 코드가 수행됩니다. recomposition때마다 수행되지 않으므로 불필요한 연산을 줄일 수 있으며, 연산으로 인하여 highPriorityTasks의 상태가 변경되더라도 해당 코드가 위치한 TodoList() Compose까지 recompose 되지 않습니다.

물론 highProorityTasks의 상태가 변경되었으므로 이 값을 읽어가는 LazyColumn 내부의 items(highPriorityTasks)는 recompose 됩니다.

snapshotFlow

snapshotFlow는 State를 Flow로 변환하기 위해 사용합니다. 즉 이전 예제들에서는 ViewModel에 정의된 Flow를 Compose에서 사용하기 위해서 Flow.collectAsState()를 호출하여 Flow -> State로 바꿨다면, 이 API는 에는 State -> Flow로 바꾸는 역할을 합니다.

Flow로 변환하여 사용하는 이유는 Flow가 지원하는 다양한 operator를 이용하여 좀 더 효율적인 코드를 구성할 수 있기 때문입니다.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

위 샘플은 LazyColumn에서 첫 번째 아이템이 스크롤로 인하여 가려지면 특정 작업을 수행하도록 합니다. State를 Flow로 변경했기 때문에 Flow에서 제공하는 여러 연산자들을 사용할 수 있습니다.

Restarting Effect

Effects 중에 LaunchedEffect , produceState, DisposableEffect는 여러 개의 param을 가질 수 있습니다. 그리고 param에 들어가는 이 key 값들이 effect를 취소하고 재시작하는 trigger 역할을 합니다.

기본적으로 이런 key들은 아래와 같은 형태를 가집니다.

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey,...) { block }

이렇게 param으로 넘어간 key들의 값이 변경되면 해당 effect는 재시작되기 때문에 필요한 key를 빼먹으면 동작에 버그가 생기고 불필요하게 많이 넣으면 불필요하게 많은 재시작이 유도되므로 효율이 떨어집니다.

따라서 key로 들어가야 하는 값들은 아래의 가이드에 따라 추가되어야 합니다.

  • mutable / immutable 값 모두 block에서 사용된다면 key로 추가 되어야 한다.
  • Effect를 재시작시키지 않아야 하지만 block에서 쓰인다면 rememberUpdateState로 wrapping 해서 써야 한다.
  • param 없이 쓰이는 remember 변수는 더 이상 상태변화가 일어나지 않으므로 key로 넘길 필요가 없다.
@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
    /* ... */
    val backCallback = remember { /* ... */ }

    DisposableEffect(backDispatcher) {
        backDispatcher.addCallback(backCallback)
        onDispose {
            backCallback.remove()
        }
    }
}

DisposableEffect에서 사용된 예제를 다시 보겠습니다.

  • backCallback은 param 없는 remember로 정의된 값이므로 변경되지 않습니다. 따라서 effect의 param으로 넘어갈 필요가 없습니다.
  • backDispatcher는 block안에서 사용되고 있으며, 변경 시 listener를 바꿔야 하므로 재시작의 trigger로 사용되어야 합니다. 따라서 key로 추가해야 합니다.

만약 backDispatcher를 key로 주지 않는다면, BackHandler가 recompose 될 때 기존 effect가 취소 없이 재시작됩니다. 즉 잘못된 backDispatcher에도 listener가 유지되기 때문에 오동작이 발생할 수 있습니다.

만약 true나 Unit으로 key를 주면 Composable의 lifecycle을 파악할 수 있습니다. 하지만 위에서 언급했듯이 while(true)와 유사한 맥락이므로 사용 시 주의를 기울여야 합니다.

References

[1] https://developer.android.com/jetpack/compose/side-effects

[2] 2021.09.05 - [개발이야기/Android] - [Compose] 2. Layout의 기본(2/2) - Lazy List, Coil, Scroll, Sticky header

[3] 2021.09.19 - [개발이야기/Android] - [Compose] 4. 상태관리 - hoisting, mutableState, remember, rememberSaveable, Parcelize, MapSaver, ListSaver

[4] 2021.09.21 - [개발이야기/Android] - [Compose] 5. LifeCycle, Recomposition의 내부 동작, Call site, @Stable

 

반응형