마스터Q&A 안드로이드는 안드로이드 개발자들의 질문과 답변을 위한 지식 커뮤니티 사이트입니다. 안드로이드펍에서 운영하고 있습니다. [사용법, 운영진]

며칠 째 중첩리사이클러뷰 + MVVM으로 골머리를 앓고 있습니다 ㅠㅠ

0 추천

enter image description here

 

위의 사진과 같이 중첩리사이클러뷰를 구현 중에 있는데요.. MVVM 패턴을 공부하면서 진행중인데, 어떤식으로 코드를 넣어야할지 도저히 감이 안와서 힌트를 얻고자 질문드립니다.

우선 현재의 데이터 모델입니다.

@Entity(
    tableName="task",
)
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    var id : Int?,
    var start_time : String,
    var end_time : String,
    var content : String,
    @ColumnInfo(defaultValue = "NO")
    var favorite : String,
    @ColumnInfo(defaultValue = "NO")
    var complete : String,
    @ColumnInfo(defaultValue = "NO")
    var alarm : String
)

 

그리고, 여기서부터 골머리가 아파오기 시작합니다.

DAO 파일입니다.

@Dao
interface ListDAO {
    /**
     * onConflict = REPLACE
     * 기존의 기본키를 가진 값이 있는데, 나중에 해당 기본키에 다른 값을 넣으면,
     * 넣은 값으로 대체된다는 의미이다. 쉽게 이야기해서 덮어쓰기의 개념.
     */

    // Task Table CRD

    // onConflict (충돌이 일어났을 때 덮어쓰기)
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertTask(task : TaskEntity)

    @Query("SELECT * FROM task")
    fun getAllTask() : LiveData<List<TaskEntity>>

    @Delete
    fun deleteTask(task : TaskEntity)

    @Update
    fun updateTask(task: TaskEntity)

    @Query("SELECT DISTINCT start_time FROM task ORDER BY start_time ASC")
    fun getParentTask() : LiveData<List<String>>

    @Query("SELECT * FROM TASK WHERE start_time = :start_time")
    fun getChildTask(start_time : String) : List<TaskEntity>

}

 

위의 그림과 함께 보시면 아시겠지만, 우선 부모 리사이클러뷰에는 시작 날짜들을 세팅하고, 자식 리사이클러뷰에는 "시작 날짜에 속하는" 할일들만 들어갈 예정입니다.

사실 LiveData를 넣기 전에는 그럭저럭 위의 사진처럼 잘 돌아갔는데, LiveData를 넣으면서부터 어떻게 넣어야할지 도저히 감이 안잡히더라구요. 진짜 제가 멍청하다는걸 계속 깨달으면서 5일동안 고민해보아도 답이 안나오더라구요..

밑은 Repository, ViewModel 입니다.

 

class ListRepository(private val listDAO : ListDAO) {
    val readAllData : LiveData<List<TaskEntity>> = listDAO.getAllTask()
    val readParentData : LiveData<List<String>> = listDAO.getParentTask()


    fun insertTask(task : TaskEntity) {
        listDAO.insertTask(task)
    }

    fun deleteTask(task : TaskEntity) {
        listDAO.deleteTask(task)
    }

    fun updateTask(task : TaskEntity) {
        listDAO.updateTask(task)
    }

    fun readChildData(start_time : String) : List<TaskEntity> {
        return listDAO.getChildTask(start_time)
    }
}
// 뷰모델은 DB에 직접 접근 X
class ListViewModel(application : Application) : AndroidViewModel(application){

    val readAllData : LiveData<List<TaskEntity>>
    val readParentData : LiveData<List<String>>
    private val repository : ListRepository

    private var _childItem : MutableLiveData<ArrayList<TaskEntity>> = MutableLiveData(arrayListOf())
    val childItem : LiveData<ArrayList<TaskEntity>>
        get() = _childItem


    // getter, setter
    private var _currentData = MutableLiveData<List<TaskEntity>>()
    val currentData : LiveData<List<TaskEntity>>
        get() = _currentData

    // 초기값 설정
    init {
        val listDAO = ListDatabase.INSTANCE!!.listDAO()
        repository = ListRepository(listDAO)
        readAllData = repository.readAllData
        readParentData = repository.readParentData
    }

    fun insertTask(task : TaskEntity) {
        repository.insertTask(task)
    }

    fun updateTask(task : TaskEntity) {
        repository.updateTask(task)
    }

    fun deleteTask(task: TaskEntity) {
        repository.deleteTask(task)
    }

    fun getChildTask(start_time : String) {
        val tmp = repository.readChildData(start_time)
        _currentData.postValue(tmp)
    }

}

 

