으레 많은 개발자들이 연말갬성을 뭉치고 모아 회고글을 쓰듯 나역시 23년 각오를 다지기 위해 뻘글을 써보고자 한다.

2022년은 경찰공무원에서 개발자로 이직에 성공한 뒤 맞는 첫 해여서 내게 굉장히 뜻깊은 1년이었다. 21년도엔 막연히 백엔드 개발자가 되기 위해 CS와 알고리즘 등을 공부했지만 신입 공채직원 부서 배치 과정에서 안드로이드 부서로 배치되었다.
안드로이드를 제대로 공부한 적도 없는데 너무 뜬금없어서 당황했지만 돌이켜보면 프레임워크를 익히지도 않고 뭣 하나 구현한 것도 없는 신입 개발자가 무슨 패기로 백엔드 직군을 지망했는지 어이가 없을 지경이다.

21년 회고는 월별로 나눠서 진행했지만 올해는 월별이고 자시고 정신없이 몰려오는 대로 치워내느라 섹션 별로 나눠서 정리해야겠다.

1. 안드로이드 개발자
2월까지의 공채 적응 기간을 거치고 난 뒤(세상에 온보딩이 2개월이라니 생각보다 갓기업이었음...) 밴드, 카페를 개발한 Group& CIC에 배치를 받았다. 거기에 네이버에서 가장 최근에 생겼다고 볼 수 있는 Game부서였다. 게임 개발하나 싶었는데 게임 커뮤였다. 멘토와 리더와의 면담 때 최대한 어필한 점은 진짜 아무것도 모르고 백엔드를 지망했었다는 점이었다. 실제로 아무것도 몰랐으니 딱히 틀린 말은 아니었지만... '-`

초면에 납작 업드려서 그런지 사수와 리더님이 굉장히 케어를 많이 해주셨다. 덕분에 정말 많은 배려를 받고 기초를 쌓는데 집중할 수 있었다. 그리고 자바로 개발하지 않고 코틀린으로 개발해야 한대서 안드로이드와 함께 코틀린까지 공부를 해야 했다.

  - Kotlin
"Kotlin in Action"이라는 책을 추천받았는데 JVM까지 고려하면서 공부하려니 진도가 너무 느렸다. 결국 람다까지만 보고 실습 프로젝트에 들어갔다. ㅠ 아직 자바도 정리 못했는데 큰일이다. 그래도 제네릭을 제외하곤 실제 개발에 본격적으로 쓰이진 않는 내용이라 천천히 공부해보려 한다.

생각보다 언어가 직관적이어서 오히려 자바보다 더 코드가 간결해졌다. 앞으로 자바로 쓰인 앱을 건들 자신이 없어졌다. 리서치하면서도 자바 코드로 쓰인 포스트는 자연스레 뒤로 가기를 누르는 나를 발견할 수 있었다.

  - Android
안드로이드의 기본적인 시스템 공부를 위해 "Do it 안드로이드 프로그래밍 with Kotlin" 도서를 잡고 외부 스터디를 주관해서 진행했다. 덕분에 어느정도 감이 잡히긴 했는데 아직 브로드캐스트 리시버와 콘텐츠 프로바이더 쪽 정리가 미숙해 23년 초에 정리를 진행할까 싶다.

사내 안드로이드 입사 동기들끼리도 스터디를 진행해서 Kotlin의 비동기 처리 수단인 Coroutine에 대해서도 어느정도 깊이있게 스터디를 진행했다. 내부 레포에 정리한 자료를 사수님이 보시고 23년 RxKotlin으로 구현된 모듈을 Coroutine으로 migration하자는 목표를 꺼내셨다. 아무래도 큰일 난 것 같다...

대부분의 앱은 XML기반으로 작성된다. 하지만 Game 부서는 신생 서비스여서 사수가 Jetpack Compose를 시작부터 도입했다고 한다. 덕분에 Compose 초기부터 현재까지 발생했던 상당히 많은 버그들과 많은 커스텀 레이아웃들을 볼 수 있었다. 문제는 초기 Compose에서 제공하지 않던 기능을 위해 구현했던 방식이 지금에 와선 best-practice아닌 부분이 있어 리팩토링하겠다고 덤볐다가 머가리가 깨지고 있다... 역시 사수님이 그렇게 작성하신 데에는 다 이유가 있기 마련이다. ㅠㅠㅠ

2. 사이드 프로젝트
9월 즈음 지인과 함께 2인 1팀으로 진행을 했다. 그 친구는 기획/마케팅/서버를 담당하고 나는 클라이언트/디자인 담당이 되었다. 업무도 적응 못했는데 뭔 사이드 플젝이냐 생각이 들었지만 또 생각해보면 코드는 많이 작성할수록 좋고 구현하면서 안드로이드 시스템 공부가 되지 않을까 싶어 적극적으로 참여하기로 했다.

약 3개월 동안 진행한 결과 쉽지 않겠단 생각이 들었지만 역시 더더욱 그렇다. 23년에도 가열차게 제작해봐야 결과지를 받아볼 수 있겠다. 일단은 계속 진행해도 괜찮겠다는 판단이 섰다.

3. 가사노동(?)
동생놈이 공부를 개같이 못해서 부모님으로부터 SOS가 들어왔다. 한창 주가를 올리기 위해 굴러야 하는 입장에서 굉장히 부담되는 요청이지만 어쩌겠는가. 지금 구제하지 못하면 60년을 살아가야 할 가족구성원 하나가 제 몫을 감당하지 못해 결국 내게 업보로 돌아오겠다는 생각이 들었다.

그래도 최대한 부담을 덜어야겠다는 생각에 머리를 굴린 결과 감독없이 일방적 보고체계만으로 진도를 확인할 수 있도록 절차를 만들었다. 그리고 점검을 위해 주말 하루만을 희생해 매주 테스트를 제작/출제를 했다.
추석부터 시작했으니 약 3개월이 지난 결과 고1 3등급 수준의 지능에서 고2 3등급 수준의 지능까지 끌어올린 느낌이다... 국어 영어만 봐주고 있는데 1월부터는 한국사까지 추가해야 한다. 

