[Android] ViewModel과 SIS

안드로이드는 적절한 시점에 대체 리소스를 제공하지만, 장치 회전에 따른 액티비티 소멸 및 재생성은 문제가 생길 수 있다. 회전 시 데이터가 초기화되는 결함을 해결하려면, 장치 회전 후에 재생성되는 MainActivity 인스턴스가 초기화 되는 데이터의 직전 값을 알아야 한다. 그러려면 장치 회전과 같은 런타임 구성 변경 시에 해당 데이터를 보존할 방법이 필요하다.

ViewModel에 UI 데이터를 저장해 UI 상태가 유실되는 결함을 해결할 수 있다. 또한, 이보다는 덜 생기지만 여전히 문제가 많은 결함인 '프로세스 종료에 따른 UI 상태 유실’도 안드로이드의 인스턴스 상태 보존 메커니즘을 사용해 해결한다.

ViewModel 의존성 추가하기

우선 ViewModel 클래스를 프로젝트에 추가한다. ViewModel 클래스는 안드로이드 Jetpack의 lifecycle-extensions(생명주기 확장) 라이브러리에 포함되어 제공되는데, 사용하려면 우선 프로젝트 의존성(dependencies) 에 lifecycle-extensions 라이브러리를 포함시켜야 한다.

프로젝트 의존성은 그래들(Gradle) 구성 파일인 build.gradle 파일에 지정한다(그래들은 안드로이드 앱의 빌드 도구다). 두 개의 build.gradle 파일 중 build.gradle(Module: YourProject.app), 즉 app 모듈의 빌드 파일에 지정한다.

build.gradle에 lifecycle-extensions 의존성 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
plugins {
id 'com.android.application'
id 'kotlin-android'
}

android {
...
}

dependencies {
...
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' // lifecycle-extensions library 포함시키기
...
}

ViewModel 추가하기

ViewModel은 특정 액티비티 화면과 연동되며, 해당 화면에 보여줄 데이터를 형식화하는 로직을 두기 좋은 곳이다. ViewModel은 모델 객체와 연동되어 모델을 ‘장식한다’. 즉, 모델 데이터를 화면에 보여주는 기능을 ViewModel이 수행한다. ViewModel을 사용하면 화면에서 필요한 모든 데이터를 한곳에서 종합하고 데이터를 형식화할 수 있다.

android.lifecycle 패키지는 생명주기를 인식하는 컴포넌트를 비롯해서 생명주기 관련 API도 제공하며, ViewModel도 android.lifecycle 패키지의 일부다. 생명주기를 인식하는 컴포넌트는 액티비티와 같은 다른 컴포넌트의 생명주기를 관찰하고 상태를 고려해 작동한다.

구글에서는 액티비티 생명주기와 다른 컴포넌트 생명주기 처리를 쉽게 할 수 있도록 android.lifecycle 패키지와 이 패키지의 내용물(클래스나 인터페이스 등)을 만들었다. 이는 또다른 생명주기 인식 컴포넌트인 LiveData백그라운드 스레드 내용과 연결된다.

ViewModel 클래스 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private const val TAG = "QuizViewModel"

class QuizViewModel : ViewModel() {

init {
Log.d(TAG, "ViewModel instance created")
}

// ViewModel 인스턴스가 소멸되기 전에 호출됨
override fun onCleared() {
super.onCleared()
Log.d(TAG, "ViewModel instance about to be destroyed")
}
}

ViewModel 인스턴스 사용하기

MainActivity.kt의 onCreate(Bundle?)에서 현재 액티비티를 QuizViewModel 인스턴스와 연결한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
setContentView(R.layout.activity_main)

val provider: ViewModelProvider = ViewModelProvider(this)
val quizViewModel = provider.get(QuizViewModel::class.java)
Log.d(TAG, "Got a QuizViewModel: $quizViewModel")

trueButton = findViewById(R.id.true_button)
...
}
...
}

