Android

안드로이드 [Kotlin] - ViewModel 파헤쳐보기

🤖 Play with Android 🤖 2022. 10. 3. 14:07
728x90


Android ViewModel에 대해 알아보기 전에 우선 ViewModel이 왜 필요한 지부터 생각해보자.

 

📌  UI 상태 저장 및 복원의 필요성

  • 화면 회전 또는 멀티 윈도우 모드로 전환하는 것과 같이 Configuration이 변경되어도 사용자는 Activity의 UI상태가 그대로 유지하기를 기대한다.

 

Android Configuration이 변경되는 경우

  • 언어 설정을 변경할 때
  • 화면을 가로/세로 회전할 때
  • 폰트 크기나 폰트를 변경했을 때
  • 기기 서비스 국가가 변경되었을 때 등

 

위와 같은 경우 Activity의 onCreate() 콜백 함수가 다시 실행되기 때문에 데이터가 보존되지 못한다.

  • 이를 대체하기 위한 첫 번째 방법은 onSaveInstanceState() 메서드이다.
  • onSaveInstanceState()를 이용하면 map 형식으로 bundle(번들)에 데이터를 저장하고 onCreate()에서 널 체크를 한 뒤에 데이터를 가져올 수 있다.
  • 문제는 bundle(번들)은 거대한 데이터를 다루기 위해 만들어진 포맷이 아니다. (구글에서는 bundle에서 다루는 데이터의 크기를 제한하고 있기도 하다)
  • 또한 bundle(번들)은 구조상 직렬화에 사용할 수도 없다.

따라서 구글이 제안하고 있는 것이 ViewModel이다.

 

 


 

📌  ViewModel이란?

  • ViewModel이란 Android Jepack의 구성요소 중 하나이다.
  • 본래 ViewModel이란 이름은 소프트웨어 개발 디자인 패턴 중 하나인 MVVM(Model — View — ViewModel) 디자인 패턴으로부터 파생되었다.

 

MVVM의 관점에서 부르는 ViewModel과 Android Jetpack에 포함된 ViewModel 클래스를 구분하기 위해 흔히 Android Jetpack에 포함된 ViewModel을 Android Architecture ViewModel의 약자인 AAC ViewModel이라고 부르기도 한다.

 

 

 

🤷‍♂️  ViewModel을 사용하는 이유?

ViewModel은 View로부터 독립적이며, View가 필요로 하는 데이터만을 저장하고 관리한다. 안드로이드 개발 시에도 MVVM 디자인 패턴을 적용하면 Activity나 Fragment 같은 UI 컨트롤러의 과도한 책임을 분담하여 클래스가 거대해지는 것을 방지하고, 유지보수, 재사용성 그리고 테스트 등을 용이하게 만들어 준다. 이에 따라 구글에서도 앱 개발자들에게 MVVM패턴을 사용을 권장하고 있다.

 

처음 말한 것처럼 화면 회전 또는 멀티 윈도우 모드로 전환하는 것과 같이 Configuration이 변경되어도 사용자는 Activity의 UI상태가 그대로 유지하기를 기대한다. Activity는 Configuration이 변경되면 기존 Activity를 소멸시키고 새로운 Activity 인스턴스를 생성하기 때문에 이전 UI상태가 모두 날아가버린다.

 

ViewModel은 Activity에서는 Activity가 완전히 종료될 때까지, 그리고 Fragment에서는 Fragment가 분리될 때까지 메모리에 남아있도록 설계되어있다.

Activity와 ViewModel의 생명주기

 