4. 운동
몸을 축내면서 이직 준비를 하느라 이젠 진짜 몸을 움직여야 한다고 신체가 신호를 보내왔다. 마침 이사를 하고 난 뒤 단지 시설에 나?름 갖춰진 헬스장이 있어 가끔 출석도장을 찍었다. 처음에는 "진짜 답도 없네 ㅋㅋ" 싶었는데 몇 달에 걸쳐서 꾸준히 가다보니 생각보다 조금씩 적응하는 게 보여서 재미를 붙일 수 있었다. 2월 즈음부터 다니기 시작했는데 요번에 보니 3개월 좀 넘는 107일로 기록되어 있다. 계산해보면 대충 1주에 약 2일을 운동한 셈이다. 생각보다 출석율이 저조해서 23년에는 200일 언저리로 다닐 수 있도록 해봐야겠다.

프레임이 넓어졌다고 느끼는 게 한국 기준 100사이즈 티셔츠가 맞지 않아서 서양 기준 100이나 한국 기준 105를 입어야 찌셔츠가 되지 않고 공간이 남는 수준이 되었다. 23년 일정이 22년보다 바빠질 예정이지만 게임을 줄여서라도 가야지...

총평
21년은 네이버 입사가 주요 이벤트라면 22년은 신입 직원의 적응기로 요약할 수 있겠다. 그리고 생각보다 허비한 시간이 많다 생각했는데 꽤나 꽉 차 있던 한 해였다. 작년에도 제작년보다 나아졌다고 생각했는데 올해도 작년보다 조금이나마 나아진 느낌이라 기분이 나쁘진 않다.

23년은 대학원 복학이 예정되어 있다. 이직하고 22년 적응하느라 1년 휴학을 했는데 이제 영수증을 받아볼 때가 되었다. 자퇴를 할까도 싶었는데 지금 아니면 석사를 언제 따겠나 싶어서 복학을 결정했다. 딱히 관련없는 보안 전공이라 너무 많은 노력을 쏟을 생각은 없다.

결국 내년에는 위의 활동과 함께 대학원이라는 과업이 추가되었다. 가을 즈음에 번아웃 비스무리한 증상이 찾아왔었는데 23년에 그런 증상 없이 보내기 위해 다른 과제들을 쳐내는 효율을 올릴 필요가 있어보인다.

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 관리할 State가 많아질 때 어떻게 책임을 나눠야하는 지 알아봅니다. 사실상, 대부분의 서비스 앱은 MVVM 패턴으로 구성된다. 이번 포스팅은 Compose를 도입 시 어떻게 MVVM 패턴으로 구현할 수 있는지 정리함을 목적으로 합니다.

Compose에서의 상태 관리 - UI, State Holder, ViewModel

Compose에서는 UI element의 상태, UI에 표시될 상태 등 다양한 상태를 관리하면서 사용자에게 화면을 출력한다. 간단한 상태 호이스팅은 컴포저블 함수에서 관리할 수 있다. 그러나, 루트 컴포저블에 가까워질 수록 추적할 상태가 많아지거나 컴포저블 함수에서 실행할 비교적 복잡한 로직이 있는 경우 로직과 상태에 대한 책임을 다른 클래스에 위임하는 것이 좋다.

특히 Compose UI element의 상태를 관리하는 다른 클래스, 상태 홀더(State Holder)에 위임하는 것을 권장한다. 아래는 Compose 화면의 상태 관리와 관련된 주요 항목이다.

  • 컴포저블(Composable): 간단한 UI element state를 관리
  • 상태 홀더(State Holder): 복잡한 UI element state를 관리하며 state와 연결된 UI 로직도 보유
  • ViewModel: 비즈니스 로직 및 화면 UI state에 대한 액세스 권한을 제공

Android_Compmose_Managing_State_001

위 그림은 Compose의 상태 관리와 관련된 항목과 역할을 요약한 그림이다.

컴포저블은 복잡성에 따라 0개 이상의 상태 홀더를(일반 객체 혹은 ViewModel) 사용할 수 있다. 상태 홀더를 유연하게 필요에 따라 사용할 수 있다는 의미이다.

일반적인 State Holder는(영어 버전 공식문서에서는 Plain State Holder로 명명되어 있으며 한국어 번역 상 "일반적인 상태 홀더"라고 작성된 것으로 보인다.) 비즈니스 로직이나 화면 상태에 액세스해야 하는 경우 ViewModel을 사용할 수도 있다. ViewModel은 비즈니스 로직을 담당하고 있으며 UI에 표시할 데이터 또한 보관하고 있기에 상태 홀더가 ViewModel에 접근하는 경우가 있다는 말이다.

ViewModel은 비즈니스 레이어 혹은 데이터 영역을 사용한다. ViewModel은 데이터를 조회하고 저장하기 위해 데이터 영역과 상호작용한다. 이는 비즈니스 로직 수행을 위한 비즈니스 레이어로의 접근과도 동일하게 볼 수 있다.

상태 및 로직 유형

상태 유형

  • 화면 UI 상태 : 화면에 표시되어야 하는 정보를 담는다. 앱의 데이터를 포함하고 있어 보통 다른 layer들과 연결된다.
  • UI element 상태 : UI element의(ex. Composable) 상태를 호이스팅한 결과다. 예를 들어, ScaffoldStateScaffold 컴포저블의 상태를 처리한다.

로직 유형

  • UI 로직 : 화면에 상태 변경을 표시하는 방법과 관련이 있다. 예를 들어, 버튼을 사용자가 클릭하면 특정 화면으로 탐색하는 로직이나 목록의 특정 아이템으로 스크롤하는 로직 등이 해당된다.
  • 비즈니스 로직 : 상태 변경에 따라 진행 여부가 결정되는 작업이다. 결제/환경설정 저장 등이 해당된다. 예를 들어, 유저가 버튼을 클릭하여 뉴스 앱에서 기사를 북마크할 때 비즈니스 로직은 북마크 정보를 파일이나 데이터베이스에 저장한다. 따라서, 해당 로직은 보통 도메인이나 데이터 레이어에 속하며 UI 레이어에 배치되진 않는다. 상태 홀더는 보통 이 로직을 도메인/데이터 레이어가 노출한 함수를 호출함으로써 위임한다.

