[Android] LiveData와 ViewModel

이제일·2022년 7월 18일
2

Android

목록 보기
4/15
post-thumbnail

이미지 출처

LiveData?

LiveData는 Observable data holder class로 데이터를 관찰하는 클래스입니다.
액티비티, 프래그먼트, 서비스 등 안드로이드 컴포넌트의 Lifecycle에 영향을 받습니다.
컴포넌트들의 생명주기 상태가 활성화된 상태일 때만 data에 대한 update를 제공합니다.

  • View 의 데이터를 항상 최신으로 유지할 수 있다
  • 관찰자 (액티비티, 프래그먼트) 의 생명주기를 알고 있는 observer 패턴으로 생명 주기를 수동으로 처리하지 않아도 되어 메모리 릭이 사라진다
  • Resource를 공유할 수 있다.

ViewModel?

ViewModel 객체의 범위는 ViewModel을 가져올 때 ViewModelProvider에 전달되는 Lifecycle로 지정됩니다. ViewModel은 범위가 지정된 Lifecycle이 활동이 끝날 때까지 그리고 프래그먼트에서는 프래그먼트가 분리될 때까지 메모리에 남아 있습니다.
주로 LiveData와 함께 쓰입니다.

  • UI 컨트롤러 로직에서 뷰 데이터 소유권을 분리할 수 있다
  • View의 상태 데이터 저장이 용이하다.
  • MVVM 패턴으로 아키텍처에서 View나 Model과 독립적으로 개발 가능
  • 위의 독립성으로 테스트 가능한 코드를 짤 수 있어 유지 보수가 용이하다.

DataBinding?

선언적 형식으로 레이아웃의 UI 구성요소를 앱의 데이터 소스와 결합할 수 있는 라이브러리입니다.

레이아웃 파일에서 구성요소를 결합하면 활동에서 많은 UI 프레임워크 호출을 삭제할 수 있어 파일이 더욱 단순화되고 유지관리 또한 쉬워집니다. 앱 성능이 향상되며 메모리 누수 및 null 포인터 예외를 방지할 수 있습니다.

아래의 기존 코드는 findViewById()를 호출하여 TextView 위젯을 찾아 값을 대입하는 방식입니다

    findViewById<TextView>(R.id.sample_text).apply {
        text = viewModel.userName
    }

위의 코드를 databinding을 사용해서 레이아웃 파일에서 직접 위젯에 텍스트를 할당하는 방법입니다.

<TextView
        android:text="@{viewmodel.userName}" />

Start Databinding

databinding을 사용하려면 우선 모듈 수준의 build.gradle에 다음과 같이 설정합니다.

android{
	...
    dataBinding {
        enabled = true
    }
    viewBinding {
        enabled = true
    }
    ...
}

레이아웃 및 코드 설정

코드의 데이터를 자동으로 뷰에 바인딩해주기 위해 XML에서 코드를 추가해야 합니다.
루트 레이아웃을 <layout>으로 감싸고 <data>태그를 추가합니다

activity_main.xml

<layout>
    <data>
        <variable
            name="myData"
            type="com.example.jetpacksample.MainActivity" />
    </data>
    
    ...
    	<TextView
        	android:text="@{myData.title}"
    ...
</layout>

name : xml에서 참조할 데이터 이름
type : 참조할 데이터 타입
데이터의 참조는 @{데이터} 으로 하면 됩니다

View에서는 databinding을 진행하고 해당 데이터에 값을 넣습니다

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding
    val title:String = "JETPACK"


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.myData = this
    }
}

Fragment의 경우
binding = DataBindingUtil.inflate(inflater, R.layout.activity_main, container, false)
로 바인딩할 수 있습니다.

data class 타입의 데이터도 전달할 수 있습니다.

data/User.kt

data class User(
    val id:Int,
    val name:String,
    val phoneNumber: String
){
    override fun toString() ="id : $id\nname : $name\nphoneNumber : $phoneNumber"
}

