Idealim
article thumbnail

/* 본 게시물은 ' 오준석의 안드로이드 생존코딩 | with 오준석 ' 의 내용을 토대로 작성되었습니다. */

참고 자료

[Android - Room, LiveData, ViewModel로 Reactive 한 데이터 연동] : 

https://velog.io/@lsb156/Android-Room-LiveData-ViewModel%EB%A1%9C-Reactive%ED%95%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%97%B0%EB%8F%99

[RecyclerView + MVVM + Room을 연습해보자!] : https://todaycode.tistory.com/34


#TodoList 

TodoList 앱 개발에는 RecyclerView, MVVM, Room 데이터베이스의 기초지식이 필요하다. 이 앱의 기능은 캘린더 뷰에서 날짜를 선택하여 선택한 날짜에 맞는 할 일 리스트를 보여주고,  할 일을 추가하는 기능을 구현한다. 

이번 프로젝트 구조로는 Room + RecyclerView + ViewModel 을 사용하겠다. 

1. Calendar View ( 캘린더 기능 및 날짜 선택)

이번에 앱 개발하면서 처음으로 Calendar View를 사용해서 간단한 테스트를 진행했다.

class CalenderActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityCalenderBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val calendar: Calendar = Calendar.getInstance()

        var date = binding.calendarView.date
        binding.calenderText.text = date.toString()
        binding.calendarView.setOnDateChangeListener{ _, year, month, dayOfMonth ->
            calendar.set(Calendar.YEAR, year)
            calendar.set(Calendar.MONTH, month)
            calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth)
            date = calendar.timeInMillis
            binding.calenderText.text = date.toString()
        }
    }
}

실행 결과

초기 값은 오늘 날짜로 calendarView.date 을 통해 Long 형식의 날짜 정보를 받을 수 있다. 또한 날짜를 바꿀 때마다 calendar 객체의 연도/달/일 정보를 설정하여 date 변수에 담는다. 이 때 Long 형식으로 저장하기위해 calendar.timeInMillis를 사용한다. 


실행 결과에 주목해보자. calendarView에서 날짜를 선택할 때, calendar 객체의 년/월/일 값만 변경된다. 결국 calendar 객체에 포함된 시/분/초 는 calendar 객체의 생성 시점에 따라 달라진다. 여기서 문제점이 하나있다. calendar 객체로 데이터를 찾는다면 매번 다른 시/분/초 값 때문에 데이터를 읽어올 수 없을 것이다. 예를 들어, 21/08/17/2000 에 데이터를 추가하고, 21/08/17/2100 에 데이터를 추가한다고 가정해보자. 내가 날짜를 클릭했을 때 데이터베이스에서 값을 가져와야하는데 calendar 객체의 시/분/초 값이 달라 데이터를 못 불러온다. 

이러한 문제점을 해결하기 위한 내가 생각한 방법은 다음과 같다. 첫 번째로는 데이터의 날짜를 string 형으로 저장하는 방법, 두 번째로는 정규식 query 문을 써서 해결하는 방법이다. 세 번째로는 시 분 초를 동일한 값으로 설정하는 것이다. 마지막으로는 LocalDate 를 사용하는 방법이다.(마지막 방법은 최소 API가 26이기에 사용하지 않았다) 


+ 추가적으로 찾아보니 SQLite는 저장할 수 있는 데이터형은 다음과 같다.

데이터 타입 설명
NULL NULL 값
INTEGER 부호있는 정수. 1, 2, 3, 4, 6, or 8 바이트로 저장
REAL 부동 소수점 숫자. 8 바이트로 저장
TEXT 텍스트. UTF-8, UTF-16BE or UTF-16-LE 중 하나에 저장
BLOBBinary Large OBject. 입력 데이터를 그대로 저장

Room DB에 기본 자료형이 아닌 Date 객체를 저장하기 위해서 @TpyeConverter을 사용해야한다. 

/* TypeConverter.kt */
class TypeConverter {
    @TypeConverter
    fun fromTimestamp(value: Long?) : Date? = value?.let { Date(it) }

    @TypeConverter
    fun dateToTimeStamp(date : Date?) : Long? = date?.time
}

Date -> Long, Long -> Date 로 반환하는 함수이다. 

