Android MVVM Pattern 적용 예제

LeeEunJae·2023년 1월 13일
4

이전에 MVVM에 대해 공부하고 글을 작성한적이 있었는데 DataBinding 에 대한 정확한 이해가 없이 진행했던 것 같아서 이번에 확실하게 공부하고 다시 정리하려 합니다.

본론

무엇인가를 만들기 위해서 아무런 설계도 없이 만드는 것은 모양새가 이쁘지 않거나 제 기능을 하지 못하게 되는 처참한 결과가 도출될 수 있습니다.
앱 개발도 마찬가지 입니다. 앱을 개발하는데 있어서 어떤 아키텍쳐 패턴을 사용해서 개발하느냐에 따라 코드가 더 깔끔해지거나 혹은 스파게티 코드가 될 수 있습니다.

이에따라 안드로이드 공식 문서 앱 아키텍처 가이드 에서 권장 아키텍처를 제안하고 있습니다.

그래서 MVVM이 뭔데?

MVVM은 Model View ViewModel 로 구성되는데 한마디로 말하면,
체계적으로 앱을 만들고 관리하기 위해서 만들어진 디자인 패턴입니다.

View : Activity, Fragment
viewModel : view가 요청한 데이터를 Model에 요청
Model : ViewModel 이 요청한 데이터 반환

MVVM 에서 가장 중요한 것은 관심사 분리 입니다.
기존 MVC 패턴의 경우에는 Controller(activity or fragment) 의 역할이 막중했습니다. UI 업데이트, 데이터 갱신 요청 을 Controller 에서 모두 처리해야해서 프로젝트 규모가 커질 수록 스파게티 코드가 되기 쉽습니다.

그래서 MVVM 에서는 역할분담을 하여 한쪽에 코드가 몰리는 것을 방지해줍니다.

구체적인 이론 설명보다는 예제를 진행하면서 MVVM 패턴을 분석해보겠습니다.

MVVM 적용 예제

사진과 같이 간단한 Todo App 을 MVVM 패턴을 적용해서 만들어보겠습니다.

예제를 진행하기 위해 build.gradle 에 아래와 같이 추가해주세요.

plugins {
	...
    id 'kotlin-kapt'
}
dataBinding{
        enabled = true
}

dependencies {
	...
    kapt "com.android.databinding:compiler:3.1.4"
    implementation 'androidx.fragment:fragment-ktx:1.5.5' // viewModels()
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
}

activity_main.xml

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".view.MainActivity">

        <EditText
            android:id="@+id/editText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toStartOf="@id/addButton"
            android:hint="할 일 메모하기"
            android:paddingHorizontal="18dp"
            android:paddingBottom="18dp"
            app:layout_constraintTop_toTopOf="parent"/>

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/addButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="add"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toBottomOf="@id/editText"
            android:layout_marginTop="10dp"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

메인 화면입니다. dataBinding 을 사용하기 위해서는 <layout> 태그로 감싸줘야 합니다.
그 외에 더 해야할 작업들이 있는데 밑에서 진행하도록 하겠습니다.

Model

data class Todo(
    val content: String
)

RecyclerView 의 아이템으로 들어갈 data class 입니다.

ViewModel

class MainViewModel: ViewModel() {
    private var _todoList = MutableLiveData<List<Todo>>()
    val todoList : LiveData<List<Todo>>
        get() = _todoList

    private var items = mutableListOf<Todo>()
    init {
        items = arrayListOf(
            Todo("테스트1"),
            Todo("테스트2")
        )
        _todoList.postValue(items)
    }


    fun addTodo(content: String){
        if(content == ""){
            return
        }
        val todo = Todo(content)
        items.add(todo)
        _todoList.postValue(items)
    }
}

View가 UI 를 업데이트하는데 필요한 데이터들이 ViewModel에서 관리됩니다.

addTodo는 파라미터로 String type의 content를 받아서 할 일 list에 추가해줍니다.
content는 editText의 text가 들어오게 뒤에서 연결해주겠습니다.

