이번 포스팅에서는 Android Jetpack의 ViewModel 컴포넌트에 대해 알아보도록 하겠습니다. MVVM 패턴의 ViewModel과는 다른 개념이라는 것을 미리 말씀드립니다.
ViewModel에 대해 설명하기 위해서는 우선 Activity의 Lifecycle을 짚고 넘어가야 할 필요가 있습니다. 프로그램 상태를 안정적으로 계속 유지할 수 있는 데스크탑과는 다르게, 모바일에서는 전화가 걸려온다거나 핸드폰을 꺼서 프로그램이 갑자기 종료되는 일이 빈번히 발생할 수 있습니다. 또한 모바일 기기의 특성상 배터리 지속시간도 중요하기 때문에 여러개의 프로그램을 계속 켜놓고 있게 할 수도 없어, 사용되지 않는 프로그램은 OS에서 강제로 종료시키는 일도 일어나게 됩니다.
그래서 구글은 안드로이드에서 Activity의 상태를 용이하게 관리할수 있도록 Lifecycle 개념을 만들었습니다. Activity의 Lifecycle은 다음과 같은 구조를 가지고 있습니다.

Lifecycle에 따르면 Activity가 처음 생성될 때 onCreate 가 실행되고, 백그라운드로 밀려날 때는 onPause가 실행됩니다. 그리고 어떠한 이유로든 Activity가 종료되면 onDestroy 가 실행되게 됩니다.
문제는 앱이 사용되고 있는 도중에도 여러가지 이유로 Activity가 파괴되고 재생성되는 일이 발생할 수 있다는 건데요, 그 경우 잘 동작하고 있던 Activity는 데이터를 모두 잃어버리고 onCreate부터 다시 작업을 수행하게 됩니다.
예시를 보여드리기 위해 화면의 버튼을 클릭하면 숫자가 올라가는 단순한 카운터를 하나 만들어 보겠습니다. 우선은 프로젝트에 View Binding을 설정합니다.
android {
+ buildFeatures.viewBinding true
}
다음은 화면에 텍스트뷰와 버튼을 추가합니다.
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Increase"
android:layout_margin="30dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" />
</androidx.constraintlayout.widget.ConstraintLayout>
그리고 MainActivity에서 뷰바인딩을 적용한 뒤 버튼을 클릭했을 때의 동작을 작성합니다.
private val binding : ActivityMainBinding by lazy {
ActivityMainBinding.inflate(layoutInflater)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
var counter = 10
binding.textView.text = counter.toString()
binding.button.setOnClickListener {
counter += 1
binding.textView.text = counter.toString()
}
}
버튼을 클릭해서 숫자를 증가시킨 뒤에 화면을 세로모드에서 가로모드로 전환하면 숫자가 다시 10으로 리셋됩니다. 이건 화면 모드가 바뀌면서 기존의 Activity가 파괴된 후 onCreate가 실행되면서 var score = 10 이 다시 수행되었기 때문입니다.
화면 구성에 변화가 일어나면 그에 맞춰 화면을 다시 만들어야 하므로 Activity를 다시 생성하는 것은 스펙에 정해진 정상적인 안드로이드의 동작입니다. 문제는 재생성이 일어나면 Activity가 가지고 있던 데이터가 모두 사라져버린다는 점이죠.
그래서 구글에서는 Activity를 재생성할 때 사라져버리는 휘발성 데이터를 잠깐 어딘가에 보존했다가 다시 꺼내올 수 있게 하는 onSaveInstanceState 를 만들었습니다.
사용법은 다음과 같습니다. 우선 map 형식으로 Bundle에 데이터를 저장하고, onCreate에서 값을 가져오면 됩니다. 참고로 onSaveInstanceState는 Android Pi 이전에서는 onStop 직전에 실행되지만 onPause의 앞인지 뒤인지에 대한 보장은 없습니다. 그래서 Pi 이후 버전에서는 onStop 직후에 실행되도록 변경되었습니다.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("key", "value")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState != null) {
val value = savedInstanceState.getString("key")
}
}
문제는 Bundle이 거대한 데이터를 다루기 위해 만들어진 포맷이 아니라는 점입니다. 구글은 onSaveInstanceState에 사용되는 번들의 크기를 a few KB 로 제한하도록 권장하고 있으며, Bundle은 그 구조상 Serialization에 사용할 수도 없다는 한계가 있습니다.
그래서 구글은 ViewModel 컴포넌트를 만들었습니다. ViewModel은 다음 그림에 보시는 것 처럼 Activity와는 독립된 생명주기를 가지고 있습니다. 따라서 finish()에 의해 Activity가 파괴되고 재생성되는 동안에도 ViewModel은 계속 살아있게 되지요.
따라서 앱이 가지고 있어야 하는 정보를 ViewModel에 저장해두고, Activity는 단순히 ViewModel의 데이터를 불러오는 형태로 앱을 구성하면 화면전환에 의해 Activity가 파괴되고 재생성되어도 UI에 표시중인 데이터를 계속 유지할 수 있습니다.