/* Database.kt */
@Database(entities = [TodoEntity::class], version = 1)
@TypeConverters(TypeConverter::class) // TypeConverter를 Database에 포함한다
abstract class Database : RoomDatabase() {
    abstract fun Dao(): UserDAO
}

Datebase class 에 TypeConverter 객체를 Datebase에 집어넣기 위해 @TypeConverters 을 추가한다.

관련글

[Android developers - room 을 사용한 복잡한 데이터 참조]https://developer.android.com/training/data-storage/room/referencing-data?hl=ko 

 

Referencing complex data using Room  |  Android Developers

Room provides functionality for converting between primitive and boxed types but doesn't allow for object references between entities. This document explains how to use type converters and why Room doesn't support object references. Use type converters Som

developer.android.com


일단 간단하게 첫번째 방법으로 문제를 해결해보았다.

        binding.calendarView.setOnDateChangeListener{ _, year, month, dayOfMonth ->
            val dateStr = "${year}-${month + 1}-$dayOfMonth"
            binding.calenderText.text = dateStr
        }

calendar 객체를 쓰지 않고 단순히 String 형식으로 dateStr을 설정했다. month 는 0~11월 달로 표현되므로 month + 1을 했다.

2. DataBase 세팅

우선, Room 라이브러리를 사용하여 데이터베이스를 구축해보자. 

2-1. TodoEntity

@Entity(tableName = "todo")
data class TodoEntity(
    @PrimaryKey(autoGenerate = true)
    var id: Int?,
    var description: String = "",
    var date:String = ""
)

 todo 테이블에 id, description, date 의 칼럼을 추가한다.

2-2. TodoDAO

@Dao
interface TodoDAO {

    @Insert(onConflict = REPLACE)
    fun insert(todo: TodoEntity)

    @Query("SELECT * FROM todo WHERE date = :date")
    fun getAllByDate(date: String): LiveData<List<TodoEntity>?>

    @Delete
    fun delete(todo: TodoEntity)

    @Query("SELECT * FROM todo")
    fun getAll(): LiveData<List<TodoEntity>>
}

데이터 추가 / 날짜에 따른 데이터 가져오기 / 삭제 / 모든 데이터 가져오기 함수를 정의했다.

2-3. TodoDataBase

@Database(entities = [TodoEntity::class], version = 1)
abstract class TodoDatabase : RoomDatabase() {
    abstract fun TodoDAO() : TodoDAO

    // 객체를 생성하는 비용을 줄이기 위해 싱글톤 패턴을 사용한다.
    companion object{
        var instance : TodoDatabase? = null

        fun getInstance(context: Context): TodoDatabase? {
            if (instance == null){
                synchronized(TodoDatabase::class){
                    instance = Room.databaseBuilder(context.applicationContext,
                        TodoDatabase::class.java, "TodoDB.db")
                        .fallbackToDestructiveMigration() // database 에서 스키마의 변화가 생길 때 모든 데이터를 drop 하고 새로 생성
                        .build()
                }
            }
            return instance
        }
    }
}

2-4. TodoRepository

레퍼지토리는 여러 데이터 소스에 대한 액세스를 추상화하는 클래스이다. TodoRepository 클래스는 데이터 작업을 처리한다. 여기서는 sqlite 데이터베이스만 다루지만 network 를 연결해서 데이터를 가져오는 작업 등도 레퍼지토리에서 다루면 된다.

class TodoRepository(application: Application) {
    private val todoDAO: TodoDAO

    init{
        var db = TodoDatabase.getInstance(application)
        todoDAO = db!!.TodoDAO()
    }

    fun insert(todo: TodoEntity){
        todoDAO.insert(todo)
    }

    fun getAllByDate(date: String): LiveData<List<TodoEntity>?>{
        return todoDAO.getAllByDate(date)
    }

    fun getAll(): LiveData<List<TodoEntity>>{
        return todoDAO.getAll()
    }

    fun delete(todo: TodoEntity){
        GlobalScope.launch(Dispatchers.IO) {
            todoDAO.delete(todo)
        }
    }
}

2-5. TodoViewModel

