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()
}
}
'안드로이드 코틀린' 카테고리의 다른 글
[Kotlin][Android] 안드로이드 - Radio Button, Radio Group 사용법 (0) | 2021.10.09 |
---|---|
[Kotlin][Android] chip 동적 추가 삭제하기 (0) | 2021.10.07 |
[Android][Kotlin] 키보드 완료 버튼 누를 때 버튼 클릭되게 하기 (0) | 2021.09.29 |
[Kotlin][Android] Rxkotlin 이용한 스레드 (0) | 2021.09.09 |
[Kotlin][Android] JetPack UI 컴포넌트 Pallete 사용해보기 (0) | 2021.09.07 |