activity_main.xml

  <variable
            name="user"
            type="com.example.jetpacksample.data.User" />

MainActivity.kt

        binding.user = User(1, "홍길동", "010-0000-0000")

이벤트 바인딩

클릭과 같은 이벤트에 대해 리스너를 XML에 등록할 수 있습니다.
이벤트 바인딩은 함수 참조와 리스너 바인딩 두 가지 형태로 제공됩니다.
함수 참조 바인딩은 컴파일 단계에서 바인딩이 참조되며,리스너 바인딩은 런타임 시전에 바인딩이 참조됩니다.

함수 참조 바인딩
함수 참조(::)를 이용해서 이벤트를 등록합니다.

xml 파일에서는 다음과 같이 onClick 속성의 값으로 해당 함수를 등록합니다.

        android:onClick="@{myData::onClickName}"

myData 타입에 해당하는 클래스에서 이벤트 함수를 설정합니다.

fun onClickName(view: View){
	Toast.makeText(this, getString(R.string.app_name), Toast.LENGTH_SHORT).show()
}

이때 함수의 이름은 상관없지만, 매개변수 부분은 위와 같아야 합니다.

리스너 바인딩
이벤트 함수에 임의의 매개변수를 활용하고 싶을 땐 람다 형식으로 이벤트를 등록할 수 있습니다.

xml 파일에서 람다 함수로 등록합니다.

        android:onClick="@{() -> myData.onClickUser(user)}"

activity.kt

fun onClickUser(user: User){
	Toast.makeText(this, user.name, Toast.LENGTH_SHORT).show()
}

여기까지 진행된 코드입니다.
https://github.com/WorldOneTop/AndroidJetpackSample/tree/1484bb52be22514eb358938f0163e92264d027d6


Start ViewModel

먼저 종속성을 추가합니다.
build.gradle

dependencies {
	...
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'
}

다음 ViewModel을 상속받는 클래스를 만들고 원하는 데이터를 만들어줍니다.
ui/UserViewModel.kt

class UserViewModel:ViewModel() {
    private var user: User = User(1, "홍길동", "010-0000-0000")

    fun getUser() = user

    fun updateUser(){
        user.phoneNumber = "010-${getRandomNumber()}-${getRandomNumber()}"
    }

    private fun getRandomNumber() = (0..9999).random().toString().padStart(4, '0')
}

만든 ViewModel은 ViewModelProvider를 통해 객체를 생성합니다.

        viewModel = ViewModelProvider(this)[UserViewModel::class.java]

이렇게 만든 ViewModel을 이용해 버튼을 만들고 User의 폰번호를 바꾸도록 구현해봅시다.

    fun onClickChange(view: View){
        viewModel.updateUser()
        binding.user = viewModel.getUser()
    }
<Button
        android:id="@+id/btnNewUser"
        android:text="Change phone number"
        android:onClick="@{myData::onClickChange}"
        android:textAllCaps="false"
        app:layout_constraintTop_toBottomOf="@id/text2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

위와 같이 구현하면 디바이스가 회전이 되어도 값이 변하지 않습니다.

왼쪽이 ViewModel을 사용하지 않은 화면, 오른쪽이 ViewModel을 사용한 화면입니다

ViewModelProvider

ViewModel의 객체를 만들어주는 ViewModelProvider에 대해서 조금 더 자세히 알아보겠습니다.

다음과 같이 ViewModelProvider의 생성자를 보면 ViewModelStoreOwner를 넘겨준다는 것을 알 수 있다.
ViewModelProvider.kt

	public constructor(
        owner: ViewModelStoreOwner
    ) : this(owner.viewModelStore, defaultFactory(owner), defaultCreationExtras(owner))
    
    public constructor(owner: ViewModelStoreOwner, factory: Factory) : this(
        owner.viewModelStore,
        factory,
        defaultCreationExtras(owner)
    )

