플레이스토어에서 앱을 다운로드 받아 사용할 때, 화면 방향을 바꾼다던지, 앱을 켜둔 상태로 다크모드를 킨다던지, 멀티윈도우로 전환한다던지 하는 행동을 하곤 한다.
이때 앱에서 표시하고 있는 데이터가 변경된다거나, 만약 게임을 하던 중 화면 방향을 바꿨는데 게임이 처음부터 시작된다면 이는 앱의 옳은 동작이 아닐 것이다.
하지만 우리가 직접 만들어본 앱은 어떤가? 리스트에 아이템을 추가하는 앱을 만들었다고 했을 때 화면을 돌려보면 그 아이템이 유지가 되는가? 아마 아닐 것이다.
왜 이렇게 되는지, 그리고 상태를 유지하는 방법에 대해 알아보자.
상태가 유지되지 않는 이유
Activity의 수명주기는 다음 그림과 같다.
Activity 의 종료는 onResume() 상태에서 onPause, onStop을 거쳐 onDestroy 되어 종료된다.
각 생명주기에 로그를 찍고, 화면을 회전했을 때 어떤 생명주기로 흘러가는지 확인해보자
Activity 가 켜지고, onCreate() -> onStart() -> onResume 으로 진행되어 사용자와의 상호작용 할 수 있는 상태이다.
화면을 돌려보자.
화면을 가로로 돌렸을 때, onResume 에서 onPause() -> onStop() 을 거쳐 onSaveInstanceState 라는 상태를 지나 onDestroy 되는것을 확인할 수 있다. 그 뒤 바로 onCreate() -> onStart() -> onRestoreInstanceState() -> onResume 으로 앱이 재시작 되는 것을 확인할 수 있다.
여기서 알 수 있는 것은, 화면이 회전되면서 보이지 않지만 Activity 가 종료되었다가 다시 생성됨을 알 수 있다.
그렇기에 어떤 데이터던 Activity에 저장된 데이터는 메모리의 할당이 해제되었다가 다시 할당되면서 기존의 상태를 유지하지 못하고 초기값이 되는 것이다.
Fragment의 상황에선 어떨까? 이 Fragment 에서는 숫자를 띄워주고, 버튼을 누를때마다 숫자가 증가되게 해보겠다.
우선 Fragment의 생명주기는 다음과 같다.
Fragment가 Attach() 되고 나서, 다음의 생명주기를 가진다. Fragment의 생명주기의 맨 앞과 끝에는 항상 onAttach() 와 onDetach()가 존재한다.
기본 화면은 다음과 같다.
화면을 회전하면
Fragment 도 동일하게 Detach 되었다 다시 Attach 되어 Fragment가 재생성 되는것을 확인 할 수 있다.
정리하면, 화면을 회전하였을 때 상태가 유지되지 않는 이유는 화면이 회전되면서 보이지 않지만 Activity 또는 Fragment가 종료되었다가 다시 생성되기 때문이다.
이렇게 사용자가 Activity 를 종료하지 않아도 자동으로 종료되었다가 재생성되는 경우에는
- 화면을 회전하였을 때
- 폰트 크기 / 종류가 변경되었을 때
- 앱 사용중 다크모드를 키거나 끌 때
가 있다.
상태를 유지하는 방법?
위의 로그를 보았다면, 생소한 상태가 찍혀있는것을 확인 할 수 있을 것이다.
Activity에서는 onSaveInstanceState, onRestoreInstanceState 이고, Fragment 에서는 onSaveInstanceState, onViewStateRestored 가 바로 그것이다.
이는 Activity의 onCreate와 Fragment의 onCreateView 의 매개변수인 "saveInstanceState: Bunble?" 과 관련이 있다.
saveInstanceState 는 Bundle 타입의 객체로서, UI 상태를 저장하는데 사용된다.
키, 값 의 쌍으로 UI상태를 메모리에 저장하는데, 기본형 타입 또는 문자열과 같이 단순하고 작은 객체만 저장할 수 있는 제한사항이 있지만, 구성 변경 시에도 유지되어 UI 상태를 저장할 수 있다.
다음과 같이 사용하여 상태를 유지할 수 있다.
먼저 Activity의 예시이다. EditText에 입력한 값을 유지하도록 해본다.
1. onSaveInstanceState 콜백을 이용하여 Activity 가 종료되기 전, saveInstanceState 에 값을 저장한다.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Log.d(App.TAG + " : SaveInstanceActivity", "onSaveInstanceState")
outState.putString("INPUT", etInput.text.toString()) // key INPUT 으로 editText에 입력한 문자열 저장.
}
2. onStart 호출 이후 호출되는 콜백 onRestoreInstanceState 에서 저장된 값을 가져와 EditText에 세팅(방법 1)
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
Log.d(App.TAG + " : SaveInstanceActivity", "onRestoreInstanceState")
val saveString = savedInstanceState.getString("INPUT") // key INPUT 으로 저장된 문자열 가져오기
etInput.setText(saveString) // EditText에 세팅
}
2-1. onCreate의 매개변수 savedInstanceState 를 사용하여 값을 가져와 세팅(방법 2)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(App.TAG + " : SaveInstanceActivity", "onCreate")
setContentView(R.layout.activity_save_instance)
etInput = findViewById(R.id.et_input)
if(savedInstanceState != null){
val saveString = savedInstanceState.getString("INPUT")
etInput.setText(saveString)
}
}
방법 1과 2는 세팅하는 시점 차이이다. 만약 저장한 값을 onCreate에서 사용해야 한다면 방법 2를 사용해야 할 것이다.
onRestoreInstanceState는 호출 시점이 onStart() 가 불리고 나서 호출되기 때문이다.
Fragment의 예시이다. 누른 횟수를 저장할 것이다.
1. onSaveInstanceState 콜백을 이용하여 Fragment가 종료되기 전, saveInstanceState 에 값을 저장한다.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Log.v(App.TAG + " : SaveInstanceFragment", "onSaveInstanceState")
outState.putInt("COUNT", value)
}
2-1. onViewStateRestored 콜백을 이용하여 onStart 이후 호출될때 savedInstanceState에 저장된 값을 가져와 갱신한다.(방법1)
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
Log.v(App.TAG + " : SaveInstanceFragment", "onViewStateRestored")
if(savedInstanceState != null){
value = savedInstanceState.getInt("COUNT")
}
}
2-2. onCreateView 의 매개변수 savedInstanceState 를 이용하여 값을 가져와 세팅(방법2)
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
Log.v(App.TAG + " : SaveInstanceFragment", "onCreateView")
// Inflate the layout for this fragment
val root = inflater.inflate(R.layout.fragment_save_instance, container, false)
if(savedInstanceState != null){
value = savedInstanceState.getInt("COUNT")
}
return root
}
Activity와 마찬가지로 콜백의 호출 순서가 다르므로 저장한 값을 어디서 갱신해야할지 살펴보고, 적당한 방법을 선택해서 사용하면 된다.
지금까지 UI 의 상태를 저장할 수 있는 savedInstanceState에 대해 살펴보았다.
공식문서에서는 다음과 같이 권장하고 있다.
로컬저장소는 SharedPreference를 의미하는것 같다.
savedInstanceState는 사용방법이 간단하지만, 한계가 명확하게 존재하므로 너무 의존하지 않고 ViewModel과 적절하게 섞어 사용해야 한다.
이제 ViewModel에 대해 더 공부해보자...
참고자료
https://developer.android.com/guide/components/activities/activity-lifecycle?hl=ko#saras
https://developer.android.com/guide/fragments/lifecycle
https://developer.android.com/topic/libraries/architecture/saving-states?hl=ko