ViewModelProvider는 ViewModel의 레지스트리처럼 작동한다. 즉, 액티비티(여기선 MainActivity) 인스턴스가 처음으로 QuizViewModel을 요청하면 ViewModelProvider가 새로운 QuizViewModel 인스턴스를 생성하고 반환한다. 그리고 장치 구성이 변경되어 새로 생성된 MainActivity 인스턴스가 QuizViewModel을 또 요청하면 QuizViewModel 인스턴스가 새로 생성되지 않고 최초 생성되었던 인스턴스가 반환된다. 또한, MainActivity 인스턴스가 종료되어(예를 들어, 사용자가 백 버튼을 눌러서) 소멸될 때는 QuizViewModel 인스턴스도 같이 메모리에서 제거된다.

ViewModel 생명주기와 ViewModelProvider

사용자가 액티비티를 끝낸다는 것은 그 당시 UI 상태가 더 이상 필요 없음을 의미하므로 상태 데이터를 초기화하면 된다. 이와 달리 사용자가 장치를 회전해서 액티비티 화면의 방향이 바뀔 때는 회전 이전과 이후의 UI 상태는 같아야 한다. 사용자는 계속 같은 화면을 볼 수 있기를 기대하기 때문이다.

액티비티의 isFinishing 속성으로 이런 두 가지 시나리오 중 어느 것에 해당되는지 판단할 수 있다.

  1. 만일 isFinishingtrue면 사용자가 액티비티를 끝냈음을 의미한다(예를 들어, 백 버튼을 누르거나 오버뷰 화면에서 해당 앱 카드를 없앴을 때).
    • 따라서 현재의 액티비티 인스턴스가 소멸되더라도 이 당시의 UI 상태는 보존할 필요가 없다.
  2. 그렇지 않고 isFinishingfalse면 장치의 회전에 따른 구성 변경으로 인해 시스템이 현재의 액티비티 인스턴스를 소멸시킨다는 것을 의미한다.
    • 따라서 사용자가 계속 같은 화면을 볼 수 있도록 UI 상태가 보존되어야 한다.
    • 이때 ViewModel을 사용하면 다른 방법을 사용하지 않아도 액티비티의 UI 상태 데이터를 메모리에 보존할 수 있다.

ViewModel의 생명주기는 사용자의 기대를 더 잘 반영하는데, 이는 장치의 구성 변경이 생겨도 계속 존재하다가 액티비티가 종료될 때만 소멸되기 때문이다.

위의 MainActivity 코드에서 했던 것처럼, ViewModel 인스턴스는 액티비티 생명주기와 연동된다. ViewModel 인스턴스는 액티비티 상태 변화와 무관하게 액티비티가 종료될 때까지 메모리에 남아 있다가 액티비티가 종료되면 소멸된다.

MainActivity와 연동되는 QuizViewModel

장치의 회전 등에 따른 구성 변경이 생길 때마다 현재의 액티비티 인스턴스는 소멸되고 다시 새 인스턴스가 생성되지만, 액티비티와 연관되는 ViewModel은 메모리에 남는다.

장치 회전 시 MainActiviy와 QuizViewModel

로그캣으로 확인해보기

QuizViewModel의 인스턴스가 생성됨

앱 실행 시, MainActivity 인스턴스가 생성되고 onCreate(Bundle?)에서 최초로 ViewModel을 요청할 때 새로운 QuizViewModel 인스턴스가 생성됨을 알 수 있다.

장치 회전 시, MainActivity 인스턴스는 소멸되지만 QuizViewModel 인스턴스는 남아있음