ViewModelStoreOwner는 다음과 같은 인터페이스로 구현되어있습니다.
이는 Activity와 Fragment가 해당 인터페이스를 구현함을 알 수 있고 View(Activity, Fragment)인스턴스의 Lifecycle을 이용해 ViewModel을 제공함을 알 수 있다.

public interface ViewModelStoreOwner {
    @NonNull
    ViewModelStore getViewModelStore();
}

또한 그 다음 객체를 특정하는 함수를 ViewModelProvider Class에서 찾아보면 다음과 같다

	@MainThread
    public open operator fun <T : ViewModel> get(modelClass: Class<T>): T {
        val canonicalName = modelClass.canonicalName
            ?: throw IllegalArgumentException("Local and anonymous classes can not be ViewModels")
        return get("$DEFAULT_KEY:$canonicalName", modelClass)
    }
    
    ...
    
     public open operator fun <T : ViewModel> get(key: String, modelClass: Class<T>): T {
        val viewModel = store[key]
        if (modelClass.isInstance(viewModel)) {
            (factory as? OnRequeryFactory)?.onRequery(viewModel)
            return viewModel as T
        } else {
            @Suppress("ControlFlowWithEmptyBody")
            if (viewModel != null) {
                // TODO: log a warning.
            }
        }

해당 구현 방식은 class로부터 받은 canonicalName을 key로 두고 ViewModel을 저장하는 Map인 ViewModelStore에 해당 key값에 대해 viewModel 값을 저장한 다음 만들어진 viewModel을 return하고 있습니다.
따라서 같은 ViewModelStoreOwner(Acitivty, Fragment)에 대해 같은 이름의 ViewModel 클래스를 get하면 같은 인스턴스가 반환되는 것을 알 수 있습니다.

또한 어떤 Owner를 통해 생성하냐에 따라 ViewModel의 Scope가 정해진다. ViewModelStoreOwner를 구현하는 것은 ViewModelStore를 유지하고, Scope가 Destroy될 때 ViewModelStore.clear()를 호출하는 책임을 갖게 된다.

객체 생성 방식

파라미터와 멤버 변수를 갖고있지 않은 ViewModel 클래스 객체 생성

// #1 
viewModel1 = ViewModelProvider(this)[UserViewModel::class.java]

// #2 안드로이드가 기본적으로 제공해주는 팩토리 클래스인 ViewModelProvider.Factory 이용
viewModel2 = ViewModelProvider(this, ViewModelProvider.NewInstanceFactory())[UserViewModel::class.java]

커스텀 팩토리 클래스 사용

// #3 파라미터가 없을 때
class NoParamViewModelFactory : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
            UserViewModel() as T
        } else {
            throw IllegalArgumentException()
        }
    }
}
//사용
viewModel3 = ViewModelProvider(this, NoParamViewModelFactory())[UserViewModel::class.java]



// #4 파라미터가 있을 때
class HasParamViewModelFactory(private val param: String) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
            UserViewModel(param) as T
        } else {
            throw IllegalArgumentException()
        }
    }
}

// 사용
viewModel4 = ViewModelProvider(this, HasParamViewModelFactory(User()))[UserViewModel::class.java]

developer 사이트에 의하면 ViewModel 클래스에서 Context 객체를 소유, 접근을 권장하지 않고 있습니다.
하지만 Context를 사용해야할 경우 AndroidViewModel 클래스를 사용하면 됩니다.
관련 문제를 다룬 스택오버플로

아래와 같이 AndroidViewModel을 상속하고 application을 인자로 받으면 됩니다.
추가적인 파라미터가 필요할 경우 추가해도 상관없습니다.

// #5 
class LoginViewModel(application: Application) : AndroidViewModel(application) {
    private val context = getApplication<Application>().applicationContext
    ...
}

// 사용
viewModel5 = ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory(application))

축약된 표현 (by 키워드 사용)

class MainActivity : AppCompatActivity() {
	// #1 파라미터와 멤버 변수를 갖고있지 않은 ViewModel 클래스 객체
	private val viewModel: UserViewModel by viewModels()
    
