안드로이드 개발을 진행하며 UI내, 데이터가 의도치 않게 손실될 때가 있다. 해당 원인은 크게 보았을 때 2가지인데,
이다. 구성 변경의 경우는 사용자가 아래의 작업을 시작할 때 진행된다.
[구성 변경 발생 조건]
1. 메모리 부족
2. 화면 회전
3. 언어 변경
4. 폰트 크기 변경
5. 테마 변경
6. 폴더블 폰 접기/펴기 동작
7. 등
위처럼 되었을 때, 앱의 생명주기는 onDestroy
이후, 다시 onCreate
가 호출된다. 따라서 기존 UI의 데이터 손실로 사용자는 혼란을 느낄 수 있다.
프로세스 재시작의 경우 또한 마찬가지이다. 사용자가 앱을 잠깐 나간 후, 다른 작업을 하고 돌아왔다고 가정해보자. 이때, 리소스가 큰 작업을 진행하여 리소스 추가 확보 작업의 필요로 기존 앱을 비정상 종료시킬 수 있다. 그럴 경우 또는 위와 같은 생명주기가 다시 호출된다.
이런 이유들로 인해, 앱의 '구성 변경'과 '프로세스 강제 재시작'에 따른 대응코드 작성이 필요하다. 특히나, 갤럭시 패드와 같은 커다란 해상도의 디바이스를 타게팅하여 만든 앱이라면 더 그렇다.
구성 변경 및 프로세스 강제 재시작에 따라 알아야 할 핵심 키워드들이 있다. 크게 5가지 정도로 보면 된다.
[핵심 키워드]
- onSaveInstanceState()
- onRestoreInstanceState()
- rememberSaveable()
- SavedStateHandle()
- AAC ViewModel의 State holder Class
위 5가지 키워드들만 알고 있어도 반은 먹고들어간다 생각한다. 이제 위 키워드들의 특징을 각각 알아보고 특정 요소들마다의 교집합과 합집합이 무엇이 존재하는지 알아보자. 그러한 특징을 아래 한장의 그림으로 나타낼 수 있다.
기존 Activity가 기본적으로 보유하고 있는 생명주기 함수로 손쉽게 오버라이딩이 가능하다. 이름만 봐도 직관적으로 알겠지만, onSaveInstanceState
의 경우, 앱의 구성 변경 또는 프로세스 강제 종료가 발생할 경우 호출되는 콜백 메서드이다. 이 곳에서 UI적으로 저장해야 할 값들을 저장한다.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.apply {
putXXX()
}
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
savedInstanceState.getXXX()
}
이때 저장 및 조회 값 형태는 Bundle형태의 원시 타입만 가능하다. 하지만 Custom한 클래스를 저장하고싶을 수도 있는데, 이럴 땐, 직렬화 라이브러리인 Parcelize
와 Serializable
을 쓰면 된다. 하지만 해당 메서드 사용 시, 직렬화/역직렬화를 메인 스레드에서 진행하기에 오버헤드가 생겨, 프레임 하락 증상이 나타날 수 있다는 것을 알아야 한다. 때문에 비트맵 이미지 등과 같은 커다란 데이터 저장을 안드로이드 공식 홈페이지는 권장하지 않고 있다.
참고 : https://developer.android.com/topic/libraries/architecture/saving-states?hl=ko#onsaveinstancestate
따라서 해당 콜백함수를 사용하여 값을 저장할 땐, DataLayer로부터 데이터를 쿼리할 수 있는 'key'값 같을 것을 저장하기를 권장한다.
참고 : https://developer.android.com/topic/libraries/architecture/saving-states?hl=ko#manage
해당 함수는 onSaveInstanceState()
/onRestoreInStanceState()
와 기능이 동일하다. 따라서 rememberSaveable()
또한 '구성 변경' 및 '프로세스 강제 종료 및 재시작' 시, 데이터를 보존할 수 있으며 Bundle()
타입의 데이터 저장을 통해 상태를 복구한다. 이때, Bundle()
타입은 원시타입을 포함, 직렬화가 가능한 Parcelize
/Serializable
한 타입을 의미한다.
하지만 몇 가지 차이점이 존재하는데, onSaveInstanceState()
/onRestoreInStanceState()
의 경우, ViewSystem 사용 시 적절한 메서드이다. 하지만 rememberSaveable()
은 컴포즈UI를 구현할 때 적절한 메서드이다. 따라서 remember
라는 컴포저블 상태 메서드 명에 맞게, inputs
파라미터의 변경에 따라 람다를 다시 계산하여 타게팅하는 컴포저블 함수만 재구성한다는 특징이 있다. 또한 더 나아가, rememberSaveable()
은 Bundle()
타입 뿐만 아니라 List와 Map형태로도 데이터 보존이 가능한데, 이는 Saver
객체를 통한 저장을 의미한다. 예시 코드는 아래와 같다.
data class City(val name: String, val country: String)
val CityListSaver = listSaver<City, Any>(
save = { listOf(it.name, it.country) },
restore = { City(it[0] as String, it[1] as String) }
)
val CityMapSaver = 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 selectedListCity = rememberSaveable(stateSaver = CityListSaver) {
mutableStateOf(City("Madrid", "Spain"))
}
var selectedMapCity = rememberSaveable(stateSaver = CityMapSaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}
onSaveInstanceState()
/onRestoreInstanceState()
와 rememberSaveable()
은 '구성 변경'과 '프로세스 강제 종료 후 재시작'에 따른 데이터 보존을 가능하게 해준다. 하지만 AAC ViewModel의 경우는 그렇지 않으며 '구성 변경'에 따른 데이터 유지만 가능하게 해준다.
참고 : https://developer.android.com/topic/libraries/architecture/viewmodel?hl=ko
통상적으로 AAC ViewModel의 State Holder Class를 UiState로 선언하여 사용하곤 하는데, 이 자체가 구성 변경에 따른 상태 유지를 가능하다. 정말 쉽고 좋다.
data class MainUiState(
val bannerList: ImmutableList<Banner>
val userList: ImmutableList<User>
)
@HiltViewModel
class MainViewModel @Inject constructor(
private val userRepository: UserRepository
) {}
위에 선언 된 MainUiState
는 MainViewModel이 구성 변경에 따른 데이터 손실을 방지해준다.
[리마인드]
AAC ViewModel내 선언한 UiState는 프로세스 비정상 종료 및 재시작에도 안전하다고 생각할 수 있다. 하지만 그렇지 않음에 주의하자. '구성 변경'에 따른 데이터 보존만 가능하다.
AAC ViewModel에서 '프로세스 비정상 종료 및 재시작'에 따른 데이터를 유지하려면 어떻게 해야할까? 그에 대한 정답은 SavedStateHandle
객체를 사용하는 것이다.
해당 객체는 onSaveInstanceState()
/onRestoreInstanceState()
/rememberSaveable()
과 내부적으로 동일하게 동작한다. 따라서 Bundle
타입만 저장이 가능(원시 타입을 포함한 데이터 직렬화/역직렬화 가능한 타입만)하며, SaveStateHandle
객체를 사용한 데이터 저장 및 조회로 프로세스 비정상 종료에 따른 데이터 복구가 가능하다.
참고 : https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate?hl=ko
사용 방법은 SavedStateHandle
객체를 의존주입 받은 후, getXXX
메서드를 사용하는 것이다. 다시 한번 강조하지만, SavedStateHandle
내, 저장할 데이터는 리스트를 불러올 핵심 데이터나 소량의 데이터여야 한다. (ex. 특정 id값, 리스트의 검색어 등..) 왜냐하면 onSaveInstanceState()
/onRestoreInstanceState()
/rememberSaveable()
/SavedStateHandle
은 데이터를 저장할 때 직렬화/역직렬화를 메인 스레드에서 진행하여 UI의 프레임 하락 증상이 나타날 수 있기 때문이다.
그럼 안드로이드 공식 홈페이지에서 권장하고 있는, SavedStateHandle
로 어떤 데이터를 저장하고 있는지 알아보자.
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
}
}
샘플 데이터에서도 또한 SavedStateHandle
객체를 사용하여 'query'키값의 데이터만을 저장하고 있다. 그 후, 해당 값을 통해 DataLayer에 리스트 값을 질의한다.
[리마인드]
프로세스 비정상 종료에 따른 복구 작업을 AAC ViewModel에서 실행할 경우,SavedStateHandle
을 사용해야 한다.
View
vs ViewModel
해당 장은 개인적인 생각을 정리한 글입니다.
컴포저블 상태 변수를 선언할 때, 상태 호이스팅 레벨을 예측할 수 있어야 한다. 상태 호이스팅 정도에 따라 상태 변수는 컴포저블 함수 밑단부터 시작해, 루트 까지 갈 수 있다. 또한, 컴포저블 함수의 타입은 '구성 변경'과 '프로세스 비정상 종료 후의 재시작'의 방어 코드로 rememberSaveable()
을 사용한 형태로도 존재할 수 있다. 하지만 여기서 상태 호이스팅을 더 진행한다면 AAC ViewModel까지도 충분히 진행될 수 있다. 즉, 기존 Compose UI내에서만 존재하는 상태 변수가 UiState
까지 올라갈 수 있다는 의미이다.
하지만 ComposeUI에서만 존재하는 컴포저블 상태 변수를 AAC ViewModel로 무작정 끌어올리면 문제가 발생한다. ComposeUI에선 rememberSaveable()
사용만으로 '구성 변경' 및 '프로세스 강제 종료 후의 재시작'에 대한 대응코드가 모두 작성 가능하다. 하지만 AAC ViewModel의 경우, 단순 UiState의 존재만으론 '프로세스 강제 종료 후의 재시작'에 대한 데이터 손실이 불가피하다. 따라서 SavedStateHandle
을 추가 도입하여 데이터 보존 코드를 작성해야만 한다. 즉, 무분별한 컴포저블 상태 변수를 선언하고 추후 상태 호이스팅을 진행한다면, ViewModel
에서 로직 변경이 필요 이상으로 많이 증가하여 테스트 비용이 증가할 수 있다.
따라서 remember
또는 rememberSaveable
를 사용한 컴포저블 함수의 상태 변수를 선언할 때, 아래를 반드시 고려해야 한다. 이를 필자가 만들고 있는 앱으로 예를 들어볼까 한다.
컴포저블 상태 변수가 비즈니스 로직 실행에 필요 데이터가 될 것인가?
빨간색으로 밑줄 그인 TabRow
컴포저블 함수에 주목하자. 해당 뷰는 추후 재활용을 위해 BaseTabRow
와 같은 형식으로 추출했다. 또한 이를 호출한 곳에선 그 위에 var tabIndex by rememberSaveable { mutableStateOf(0) }
와 같은 코드를 사용하여 탭 인덱스를 관리한다. 또한 사용자 클릭에 따라 tabIndex
의 상태 변경에 따른 리컴포지션이 가능하기에, 위와 같은 설계는 개인적으로 합당해 보인다.
var tabIndex by rememberSaveable { mutableStateOf(0) }
BaseTabRow(
tabList = listOf(...)
selectedTabIndex = tabIndex,
onSelectedTabChanged = { tabIndex = it }
)
하지만 아래의 요구사항이 생긴다.
- 위의 화면은 총 5군데(A, B, C, D, E)로부터 들어올 수 있다.
- A, B, C로부터 진입할 경우, 하단 탭을 '상세정보'가 아니라, '댓글%d'탭으로 기본 선택되도록 해야한다.
위와 같은 요구사항이 들어왔을 경우, tabIndex
는 더이상 컴포저블 함수의 상태 변수로 남아있으면 안된다. 위 화면을 진입할 때, tabIndex
값을 넘겨줘야만 하며, AAC ViewModel에선 이를 받아야만 한다. 그리고 이는 tabIndex
상태 변수가 비즈니스 로직으로 사용됨을 의미한다.
즉, 위 tabIndex
상태 변수를 AAC ViewModel로 상태호이스팅 함으로써, '프로세스 비정상 종료 후의 재시작'에 따른 대응도 진행해줘야 했다. 따라서 해당 코드에는 SavedStateHandle
을 사용하여 해당 인덱스를 수신받도록 진행시켰다.
// AAC ViewModel 내부
init {
viewModelScope.launch {
setState {
update(
tabIndex = savedStateHandle.getStateFlow(TAB_INDEX, 0)
.filterNotNull()
.first()
)
}
}
}
따라서 컴포저블 함수 내, 존재하는 상태 변수가 추후 비즈니스 로직과 섞일 염려가 없다고 하면 rememberSaveable
로 남겨도 된다. 하지만 비즈니스 로직과 섞이게 된다면 이는 SaveStateHandle
의 처리 또한 고려해야하므로 신중한 고려가 필요하다.
사용자의 예상치 못한 경험은 개발자에게도 안좋다. 조금이라도 쾌속적인 앱 개발을 위하여 '구성 변경' 및 '프로세스의 강제 재시작'에 대한 대응방안을 나름 정리해보고 도식화도 진행해보았다. 또한 위 키워드들은 각각에 있어 교집합들이 듬성듬성 있어 헷갈릴 수 있다고 생각하는데, 아래 이미지 1장으로 정리가 좀 된 느낌이다.
[참고]