정보 소스로서의 컴포저블

상태와 로직이 간단하다면 컴포저블에 UI 로직와 UI element 상태를 사용함이 좋다. ]

@Composable
fun MyApp() {
    MyTheme {
        val scaffoldState = rememberScaffoldState()
        val coroutineScope = rememberCoroutineScope()

        Scaffold(scaffoldState = scaffoldState) {
            MyContent(
                showSnackbar = { message ->
                    coroutineScope.launch {
                        scaffoldState.snackbarHostState.showSnackbar(message)
                    }
                }
            )
        }
    }
}

위 코드는 UI element state인 ScaffoldStateCoroutineScope를 처리하는 임의로 작성된 MyApp 컴포저블이다. Coroutine을 사용하여 화면에 스낵바 출력에 해당하는 UI 로직을 처리하고 있다.

ScaffoldState에 변경될 수 있는 속성이 포함되기에 해당 컴포저블과의 모든 상호작용은 위에 정의한 MyApp 컴포저블에서 이루어져야 한다. 그렇지 않고 다른 컴포저블에 전달해버리면 해당 컴포저블이 상태를 변경할 수 있기에 단일 정보 소스 원칙에 위배되고 버그 추적에 어려움을 겪는다.

정보 소스로서의 State Holder

여러 UI element들의 상태와 관련된 복잡한 UI 로직이 있는 컴포저블이라면 책임을 상태 홀더에 위임하는 것이 좋다. 책임을 분리함으로써 격리하여 테스트를 진행할 수 있고 컴포저블의 복잡성 또한 줄어들기 때문이다. 컴포저블은 UI element를 출력하는 데 집중하고 상태 홀더가 UI로직과 UI element 상태를 담당함을 의미한다.

상태 홀더 구현은 일반 클래스로 하되 컴포지션에 의해 생성되고 기억되기에 Compose 종속 항목을(ex. remember~state) 사용할 수 있다. 컴포지션에서 기억할 수 있도록 remember를 사용하여 함수를 작성한다.

// Plain class that manages App's UI logic and UI elements' state
class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    private val resources: Resources,
    /* ... */
) {
    val bottomBarTabs = /* State */

    // Logic to decide when to show the bottom bar
    val shouldShowBottomBar: Boolean
      get() = /* ... */
    // Navigation logic, which is a type of UI logic
    fun navigateToBottomBarRoute(route: String) { /* ... */ }

    // Show snackbar using Resources
    fun showSnackbar(message: String) { /* ... */ }
}

@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    /* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
    MyAppState(scaffoldState, navController, resources, /* ... */)
}

이제 UI 로직과 UI element 상태를 보관하던 MyApp은 UI element 명세에만 집중하고 나머지는 MyAppState에 위임하였다. UI element state 인스턴스 조회를 위해 rememberMyAppState 함수를 새롭게 정의하여 MyApp 컴포저블에서 사용한다.

@Composable
fun MyApp() {
    MyTheme {
        val myAppState = rememberMyAppState()
        Scaffold(
            scaffoldState = myAppState.scaffoldState,
            bottomBar = {
                if (myAppState.shouldShowBottomBar) {
                    BottomBar(
                        tabs = myAppState.bottomBarTabs,
                        navigateToRoute = {
                            myAppState.navigateToBottomBarRoute(it)
                        }
                    )
                }
            }
        ) {
            NavHost(navController = myAppState.navController, "initial") { 
                /* ... */ 
            }
        }
  }
}

컴포저블이 제어할 UI element가 늘어남에 따라 매번 따로 파라미터를 관리하기가 굉장히 번잡해졌다. 컴포저블의 책임이 늘어남에 따라 State Holder 도입 필요성이 증가한다.

참고할 점 - Compose UI element state 복원

만약 Activity 혹은 프로세스가 종료된 이후에도 보존하려는 상태를 상태 홀더에 포함하려면 rememberSaveable을 사용하고 상태에 맞는 커스텀 Saver를 만들면 된다.

정보 소스로서의 ViewModel

상태 홀더 클래스가 UI 로직과 UI element의 상태를 담당했었다. ViewModel은 다른 특별한 유형의 상태 홀더로서 다음의 작업을 맡는다.

  • 비즈니스/데이터 레이어 등 주로 계층 구조의 다른 레이어에 배치되는 앱의 비즈니스 로직에 대한 액세스 권한 제공
  • 특정 화면에 표시하기 위한 앱 데이터 준비(화면 UI 상태가 됨)

참고할 점 - ViewModel의 수명 > Composable의 수명

ViewModel은 컴포지션보다 수명이 더 길다. config change가 발생하여 컴포지션이 종료되더라도 유지되기 때문이다. ViewModel의 수명주기는 Compose contents의(Activity or Fragment) 호스트 수명주기나 Navigation Graph의 수명주기를 따를 수 있다.

따라서, 컴포지션보다 긴 수명주기를 가지고 있기에 컴포지션의 수명과 바인딩된 상태를 참조해선 안된다. 컴포지션의 수명과 바인딩된 상태를 참조하여 장기 지속 참조가 이어지면 메모리 누수가 발생할 수 있기 때문이다.

주의할 점 - Composable 변수로 인스턴스 전달

ViewModel의 인스턴스를 다른 컴포저블 함수로 전달하면 안된다고 설명한다. 만약 컴포저블 함수에 ViewModel을 전달하면 해당 함수는 ViewModel의 특정 타입과 결합되어 재사용성이 떨어지고 테스트와 preview 동작에 어려움을 겪기 때문이다. 또한, ViewModel 인스턴스를 관리하는 명확한 단일 소스 저장소가 없어진다. ViewModel을 전달하면 여러 컴포저블이 ViewModel의 함수를 호출하고 상태를 수정할 수 있기 때문에 디버깅도 더 어려워진다. 따라서, UDF 권장사항에 따라 필요한 상태만 전달함이 옳다.

