안드로이드의 구성 변경과 프로세스 강제 재시작 흐름을 그림 1장으로 정리해보자

SSY·3일 전
0

Compose

목록 보기
11/11

시작하며

안드로이드 개발을 진행하며 UI내, 데이터가 의도치 않게 손실될 때가 있다. 해당 원인은 크게 보았을 때 2가지인데,

  1. 구성 변경
  2. 프로세스 강제 재시작

이다. 구성 변경의 경우는 사용자가 아래의 작업을 시작할 때 진행된다.

[구성 변경 발생 조건]
1. 메모리 부족
2. 화면 회전
3. 언어 변경
4. 폰트 크기 변경
5. 테마 변경
6. 폴더블 폰 접기/펴기 동작
7. 등

위처럼 되었을 때, 앱의 생명주기는 onDestroy이후, 다시 onCreate가 호출된다. 따라서 기존 UI의 데이터 손실로 사용자는 혼란을 느낄 수 있다.

프로세스 재시작의 경우 또한 마찬가지이다. 사용자가 앱을 잠깐 나간 후, 다른 작업을 하고 돌아왔다고 가정해보자. 이때, 리소스가 큰 작업을 진행하여 리소스 추가 확보 작업의 필요로 기존 앱을 비정상 종료시킬 수 있다. 그럴 경우 또는 위와 같은 생명주기가 다시 호출된다.

이런 이유들로 인해, 앱의 '구성 변경'과 '프로세스 강제 재시작'에 따른 대응코드 작성이 필요하다. 특히나, 갤럭시 패드와 같은 커다란 해상도의 디바이스를 타게팅하여 만든 앱이라면 더 그렇다.

핵심 키워드?

구성 변경 및 프로세스 강제 재시작에 따라 알아야 할 핵심 키워드들이 있다. 크게 5가지 정도로 보면 된다.

[핵심 키워드]

  • onSaveInstanceState()
  • onRestoreInstanceState()
  • rememberSaveable()
  • SavedStateHandle()
  • AAC ViewModel의 State holder Class

위 5가지 키워드들만 알고 있어도 반은 먹고들어간다 생각한다. 이제 위 키워드들의 특징을 각각 알아보고 특정 요소들마다의 교집합과 합집합이 무엇이 존재하는지 알아보자. 그러한 특징을 아래 한장의 그림으로 나타낼 수 있다.

키워드1. onSaveInstanceState() + onRestoreInstanceState()

기존 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한 클래스를 저장하고싶을 수도 있는데, 이럴 땐, 직렬화 라이브러리인 ParcelizeSerializable을 쓰면 된다. 하지만 해당 메서드 사용 시, 직렬화/역직렬화를 메인 스레드에서 진행하기에 오버헤드가 생겨, 프레임 하락 증상이 나타날 수 있다는 것을 알아야 한다. 때문에 비트맵 이미지 등과 같은 커다란 데이터 저장을 안드로이드 공식 홈페이지는 권장하지 않고 있다.


참고 : 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

키워드2. rememberSaveable()

해당 함수는 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"))
    }
}

키워드3. AAC ViewModel의 State Holder Class

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는 프로세스 비정상 종료 및 재시작에도 안전하다고 생각할 수 있다. 하지만 그렇지 않음에 주의하자. '구성 변경'에 따른 데이터 보존만 가능하다.

키워드4. SaveStateHandle

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을 사용해야 한다.

Deep Dive. 상태저장은 어디서 할까? 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 }
    )

하지만 아래의 요구사항이 생긴다.

  1. 위의 화면은 총 5군데(A, B, C, D, E)로부터 들어올 수 있다.
  2. 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장으로 정리가 좀 된 느낌이다.

[참고]

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글