    // #3 커스텀 팩토리 클래스 사용, 인자가 없을 때
    private val viewModel: UserViewModel by viewModels { NoParamViewModelFactory() }
    
	// #4 커스텀 팩토리 클래스 사용, 인자가 있을 때
    private val viewModel: UserViewModel by viewModels { HasParamViewModelFactory(User()) }
    
    ...
    
}

축약된 표현을 위해 gradle에 필요한 종속성
참고

dependencies {
    implementation 'androidx.activity:activity-ktx:1.5.0'
    implementation 'androidx.fragment:fragment-ktx:1.5.0'
}

여기까지 진행된 코드입니다.
https://github.com/WorldOneTop/AndroidJetpackSample/tree/19155d3ed64817069e1e0f4a966f4f6916afee5d


Start LiveData

공식 문서에서 나오는 절차는 다음과 같다

  1. 특정 유형의 데이터를 보유할 LiveData의 인스턴스를 생성합니다. 이 작업은 일반적으로 ViewModel 클래스 내에서 이루어집니다.

  2. onChanged() 메서드를 정의하는 Observer 객체를 만듭니다. 이 메서드는 LiveData 객체가 보유한 데이터 변경 시 발생하는 작업을 제어합니다. 일반적으로 활동이나 프래그먼트 같은 UI 컨트롤러에 Observer 객체를 만듭니다.

  3. observe() 메서드를 사용하여 LiveData 객체에 Observer 객체를 연결합니다. observe() 메서드는 LifecycleOwner 객체를 사용합니다. 이렇게 하면 Observer 객체가 LiveData 객체를 구독하여 변경사항에 관한 알림을 받습니다. 일반적으로 활동이나 프래그먼트와 같은 UI 컨트롤러에 Observer 객체를 연결합니다.

생성 및 사용

ViewModel 클래스에서 UI로 보여질 Live Data를 설정합니다.
기존의 user변수를 LiveData로 바꾸어줍니다

class UserViewModel:ViewModel() {
    private val _data = MutableLiveData<User>()
    val data: LiveData<User> get() = _data
    
    ...

LiveData는 MutableLiveDataLiveData가 있는데
MutableLiveData는 말그대로 변경할 수 있는 kotlin의 var과 같고
LiveData는 읽기만 가능한 val과 같다

따로 쓰는 이유는 ViewModel과 View의 역할을 분리하기 위함이다. ViewModel은 새로운 값으로 변경이 일어나기에 쓰고 읽을 수 있는 형태로 사용하는 것이고, View는 값의 입력이 아닌 읽기만을 허용하는 것이다.

옵저버를 등록해서 값 변경 시 일어나야할 일을 등록한다.
인자값으로는 lifecycle owner와 리스너가 들어간다.

class MainActivity : AppCompatActivity() {
    lateinit var binding: ActivityMainBinding
    private val viewModel: UserViewModel by viewModels()
    
    ...
    