주의할 점 - ViewModel에서 UI element state 저장

ViewModel은 컴포저블 함수가 아니므로 컴포지션의 구성요소가 아니다. 따라서, 컴포저블에서 만든 상태를(remember~State) ViewModel이 보관해서는 안된다. 별도의 State Holder 클래스를 선언하여 해당 인스턴스를 보관해야 한다.

다음 코드는 Screen Level Composable에서 사용된 ViewModel의 예시 코드이다.

data class ExampleUiState(
    val dataToDisplayOnScreen: List<Example> = emptyList(),
    val userMessages: List<Message> = emptyList(),
    val loading: Boolean = false
)

class ExampleViewModel(
    private val repository: MyRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {

    var uiState by mutableStateOf(ExampleUiState())
        private set

    // Business logic
    fun somethingRelatedToBusinessLogic() { /* ... */ }
}

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    /* ... */

    ExampleReusableComponent(
        someData = uiState.dataToDisplayOnScreen,
        onDoSomething = { viewModel.somethingRelatedToBusinessLogic() }
    )
}

@Composable
fun ExampleReusableComponent(someData: Any, onDoSomething: () -> Unit) {
    /* ... */
    Button(onClick = onDoSomething) {
        Text("Do something")
    }
}

커스텀 UI 상태 홀더 클래스인 ExampleUiState 클래스를 정의하였고 ExampleScreen 컴포저블에서 viewModel() 함수를 사용하여 ViewModel 인스턴스를 생성한 뒤 데이터를 조회하여 ExampleReusableComponent 컴포저블의 파라미터로 전달함을 확인할 수 있다. 이렇듯 ViewModel은 사용자에게 보여줄 커다란 데이터를 보관하거나 비즈니스 로직을 담당한다.

viewModel() 함수

viewModel() 함수는 현재 생성된 ViewModel 인스턴스를 불러오거나 새롭게 생성하는 함수이다. 함수 사용을 위해 build.gradle 파일에 특정 라이브러리 implementation이 필요하다.

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$version"

참고할 점 - 프로세스 재생성 후 ViewModel에서 복원할 항목

위 코드의 ExampleViewModel 클래스 생성자를 보면 savedState 이름으로 SavedStateHandle 타입의 변수를 선언했음을 확인할 수 있다. 흔한 경우는 아니지만 프로세스 재생성 후 보존할 상태가 ViewModel에 포함된다면 SavedSateHandle을 사용하여 상태를 지속하라고 설명한다. 이에 대해서는 기회가 된다면 다뤄볼 예정이다.

ViewModel과 State Holder

지금까지 Plain State Holder와 ViewModel의 역할을 보면 사뭇 다르다는 점을 발견할 수 있다. State Holder는 UI element state UI 로직(Behavior)에 대한 책임을 갖고, ViewModel은 Screen State(UI State), 비즈니스 로직에 대한 책임을 갖고 있다. 따라서, 두 가지의 상태 홀더를 화면 수준의 컴포저블에서 모두 다룰 수 있다.

하단의 코드에는 ViewModel이 UI state를 ExampleUiState로 다루고 State Holder가 UI element state를 ExampleState로 다루는 동작을 정의하였다.

class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) {
    fun isExpandedItem(item: Item): Boolean = TODO()
    /* ... */
}

@Composable
fun rememberExampleState(/* ... */): ExampleState { TODO() }

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    val exampleState = rememberExampleState()

    LazyColumn(state = exampleState.lazyListState) {
        items(uiState.dataToDisplayOnScreen) { item ->
            if (exampleState.isExpandedItem(item)) {
                /* ... */
            }
            /* ... */
        }
    }
}

ExampleState 클래스와 rememberExampleState함수가 갑자기 등장했으나 해당 클래스는 앞서 작성되었던 MyAppState 클래스와 rememberMyAppState 함수와 동일한 구조를 갖는다.

참고 자료

  1. Android Developers - 상태 및 Jetpack Compose

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 ViewModel이 UI 상태를 어떻게 다루는 지 알아봅니다.

관련 자료

Android | Jetpack Compose Observable MutableList에서 이어지는 포스트입니다.

Compose에서의 ViewModel

ViewModel은 config change 후에도 유지되므로 컴포지션보다 생명주기가 더 길고 Compose 콘텐츠 호스트(Activity, Fragment, Compose Navigation의 경우 Navigation Graph의 Destination)의 수명주기를 따를 수 있다.

주의할 점 - Compose 관련 함수

ViewModel은 컴포지션의 일부가 아니기에 메모리 누수가 발생할 수 있다. 따라서, 컴포저블에서 만든 상태를(ex. remember~State 등) 보유해서는 안된다. 특히, remember 키워드로 생성된 Compose 상태를 ViewModel 인스턴스가 보관할 경우 config change가 발생하여 컴포지션이 종료된 경우 문제가 될 수 있다. Activity가 재생성되면서 기존 remember한 상태는 컴포지션에서 삭제되었음에도 ViewModel 인스턴스는 기존 상태에 대한 레퍼런스를 들고 있어 의도와는 다르게 동작할 수 있기 때문이다.

목록 이전 및 함수 삭제

UI 코드에서 생성했던 상태와 목록을 ViewModel로 이전함과 동시에 비즈니스 로직도 ViewModel로 추출해보자.

WellnessViewModel.kt 생성

목록을 반환하던 getWellnessTasks()함수를 WellnessViewModel로 이동시킨다. 이전에 작성하였듯 toMutableStateList를 사용하여 내부 _tasks 변수를 정의하고 외부에 노출시킬 tasks를 정의한다. 이렇게 작성하면 ViewModel 외부에서 수정할 일이 없기에 책임을 집중시킬 수 있다.

아이템 제거하던 코드도 ViewModel로 이전하여 내장 remove함수를 호출하는 함수를 작성한다.

WellnessViewModel.kt

