ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • MVVM 패턴 + databinding + Room DB + recyclerview 예제
    안드로이드 학습/Android 기술면접 대비 코드 2023. 6. 15. 11:04

    이번 예제는 MVVM 패턴 + AAC(databinding + Room DB) + recyclerview를 적용시켰다.

    아래에 있는 링크로 MVVM + Databinding 예제와 Room DB 예제를 먼저 학습한다면 이해하기 더욱 쉬울듯 싶다.

     

    참고 : 

    1. MVVM 예제

    2. Room 예제

     

     

    해당 예제는 사람 이름과 무력 지력을 추가 시키면 Room DB에 데이터가 들어가고 해당 내용을 자동적으로 ViewModel과 LiveData를 통해 데이터를 업데이트 하는 방식으로 구성해보았다. 아래 그림에서 Remote Data Source만 제외된 예제라고 볼수 있다. 

     

     

    결과 스크린샷

    프로젝트 구성

    Dependency 추가

    • 1. build.gradle (app)
    • 2. build.gradle (project)

    RoomDB 추가

    • 3. Character.kt
    • 4. CharacterDao.kt
    • 5. CharRoomDB.kt

    Repository 추가

    • 6. CharacterRepository.kt

    ViewModel 추가

    • 7. MainViewModel.kt

    RecyclerView 추가

    • 8. CharcterAdapter.kt
    • 9. character_item_list.xml

    Activity 추가

    • 10. MainActivity.kt
    • 11. activity_main.xml추가

     


     

    1.build.gradle (app)

    plugins {
        id 'com.android.application'
        id 'org.jetbrains.kotlin.android'
        id 'kotlin-kapt'
    }
    
    ... 생략 ...
    
    dependencies {
    
    	... 생략 ...
    
        var room_version = "2.5.1"
        implementation("androidx.room:room-runtime:$room_version")
        annotationProcessor("androidx.room:room-compiler:$room_version")
        kapt("androidx.room:room-compiler:$room_version")
    }

     

    • plugins 쪽에  'id 'kotlin-kapt' 를 넣었고
    • dependencies에 room 라이브러리 관련된 것을 넣어주었다.

     

    2.build.gradle (project)

    plugins {
        id 'com.android.application' version '8.0.2' apply false
        id 'com.android.library' version '8.0.2' apply false
        id 'org.jetbrains.kotlin.android' version '1.8.20' apply false
    }

     


     

    3. Character.kt

    @Entity(
        indices = [
            Index(value = ["name"], unique = true)
        ]
    )
    data class Character(
        @ColumnInfo(name = "name") val name: String,
        @ColumnInfo(name = "strength") val strength: String,
        @ColumnInfo(name = "intelligence") val intelligence: String,
    ) {
        @PrimaryKey(autoGenerate = true)
        var id: Int = 0
    }

     

    중복된 삼국지 인물의 추가를 막아주기 위해서 'name' column에 unique를 걸어줬다. 여러개 unique를 해주려면 오른쪽처럼 추가시켜주만 하면 된다.["name", "strength]

    @Entity(
        indices = [
            Index(value = ["name"], unique = true)
        ]
    )

     

    • id 같은 경우 PrimaryKey 지정과 함께 자동 생성을 위해 autoGenerate를 true 값을 주었다.
    • 그리고 parameter 값으로 주지 않고 body부분에 넣어놨다. 나머지는 다 parameter부분에 넣어서 캐릭터 생성할때 유저가 직접 값을 넣어주는 방식으로 구성했다.

     

    4. CharacterDao.kt

     

    • 해당 예제에서는 getAll(), insert, delete 밖에 사용 안하지만 나중에 참고를 위해 여러가지 만들었다.
    @Dao
    interface CharacterDao {
        @Query("SELECT * FROM character")
        fun getAll(): LiveData<List<Character>>
    
        @Query("SELECT * FROM character WHERE id IN (:characterIds)")
        fun loadAllByIds(characterIds: IntArray): List<Character>
    
        @Query("SELECT * FROM character WHERE name LIKE :name LIMIT 1")
        fun findByName(name: String): Character
    
        @Insert
        fun insertAll(vararg character: Character)
    
        @Insert
        fun insert(character: Character)
    
        @Delete
        fun delete(character: Character)
    
        @Query("DELETE FROM character WHERE name LIKE :name LIMIT 1")
        fun deleteByName(name: String){
    
        }
    }

     

    5. CharRoomDB.kt

     

    • companion object는 자바로 보면 static 같은 개념으로 RoomDB에 싱글톤 패턴을 적용시킨 부분이다.
    @Database(entities = [Character::class], version = 1)
    abstract class CharRoomDB : RoomDatabase() {
    
        abstract fun characterDao(): CharacterDao
    
        companion object {
            private var instance: CharRoomDB? = null
    
            @Synchronized
            fun getInstance(context: Context): CharRoomDB? {
                if (instance == null) {
                    synchronized(CharRoomDB::class) {
                        instance = Room.databaseBuilder(
                            context.applicationContext,
                            CharRoomDB::class.java,
                            "char-database"
                        )
                            .build()
                    }
                }
                return instance
            }
        }
    }

     


     

    6. CharacterRepository.kt

    더보기
    class CharacterRepository(var application: Application) {
    
        private val charRoomDB = CharRoomDB.getInstance(application)!!
        private val characterDao: CharacterDao = charRoomDB.characterDao()
        private val characters: LiveData<List<Character>> = characterDao.getAll()
    
        fun getAll(): LiveData<List<Character>> {
            return characters
        }
    
        fun insert(character: Character) {
            try {
                val thread = Thread {
                    try {
                        characterDao.insert(character)
                    } catch (e: SQLiteConstraintException) {
    
                        Log.e("aaaaaaa", e.toString())
                        //이름에 Unique를 걸어놨기 때문에
                        val errorMsg = e.toString()
                        if (errorMsg.contains("UNIQUE constraint failed: Character.name")) {
                            val handler = Handler(Looper.getMainLooper())
                            handler.postDelayed(Runnable {
                                Toast.makeText(application, "이름이 중복됩니다.", Toast.LENGTH_SHORT).show()
                            }, 0)
                        }
                    }
                }
                thread.start()
            } catch (e: Exception) {
                Log.e("eeeeeeeee", e.toString())
            }
        }
    
        fun delete(character: Character) {
            try {
                val thread = Thread {
                    characterDao.delete(character)
                }
                thread.start()
            } catch (e: Exception) {
            }
        }
    
        @WorkerThread
        suspend fun deleteByName(name: String) {
            characterDao.deleteByName(name)
        }
    }

     

    Repository부분은 좀 집고 넘어가야 하는 부분이 있다. 

     

    1. Room DB에 저장되는건 비동기 방식으로 저장되는건지 UI Thread에서 CRUD를 처리해서는 안된다. 내가 아는 범위로는 대략 2가지 방법이 있다.

    • Worker Thread를 만들어서 해당 작업 수행
    • 코루틴을 만들어서 수행

    해당 예제에서는 Worker Thread 만드는 2가지 방식을 사용하고 있다. 

     

     

    2. insert 메소드에 있는 Toast message 부분에 Handler를 안쓰면 아래와 같은 에러가 발생했다. UI 스레드가 아닌 스레드에서 토스트 창을 띄우려고 해서 발생한 문제 라고 한다.

    Process: com.jhl.mvvm_roomdb, PID: 22375
    java.lang.NullPointerException: Can't toast on a thread that has not called Looper.prepare()
    at com.android.internal.util.Preconditions.checkNotNull(Preconditions.java:157)
    ... 생략 ...

     

    3. Character List가 LiveData로 감싸져 있다. 이것을 통해 그냥 넣어주는 작업만으로도 자동적으로 업데이트 된다.

     


     

    7. MainViewModel.kt

    class MainViewModel(application: Application) : AndroidViewModel(application) {
    
        private val repository = CharacterRepository(application)
        private var _character = repository.getAll()
        private val character: LiveData<List<Character>>
            get() = _character
    
        fun getAll(): LiveData<List<Character>> {
            return character
        }
    
        fun insert(character: Character) {
            repository.insert(character)
        }
    
        fun delete(character: Character) {
            repository.delete(character)
            Log.d("herehereherehere", "here")
        }
    
    }

     


     

    8. CharcterAdapter.kt

     

    더보기
    class CharacterAdapter :
        ListAdapter<Character, CharacterAdapter.MyCustomViewHolder>(DIFF_CALLBACK) {
    
        private var dataList: List<Character> = ArrayList()
        lateinit var listener: OnItemClickListener
    
        companion object {
            private val DIFF_CALLBACK: DiffUtil.ItemCallback<Character> =
                object : DiffUtil.ItemCallback<Character>() {
                    override fun areItemsTheSame(oldItem: Character, newItem: Character): Boolean {
                        return oldItem.id === newItem.id
                    }
    
                    override fun areContentsTheSame(oldItem: Character, newItem: Character): Boolean {
                        return oldItem.name == newItem.name &&
                                oldItem.strength == newItem.strength &&
                                oldItem.intelligence == newItem.intelligence
                    }
                }
        }
    
        class MyCustomViewHolder(private val binding: CharacterItemListBinding) :
            RecyclerView.ViewHolder(binding.root) {
            fun bind(character: Character, adapter: CharacterAdapter) {
                binding.character = character
                binding.adapter = adapter
                binding.executePendingBindings()
            }
        }
    
        //만들어진 뷰홀더 없을때 뷰홀더(레이아웃) 생성하는 함수
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyCustomViewHolder {
            val inflater = LayoutInflater.from(parent.context)
            val binding = CharacterItemListBinding.inflate(inflater, parent, false)
            return MyCustomViewHolder(binding)
        }
    
        override fun getItemCount(): Int = dataList.size
    
        override fun onBindViewHolder(holder: MyCustomViewHolder, position: Int) {
            holder.bind(dataList[position], this)
        }
    
        @SuppressLint("NotifyDataSetChanged")
        fun setData(list: List<Character>) {
            dataList = list
            notifyDataSetChanged()
        }
    
        // MainActivity에 해당 메소드 정의
        interface OnItemClickListener {
            fun onItemClick(character: Character?)
        }
    
        fun setOnItemClickListener(listener: OnItemClickListener) {
            this.listener = listener
        }
    }

     

     

     

    중간에 보면 'DIFF_CALLBACK'  부분이 있다. 다른 예제들을 보니 이런게 있었는데 이건 왜 있지 싶어서 일단 추가 시켜보고 알아봤다.

        companion object {
            private val DIFF_CALLBACK: DiffUtil.ItemCallback<Character> =
                object : DiffUtil.ItemCallback<Character>() {
                    override fun areItemsTheSame(oldItem: Character, newItem: Character): Boolean {
                        return oldItem.id === newItem.id
                    }
    
                    override fun areContentsTheSame(oldItem: Character, newItem: Character): Boolean {
                        return oldItem.name == newItem.name &&
                                oldItem.strength == newItem.strength &&
                                oldItem.intelligence == newItem.intelligence
                    }
                }
        }

     

    • notifyDataSetChanged() 라는 함수가 있지만 뷰를 업데이트시키는 과정에서 모든 데이터를 다시 그리기 때문에 비효율적이다.
    • 그래서 DiffUtil을 사용해 RecyclerView에서 데이터 업데이트를 효율적으로 처리 하기 위해 객체간의 차이점을 찾고, 업데이트 되어야 할 목록을 반환해주며, 어댑터에 대한 업데이트를 알리는데 사용된다.
    • 해당 내용을 더 자세히 알려면 너무 길어질것 같아서... 일단 '참고링크' 를 보고 나중에 정리해봐야겠다.

     

    9. character_item_list.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>
    
            <import type="android.view.View" />
    
            <variable
                name="viewModel"
                type="com.jhl.mvvm_roomdb.MainViewModel" />
    
            <variable
                name="character"
                type="com.jhl.mvvm_roomdb.repository.roomDB.Character" />
    
            <variable
                name="adapter"
                type="com.jhl.mvvm_roomdb.adapter.CharacterAdapter" />
    
        </data>
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="80dp"
            android:gravity="center"
            android:orientation="horizontal"
            android:weightSum="4">
    
    
            <TextView
                android:id="@+id/name"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:text="@{character.name}"
                android:textSize="30sp"
                android:textStyle="bold"
                android:layout_weight="1"/>
    
            <TextView
                android:id="@+id/power"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:text="@{character.strength}"
                android:textSize="30sp"
                android:textStyle="bold"
                android:layout_weight="1"/>
    
            <TextView
                android:id="@+id/intelli"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:text="@{character.intelligence}"
                android:textSize="30sp"
                android:textStyle="bold"
                android:layout_weight="1"/>
    
            <Button
                android:id="@+id/deleteButton"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:textSize="20sp"
                android:onClick="@{(view) -> adapter.listener.onItemClick(character)}"
                android:layout_weight="1"
                android:text="삭제" />
    
        </LinearLayout>
    
    
    </layout>

     

    • 데이터 바인딩 적용됨.

     

    10. MainActivity.kt

     

    더보기
    class MainActivity : AppCompatActivity(), CharacterAdapter.OnItemClickListener {
    
        private lateinit var binding: ActivityMainBinding
        lateinit var mainViewModel: MainViewModel
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
    
            val charRecyclerView = binding.charRecyclerView
            val charAdapter = CharacterAdapter()
            charAdapter.setOnItemClickListener(this)
    
            charRecyclerView.adapter = charAdapter
            charRecyclerView.layoutManager = LinearLayoutManager(this)
            charRecyclerView.setHasFixedSize(true)
    
            mainViewModel = ViewModelProvider(this)[MainViewModel::class.java]
    
            mainViewModel.getAll().observe(this) {
                charAdapter.setData(it)
                Toast.makeText(this@MainActivity, "onChanged", Toast.LENGTH_SHORT).show()
            }
    
            binding.button.setOnClickListener{
                val name = binding.nameEditText.text.toString()
                val power = binding.strengthEditText.text.toString()
                val intel = binding.intelText.text.toString()
                mainViewModel.insert(Character(name, power, intel))
    
                binding.nameEditText.setText("")
                binding.strengthEditText.setText("")
                binding.intelText.setText("")
    
            }
        }
    
        override fun onItemClick(character: Character?) {
            if (character != null) {
                mainViewModel.delete(character)
            }
        }
    }

     

    • adapter에서 MainViewModel의 기능을 써야 하는데 파라미터 값으로 넘겨줄지 아니면 interface를 만들어 가져다 쓸지 하다가 interface를 만들어서 가져다 썼다. 

     

     

    11. 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>
            <import type="android.view.View" />
            <variable
                name="viewModel"
                type="com.jhl.mvvm_roomdb.MainViewModel" />
        </data>
    
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".MainActivity">
    
            <LinearLayout
                android:id="@+id/content_bar"
                android:layout_width="match_parent"
                android:layout_height="80dp"
                android:gravity="center"
                android:orientation="horizontal"
                app:layout_constraintTop_toTopOf="parent"
                android:weightSum="4">
    
                <TextView
                    android:id="@+id/name"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:gravity="center"
                    android:text="이름"
                    android:textSize="30sp"
                    android:textStyle="bold"
                    android:layout_weight="1"/>
    
                <TextView
                    android:id="@+id/power"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:gravity="center"
                    android:text="무력"
                    android:textSize="30sp"
                    android:textStyle="bold"
                    android:layout_weight="1"/>
    
                <TextView
                    android:id="@+id/Intelli"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:gravity="center"
                    android:text="지력"
                    android:textSize="30sp"
                    android:textStyle="bold"
                    android:layout_weight="1"/>
    
                <Button
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:visibility="invisible"
                    android:layout_weight="1"/>
    
            </LinearLayout>
    
            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/charRecyclerView"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                app:layout_constraintBottom_toTopOf="@+id/nameEditText"
                app:layout_constraintTop_toBottomOf="@+id/content_bar"
                tools:listitem="@layout/character_item_list" />
    
            <EditText
                android:id="@+id/nameEditText"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="30dp"
                android:layout_marginBottom="10dp"
                android:ems="10"
                android:hint="이름"
                android:inputType="text"
                app:layout_constraintBottom_toTopOf="@+id/strengthEditText"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/charRecyclerView" />
    
            <EditText
                android:id="@+id/strengthEditText"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="10dp"
                android:ems="10"
                android:hint="무력"
                android:inputType="number"
                app:layout_constraintBottom_toTopOf="@+id/intelText"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent" />
    
            <EditText
                android:id="@+id/intelText"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="20dp"
                android:ems="10"
                android:hint="지력"
                android:inputType="number"
                app:layout_constraintBottom_toTopOf="@+id/button"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent" />
    
            <Button
                android:id="@+id/button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="30dp"
                android:text="추가"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent" />
    
        </androidx.constraintlayout.widget.ConstraintLayout>
    </layout>

    '안드로이드 학습 > Android 기술면접 대비 코드' 카테고리의 다른 글

    MVC 패턴 예제  (0) 2023.12.14
    안드로이드 Bound Service 예제  (0) 2023.07.03
    (AAC) Room DB  (0) 2023.06.14
    Broadcast Receiver 예제  (0) 2023.06.11
    간단한 MVVM 패턴 예제  (0) 2023.06.07
Designed by Tistory.