	override fun onCreate(savedInstanceState: Bundle?) {
		...
    
		viewModel.userData.observe(this){ newUser ->
            	binding.text2.text = newUser.toString()
	}

observer 등록 시 onCreate에서 해야하는 이유
다른 생명주기에 할 경우 코드가 중복호출이 될 수 있기 때문에
생명주기에 대한 추가적인 handling을 하지 않아도 되는 LiveData에 대한 장점이 사라지게 됨

이제 클릭 시 MutableLiveData의 값만을 변경하도록 한다
기존의 MainActivity에 있던 onClickChange의 역할이 updateUser함수로 옮겨졌다

fun updateUser(){
        _userData.value = User(1, "홍길동",
        "010-${getRandomNumber()}-${getRandomNumber()}") //아래와 같음
        //_userData.setValue(User(1, "홍길동","010-${getRandomNumber()}-${getRandomNumber()}"))
    }

메인쓰레드의 경우 setValue를 사용하고
아닐 경우 postValue를 사용해야함

Coroutine을 이용한 비동기 처리

종속성
ViewModelScope의 경우 androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0 이상을 사용합니다.
LifecycleScope의 경우 androidx.lifecycle:lifecycle-runtime-ktx:2.4.0 이상을 사용합니다.
liveData의 경우 androidx.lifecycle:lifecycle-livedata-ktx:2.4.0 이상을 사용합니다.

Application
애플리케이션 전체 스코프에 대한 좋은 사용 사례가 있지만
WorkManager를 사용하는 것을 먼저 고려해야 합니다.

Activity, Fragment

lifecycleScope.lauch를 사용하는 경우, 작업의 스코프를 View의 특정 인스턴스로 지정할 수 있습니다.
launchWhenResumed, launchWhenStarted, launchWhenCreated를 사용하여 특정 라이프사이클 상태로 작업을 제한한 경우, 해당 상태로 범위를 좁힐 수 있습니다.

class MyActivity : Activity {
    override fun onCreate(state: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            // Run
        }

        lifecycleScope.launchWhenResumed {
            // Run
        }
     }
 }

ViewModel + LiveData

대부분의 데이터 작업은 ViewModel에서 시작되므로 이 방법은 코루틴을 실행하는 가장 일반적인 방법중 하나입니다. viewModelScope를 사용하면 ViewModel의 onCleared() 함수에서 작업이 자동으로 취소됩니다. viewModelScope.launch를 사용하여 코루틴을 시작합니다.

TextView를 추가하여 현재 시간을 나타내보자

dataBinding을 이용해 viewModel을 가져와서 값을 변경하기 위해
Activity에 lifecylceOwner을 추가한다

override fun onCreate(savedInstanceState: Bundle?) {
	...
    binding.lifecycleOwner = this
}

바인딩 할 데이터를 생성하고 viewModelScope에 코루틴을 설정한다.

class UserViewModel:ViewModel() {
	...
    val currentTime: MutableLiveData<String> = MutableLiveData<String>()

    init {
        viewModelScope.launch {
            while(true){
                currentTime.value = Date(System.currentTimeMillis()).toString()
                delay(1000)
            }
        }
    }

하지만 결국 이 결과는 view에 표시하므로 코루틴을 시작한 후 불변적인 LiveData를 통해 결과를 노출할 수 있는 liveData coroutine builder를 사용할 수 있습니다.
업데이트를 전송하기 위해서 emit() 함수를 사용합니다.

class UserViewModel:ViewModel() {
    ...
    val currentTime: LiveData<String> = liveData { 
        while (true) { 
            emit(Date(System.currentTimeMillis()).toString())
            delay(1000) 
        } 
    }
    ...
}

emit과 emitsource의 차이점
emit은 값을 업데이트하고, emitsource는 추가로 liveData를 수신해 LiveData를 다른 LiveData에 첨부할 수 있다.
stackoverflow

LiveData Coroutine builder with a switchMap
LiveData 값이 변경될 때마다 코루틴을 시작하려고 할 때엔 Transformations.switchMap을 사용합니다.
예를 들어 데이터 로드 작업을 시작하기 전에 ID가 필요한 경우입니다.

currentTime의 switchMap을 이용해서 변화를 체크해서 liveData를 실행합니다.
delay를 사용하기에 코루틴으로 감싸줍니다.

class UserViewModel:ViewModel() {
    ...
    val timeSound: LiveData<String> = currentTime.switchMap {
        liveData {
            emit("째깍")
            delay(300)
            emit("")
        }
    }
...
}

참고사이트 및 샘플 코드

샘플 코드
https://github.com/WorldOneTop/AndroidJetpackSample/tree/LiveData_ViewModel

참고 사이트
LiveData 공식 문서
ViewModel 공식 문서
Data Binding 공식 문서
coroutine 공식 문서

Data Binding
ViewModel
ViewModel
coroutine

by 키워드

작동 영상

profile
세상 제일 이제일

0개의 댓글