MVVM 패턴을 사용해보자

김흰돌·2023년 5월 2일
0

아키텍처 패턴

목록 보기
2/2

MVP와 MVVM의 차이점은 뭘까?

MVP 패턴은 Presenter를 통해 View와 Model 사이의 중간자 역할을 하는 방식으로 구현하며, 구성 요소 간의 의존성을 줄이고 코드 재사용성을 높인다. 하지만, View와 Model 사이의 의존성은 Presenter가 처리하기 때문에, Presenter의 복잡성이 증가하면 유지보수가 어려워질 수 있다.

MVVM 패턴은 MVP 패턴의 단점을 보완하면서 개발되었다. MVVM 패턴은 ViewModel을 사용하여 View와 Model 사이의 의존성을 제거하고, 데이터 바인딩을 통해 UI 업데이트를 자동화한다. 이러한 특성 덕분에 MVVM 패턴은 유지보수 및 확장성이 높고, 테스트하기 쉬워지며, 개발 생산성도 높다.



어떤 코드를 ViewModel로 분리하면 될까?

데이터를 가져오는 코드

  • ViewModel은 Model로부터 데이터를 가져와야 한다. 이를 위해 Retrofit, Room, Repository 등의 라이브러리를 사용할 수 있다.

데이터를 가공하는 코드

  • 가져온 데이터를 필요한 형식으로 가공하는 작업도 ViewModel에서 수행한다.

UI 로직을 처리하는 코드

  • 사용자가 버튼을 눌렀을 때 특정 작업을 수행하는 등의 UI 로직을 처리할 수 있다.

상태 관리 코드

  • 데이터를 가져오는 중일 때 로딩 상태를 표시하고, 가져온 데이터가 없을 때 빈 화면을 표시하는 등의 작업을 수행한다.


MVVM에 사용하는 라이브러리

MVVM에 사용되는 많은 라이브러리들이 있지만 대표적으론 LiveData와 Databindig이 있다.

LiveData

  • 생명주기를 인식하여 데이터가 변경되면 관련된 View에 알림을 보내 업데이트를 수행함

Databinding

  • 데이터가 변경되면 자동으로 뷰에 업데이트 되어 코드에서 UI 업데이트 관련 처리를 수동으로 구현할 필요가 없어짐

AAC ViewModel

  • MVVM 패턴의 ViewModel과는 AAC ViewModel은 다르다는 걸 알아야 함
  • AAC ViewModel은 수명 주기를 고려해 데이터를 저장하고 관리하도록 설계됨
  • 위의 그림과 같이 AAC ViewModel을 사용하면 기존의 Activity, Fragment의 생명 주기에 관련된 데이터를 관리하는 부분을 간단하게 처리할 수 있음
  • 흔히 가장 자주 드는 예로 화면 회전이 있는데 화면이 회전하는 동한 뷰가 파괴되고 새로 만들어지는 과정에서 AAC ViewModel을 사용하면 생명주기에 관계없이 데이터를 보존할 수 있음.


MVVM 패턴 적용

MVVM 패턴을 적용할 예제로 Retrofit으로 LOL API를 다뤄보자 프로젝트를 리팩토링 해보겠다.

ViewModel로 분리할 코드는 위의 '어떤 코드를 ViewModel로 분리하면 될까?' 글을 통해 생각해보면 Retrofit으로 데이터를 가져오는 부분을 분리하면 될 것이다.

ViewModel 관련 코드 작성

// ViewModel 상속
class MainViewModel() : ViewModel() {

    private val _items = MutableLiveData<ArrayList<LOLResponseItem>>()
    val items: LiveData<ArrayList<LOLResponseItem>>
        get() = _items

    private val _fail = MutableLiveData<Boolean>()
    val fail: LiveData<Boolean>
        get() = _fail

    fun loadUserInfo() {
        val retrofitAPI = RetrofitConnection.getInstance().create(LOLService::class.java)
        retrofitAPI.getInformation(
            "LOL API 인증키"
        ).enqueue(object : Callback<List<LOLResponseItem>> {
            override fun onResponse(
                call: Call<List<LOLResponseItem>>,
                response: Response<List<LOLResponseItem>>
            ) {
                if (response.isSuccessful) {
                    response.body()?.let { 
                    	// 받아온 데이터 LiveData에 넣어주기
                        _items.value = it as ArrayList<LOLResponseItem> 
                    }
                } else {
                	// 데이터 요청에 실패했을 시 요청 실패관련 값을 가지는 LiveData에 결과 넣어주기
                    _fail.value = true
                }
            }

            override fun onFailure(call: Call<List<LOLResponseItem>>, t: Throwable) {
                Log.e("가져오기 실패", "실패?")
                t.printStackTrace()
            }
        })
    }
}
  • AAC ViewModel을 상속받음
  • API 요청을 하는 코드를 실행하고 받아온 결과를 LiveData에 넣어줌

LiveData를 아래와 같이 구현하는 이유는

