깨알 개념/Android

[Android] Coroutines Flow - Jetpack Compose에서 사용하기

interfacer_han 2024. 8. 6. 02:31

#1 개요

 

[Kotlin] Coroutines Flow - 기초

#1 Coroutines Flow#1-1 개요 FlowFlow An asynchronous data stream that sequentially emits values and completes normally or with an exception. Intermediate operators on the flow such as map, filter, take, zip, etc are functions that are applied to the upst

kenel.tistory.com

위에서 다룬 Coroutines Flow를 Jetpack Compose에 적용시켜본다. 여기서 말하는 '적용'이란, 단순한 사용이 아니라 Flow 객체를 State로서 다룬다는 의미다.
 

#2 수정할 샘플 코드

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // State 선언
        val count = mutableStateOf(1)

        // State의 값 변경
        CoroutineScope(Dispatchers.Default).launch {
            for (i in 1..5) {
                delay(1000)
                count.value = i
            }
        }

        setContent {
            Box(
                modifier = Modifier.fillMaxSize(),
            ) {
                TextExample(
                    count.value, // State Hoisting (https://kenel.tistory.com/186)
                    Modifier.align(Alignment.Center)
                )
            }
        }
    }
}

@Composable
fun TextExample(
    currentCount: Int,
    modifier: Modifier = Modifier,
) {
    Text(
        text = "Count: $currentCount",
        fontSize = 40.sp,
        modifier = modifier
    )
}

State가 쓰인 간단한 코드다. 이 앱을 실행시키면, Count: 1으로 시작해 Count: 5까지 표시하는 Text가 화면 중앙에 보일 것이다. 이제 이 코드와 같은 동작을 하는 코드를 Flow를 활용해서 짜보겠다.
 

#3 Flow로 같은 동작 구현하기

#3-1 코드

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Flow 선언
        val countFlow = flow {
            for (i in 1..5) {
                delay(2000)
                emit(i)
            }
        }

        setContent {
            Log.i("interfacer_han", "Recomposition ...")
            Box(
                modifier = Modifier.fillMaxSize(),
            ) {
                // collectAsState()
                val count = countFlow.collectAsState(initial = 1)

                TextExample(
                    count.value, // State Hoisting (https://kenel.tistory.com/186)
                    Modifier.align(Alignment.Center)
                )
            }
        }
    }
}

@Composable
fun TextExample(...) { ... }

Flow를 활용해 #2의 코드를 다시 짜면 이런 코드가 된다. Flow를 만들고, collect()를 수행한다. 이 때 Jetpack Compose와 관련된 작업에서는, 다시 말해 UI에 관련된 작업에서는 collect()가 아닌 collectAsState()를 사용한다. collectAsState()는 State 타입을 반환하는 collect()다. 단순히 반환형 뿐만 아니라, collect()된 값을 UI에 표시하기 위한 여러가지 암시적인 동작들이 정의되어있는 함수다.
 
그리고 count가 TextExample에서 참조되고 있으므로, 다시 말해 데이터형이 State인 프로퍼티가 UI에서 참조되고 있으므로, count.value의 값이 변하면 Recomposition이 암시적으로 수행될 것이다 (이 게시글의 #4 참조).
 

#3-2 remember에 대한 의문점과 설명

#3-1의 코드를 보면 이런 생각이 들 수 있다. Recomposition이 일어난다는 것은 setContent { ... } 안에 위치한 count도 초기화된다는 말이 아닌가? 즉, countFlow.collectAsState(initial = 1)를 remember { ... }로 감싸 Recomposition에 대한 방어막을 쳐두지 않으면 count 초기화, Recomposition, count 초기화, Recomposition, count 초기화, ...의 무한 루프가 일어나는 게 아닌가?

하지만 아니다. 왜냐하면 collectAsState()는 State object 그 자체가 아니라, State object를 반환하는 Getter 함수이기 때문이다. State object가 Get되는 원본 데이터는 setContent { ... } 밖에 있는 countFlow의 인스턴스에 위치한다. 그래서 remember { ... }로 감싸지 않는 것이다. 이해가 가지 않는다면 State object의 Remembering에 다룬 이 게시글을 참조하자.
 
그렇다면 만약 countFlow 프로퍼티가 setContent { ... } 블록 내부에 위치한다면,
 

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // Flow 객체
            val countFlow = remember {
                flow {
                    for (i in 1..5) {
                        delay(2000)
                        emit(i)
                    }
                }
            }

            Box(
                modifier = Modifier.fillMaxSize(),
            ) {
                // collectAsState()
                val count = countFlow.collectAsState(initial = 1)

                TextExample(
                    count.value, // State Hoisting (https://kenel.tistory.com/186)
                    Modifier.align(Alignment.Center)
                )
            }
        }
    }
}

@Composable
fun TextExample(...) { ... }

이렇게 countFlow 프로퍼티에 대입되는 Flow 인스턴스를 remember { ... }로 감싸주어야 한다. 그렇지 않으면 무한 루프에 빠지고 만다. 물론 이 경우에도 collectAsState()는 그저 Getter로서의 역할을 수행할 뿐이다.
 

#3-3 설명에 대한 증거

#3-2의 코드에서 만약 Flow 인스턴스를 remember { ... }로 감싸지 는다면? 이렇게 무한 루프가 발생한다.
 

반면, remember { ... }로 감싸면, Count: 1으로 시작해 Count: 5까지 표시하는 Text가 의도한 대로 잘 표시된다.
 

