[Compose 공식문서 톺아보기] 6. Compose에 UI 상태 저장

이승우·2023년 7월 4일
0

상태가 호이스팅된 위치와 필요한 로직에 따라 서로 다른 API를 사용하여 UI 상태를 저장하고 복원할 수 있다.

모든 Android 앱은 Activity 또는 Process 재생성으로 인해 UI 상태가 손실될 수 있다. 이러한 상태 손실은 다음과 같은 이벤트로 인해 발생할 수 있다.

  • 구성 변경 : 구성 변경이 수동으로 처리되지 않는한 Activity가 소멸되고 재생성된다.
  • 시스템에서 시작된 프로세스 종료 : 앱이 백그라운드에 있고 기기가 리소스(메모리 등)를 다른 프로세스에서 사용할 수 있도록 확보한다.

시스템에서 시작된 프로세스 종료는 사용자가 명시적으로 Activity를 닫는 사용자가 시작한 프로세스 종료와 다르다. 사용자가 시작한 프로세스 종료에서는 일시적인 상태의 손실이 대체적으로 합리적이다.

이러한 이벤트 후에 상태를 보존하는 것은 긍정적인 사용자 경험을 제공하는데 중요하다. 어느 상태가 보존되도록 선택해야 하는지는 앱의 고유한 사용자 흐름에 따라 달라진다. 권장사항은 적어도 사용자 입력 및 탐색 관련 상태는 유지하는 것이다.

ex) 목록의 스크롤 위치, 사용자가 더 자세히 알고자 하는 항목의 ID, 진행중인 사용자 환경설정 선택, 텍스트 필드의 입력 등

여기서는 상태가 호이스팅되는 대상 위치와 상태를 필요로 하는 로직에 따라 UI 상태를 저장하는데 사용할 수 있는 API에 대해 알아보려고 한다.

UI 로직

상태가 UI에서 구성 가능한 함수나 컴포지션으로 범위가 지정된 일반 상태 홀더 클래스로 호이스팅되는 경우, rememberSaveable을 사용하여 여러 Activity, Process 재생성 후에도 상태를 유지할 수 있다.

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) }
    // 채팅 풍선이 접혔는지 아니면 펼쳐졌는지를 저장하는 불리언 변수이다.

    ClickableText(
        text = message.content,
        onClick = { showDetails = !showDetails }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

위 코드는 rememberSaveable이 단일 Boolean UI 요소 상태를 저장하는데 사용된다.

저장된 인스턴스 상태에 저장된 데이터는 보통 일시적인 상태이며, 사용자 입력이나 탐색에 따라 달라진다. ex) 목록의 스크롤 위치, 사용자가 더 자세히 알고자 하는 항목의 ID, 진행중인 사용자 환경설정 선택, 텍스트 필드의 입력 등

rememberSaveable은 저장된 인스턴스 상태 메커니즘을 통해 UI 요소 상태를 Bundle에 저장한다.

UI 요소 상태 : 렌더링 방식에 영향을 주는 UI 요소에 고유한 속성을 나타낸다. UI 요소는 표시하거나 숨길 수 있으며, 특정 글꼴이나 글꼴 크기, 글꼴 색상을 적용할 수 있다. Android 뷰는 기본적으로 Stateful이므로 이 상태 자체를 관리하여 상태를 수정하거나 쿼리하는 메소드를 노출한다. 텍스트에 관한 TextView 클래스의 get or set 메소드가 그 예가 될 수 있다. Jetpack Compose에서 상태는 컴포저블의 외부에 있으며 컴포저블 아주 가까이에서 호출 구성 가능한 함수나 상태 홀더로 호이스팅할 수도 있다. Scaffold 컴포저블의 ScaffoldState가 그 예가 될 수 있다.

기본 유형은 자동으로 Bundle에 저장할 수 있다. 반면, 상태가 데이터 클래스와 같이 기본 유형이 아닌 유형에 저장되어 있다면 Parcelize 주석을 사용하거나 listSave or mapSaver 등의 Compose API를 사용하거나, Compose 런타임 Saver 클래스를 확장하여 커스텀한 Saver 클래스를 구현하는 등의 다른 저장 메커니즘을 사용할 수도 있다.

아래 코드 스니펫을 보면 rememberLazyListStaterememberSaveable을 사용하여 LazyColumn 또는 LazyRow의 스크롤 상태로 구성되는 LazyListState를 저장한다. 또한, 스크롤 상태를 저장하고 복원할 수 있는 맞춤 Saver인 LazyListState.Saver를 사용한다. Activity 또는 Process가 재생성된 후(ex: 기기 방향 변경과 같은 구성 변경 후), 스크롤 상태가 보존된다.

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

권장사항

remeberSaveableBundle을 사용하여 UI 상태를 저장한다. Bundle은 Activity에서 이루어지는 onSaveInstanceState() 호출과 같이 Bundle에 쓰기를 수행하기도 하는 다른 API와 공유된다. 단, 이 Bundle의 크기는 제한적이므로 여기에 큰 객체를 저장하면 런타임에서 TransactionTooLarge 예외가 발생할 수 있다. 특히, 앱 전체에서 동일한 Bundle이 사용되는 Single Activity 앱에서 문제가 될 수 있다.

