MVP 패턴은 Presenter를 통해 View와 Model 사이의 중간자 역할을 하는 방식으로 구현하며, 구성 요소 간의 의존성을 줄이고 코드 재사용성을 높인다. 하지만, View와 Model 사이의 의존성은 Presenter가 처리하기 때문에, Presenter의 복잡성이 증가하면 유지보수가 어려워질 수 있다.
MVVM 패턴은 MVP 패턴의 단점을 보완하면서 개발되었다. MVVM 패턴은 ViewModel을 사용하여 View와 Model 사이의 의존성을 제거하고, 데이터 바인딩을 통해 UI 업데이트를 자동화한다. 이러한 특성 덕분에 MVVM 패턴은 유지보수 및 확장성이 높고, 테스트하기 쉬워지며, 개발 생산성도 높다.
MVVM에 사용되는 많은 라이브러리들이 있지만 대표적으론 LiveData와 Databindig이 있다.
MVVM 패턴을 적용할 예제로 Retrofit으로 LOL API를 다뤄보자 프로젝트를 리팩토링 해보겠다.
ViewModel로 분리할 코드는 위의 '어떤 코드를 ViewModel로 분리하면 될까?' 글을 통해 생각해보면 Retrofit으로 데이터를 가져오는 부분을 분리하면 될 것이다.
// 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()
}
})
}
}
LiveData를 아래와 같이 구현하는 이유는
private val _items = MutableLiveData<ArrayList>()
val items: LiveData<ArrayList>
get() = _items
ViewModel 내부에서는 mutableLiveData를 사용하여 데이터를 수정할 수 있지만, View에서는 LiveData를 감지하고 표시하는 역할을 한다. View에서 데이터를 수정하면, 에기치 않은 상황이 발생할 수 있기 때문이다.
또한 데이터 변경과 UI 갱신은 서로 다른 쓰레드에서 동시에 일어날 수 있기 때문에, LiveData를 통해 View에서 데이터 변경을 감지하고 표시하도록 분리하는 것이 중요하다.
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()
}
})
}
}
<?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>
※ android.content.res.Resources$NotFoundException: String resource ID #0x6 라는 에러가 나올텐데 String.valueOf()로 문자열로 변환해주면 정상적으로 작동한다.
이렇게 기존에 Retrofit으로 데이터를 가져와 RecyclerView에 넣어주는 코드를 MVVM 패턴으로 바꿔보았다.
데이터를 가져오는 로직을 분리하고 LiveData와 Databinding으로 쉽게 값을 가져오고 넘겨줄 수 있어 코드의 가독성 부분에서 보기 편해졌고, 앞으로 다른 로직을 추가하더라도 ViewModel에 관련 코드를 작성하고 LiveData로 받아온 값을 넘기기만 하면 되니 유지보수에도 큰 장점이 있을 것 같다.