/* 본 게시물은 ' 오준석의 안드로이드 생존코딩 | with 오준석 ' 의 내용을 토대로 작성되었습니다. */
참고 자료
[Android - Room, LiveData, ViewModel로 Reactive 한 데이터 연동] :
[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
일단 간단하게 첫번째 방법으로 문제를 해결해보았다.
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
}
}
}
실행 결과