지난 글에 이어 본격적으로 예제를 작성해보려고 한다. MVVMDataBinding은 대부분 함께 사용되지만, 각각 어떤 구조로 만들어졌고 어떤 역할을 하는지 차근차근 익히기 위해 우선 MVVM만 적용하여 최대한 간단한 예제를 만들어 보기로 했다.

  1. MVVM + AAC 시작하기 - MVC와의 차이점, MVVM의 장단점, AAC 설명
  2. MVVM 연습 예제1 (현재글) - MVVM, AAC(ViewModel, LiveData, Room), RecyclerView
  3. MVVM 연습 예제2 - 예제1 + DataBinding (조금 더 사용해보고 추가하겠습니다)

MVVM 예제

연락처(Contacts) 리스트를 보여주는 예제이다. 연락처 목록과 추가의 두 개의 액티비티를 가진다.

목록 안에는 RecyclerView로 연락처가 나열되고, 새 연락처를 추가하면 자동으로 추가/정렬된다. 리스트의 연락처를 클릭하면 편집할 수 있고, 길게 누르면 삭제할 것인지 다이얼로그를 보여준다.




화면이 두 개인 단순한 앱임에도, MVVM 패턴으로 구성하려면 꽤 많은 클래스가 필요하다. 아래의 그림은 AAC의 구조와 그 옆에 무엇을 만들어야 하는지 작성한 그림이다.


아래의 데이터베이스부터 위의 뷰까지 차례로 구성 요소를 만들어가려고 한다.

  1. Dependency 추가
  2. Room 생성 (Entity, DAO, Database)
  3. Repository 생성
  4. ViewModel 생성
  5. MainActivity 설정
  6. RecyclerView 설정 (xml, Adapter)
  7. AddActivity 생성

1. Dependency 추가

우선, Kotlin 언어를 사용하기 때문에 Annotation processors 대신 kapt를 사용한다. 그러기 위해 앱 단의 Gradle에서 kapt 플러그인을 적용한다.

관련 문서 - https://kotlinlang.org/docs/reference/kapt.html

apply plugin: 'kotlin-kapt'

하단에는 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 'com.android.support:recyclerview-v7:28.0.0'
    implementation 'com.android.support:cardview-v7:28.0.0'
}



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

연락처 리스트를 만들 것이므로, 개개인의 연락처를 저장할 클래스를 Entity로 사용하기로 한다.

룸 관련해서는 이전에 작성한 Android Room 포스트 에 조금 더 자세히 정리해두었다.


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를 통해 칼럼명을 지정해주었지만, 칼럼명을 변수명과 같이 쓰려면 생략이 가능하다.

Entity에서 테이블 이름을 작성하는 부분인 (tableName = "contact") 부분도 위의 경우에는 클래스명과 같기 때문에 생략이 가능하다. 개인적으로는 명시하는 편이 낫다고 생각하여 함께 써주었다.

Entity를 만들었으면 SQL을 작성하기 위한 DAO 인터페이스를 만들어준다.

// 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)

}


@Query, @Insert, @Update, @Delete 등의 어노테이션을 제공한다. 또한 Insert와 Update에서는 onConflict 속성을 지정할 수 있다. 중복된 데이터의 경우 어떻게 처리할 것인지에 대한 처리를 지정할 수 있다.



OnConflictStrategy 인터페이스를 호출해 REPLACE, IGNORE, ABORT, FAIL, ROLLBACK 등의 액션이 지정 가능하다.

또, 주목할 것은 전체 연락처 리스트를 반환하는 getAll 함수를 만들 때 LiveData 를 반환해준다는 점이다. 기존의 익숙한 List<Contact> 형식에 LiveData<>를 감싸주는 방식으로 만들 수 있다.


다음으로 만들 것은 실질적인 데이터베이스 인스턴스를 생성할 Database 클래스이다. RoomDatabase 클래스를 상속하는 추상 클래스로 생성한다.

// 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
        }
    }

}