class WellnessViewModel : ViewModel() {
    private val _tasks = getWellnessTasks().toMutableStateList()
    val tasks: List<WellnessTask>
      get() = _tasks
    fun remove(item: WellnessTask) {
        _tasks.remove(item)
    }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

viewModel() 함수를 호출하여 컴포저블에서 해당 ViewModel에 액세스

viewModel()함수는 추가 라이브러리가 필요하다.

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"

wellnessViewModel을 인스턴스화하고 컴포저블에 적용

WellnessScreen.kt

@Composable
fun WellnessScreen(
  modifier: Modifier = Modifier,
  wellnessViewModel: WellnessViewModel = viewModel()
) {
    Column(modifier = modifier) {
        StatefulCounter()
        WellnessTasksList(
            list = wellnessViewModel.tasks,
            onCloseTask = { 
                task -> wellnessViewModel.remove(task) 
            }
        )
    }
}

viewModel()은 기존 생성되어 있던 ViewModel을 반환하거나 지정된 scope에서 새로운 ViewModel을 생성한다. ViewModel 인스턴스는 scope가 활성화되어 있는 동안 유지된다. 만약 컴포저블이 Activity에서 사용되는 경우 viewmodel()은 Activity가 finish되거나 프로세스가 종료될 때까지 동일한 인스턴스를 반환한다.

이로써 UI 표시될 데이터와 비즈니스 로직이 포함된 ViewModel과 화면이 서로 연결되었다.

참고할 점 - Composable에서의 ViewModel 사용

ViewModel은 Navigation Graph의 Activity나 Fragment 혹은 이런 호스트에서 호출되는 Root 컴포저블 근처에서 사용하는 것이 좋다. 그리고 앞서 주의점으로 설명하였듯이 ViewModel을 다른 컴포저블로 전달하지 않고 필요한 데이터나 로직을 실행하는 함수만 매개변수로 전달함을 잊어셔는 안된다.

목록 아이템 선택 상태 이전

마지막으로 선택된 상태와 관련된 로직을 ViewModel로 이전해보자. 해당 작업이 완료되면 모든 데이터와 상태가 ViewModel에서 관리되므로 코드가 더 간단해지며 테스트도 용이해진다.

WellnessTask 모델 클래스 수정

앞서 데이터 클래스로 정의했던 WellnessTask 클래스에 선택된 상태를 저장할 수 있도록 변수를 선언하고 기본값을 false로 지정한다.

WellnessTask.kt

data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)

ViewModel에 목록 아이템 선택 상태 수정 함수 정의

목록 아이템 선택 여부를 저장하도록 수정하였으니 ViewModel에서 선택된 상태를 새로운 값으로 수정하는 함수를 구현한다.

WellnessViewModel.kt

class WellnessViewModel : ViewModel() {
   ...
   fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
       tasks.find { it.id == item.id }?.let { task ->
           task.checked = checked
       }
}

새롭게 정의된 함수는 파라미터로 수정할 목록 아이템과 선택 상태를 수정할 값을 받는다. 이후 갖고 있는 아이템 목록에서 일치하는 아이템을 찾고 값을 수정한다.

Composable에서 아이템 선택 상태 수정 함수 호출

목록 아이템의 선택 상태를 정의하고 선택 상태를 변경하는 함수를 정의했으니 이제 Compose의 목록 아이템에서 함수를 호출하는 코드를 작성해야 한다.

WellnessScreen.kt

@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier,
    wellnessViewModel: WellnessViewModel = viewModel()
) {
   Column(modifier = modifier) {
       StatefulCounter()

       WellnessTasksList(
           list = wellnessViewModel.tasks,
           onCheckedTask = { task, checked ->
               wellnessViewModel.changeTaskChecked(task, checked)
           },
           onCloseTask = { task ->
               wellnessViewModel.remove(task)
           }
       )
   }
}

우선 목록 컴포저블의 onCheckedTask 변수로 ViewModel의 changeTaskChecked 함수를 호출하는 람다함수를 정의한다.

파라미터로 전달한 람다 함수를 목록 아이템에 추가

목록 컴포저블에 체크박스 선택 시 동작을 전달했으므로 각 아이템 컴포저블에 필요한 매개변수를 전달하면 된다.

WellnessTasksList.kt

@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCheckedTask: (WellnessTask, Boolean) -> Unit,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(
       modifier = modifier
   ) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(
               taskName = task.label,
               checked = task.checked,
               onCheckedChange = { checked -> onCheckedTask(task, checked) },
               onClose = { onCloseTask(task) }
           )
       }
   }
}

목록 컴포저블로부터 전달받은 onCheckedTask 람다함수에 실제 반영될 파라미터인 taskchecked 변수를 입력하여 앞서 정의했던 ViewModel의 함수에 아이템의 정보가 입력되게끔 한다.

불필요한 WellnessTaskItem 코드 정리

체크박스 선택 상태를 목록 컴포저블까지 호이스팅했기에 이제 Stateful 컴포저블은 필요치 않다. 따라서, 삭제를 진행한 뒤 Stateless 컴포저블만 남겨둔다.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(
   taskName: String,
   checked: Boolean,
   onCheckedChange: (Boolean) -> Unit,
   onClose: () -> Unit,
   modifier: Modifier = Modifier
) {
   Row(
       modifier = modifier, verticalAlignment = Alignment.CenterVertically
   ) {
       Text(
           modifier = Modifier
               .weight(1f)
               .padding(start = 16.dp),
           text = taskName
       )
       Checkbox(
           checked = checked,
           onCheckedChange = onCheckedChange
       )
       IconButton(onClick = onClose) {
           Icon(Icons.Filled.Close, contentDescription = "Close")
       }
   }
}

앱을 실행해보면 아직 체크박스 상태가 제대로 동작하지 않음을 확인할 수 있다.

Android_Compmose_ViewModel_State_001

의도한 대로 동작하지 않는 이유로는 Compose에서 MutableList를 위해 추적하는 상태가 아이템 추가 및 삭제와 관련된 변경사항이기 때문이다. 따라서, 삭제는 제대로 작동하지만 추적하도록 지시하지 않았던 아이템의 값 변경사항에 대해서는 인지하지 못하기 때문이다.

