본문 바로가기
프로그래밍/Jetpack Compose

[Jetpack Compose] Compose UI Action 으로 인한 Lambda 최소한으로 줄여보기 [MVI 패턴]

by dev_gyu 2024. 10. 21.
728x90

Compose 를 쓰는 사람이라면 Compose 에서 State Hoisting (상태 끌어올리기) 이라는 기법이 존재한다는 것을 알고 있을 것이다.

State Hoisting 을 통해 최소한의 부분에서 리컴포지션을 유발하도록 하고 함수의 재사용과 테스트 용이성을 증대시킬 수도 있다.

 

하지만 이러한 State Hoisting 으로 인해서 우리의 함수에는 엄청난 수의 Lambda 를 전달해주는 상황들이 벌어지고, 이로 인해 코드의 복잡성이 증대하게 되는데 이번 시간에는 이러한 문제를 해결하기 위한 방법을 작성해보려 한다.


# 예시

하나 간단한 로직을 만들어보자.

뷰모델에서 MutableStateFlow 를 작성하고, 이를 컴포저블에서 구독한 뒤 버튼을 클릭했을때 뷰모델의 StateFlow 를 업데이트 해주는 로직이다.

class TestViewModel: ViewModel(){
    val first = MutableStateFlow(0)
    fun onClickFirst(){ first.update { it + 1 }}
}

@Composable
fun TestA(
    viewModel: TestViewModel = hiltViewModel()
){
    val first by viewModel.first.collectAsStateWithLifecycle()
    TestB(
        first = first,
        onClickFirst = viewModel::onClickFirst
    )
}

@Composable
fun TestB(
    first: Int,
    onClickFirst: () -> Unit
){
    TestC(first = first, onClickFirst = onClickFirst)
}

@Composable
fun TestC(
    first: Int,
    onClickFirst: () -> Unit
){
    Text(text = first.toString())
    
    Button(onClick = { onClickFirst() }) {
        Text(text = "first클릭")
    }
}

 

자, 위의 코드를 보면 뷰모델에서 first 변수를 가져와서 상태로 구독하고, 이를 Test B 를 거쳐 Test C 까지 전달하고 있다.

또한 TestC 에서 버튼을 클릭하는 경우 State Hoisting 을 통해 뷰모델까지 이벤트가 전파되도록 설정하였다.

 

현재는 상태를 구독하고, 이 상태에 대한 이벤트를 처리하는 과정이 1번뿐이다보니 아주 간단해보인다.

그럼 이제 이것에 더해 second, third 도 추가해보자.

 

class TestViewModel: ViewModel(){
    val first = MutableStateFlow(0)
    val second = MutableStateFlow(0)
    val third = MutableStateFlow(0)

    fun onClickFirst(){ first.update { it + 1 }}
    fun onClickSecond(){ second.update { it + 1 }}
    fun onClickThird(){ third.update { it + 1 }}
}

@Composable
fun TestA(viewModel: TestViewModel = hiltViewModel()){
    val first by viewModel.first.collectAsStateWithLifecycle()
    val second by viewModel.second.collectAsStateWithLifecycle()
    val third by viewModel.third.collectAsStateWithLifecycle()

    TestB(
        first = first,
        second = second,
        third = third,
        onClickFirst = viewModel::onClickFirst,
        onClickSecond = viewModel::onClickSecond,
        onClickThird = viewModel::onClickThird
    )
}


@Composable
fun TestB(
    first: Int,
    second: Int,
    third: Int,
    onClickFirst: () -> Unit,
    onClickSecond: () -> Unit,
    onClickThird: () -> Unit
){
    TestC(
        first = first,
        second = second,
        third = third,
        onClickFirst = onClickFirst,
        onClickSecond = onClickSecond,
        onClickThird = onClickThird
    )
}

@Composable
fun TestC(
    first: Int,
    second: Int,
    third: Int,
    onClickFirst: () -> Unit,
    onClickSecond: () -> Unit,
    onClickThird: () -> Unit
){
    Text(text = first.toString())
    Text(text = second.toString())
    Text(text = third.toString())

    Button(onClick = { onClickFirst() }) {
        Text(text = "first클릭")
    }
    Button(onClick = { onClickSecond() }) {
        Text(text = "second클릭")
    }
    Button(onClick = { onClickThird() }) {
        Text(text = "third클릭")
    }
}

 

