UI 상태 저장과 복원

Arakene·2024년 11월 20일

UI를 복원해야할 일이 있을 때 ViewModel과 SavedInstanceState를 생각하는데 두가지 중 어떻게 구분해서 사용하는것이 좋을지에 대해서 정리하려한다.

UI 상태 유지를 위해서 어떤걸 사용할 수 있을까?

  • ViewModel 객체
  • context내 저장된 인스턴스
    • compose의 rememberSaveable
    • onSaveInstanceState
    • ViewModel의 SavedStateHandle

어떤 조건에서 어떤 방법을 선택하는게 좋을까?

간단하게는 아래 표와 같이 정리할 수 있다. 참고로 여기서 사용된 SaveInstanceState는 ViewModel의 SavedStateHandlerememberSaveable도 포함한다.

구분ViewModelSaveInstanceState영구 스토리지
저장소 위치메모리메모리디스크 또는 네트워크
구성 변경 시에도 유지
시스템에서 시작된 프로세스 종료 시에도 유지아니요
사용자의 완전한 활동 닫기/onFinish() 시에도 유지아니요아니요
데이터 제한복잡한 객체 허용, 사용 가능한 메모리에 의해 제한단순하고 작은 객체만 허용 (예: 원시 유형, 문자열)디스크 공간, 네트워크 리소스, 검색 시간에 의해 제한
읽기/쓰기 시간빠름 (메모리 액세스)느림 (직렬화/역직렬화 필요)느림 (디스크 액세스 또는 네트워크 트랜잭션 필요)

ViewModel의 경우

이점

사용자가 앱을 사용하는 동안 UI 관련 데이터를 저장 및 관리하는데 이상적 이다.
ViewModel을 사용하면 UI 데이터에 빠르게 액세스 가능하며 회전, 창 크기 조절 및 일반적으로 발생하는 기타 구성 변경시 네트워크나 DB에서 데이터를 다시 가져오는걸 방지할 수 있다.

데이터가 파괴되는 시점

  • 사용자가 Activit or Fragment를 종료 또는 개발자가 finish()를 호출
  • 프로세스 종료

ViewModel에서 프로세스 종료 후 UI 복원을 하고싶다면

SavedStateHandle API를 사용하면 가능하다.

SaveInstanceState의 경우

onSaveInstanceState, Compose의 rememberSaveable, ViewModel의 SavedStateHandle는 Activity or Fragemnt같은 UI가 파괴되고 나중에 재생성 시에 상태를 다시 로드할 때 필요한 데이터를 저장한다.

이점

구성 변경 및 프로세스 종료 시에도 유지된다.

주의점

  • 데이터를 직렬화하기에 저장용량 및 속도가 제한된다.
  • 직렬화 프로세스는 구성 변경 시 기본 스레드에서 발생하기때문에 장기적으로 실행되면 프레임 하락 및 시각적인 끊김 현상이 발생 가능하다.

간단한 예시

  • 로컬 스토리지: 앱을 열고 닫을 때에도 손실되지 않아야 할 데이터
    예: 노래 객체, 오디오 파일이나 메타데이터
  • ViewModel: 연결된 UI 컨트롤러를 표시하는 데 필요한 모든 데이터
    예: 가장 최근에 검색한 노래와 그 검색어
  • onSavedInstanceState: Activity가 중지되었다가 다시 생성되는 경우 Activity의 상태를 쉽게 로드하는 데 필요한 소량의 데이터. 여기에 복잡한 객체를 저장하는 대신 로컬 스토리지에 복잡한 객체를 유지하고 이러한 객체에 대한 고유 ID를 onSavedInstanceState에 저장합니다.
    에: 가장 최근에 검색하는데 사용한 검색어

예를 들어, 노래 라이브러리를 검색하는 Activity를 떠올려 보세요. 다양한 이벤트를 처리하는 방법은 다음과 같습니다.

사용자가 노래를 추가하면 ViewModel은 즉시 이 데이터를 로컬로 유지합니다. 새로 추가된 이 곡이 UI에 표시해야 하는 곡인 경우 ViewModel 객체의 데이터도 업데이트하여 곡을 추가해야 합니다.

사용자가 노래를 검색할 때 UI 컨트롤러를 위해 데이터베이스에서 로드한 복잡한 노래 데이터는 즉시 ViewModel 객체에 저장되어야 합니다. 또한 검색 조회 자체를 ViewModel 객체에 저장해야 합니다.

활동이 백그라운드로 전환되고 시스템이 저장된 인스턴스 상태 API를 호출하면 검색어는 프로세스가 다시 생성될 경우에 대비하여 저장된 인스턴스 상태에 저장되어야 합니다. 이 정보는 여기에 저장된 애플리케이션 데이터를 로드하는 데 필요하므로 검색어를 ViewModel SavedStateHandle에 저장합니다. 이는 데이터를 로드하고 UI를 현재 상태로 되돌리는 데 필요한 모든 정보입니다.

SavedStateRegistry

Activity와 Fragment같은 UI 컨트롤러는 SavedStateRegistryOwner를 구현하고 컨트롤러에 결합되는 SavedStateRegistry를 제공한다. 해당 레지스트리를 통해 UI 컨트로럴에 저장된 상태에 구성요소가 연걸되어 소비되거나 변경할 수 있다. getSavedStateRegistry()를 호출하여 UI 컨트롤러 내에서 SavedStateRegistry를 검색할 수 있다.
saveState()를 가지고 있는 SavedStateRegistry.SavedStateProvider를 구현해서 저장할 데이터를 Bundle로 리턴할 수 있다. SavedStateRegistry는 UI 컨트롤러 수명 주기의 saving state phase에서 호출된다.