RecyclerView Item

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

    <data>
        <variable
            name="model"
            type="com.dldmswo1209.mvvmpattern.model.Todo" />
    </data>

    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingHorizontal="20dp"
            android:paddingVertical="30dp"
            android:layout_marginBottom="10dp"
            android:layout_marginHorizontal="10dp"
            android:text="@{model.content}"
            android:elevation="10dp"
            android:background="@color/white" />

    </androidx.appcompat.widget.LinearLayoutCompat>
</layout>

리사이클러뷰 아이템 레이아웃입니다.
마찬가지로 DataBinding 을 사용하기 위해서 layout 태그로 감싸줬고,
data 태그를 사용해서 model 변수를 생성해줍니다.
type 은 Model data class 가 있는 패키지 경로입니다.

android:text="@{model.content}"

text에 model의 content를 연결해줍니다.

RecyclerView Adapter

class RecyclerAdapter: ListAdapter<Todo, RecyclerAdapter.MyViewHolder>(diffUtil) {
    inner class MyViewHolder(private val binding: RecyclerItemBinding): RecyclerView.ViewHolder(binding.root){
        fun bind(todo : Todo){
            binding.model = todo
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder(RecyclerItemBinding.inflate(LayoutInflater.from(parent.context),parent,false))
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(currentList[position])
    }

    companion object{
        val diffUtil = object: DiffUtil.ItemCallback<Todo>(){
            override fun areItemsTheSame(oldItem: Todo, newItem: Todo): Boolean {
                return oldItem == newItem
            }

            override fun areContentsTheSame(oldItem: Todo, newItem: Todo): Boolean {
                return oldItem == newItem
            }

        }
    }

}

리사이클러뷰 어답터입니다.
binding.model = todo 를 통해 위에서 만든 model 변수에 todo 를 전달해줍니다.
이렇게 todo 를 전달해주면, 데이터바인딩을 통해 TextView text 에 model.content가 들어가게 됩니다.

BindingAdapter

object MyBindingAdapter {
    @BindingAdapter("items")
    @JvmStatic
    fun setItem(recyclerView: RecyclerView, todoList: List<Todo>?){
        if(recyclerView.adapter == null){
            val adapter = RecyclerAdapter()
            recyclerView.adapter = adapter
        }

        todoList?.let{
            val myAdapter = recyclerView.adapter as RecyclerAdapter
            myAdapter.submitList(it)
            myAdapter.notifyDataSetChanged()
        }
    }
}

liveData의 관찰자(observer)를 통해서 list를 리사이클러뷰 어답터에 전달할 수 있지만, MVVM 패턴과는 거리가 있는 접근방식이므로 BindingAdapter 를 사용하겠습니다.

activity_main.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"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="viewModel"
            type="com.dldmswo1209.mvvmpattern.viewModel.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".view.MainActivity">

        <EditText
            android:id="@+id/editText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toStartOf="@id/addButton"
            android:hint="할 일 메모하기"
            android:paddingHorizontal="18dp"
            android:paddingBottom="18dp"
            app:layout_constraintTop_toTopOf="parent"/>

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/addButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="add"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:onClick="@{()->viewModel.addTodo(editText.getText().toString())}"/>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintTop_toBottomOf="@id/editText"
            android:layout_marginTop="10dp"
            items="@{viewModel.todoList}"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

다시 activity_main.xml 로 돌아와서 viewModel 을 추가해줍니다.

android:onClick="@{()->viewModel.addTodo(editText.getText().toString())}"

뷰모델의 addTodo 함수를 버튼의 클릭이벤트로 연결해주고, 파라미터로 editText의 text 를 전달합니다.

items="@{viewModel.todoList}"

BindingAdapter 를 통해 만든 items 속성을 사용해서 리사이클러뷰에 데이터를 전달합니다.

MainActivity(View)

class MainActivity : AppCompatActivity() {
    private lateinit var binding : ActivityMainBinding
    private val viewModel : MainViewModel by viewModels()

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

}

마지막으로 MainActivity 입니다.
viewModel 변수로 생성한 viewModel을 전달합니다.
lifeCycleOwner를 지정해줘야 viewModel 에 선언해놓은 LiveData 의 변화를 감지해 UI 를 업데이트할 수 있습니다.

앱 전체 코드

GitHub Repository

profile
매일 조금씩이라도 성장하자

0개의 댓글