이러한 유형의 비정상 종료를 방지하려면 번들에 크고 복잡한 객체나 객체 목록을 저장하지 않아야 한다.

대신 ID나 키와 같이 필요한 최소 상태를 저장하고 더 복잡한 UI 상태의 복원을 영구 저장소와 같은 다른 메커니즘에 위임하는데 이 데이터를 사용한다.

상태 복원 확인

Activity나 Process가 재생성되었을 때, rememberSaveable을 사용하여 Compose 요소에 저장된 상태가 올바르게 복원되는지 확인할 수 있다. 이를 위한 API로 StateRestorationTester를 사용하면 된다.

비즈니스 로직

UI 요소 상태가 비즈니스 로직에서 필요하기 때문에 ViewModel로 호이스팅된 경우, ViewModel의 API를 사용할 수 있다.

구성이 변경되어 Activity가 파괴되었다가 재생성되는 경우, ViewModel로 호이스팅된 UI 상태는 메모리에 유지된다. 재생성 후에는 기존 ViewModel 인스턴스가 새 Activity에 연결된다.

그러나 ViewModel 인스턴스는 시스템에서 시작된 프로세스 종료가 발생한 경우에는 유지되지 않는다. UI 상태가 유지되도록 하려면 SavedStateHandle API를 포함하는 ViewModel의 저장된 상태 모듈을 사용해야 한다.

권장사항

SavedStateHandle은 UI 상태를 저장하기 위해 Bundle 메커니즘도 사용하므로 간단한 UI 요소 상태를 저장하는데에만 사용해야 한다.

비즈니스 규칙을 적용하고 UI 이외의 애플리케이션 레이어에 접근함으로써 생성되는 화면 UI 상태는 그 복잡도와 크기 때문에 SavedStateHandle에 저장해서는 안된다. 복잡하거나 큰 데이터를 저장할 때는 로컬 영구 스토리지를 비롯한 여러 메커니즘을 사용할 수 있다.

SavedStateHandle API

다음과 같이 UI 요소 상태를 저장하는 여러 API가 있다.

1) Compose State : saveable()
SavedStateHandle의 saveable API를 사용하여 UI 요소 상태를 MutableState로 읽고 쓴다. 그러면 여러 Activity, Process 재생성 후에도 최소한의 코드 설정으로 UI 요소 상태가 유지된다.

saveable API는 추가 설정 없이 기본 유형을 지원하며, rememberSaveable()처럼 맞춤 Saver를 사용하기 위해 stateSaver 매개변수를 받는다.

@OptIn(SavedStateHandleSaveableApi::class)
class ConversationViewModel(
   private val savedStateHandle: SavedStateHandle
) : ViewModel() {

   var message by savedStateHandle.saveable(stateSaver = TextFieldValue.Saver) {
      mutableStateOf(TextFieldValue(""))
   }
       private set

   fun update(newMessage: TextFieldValue) {
       message = newMessage
   }

   // …
}

@Composable
fun UserInput() {

   TextField(
       value = viewModel.message,
       onValueChange = { viewModel.update(it) }
   )
}

2) StateFlow : getStateFlow()

getStateFlow()를 사용하여 UI 요소 상태를 저장하고 SavedStateHandle에서의 흐름으로 사용한다. StateFlow는 읽기 전용이며, 이 API에서는 개발자가 키를 지정해야 흐름을 대체하여 새 값을 내보낼 수 있다. 키를 구성했으면 StateFlow를 검색하여 최신 값을 수집할 수 있다.

다음 코드 스니펫에서 savedFilterType은 채팅 앱의 채팅 채널 목록에 적용된 필터 유형을 저장하는 StateFlow 변수이다.

private const val CHANNEL_FILTER_SAVED_STATE_KEY = "ChannelFilterKey"

class ChannelViewModel(
   private val channelsRepository: ChannelsRepository,
   private val savedStateHandle: SavedStateHandle
) : ViewModel() {

   private val savedFilterType: StateFlow<ChannelsFilterType> =
       savedStateHandle.getStateFlow(key = CHANNEL_FILTER_SAVED_STATE_KEY, initialValue = ALL_CHANNELS)

   private val filteredChannels: Flow<List<Channel>> =
       combine(channelsRepository.getAll(), savedFilterType) { channels, type ->
           filter(channels, type)
       }
           .onStart { emit(emptyList<List<Channel>>()) }

   fun setFiltering(requestType: ChannelsFilterType) {
       savedStateHandle[CHANNEL_FILTER_SAVED_STATE_KEY] = requestType
   }

   // ...
}

enum class ChannelsFilterType {
   ALL_CHANNELS,
   RECENT_CHANNELS,
   ARCHIVED_CHANNELS
}

사용자가 새 필터 유형을 선택할 때마다 setFiltering이 호출된다. 이렇게 하면 SavedStateHandle에 CHANNEL_FILTER_SAVED_STATE_KEY 키와 함께 새로운 값이 저장된다. savedFilterType은 키에 저장된 최신 값을 내보내는 흐름이다. filteredChannels는 채널 필터링을 수행하기 위해 흐름을 수신한다.

Ref

profile
Android Developer

0개의 댓글