본문 바로가기
안드로이드 코틀린

[JAVA][Kotlin] MVVM 패턴 으로 RecyclerView 만들기

by teamnova 2021. 9. 30.

MVVM 패턴 

액티비티에 기능을 붙이다보면 액티비티가 무거워지거나 혹은 종속성이 너무 강해 테스트가 힘들고 유지보수가 어려워집니다. 이런 고민 때문에 MVVM 패턴이 등장했다. MVVM은 View - ViewModel - Model을 이용해 각각의 역할을 분리하여 가독성과 재사용성을 높인 디자인 패턴입니다.

 

안드로이드 아키텍쳐 컴포넌트 ( Android Architecture Components, AAC )

안드로이드 아키텍쳐 컴포넌트는 앱 구조를 더 튼튼하고, 테스트에 용이하고, 유지 보수성이 뛰어나게 만들어 주는 라이브러리 모음이다. 아키텍쳐 컴포턴트에서는 조금 더 모듈화된 코딩을 돕기 위해 Databinding, LiveData, ViewModel 등의 유용한 라이브러리를 제공하며, 이러한 라이브러리의 모음은 MVVM 패턴의 구조의 설계에 최적화되어 있다.

 

 

 

1. Dependency 추가 

- Kapt : Kotlin에서 Java의 Glide나 Dagger의 Annotation Processing을 사용하기 위한 플러그인

apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'

Room, LiveData 라이브러리를 사용하기위해 dependency를 추가한다.

 

RecyclerView, CardView도 추가한다.

dependencies {
    // room
    implementation 'android.arch.persistence.room:runtime:1.1.1'
    kapt 'android.arch.persistence.room:compiler:1.1.1'

    // livedata
    implementation 'android.arch.lifecycle:extensions:1.1.1'
    kapt 'android.arch.lifecycle:compiler:1.1.1'

    // recyclerview, cardview
    implementation "androidx.recyclerview:recyclerview:1.2.1"
    implementation "androidx.cardview:cardview:1.0.0"
}

 

2. Room생성 (Entity, DAO, Database)

Contact라는 data class를 만들고 상단에 @Entity 속성을 주어 Entity를 만들었습니다.

android.arch.persistence.room를 import하게 됩니다.

 

Contact.kt

@Entity(tableName = "contact")
data class Contact(
    @PrimaryKey(autoGenerate = true)
    var id: Long?,

    @ColumnInfo(name = "name")
    var name: String,

    @ColumnInfo(name = "number")
    var number: String,

    @ColumnInfo(name = "initial")
    var initial: Char
) {
    constructor() : this(null, "", "", '\u0000')
}

 

기본키가 되는 id는 @PrimaryKey로 지정하고, null일 경우엔 자동으로 생성되도록 (autoGenerate = true) 속성을 주었습니다.

나머지 칼럼엔 @ColumnInfo를 통해 칼럼명을 지정해주었지만, 칼럼명을 변수명과 같이 쓰려면 생략이 가능합니다.

 

ContactDao.kt

@Dao
interface ContactDao {

    @Query("SELECT * FROM contact ORDER BY name ASC")
    fun getAll(): LiveData<List<Contact>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(contact: Contact)

    @Delete
    fun delete(contact: Contact)

}

 

ContactDatabase.kt

@Database(entities = [Contact::class], version = 1)
abstract class ContactDatabase: RoomDatabase() {

    abstract fun contactDao(): ContactDao

    companion object {
        private var INSTANCE: ContactDatabase? = null

        fun getInstance(context: Context): ContactDatabase? {
            if (INSTANCE == null) {
                synchronized(ContactDatabase::class) {
                    INSTANCE = Room.databaseBuilder(context.applicationContext,
                         ContactDatabase::class.java, "contact")
                        .fallbackToDestructiveMigration()
                        .build()
                }
            }
            return INSTANCE
        }
    }

}

 

3. Repository 생성

ContactRepository.kt

class ContactRepository(application: Application) {

    private val contactDatabase = ContactDatabase.getInstance(application)!!
    private val contactDao: ContactDao = contactDatabase.contactDao()
    private val contacts: LiveData<List<Contact>> = contactDao.getAll()

    fun getAll(): LiveData<List<Contact>> {
        return contacts
    }

    fun insert(contact: Contact) {
        try {
            val thread = Thread(Runnable {
                contactDao.insert(contact) })
            thread.start()
        } catch (e: Exception) { }
    }

    fun delete(contact: Contact) {
        try {
            val thread = Thread(Runnable {
                contactDao.delete(contact)
            })
            thread.start()
        } catch (e: Exception) { }
    }

}

 

 Database, Dao, contacts를 각각 초기화 해줍니다.

그리고 ViewModel에서 DB에 접근을 요청할 때 수행할 함수를 만들어둡니다.