#4 collectAsState()는 동기 코드다

// collect()
public abstract suspend fun collect(
    collector: FlowCollector<T>
): Unit

// collectAsState()
@Composable
public fun <T : R, R> Flow<T>.collectAsState(
    initial: R,
    context: CoroutineContext = EmptyCoroutineContext
): State<R>

collectAsState()에는 한 가지 더 짚고 넘어가야 하는 것이 있다. 바로 collect()가 비동기 코드로서 suspend 키워드가 붙어있는 반면, collectAsState()는 동기 코드고 따라서 suspend 키워드도 보이지 않는다는 점이다. 당연하겠지만, collectAsState() 동작은 비동기적이지 않을 수 없다. 그리고 실제로도 내부적인 동작은 비동기적으로 작동한다. 하지만 으로는 State를 반환하는 평범한 함수의 모양이다. 결론적으로 collectAsState()는 동기 코드인 척을하고 있다는 말이다. 그래야 Compose 런타임이 수행하는 Recomposition의 기제(UI 표시)에 스며들어 프로그래머가 쉽게 다룰 수 있게 되기 때문이다.
 
쉬운 사용성 외에도 collectAsState()가 동기 코드인 척을 해야하는 이유는 바로 초깃값의 존재다. 이 초깃값은 collectAsState() 내부에서 flow의 emit()을 기다리(비동기 코드의 정의는 '기다림'의 존재다)는 동안, 사용자에게 표시할 기본값의 역할을 수행한다. 이렇게 데이터가 collectAsState()로 전달되는동안, UI가 빈 화면으로 표시되는 것을 예방한다. 데이터가 오면 오는 대로 받아 Recomposition을 수행하면 되고, 데이터가 오기 전까지는 초깃값을 즉시 반환하므로 collectAsState()는 겉으로 봐서는 동기 코드인 것이다.
 

#5 ViewModel에서 Flow 사용하기

#5-1 이전 게시글

 

[Android] Jetpack Compose - ViewModel에서 State 사용하기

#1 개요Jetpack Compose에서 ViewModel을 사용해본다. Jetpack Compose를 사용하지 않는 전통적인 방식에서의 ViewModel과 크게 다를 게 없다. Jetpack Compose에 ViewModel을 구현함으로써 State Hoisting 패턴을 극대화시

kenel.tistory.com

위 게시글에 기반해 #3-1의 코드와 똑같은 동작을 하는 코드를 ViewModel을 이용해 작성해본다.
 

#5-2 모듈 수준 build.gradle 수정

plugins {
    ...
}

android {
    ...
}

dependencies {

    ...

    // ViewModel (Compose)
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
}

 

#5-3 코드 - MainViewModel

// package com.example.collectasstate

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow

class MainViewModel : ViewModel() {
    val numberFlow = flow {
        for (i in 1..5) {
            delay(1000)
            emit(i)
        }
    }
}

 

#5-4 코드 - MainActivity

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val viewModel = viewModel<MainViewModel>()

            Box(
                modifier = Modifier.fillMaxSize(),
            ) {
                // collectAsState()
                val count = viewModel.countFlow.collectAsState(initial = 1)

                TextExample(
                    count.value, // State Hoisting (https://kenel.tistory.com/186)
                    Modifier.align(Alignment.Center)
                )
            }
        }
    }
}

@Composable
fun TextExample(...) { ... }

 

#6 작동 확인

#6-1 Log를 추가한 코드

...

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Flow 선언
        val countFlow = flow {
            for (i in 1..5) {
                delay(2000)

                Log.i("interfacer_han", "---\nemit(): $i")
                emit(i)
            }
        }

        setContent {
            Log.i("interfacer_han", "Recomposition ...")

            Box(
                modifier = Modifier.fillMaxSize(),
            ) {
                // collectAsState()
                val count = countFlow.collectAsState(initial = 1)
                Log.i("interfacer_han", "Collected value: ${count.value}")

                TextExample(
                    count.value, // State Hoisting (https://kenel.tistory.com/186)
                    Modifier.align(Alignment.Center)
                )
            }
        }
    }
}

@Composable
fun TextExample(...) { ... }

작동 확인을 위해 #3-1의 코드에 Log를 추가했다.
 

#6-2 스크린샷

잘 작동한다.
 

#6-3 로그 메시지

Recomposition ...
Collected value: 1
---
emit(): 1
---
emit(): 2
Recomposition ...
Collected value: 2
---
emit(): 3
Recomposition ...
Collected value: 3
---
emit(): 4
Recomposition ...
Collected value: 4
---
emit(): 5
Recomposition ...
Collected value: 5

flow가 1을 emit()했을 때 Recomposition이 일어나지 않은 이유는 State.value가 이미 1을 가지고 있었기 때문이다. 즉, State.value가 변하지 않았기 때문이다. Compose 런타임은 State형 프로퍼티의 값을 기억한다. 그래서 발생된 의도적 누락이다.
 

#7 요약

collectAsState()는 동기코드인 척을 하는 Getter 함수다.
 

#8 완성된 앱

 

android-practice/jetpack-compose/CollectAsState at master · Kanmanemone/android-practice

Contribute to Kanmanemone/android-practice development by creating an account on GitHub.

github.com

깃허브에 올린 안드로이드 프로젝트는 ViewModel을 활용한 #5의 코드로 작성했다.