굳이 Repository를 사용하지 않고 ViewModel 에서 db 데이터를 처리해도 상관없다.(지금은 todo 데이터 하나만 처리하기 때문) db를 생성하기 위해서는 context 가 필요하므로 application을 생성자로 주입했다. 

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

    private val repository = TodoRepository(application)

    private val _date = MutableLiveData<String>()

    val date : LiveData<String>
        get() = _date

    fun insert(todo: TodoEntity){
        repository.insert(todo)
    }

    fun delete(todo: TodoEntity){
        repository.delete(todo)
    }

    fun getAllByDate(date: String): LiveData<List<TodoEntity>?>{
        return repository.getAllByDate(date)
    }

    fun getAll(): LiveData<List<TodoEntity>>{
        return repository.getAll()
    }

    fun updateDate(date: String){
        _date.value = date
    }

}

2-6. TodoActivity()

class MainActivity : AppCompatActivity() {

    lateinit var binding : ActivityMainBinding
    private lateinit var adapter: TodoListAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        //ViewModel
        val todoViewModel = ViewModelProvider(this).get(TodoViewModel::class.java)

        val recyclerView = binding.todoRecyclerView

        todoViewModel.date.observe(this, androidx.lifecycle.Observer {
            Log.d("date", it.toString())
            todoViewModel.getAllByDate(it).observe(this,{todoList ->
                adapter = TodoListAdapter(todoList)
                recyclerView.adapter = adapter
            })
        })
        //RecyclerView

        setRecyclerView(todoViewModel, recyclerView)


        binding.calendarView.setOnDateChangeListener{ _, year, month, dayOfMonth ->
            val dateStr = "$year-${month+1}-$dayOfMonth"
            todoViewModel.updateDate(dateStr)
        }

        binding.todoAddBtn.setOnClickListener{
            val description = binding.todoEditText.text.toString()
            GlobalScope.launch(Dispatchers.IO) {
                val date = todoViewModel.date.value!!
                todoViewModel.insert(TodoEntity(null,description, date))
            }
        }
    }

    private fun setRecyclerView(viewModel: TodoViewModel, recyclerView: RecyclerView){
        val layoutManager = LinearLayoutManager(this)
        recyclerView.layoutManager = layoutManager
        val dateOfTodayLong = binding.calendarView.date
        val sdf: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd")
        val dateOfToday = sdf.format(dateOfTodayLong)
        viewModel.updateDate(dateOfToday)
    }
}

date LiveData를 통해 date의 변화를 감지 -> 날짜에 따른 데이터 변화를 감지 후 -> Adapter에 데이터 삽입 ->  RecyclerView를 갱신 순으로 이루어져있다. 데이터를 추가하는 과정은 백그라운드에서 해야하기 때문에 코루틴으로 처리하였다. 

실행 결과

실행결과를 보면 날짜를 클릭했을 때 데이터를 가져오는 것을 확인할 수 있다. 하지만 여기서 문제가 발생하였다. 첫 번째로는 앱을 실행하였을 때 데이터를 불러오지 못하고 날짜를 클릭했을 때만 불러온다. 두 번째로는 빠르게 추가 버튼을 클릭시 어댑터가 속도를 못 따라잡고 엉뚱한 데이터를 불러오는 경우가 존재한다. 

(첫 번째를 해결하기 위해 ViewModel 의 date 초기 값을 init으로 설정해주는 방법, setRecyclerView에서 오늘 날짜를 viewModel.updateDate() 함수를 통해 변경해도 값을 가져오지 못했다.)


3. 오류 해결 및 코드 개선 

3-1. recyclerView notifyDataSetChanged()

Adapter의 데이터를 갱신하는 것을 확인하는 함수이다. 기존 코드에서는 todoList를 adapter의 생성자로 주입해주었다. 이는 adapter 를 다시 생성하여 비용이 많이 들었다. 

Adapter에 setTodoList() 함수를 추가하여 todoList를 갱신하였다. (DiffUtil 을 사용하는 방법도 추가적으로 공부해보자)

    fun setTodoList(todo: List<TodoEntity>?){
        if (todo != null){
            todoList.clear()
            todoList.addAll(todo)
            println(todoList)
        } else {
            todoList.clear()
        }
    }
       // date LiveData 변경 감지
        todoViewModel.date.observe(this, androidx.lifecycle.Observer {
            Log.d("date", it.toString())
            todoViewModel.getAllByDate(it)
                .observe(this,{todoList ->
                    Log.d("todoList", todoList.toString())
                    if(todoList != null){
                        // Adapter 데이터 갱신
                        adapter.setTodoList(todoList)
                        adapter.notifyDataSetChanged()
                    }
            })
        })