장치 회전 시, MainActivity 인스턴스는 소멸되지만 QuizViewModel 인스턴스는 남아있음을 알 수 있다. 장치가 회전된 후 새로운 MainActivity 인스턴스가 생성될 대 QuizViewModel을 다시 요청한다. 그런데 이전에 생성된 QuizViewModel 인스턴스가 여전히 메모리에 남아있으므로 ViewModelProvide는 새 인스턴스를 생성하지 않고 기존 인스턴스를 반환한다. (QuizViewModel@5a77eca를 그대로 사용하는 것을 확인할 수 있다.)

MainActivity 인스턴스와 QuizViewModel 인스턴스가 모두 소멸됨

백 버튼을 누르면 MainActivity 인스턴스가 소멸될 때 QuizViewModel 인스턴스도 같이 소멸됨을 알 수 있다. 이때 QuizViewModel의 onCleared()가 호출된다.

MainActivity와 QuizViewModel 간의 관계는 단방향이다. 즉, 액티비티는 ViewModel을 참조하지만, ViewModel은 액티비티를 참조하지 않는다. ViewModel은 액티비티나 다른 뷰의 참조를 가지면 안 된다. 메모리 유실(memory leak) 이 생길 수 있기 때문이다.

소멸되어야 하는 객체의 참조를 다른 객체가 가지면 메모리 유실이 생길 수 있다. 이때 참조되는 객체를 가비지 컬렉터가 메모리에서 제거할 수 없게 된다(이것을 강한 참조(strong reference)라고 한다). 구성 변경으로 인한 메모리 유실은 흔히 생기는 결함이다.

장치 회전 시에 액티비티 인스턴스는 소멸되지만, ViewModel 인스턴스는 메모리에 남는다. 그런데 ViewModel 인스턴스가 액티비티 인스턴스에 대해 강한 참조를 가지면 다음 두 가지 문제가 생길 수 있다.

  1. 액티비티 인스턴스가 메모리에서 제거되지 않아 이 인스턴스가 사용하는 메모리가 유실된다.
  2. ViewModel 인스턴스가 과거 액티비티의 뷰를 변경하려고 하면 IllegalStateException이 발생한다.

ViewModel에 데이터 추가하기

QuizViewModel에 모델 데이터와 비즈니스 로직 추가하기

  • currentQuestionAnswercurrentQuestionText는 연산 프로퍼티 속성이다.
    • 이것은 다른 프로퍼티의 값을 사용해서 산출된 값을 자신의 값으로 반환하므로 이 프로퍼티의 값을 저장하는 필드 backing field가 클래스 인스턴스에 생기지 않는다.
    • get()은 프로퍼티의 값을 반환하는 접근자 accessor이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class QuizViewModel : ViewModel() {
// 모델 데이터 추가 ↓
var currentIndex = 0

private val questionBank = listOf(
Question(R.string.question_australia, true),
Question(R.string.question_oceans, true),
Question(R.string.question_mideast, false),
Question(R.string.question_africa, false),
Question(R.string.question_americas, true),
Question(R.string.question_asia, true)
)

// 비즈니스 로직 추가 ↓
val currentQuestionAnswer: Boolean
get() = questionBank[currentIndex].answer

val currentQuestionText: Int
get() = questionBank[currentIndex].textResId

fun moveToNext() {
currentIndex = (currentIndex + 1) % questionBank.size
}
}
  • ViewModel은 사용하기 쉽도록 자신과 연관된 화면에서 필요한 모든 데이터를 저장하고 형식화한다. 따라서 프레젠테이션 로직 코드를 액티비티와 분리할 수 있어서 액티비티를 좀 더 간단하게 유지할 수 있다.
    • 가능한 한 액티비티를 간단히 유지하는 것이 좋은 이유는 액티비티에 추가되는 모든 코드는 뜻하지 않게 액티비티 생명주기의 영향을 받을 수 있기 때문이다.
    • 간단하게 유지하면 액티비티는 화면에 나타나는 것을 처리하는 것만 집중하고, 보여줄 데이터를 결정하는 내부로직은 신경 쓰지 않아도 된다.

늦게 초기화되는 QuizViewModel