주의할 점은 Room DB를 메인 스레드에서 접근하려 하면 크래쉬가 발생합니다.

 

 

4. ViewModel 생성

class ContactViewModel(application: Application) : AndroidViewModel(application) {

    private val repository = ContactRepository(application)
    private val contacts = repository.getAll()

    fun getAll(): LiveData<List<Contact>> {
        return this.contacts
    }

    fun insert(contact: Contact) {
        repository.insert(contact)
    }

    fun delete(contact: Contact) {
        repository.delete(contact)
    }
}

 

5. MainActivity

class MainActivity : AppCompatActivity() {

    private lateinit var contactViewModel: ContactViewModel

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

        contactViewModel = ViewModelProviders.of(this).get(ContactViewModel::class.java)
        contactViewModel.getAll().observe(this, Observer<List<Contact>> { contacts ->
                // Update UI
            })
    }

}

 

6. RecyclerView 설정

item_contact.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    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">

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <TextView
            android:layout_width="50dp"
            android:layout_height="wrap_content"
            android:id="@+id/item_tv_initial"
            android:textSize="30dp"
            android:padding="4dp"
            android:background="@android:color/darker_gray"
            android:layout_marginTop="16dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="16dp"
            tools:text="H"
            android:gravity="center"
            app:layout_constraintBottom_toBottomOf="parent"
            android:layout_marginBottom="16dp"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/item_tv_name"
            android:textSize="20dp"
            android:textStyle="bold"
            app:layout_constraintStart_toEndOf="@+id/item_tv_initial"
            android:layout_marginStart="16dp"
            tools:text="Hello Someone"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toTopOf="@+id/item_tv_number"/>

        <TextView
            android:id="@+id/item_tv_number"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="4dp"
            app:layout_constraintTop_toBottomOf="@+id/item_tv_name"
            app:layout_constraintStart_toStartOf="@+id/item_tv_name"
            android:layout_marginBottom="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            tools:text="999-888-777"/>
    </android.support.constraint.ConstraintLayout>

</android.support.v7.widget.CardView>

 

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello MVVM!"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_marginTop="16dp"
        android:id="@+id/textview"/>

    <android.support.v7.widget.RecyclerView
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="16dp"
        app:layout_constraintTop_toBottomOf="@+id/textview"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        android:id="@+id/main_recycleview"
        tools:listitem="@layout/item_contact"
        app:layout_constraintBottom_toTopOf="@+id/main_button"/>

    <Button
        android:text="Add"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:id="@+id/main_button"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"/>

</android.support.constraint.ConstraintLayout>

 

ContactAdapter.kt

class ContactAdapter(val contactItemClick: (Contact) -> Unit, val contactItemLongClick: (Contact) -> Unit)
    : RecyclerView.Adapter<ContactAdapter.ViewHolder>() {
    private var contacts: List<Contact> = listOf()

    override fun onCreateViewHolder(parent: ViewGroup, i: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_contact, parent, false)
        return ViewHolder(view)
    }

    override fun getItemCount(): Int {
        return contacts.size
    }

    override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
        viewHolder.bind(contacts[position])
    }

    inner class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
        private val nameTv = itemView.findViewById<TextView>(R.id.item_tv_name)
        private val numberTv = itemView.findViewById<TextView>(R.id.item_tv_number)
        private val initialTv = itemView.findViewById<TextView>(R.id.item_tv_initial)

        fun bind(contact: Contact) {
            nameTv.text = contact.name
            numberTv.text = contact.number
            initialTv.text = contact.initial.toString()

            itemView.setOnClickListener {
                contactItemClick(contact)
            }

            itemView.setOnLongClickListener {
                contactItemLongClick(contact)
                true
            }
        }
    }

    fun setContacts(contacts: List<Contact>) {
        this.contacts = contacts
        notifyDataSetChanged()
    }

}

 

MainActivity.kt (수정)

class MainActivity : AppCompatActivity() {

    private lateinit var contactViewModel: ContactViewModel

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

        // Set contactItemClick & contactItemLongClick lambda
        val adapter = ContactAdapter({ contact ->
            // put extras of contact info & start AddActivity
        }, { contact ->
            deleteDialog(contact)
        })

        val lm = LinearLayoutManager(this)
        main_recycleview.adapter = adapter
        main_recycleview.layoutManager = lm
        main_recycleview.setHasFixedSize(true)

        contactViewModel = ViewModelProviders.of(this).get(ContactViewModel::class.java)
        contactViewModel.getAll().observe(this, Observer<List<Contact>> { contacts ->
                adapter.setContacts(contacts!!)
            })
    }

    private fun deleteDialog(contact: Contact) {
        val builder = AlertDialog.Builder(this)
        builder.setMessage("Delete selected contact?")
            .setNegativeButton("NO") { _, _ -> }
            .setPositiveButton("YES") { _, _ ->
                contactViewModel.delete(contact)
            }
        builder.show()
    }
}

 