문제를 해결하기 위해 두 가지 방법을 사용할 수 있다.

  • 데이터 클래스 WellnessTask를 변경하여 checkedState의 타입을 Boolean에서 MutableState<Boolean>이 되도록 하면 Compose에서 해당 변수 상태의 변경사항을 추적한다.
  • 변경하려는 항목을 복사하고 목록에서 항목을 삭제한 뒤 변경된 항목을 다시 목록에 추가한다. 그러면 Compose에서는 아이템의 추가 및 삭제와 관련하여 추적하고 있기 때문에 목록 변경사항을 인지한다.

두 방법 모두 장단이 존재하며 상황에 따라 취사선택하라고 문서에서 제시한다. 구현된 목록 구조에 따라 요소를 삭제하고 읽는 데 비용이 많이 들 수 있기에 코드랩에서는 잠재적으로 비용이 많이 드는 목록 작업 대신 Compose 직관적인 방법을 택하였다. 따라서 checkedState를 Observable하게 변경한다.

체크박스 선택 여부를 Observable하게 변경

변수 객체타입을 바꾸면 다음과 같이 변경할 수 있다.

WellnessTask.kt

data class WellnessTask(
  val id: Int,
  val label: String,
  val checked: MutableState<Boolean> = mutableStateOf(false)
)

Kotlin의 delegated(위임된) 속성을 이용하면 checked 변수를 좀 더 간단하게 사용할 수 있다.

WellnessTask.kt

class WellnessTask(
    val id: Int,
    val label: String,
    initialChecked: Boolean = false
) {
    var checked by mutableStateOf(initialChecked)
}

이제 앱을 실행해보면 체크박스 작동이 정상적으로 이루어짐을 확인할 수 있다.

Android_Compmose_ViewModel_State_002

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #12. ViewModel의 상태

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서의 변경 가능한 목록 구현을 위해 어떤 코드를 작성해야 하는지 다룹니다.

관련 자료

Android | Jetpack Compose State 복원, 목록 사용에서 이어지는 포스트입니다.

변경 가능한 목록으로 전환

이전 포스트에서 목록을 구현하여 체크박스 기능까지 지원하였다. 하지만, 삭제 기능은 현재 빈 람다를 반환하여 동작하지 않았다. 흔히 변경 가능한 리스트를 생각하면 ArrayList<T>mutableListOf()를 사용한다. 그러나, Compose에서는 이들을 사용한다고 해서 목록에 변동이 생길 경우 UI의 리컴포지션을 예약한다고 Compose에 알리지 않는다.

목록 변동을 Compose가 관찰할 수 있도록 MutableList 인스턴스를 만들어야 한다. 이를 위해 안드로이드에서는 toMutableStateList() 확장함수를 제공한다. 이를 통해 관찰 가능한 MutableList를 만들 수 있다.

Compose UI에서 상태를 관찰하기 위해선 State<T>로 매핑해야 한다고 설명했었다. 마찬가지로 mutableStateOf 함수는 MutableState<T> 타입의 객체를 반환하며 mutableStateListOftoMutableStateList 함수는 SnapshotStateList<T> 타입의 객체를 반환하며 해당 타입의 객체를 문서에서 Observable MutableList라 설명한다.

Task 리스트를 Observable MutableList로 전환

WellnessScreen.kt

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()

       val list = remember { getWellnessTasks().toMutableStateList() }
       WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
   }
}
// copy from WellnessTaskList.kt
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

StatefulCounter 컴포저블이 상단에 위치하고 list 변수에 remember를 사용하여 MutableStateList 타입의 객체를 담았다.

주의할 점

mutableStateListOf API를 사용하여 목록을 만들 수 있으나 이를 사용하면 예기치 않은 리컴포지션이 발생할 수 있음을 숙지해야 한다. 잦은 리컴포지션의 발생은 UI 성능 저하를 유발할 수 있기 때문에 민감하게 받아들여야 한다.

만약 아래와 같이 MutableStateListremember하는 변수 선언을 한 뒤 해당 상태 객체에 데이터를 담는 코드를 작성했다고 해보자.

// Don't do this!
val list = remember { mutableStateListOf<WellnessTask>() }
list.addAll(getWellnessTasks())

이 경우 리스트에 아이템이 담길 때마다 상태가 변경된 것으로 인식하기 때문에 매번 리컴포지션이 발생한다. 현재는 초기 값을 설정할 수 있으므로 한꺼번에 구성해서 remember하는 것이 불필요한 리컴포지션을 줄일 수 있다.

// Do this instead. Don't need to copy
val list = remember {
mutableStateListOf<WellnessTask>().apply { addAll(getWellnessTasks()) }
}

보통 데이터를 연속적으로 요청하지 않고 일정 부분을 받기에 객체에 담은 뒤 remember하는 코드를 작성하면 이후에 해당 State 객체에 item을 담는 코드를 작성하지 않아 리컴포지션을 줄였다.

WellnessTaskList 컴포저블 함수 수정

이전 포스트에서 WellnessTaskList 컴포저블의 목록을 기본값으로 설정했었다. 이를 삭제하고 외부에서 받게끔 작성한 뒤 목록이 Screen 레벨로 호이스팅되었기에 WellnessTask 아이템 삭제를 담당하는 onCloseTask 람다 파라미터를 새롭게 정의한다. 그리고 onCloseTaskWellnessTaskItem에 전달하여 각 item의 우측 삭제 버튼 클릭 시 실행하도록 수정한다.

추가로 Mutable한 리스트를 Compose에 출력하는 동안 데이터가 변경될 때 추가 조치가 필요하다. 목록 출력을 위해 사용했던 items 함수에 key 매개변수를 추가로 작성해야 한다. 만약 key를 추가하지 않으면 기본값으로 목록에 있는 항목의 위치를 기준으로 키가 지정된다. 따라서, 데이터 변동은 위치를 변경함을 의미하고 이는 기억된 상태를 잃는다는 뜻이다. 항목의 위치를 기준올 설정된 기본 키 값에서 별도의 id 값으로 지정하면 이 문제를 해결할 수 있다.