1
2
3
4
5
6
7
8
9
10
11
12
13
class MainActivity : AppCompatActivity() {
...
// by lazy 키워드 사용
private val quizViewModel: QuizViewModel by lazy {
ViewModelProvider(this).get(QuizViewModel::class.java)
}

override fun onCreate(savedInstanceState: Bundle?) {
...
}
...
}

  • by lazy 키워드를 사용하면 quizViewModel을 var이 아닌 val 속성으로 선언할 수 있어 좋다.
    • 액티비티 인스턴스가 생성될 때 QuizViewModel 인스턴스 참조를 quizViewModel에 한번만 저장하기 때문.
  • 또한, by lazy 키워드를 사용하면 최초로 quizViewModel이 사용될 때까지 초기화를 늦출 수 있다.
    • MainActivity 인스턴스가 생성된 후 호출되는 onCreate(Bundle?)에서 quizViewModel이 사용되므로 이때 quizViewModel이 QuizViewModel 인스턴스 참조로 초기화되어 안전하게 사용할 수 있다.

QuizViewModel로부터 문제, 정답 및 인덱스 가져오기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
nextButton.setOnClickListener {
// currentIndex = (currentIndex + 1) % questionBank.size
quizViewModel.moveToNext()
updateQuestion()
}
...
}
...
private fun updateQuestion() {
// val questionTextResId = questionBank[currentIndex].textResId
val questionTextResId = quizViewModel.currentQuestionText
questionTextView.setText(questionTextResId)
}

private fun checkAnswer(userAnswer: Boolean) {
// val correctAnswer = questionBank[currentIndex].answer
val correctAnswer = quizViewModel.currentQuestionAnswer
...
}

ViewModel을 사용함으로써 회전하기 직전에 보던 문제를 MainActivity가 기억해서 보여준다. 이로써 장치 회전으로 생긴 UI 상태 유실 결함은 해결되었다. 하지만 아직 눈에 띄지 않는 또 다른 결함이 있다.

프로세스 종료 시에 데이터 보존하기

안드로이드 운영체제가 앱의 프로세스를 소멸시킬 때는 메모리에 있는 앱의 모든 액티비티들과 ViewModel들이 제거되지만, 액티비티나 ViewModel의 그 어떤 생명주기 콜백 함수도 호출하지 않는다.

그렇다면 액티비티가 소멸될 때 UI 상태 데이터를 보존해 액티비티의 재구성에 사용할 수 있는 방법은 무엇일까? SIS(Saved Instance State, 저장된 인스턴스 상태) 에 데이터를 저장하는 것이 방법이 될 수 있다. SIS는 안드로이드 운영체제가 일시적으로 액티비티 외부에 저장하는 데이터이며, Activity.onSaveInstanceState(Bundle)을 오버라이드해 SIS에 데이터를 추가할 수 있다.

액티비티가 ‘중단’ 상태로 바뀔 때는 언제든지 안드로이드 운영체제가 Activity.onSaveInstanceState(Bundle)을 호출한다. 중단된 액티비티는 종료 대상이 되므로 이때 시점이 중요하다. 만일 우선순위가 낮은 백그라운드 앱이라서 앱의 프로세스가 종료된다면 Activity.onSaveInstanceState(Bundle)이 이미 호출되었다고 생각하면 된다.

액티비티의 슈퍼 클래스에 기본 구현된 onSaveInstanceState(Bundle)에서는 현재 액티비티의 모든 뷰가 자신들의 상태를 Bundle 객체의 데이터로 저장한다. Bundle은 문자열 키와 값을 쌍으로 갖는 구조체다. onCreate(Bundle?)의 인자로 전달되는 Bundle 객체를 앞에서 이미 보았다.

1
2
3
4
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
}