위의 그림을 보면 Activity의 생명주기와 ViewModel의 생명주기를 함께 확인할 수 있다. 액티비티가 최초 생성될 때 일반적으로 ViewModel을 인스턴스화 하여 생명주기를 함께 시작한다.

 

  • ViewModel은 생명 주기를 고려하여 UI 관련 데이터를 저장하고 관리하도록 설계되었다.
  • ViewModel은 Activity와는 독립된 생명주기를 가지고 있다.
  • 따라서 Activity가 파괴가 되고 재생성되는 경우에도 ViewModel은 계속 살아있게 된다.
  • 따라서 View가 보여줘야 하는 데이터는 ViewModel이 가지고 있고 View는 ViewModel에서 데이터를 불러와 사용하면 안정적으로 데이터를 사용할 수 있게 된다.
  • Activity의 finish() 호출 등에 의해 액티비티가 생명주기가 종료됨에 따라 내부의 LifecycleEventObserver를 통해 ViewModel도 onCleared() 콜백 메서드를 호출하고 종료된다.

 

 

 

🚨  ViewModel을 사용할 때 주의할 점

  • ViewModel 인스턴스를 그냥 생성하면 경우에 따라서는 인스턴스가 여러 개 만들어지는 문제가 발생할 수 있다.
  • 따라서 ViewModelProvider()를 통해 싱글턴으로 인스턴스를 생성하도록 한다.

 

아래 예제 코드를 확인해보자. 버튼을 누를 때마다 ViewModel에 있는 counter가 1씩 증가하는 간단한 코드이다.

// Activity
class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // 뷰모델 적용
        // ViewModelProvider()을 이용하여 싱글턴으로 인스턴스 생성
        val myViewModel = ViewModelProvider(this).get(MyViewModel::class.java) 
        myViewModel.counter = 100 // 하지만 역시 onCreate될때 ViewModel에 100을 건네주기 때문에 데이터를 저장하고 있지 못한다.
        binding.textView.text = myViewModel.counter.toString()

        binding.button.setOnClickListener {
            myViewModel.counter += 1
            binding.textView.text = myViewModel.counter.toString()
        }
}

 

  • 하지만 위 코드 역시 onCreate() 될 때 ViewModel에 100을 건네주기 때문에 데이터를 저장하고 있지 못한다.
  • 이를 해결하기 위해 ViewModel을 초기화할 때 100을 건네주고 데이터를 저장하게 해야 하는데 ViewModelProvider()를 생성할 때 초기값을 주는 것이 금지되어 있다.
  • 따라서 팩토리 패턴(Factory Pattern)을 사용해야 한다.
팩토리 패턴이란 객체를 생성하기 위한 인터페이스를 정의하는데, 어떤 클래스의 인스턴스를 만들지는 서브 클래스에서 결정하게 만드는 패턴이다.
// ViewModel
class MyViewModel(
    _counter : Int,
): ViewModel() {

    var counter: Int = _counter
}

// ViewModelFactory
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")
    }
}

// Activity
class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // 팩토리 패턴을 통해 뷰모델에 초기값 적용
        val factory = MyViewModelFactory(100)
        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에 성공적으로 초기값을 적용하는 것을 확인할 수 있다.
  • 코드를 조금 더 깔끔하게 수정하려면 by 키워드를 통해 ViewModel 객체를 간단하게 생성할 수 있다.

// Activity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(binding.root)

    // 팩토리 패턴을 통해 뷰모델에 초기값 적용
    val factory = MyViewModelFactory(100)
    val myViewModel: by viewModels<MyViewModel>() { factory } // by 키워드를 통해 ViewModel 인스턴스를 간단히 생성
    binding.textView.text = myViewModel.counter.toString()

    binding.button.setOnClickListener {
        myViewModel.counter += 1
        binding.textView.text = myViewModel.counter.toString()
    }
}

 

 

 


 

 

📌  만약 시스템에 의해 Activity가 종료되는 경우 ViewModel은?

우선 우리가 왜 ViewModel을 사용했는지부터 다시 생각해보자.

 

UI 상태 저장 및 복원의 필요성

  • 화면 회전 또는 멀티 윈도우 모드로 전환하는 것과 같이 Configuration이 변경되어도 사용자는 Activity의 UI상태가 그대로 유지하기를 기대한다.
  • Configuration 변경의 경우 ViewModel을 사용하는 것으로 UI 상태를 유지할 수 있다.

 

사용자가 명시적으로 Activity를 종료하는 경우 대체로 UI 상태를 저장할 필요가 없기 때문에 시스템에서는 Activity와 관련된 ViewModel 및 저장된 인스턴스 상태를 메모리에서 삭제한다.

 

 

