Activity 또는 fragment 는 데이터를 디스플레이하고, 사용자 입력 및 안드로이드 시스템 이벤트를 캡쳐하는 역할을 하여, UI 에 대한 컨트롤러 역할을 한다.
ViewModel 에는 UI 를 그리는 데 필요한 모든 데이터와, UI 를 최신 상태로 유지하는 기능이 포함되어 있다.
위 다이어그램은 ViewModel이 다양한 라이프사이클 변경을 거치는 동안 관련 활동과 함께 수명을 보여준다. ViewModel은 구성이 변경되는 동안(활동이 삭제되었다가 다시 생성되는 경우)에도 계속 유지된다.
구성 변경 후 동일한 ViewModel 인스턴스를 검색하고 UI를 다시 설정할 수 있으므로, 번들에서 상태를 수동으로 저장했다가 복원할 필요가 없다. ViewModel은 작업이 완료될 때까지 메모리에 남아 있다가 ViewModel onCleared() 메서드를 호출하여 리소스를 정리한다.
ViewModel 은 Kotlin 의 추상클래스다. 추상클래스는 인스턴스화할 수 없으며 서브클래스여야 한다. 이는 ViewModel 이 더이상 사용되지 않을 때 호출되는 Cleared() 메서드에서 보호된다.
dependencies {
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.activity:activity-ktx:$activity_version"
}
class ScoreViewModel : ViewModel() {
var scoreA : Int = 0
var scoreB : Int = 0
fun incrementScore(isTeamA: Boolean) {
if (isTeamA) {
scoreA++
}
else {
scoreB++
}
}
}
추상 ViewModel 클래스를 확장한 ScoreViewModel 클래스를 만든다. ScoreViewModel 은 A팀과 B팀 두 개의 정수 점수를 저장한다.
class MainActivity : AppCompatActivity() {
// Delegate provided by androidx.activity.viewModels
val viewModel: ScoreViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
val scoreViewA: TextView = findViewById(R.id.scoreA)
scoreViewA.text = viewModel.scoreA.toString()
}
MainActivity 에서 kotlin 위임 프로퍼티 By viewModels() 을 사용하여 ScoreModel 을 가져온다.
Within MainActivity onCreate()
val scoreViewA: TextView = findViewById(R.id.scoreA)
val plusOneButtonA: Button = findViewById(R.id.plusOne_teamA)
plusOneButtonA.setOnClickListener {
viewModel.incrementScore(true)
scoreViewA.text = viewModel.scoreA.toString()
}
A팀의 +1 버튼에 클릭 리스너를 추가하여 ViewModel을 호출하여 점수를 올린다.
그런 다음 새로운 점수로 TextView를 업데이트한다.
ViewModel 개체는 앱에 데이터를 저장한다. 레이아웃에서 UI controller 를 매개체로 사용하지 않고, ViewModel 인스턴스가 View 와 직접 소통하게 된다.
ViewModel 개체를 Data Binding 에 전달하면 View 와 ViewModel 개체 간의 일부 통신을 자동화할 수 있다.
<layout>
<data>
<variable>
name="viewModel"
type="com.example.kabaddikounter.ScoreViewModel" />
</data>
<ConstraintLayout ../>
</layout>
XML 에서도 ViewModel 을 사용할 수 있다. 단, 이 선언이 ViewModel 을 자동으로 binding 하지는 않으며, binding 에 노출된 ScoreViewModel 이 있을 것이라고 선언하는 것이다.
class MainActivity : AppCompatActivity() {
val viewModel: ScoreViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: ActivityMainBinding = DataBindingUtil.setContentView(this,
R.layout.activity_main)
binding.viewModel = viewModel
...
ViewModel binding 은 여기서 한다!
in activity_main.xml
<TextView
android:id="@+id/scoreViewA"
android:text="@{viewModel.scoreA.toString()}" />
binding 완료 시 XML 레이아웃에서 ViewModel 을 사용할 수 있다. View 를 inflate 할 때, TextView 는 A Team 의 점수를 반영한다.
override fun onCreate(savedInstanceState: Bundle?) {
...
val binding: ActivityMainBinding = DataBindingUtil.setContentView(this,
R.layout.activity_main)
binding.plusOneButtonA.setOnClickListener {
viewModel.incrementScore(true)
binding.scoreViewA.text = viewModel.scoreA.toString()
}
}
binding.scoreViewA.text = viewModel.scoreA.toString()
ViewModel에서 가져온 "scoreA" 값을 문자열로 변환하여 해당 TextView에 설정한다.
Android에서 Data binding 을 사용하면 XML 레이아웃 파일에서 지정한 android:text 속성이나 다른 속성들이 자동으로 갱신되지 않는다.
따라서 ViewModel의 값이 변경될 때마다 명시적으로 해당 View에 값을 설정해야한다.
Data binding 으로 인해 UI 의 일부 기능 호출이 줄었으나, View 가 변경될 때 새로운 View Model 값으로 업데이트해야하는 번거로움이 있다.
observer design pattern 은 observable 과 observer 간 통신을 지정한다. observable 은 observer 에게 상태 변화에 대해 알린다.
LiveData 는 Android LifeCycle 을 고려한 Data Holder 이며, 데이터 변화를 감지할 수 있는 wrapper 역할을 한다. (다양한 타입 사용 가능)
LiveData<Int>
LiveData는 Int 값을 감싸고 있다. iveData가 데이터를 감싸고 있기 때문에 해당 데이터의 변경 사항을 관찰할 수 있다.
LiveData 는 Observer Pattern 을 기반으로 하고 있다. 이때 Observable은 LiveData이며, 데이터의 변화를 관찰하고자 하는 것들은 "Observer"로서 동작한다. Observer 는, 주로 UI Controller 인 Activity 나 Fragment 와 같은 요소들이다. 이들은 기본 데이터가 변경될 때 UI를 업데이트해야 하므로, LiveData를 통해 데이터의 변화를 감지하고자 한다.
Observer는 LifecycleOwner와 함께 추가되며, Observer는 LifecycleOwner가 활성 상태일 때에만 업데이트를 수신한다. 이는 Android LifeCycle 을 고려한 것이다. 예를 들어, 화면이 사용자에게 보여질 때나, 액티비티가 활성 상태일 때에만 데이터의 변화를 감지하고자 하는 것이다. 이를 통해 앱은 효율적으로 리소스를 관리하고 UI를 업데이트할 수 있게 된다.
LiveData 는 저장된 데이터를 업데이트할 public method 가 없다.(즉, 읽기전용)
MutableLiveData 클래스는 public method 인 setValue(T), postValue(T) 를 노출한다.
ViewModel 에서 MutableLiveData 가 비공개 변수로 사용되는 것이 일반적이며, ViewModel 에서는 변경할 수 없는 LiveData 개체만 Observer 에게 노출한다. UI 의 역할은 LiveData 값 변화에 대응하는 것이지만, ViewModel 은 모든 논리를 구현하고 MutableLiveData 를 업데이트하는 역할을 하기 때문에 우려사항을 분리할 수 있다.
class ScoreViewModel : ViewModel() {
// Mutable LiveData 로 스코어를 저장하는 변수
private val _scoreA = MutableLiveData<Int>(0)
// 외부에서 접근 가능한 LiveData 로 스코어 읽는 변수
val scoreA: LiveData<Int>
get() = _scoreA
// 팀 A의 스코어를 증가시키는 함수
fun incrementScore(isTeamA: Boolean) {
// 팀 A인 경우에만 스코어를 증가시킨다.
if (isTeamA) {
// 현재 스코어를 가져와 1을 더한 후 다시 _scoreA에 할당
_scoreA.value = _scoreA.value!! + 1
}
...
ViewModel 코드를 수정하여 LiveData 를 사용할 수 있다. 외부에서 스코어를 변경하는 것을 원하지 않으므로 private Mutavle LiveData 와 public LiveData 가 있다. _scoreA 는 Int 0을 가지는 Mutable LiceData 로 초기화된다. 스코어를 증가시킬 때가 되면 _scoreA 만 수정한다. 외부 호출자가 public scoreA 의 속성에 엑세스하려고 하면, 클래스는 실제로 _scoreA 를 Mutable LiveData 로 반환한다.
_이 코드는 스코어를 관리하는 ViewModel 이다. ViewModel 내부에는 Team A 의 스코어를 나타내는 MutableLiveData 가 있다. MutableLiveData는 LiveData의 서브클래스로, 데이터의 변경이 가능한(Mutable) 버전이다.
_이 코드에서 _scoreA는 초기값 0을 갖는 Mutable LiveData로 초기화되었다. 이후 scoreA라는 공개된 LiveData가 _scoreA를 읽기 전용으로 노출시킨다. 외부에서는 이 scoreA를 통해 현재 팀 A의 스코어를 관찰할 수 있다.
_incrementScore 함수는 팀 A인 경우에만 스코어를 증가시킨다. 이때 _scoreA.value!! + 1을 통해 현재 스코어를 가져와 1을 더한 후 다시 _scoreA에 할당한다. 이로써 스코어의 변경은 _scoreA를 통해서만 이루어지며, 외부에서는 scoreA를 통해 변경 사항을 관찰할 수 있게 된다.
binding.plusOneButtonA.setOnClickListener {
viewModel.incrementScore(true)
}
A 버튼이 클릭되면 ViewModel 의 incrementScore 함수를 호출하여, 팀 A의 스코어를 1 증가시킨다.
val scoreA_Observer = Observer<Int> { newValue ->
binding.scoreViewA.text = newValue.toString()
}
scoreA_Observer 는 Int 형태의 LiveData 의 변경을 감지하고, 변경된 값을 받아와서 UI 의 scoreViewA TextView 에 해당 값을 문자열로 설정.
viewModel.scoreA.observe(this, scoreA_Observer)
scoreA는 ViewModel에서 제공되는 LiveData로, 여기에 Observer를 추가한다. 이는 LiveData의 값이 변경될 때마다 scoreA_Observer의 코드 블록이 실행되어 UI를 업데이트하게 된다.
_즉, 클릭 리스너에서 버튼이 클릭되면 ViewModel의 incrementScore를 호출하여 팀 A의 스코어를 증가시키고, 이 변경을 LiveData를 통해 감지하고 있는 Observer가 해당 변경을 감지하여 UI를 업데이트한다. 이를 통해 사용자가 버튼을 클릭할 때마다 팀 A의 스코어가 증가하고, 이를 화면에 실시간으로 반영하게 된다.
XML 에서 LiveData 에 binding 하면, 코드에서 Observer 가 필요하지 않다. UI 동기화를 유지하기 위해 작성해야하는 코드의 양을 줄일 수 있다.
<layout>
<data>
<variable>
name="viewModel"
type="com.example.kabaddikounter.ScoreViewModel" />
</data>
<ConstraintLayout ..>
<TextView ...
android:id="@+id/scoreViewA"
android:text="@{viewModel.scoreA.toString()}" />
...
</ConstraintLayout>
</layout>
ViewModel 변수를 사용하여 TextView 속성을 할당하면, LiveData 에 대한 Observer 가 필요하지 않다.
class MainActivity : AppCompatActivity() {
val viewModel: ScoreViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: ActivityMainBinding = DataBindingUtil
.setContentView(this, R.layout.activity_main)
binding.viewModel = viewModel
binding.lifecycleOwner = this
binding.plusOneButtonA.setOnClickListener {
viewModel.incrementScore(true)
}
...
binding 개체에 viewModel 을 설정한다. 해당 Activity 를 lifecycleOwner 로 지정한다.
더 이상 observer 개체를 필요로 하지 않으며, +1 버튼을 클릭하여 더 이상 TextView 점수를 수동으로 업데이트 할 필요가 없다.
class ScoreViewModel : ViewModel() {
private val _scoreA = MutableLiveData<Int>(0)
val scoreA : LiveData<Int>
get() = _scoreA
private val _scoreB = MutableLiveData<Int>(0)
val scoreB : LiveData<Int>
get() = _scoreB
fun incrementScore(isTeamA: Boolean) {
if (isTeamA) {
_scoreA.value = _scoreA.value!! + 1
} else {
_scoreB.value = _scoreB.value!! + 1
}
}
}
이제 A팀 또는 B팀 점수에 변경된 내용은 레이아웃에서 TextViews를 자동으로 업데이트합니다. incrementScore()가 호출될 때마다 UI에 변경 내용이 반영됩니다.
observer 에게 전달하기 전에 LiveData 개체에 저장된 값을 변경해야 하거나, 다른 개체의 값에 따라 다른 LiveData 인수를 반환해야 할 수도 있다. lifecycle package 에는 Transformation 클래스가 제공된다.
val result: LiveData<String> = Transformations.map(viewModel.scoreA) {
x -> if (x > 10) "A Wins" else ""
}
Transformations.map은 LiveData를 변환하여 새로운 LiveData 객체를 생성하는 메소드 중 하나이다. 이 메소드를 사용하면 기존 LiveData의 값을 변환하여 새로운 형태의 LiveData를 얻을 수 있다.
viewModel.scoreA는 이미 정의된 LiveData로, 이를 map 함수에 전달하여 변환 작업을 수행하며, 변환 작업은 람다 함수를 통해 이루어진다. 람다 함수의 매개변수 x는 viewModel.scoreA의 현재 값이다. 이 람다 함수는 x의 값에 따라 조건을 검사하고 새로운 문자열을 반환한다. 반환된 문자열은 result라는 새로운 LiveData 객체로 감싸져서 사용된다.
여기서는 특히 x > 10인 경우 "A Wins"라는 문자열을, 그렇지 않은 경우 빈 문자열("")을 반환하도록 설정되어 있다. 따라서 result LiveData는 viewModel.scoreA의 값이 10을 초과하는 경우 "A Wins"를 갖고, 그 외의 경우에는 빈 문자열을 갖게 된다.
map() | switchMap() |
---|---|
입력 LiveData 의 값에 기반하여 새로운 형태의 값 생성 | 입력 LiveData 의 값에 기반하여 새로운 LiveData 생성 |
변환 함수는 입력 LiveData의 현재 값에만 의존하므로, 입력 LiveData가 변경되더라도 변환 함수는 현재 값에만 반응 | 변환 함수는 입력 LiveData의 현재 값에만 의존하지 않고, 항상 최신 값을 기반으로 새로운 LiveData를 생성합니다. 따라서 입력 LiveData가 변경될 때마다 새로운 LiveData를 생성 |
특정 숫자를 기반으로 문자열을 생성하거나, 리스트의 각 항목에 어떤 연산을 적용하여 새로운 리스트를 생성하는 데 사용 | 주로 입력 LiveData의 값에 따라 동적으로 데이터를 로드하고 싶을 때 사용됩니다. 예를 들어, 사용자의 ID에 기반하여 해당 사용자의 프로필 정보를 로드하고 싶을 때 switchMap이 유용하게 사용 |
간단히 말해, map은 현재 값에만 의존하여 변환을 수행하고, switchMap은 입력 값의 변경에 동적으로 반응하여 새로운 LiveData를 생성한다.