그리고 오버라이드한 onCreate(Bundle?)에서는 인자로 받은 Bundle 객체를 액티비티의 슈퍼 클래스에 정의된 onCreate(Bundle?)에 전달해 호출한다. 그러면 슈퍼 클래스의 onCreate(Bundle?)에서는 인자로 받은 Bundle 객체에 저장된 뷰들의 상태 데이터를 사용해서 액티비티의 뷰 계층을 다시 생성한다.

onSaveInstanceState(Bundle) 오버라이드하기

  • 액티비티의 슈퍼 클래스에 기본 구현된 onSaveInstanceState(Bundle)에서는 현재 액티비티의 모든 뷰가 자신들의 상태를 Bundle 객체의 데이터로 저장
  • 액티비티에서 onSaveInstanceState(Bundle)을 오버라이드하면 추가적으로 Bundle 객체에 데이터를 저장 가능하며 onCreate(Bundle?)에서 다시 받을 수 있다.

키로 이용할 상수 추가하기

1
2
3
4
5
6
private const val TAG = "MainActivity"
private const val KEY_INDEX = "index" // Bundle 객체에 저장될 데이터의 키로 사용

class MainActivity : AppCompatActivity() {
...
}

onSaveInstanceState(Bundle) 오버라이드하기

  • currentIndex의 값을 Bundle 객체에 저장
    • 이때 키는 상수인 KEY_INDEX이며 키의 값은 currentIndex다.
1
2
3
4
5
6
7
8
9
10
11
12
13
override fun onPause() {
...
}

override fun onSaveInstanceState(savedInstanceState: Bundle) {
super.onSaveInstanceState(savedInstanceState)
Log.d(TAG, "onSaveInstanceState")
savedInstanceState.putInt(KEY_INDEX, quizViewModel.currentIndex)
}

override fun onStop() {
...
}

onCreate(Bundle?)에서 Bundle 객체 값 확인하기

  • 마지막으로 onCreate(Bundle?)에서는 Bundle 객체에 저장된 값을 확인해 값이 있으면 그 값을 currentIndex에 지정하면 된다.
    • 키(“index”)가 Bundle 객체에 없거나 Bundle 객체 참조가 null이면 currentIndex의 값을 0으로 설정한다.
1
2
3
4
5
6
7
8
9
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate(Bundle?) called")
setContentView(R.layout.activity_main)

val currentIndex = savedInstanceState?.getInt(KEY_INDEX, 0) ?: 0
quizViewModel.currentIndex = currentIndex
...
}
  • onCreate(Bundle?)에서는 null이 될 수 있는 Bundle 객체 참조를 인자로 받는다.
    • 액티비티의 새로운 인스턴스가 최초로 생성될 때는 상태 데이터가 없으므로 Bundle 객체 참조가 null이 되기 때문.
    • 하지만 장치 회전이나 프로세스 종료 후에 액티비티 인스턴스가 다시 생성될 때는 Budle 객체 참조가 null이 아니며, 이때는 onSaveInstanceState(Bundle)에서 추가한 키와 값의 쌍으로 된 데이터가 Bundle 객체에 포함된다.
  • 물론 Bundle 객체에는 프레임워크에서 추가한 정보 예로, EditText의 값이나 다른 기본 UI 위젯의 상태 데이터도 포함될 수 있다.

복원 테스트 해보기

설정 → 개발자 옵션에서 '앱’으로 표시된 항목의 ‘활동 유지 안함’ 옵션을 활성화하여 사용자가 종료하면 즉시 모든 작업을 삭제시킬 수 있다.


‘활동 유지 안함’ 비활성화 상태에서 홈 버튼 클릭

  • onDestroy() 호출이 되지 않아 액티비티가 소멸하지 않고 중단된 상태로 유지된다.