앞서 Wellnesstask 데이터 클래스를 정의할 때 id 변수를 정의했었다. 해당 변수를 itemskey 변수로 넣어주자.

WellnessTaskList.kt - WellnessTaskList

@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(modifier = modifier) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
       }
   }
}

이로써 목록 컴포저블은 외부에서 아이템 삭제 시 실행할 람다 파라미터를 받고 각 아이템에 전달하는 역할을 수행한다.

onClose람다 함수를 Stateful 컴포저블로 옮기고 Stateless 컴포저블에서 호출

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(
   taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
   var checkedState by rememberSaveable { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = onClose,
       modifier = modifier,
   )
}

WellnessScreen에서 삭제 시 동작할 람다 함수를 onClose로 전달하였고 목록 컴포저블을 거쳐 Stateless 컴포저블까지 전달 후 실행하게끔 작성하였다. 이제 목록에서 아이템을 삭제할 수 있다.

각 목록 아이템의 우측 X를 클릭하면 이벤트가 상태를 소유한 목록까지 이동하므로 목록에서 항목이 삭제되며 Compose는 화면을 재구성한다.

Android_Compmose_MutableList_001

그림에 따라 상태는 하단으로 전파되고 아이템을 삭제하겠다는 이벤트는 하단 컴포저블로부터 상단까지 상승하는 단방향 흐름을 볼 수 있다.

목록 사용 시 주의점

앞서 코드에서 remember를 사용하여 목록을 저장한 바 있다. 그렇다면 컴포지션 종료 후 복원 간 유지를 위해 rememberSaveable을 사용하여 보관할 수 있을까에 대한 의문이 든다. 바로 적용해보면 런타임 에러가 발생하며 이는 Android | Jetpack Compose State 복원, 목록 사용에 작성하였듯이 맞춤 Saver를 제공하지 않았기 때문이다. 그리고 rememberSaveableBundle객체에 데이터를 보관 후 복원 절차를 수행한다. Bundle 객체에는 가급적 텍스트, 부울 등 기본 자료형 저장을 권장하며 용량 제한은 50KB정도이다. 따라서, 직렬화/역직렬화가 필요한 데이터 구조나 대량의 데이터를 저장하는 데 rememberSaveable을 사용하는 것은 부적합하다.

따라서 목록 등 대량의 데이터 저장과 앱 상태 홀더의 역할을 수행하는 ViewModel을 도입해야 한다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #11. 관찰 가능한 MutableList
  2. Android | Jetpack Compose State 복원, 목록 사용

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 컴포지션 종료와 재생성 간 상태를 어떻게 보관하는지를 Compose에서의 목록 사용과 연계해서 알아봅니다.

관련 자료

Compose State 복원은 Android | Jetpack Compose Remember State에서 이어집니다.

Compose 목록 사용은 Android | Jetpack Compose State Hoisting에서 이어집니다.

액티비티 재구성에 따른 상태 변화

Compose 상태가 바뀌면 리컴포지션이 발생하면서 변화된 상태를 반영했었다. 그리고 변경된 상태를 리컴포지션에도 유지하기 위해 remember API를 사용하여 저장한다. 그렇다면, 화면 회전이나 언어 변경, 라이트/다크 모드 전환과 같이 Config Change로 인해 실행 중인 Activity가 Android 시스템에 의해 닫혔다가 다시 생성되는 경우엔 어떻게 될까?

앱을 실행한 뒤 Add one 버튼을 눌러 count 변수를 0에서 1로 바꾼 뒤 기기를 회전시켜 화면을 세로모드에서 가로모드로 바꿔보자.

Android_Compose_State_Restore_001

Activity는 Config Change(구성 변경, 이 경우는 방향) 후 재생성되므로 저장된 상태를 삭제된다. 따라서, count 변수는 다시 초기값인 0으로 되돌아가며 카운터가 사라진다.

remember를 사용하면 리컴포지션 간 상태를 유지할 수 있지만 Config Change로 인한 구성 변경 간에는 유지되지 않는다. 이를 위한 새로운 rememberSaveable이라는 API를 사용해야 한다.

상태 복원에 쓰이는 rememberSaveable

rememberSaveableBundle에 저장할 수 있는 모든 값을 자동으로 저장한다. 다른 값의 경우 커스텀 Saver 객체를 전달할 수 있다.

Bundle에 저장할 수 없는 항목 저장

Parcelize 주석 추가

가장 간단한 방법으로 @Parcelize 주석을 추가하는 것이다. 그러면 객체가 parceable이 되며 번들로 제공된다. 다음의 코드는 City 데이터 클래스를 parceable로 만들어 상태에 저장한다.

@Parcelize
data class City(val name: String, val country: String) : Parceable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver API 사용

key-value 집합으로 객체를 변환하는 규칙을 정의하여 Bundle에 저장할 수 있는 값 집합으로 만들 수 있다.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver API 사용

list 형태로도 stateSaver를 만들 수 있다.

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

rememberSaveable 적용

기존 WaterCounter에서 rememberrememberSaveable로 바꿔보자.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       //var count by remember { mutableStateOf(0) }
       var count by rememberSaveable { mutableStateOf(0) }

       if (count > 0) {
           Text("You've had $count glasses.")
       }
       Button(
           onClick = { count++ },
           enabled = count < 10,
           Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

Android_Compose_State_Restore_002

결국 앱의 UX 상황에 맞춰서 remember를 사용할 것인지 rememberSaveable을 사용할 것인지 결정해야 한다.

Compose에서의 목록 사용

Android | Jetpack Compose State Hoisting에 이어 목록 사용을 위해 앱에 두 가지 작업을 추가한다.

  • 작업을 완료로 표시하기 위해 목록 항목 선택 기능
  • 완료할 필요 없는 작업을 목록에서 삭제 기능

목록 item 정의

목록에 보여줄 아이템을 정의해야 한다. 이전에 Android | Jetpack Compose Remember State에서 정의했던 WellnessTaskItem을 재사용하여 체크박스와 닫기 버튼을 추가해주자.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(
    taskName: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier, verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 16.dp),
            text = taskName
        )
        Checkbox(
            checked = checked,
            onCheckedChange = onCheckedChange
        )
        IconButton(onClick = onClose) {
            Icon(Icons.Filled.Close, contentDescription = "Close")
        }
    }
}

