일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 스택 큐 차이
- android ar 개발
- rxjava cold observable
- 큐 자바 코드
- jvm 작동 원리
- 안드로이드 라이선스 종류
- 2022 플러터 설치
- android retrofit login
- 안드로이드 레트로핏 crud
- 스택 자바 코드
- 안드로이드 유닛 테스트
- 자바 다형성
- 안드로이드 라이선스
- 플러터 설치 2022
- ar vr 차이
- 객체
- 서비스 쓰레드 차이
- 안드로이드 os 구조
- 멤버변수
- 안드로이드 유닛 테스트 예시
- rxjava disposable
- 서비스 vs 쓰레드
- rxjava hot observable
- jvm이란
- 클래스
- ANR이란
- 안드로이드 유닛테스트란
- Rxjava Observable
- 2022 플러터 안드로이드 스튜디오
- 안드로이드 레트로핏 사용법
- Today
- Total
나만을 위한 블로그
[Android] 뷰모델이란? + 뷰모델 코틀린 예제 본문
MVVM 패턴을 구현하면 필수적으로 뷰모델이란 걸 구현해서 사용해야 한다.
그런데 이 뷰모델은 무슨 역할을 하는 것인가? 안드로이드 디벨로퍼에선 이렇게 말한다.
https://developer.android.com/topic/libraries/architecture/viewmodel?hl=ko
뷰모델 클래스는 수명 주기를 고려해 UI 관련 데이터를 저장하고 관리하도록 설계됐다. 뷰모델 클래스를 사용하면 화면 회전 같이 구성을 변경할 때도 데이터를 유지할 수 있다.
안드로이드 프레임워크는 액티비티, 프래그먼트 같은 UI 컨트롤러의 수명 주기를 관리한다. 프레임워크는 특정 사용자 작업이나 완전히 통제할 수 없는 기기 이벤트에 대한 응답으로 UI 컨트롤러를 제거하거나 다시 만들도록 결정할 수 있다. 시스템에서 UI 컨트롤러를 제거하거나 다시 만드는 경우, 컨트롤러에 저장된 모든 일시적인 UI 관련 데이터가 삭제된다. 액티비티 중 하나에 사용자 목록이 포함돼 있는데 구성이 변경되어 액티비티가 재생성되면 새 액티비티가 사용자 목록을 다시 가져와야 한다. 데이터가 단순하다면 액티비티는 onSaveInstanceState()를 써서 onCreate()의 번들에서 데이터를 복원할 수 있다. 하지만 이 접근 방법은 사용자 목록, 비트맵 같은 대용량일 가능성이 높은 데이터가 아닌 직렬화했다가 다시 역직렬화할 수 있는 소량의 데이터에만 적합하다.
다른 문제는 UI 컨트롤러가 반환하는 데 시간이 걸릴 수 있는 비동기 호출을 자주 해야 한다는 점이다. UI 컨트롤러는 비동기 호출을 관리해야 하며, 메모리 누수 가능성을 방지하기 위해 호출이 제거된 후 시스템에서 호출을 정리하는지 확인해야 한다. 관리에는 많은 유지관리가 필요하며, 구성 변경 시 개체가 다시 생성되는 경우 개체가 이미 수행된 호출을 다시 호출해야 할 수 있으므로 리소스가 낭비된다.
액티비티, 프래그먼트 같은 UI 컨트롤러의 목적은 기본적으로 UI 데이터를 표시하거나, 사용자 작업에 반응하거나, 권한 요청과 같은 운영체제 커뮤니케이션을 처리하는 것이다. 또한 UI 컨트롤러에 데이터베이스나 네트워크에서 데이터 로드를 책임지도록 요구하면 클래스가 팽창된다. UI 컨트롤러에 과도한 책임을 할당하면 다른 클래스로 작업이 위임되지 않고, 단일 클래스가 혼자서 앱의 작업을 모두 처리하려고 할 수 있다. 또한 이런 방법으로 UI 컨트롤러에 과도한 책임을 할당하면 테스트가 훨씬 어려워진다. UI 컨트롤러에서 뷰 데이터 소유권을 분리하는 방법이 훨씬 쉽고 효율적이다...(중략)...뷰모델의 목적은 UI 컨트롤러의 데이터를 캡슐화해서 구성이 변경돼도 데이터를 유지하는 것이다.
정리하면 디벨로퍼에서 말하는 뷰모델에 대한 내용은 아래와 같다.
- 화면 회전 등 화면 구성이 변할 때 생명주기를 고려해 UI 데이터를 저장하고 관리하는 곳이다
- 비트맵 등 대용량 데이터를 저장할 때 사용한다
- 자주 비동기 호출을 수행할 때 리소스를 관리하는 요소가 필요하다
- UI 단에 데이터 관리 등 많은 책임이 할당되면 테스트가 어려워지는데, 테스트를 수월하게 하기 위해 데이터를 관리하는 요소가 필요하다
위의 경우를 대비해 만들어진 것이 뷰모델이라고 이해했다. 같은 페이지의 'ViewModel의 수명 주기' 파트에서 생명주기와 관련된 내용을 더 자세하게 설명하고 있다.
ViewModel 객체의 범위는 뷰모델을 가져올 때 ViewModelProvider에 전달되는 LifeCycle로 지정된다. 뷰모델은 범위가 지정된 LifeCycle이 영구적으로 경과될 때까지, 즉 액티비티에선 액티비티가 끝날 때까지, 그리고 프래그먼트에선 프래그먼트가 분리될 때까지 메모리에 남아 있다.
아래 그림에선 액티비티가 회전을 거친 다음 끝날 때까지 액티비티의 다양한 생명주기 상태를 보여준다. 또한 관련 액티비티 생명주기 옆에 뷰모델의 전체 기간도 보여준다. 이 특정 다이어그램에선 액티비티의 상태를 보여준다. 동일한 기본 상태가 프래그먼트 생명주기에 적용된다
액티비티가 시작하고 종료될 때까지 여러 생명주기 메서드가 호출되는데 뷰모델은 계속해서 같은 상태인 걸 볼 수 있다.
디벨로퍼에서 많은 내용을 말하고 있지만 잘 이해되지 않아서 다른 블로그들을 확인해봤다.
뷰모델이란 안드로이드 제트팩 구성요소 중 하나로 본래 뷰모델이란 이름은 SW 개발 디자인 패턴 중 하나인 MVVM 패턴으로부터 파생됐다. MVVM의 관점에서 부르는 뷰모델과 안드로이드 제트팩에 포함된 뷰모델 클래스를 구분하기 위해 흔히 안드로이드 제트팩의 뷰모델을 Android Architecture ViewModel의 약자인 AAC ViewModel이라 부르기도 한다.
뷰모델은 왜 필요한가?
MVVM의 관점에서 뷰모델은 View로부터 독립적이며 View가 필요로 하는 데이터만 소유한다. 안드로이드 앱 개발 시 액티비티, 프래그먼트 같은 UI 컨트롤러의 과도한 책임을 분담해 클래스가 거대해지는 걸 방지하고 유지보수, 재사용성, 테스트 등을 용이하게 만들어준다. MVVM 관점의 뷰모델 구현 시 AAC ViewModel을 쓰면 좋다.
뷰모델의 특징
뷰모델은 액티비티에선 액티비티가 완전히 종료될 때까지, 프래그먼트에선 프래그먼트가 분리될 때까지 메모리에 남아있도록 설계되어 있다. 위의 그림을 보면 액티비티가 최초 생성될 때 일반적으로 뷰모델을 인스턴스화해서 생명주기를 함께 시작한다. Configuration 변경(화면 회전 등)이 발생 시 액티비티가 재시작되지만 뷰모델은 여전히 메모리 상에 남아있다. 액티비티 안에서 Configuration 변경과 무관하게 유지되는 NonConfigurationInstances 객체를 따로 관리하기 때문이다.
액티비티의 finish() 호출 등에 의해 액티비티가 생명주기가 종료됨에 따라 내부의 LifecycleEventObserver를 통해 뷰모델도 onCleared()를 호출하고 종료된다.
https://jeongmin.github.io/2020/05/04/android/architecture-components/viewmodel/
안드로이드 앱의 거의 모든 UI 요소들은 액티비티에 속하고, 액티비티는 생명주기를 갖는다. 여기서 생명주기는 크게 액티비티의 생성(onCreate)부터 소멸(onDestroy)까지를 말하고 좀 더 디테일하게 보면 유저에게 최초로 보이는 시점(onStart-onStop), 포커스를 갖게 되는 시점(onResume-onPause) 등을 포함한다.
문제는 액티비티는 시스템에 의해 재생성될 수 있다는 것이다. 이 상황은 단말의 방향 전환(가로/세로 모드), 언어 설정 변경 등을 생각해 볼 수 있다. 이 시스템 설정 변경은 앱 밖에서 발생하기 때문에 이것으로 발생하는 액티비티의 재생성은 내가 제어할 수 없고, 액티비티가 재생성되면 액티비티가 갖고 있던 데이터는 사라진다. 그래서 좋은 유저 경험을 주려면 UI 관련 데이터를 어디 저장해 뒀다가 이를 이용해 다시 화면을 그려줘야 한다.
기존엔 onSaveInstanceState() 콜백을 쓰면 이런 예외적 상황에서 저장하고 싶은 데이터를 Bundle에 저장할 수 있었다. 이렇게 저장한 데이터는 액티비티가 재생성될 때 onCreate()나 onRestoreInstanceState() 콜백을 통해 다시 전달받을 수 있다.
하지만 이 방법은 번들을 쓰기 때문에 저장하려는 데이터의 형태도 제한되고 대량의 데이터를 저장하기엔 제한이 있다. 번들 사용에 대해 가이드하고 있는 공식 문서에선 50KB 미만으로 유지하라고 말한다. 또한 이 방법에서 onSaveInstanceState()는 메인 쓰레드에서 동작해야 하기 때문에, 여기서 데이터를 저장하는 데 시간을 많이 쓰게 되면 그만큼 UI가 버벅거리게 된다.
뷰모델은 액티비티가 재생성될 경우, onDestroy()가 호출되더라도 소멸되지 않는다. 뷰모델은 액티비티 안에서 finish()를 직접 호출하거나 사용자가 액티비티를 닫을 때 소멸된다.
뷰모델이 액티비티의 재생성 시에도 살아남으니 해결책은 간단하다. 액티비티가 재생성됐을 때에도 유지하고 싶은 데이터는 뷰모델에 저장하면 된다.
다른 블로그에서 설명하는 내용들도 디벨로퍼의 내용과 별 다를 게 없었다.
그럼 이 뷰모델을 어떻게 구현해서 사용하는 걸까? 코틀린으로 간단한 예제를 만들어봤다. 먼저 뷰모델을 만들 때 사용하는 ViewModelFactory의 전체 코드다.
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.viewmodelex.viewmodel.CounterViewModel
import java.lang.IllegalArgumentException
class ViewModelFactory: ViewModelProvider.Factory {
val TAG = javaClass.simpleName
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CounterViewModel::class.java)) {
return CounterViewModel() as T
}
throw IllegalArgumentException("뷰모델을 만들 수 없습니다 : IllegalArgumentException")
}
}
ViewModelProvider.Factory라는 인터페이스를 구현하고 create()를 재정의해서 뷰모델을 만드는 곳이다.
여기서 조건문으로 분기하면 한 팩토리에서 여러 뷰모델을 만들어 관리할 수도 있다. 팩토리 패턴이라는 디자인 패턴을 사용한 것이라는데 팩토리 패턴에 대해서 공부해야겠다. 다음은 뷰모델 클래스다.
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class CounterViewModel : ViewModel() {
val TAG = javaClass.simpleName
private val _liveData = MutableLiveData<Int>()
val liveData: LiveData<Int> = _liveData
init {
_liveData.value = 0
}
fun increment() {
_liveData.value = _liveData.value!! + 1
}
fun decrement() {
_liveData.value = _liveData.value!! - 1
}
}
마지막으로 액티비티 파일과 XML 파일들을 수정해준다.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.MainActivity">
<TextView
android:id="@+id/hashcode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Hello World!" />
<TextView
android:id="@+id/counter"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:id="@+id/increment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="+" />
<Button
android:id="@+id/decrement"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-" />
</LinearLayout>
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.example.viewmodelex.R
import com.example.viewmodelex.viewmodel.CounterViewModel
import com.example.viewmodelex.viewmodel.factory.ViewModelFactory
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
val TAG = javaClass.simpleName
private lateinit var viewmodel: CounterViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
hashcode.text = this.hashCode().toString()
setUpViewModel()
setUpListeners()
}
private fun setUpViewModel() {
viewmodel = ViewModelProvider(this, ViewModelFactory()).get(CounterViewModel::class.java)
viewmodel.liveData.observe(this, Observer {
counter.text = it.toString()
})
}
private fun setUpListeners() {
increment.setOnClickListener {
viewmodel.increment()
}
decrement.setOnClickListener {
viewmodel.decrement()
}
}
}
이 예제를 빌드하면 앱 화면 상단에 해시코드가 무작위로 생성되고 그 바로 밑에 0이라는 숫자가 보인다. 그리고 +나 - 버튼을 누르면 숫자가 바뀌는 걸 볼 수 있다.
각 메서드에 로그를 찍어서 어떤 순서로 실행되는지 파악하면 뷰모델이 어떻게 작동하는지 더 잘 알 수 있으니 확인해보자.
'Android' 카테고리의 다른 글
[Android] 인텐트란? (0) | 2021.10.07 |
---|---|
[Android] 액티비티 vs 프래그먼트 차이 (0) | 2021.10.07 |
[Android] 서비스란? (0) | 2021.10.01 |
[Android] 인텐트 필터란? (0) | 2021.09.30 |
[Android] 플레이 스토어 앱 등록 시 failed to decrypt safe contents entry: java.io.IOException: getSecretKey failed: Password is not ASCII 에러 해결 (0) | 2021.09.28 |