‘활동 유지 안함’ 활성화에서 홈 버튼 클릭

  • onDestroy() 호출이 되어 중단 상태의 액티비티가 소멸되어 메모리에서 제거된다.
  • 따라서 새로 생성된 액티비티 인스턴스의 onCreate(Bundle?)에서는 소멸된 인스턴스에서 Bundle 객체에 저장했던 문제의 인덱스를 사용해서 현재 액티비티 인스턴스의 문제 인덱스를 복원할 수 있다.

onSaveInstanceState(Bundle)를 오버라이드하여 Bundle 객체에 저장하지 않았을 때는 홈 버튼 클릭 후 다시 앱을 실행시키면 홈 버튼을 누를 당시의 문제가 유지되지 않고 첫번째 문제 인덱스인 '캔버라는 호주의 수도이다’가 표시되지만, onSaveInstanceState(Bundle)를 오버라이드하여 Bundle 객체에 currentIndex 값을 저장시키면 홈 버튼 클릭 후 다시 앱을 실행시켜도 홈 버튼을 누를 당시의 문제가 그대로 보인다. 아래 이미지에서 ‘수에즈 운하는 홍해와 인도양을 연결한다’ 문제가 그대로 유지되고 있는 것을 확인할 수 있다.

위의 테스트에서는 액티비티가 확실하게 메모리에서 제거되도록 하기 위해 임시로 ‘활동 유지 안함’ 옵션을 활성화했지만, 이 경우에는 성능 저하가 생길 수 있으므로 테스트가 끝나면 해당 옵션을 비활성화 하도록 한다. 홈 버튼 대신 백 버튼을 누르면 ‘활동 유지 안함’ 옵션과는 무관하게 항상 액티비티가 소멸된다는 사실을 알아두자.

SIS와 액티비티 레코드

액티비티(프로세스)의 소멸에도 어떻게 onSaveInstanceState(Bundle)에 저장된 데이터가 존속할까? onSaveInstanceState(Bundle)이 호출될 때 데이터가 저장된 Bundle 객체는 안드로이드 운영체제에 의해 액티비티 레코드 activity record 로 저장되기 때문이다.

아래는 액티비티 레코드가 무엇인지 알기 위해 액티비티 생명주기에 보존 stashed 상태가 추가된 이미지이다.

완전한 액티비티 생명주기

액티비티가 보존 상태이면 액티비티 인스턴스는 존재하지 않지만, 액티비티 레코드 객체는 안드로이드 운영체제에 살아있다. 따라서 안드로이드 운영체제는 해당 액티비티 레코드를 사용해서 액티비티를 되살릴 수 있다.

액티비티는 onDestroy()가 호출되지 않고 보존 상태가 될 수 있다. 그러므로 장치에 이상이 생기지 않는 한, onStop()onSaveInstanceState(Bundle)이 호출되는 거에 의존해서 코드를 작성하면 된다. 일반적으로는 현재 액티비티에 속하는 작고 일시적인 상태 데이터를 Bundle 객체에 보존하기 위해 onSaveInstanceState(Bundle)을 오버라이드 한다. 그리고 지속해서 저장할 데이터(예로, 사용자가 입력/수정한 것)는 onStop()을 오버라이드해서 처리한다. 이 함수가 실행된 후에는 언제든 해당 액티비티가 소멸될 수 있기 때문이다.

그런데 액티비티 레코드는 언제 없어질까? 액티비티가 종료되면 액티비티 레코드도 같이 소멸된다. 액티비티 레코드는 장치가 다시 부팅될 때도 폐기된다.

ViewModel vs SIS

SIS에는 프로세스가 종료될 때는 물론이고, 장치의 구성 변경이 생길 때도 Bundle 객체를 사용해서 액티비티 레코드를 저장할 수 있다. 액티비티가 최초 실행될 때는 SIS의 Bundle 객체 참조가 null이다. 그리고 장치를 회전하면 안드로이드 운영체제가 현재 액티비티 인스턴스의 onSaveInstanceState(Bundle)을 호출하므로 보존할 상태 데이터를 이 함수에서 Bundle 객체에 저장할 수 있다. 그리고 이후에 새로운 액티비티 인스턴스가 생성되면 안드로이드 운영체제가 Bundle 객체에 저장된 상태 데이터를 onCreate(Bundle?)의 인자로 전달한다.