activity_add.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".AddActivity">


    <TextView
        android:text="Name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/add_tv_name"
        app:layout_constraintStart_toStartOf="parent"
        android:layout_marginStart="32dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintVertical_bias="0.41000003"/>

    <EditText
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:inputType="textPersonName"
        android:ems="10"
        android:id="@+id/add_edittext_name"
        app:layout_constraintBottom_toBottomOf="@+id/add_tv_name"
        app:layout_constraintTop_toTopOf="@+id/add_tv_name"
        app:layout_constraintStart_toEndOf="@+id/add_tv_name"
        android:layout_marginStart="32dp"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginEnd="32dp"/>

    <TextView
        android:text="Number"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/add_tv_number"
        android:layout_marginTop="40dp"
        app:layout_constraintTop_toBottomOf="@+id/add_tv_name"
        app:layout_constraintStart_toStartOf="@+id/add_tv_name"/>

    <EditText
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:ems="10"
        android:id="@+id/add_edittext_number"
        app:layout_constraintStart_toStartOf="@+id/add_edittext_name"
        app:layout_constraintTop_toTopOf="@+id/add_tv_number"
        app:layout_constraintBottom_toBottomOf="@+id/add_tv_number"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginEnd="32dp"
        android:inputType="phone"/>

    <Button
        android:text="done"
        android:layout_width="0dp"
        android:layout_height="49dp"
        android:id="@+id/add_button"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"/>

</android.support.constraint.ConstraintLayout>

 

AddActivity.kt

class AddActivity : AppCompatActivity() {

    private lateinit var contactViewModel: ContactViewModel
    private var id: Long? = null

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

        contactViewModel = ViewModelProviders.of(this).get(ContactViewModel::class.java)

        // intent null check & get extras
        if (intent != null && intent.hasExtra(EXTRA_CONTACT_NAME) && intent.hasExtra(EXTRA_CONTACT_NUMBER)
            && intent.hasExtra(EXTRA_CONTACT_ID)) {
            add_edittext_name.setText(intent.getStringExtra(EXTRA_CONTACT_NAME))
            add_edittext_number.setText(intent.getStringExtra(EXTRA_CONTACT_NUMBER))
            id = intent.getLongExtra(EXTRA_CONTACT_ID, -1)
        }

        add_button.setOnClickListener {
            val name = add_edittext_name.text.toString().trim()
            val number = add_edittext_number.text.toString()

            if (name.isEmpty() || number.isEmpty()) {
                Toast.makeText(this, "Please enter name and number.", Toast.LENGTH_SHORT).show()
            } else {
                val initial = name[0].toUpperCase()
                val contact = Contact(id, name, number, initial)
                contactViewModel.insert(contact)
                finish()
            }
        }
    }

    companion object {
        const val EXTRA_CONTACT_NAME = "EXTRA_CONTACT_NAME"
        const val EXTRA_CONTACT_NUMBER = "EXTRA_CONTACT_NUMBER"
        const val EXTRA_CONTACT_ID = "EXTRA_CONTACT_ID"
    }
}

 

MainActivity.kt (최종)

class MainActivity : AppCompatActivity() {

    private lateinit var contactViewModel: ContactViewModel

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

        // Set contactItemClick & contactItemLongClick lambda
        val adapter = ContactAdapter({ contact ->
            val intent = Intent(this, AddActivity::class.java)
            intent.putExtra(AddActivity.EXTRA_CONTACT_NAME, contact.name)
            intent.putExtra(AddActivity.EXTRA_CONTACT_NUMBER, contact.number)
            intent.putExtra(AddActivity.EXTRA_CONTACT_ID, contact.id)
            startActivity(intent)
        }, { contact ->
            deleteDialog(contact)
        })

        val lm = LinearLayoutManager(this)
        main_recycleview.adapter = adapter
        main_recycleview.layoutManager = lm
        main_recycleview.setHasFixedSize(true)

        contactViewModel = ViewModelProviders.of(this).get(ContactViewModel::class.java)
        contactViewModel.getAll().observe(this, Observer<List<Contact>> { contacts ->
                adapter.setContacts(contacts!!)
            })

        main_button.setOnClickListener {
            val intent = Intent(this, AddActivity::class.java)
            startActivity(intent)
        }
    }

    private fun deleteDialog(contact: Contact) {
        val builder = AlertDialog.Builder(this)
        builder.setMessage("Delete selected contact?")
            .setNegativeButton("NO") { _, _ -> }
            .setPositiveButton("YES") { _, _ ->
                contactViewModel.delete(contact)
            }
        builder.show()
    }
}