이번 글은 다음의 아티클을 참고하고 추가적인 자료 조사 후 작성하였습니다.
Jetpack Compose를 도입한다고 해서 앱의 수명 주기를 관리를 안해줘도 되는 것은 아닙니다. Composable에는 고유한 수명주기가 존재하고 있기 때문에 Stream을 state 개체로 변환해야 하는 상황이 발생하게 됩니다. Stream을 Observing 하거나 Flow 개체의 경우 collection 할 때 앱의 수명 주기를 사용해야 합니다.
그렇기에 이번 글에서는 앱의 수명 주기를 자동으로 관리하면서 Composable에서 Flow Stream을 쉽게 수정할 수 있는 몇 가지 방법을 작성해보고자 합니다.
Jetpack Compose의 수명 주기 인식
Jetpack Compose에는 고유한 수명주기가 있다고 위에서 말했었습니다. 그런데 왜 앱의 수명주기까지 고려해줘야 하는 걸까 할 수 있습니다. Composable에 UI 렌더링하고 상태를 유지하기 위해 자체 수명 주기가 필요로 해서 Compose가 고유의 수명주기를 가지고 있지만 앱이 백그라운드로 이동하거나 스트림을 collect 하는 동안 중지하는 경우를 처리해줘야하기 때문에 앱의 수명주기를 고려해줘야 합니다.
Jetpack Compose에서는 Stream의 항목을 직접 사용하지 않고 상태로 변환하는 경우가 굉장히 많습니다. 앱이 존재하지 않는 동안 잠재적으로 트리거 될 수 있는 Stream을 계속 수신하거나 적절하게 처리되지 않고 앱 수명 주기 동안 남아 있기 때문에 이러한 경우를 처리하는 방법을 코드에 적용해주어야 합니다.
Jetpack Compose에서 flow를 collect 하려면 다음과 같이 사용할 수 있습니다.
class ExampleViewModel : koinViewModel() {
// Koin을 통해 의존성 주입하는 방법
private val watchAllExampleEntitiesUseCase by inject<WarchAllExampleEntitiesUseCase>()
val exampleEntities : Flow<List<ExampleEntitiy>> = watchAllEntitiesUseCase.call()
}
그리고 다음과 같이 사용하여 Flow 확장 함수인 collectAsState()을 통해 Composable에서 Flow를 State 개체로 변환하여 상태로 변환해 사용하게 됩니다.
@Composable
fun ExampleScreen(
modifier : Modifier = Modifier,
viewModel : ExampleViewModel = viewModel()
){
// 1
val exampleEntities : List<ExampleEntity> by viewModel.exampleEntities.collectionAsState(initial = emptyList())
ExampleScreenContent(exampleEntites = exampleEntities)
}
-----------
@Composable
private fun ExampleScreenContent(
exampleEntites : List<ExampleEntity>
){
Box(modifier = Modifier.fillMaxSize()){
// example entities의 리스트를 보여줍니다.
}
}
위의 코드 1번에서 보이는 것처럼 by delegate를 통해 해당 함수를 호출하기만 하면 됩니다. 그러면 기본 상태가 entity 목록으로 직접 평가되기 때문에 더 이상 기본 상태를 처리할 필요가 없습니다. 그런 다음 UI 표시를 담당하는 contents Comsable에 목록을 전달할 수 있습니다. 이러한 접근 방식이 쉬울 수 있지만 위의 코드에는 위험한 부분이 있습니다.
바로 앱이 백그라운드에서 실행되더라도 Flow가 계속 collect 할 가능성이 있습니다. 그렇기에 이러한 위험성을 제거해주기 위한 방법을 바로 적어보겠습니다.
Android UI에서 Flow를 collect 하는 안전한 방법은 Flow의 확장 함수인 flowWithLifecycle() 함수를 사용할 수 있습니다. 이 함수는 repeatOnLifecycle() 함수를 사용하여 구현되므로 앱의 수명 주기 상태를 고려하면서 Flow의 collection을 관리할 수 있습니다.
따라서 flowWithLifecycle()을 사용하면 viewModel에서 Flow를 직접 수집할 수 없지만 collectAsState()을 사용하기 전에 remember로 상태를 감싸준다면 사용이 가능하게 됩니다.
@Composable
fun ExampleScreen(
modifier : Modifier = Modifier,
viewModel : ExampleViewModel = viewModel()
){
val lifecycleOwner = LocalLifecycleOwner.current
val exampleEntitiesFlowLifecycleAware = remember(viewModel.exampleEntities, lifecycleOwner){
viewModel.exampleEntities.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
val exampleEntities : List<ExampleEntity> by exampleEntitiesFlowLifecycleAware.collectAsState(initial = emptyList())
ExampleScreenContent(exampleEntites = exampleEntities)
}
------------------------------------------------------------
@Composable
private fun ExampleScreenContent(
exampleEntites : List<ExampleEntity>
){
Box(modifier = Modifier.fillMaxSize()){
// example entities 목록을 보여줍니다.
}
}
위의 코드는 그 전 코드와 달리 Flow가 수명 주기를 인식하도록 하였습니다 이제 viewModel 자체의 Flow에서 직접적으로 collectAsState() 함수를 호출하지 않고 flowWithLifecycle() 함수로 호출할 수 있습니다.
위에 코드에서 Flow의 동작 시작을 Lifecycle.STARTED로 설정을 해놨기 때문에 Start 상태에서만 동작하게 됩니다. 위에 코드가 APP의 생명주기에 따라 동작을 할 수 있도록 구현을 했을지는 몰라도 상당히 길어지고 복잡해진 것을 보실 수 있습니다. 지금은 Flow 개체가 하나만 있기 때문에 덜 할 수 있지만 실제 앱을 구현하다 보면 Flow가 한 두개 필요한 것이 아니기 때문입니다. 그렇기 때문에 위처럼 수명주기를 인식함과 동시에 코드의 수도 줄일 수 있는 방법을 작성해보겠습니다.
코드로 바로 보겠습니다.
@Composable
fun <T> rememberFlow(
flow : Flow<T>,
lifecycleOwner : LifecycleOwner = LocalLifecycleOwner.current
):Flow<T> {
return remember(key1 = flow, key2 = lifecycleOwner){
flow.flowWithLifecycle(lifecylceOwner.lifecycle,Lifecycle.State.STARTED)
}
}
다음과 같이 구현을 하게 된다면 변환하려는 Flow를 가져와서 remember 함수에 key1로 직접 전달하고 flowWithLifecycle() 함수를 적용합니다. 그 후
@Composable
fun ExampleScreen(
modifier : Modifier = Modifier,
viewModel : ExampleViewModel = viewModel()
){
val exampleEntitiesFlowLifecycleAware = rememberFlow(viewModel.exampleEntities)
val exampleEntities: List<ExampleEntitiy> by exampleEntityFlowLifecycleAware.collectAsState(initial = emptyList())
ExampleScreenContents(exampleEntities = exampleEntities)
}
각 Flow를 입력 매개변수로 사용하여 rememberFlow()함수를 사용하고 생성된 Flow를 collect 하여 자동으로 수명 주기를 인식할 수 있습니다. 하지만 Flow collection에 대해 한 줄의 코드가 아닌 두 줄이라는 단점이 남아있습니다.
위의 코드들을 보면 생성되는 모든 Flow는 rememberFlow()를 사용하는 단계가 필요로 합니다. 그렇기에 그 부분을 collectAsState()와 병합하여 코드의 수를 줄여보겠습니다
@Composable
fun<T:R, R> Flow<T>.collectAsStateLifecycleAware(
initial : R,
context : CoroutineContext = EmptyCoroutineContext
): State<R> {
val lifecycleAwareFlow = rememberFlow(flow = this)
return lifecycleAwareFlow.collectAsState(initial = initial, context = context)
}
위의 코드를 Composable에서 by 대리자를 사용하여 평가할 수 있는 State를 반환 전에 rememberFlow() 함수를 복제하고 놔줍니다.
@Composable
fun ExampleScreen(
modifier : Modifier = Modifier,
viewModel : ExampleViewModel = viewModel()
){
val exampleEntites : List<ExampleEntity> by viewModel.exampleEntities.collectionAsStateLifecycleAware(initial = emptyList())
ExampleScreenContent(exampleEntities = exampleEntities)
}
--------------------------------------------------
@Composable
private fun ExampleScreenContent(
exampleEntites : List<ExampleEntity>
){
Box(modifier = Modifier.fillMaxSize()){
// example entities 목록을 보여줍니다.
}
}
다음과 같이 구현한다면 한 줄의 코드로 Flow 수명 주기를 인식할 수 있는 collection을 할 수 있습니다.
동일한 StateFlow에 대해 서로 다른 두 위치에서 항상 초기 값을 관리해야 하는 경우 더 높은 cost를 유발할 수 있습니다.
그렇기에 함수를 조정하여 StateFlow 전용 확장 함수로 변환해보겠습니다.
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun <T> StateFlow<T>.collectionAsStateLifecycleAware(
context : CoroutineContext = EmptyCoroutineContext
):State<T> = collectAsStateLifecycleAware(initial = value, context = context)
위의 코드에서도 본다면 처음 .collectionAsStateLifecycleAware() 함수를 사용하였지만 다시 한번 사용해야 하는 일이 발생하게됩니다.
StateFlow의 범위에 있기 때문에 이제 해당 값에 Access 하여 직접 collectAsStateLifecycleAware() 함수의 초기 값으로 설정할 수 있으므로 기본 생성된 State 객체의 초기 값으로 설정할 수 있습니다.
이번 글에서는 State를 관찰하거나 collect를 할때 수명 주기를 고려하는 방법에 대해 작성해봤습니다.
repeatOnLifecycle()과 flowWithLifecycle()등과 같은 확장 함수를 사용한다면 App의 급격한 상태 변환에 쉽게 대응을 할 수 있습니다. 또한 Kotlin의 Delegate 속성 및 확장 함수를 통해 코드의 수를 줄일 수 있는 방법에 대해서도 작성해보는 시간을 가져봤습니다.
긴글을 읽어주셔서 감사합니다!!