그렇다면 SIS만 사용해도 충분한데, GeoQuiz 앱에서는 굳이 ViewModel도 같이 사용할까? 사실 GeoQuiz 앱은 간단해서 SIS만 사용해도 된다.

그러나 대부분의 앱은 GeoQuiz처럼 작으면서 하드코딩된 데이터에 의존하지 않는다. 대신에 데이터베이스, 인터넷, 또는 둘 다로부터 동적인 데이터를 가져온다. 그리고 이런 작업은 비동기적이면서 느릴 수 있으며, 장치의 배터리나 네트워크 리소스를 많이 사용한다. 또한, 이런 작업을 액티비티 생명주기와 결속해서 처리하면 오류도 많이 생길 수 있다.

ViewModel의 진가는 액티비티의 동적 데이터를 처리할 때 발휘된다. ViewModel은 장치의 구성 변경이 생겨도 다운로드 작업을 계속할 수 있게 해준다. 그리고 이미 알고 있듯이, 사용자가 액티비티를 끝내면 ViewModel은 자동으로 클린업이 된다.

하지만 프로세스가 종료되면 ViewModel이 처리하지 못한다. 자신이 가진 모든 것이 프로세스와 함께 메모리에서 완전히 제거되기 때문이다. SIS가 주목받는 이유가 바로 이 때문이다. 그런데 SIS에는 제약이 있다. SIS는 직렬화되어 serialized 디스크에 저장되므로 크거나 복잡한 객체를 저장하는 것은 피해야 한다.

그런데 구글 안드로이드 팀의 적극적인 ViewModel 개선 작업으로 lifecycle-viewmodel-savedstate 라이브러리가 새로 배포되었는데, 이 라이브러리는 프로세스가 종료될 때 ViewModel이 자신의 상태 데이터를 보존할 수 있게 해준다. 따라서 액티비티의 SIS와 더불어 ViewModel 사용할 때의 어려움을 덜어줄 것이다.

이제는 ViewModel이나 SIS 중 어느 것이 더 좋은가는 문제되지 않으므로 두 가지를 절충해서 사용하면 된다.

  • UI 상태를 다시 생성하기 위해 필요한 소량의 정보를 저장할 때 -> SIS
  • 장치의 구성 변경이 생겨서 UI에 넣는데 필요한 많은 데이터에 빠르고 쉽게 접근하고자 메모리에 캐싱할 때 -> ViewModel

프로세스가 종료된 후 액티비티 인스턴스가 다시 생성될 때는 SIS 데이터를 사용해서 ViewModel을 설정할 수 있다. 이렇게 하면 ViewModel과 액티비티가 절대 소멸되지 않는 것처럼 처리할 수 있다.

그런데 장치의 구성 변경 후에 SIS 데이터를 사용해서 ViewModel을 변경하면 앱에서 불필요한 작업을 하게 된다. 구성 변경 시에는 ViewModel이 메모리에 남아 있기 때문이다. 또한, ViewModel의 변경 작업으로 사용자가 기다리게 되거나 쓸데없이 리소스 배터라를 사용하게 된다.

이 문제를 해결하려면 ViewModel의 데이터를 변경하기 위해 더 많은 작업이 필요할 때는 ViewModel의 데이터 갱신이 필요한지 먼저 검사한 후에 데이터를 가져오는 작업을 수행하고 변경한다.

1
2
3
4
5
6
7
8
9
class SomeFancyViewModel : ViewModel() {
...
fun setCurrentIndex(index: Int) {
if (index != currentIndex) {
currentIndex = index
// 현재의 문제를 데이터베이스에서 로드한다.
}
}
}