private val _items = MutableLiveData<ArrayList>()
val items: LiveData<ArrayList>
get() = _items

ViewModel 내부에서는 mutableLiveData를 사용하여 데이터를 수정할 수 있지만, View에서는 LiveData를 감지하고 표시하는 역할을 한다. View에서 데이터를 수정하면, 에기치 않은 상황이 발생할 수 있기 때문이다.
또한 데이터 변경과 UI 갱신은 서로 다른 쓰레드에서 동시에 일어날 수 있기 때문에, LiveData를 통해 View에서 데이터 변경을 감지하고 표시하도록 분리하는 것이 중요하다.

Activity 코드 작성

class MainActivity : AppCompatActivity(){

    private lateinit var binding: ActivityMainBinding
  	
	// ViewModelProvider를 통한 MainViewModel 인스턴스 생성
    private val viewModel by lazy {
        ViewModelProvider(this).get(MainViewModel::class.java)
    }
    private val adapter by lazy { RecyclerViewAdapter() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
  		
  		// Databinding
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        binding.recyclerView.adapter = adapter
        binding.recyclerView.layoutManager = LinearLayoutManager(this)
		
  		// API 요청 함수 실행
        viewModel.loadUserInfo()

  		// items LiveData 옵저빙
        viewModel.items.observe(this, Observer {
            (binding.recyclerView.adapter as RecyclerViewAdapter).updateList(it)
        })

  		// fail LiveData 옵저빙
        viewModel.fail.observe(this, Observer {
            if (viewModel.fail.value == true) {
                Toast.makeText(this, "API 요청에 실패했습니다.", Toast.LENGTH_SHORT).show()
            } else {
                Toast.makeText(this, "가져오기 성공!", Toast.LENGTH_SHORT).show()
            }
        })

    }
}

ViewModelProvider

  • ViewModelProvider를 통해 AAC ViewModel을 상속받은 ViewModel의 인스턴스를 생성하고 제공함
  • ViewModelProvider는 생성자를 통해 LifecycleOwner 객체를 전달받고, ViewModelProvider에 의해 생성된 ViewModel은 이 LifecycleOwner의 생명주기를 따라감
  • LifecycleOwner는 액티비티나 프래그먼트가 될 수 있으며, LifecycleOwner의 생명주기가 종료되면 ViewModel도 함께 종료됨.

Databinding

  • setContentView 대신 DataBindingUtil.setContentView를 호출하여 뷰와 데이터를 연결함

LiveData Observing

  • ViewModel의 LiveData를 옵저빙 하여 데이터 변화가 감지되었을 때 블록 안의 코드를 수행한다.

데이터를 담는 XML

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="data"
            type="com.example.lol.retrofit.LOLResponse.LOLResponseItem" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp">

        <ImageView
            android:id="@+id/img_champ"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:background="#FFFFFF"
            android:src="@drawable/sample"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:layout_marginStart="20dp"
            android:gravity="center"
            android:textSize="19sp"
            android:textColor="@color/white" />

        <TextView
            android:id="@+id/tv_champ_name"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:text="@{String.valueOf(data.championId)}"
            app:layout_constraintStart_toEndOf="@id/img_champ"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toTopOf="@id/tv_champ_level"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="20dp"
            android:layout_marginStart="10dp" />

        <TextView
            android:id="@+id/tv_champ_level"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:text="@{String.valueOf(data.championLevel)}"
            app:layout_constraintStart_toEndOf="@id/img_champ"
            app:layout_constraintTop_toBottomOf="@id/tv_champ_name"
            app:layout_constraintBottom_toTopOf="@id/tv_champ_points"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="20dp"
            android:layout_marginStart="10dp" />

        <TextView
            android:id="@+id/tv_champ_points"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:text="@{String.valueOf(data.championPoints)}"
            app:layout_constraintStart_toEndOf="@id/img_champ"
            app:layout_constraintTop_toBottomOf="@id/tv_champ_level"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="20dp"
            android:layout_marginStart="10dp" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
  • xml 파일에서 data를 정의해 줌
  • 태그 안에 데이터의 이름과 형식을 설정해 줌
  • android:text 태그에 "@{data.champId}" 형식으로 작성해 줌

※ android.content.res.Resources$NotFoundException: String resource ID #0x6 라는 에러가 나올텐데 String.valueOf()로 문자열로 변환해주면 정상적으로 작동한다.

마무리

이렇게 기존에 Retrofit으로 데이터를 가져와 RecyclerView에 넣어주는 코드를 MVVM 패턴으로 바꿔보았다.
데이터를 가져오는 로직을 분리하고 LiveData와 Databinding으로 쉽게 값을 가져오고 넘겨줄 수 있어 코드의 가독성 부분에서 보기 편해졌고, 앞으로 다른 로직을 추가하더라도 ViewModel에 관련 코드를 작성하고 LiveData로 받아온 값을 넘기기만 하면 되니 유지보수에도 큰 장점이 있을 것 같다.

0개의 댓글