클래스 이름 위에 @Database 어노테이션을 이용해 entity를 정의하고 SQLite 버전을 지정한다. 또한 데이터베이스 인스턴스를 싱글톤으로 사용하기 위해, companion object 에 만들어주었다.

getInstance 함수는 여러 스레드가 접근하지 못하도록 synchronized로 설정한다. 여기서 실질적으로 Room.databaseBuilder 로 인스턴스를 생성하고, fallbackToDestructiveMigration 을 통해 데이터베이스가 갱신될 때 기존의 테이블을 버리고 새로 사용하도록 설정했다.

이렇게 만들어지는 DB 인스턴스는 Repository에서 호출하여 사용할 것이다.



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) { }
    }

}


사실 Repository에서 크게 정의하는 부분은 없다. 우선 Database, Dao, contacts를 각각 초기화해준다.

그리고 ViewModel에서 DB에 접근을 요청할 때 수행할 함수를 만들어둔다.주의할 점은 Room DB를 메인 스레드에서 접근하려 하면 크래쉬가 발생한다. 에러 메세지는 다음과 같다.

  • Cannot access database on the main thread since it may potentially lock the UI for a long period of time. (메인 UI 화면이 오랫동안 멈춰있을 수도 있기 때문에, 메인 쓰레드에서는 데이터베이스에 접근할 수 없습니다.)

따라서 별도의 스레드에서 Room의 데이터에 접근해야 한다.



4. ViewModel 생성

안드로이드 뷰모델 AndroidViewModel을 extend 받는 ContactViewModel을 만들어준다.

// ContactViewModel.kt

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)
    }
}


AndroidViewModel 에서는 Application을 파라미터로 사용한다. (Repository를 통해서) Room 데이터베이스의 인스턴스를 만들 때에는 context가 필요하다. 하지만, 만약 ViewModel이 액티비티의 context를 쓰게 되면, 액티비티가 destroy 된 경우에는 메모리 릭이 발생할 수 있다. 따라서 Application Context를 사용하기 위해 Applicaion을 인자로 받는다.

DB를 제어할 함수는 Repository에 있는 함수를 이용해 설정해준다.



5. MainActivity 설정

우선적으로 MainActivity에서 해줄 일은 ContactViewModel 인스턴스를 만들고, 이를 관찰하는 역할이다.

// MainActivity.kt

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
            })
    }

}

뷰모델 객체는 직접적으로 초기화 해주는 것이 아니라, 안드로이드 시스템을 통해 생성해준다. 시스템에서는 만약 이미 생성된 ViewModel 인스턴스가 있다면 이를 반환할 것이므로 메모리 낭비를 줄여준다. 따라서 ViewModelProviders를 이용해 get 해준다.

또한 Observere를 만들어서 뷰모델이 어느 액티비티/프래그먼트의 생명주기를 관찰할 것인지 정한다. 이 액티비티가 파괴되면 시점에 시스템에서 뷰모델도 자동으로 파괴할 것이다. Kotlin 에서는 람다를 이용해 보다 간편하게 사용할 수 있다. 옵저버는 이렇게 생겼다.



onChanged 메소드를 가지고 있다. 즉, 관찰하고 있던 LiveData가 변하면 무엇을 할 것인지 액션을 지정할 수 있다. 이후 액티비티/프래그먼트가 활성화되어 있다면 View에서 LiveData를 관찰하여 자동으로 변경 사항을 파악하고 이를 수행한다. 이 부분에서 UI를 업데이트 하도록 만들 것이다.



6. RecyclerView 설정 (xml, Adapter)

기본적인 틀은 작성했으므로, 연락처 리스트를 나타낼 화면의 UI를 만들어주었다. RecyclerView itemactivity_main 레이아웃을 순서로 그려 주고, ContactAdapter, MainActivity 를 각각 손보았다.

1) item_contact.xml


<!--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>


2) activity_main.xml

<!-- 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>


레이아웃을 만들었으면 RecyclerView Adapter 클래스를 만들어준다.


3) ContactAdapter.kt