여기서는 문제의 인덱스 값을 현재의 인덱스 값과 비교해서 다를 때만 해당 인덱스의 문제를 데이터베이스 등에서 새로 가져온다. 같으면 이미 문제를 갖고 있는 것이기 때문이다. 따라서 필요할 때만 ViewModel 데이터의 변경 작업이 수행된다.

장기간 저장하는 데이터의 경우에는 ViewModel이나 SIS 모두 해결책이 아니다. 따라서 액티비티의 상태와 무관하게 앱이 장치에 설치되어 있는 동안 계속 남아 있어야 할 데이터를 저장해야 한다면 다른 영구 저장소를 사용해야한다. 이때 데이터베이스와 shared preference를 사용할 수 있다.

섣부른 해결책 피하기

장치의 구성 변경으로 인한 앱의 결함(UI 상태 유실)을 앱 회전을 비활성화해서 해결하려는 시도는 장치 회전에 따른 문제는 해결하겠지만, 앱의 다른 결함을 일으키기 쉽다. 개발이나 테스트할 때는 잘 나타나지 않지만, 사용자는 틀림없이 생명주기와 관련해서 다음 두 가지 결함에 직면할 여지를 남기기 때문이다.

  1. 런타임 시에 생길 수 있는 구성 변경이 있다.
    • 창 크기 조정이나 야간 모드 변경 등의 구성 변경이 예시이다. 물론 이런 구성 변경도 별도로 잡아내어 무시하거나 처리할 수 있을 것이다. 하지만 런타임 구성 변경에 따라 올바른 리소스를 자동 선택해주는 시스템의 기능을 비활성화시키기 때문에 나쁜 방법이다.
  2. 회전의 비활성화나 첫째 방법과 같은 구성 변경 처리는 프로세스 중단으로 인한 문제를 해결하지 못한다.
    • 따라서 앱에서 필요해서 가로나 세로 방향으로 고정시키고 싶다고 하더라도 구성 변경과 프로세스 중단에 대비하는 코드를 여전히 작성해야 한다. 이를 위해선 ViewModel과 SIS를 잘 알아야 한다.

Jetpack, AndroidX 그리고 아키텍처 컴포넌트

ViewModel을 포함하는 lifecycle-extensionslifecycle-viewmodel 라이브러리는 안드로이드 Jetpack 컴포넌트의 일부다. 줄여서 Jetpack이라고 하는 안드로이드 Jetpack 컴포넌트는 안드로이드 앱 개발을 더욱더 쉽게 하고자 구글이 만든 라이브러리의 모음이며, developer.android.com/jetpack에서 모든 Jetpack 라이브러리의 내역을 볼 수 있다. app 모듈의 build.gradle 파일에 해당 라이브러리의 의존성을 추가하면 어떤 Jetpack 라이브러리도 프로젝트에 포함시킬 수 있다.

각 Jetpack 라이브러리는 androidx 네임스페이스로 시작하는 패키지에 위치한다. 이러한 이유로 때로는 'AndroidX’와 'Jetpack’을 혼용하기도 한다.

Jetpack 라이브러리는 기반(foundation), 아키텍처(architecture), 행동(behavior), UI의 네 가지 범주로 분류된다. 이 중에서 아키텍처 범주의 라이브러리들을 아키텍처 컴포넌트 architecture components 라고도 한다. ViewModel도 이런 아키텍처 컴포넌트 중 하나다. 다른 주요 아키텍처 컴포넌트는 Room, Data Binding, WorkManager가 있다.

일부 Jetpack 컴포넌트들은 완전히 새로운 것인 반면에, 다른 컴포넌트는 지원 라이브러리 support library로 불렸던 이전의 많은 라이브러리를 소수의 더 큰 라이브러리로 모아 놓은 것이다. 따라서 이제부터는 종전의 지원 라이브러리 대신 Jetpack(AndroidX) 버전을 사용한다.

댓글