구글링도 여러번 해보고 그랬지만 중첩 리사이클러뷰는 정보가 너무.. 너무 없더라구요. 제가 ViewModel에 대한 이해도가 부족한 것도 굉장히 큰 비중을 차지하는 것 같구요..

 

밑은 프래그먼트와 부모 어댑터입니다. 자식 어댑터는 데이터 세팅하는게 다라서 일단 안넣겠습니다.

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        var currentTime : Long = System.currentTimeMillis()
        var timeFormat = SimpleDateFormat("yyyy-MM-dd", Locale("ko","KR"))
        today = timeFormat.format(Date(currentTime))

         // 코틀린에서 apply 변수로 가독성 좋게 표현할 수 있음.
        binding.listRcview.apply {
            // this.addItemDecoration(dividerItemDecoration)
            this.layoutManager = LinearLayoutManager(requireContext())
            this.addItemDecoration(RecyclerViewDecoration(30))
        }

        // 현재 날짜 데이터 리스트 관찰하여 변경시 어댑터에 전달해줌
        listViewModel.readParentData.observe(viewLifecycleOwner) {
            Log.d("live Parent Data :: ", "ON")
            binding.listRcview.adapter = parentAdapter
            parentAdapter.setParent(it)
        }

        // . . . .
class ListParentAdapter(
    val context: Context,
    private val listViewModel: ListViewModel,
    private val lifecycleOwner: LifecycleOwner
) : RecyclerView.Adapter<ListParentAdapter.ListParentViewHolder>() {

    private var parentList = emptyList<String>()
    var testList = emptyList<TaskEntity>()

    fun setParent(task: List<String>) {
        parentList = task
        notifyDataSetChanged()
    }

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

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListParentViewHolder {
        val binding =
            ItemListParentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ListParentViewHolder(binding)
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onBindViewHolder(holder: ListParentViewHolder, position: Int) {
        val parentDate = parentList[position]


        Log.d("ParentDate :: ", parentDate)

        holder.date.text = parentDate

        var divideItemDecoration =
            DividerItemDecoration(holder.child.context, LinearLayoutManager(context).orientation)
        // ItemTouchHelper 를 RecyclerView 와 연결한다.
        val swipeHelperCallback = SwipeHelperCallback().apply {
            setClamp(200f)
        }

        val itemTouchHelper = ItemTouchHelper(swipeHelperCallback)
        itemTouchHelper.attachToRecyclerView(holder.child)

        holder.child.apply {
            this.addItemDecoration(divideItemDecoration)
            this.layoutManager = LinearLayoutManager(context)
            // this.addItemDecoration(RecyclerViewDecoration(20))
            this.hasFixedSize()
            this.setOnTouchListener { _, _ ->
                swipeHelperCallback.removePreviousClamp(this)
                false
            }
        }

        listViewModel.readAllData.observe(lifecycleOwner) {
            test(parentDate)
        }

        listViewModel.currentData.observe(lifecycleOwner) {
            testTwo(it, holder.child)
        }
       

    @SuppressLint("StaticFieldLeak")
    fun test(parentDate: String) {
        var testData : List<TaskEntity>? = null
        val childTask =
            object : AsyncTask<Unit, Unit, Unit>() {
                override fun doInBackground(vararg p0: Unit?) {
                    listViewModel.getChildTask(parentDate)
                }
            }
        childTask.execute()
    }

 

우선 제가 생각해본 것은, 모델이 TaskEntity 하나만으로는 안될 것 같은데,, 부모 자식으로 나눠서 부모쪽에 ArrayList 를 주는게 맞는가 싶으면서도 어떤식으로 데이터를 넣어야할지 도무지 감이 안잡히더라구요.

 

피가 되고 살이 되도록 쓴 소리도 달게 받겠으니 부디 조언을 주세요 ㅠㅠ 어떤 식으로 고민을 해야할지 조차 모르겠어서 너무나도 힘이 듭니다,,,, 도와주세요...!!!

hoya25 (150 포인트) 님이 2021년 11월 29일 질문
위의 화면을 중첩 리사이클러뷰로 만드셨어요? 제가 보기에는 리사이클러뷰 하나에 같은 날짜를 끼리 section(group) header만 집어넣으면 될 것으로 보이는데요. 중첩으로 만들면 복잡도가 대폭 증가하기 때문에 리사이클러뷰 하나로 처리할 수 있다면 하나로 하시면 될 것 같아요. 저도 저런 비슷한 화면이 있는데 리사이클러뷰 하나에 ItemDecoration을 이용해서 날짜별로 그룹지어서 그룹별로 헤더만 추가해서 사용하고 있어요. 아래 링크는 제가 하고 있는 방법과 같고요.
https://demonuts.com/android-recyclerview-section-header/

개발자랩에 어댑터에 들어가는 리스트를 가지고 다르게 하는 방법도 나와있네요.
https://developer.android.com/codelabs/kotlin-android-training-headers#0
한번 체크해 보세요.

제가 말씀드린 방법들이 님이 원하는 것과 같다면 방법을 바꾸세요.

1개의 답변

0 추천
 
채택된 답변

DAO에서 가져온 리스트에 그룹헤더를 붙여서 처리하는 방법을 공유해 드릴게요.   DB테이블의 구조를 그대로 사용하지 마시고 화면에 필요한 데이터로 가공하여 처리하는 것이 핵심포인트 중의 하나입니다. 클래스, 프로퍼티 이름 등은 살짝 다를 수 있지만 뭔지아실거예요.

먼저 어댑터에 날짜별 그룹헤더와 Task를 보여주기 위해 Mutli ViewType 이용해서 처리합니다. 이를 위해서 Sealed class를 사용하겠습니다.

sealed class TaskListItem {
    abstract val task: TaskEntity
    abstract val layoutId: Int

    data class Header(
        override val task: TaskEntity,
        override val layoutId: Int = VIEW_TYPE
    ) : TaskListItem() {

        companion object {
            const val VIEW_TYPE = R.layout.item_task_header
        }
    }

    data class Item(
        override val task: TaskEntity,
        override val layoutId: Int = VIEW_TYPE
    ) : TaskListItem() {

        companion object {
            const val VIEW_TYPE = R.layout.item_task
        }
    }
}

 

ViewModel 에서는 Repository에서 가져온 데이터를 어댑터에서 사용할 수 있는 형태로 가공합니다.

lass TaskViewModel(
    private val taskRepository: TaskRepository
) : ViewModel() {

    private val taskLiveData = MutableLiveData<List<TaskListItem>>()
    val tasks: LiveData<List<TaskListItem>> get() = taskLiveData


    fun fetchTasks() {
        viewModelScope.launch {
            val listItems = taskRepository.getAllTasks().toListItems()
            taskLiveData.postValue(listItems)
        }
    }

    // Repository 에서 가져온 리스트 가공. Repository에서는 날짜별로 정렬한 상태로 리스트를 반환해야함.
    private fun List<TaskEntity>.toListItems(): List<TaskListItem> {
        val result = arrayListOf<TaskListItem>() // 결과를 리턴할 리스트

        var groupHeaderDate = "" // 그룹날짜
        this.forEach { task ->
            // 날짜가 달라지면 그룹헤더를 추가.
            if (groupHeaderDate != task.start_time) {
                result.add(TaskListItem.Header(task))
            }

            //  타스크 추가.
            result.add(TaskListItem.Item(task))

            // 그룹날짜를 바로 이전 날짜로 설정.
            groupHeaderDate = task.start_time
        }

        return result
    }
}

toListItem()을 눈여겨 보시면 될 것 같아요. 로직은 별로 복잡하지 않습니다. Kotlin extension function 이므로 잘 이해가 가지 않으시면 아래처럼 this를 tasks로 변경해서 보시면 됩니다.

 private fun toListItem(tasks: List<TaskEntity>) {
     // this를 tasks로 변경.
 }

 

다음은 어댑터입니다. Multi View를 어떻게 지원하는지 보시면 좋을 것 같습니다.

class TaskAdapter(tasks: List<TaskListItem> = emptyList()) :
    RecyclerView.Adapter<TaskViewHolder>() {

    private val items = arrayListOf<TaskListItem>()

    fun submitList(items: List<TaskListItem>) {
        this.items.clear()
        this.items.addAll(items)
    }

    private fun getItem(position: Int): TaskListItem = this.items[position]

    override fun getItemCount(): Int = this.items.size

    // 리턴값은 onCreateViewHolder의 viewType에 전달됩니다.
    override fun getItemViewType(position: Int): Int = getItem(position).layoutId

    // viewType에 따라 각기 다른 ViewHolder를 생성해 줍니다.
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
        return when (viewType) {
            TaskListItem.Header.VIEW_TYPE -> TaskHeaderViewHolder(itemView)
            TaskListItem.Item.VIEW_TYPE -> TaskItemViewHolder(itemView)
            else -> throw IllegalArgumentException("Cannot create ViewHolder for view type: $viewType")
        }
    }

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
}


abstract class TaskViewHolder(
    itemView: View
) : RecyclerView.ViewHolder(itemView) {
    abstract fun bind(item: TaskListItem)
}

class TaskHeaderViewHolder(
    itemView: View
) : TaskViewHolder(itemView) {

    private val binding by lazy { ItemTaskHeaderBinding.bind(itemView) }

    override fun bind(item: TaskListItem) {
        val task = (item as TaskListItem.Header).task
        binding.apply {
            startDateTxt.text = task.start_time
        }
    }
}

class TaskItemViewHolder(
    itemView: View
) : TaskViewHolder(itemView) {

    private val binding by lazy { ItemTaskBinding.bind(itemView) }

    override fun bind(item: TaskListItem) {
        val task = (item as TaskListItem.Item).task
        binding.apply {
            val alarmResId = if (task.hasAlarm) R.drawable.ic_alarm_on else R.drawable.ic_alarm_off
            alarmImg.setImageResource(alarmResId)
            contentTxt.text = if (task.isDone) task.content.strikeThrough() else task.content
            endDateTxt.text = if (task.end_time.isNotBlank()) "~ ".plus(task.end_time.takeLast(5)) else ""
        }
    }
}

 

Adapter는 ListAdapter라는 걸 사용하면 데이터가 많아질 수록 퍼포먼스가 좋아집니다. 뷰홀더는 bind하는 부분들이 좀 지저분한데, 정리해서 사용하시면 될 것 같습니다.

최종적으로 액티비티. 코드가 정리된게 아니니 참고만 하세요.

class MainActivity : AppCompatActivity() {

    lateinit var viewModel: TaskViewModel

    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }

    override fun onCreate(savedInstanceState: Bundle?) {
        Injector.inject(this)
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        setupViews()
        observeListItems()
    }

    private val taskAdapter by lazy {
        TaskAdapter()
    }

    private fun setupViews() {
        binding.apply {
            listRcview.adapter = taskAdapter
            listRcview.addItemDecoration(DividerItemDecoration(listRcview.context, RecyclerView.VERTICAL))
        }
    }

    private fun observeListItems() {
        viewModel.tasks.observe(this) { taskItems ->
            taskAdapter.submitList(taskItems)
        }
    }

    override fun onStart() {
        super.onStart()
        viewModel.fetchTasks()
    }

 

실행 결과입니다.

spark (224,800 포인트) 님이 2021년 12월 1일 답변
hoya25님이 2021년 12월 1일 채택됨
감사합니다ㅠㅠ 참고해서 열심히 만들어보겠습니다..!
혹시, Repository 를 꼭 ViewModel 의 생성자로 삼아야 작동이 되는건가요? ㅠㅠ 그렇다면 어떤 이유로 그렇게 진행해야 하는지 알 수 있을까요?
그런건 아니지만, 코드의 측면에서 더 좋은 코드가 되기 때문입니다. 생성자에 의존 클래스를 전달하는 것을 Dependney Injection(의존성 주입?)이라고 부릅니다. 줄여서 DI입니다. 주된 이유는 ViewModel과 Repository간의 의존성을 줄이고 테스트를 용이하게 하는 디자인패턴입니다. 현업에서 아주 흔하게 사용하는 패턴입니다.
테스트코드에서 아래와 같이 쉽게 테스트용 Repository 를 사용해 테스트 코드를 작성할 수 있습니다.

interface TaskRepository {

}

// 실제 사용하는 Repoistory
class TaskRepositoryImpl: TaskRepository {

}

// 테스트용 Repository
class FakeRepostitory: TaskRepository {

}

class ViewModel(val respoistory: TaskRepository) {

}

위와 같이 테스트시에는 ViewModel(repository = FakeRepository())처럼 repository를 바꿔치기 할 수 있습니다.
저런식으로 모바일에서도 사용하는군요... 아직 갈길이 멀다는걸 뼈저리게 느낍니다 ㅠㅠ

우선 올려주신 코드 제 파일에 적용해서 처음에 리스트 띄우는 것 까진 성공했는데, 다시 해당 프래그먼트로 이동했을 때 리사이클러뷰 화면이 텅 비는 버그와 태스크를 추가했을 때 옵저빙 하지 않는 버그가 발견되어 차차 해결보고자 합니다 ㅠㅠ 여러가지로 힌트 주셔서 너무나도 감사드립니다..
DAO의 fun getAllTask() : LiveData<List<TaskEntity>>에서 리턴한 LiveData를 ViewModel에서 사용하도록 만드셔야 할 것 같네요. 그리고 님과같이 DAO가 LiveData 를 리턴하는 경우는 Repository를 두어야 할지 없앨지 고민을 해보시는게 맞을 것 같아요. LiveData는 뷰레이어 관련된 Observer라 DB쪽에서 사용할 경우는  Repository를 둘 의미가 별로 없어지거든요. Repository를 사용하실 거면 아래처럼 바로Dao의  LiveData를 리턴하세요.

 fun getAllTask() : LiveData<List<TaskEntity>> {
        return Dao.getAllTask()
}

그리고 뷰모델에서는 여기에서 리턴된 LiveData를 사용하시면 되구요.
...