Checkbox 컴포저블과 IconButton 컴포저블을 추가하여 체크와 닫기 기능을 지원할 수 있도록 UI를 구성하였고 컴포저블 파라미터로 checked, onCheckedChange, onClose를 입력하여 Stateless 컴포저블이 될 수 있도록 한다.

목록 item의 상태 정의

목록 item을 stateless하게 정의했으므로 외부에서 상태를 주입해야 한다. checkedStated를 정의하는 동일한 이름의 Stateful 컴포저블을 우선 정의해둔다.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
   var checkedState by remember { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = {}, // we will implement this later!
       modifier = modifier,
   )
}

Task의 내용을 담을 데이터 클래스 정의

WellnessTask.kt파일을 만들어 ID와 라벨이 포함된 Task 모델을 데이터 클래스로 정의한다.

WellnessTask.kt

data class WellnessTask(val id: Int, val label: String)

Task 목록을 반환할 함수 추가 정의

WellnessTasksList.kt라는 파일을 만들어 더미 데이터를 반환하는 함수를 정의한다.

WellnessTasksList.kt

private fun getWellnessTasks() = List(30) { i ->
    WellnessTask(i, "Task # $i")
}

가짜 데이터를 반환하고 있지만 실제 서비스에서는 데이터 영역에서 조회하거나 네트워크를 통해 데이터를 받아서 전달한다.

WellnessTasksList.kt에서 목록 정의

WellnessTasksList.kt에서 목록을 만드는 컴포저블 함수를 정의한다. LazyColumn으로 목록을 구현하고 삽입할 item으로 앞서 정의했던 WellnessTaskItem을 활용하면 되겠다.

@Composable
fun WellnessTasksList(
    modifier: Modifier = Modifier,
    list: List<WellnessTask> = remember { getWellnessTasks() }
) {
    LazyColumn(
        modifier = modifier
    ) {
        items(list) { task ->
            WellnessTaskItem(taskName = task.label)
        }
    }
}

목록을 WellnessScreen에 추가

Task들을 보여줄 목록을 정의했으므로 화면에 출력하기 위해 WellnessTaskList 컴포저블을 WellnessScreen에 추가한다.

WellnessScreen.kt

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()
       WellnessTasksList()
   }
}

이제 앱을 실행해보면 체크박스 선택이 가능해진다.

Android_Compose_State_Restore_003

다만 삭제하는 로직은 빈 람다함수를 파라미터로 넣었기 때문에 아직 동작하지 않는다. 그리고 WellnessTaskItem의 상태로 remember API를 사용했었는데 이와 관련해서 어떤 부작용이 있는지 확인해보자.

LazyList에서의 상태 복원

checkedState는 각 WellnessTaskItem 컴포저블에 독립적으로 속한다. checkedState가 변경되면 WellnessTaskItem의 해당 인스턴스만 재구성되며 LazyColumn의 모든 WellnessTaskItem 인스턴스에 리컴포지션이 트리거되진 않는다. 현대 WellnessTaskItem 코드를 보면 remember API를 사용하여 checkedState 변수를 정의하였다. 앱을 켜고 다음의 절차를 따라해보면 의도와는 다르게 동작함을 확인할 수 있다.

  1. 해당 목록 상단의 item 체크박스를 선택
  2. 선택한 item들이 화면 밖으로 나가도록 스크롤
  3. 앞서 선택한 item이 화면 안으로 들어오도록 스크롤
  4. 선택했던 item들의 체크박스가 해제되어 있음

remember의 문제점으로 컴포지션에서 호출되지 않으면 기억된 상태를 삭제한다는 점이 있었다. LazyColumn 컴포저블의 경우 스크롤하면서 항목을 지나치면 해당 item에 해당 컴포지션을 완전히 종료하기 때문에 UI 컴포지션에서 삭제되어 더 이상 항목이 표시되지 않는다.

Android_Compose_State_Restore_004

이 문제를 해결하기 위해선 `rememberSaveable을 사용하면 된다. 저장된 값은 저장된 인스턴스 상태 메커니즘을 통해 Activity나 프로세스 재생성 시에도 유지되기 때문에 컴포지션이 종료될 때도 유지된다.

Statefull하게 정의한 WellnessTaskItem의 코드에서 rememberrememberSaveable로 바꿔보자.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
   var checkedState by rememberSaveable { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = {}, // we will implement this later!
       modifier = modifier,
   )
}

코드를 수정한 뒤 앱을 실행해보면 체크 상태가 유지되고 있음을 확인할 수 있다.

Android_Compose_State_Restore_005

Compose의 일반적 패턴

LazyColumn의 내부 구현을 보면 state를 다음과 같이 정의해두고 있다.

LazyDsl.kt

@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    ...
) {
    ...
}

내부 상태 정의를 위해 LazyListState 타입을 반환하는 rememberLazyListState()함수를 호출함을 확인할 수 있다. rememberLazyListState 함수의 내부 구현을 더 들어가보자.

LazyListSTate.kt

@Composable
fun rememberLazyListState(
    initialFirstVisibleItemIndex: Int = 0,
    initialFirstVisibleItemScrollOffset: Int = 0
): LazyListState {
    return rememberSaveable(saver = LazyListState.Saver) {
        LazyListState(
            initialFirstVisibleItemIndex,
            initialFirstVisibleItemScrollOffset
        )
    }
}

rememberLazyListState 컴포저블 함수는 rememberSaveable을 사용하여 목록의 초기 상태를 구현한다. 그렇기 때문에 Activity가 재생성 되더라도 스크롤 상태는 어떠한 조치를 취하지 않아도 유지된다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #8. Compose에서 상태 복원
  2. Google Codelab - Jetpack Compose의 상태 #10. 목록 사용
  3. Android Developers - 상태 및 Jetpack Compose

+ Recent posts