3-2. Activity에 ViewModel 불러오기

// ViewModelProvider를 사용
val todoViewModel = ViewModelProvider(this).get(TodoViewModel::class.java)
//위와 동일한 결과
private val todoViewModel: TodoViewModel by viewModels()

by위임을 통해 ViewModel을 불러올 수 있다. viewModels()를 import 하기 위해서는 의존성을 추가해야 한다.

    implementation 'androidx.activity:activity-ktx:1.1.0'

3.3 Room 비동기 처리

db 작업을 코루틴으로 처리하는 방법에 대해 관련 글들을 많이 찾아보고 공부했다. 아직 코루틴이 미숙하여 LiveData를 이용했다. 관찰 가능한 쿼리를 사용하여 데이터를 처리하였다. (코루틴 / LiveData 에 대해 추가적인 공부가 필요)

관련글

3-4. MainActivity 전체 코드

class MainActivity : AppCompatActivity() {

    lateinit var binding : ActivityMainBinding
    private lateinit var adapter: TodoListAdapter
    private val todoViewModel: TodoViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

//        //ViewModel
//        val todoViewModel = ViewModelProvider(this).get(TodoViewModel::class.java)

        //RecyclerView
        val recyclerView = binding.todoRecyclerView
        setRecyclerView(recyclerView)

        // date LiveData 변경 감지
        todoViewModel.date.observe(this, androidx.lifecycle.Observer {
            Log.d("date", it.toString())
            todoViewModel.getAllByDate(it)
                .observe(this,{todoList ->
                    Log.d("todoList", todoList.toString())
                    if(todoList != null){
                        // Adapter 데이터 갱신
                        adapter.setTodoList(todoList)
                        adapter.notifyDataSetChanged()
                    }
            })
        })



        binding.calendarView.setOnDateChangeListener{ _, year, month, dayOfMonth ->
            val dateStr = "$year-${month+1}-$dayOfMonth"
            todoViewModel.updateDate(dateStr)
        }

        binding.todoAddBtn.setOnClickListener{
            val description = binding.todoEditText.text.toString()
            GlobalScope.launch(Dispatchers.IO) {
                val date = todoViewModel.date.value!!
                todoViewModel.insert(TodoEntity(null,description, date))
            }
        }
    }

    private fun setRecyclerView(recyclerView: RecyclerView){
        val layoutManager = LinearLayoutManager(this)
        recyclerView.layoutManager = layoutManager
        adapter = TodoListAdapter()
        recyclerView.adapter = adapter

        //처음 시작 시 오늘 날짜를 설정해줌.
        val dateOfToday = getTodayOfDate()
        todoViewModel.updateDate(dateOfToday)
    }

    private fun getTodayOfDate(): String {
        // 캘린더뷰의 날씨 가져옴
        val dateOfTodayLong = binding.calendarView.date
        val sdf = SimpleDateFormat("yyyy-MM-dd")
        return sdf.format(dateOfTodayLong)
    }

}

3-5. TodoListAdapter 전체 코드

class TodoListAdapter : RecyclerView.Adapter<TodoListAdapter.ViewHolder>(){

    private val todoList = ArrayList<TodoEntity>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoListAdapter.ViewHolder {
        val binding = TodoitemRecyclerBinding.inflate(LayoutInflater.from(parent.context))
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: TodoListAdapter.ViewHolder, position: Int) {
        val todoEntity = todoList[position]
        holder.setTodoListUI(todoEntity,position)
    }

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

    fun setTodoList(todo: List<TodoEntity>?){
        if (todo != null){
            todoList.clear()
            todoList.addAll(todo)
            println(todoList)
        } else {
            todoList.clear()
        }
    }

    inner class ViewHolder(private val binding: TodoitemRecyclerBinding) : RecyclerView.ViewHolder(binding.root){
        fun setTodoListUI(todo: TodoEntity, position: Int){
            binding.todoDescription.text = todo.description
            binding.todoId.text = "$position"
            binding.todoDate.text = todo.date
        }
    }
}

실행 결과

 

반응형
profile

Idealim

@Idealim

읽어주셔서 감사합니다. 잘못된 내용이 있으면 언제든 댓글로 피드백 부탁드립니다.