ContactAdapter({ contactItemClick }, { contactItemLongClick }) 형태로, 클릭했을 때의 액션과 롱클릭 했을 때의 액션을 각각 MainActivity에서 넘겨주는 방식을 사용했다.

// 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()
    }

}


View에서 화면을 갱신할 때 사용할 setContacts 함수도 하나 만들어두었다. 데이터베이스가 변경될 때마다 이 함수가 호출될 것이다.


4) MainActivity.kt

조금 전에 contactViewModel을 초기화한 부분 위에 RecyclerView AdapterLayoutManager 를 만들고 RecyclerView와 연결했다. 뷰모델의 Observer 의 onChanged에 해당하는 식에는 Adapter를 통해 UI를 업데이트 하도록 지정해 주었다.

// 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()
    }
}


Adapter에 onClick 시에 해야할 일과 onLongClick 시에 해야할 일 두 개의 (Contact) -> Unit파라미터를 넘겨주어야 한다.

클릭했을 때에는 현재 contact에서 name, number, id를 뽑아 인텐트에 포함시켜 AddActivity로 넘겨주면서 액티비티를 시작하도록 만들 것이다. AddActivity를 우선 만든 후에 수정해 주기로 한다.

또, 롱클릭 했을 때에는 다이얼로그를 통해 아이템을 삭제하도록 만들었다.



7. AddActivity 생성

우선 간단하게 이름, 번호의 EditText와 하단에 완료 버튼을 가진 액티비티 레이아웃을 만들었다.



<!-- 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에서는 이런 역할을 했다. 1) intent extra로 사용할 상수를 만든다. (companion object) 2) ViewModel 객체를 만든다. 3) 만약 intent가 null이 아니고, extra에 주소록 정보가 모두 들어있다면 EditTextid값을 지정해준다. MainActivity에서 ADD 버튼을 눌렀을 때에는 신규 추가이므로 인텐트가 없고, RecyclerView item 을 눌렀을 때에는 편집을 할 때에는 해당하는 정보를 불러오기 위해 인텐트 값을 불러올 것이다. 4) 하단의 DONE 버튼을 통해 EditText의 null 체크를 한 후, ViewModel을 통해 insert 해주고, MainActivity로 돌아간다.

// 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"
    }
}



아이디 값이 null일 경우 Room에서 자동으로 id를 생성해주면서 새로운 contact를 DB에 추가한다. id값을 Main에서 intent로 받아온 경우, 완료 버튼을 누르면 해당 아이템을 수정하게 된다. DAO에서 OnConflictStrategy를 REPLACE로 설정해두었기 때문이다. 이제 MainActivity로 돌아가서 코드를 조금 수정해 준다.


// 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()
    }
}


최종적인 Main의 코드이다. ADD 버튼을 눌렀을 때에 새로운 주소록 추가를 위해 AddActivity를 시작했다. adapter의 contactItemClick 에는 해당 아이템을 수정하기 위해 intent를 통해 contact 정보를 extra로 추가하고 AddActivity 시작했다.

DAO의 getAll() 함수의 Query에 이름으로 ASC 정렬하도록 설정해두었기 때문에 새로운 데이터를 추가하면 Model -> ViewModel에 라이브데이터 리스트를 넘겨줄 때 자동으로 이름 순으로 정렬된다. 뷰에서는 Observer.onChanged()를 통해 이를 관찰하고 있으므로 자동으로 UI를 갱신한다.




결론

뭔가 클래스를 많이 만들었다. 하지만 클래스 내의 코드는 훨씬 간결해졌다. (편-안) 다만 여러 종류의 DB와 Activity가 존재할텐데, 상용 앱에서는 MVVM을 적용하려면 어떻게 설계를 하는 지가 굉장히 중요해질 것 같다.

그리고 Observer 패턴을 사용하기 때문에, Databinding을 사용하지 않을 수가 없다. 한꺼번에 이 모든 것을 사용하자니 복잡해서 우선 MVVM 패턴으로만 예제를 작성했다. Databinding 예제도 얼른 추가해서 같이 써봐야겠다!


References

  • https://www.randomlists.com/phone-numbers