SideEffect
SideEffect란, Composable 함수 외부에서 발생하는 앱 상태의 변화를 의미한다. Compose에서 SideEffect는 지양하고 있다. 왜냐하면, 외부에서 Composable 함수의 생명주기를 알 수 없으며, Recomposition에 대한 속성 떄문이다.
간단한 예시를 통해 SideEffect를 알아보자 !
class MainActivity : ComponentActivity() {
private var i = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
var text by remember {
mutableStateOf("")
}
MyApplicationTheme {
Button(
onClick = { text += "N" }
) {
i++ // !! Side Effect !!
Text(text)
}
}
}
}
}
Button은 text State를 통해, content를 표현하고, 버튼이 클릭될 때마다 text state에 'N'이 추가하는 로직을 가지고 있다.
Text의 State가 변경되어 Recomposition이 발생하며, Recomposition이 발생할 때마다 Button의 Text가 변경되고, i를 증가시키는 로직이 수행된다.
Text State가 Recomposition 되면서, 외부 State인 i도 함께 변화하게 되는 상황이 발생했다 !
해당 예시는 임의로 만든 예시이고, "누가 이렇게 코드를 작성해 ???" 라고 말할 수 있다.
하지만, 우리가 사용할 수 밖에 없는 예시가 있다. 바로 ViewModel의 Flow를 수집하는 것이 가장 일반적으로 발생하는 SideEffect이다.
이런 경우, 어떻게 처리해야 할까? 다음에서 소개할 Side Effect Handler를 통해 해결할 수 있다.
LaunchedEffect
LaunchedEffect를 알아보기 전, 다음의 예시를 소개한다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
var text by remember {
mutableStateOf("")
}
MyApplicationTheme {
LaunchedEffect(key1 = text) { // !! Coroutine Scope
delay(1000L) // !! 텍스트가 변경될 때마다, 취소되고 재개된다.
println("the text is $text") // !! 그럼 언제 출력될까?
}
}
}
}
}
LaunchedEffect를 사용하면 위에서 소개한 SideEffect 코드를 다음과 같이 수정할 수 있다.
LaunchedEffect는 key를 매개변수로 받고 있다. key는 모든 타입의 상위 타입인 Any? 타입으로, key가 변경될 때 마다, 해당 LaunchedEffect가 취소되고 재개된다.
위의 코드에서는 text의 상태가 변경될 때마다 취소되고 재개되며, 재개한 1초 동안 바뀌지 않으면, text가 출력되는 로직이다.
실제 사용하는 예시를 통해 자세히 알아보자 !
LaunchedEffectViewModel.kt
class LaunchedEffectViewModel: ViewModel() {
private val _sharedFlow = MutableSharedFlow<ScreenEvents>()
val sharedFlow = _sharedFlow.asSharedFlow()
init {
viewModelScope.launch {
_sharedFlow.emit(ScreenEvents.ShowSnackbar("네트워크 연결이 불안정합니다."))
}
}
sealed class ScreenEvents {
data class ShowSnackbar(val message: String): ScreenEvents()
data class Navigate(val route: String): ScreenEvents()
}
}
위의 ViewModel은 우리가 흔하게 일회성 이벤트를 발행하기 위해 작성하는 코드이다. 만약, Composable 함수에서 바로 Flow를 collect() 한다면, 그것은 위에서 설명한 SideEffect이다.
Composable 함수에서 위의 sharedFlow를 안전하게 수집하려면 다음과 같은 코드를 작성할 수 있다.
@Composable
fun LaunchedEffectFlowExample(
viewModel: LaunchedEffectViewModel
) {
LaunchedEffect(key1 = true) { // !! true는 단 한번만 호출
viewModel.sharedFlow.collect { event ->
when(event) {
is LaunchedEffectViewModel.ScreenEvents.Navigate -> { }
is LaunchedEffectViewModel.ScreenEvents.ShowSnackbar -> { }
}
}
}
}
LaunchedEffect를 활용하면, 처음 composable 함수가 그려지거나 key가 변경될 때만 호출되며, recompose로 인해 취소되거나 재개되는 일 없이, Event를 수집할 수 있다.
key를 true로 설정하면, 처음 composable 함수가 그려질 때만 호출된다.
그럼 key가 변하는 상황은 어떤게 있을까? 보통 Animation에서 자주 사용된다.
@Composable
fun LaunchedEffectAnimation(
counter: Int
) {
val animatable = remember {
Animatable(0f)
}
LaunchedEffect(key1 = counter) {
animatable.animateTo(counter.toFloat())
}
}
위의 코드는 현재 값을 이용해 Animation을 보여주는 코드이다.
count가 새로 변경될 때마다 진행하고 있는 것을 취소하고, 새로운 Anmiation을 시작해야 하는 경우이기 때문에 적절한 상황이라고 볼수 있다.
RememberCoroutineScope
위에서 설명한 LaunchedEffect는 composable 함수로 또 다른 composable scope에서만 사용이 가능하다. 만약 non-composable scope에서 사용하려면 어떻게 해야할까?
이때 우리는 rememberCoroutineScope를 사용할 수 있다.
@Composable
fun RememberCoroutineScopeExample() {
val scope = rememberCoroutineScope()
Button(
onClick = { // !! Not Composition, Yes Callback
scope.launch {
delay(1000L)
println("")
}
}
) { }
}
위와 같이 Button의 callback scope에서 SideEffect를 처리할 수 있다.
rememberCoroutineScope는 생성된 composable 함수의 LifeCycle에 종속되며, 해당 scope를 통해 다른 suspend Call이나 network call을 안전하게 수행할 수 있다.
위에서 설명할 때 rememberCoroutineScope는 non-composable scope에서 사용된다고 하였는데, composable 함수에서도 사용하면 안될까?
그렇게 구성하면, LaunchedEffect나 non-composable에서만 사용하라고 경고가 발생한다.
왜냐하면, 매 recompose 마다 새로운 코루틴 스코프가 생성되고, 수행되기 때문이다. 이는 결국 위에서 설명한 SideEffect이다.
하지만, 보통 non-composable scope에서 SideEffect를 처리하는 경우는 드물다. 왜냐하면, ViewModel에서 viewModelScope를 통해 처리하기 때문이다.
필자의 경우, activity Context를 필요로 하는 외부 sdk를 사용하기 위해, 해당 rememberCoroutineScope를 사용하였다.
RememberUpdatedState
RememberUpdatedState를 설명하기 위해 하나의 예시를 통해 설명하겠다.
우리는 Compose로 Splash 화면을 구현하려고 한다. 그럼 다음과 같은 코드를 통해 구성할 수 있다.
@Composable
fun RememberUpdatedStateExample(
onTimeout: () -> Unit,
) {
LaunchedEffect(true) {
delay(3000L)
onTimeout()
}
}
해당 코드는 LaunchedEffect로 처음 구성될 때, 3초 딜레이 후 onTimeout 콜백을 호출하는 코드로 구성되어 있다.
해당 코드에서 문제가 있을까?
문제가 되는 상황은 3초동안 onTimeout()이 recompose 때문에 변경되는 경우가 해당된다. 매개변수인 onTimeout()과 LaunchedEffect 내의 onTimeout()이 달라, 호출되지 않는다.
그럼 어떻게 해야할까? LaunchedEffect의 key로 onTimeout()을 넣어야 할까?
만약 그렇게 한다면, Splash 화면에서 onTimeout이 무한대로 recompose 되면, 끝나지 않는 Splash 화면을 만날 수 있을 것이다.
이런 경우 우리는 RememberUpdatedState를 활용할 수 있다.
@Composable
fun RememberUpdatedStateExample(
onTimeout: () -> Unit,
) {
val updatedOnTimeout by rememberUpdatedState(newValue = onTimeout)
LaunchedEffect(true) {
delay(3000L)
updatedOnTimeout()
}
}
해당 Composable 함수를 통해, wrapping하면 해당 onTimeout이 변경되더라도, 다시 LauncehdEffect가 취소되지 않고, onTimeout이 업데이트 된 채로 수행할 수 있다.
애초의 목적이였던, 정확히 3초후 다른 화면으로 넘어가는 상황을 구현할 수 있는 것이다.
Disposable Effect
컴포저블 함수에서, 액티비티의 라이프 사이클 이벤트를 가로채기 위해 다음과 같은 코드를 작성할 수 있다.
@Composable
fun DisposableEffectExample() {
val lifecycleOwner = LocalLifecycleOwner.current
val observer = LifecycleEventObserver { _, event ->
if(event == Lifecycle.Event.ON_PAUSE) {
println("On pause called")
}
}
lifecycleOwner.lifecycle.addObserver(observer)
}
해당 코드는 일시중지 상태일 때, 해당 상태를 출력하는 것을 나타낸 코드이다.
해당 코드에서 문제가 뭘까?
문제는 옵저버를 생성한 것이다. 매 리컴포지션마다 생성되며, 우리가 원하지 않는 경우에도 생성될 수 있다.
그럼 LaunchedEffect에 넣으면 되겠지,, 라고 생각하면 오산이다.
해당 옵저버는 사용이 끝난 후에 Dispose, 처분되어야 하기 때문이다. (리소스 해제)
이런 경우 우리는 disposable effect를 사용할 수 있다.
@Composable
fun DisposableEffectExample() {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(true) {
val observer = LifecycleEventObserver { _, event ->
if(event == Lifecycle.Event.ON_PAUSE) {
println("On pause called")
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
LaunchedEffect와 유사하나, onDispose { }을 포함해야 한다.
해당 onDispose {} 은 해당 DisposableEffect가 재시작을 위해 취소되거나, 새로 화면이 그려질 때 호출된다.
이렇게 구성하면, 메모리 누수 없이 사용할 수 있다.
참고 자료
해당 예제는 다음과 같은 영상을 기반으로 작성하였습니다.
https://www.youtube.com/watch?v=gxWcfz3V2QE&t=176s
이외 참고 자료
https://developer.android.com/jetpack/compose/side-effects?hl=ko#rememberupdatedstate
https://medium.com/@mortitech/exploring-side-effects-in-compose-f2e8a8da946b