🚨 하지만 만약 사용자가 명시적으로 Activity를 종료하지 않았는데 Activity가 종료된다면?

  • 시스템에 의해서 Activity가 종료되는 경우 역시 ViewModel도 같이 메모리에서 제거되기 때문에 UI 상태를 보존할 수 없다.
  • 시스템은 RAM에 여유 공간이 필요할 때 프로세스를 종료시킨다.
  • 다음은 프로세스 상태, Activity상태, 시스템이 프로세스를 종료할 가능성 사이의 상관관계를 나타낸다.

https://developer.android.com/guide/components/activities/activity-lifecycle.html?hl=ko#asem[/caption]

 

 

 

결과적으로 "사용자는 UI 상태가 유지되기를 기대했는데 UI 상태가 유지되지 않는 문제" 가 발생할 수 있다.

 

 

 

🤷‍♂️  그렇다면 대책은?

  • 시스템에 의해서 Activity가 종료되는 경우 저장된 인스턴스 상태 또는 데이터베이스에 UI상태를 저장하여 복원하는 방법이 있다.
  • 다음의 표는 UI 상태를 유지하기 위해 고려할만한 사항들을 정리하고 있다.

 

따라서 필요한 경우라면 ViewModel과 저장된 인스턴스 상태(savedInstanceState)를 모두 사용해야 한다.

  • key - value 형식으로 저장이 가능하고 불러오는 것이 가능하다.
  • SavedStateHandle에 저장되는 데이터는 단순하고 가벼워야 한다. 복잡하거나 큰 데이터의 경우 데이터베이스를 사용하도록 하자.

 

 

 

💻  코드 예제 (실습)

ViewModel을 위한 saved state 모듈은 lifecycle 버전 2.2.0에서 추가되었다. 버전은 지속해서 올라갈 수 있다.

dependencies {
	...
    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.0-alpha02"
}

 

ViewModel

// ViewModel
class MyViewModel(
    val savedStateHandle: SavedStateHandle
) : ViewModel() {
    ...
}

SavedStateHandle을 ViewModel에서 다루기 위해서는 우선 ViewModel의 생성자로 다음과 같이 SavedStateHandle을 받아야 한다.

 

 

Activity

// Activity
class MainActivity : AppCompatActivity() {
    private val binding: ActivityMainBinding by lazy {
        ActivityMainBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // AbstractSavedStateViewModelFactory를 확장하는 Factory 클래스
        val factory = MySavedStatedViewModelFactory(application, this)
        val myViewModel = ViewModelProvider(this, factory).get(MyViewModel::class.java)
    }
}

SavedStateHandle을 받도록 ViewModel 인스턴스를 생성하려면 AbstractSavedStateViewModelFactory를 확장하는 Factory 클래스를 만들어야 한다.

 

이제 ViewModel의 SavedStateHandle을 이용하여 간단한 데이터를 저장하고 복원하는 코드를 구현할 수 있다. 이전 예제 처럼 Int형 변수 count에 상태를 저장하고 복원해보자.

 

 

ViewModel

// ViewModel
class MyViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    var count = 0
    set(value) {
        savedStateHandle.set("count",value)
        field = value
    }
    init {
        savedStateHandle.get<Int>("count")?.run {
            count = this
        }
    }
}

 

이처럼 key - value 형태로 값을 저장하고 불러오는 것이 가능하다.

SavedStateHandle 클래스에는 다음과 같이 key - value 값에 필요한 메서드가 있다.

  • get(String key)
  • contains(String key)
  • remove(String key)
  • set(String key, T Value)
  • keys()
  • getLiveData(String key) : 식별 가능한 LiveData 항목으로 래핑 된 값을 반환

위 예제 처럼 필요한 경우라면 ViewModel과 저장된 인스턴스 상태(savedInstanceState)를 모두 사용하여 시스템에 의해 ViewModel이 종료되더라도 UI 상태를 유지할 수 있다.