ViewModel의 SavedStateHandle

ViewModel객체가 구성 변경사항을 처리할 수 있어 개발자는 회전이나 다른 상황에서 UI 상태유지에 신경쓰지 않아도 된다. 다만 프로세스 종료를 처리해야하는 경우 SavedStateHandle API로 백업을 하는것을 권장한다.

일반적으로 UI 상태는 Activity가 아닌 ViewModel에 저장하거나 참조된다. 따라서 onSaveInstanceState()rememberSaveable을 사용하기위한 키워드가 필요하다.

이 모듈을 사용하면 ViewModel은 생성자를 통해 SavedStateHandle을 받게된다. 이 객체는 저장된 상태에 객체를 저장하고 검색할 수 있게하는 키-값 맵이다. 해당 객체는 시스템에서 프로세스가 중단된 이후에도 유지되며 동일한 객체를 계속 사용할 수 있다.

위에서 저장된 상태값들은 작업 스택에 연결된다. 작업 스택이 사라지면 저장된 상태도 사라진다. 앱을 강제 종료하거나 최근 메뉴에서 앱을 삭제하거나 기기를 재부팅하는 경우가 해당된다.

설정

ViewModel의 생성자로 SavedStateHandle을 받아올 수 있다.

class SavedStateViewModel(private val state: SavedStateHandle) : ViewModel() { ... }

추가 구성없이 ViewModel인스턴스를 가져올 수 있다. 기본 ViewModelFactory는 각 ViewModel에 적절한 SavedStateHandle을 제공해준다.
맞춤 ViewModelProvider.Factory 인스턴스를 제공할 때 AbstractSavedStateViewModelFactory를 확장하여 SavedStateHandle 사용을 설정할 수 있다.

사용

맵으로 구성되어있어 set(), get()으로 데이터를 작성하고 가져와서 사용할 수 있다.
SavedStateHandle을 사용하면 쿼리 값이 프로세스 종료 전반에 유지되어 활동이나 프래그먼트에서 값을 수동으로 저장 및 복원하고 ViewModel에 다시 전달하지 않고도 재생성 전과 후에 동일한 필터링된 데이터 세트가 사용자에게 표시된다.

주의점으로 Activity가 중지된 경우에만 작성된 데이터를 저장한다. Activity가 이미 중지된 동안에는 쓰기작업이 이루어지지 않는다. 다만 ActivityonStart, onStop을 반복하는 경우는 예외다.

LiveData, StateFlow, Compose의 상태 API를 통해서도 UI와 결합해서 사용할 수 있다.

LiveData와 결합

getLiveData()를 사용해서 SavedStateHandle에서 가져온 값을 LiveData를 래핑할 수 있다.

class SavedStateViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    val filteredData: LiveData<List<String>> =
        savedStateHandle.getLiveData<String>("query").switchMap { query ->
        repository.getFilteredData(query)
    }

    fun setQuery(query: String) {
        savedStateHandle["query"] = query
    }
}

StateFlow와 결합

getStateFlow()을 사용해서 StateFlow로 값을 래핑할 수 있다. 키의 값을 업데이트하면 StateFlow가 새 값을 수신한다.

class SavedStateViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    val filteredData: StateFlow<List<String>> =
        savedStateHandle.getStateFlow<String>("query")
            .flatMapLatest { query ->
                repository.getFilteredData(query)
            }

    fun setQuery(query: String) {
        savedStateHandle["query"] = query
    }
}

Compose와 결합

saveable을 통해 MutableState로 읽고 쓴다. rememberSaveable()처럼 커스텀 Saver를 설정할 수 있다.

class SavedStateViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {

    var filteredData: List<String> by savedStateHandle.saveable {
        mutableStateOf(emptyList())
    }

    fun setQuery(query: String) {
        withMutableSnapshot {
            filteredData += query
        }
    }
}

parcelable이 아닌 클래스 저장

기본적으로는 번들에 데이터를 넣는 형식이기에 기본형이나 Parcelable or Serializeable를 구현한 인터페이스만 가능하다.
다만 Lifecycle 2.3.0-alpha03부터는 setSavedStateProvider()메서드를 통해 객체를 Bundle로 저장하고 복원하는 Compose의 Saver와 같은 기능을 구현함으로써 모든 객체를 저장할 수 있다.
프로세스가 중단되고 나중에 복원되는 경우 임시 파일이 손실되지 않도록 유지할 수 있다.

private fun File.saveTempFile() = bundleOf("path", absolutePath)

private fun Bundle.restoreTempFile() = if (containsKey("path")) {
    File(getString("path"))
} else {
    null
}

class TempFileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    private var tempFile: File? = null
    init {
        val tempFileBundle = savedStateHandle.get<Bundle>("temp_file")
        if (tempFileBundle != null) {
            tempFile = tempFileBundle.restoreTempFile()
        }
        savedStateHandle.setSavedStateProvider("temp_file") { // saveState()
            if (tempFile != null) {
                tempFile.saveTempFile()
            } else {
                Bundle()
            }
        }
    }

    fun createOrGetTempFile(): File {
      return tempFile ?: File.createTempFile("temp", null).also {
          tempFile = it
      }
    }
}

SavedStateHandler 테스트

SavedStateHandler를 테스트 데이터를 만들어 전달함으로써 테스트도 가능하다.

class MyViewModelTest {

    private lateinit var viewModel: MyViewModel

    @Before
    fun setup() {
        val savedState = SavedStateHandle(mapOf("someIdArg" to testId))
        viewModel = MyViewModel(savedState = savedState)
    }
}
profile
안녕하세요 삽질하는걸 좋아하는 4년차 안드로이드 개발자입니다.

0개의 댓글