그러면 카운터 앱에 ViewModel을 도입해 보도록 하겠습니다. 우선은 dependency를 추가하고 카운터를 저장하는 ViewModel을 하나 만들어 줍니다.
dependencies {
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1"
}
class MyViewModel: ViewModel() {
var counter: Int = 0
}
그리고 MainActivity의 counter 대신 ViewModel의 counter를 사용하도록 변경합니다. 이 때 ViewModel 인스턴스를 그냥 생성하면 경우에 따라서는 인스턴스가 여러개 만들어지는 문제가 발생할 수 있기 때문에 ViewModelProvider를 통해서 인스턴스를 싱글톤으로 생성해야 합니다. 싱글톤에 대한 더 구체적인 설명은 알기쉬운 Singleton Pattern 강의를 참조하시기 바랍니다. 다음 코드에서 ViewModelProvider는 MainActivity의 Lifecycle을 따르는 MyViewModel 객체를 생성하게 됩니다.
val myViewModel = ViewModelProvider(this)[MyViewModel::class.java]
myViewModel.counter = 10
binding.textView.text = myViewModel.counter.toString()
binding.button.setOnClickListener {
myViewModel.counter += 1
binding.textView.text = myViewModel.counter.toString()
}
그럼 이제 MainActivity는 카운터를 읽어오고 저장하는데에 myViewModel을 사용하게 됩니다.
그런데 이대로라면 ViewModel을 사용하고는 있지만 onCreate에서 counter값을 10으로 지정하는 로직이 있기 때문에 Activity가 재생성될때마다 값이 다시 10으로 돌아가버리게 됩니다. 이 문제를 해결하기 위해서는 ViewModel을 초기화할때 10을 건네주고 나머지 로직에서는 저장된 값을 사용하도록 하면 됩니다.
이 때 ViewModelProvider로 ViewModel 객체를 만들 때는 생성자를 사용할 수 없게 되어있기 때문에 팩토리 패턴을 사용해야 합니다. 우선은 MyViewModel이 전달받은 초기값을 사용하도록 수정합니다.
class MyViewModel(
+ _counter : Int,
): ViewModel() {
+ var counter: Int = _counter
}
다음은 ViewModelProvider.Factory를 상속받는 팩토리 클래스를 만들어줍니다.
팩토리는 isAssignableFrom을 이용해 전달받은 클래스가 MyViewModel을 상속하고 있는지 판정하고 만약 그렇다면 생성자를 담아서 반환해주는 작업을 하게 됩니다.
@Suppress("UNCHECKED_CAST")
class MyViewModelFactory(private val counter: Int): ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MyViewModel::class.java)) {
return MyViewModel(counter) as T
}
throw IllegalArgumentException("ViewModel class not found")
}
}
그리고 onCreate에서 초기값을 팩토리에 전달하고 ViewModelProvider가 MyViewModel을 생성하도록 합니다. 그러면 드디어 ViewModel이 적용된 숫자카운터가 정상적으로 작동하게 됩니다.
val factory = MyViewModelFactory(10)
val myViewModel = ViewModelProvider(this, factory).get(MyViewModel::class.java)
binding.textView.text = myViewModel.counter.toString()
binding.button.setOnClickListener {
myViewModel.counter += 1
binding.textView.text = myViewModel.counter.toString()
}
ViewModel 객체는 이렇게 직접 ViewModelProvider를 써서 만들 수도 있습니다만, by 키워드를 이용해 간편하게 만들 수도 있습니다. 코틀린에서 by 키워드는 위임을 의미하는데 Activity에서 초기화할 때는 activity-ktx의 ActivityViewModelLazy.kt에 초기화 작업이 위임되며, Fragment에서 초기화할 때는 fragment-ktx의 FragmentViewModelLazy.kt에 작업이 위임됩니다.
우선 위임 작업에 필요한 Dependency를 추가합니다.
implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.fragment:fragment-ktx:1.4.1'
그리고나서 ViewModel 인스턴스를 다음과 같이 초기화하면 됩니다.
val myViewModel by viewModels<MyViewModel>()
팩토리를 적용해야 할 경우 다음과 같이 사용할 수 있습니다.
val myViewModel by viewModels<MyViewModel> { factory }
Fragment에서는 viewModels() 외에 activityViewModels()를 사용할 수도 있습니다. by를 사용하면 ViewModel 객체의 Lifecycle이 현재 초기화를 수행하는 Activity 혹은 Fragment와 연동되게 됩니다. 그런데 activityViewModels()는 Fragment에서 초기화를 수행할 때 ViewModel의 Lifecycle을 Fragment가 아닌, 현재 Fragment를 생성한 Activity의 Lifecycle에 종속시킵니다. 만약 한 Activity에서 여러개의 Fragment를 생성해서 사용하고 있고, 이 Fragment 사이에서 ViewModel을 공유해야 할 경우에 사용할 수 있는 옵션입니다.
val sharedViewModel by activityViewModels<SharedViewModel>()
앞에서 설명했듯이, 액티비티가 아닌 Viewmodel이 데이터를 가지게 하면 라이프사이클이 분리되므로 화면이 회전되어도 상태를 신경 쓸 필요가 없습니다. 다만 메모리 부족등의 이유로 시스템이 앱을 강제종료시킬 경우엔 ViewModel도 데이터를 유지할 방법이 없습니다.
숫자를 증가시킨 상태에서 앱을 백그라운드로 보낸 뒤 다음 명령으로 앱을 강제로 종료시켜보겠습니다.
adb devices
adb -s emulator-5554 shell am kill com.example.applyviewmodel
앱을 다시 포그라운드로 가져오면 종료된 앱이 재실행되고 값이 10으로 돌아간 것을 확인할 수 있습니다. 이렇게 앱이 강제종료 된 경우에도 ViewModel의 데이터를 저장하려면 SavedStateHandle을 사용하면 됩니다. 앞에서 설명했듯이 Saved State는 많은 데이터를 저장할 수는 없지만 시스템에 의한 강제종료가 발생해도 살아남을 수 있습니다.
ViewModel / Saved instance state / Persistent storage의 차이는 다음 표와 같습니다.
| ViewModel | Saved instance state | Persistent storage | |
|---|---|---|---|
| Storage location | in memory | serialized to disk | on disk or network |
| Survives configuration change | Yes | Yes | Yes |
| Survives system-initiated process death | No | Yes | Yes |
| Survives user complete activity dismissal/onFinish() | No | No | Yes |
| Data limitations | complex objects are fine, but space is limited by available memory | only for primitive types and simple, small objects such as String | only limited by disk space or cost / time of retrieval from the network resource |
| Read/write time | quick (memory access only) | slow (requires serialization/deserialization and disk access) | slow (requires disk access or network transaction) |
Saved State는 거의 모든 Kotlin 데이터 타입을 저장할 수 있습니다. 그럼 ViewModel의 counter를 Saved State에 연결하여 시스템에 의한 종료가 발생해도 값이 유지되도록 해 보겠습니다. 우선 다음 savedstate Dependency를 추가합니다.
dependencies {
+ implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.1"
}
그리고 MyViewModel이 생성자로 SavedStateHandle을 받도록 합니다. Saved State에는 Key:Value 형태로 값을 저장하기 때문에 저장과 복원에 사용할 SAVE_STATE_KEY를 정해줍니다. 다음으로 counter 값을 저장하는 saveState를 정의하고, counter는 기본적으로 savedStateHandle에서 값을 가져오되, 만약 그 값이 null 이면 전달받은 초기값을 사용하도록 변경합니다.
class MyViewModel(
_counter : Int,
+ private val savedStateHandle: SavedStateHandle,
): ViewModel() {
- var counter: Int = _counter
+ var counter = savedStateHandle.get<Int>(SAVE_STATE_KEY) ?: _counter
+ fun saveState() {
+ savedStateHandle.set(SAVE_STATE_KEY, counter)
+ }
+ companion object {
+ private const val SAVE_STATE_KEY = "counter"
+ }
}
다음은 MyViewModelFactory가 생성자로 SavedStateHandle을 받을 수 있게 변경하여야 합니다. 기존에는 ViewModelProvider.Factory를 상속하였으나 이번엔 AbstractSavedStateViewModelFactory를 상속하도록 하고 생성자를 변경해줍니다. 그리고 ViewModel을 반환할 때 handle을 함께 반환하도록 하면 됩니다.
@Suppress("UNCHECKED_CAST")
class MyViewModelFactory(
private val counter: Int,
owner: SavedStateRegistryOwner,
defaultArgs: Bundle? = null,
) : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle,
): T {
if (modelClass.isAssignableFrom(MyViewModel::class.java)) {
return MyViewModel(counter, handle) as T
}
throw IllegalArgumentException("Viewmodel class not found")
}
}
마지막으로 MainActivity에서 팩토리를 초기화할 때 SavedStateRegistryOwner를 전달해주고, 카운터 값을 늘릴때마다 saveState를 통해 값을 Saved State에 저장하도록 하면 됩니다.
val factory = MyViewModelFactory(10, this)
val myViewModel by viewModels<MyViewModel> { factory }
binding.textView.text = myViewModel.counter.toString()
binding.button.setOnClickListener {
myViewModel.counter += 1
binding.textView.text = myViewModel.counter.toString()
myViewModel.saveState()
}
이렇게 해서 Jetpack ViewModel을 사용하는 법에 대해 알아보았습니다.