한 눈에 보더라도 코드의 양이 급격하게 많아진 것을 알 수 있다.

그러나 우리가 평상시에 프로젝트를 진행하며 Hoisting 을 위한 Lambda 를 사용하는 것은 대부분 이렇게 적지 않고 꽤나 많아지는 경우가 대부분이다.

 

그렇다면 이벤트를 100개를 처리해야하는 경우는 어떻게 해야하는가? 또 1000개를 처리해야하는 경우는?

또한 자식 컴포저블이 2 Depth 가 아닌 10 Depth 를 넘는등의 상황을 고려한다면 ..?

생각만해도 무척이나 끔찍한 상황이 아닐 수 없다.

 

하지만 그렇다고 해서 콜백을 사용하지 않을 수도 없는 노릇.

이러한 경우를 대비하여 다음과 같은 방법으로 이 상황을 해결할 수 있다.

 

# 이벤트 Selead class 를 정의하여 콜백을 정리하기

위에 말한 콜백 지옥에 대한 해결 방법으로는 이벤트를 정의한 sealed class 를 정의하는 것이다.

그럼 이제 코드를 구현해보자

sealed class TestEventAction {
    data object OnClickFirst: TestEventAction()
    data object OnClickSecond: TestEventAction()
    data object OnClickThird: TestEventAction()
}

class TestViewModel: ViewModel(){
    val first = MutableStateFlow(0)
    val second = MutableStateFlow(0)
    val third = MutableStateFlow(0)

    fun onAction(eventAction: TestEventAction){
        when (eventAction){
            TestEventAction.OnClickFirst -> first.update { it + 1 }
            TestEventAction.OnClickSecond -> second.update { it + 1 }
            TestEventAction.OnClickThird -> third.update { it + 1 }
        }
    }
}

 

우선 EventAction 들을 정의한 sealed class 를 만들어두었고, value class 에서는 이 sealed class 를 파라미터로 받도록 설정하였다.

그리고 ViewModel 에서는 UiAction value class 를 파라미터로 받는 onAction 함수를 지정해주었다.

 

이전에 만들어둔 간소화 된 이벤트 처리로 인해 Compose 단에서는 위와 같이 코드를 생략할 수 있다.


@Composable
fun TestA(viewModel: TestViewModel = hiltViewModel()){
    val first by viewModel.first.collectAsStateWithLifecycle()
    val second by viewModel.second.collectAsStateWithLifecycle()
    val third by viewModel.third.collectAsStateWithLifecycle()

    TestB(
        first = first,
        second = second,
        third = third,
        onAction = viewModel::onAction,
    )
}


@Composable
fun TestB(
    first: Int,
    second: Int,
    third: Int,
    onAction: (TestEventAction) -> Unit
){
    TestC(
        first = first,
        second = second,
        third = third,
        onAction = onAction
    )
}

@Composable
fun TestC(
    first: Int,
    second: Int,
    third: Int,
    onAction: (TestEventAction) -> Unit
){
    Text(text = first.toString())
    Text(text = second.toString())
    Text(text = third.toString())

    Button(onClick = { onAction(TestEventAction.OnClickFirst) }) {
        Text(text = "first클릭")
    }
    Button(onClick = { onAction(TestEventAction.OnClickSecond) }) {
        Text(text = "second클릭")
    }
    Button(onClick = { onAction(TestEventAction.OnClickThird) }) {
        Text(text = "third클릭")
    }
}

 

 

만약 전역적으로 사용해야하는 Event 를 처리하는 경우 value class 를 정의하여 데이터 타입을 고정적으로 사용하도록 강제를 해줄 수 있다.

위처럼 이벤트 객체를 사용하여 모델을 업데이트 하는 아키텍처 패턴을 MVI 라고 한다고 하는데, MVVM 과 결합하여 사용하니 매우 편한 것 같다.


참고문서 - https://engineering.teknasyon.com/stop-passing-event-ui-action-callbacks-in-jetpack-compose-a4143621c365

 

728x90