[THE SOPT] Android 2차 세미나 과제

한승현·2022년 4월 21일
4
post-thumbnail

SOPT 30기 Android 파트 세미나 과제입니다.
github: https://github.com/KINGSAMJO/iOS_Seunghyeon
해당 주차 브랜치(ex. seminar/2)에서 각 세미나별 과제 코드를 확인할 수 있습니다.
github를 통해 코드를 보시는 것을 추천드립니다.

목차

  • 필수과제
  • 성장과제
  • 도전과제

들어가기에 앞서, 본 과제는 DataBinding을 사용해 구현했음을 미리 알려드립니다.

필수과제

필수과제 1.



필수과제 1은 HomeActivity 하단에 FollowerRecyclerView와 RepositoryRecyclerView를 생성해 보여주는 것입니다. 과제의 요구사항은 다음과 같습니다.

  1. Button, TextView 활용한다.
  2. FollowerRecyclerView, RepositoryRecyclerView 만들기(RecyclerView)
  3. 각각의 RecyclerView를 담는 Fragment 2개 만들기(Fragment)
  4. 각각의 Button 눌렀을 때 알맞은 Fragment로 전환되게 하기(FragmentManager)
  5. Default로 보이는 Fragment는 FollowerRecyclerView를 담은 Fragment로 하기
  6. 설명이 너무 길어서 글씨가 길어지면 뒤에 ...으로 표시하기(ellipsize, maxLine)

제가 구현한 화면을 먼저 첨부하겠습니다.


필수과제 1은, RecyclerViewFragment을 알아야 구현할 수 있는 내용입니다. 개념을 먼저 짚고 가보겠습니다.

RecyclerView란?


모자이크 해주신 30기 Android 파트 YB 최O정님께 감사의 말씀 전합니다.

RecyclerView란, data를 list화해서 View로 보여주는 하나의 Container입니다. 사실 View를 List화해서 보여주는 Container는 이미 ListView라는 친구가 있습니다. 그렇다면 ListViewRecyclerView의 차이는 무엇일까요? Android 공식문서에서는 이렇게 말하고 있습니다.

As the name implies, RecyclerView recycles those individual elements.

RecyclerView의 이름이 의미하듯, RecyclerView는 list로 되어있는 data를 View로 보여주되, 이 View 객체를 재활용합니다. 아래 사진을 보면서 ListView와 RecyclerView의 차이를 조금 더 이해해보겠습니다.

아니 왜 RecyclerView 설명한다면서 ListView 얘기하세요?
RecyclerView가 어떤 친구인지 이해하려면 비교가 꼭 필요합니다.

ListView는 사용자가 스크롤할 때마다 매번 밀려나는 View 객체는 삭제하고 밀려오는 View 객체는 생성합니다. 그래서 View 객체를 생성하고 삭제하는 비용이 큰 편입니다. 반면 RecyclerView는 화면에 보일 정도의 View 객체만 생성합니다. 그리고 사용자가 스크롤을 해서 View 객체가 아래에서 밀려오고 위에는 밀려나게 된다면 밀려나는 View 객체를 삭제하는 대신 이를 재활용해서 사용하게 됩니다. 따라서 ListView에 비해 비용이 적은 편입니다.

말로만 설명하면 어려우니 위의 사진으로 함께 설명해보겠습니다. 우리의 기기는 화면에 8개의 item만 보여줄 수 있는 크기라고 가정해보겠습니다. 1번부터 8번까지의 Item이 화면에서 보이는 상황에서, 우리가 3번부터 10번까지의 Item이 보이도록 스크롤을 했습니다. 이 때 ListView에서는 밀려나는 1번, 2번 Item의 View 객체는 삭제됩니다. 밀려오는 9번, 10번 Item의 View 객체는 새로 생성됩니다. 만약 우리가 다시 1번부터 8번까지의 Item이 보이도록 스크롤한다면, 밀려오는 1번, 2번 Item의 View 객체는 다시 새로 생성되고, 밀려나는 9번, 10번 Item의 View 객체는 삭제됩니다. 이처럼 스크롤을 할 때마다 View 객체의 생성과 삭제가 빈번하게 일어나기 때문에 비용이 크다고 할 수 있습니다.
반면에 RecyclerView는 스크롤로 밀려나게 되는 View 객체를 삭제하지 않습니다. 대신 재사용합니다. 1번부터 8번 Item이 보이는 상태에서, 2번부터 9번까지의 Item이 보이도록 스크롤하면 밀려난 1번 Item의 View 객체는 삭제되지 않습니다. 대신에 그림처럼 재사용될 준비를 합니다. View 객체를 삭제하지 않고, View에 표시할 data로 화면을 갱신합니다. 따라서 아무리 스크롤을 하더라도 View 객체를 새로 생성하거나 삭제하는 작업을 추가로 하지 않습니다. 이런 이유와 또 다른 장점들(여기서는 설명하지 않겠습니다.)이 있어 리스트를 보여줘야 할 때 RecyclerView를 많이 사용합니다.

위의 설명이 잘 이해가 되지 않는 분들을 위한 (조금은 개념이 정확하지는 않을 수 있지만) 이해를 돕는 예시
ListView: 라면을 끓일 때마다 냄비를 만들어서 끓여먹음. 라면 다 먹으면 냄비 부숴버림
RecyclerView: 라면 끓일 냄비를 미리 몇 개 만들어놓음. 라면 다 먹으면 냄비를 설거지하고 다음에 라면 먹을 때 또 씀

추가) 이렇게 View 객체를 재활용할 수 있는 것은 바로 ViewHolder 패턴 덕분입니다. ViewHolder라는 단어에서 유추할 수 있듯, ViewHolder는 View를 Hold, 즉 View를 보관하는 객체입니다. ViewHolder는 View를 가지고 있으면서, 스크롤 등의 이유로 View를 재사용하게 될 필요가 생겨 data를 갱신해야 하는 경우 View를 업데이트합니다. 이 ViewHolder에 대해서는 추후 코드와 함께 보겠습니다. 일단은 개념에 대해서 아 ~ 그렇군 ~ 정도면 될 것 같습니다.

Fragment란?

Fragment는 2차 세미나에서 새로 배운 개념입니다. Fragment란 무엇일까요? Android 공식문서에서는 Fragment를 어떻게 정의하고 있는지 먼저 살펴보겠습니다

모든 문장이 의미있는 말이라 한 줄씩 읽어나가 봅시다.

A Fragment represents a reusable portion of your app's UI.

Fragment는 앱 UI의 재사용 가능한 부분을 나타낸다고 합니다.

A fragment defines and manages its own layout, has its own lifecycle, and can handle its own input events.

Fragment는 자기 자신의 레이아웃을 정의하고 관리할 수 있고, 자체적인 수명주기를 가지며, 자기 자신에 대한 입력 이벤트를 핸들링할 수 있다고 합니다.

Fragments cannot live on their own - they must be hosted by an activity or another fragment. The fragment's view hierarchy becomes part of, or attaches to, the host's view hierarchy.

Fragment는 혼자서는 살아갈 수 없다고 합니다(자립심이 부족하네요). Fragment는 반드시 다른 Activity나 다른 Fragment에게 호스팅되어야 하며, Fragment의 View 계층 구조는 호스트의 View 계층 구조의 일부분이 되거나 연결된다고 합니다.

역시 공식문서입니다. 요약해보자면 아래와 같습니다.

  1. 화면의 부분(전체일 수도 있습니다)을 차지하며 앱의 전반적인 UI에서 재활용이 가능하다.
  2. Fragment는 자체적인(독자적인) 레이아웃과 수명주기와 입력 이벤트 핸들링을 가진다.
  3. Fragment는 혼자서는 존재할 수 없다. 다른 Activity나 Fragment 안에 호스팅되어야 한다.

구현 설명

아래와 같은 순서로 구현을 진행하였습니다.

  1. FollowerRecyclerView, RepositoryRecyclerView를 담는 HomeFollowerFragment, HomeRepositoryFragment를 구현한다.
  2. FollowerRecyclerView, RepositoryRecyclerView에 대한 Item Layout을 구현한다.
  3. FollowerRecyclerView, RepositoryRecyclerView에 대한 Adapter를 구현한다.
  4. AdapterRecyclerView를 연결한다.
  5. FragmentContainerView를 활용해 두 Fragment를 Home 화면에 호스팅한다.
  6. Home 화면에 버튼을 추가하고, 버튼을 누르면 적합한 Fragment를 표시하도록 구현한다.

1. HomeFollowerFragment, HomeRepositoryFragment 구현

각각의 RecyclerView를 담을 Fragment 두 개를 만들겠습니다. 이름은 각각 HomeFollowerFragmentHomeRepositoryFragment로 했습니다. 이 두 Fragment의 레이아웃은 매우 단순합니다. RecyclerView만 보유하고 있습니다. 아래 코드로 함께 보겠습니다.

<!--fragment_home_follower-->
<androidx.constraintlayout.widget.ConstraintLayout
	android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.ui.home.HomeFollowerFragment">

    <androidx.recyclerview.widget.RecyclerView
    	android:id="@+id/rv_home_follower"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
        app:spanCount="2"
        tools:listitem="@layout/item_home_follower" />

</androidx.constraintlayout.widget.ConstraintLayout>
<!--fragment_home_repository-->
<androidx.constraintlayout.widget.ConstraintLayout
	android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.ui.home.HomeRepositoryFragment">

    <androidx.recyclerview.widget.RecyclerView
    	android:id="@+id/rv_home_repository"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:listitem="@layout/item_home_repository" />

</androidx.constraintlayout.widget.ConstraintLayout>

대강의 레이아웃은 이렇습니다. Fragment는 RecyclerView 하나만을 가집니다. 여기서 과제의 요구사항 3번(각각의 RecyclerView를 담는 Fragment를 2개 만들어라)을 만족했습니다. 이 중 RecyclerView와 관련이 있는 속성들을 짚어보겠습니다.

  1. app:layoutManager : RecyclerView는 데이터를 리스트로 보여주는 View라고 했습니다. 이 리스트를 어떻게 보여줄지를 결정하는 속성입니다. 크게 3가지가 있으며, 이번 포스팅에서는 LinearLayoutGridLayout만을 다룹니다.
  2. app:spanCount : GridLayout을 사용할 때 한 줄에 몇 개의 Item을 보여줄 것인지를 결정하는 속성입니다.

LinearLayout vs GridLayout
이름에서 유추할 수 있듯이, LinearLayout은 리스트를 수직(default), 수평으로 배치시켜주는 레이아웃입니다. 반면 GridLayout은 리스트를 격자형으로 배치시켜주는 레이아웃입니다. 위에 첨부한 제가 구현한 화면을 보시면 알 수 있듯이, FollowerRecyclerView는 GridLayout으로, RepositoryRecyclerView는 LinearLayout으로 설정했습니다. GridLayout을 적용할 경우에는, 한 줄에 몇 개의 아이템을 보여줄 것인지를 명시해야 합니다. 이는 spanCount 속성을 통해 양의 정수값을 할당함으로써 명시할 수 있습니다.

2. FollowerRecyclerView, RepositoryRecyclerView에 대한 Item Layout 구현

RecyclerView에는 아이템 각각을 보여줄, 아이템에 대한 레이아웃이 필요합니다. 따라서 레이아웃 파일을 각각 item_home_follower.xmlitem_home_repository라는 이름으로 만들어주었고 아래와 같이 구현했습니다. RepositoryRecyclerView에 이번 세미나 과제의 요구사항 이상의 내용들을 실험적으로 구현한 관계로 지금부터는 FollowRecyclerView에 대해서만 코드를 첨부하고 설명하도록 하겠습니다. (실험적으로 구현한 것들은 추후 포스트로 정리해보겠습니다.)

<!--item_home_follower.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">

    <data>

        <variable
            name="follower"
            type="co.kr.sopt_seminar_30th.domain.entity.follower.FollowerInformation" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="5dp">

        <ImageView
            android:id="@+id/iv_follower_profile"
            setProfileImageString="@{follower.followerImage}"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:contentDescription="@string/description_profile_image"
            app:layout_constraintBottom_toBottomOf="@id/tv_follower_description"
            app:layout_constraintDimensionRatio="1:1"
            app:layout_constraintHorizontal_bias="0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="@id/tv_follower_name" />

        <TextView
            android:id="@+id/tv_follower_name"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="10dp"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="@{follower.followerName}"
            android:textColor="@color/black"
            android:textSize="17sp"
            app:layout_constraintBottom_toTopOf="@id/tv_follower_description"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0"
            app:layout_constraintStart_toEndOf="@id/iv_follower_profile"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tv_follower_description"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:maxLines="1"
            android:text="@{follower.followerDescription}"
            android:textSize="15sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="@id/tv_follower_name"
            app:layout_constraintStart_toStartOf="@id/tv_follower_name"
            app:layout_constraintTop_toBottomOf="@id/tv_follower_name" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

주의해야 할 점은, View의 height입니다. 가장 최상단에 위치하는 View의 heightmatch_parent로 되어있으면, 의도하지 않은 높이의 View가 RecyclerView에 들어갈 가능성이 높습니다. 상황에 맞게 width와 height를 설정해야 합니다.

여기서 과제의 요구사항 6번(설명이 너무 길어 글씨가 길어지면 뒤에 ...으로 표시되게 하기)을 만족시켰습니다. TextView의 text가 1줄 이상이 되지 않도록 android:maxLines="1"을 설정하고, 글씨가 길어지면 뒤에 ...을 표시하도록 android:ellipsize="end"를 설정했습니다.

3. FollowerRecyclerView, RepositoryRecyclerView에 대한 Adapter 구현

RecyclerView는 Adapter를 필요로 합니다. Adapter란 뭘까요?

RecyclerView Adapter란?

여기까지 왔다면, 우리에게는 지금 2가지가 준비되어 있습니다. HomeFollowerFragment의 레이아웃 안에 있는 RecyclerView와, RecyclerView 안에 들어갈 View에 해당하는 item_home_follower.xml 이 2가지가 준비되어 있습니다. 하지만, 여러분의 관점 말고 코드의 관점에서 생각해볼까요?

RecyclerView, 즉 rv_home_follower 안에 item_home_follower 뷰를 그려넣어야 한다고 코드 상 명시한 적이 있나요? 혹은, item_home_follower에 어떤 데이터를 넣어서 보여줄 것인지 코드 상 명시한 적이 있나요? 그렇지 않습니다. 아직 RecyclerViewItemView는 그 어떠한 관계도 갖고 있지 않습니다. 그렇다면 어떻게 해야 할까요?

RecyclerView.Adapter란, RecyclerViewViewHolderData를 연결해주는 역할을 맡은 친구입니다. 이 클래스를 구현할 때, 우리는 다음 4가지를 꼭 구현해야 합니다.

  1. ViewHolder 클래스 구현
  2. onCreateViewHolder 메서드 구현
  3. onBindViewHolder 메서드 구현
  4. getItemCount 메서드 구현

ViewHolder 클래스 구현

ViewHolder란 앞에서 설명한 바와 같이, View를 보관하는 객체입니다. 조금 더 쉽게 설명하면, View를 담고 있는 객체 정도로 생각하시면 됩니다. 이런 객체를 만들기 위한 클래스인 ViewHolder 클래스를 RecyclerView Adapter 안에 구현해야 합니다. 말로만 들으면 어려우니 코드를 보며 설명해보겠습니다.

class HomeFollowerViewHolder(private val binding: ItemHomeFollowerBinding)
: RecyclerView.ViewHolder(binding.root) {
	fun bind(follower: FollowerInformation) {
		binding.follower = follower
    }
}

저는 HomeFollowerViewHolder라는 이름을 가진 ViewHolder 클래스를 만들었습니다. 이 클래스는 RecyclerView.ViewHolder 클래스를 상속받습니다. RecyclerView.ViewHolder 클래스는 생성자로 Binding 객체의 root를 받습니다. 따라서, 우리는 HomeFollowerViewHolder의 생성자로 Binding 객체를 받아야 합니다(Binding의 root를 받아도 상관은 없으나, Binding 객체를 통해 id를 가진 각각의 View에 접근할 수 있으니 그냥 저는 Binding 객체를 받겠습니다).

ViewHolder의 역할은 View를 보관하는 것입니다. 더 나아가, 필요 시 갱신하는 기능 또한 갖춰야 합니다. 갱신하는 기능은 HomeFollowerViewHolder 클래스 내부의 bind() 메서드에서 수행합니다. bind() 메서드는 인자로 FollowerInformation 클래스 타입의 follower 객체를 받아서, binding 객체 내의 변수 follower에 인자로 받은 follower를 할당해줍니다. ViewBinding을 사용한 경우에는 각각의 View의 속성에 할당해주면 됩니다.
(ex - binding.tvFollowerName.text = follower.followerName)


onCreateViewHolder 메서드 구현

ViewHolder 클래스를 우리는 위에서 만들었습니다(HomeFollowerViewHolder). 다음으로 할 일은 RecyclerView.Adapter 클래스를 상속받아 Adapter 클래스를 만들 때 필수적으로 구현해야 할 3개의 메서드 중 첫 번째 메서드인 onCreateViewHolder() 메서드를 구현하는 것입니다.

onCreateViewHolder() 메서드가 하는 일은, 말 그대로 ViewHolder를 만드는 일입니다. 코드와 함께 보겠습니다.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeFollowerViewHolder {
    val binding = DataBindingUtil.inflate<ItemHomeFollowerBinding>(
    	LayoutInflater.from(parent.context),
    	R.layout.item_home_follower,
    	parent,
    	false
    )
	return HomeFollowerViewHolder(binding)
}

저는 DataBinding을 사용해서 ViewBinding을 사용하신 분들과는 약간의 형식은 다르겠지만, 의미는 크게 다르지 않습니다. ItemHomeFollowerBinding 클래스를 인스턴스화시켜 binding 객체를 만들고, 이 binding 객체를 ViewHolder의 생성자로 넘겨주며 ViewHolder 객체를 만들고 그것을 return합니다.

즉, 정리하면, onCreateViewHolder() 메서드는 말 그대로 ViewHolder를 만듭니다. ViewHolder 클래스에게 생성자로 넘겨주기 위해 필요한 binding 객체를 만들고, 이 binding 객체를 ViewHolder 클래스의 생성자로 넘겨주어 인스턴스를 만들고 그 인스턴스를 return하는 게 전부입니다.


onBindViewHolder 메서드 구현

앞서, RecyclerView.Adapter 클래스를 상속받을 때 필수적으로 구현해야 하는 메서드 3개가 있다고 했습니다. onCreateViewHolder() 메서드를 통해 우리는 ViewHolder 객체를 만들어냅니다. 그렇다면 이렇게 만들어낸 ViewHolder 객체에 담긴 View에 어떻게 데이터를 표시해야 할까요? 이 과정은 onBindViewHolder() 메서드에서 담당합니다. 코드를 설명하기에 앞서, 우리가 지금 만들고 있는 이 Adapter 클래스 안에 itemList라고 부르는, 화면에 표시할 데이터들이 담긴 리스트가 있다고 가정해봅시다. 그리고 코드를 보겠습니다.

private val itemList = mutableListOf<FollowerInformation>()

override fun onBindViewHolder(holder: HomeFollowerViewHolder, position: Int) {
	holder.bind(itemList[position])
}

onBindViewHolder() 메서드는 holder라는 이름을 가진 HomeFollowerViewHolder 객체에게 시킵니다. 아까 우리가 위에서 작성한 HomeFollowerViewHolder 클래스 내부의 bind() 메서드 기억나시나요? bind() 메서드는 인자로 FollowerInformation 클래스 타입의 객체를 전달하면, binding 객체를 활용해 View에 데이터들을 적합하게 띄워주는 기능을 하는 메서드입니다. 즉, onBindViewHolder() 메서드는 ViewHolder에게 View와 Data를 결합해 적절하게 화면을 그리라고 알리는 역할을 수행합니다. 어떤 Data를 결합하는지는 position에 따라, itemList 중 몇 번째 포지션의 아이템을 bind시킬지 결정됩니다.


getItemCount() 메서드 구현

getItemCount() 메서드는 가장 이해하기 쉽습니다. Item의 개수를 반환하는 함수입니다. 코드만 첨부하겠습니다.

override fun getItemCount(): Int = itemList.size

꼭 구현해야 하는 4가지를 다 구현했습니다. 따로 봐서 이해가 안 될 수 있으니 전체 코드를 첨부하겠습니다.

itemList를 private으로 선언했기 때문에 Adapter 외부에서는 itemList에 접근할 수 없습니다. 이 itemList를 업데이트하는 코드는 DiffUtil이라는 클래스를 이용해 구현했기 때문에 도전과제 1. notifyDataSetChanged의 문제점 개선하기에서 설명하겠습니다.

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import co.kr.sopt_seminar_30th.R
import co.kr.sopt_seminar_30th.databinding.ItemHomeFollowerBinding
import co.kr.sopt_seminar_30th.domain.entity.follower.FollowerInformation
import co.kr.sopt_seminar_30th.util.MyDiffUtilCallback
import java.util.*

class HomeFollowerAdapter(private val itemClick: (FollowerInformation) -> (Unit)) :
    RecyclerView.Adapter<HomeFollowerAdapter.HomeFollowerViewHolder>() {
    private val itemList = mutableListOf<FollowerInformation>()

    class HomeFollowerViewHolder(
        private val binding: ItemHomeFollowerBinding
    ) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(follower: FollowerInformation) {
            binding.follower = follower
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeFollowerViewHolder {
        val binding = DataBindingUtil.inflate<ItemHomeFollowerBinding>(
            LayoutInflater.from(parent.context),
            R.layout.item_home_follower,
            parent,
            false
        )
        return HomeFollowerViewHolder(binding)
    }

    override fun onBindViewHolder(holder: HomeFollowerViewHolder, position: Int) {
        holder.bind(itemList[position])
    }

    override fun getItemCount(): Int = itemList.size

    fun updateItemList(newItemList: List<FollowerInformation>?) {
        newItemList?.let {
            val diffCallback = MyDiffUtilCallback(itemList, newItemList)
            val diffResult = DiffUtil.calculateDiff(diffCallback)

            itemList.run {
                clear()
                addAll(newItemList)
                diffResult.dispatchUpdatesTo(this@HomeFollowerAdapter)
            }
        }
    }
}

ViewHolder 클래스를 구현한 거야 그렇다고 쳐도, onCreateViewHolder(), onBindViewHolder(), getItemCount() 메서드는 우리가 구현하면서 따로 호출한 적이 없는 메서드인데 언제 쓰는지 궁금할 수 있습니다. 우리가 override한 이 3개의 메서드는 내부적으로 호출됩니다. 따라서 개발자가 명시적으로 호출할 일은 드뭅니다. (제가 아직 초보 개발자라 명시적으로 호출한 적이 없는 것일수도 있습니다. :-P )

4. 만든 Adapter와 RecyclerView를 연결

이제 Adapter까지 만들었으니, RecyclerView에게 너 이대로 행동해야 해라고 알려줘야 합니다. 이 코드는 RecyclerView를 포함한 Fragment에서 구현합니다.

class HomeFollowerFragment : Fragment() {
	private val _binding: FragmentHomeFollowerBinding? = null
    private val binding get() = _binding ?: error("null ~ 만나면 ~")
    private lateinit var homeFollowerAdapter: HomeFollowerAdapter
    
    override fun onCreateView(
    	inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
    	_binding = DataBindingUtil.inflate(
        	layoutInflater,
            R.layout.fragment_home_follower,
            container,
            false
		)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    	super.onViewCreated(view, savedInstanceState)
        initRecyclerView()
    }
    
    override fun onDestroyView() {
	    _binding = null
        super.onDestroyView()
	}
    
    private fun initRecyclerView() {
    	homeFollowerAdapter = HomeFollowerAdapter()
        binding.rvHomeFollower.adapter = homeFollowerAdapter
	}
}

Fragment의 수명주기를 잠깐 눈여겨보겠습니다. 세미나 때는 onCreateView() 내에서 이것 저것 했던 것 같은데, 저는 onViewCreated() 내에서 RecyclerView를 초기화해줍니다. 왜 이렇게 할까요? 이는 수명 주기와 관련이 있습니다. 두 수명주기 콜백 메서드의 이름을 눈여겨볼까요? 하나는 onCreateView(), 즉 View를 만드는 단계이고 다른 하나는 onViewCreated(), 즉 View가 만들어진 이후 입니다. 따라서 onCreateView()에서는 View를 만드는 일에만 집중하게 하고, onViewCreated()에서는 만들어진 View에 대해 이것저것 초기화를 해주는 것입니다.

또 하나 더 볼까요? FragmentActivity와 다른 수명 주기를 가집니다. 즉, Activity는 onCreate() 단계에서 Activity 자기 자신을 인스턴스화하고 View도 생성합니다. 하지만 Fragment는 onCreate 단계에서는 자기 자신만 인스턴스화할 뿐, View를 생성하지는 않습니다. 반대로, Fragment의 View는 소멸되었어도 자기 자신의 인스턴스는 소멸되지 않았을 수도 있습니다. 이럴 경우를 대비해, onDestroyView() 메서드 내부에 _binding 객체를 null로 만들어주는 작업을 넣습니다. JVM의 GC(가비지 컬렉터)는 사용되지 않는 메모리를 수거해갑니다. 따라서 null로 만들어주어 JVM의 GC가 할당된 메모리를 수거해갈 수 있도록 합니다.

이제 각설하고(라고 말하지만 수명 주기는 그 자체만으로 포스팅 하나를 해야 할 정도로 중요한 내용이라고 생각합니다), RecyclerView의 초기화 과정에 대해 다시 보겠습니다. initRecyclerView() 메서드 내부를 보면 homeFollowerAdapter라는 lateinit 변수에 HomeFollowerAdapter 객체를 생성해 할당해줬습니다. 또, binding.rvHomeFollower.adapterhomeFollowerAdapter 객체를 할당해줬습니다. 이렇게 하면, View에 있는 RecyclerView의 Adapter로 homeFollowerAdapter가 연결이 되어 우리가 HomeFollowerAdapter 클래스를 짤 때 의도한 대로 binding.rvHomeFollower가 동작하게 됩니다.

5. FragmentContainerView를 활용해 Fragment를 호스팅

먼저, Activity 혹은 Fragment에 FragmentContainerView를 추가해야 합니다. 저는 이 FragmentContainerView의 id를 fcv_home_bottom이라고 만들었습니다. 먼저, Activity에서는 어떻게 Fragment를 호스팅하는지 아래 코드를 보겠습니다.

private fun initFragmentContainerView() {
	val transaction = supportFragmentManager.beginTransaction()
    transaction.replace(R.id.fcv_home_bottom, HomeFollowerFragment())
    	.commit()
}

세미나에서 배운 내용입니다. 파트장이 세미나에서 충분히 잘 설명했다고 생각해서 깊게 다루지는 않겠지만, 하나 정리하고 가겠습니다.

add: 해당 id를 가진 View에 해당 Fragment를 추가한다.
replace: 해당 id를 가진 View에 해당 Fragment로 교체한다.
remove: 해당 id를 가진 View에 해당 Fragment를 삭제한다.

하지만, 1차 세미나에서 저는 Home 화면을 Fragment로 구현했습니다. 따라서, 제 과제물의 구조는 이렇습니다.

HomeActivity 안에 HomeFragment에서 사용자의 프로필과 하단 RecyclerView를 보여줄 Fragment가 표시된다.

즉, Activity가 호스트가 되는 것이 아닌, HomeFragmentHomeFollowerFragment를 호스팅해야 하는 상황이 된 것입니다. 이럴 때는 어떻게 구현할까요? 바로 supportFragmentManager가 아닌 parentFragmentManagerchildFragmentManager를 활용하는 방법입니다. 그렇다면 parentFragmentManagerchildFragmentManager의 차이는 무엇일까요? 바로 어떤 FragmentManage할 것인지에 따라 나뉩니다.

parentFragmentManager는, 현재 Fragment의 호스트의 FragmentManager에 접근합니다. 반면 childFragmentManager는, 현재 Fragment가 호스트인 FragmentManager에 접근합니다. 즉, 자신 Fragment의 하위 요소를 관리할 때는 childFragmentManager, 자신의 Host의 하위 요소를 관리할 때는 parentFragmentManager를 사용하면 됩니다.

따라서 저는 HomeFragment에서 이렇게 구현했습니다.

class HomeFragment : Fragment() {
	...
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        super.onCreateView(inflater, container, savedInstanceState)
        binding.viewmodel = homeViewModel
        initFragmentContainerView()
        return binding.root
    }
    
    private fun initFragmentContainerView() {
    	childFragmentManager.apply {
            if (fragments.isEmpty()) {
                commit {
                    add<HomeFollowerFragment>(R.id.fcv_home_bottom, FOLLOWER_FRAGMENT)
                }
            }
        }
	}
}

이처럼 기본적으로 HomeFollowerFragment를 보여줄 때는, HomeFragment에서 childFragmentManager를 활용해 현재 관리 중인 Fragment가 없다면 HomeFollowerFragment를 add하도록 구현했습니다. 요구사항 5번인 Default로 보이는 Fragment는 FollowerRecyclerView를 담은 Fragment로 하기도 구현해냈습니다. 그러면 이제 Button을 눌렀을 때 해당하는 Fragment로 전환되게 구현해야 합니다. 거의 다 왔습니다.

6. Home 화면에 버튼을 추가하고, 버튼을 누르면 적합한 Fragment를 표시하도록 구현

사실 버튼을 누르면 적합한 Fragment가 표시되도록 하는 것은 2가지만 하면 됩니다.

  1. Button에 ClickListener를 설정한다.
  2. ClickListener 안에, 클릭 시 fragmentManager를 활용해 replace 시킨다.

이 방법은 세미나에서도 이미 설명된 내용이라서 별도로 다루지는 않겠습니다(세미나 자료가 더 좋으니까요^^...). 하지만 저는 replace를 활용해 Fragment를 전환하지 않고 hide/show를 통해 Fragment를 전환시켰습니다. 코드를 보며 설명해보겠습니다.

private fun changeToFollowerFragment() {
	binding.btnHomeFollower.setOnClickListener {
    	val followerFragment = childFragmentManager.findFragmentByTag(FOLLOWER_FRAGMENT)
	    val repositoryFragment = childFragmentManager.findFragmentByTag(REPOSITORY_FRAGMENT)

        childFragmentManager.commit {
			setReorderingAllowed(true)
        	repositoryFragment?.let { hide(it) }
            followerFragment?.let { show(it) }
            	?: add<HomeFollowerFragment>(R.id.fcv_home_bottom, FOLLOWER_FRAGMENT)
		}
	}
}

private fun changeToRepositoryFragment() {
	binding.btnHomeRepository.setOnClickListener {
    	val followerFragment = childFragmentManager.findFragmentByTag(FOLLOWER_FRAGMENT)
        val repositoryFragment = childFragmentManager.findFragmentByTag(REPOSITORY_FRAGMENT)

        childFragmentManager.commit {
        	setReorderingAllowed(true)
            followerFragment?.let { hide(it) }
            repositoryFragment?.let { show(it) }
            	?: add<HomeRepositoryFragment>(R.id.fcv_home_bottom, REPOSITORY_FRAGMENT)
		}
	}
}

companion object {
	val FOLLOWER_FRAGMENT: String = HomeFollowerFragment::class.java.simpleName
    val REPOSITORY_FRAGMENT: String = HomeRepositoryFragment::class.java.simpleName
}

전체적인 흐름은 fragmentManager를 활용해 replace하는 것과 별반 다르지 않으나, hideshow 메서드를 사용했습니다. TAG는 클래스명으로 사용했고, findFragmentByTag() 메서드를 사용해 만일 보여줘야 할 Fragment가 fragmentManager의 관리 하에 있지 않다면, 즉 생성된 적이 없다면 생성되도록 코드를 작성했습니다. let을 사용해, 각각의 findFragmentByTag()의 return값이 null이 아니라면 보여주지 않아야 할 Fragment를 hide시키고, 보여줘야 할 Fragment를 show시켰습니다. 하지만 만일, 보여줘야 할 Fragment가 생성된 적이 없다면 ?: 연산자를 활용해 add하도록 구현했습니다. 이렇게 해서 요구사항 1번이었던 Button 활용과 요구사항 4번이었던 각각의 Button을 눌렀을 때 알맞은 Fragment로 전환시키기 또한 충족시켰습니다.

replace()가 아닌 hide()/show()를 사용했나요?
replace()hide()/show()의 차이를 알아보기 위해 사용했습니다.
replace()를 통해 Fragment를 전환할 경우, 다른 Fragment로 전환했다가 원래의 Fragment로 다시 전환할 때 원래의 Fragment는 onCreateView()부터 시작하게 됩니다.
반면 hide()/show()를 통해 Fragment를 전환할 경우, 다른 Fragment로 전환될 때 원래의 Fragment는 계속 onResume()에 머물러 있습니다. 다시 원래의 Fragment로 전환될 때에도 마찬가지로 onResume()에 있습니다. 즉, Fragment의 수명 주기 자체에 영향을 미치는 것이 아닌 그저 숨긴다/보이게 한다라는 차이가 있었습니다.
상황에 맞게 replace()hide()/show()를 사용하면 될 것 같습니다.

필수과제 2.

필수과제 2는 두 개의 RecyclerView 중 하나는 GridLayout으로 만드는 것입니다. 하지만 GridLayout으로 구현하는 방법은 필수과제 1의 설명에 포함되어 있으므로 최대한 간략하게 코드만 첨부하겠습니다.

<!--xml 코드 상에서 Layout 설정하는 방법-->
<!--GridLayout-->
<androidx.recyclerview.widget.RecyclerView
	android:id="@+id/rv_home_follower"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
    app:spanCount="2"
    tools:listitem="@layout/item_home_follower" />

<!--LinearLayout-->
<androidx.recyclerview.widget.RecyclerView
	android:id="@+id/rv_home_repository"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
    tools:listitem="@layout/item_home_repository" />
// Kotlin 코드 상에서 Layout 설정하는 방법
// 1. GridLayout으로 전환 시 - 인자로 context와 spanCount 전달
binding.rvHomeRepository.layoutManager = GridLayoutManager(requireContext(), 2)
// 2. LinearLayout으로 전환 시 - 인자로 context 전달
binding.rvHomeFollower.layoutManager = LinearLayoutManager(requireContext())

성장과제

성장과제 1.

성장과제 1은 RecyclerView의 아이템을 클릭했을 때 상세 설명을 보여주는 DetailActivity로 이동하게 하는 것입니다. 요구사항은 다음과 같습니다.

  1. RecyclerView 아이템 클릭 시 상세 설명을 보여주는 DetailActivity로 이동한다.
  2. DetailActivity에서는 해당 아이템의 이름과 설명 값을 보여줘야 한다.

얼핏 생각해보면 그렇게 어려울 것 같지 않습니다. RecyclerView에 setOnClickListener 달아서 처리해주면 되지 않나? 이런 생각은 실제로 제가 처음 Android 개발을 시작할 때 했던 생각이기도 합니다. 하지만 몇 분, 혹은 몇 초 지나지 않아 생각에 빠지게 됩니다.

RecyclerView 자체에 클릭 리스너를 달아버리면, 내가 어떤 아이템(몇 번째 position)을 클릭했는지는 어떻게 알지?

그래서 머리를 굴리던 저는, 이런 방법을 생각했었습니다. ViewHolder에는 View가 있으니까, 거기서 setOnClickListener를 달아주면 되지 않을까? 실제로 ViewHolder 안에 클릭 리스너를 구현하긴 합니다. 하지만 Android 갓난아기였던(아직도 그렇긴 하지만) 저는 새로운 난관에 빠졌습니다.

startActivity()를 호출하려면 Intent 객체가 필요한데, Intent 객체를 만들려면 context가 필요하네? 아 근데 이거는 왜 thisrequireContext로 안 불려와질까...? 하......

그래서 처음에 마구잡이로 구현했을 땐 이렇게 구현했습니다.

Adapter 클래스의 생성자로 Activity나 Fragment의 context를 전달해보자. 그리고 그 context 쓰면 되잖아? (오 나 천재인 듯? ㅋㅋ)

context는 잘만 쓰면 참 좋은 친구입니다. 하지만 잘못 쓰면, 메모리 누수의 원흉이 되기도 합니다.

그래서 저는 Kotlin 람다 표현식를 통해 클릭 리스너를 구현합니다. 이를 위해 람다란 무엇인지, 살짝만 맛보고 가겠습니다. (맛보기에 너무 부담스러운 분들은 아래 부분은 넘기셔도 좋습니다. 근데 안 넘기시면 좋겠습니다.)

람다 표현식이란?

Kotlin은 함수형 프로그래밍을 지원하는 언어입니다. 그래서 람다 표현식이 정말 많이 쓰입니다. 저 또한 무지성으로 람다를 사용하긴 했지만, 무엇인지 잘 모르고 사용하는 경우가 다반사였습니다. 이번 기회에 람다란 무엇인지 알아보겠습니다.

먼저 함수형 프로그래밍이란 무엇인지부터 알아보겠습니다. 함수형 프로그래밍의 정의란 무엇일까요? Wikipedia는 함수형 프로그래밍을 이렇게 정의하고 있습니다.

함수형 프로그래밍은 자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다.

말이 너무 어렵습니다. 조금 더 쉽게 이해해볼까요? 여러분들이 수학 시간에 배웠던 함수란 무엇인가요? f(x)를 통해 표현됐던 그 함수 말입니다.

x라는 값을 넣으면 f(x)라는 값이 나온다. 이렇게 만들어주는 식이 f이다.

코드를 작성할 때 가변 변수들을 사용하면서 생길 수 있는 문제점을 배제하려고 하는 프로그래밍 방식입니다. 즉, 함수형 프로그래밍이란 x라는 input이 주어졌을 때, f(x)라는 output이 나온다는 것을 확실하게 보장하는 수학적 함수 방식을 가진 프로그래밍입니다.

Q. 아니 ... 보장이 안 될 수가 있나요?
A. 안 될 수 있습니다. 가령, 함수 외부의 요인이 결과에 영향을 미치는 경우가 있습니다. 아래 함수를 한 번 볼까요?

var c = 1
fun add(a: Int, b: Int): Int {
    return a+b+c
}

fun main() {
	println(add(1,1))
    c=downTo()
    println(add(1,1))
}

분명 똑같은 add 함수에 똑같은 값을 인자로 전달했습니다. 하지만 함수 외부의 상태(변수 c)에 의해 결과가 달라졌습니다. 이 경우 add 함수를 순수하지 않은 함수라고 부릅니다.

그리고 다음 설명을 위해, 익명함수에 대해 간단히 짚고 가겠습니다. 익명함수란 말 그대로 이름 없이 정의되는 함수를 말합니다. 보통 한 번 사용하고 재사용하지 않을 함수를 익명함수로 만듭니다. 굳이 함수로 따로 빼내어 만들지 않아도 되고, 코드 중간에 익명함수를 만들 수도 있습니다. 아주 간단한 예시를 한 번 보겠습니다.

fun main() {
	val printHello = fun() {
        println("Hello")
    }
    printHello()
}

printHello라는 변수에 Hello를 출력하는 익명 함수를 할당했습니다. 이 printHello를 호출하면, console에는 Hello가 찍히게 됩니다.

그렇다면 이번에는 고차함수에 대해 알아보겠습니다. 고차함수는 함수를 인수로 받거나 혹은 결과로 함수를 반환하는 함수를 말합니다. Android에서 가장 흔히 보는 고차함수는 setOnClickListener 같은 콜백 함수입니다. 아까 위에서 Fragment를 전환시키는 부분의 코드를 잠깐 다시 가져와보겠습니다.

private fun changeToFollowerFragment() {
	binding.btnHomeFollower.setOnClickListener {
    	val followerFragment = childFragmentManager.findFragmentByTag(FOLLOWER_FRAGMENT)
	    val repositoryFragment = childFragmentManager.findFragmentByTag(REPOSITORY_FRAGMENT)

        childFragmentManager.commit {
			setReorderingAllowed(true)
        	repositoryFragment?.let { hide(it) }
            followerFragment?.let { show(it) }
            	?: add<HomeFollowerFragment>(R.id.fcv_home_bottom, FOLLOWER_FRAGMENT)
		}
	}
}

사실 btnHomeFollower에 setOnClickListener로 콜백을 등록하는 코드입니다. setOnClickListener에 전달된 인자는 익명함수입니다. 여기에는 람다가 사용되었습니다.

그렇다면 이제 대망의 람다란 무엇일까요? 람다 대수는 함수를 단순하게 표현할 수 있도록 도와주는 개념입니다. 중괄호로 묶어서 사용할 수 있습니다. 또한, 고차함수의 인자로 사용할 수 있으며 함수가 이름을 가질 필요가 없는 익명함수이기도 합니다. 정말 무책임하지만, 개념 자체는 이 정도면 과제를 수행하기에 충분합니다. 하지만 말로만 이렇게 적어놓으면, 역시나 무슨 말인가 싶습니다. 간단한 예시 코드를 본 후, 과제에 어떻게 적용하는지까지 보겠습니다.

먼저, 람다를 사용해 보겠습니다. sum이라는 val에 람다를 할당해보겠습니다.

val sum : (Int, Int) -> Int = { x, y -> x + y }

위 람다는 이렇게 바꿀 수도 있습니다.

val sum = { x: Int, y: Int -> x + y }

그리고 한 번 실행시켜 보겠습니다.

fun main() {
    val sum = { x: Int, y: Int -> x + y }
    val result = sum(1,1)
    println(result)
}

sum이라는 변수에 람다가 할당되어 함수로서 기능을 하고 있습니다. 그렇다면 이번엔 람다를 활용해 고차함수를 만들어 볼까요?

fun add(x: Int, y: Int, f: (Int, Int) -> Int): Int {
    return f(x, y)
}

fun main() {
    val sum = { x: Int, y: Int -> x + y }
	val result = add(1, 1, sum)
    println(result)
}

add라는 함수는 인자 3개를 받습니다. Int형 인자 2개와, Int 2개를 가지고 Int 하나를 return하는 람다를 받습니다. 그래도 add(1, 1, sum) 이라고 하니까 그나마 좀 익숙한 함수의 형태 같습니다. 그렇다면 위 형태를 조금 바꿔보겠습니다.

fun add(x: Int, y: Int, f: (Int, Int) -> Int): Int {
    return f(x, y)
}

fun main() {
	val result = add(1, 1, { x: Int, y: Int -> x + y })
    println(result)
}

뭔가 어지럽습니다. sum을 넣어줄 때가 좋았던 것 같기도 합니다. 가독성도 더 나빠진 것 같습니다(기분탓인가). 만일, 고차함수의 인자의 제일 마지막이 람다라면 인자를 소괄호 밖으로 뺄 수 있습니다. 아래 코드처럼 말입니다.

fun add(x: Int, y: Int, f: (Int, Int) -> Int): Int {
    return f(x, y)
}

fun main() {
	val result = add(1, 1) { x: Int, y: Int -> 
    	x + y 
    }
    println(result)
}

혹은, 고차함수에 람다의 인자 타입이 정해져 있다면 타입을 생략할 수도 있습니다.

fun add(x: Int, y: Int, f: (Int, Int) -> Int): Int {
    return f(x, y)
}

fun main() {
	val result = add(1, 1) { x, y -> 
    	x + y 
    }
    println(result)
}

뭔가 점점 Android 개발하면서 본 형태로 변모하고 있습니다. 람다 내에서 의미있는 반환값이 없다면 어떻게 될까요? 마치 C++이나 Java의 void처럼 말입니다. 그럴 땐 이렇게 사용합니다.

fun printName(f: () -> Unit) {
	print("Name: ")
    f()
}

fun main() {
	printName {
    	println("한승현")
	}
}

위 코드를 실행하면 어떻게 될까요? printName 내부의 print()에 의해 Name: 이 출력되고, 그 후 인자로 전달된 함수가 실행되면서 한승현이 출력됩니다. 뭔가 setOnClickListener와 같은 모습이 되지 않았나요? 이처럼, 여러분들 또한 알게 모르게 람다를 엄청나게 자주 사용하고 있습니다.

다시 과제로 돌아와서, RecyclerView의 Item 클릭 이벤트는 람다 표현식을 통해 구현한다고 했습니다. 람다가 무엇인지 이제 알았으니, 코드를 보며 살펴보겠습니다.

Adapter

class HomeFollowerAdapter(private val itemClick: (FollowerInformation) -> (Unit)) :
    RecyclerView.Adapter<HomeFollowerAdapter.HomeFollowerViewHolder>() {

	....
    
    class HomeFollowerViewHolder(
        private val binding: ItemHomeFollowerBinding,
        private val itemClick: (FollowerInformation) -> (Unit)
    ) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(follower: FollowerInformation) {
            binding.follower = follower

            binding.root.setOnClickListener {
                itemClick(follower)
            }
        }
    }
    
    ....
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeFollowerViewHolder {
        val binding = DataBindingUtil.inflate<ItemHomeFollowerBinding>(
            LayoutInflater.from(parent.context),
            R.layout.item_home_follower,
            parent,
            false
        )
        return HomeFollowerViewHolder(binding, itemClick)
    }
    
    ....
}

위의 Adapter 코드에서 집중적으로 볼 부분은 크게 세 부분으로 나눌 수 있습니다.

  1. Adapter의 생성자로 넣어주는 람다 itemClick
  2. ViewHolder의 생성자로 넣어주는 람다 itemClick
  3. onCreateViewHolder()에서 ViewHolder를 생성할 때 람다 itemClick을 전달

Adapter 생성자로 넣어주는 itemClick을 먼저 보겠습니다.

private val itemClick: (FollowerInformation) -> (Unit)

이게 무슨 의미일까요? 하나하나 뜯어보겠습니다.

먼저, private val 이라는 것은 이 itemClick 이라는 변수가 HomeFollowerAdapter 클래스 내에서만 사용이 가능하다는 뜻입니다. 이 itemClick 변수는 람다 표현식이며, input으로 FollowerInformation이 주어지면 output은 return이 없는 Unit이 된다는 의미가 됩니다. 이 output에 대해서는 조금 이따 살펴보겠습니다. 지금까지는 일단 이 itemClick이라는 친구는 FollowerInformation을 입력으로 받아 무언가를 내부적으로 수행한 뒤 반환하는 값은 없는 익명함수라고 생각하면 됩니다. 곧 이 익명함수에 대해서도 볼 것입니다.

다음으로, ViewHolder의 생성자로 넣어주는 itemClick 또한 보겠습니다.

비록 HomeFollowerViewHolder 클래스가 HomeFollowerAdapter 내부에 위치하긴 하지만, 이 ViewHolder 클래스는 Adapter 클래스의 private에는 접근할 수 없습니다. 따라서 생성자로 itemClick을 명시적으로 인자로 받는 것입니다.

마지막으로, onCreateViewHolder()에서 ViewHolder 생성 시 람다 itemClick을 전달하는 것입니다. 이 또한 같은 맥락으로, ViewHolder 클래스는 Adapter 클래스의 private에 접근할 수 없기 때문에 명시적으로 인자로 전달해주는 것입니다.

이렇게 생성자로 전달된 람다 itemClick은 어떨 때 실행될까요? 바로 ItemView가 클릭됐을 때입니다. 따라서 binding.root에 setOnClickListener에 itemClick을 전달합니다. itemClick, 즉 람다에 input으로 전달할 인자는 바로 bind() 메서드에서 사용한 follower라는 FollowerInformation 타입의 객체입니다. 이 follower를 람다로 전달해주면, 람다에서는 이 input을 이용해 코드를 실행하게 됩니다.

Fragment

homeFollowerAdapter = HomeFollowerAdapter {
	val intent = Intent(requireContext(), DetailActivity::class.java)
	intent.apply {
		putExtra("name", it.followerName)
	    putExtra("description", it.followerDescription)
	    putExtra("image", it.followerImage)
	    }
	startActivity(intent)
}

위의 Fragment 코드에서 집중적으로 볼 부분은 바로 Adapter 생성 시 생성자로 람다 itemClick을 전달하는 과정입니다. 아까 람다를 다루며 다뤘던 내용이 있습니다. 고차함수의 인자의 제일 마지막이 람다라면 인자를 소괄호 밖으로 뺄 수 있습니다. HomeFollowerAdapter의 생성자로 전달할 인자는 1개, itemClick 뿐입니다. 따라서 인자를 소괄호 밖으로 빼고, 익명함수를 중괄호 안에 구현했습니다. 이 익명함수는 우리의 목적과 의도대로 구현되어 있습니다. DetailActivity에 대한 Intent 객체를 생성하고, 이름과 설명, 프로필 사진을 Extra에 담아 startActivity()를 실행합니다. 여기서 it이 무엇인가 싶습니다.

it이란, 람다로 전달될 익명함수의 input, 즉 인자가 1개일 때 사용할 수 있는 이름입니다. itemClick은 input으로 FollowerInformation을 넣는다고 했습니다. output은 별다른 return이 없다고 했습니다. 즉, 여기서 it은 input으로 전달된 FollowerInformation에 해당됩니다.

그러면 마지막으로 정리해보겠습니다.

  1. RecyclerView의 Adapter의 생성자로 itemClick이라는 변수를 람다 표현식을 이용해 전달한다.
  2. RecyclerView Adapter 내부에서 ViewHolder를 생성할 때 itemClick이라는 람다 표현식을 ViewHolder의 생성자로 전달한다.
  3. ViewHolder에서 bind() 메서드 수행 시, ItemView를 클릭하면 itemClick이라는 람다 표현식을 실행하도록 코드를 구현한다.
  4. RecyclerView Adapter를 생성할 때, 람다 표현식으로 ItemView 클릭 시 수행될 코드를 작성해 Adapter의 생성자로 전달한다.

최대한 쉽게 풀어쓴다고 써봤지만, 람다는 쉬운 듯 쉽지 않은 듯 알쏭달쏭한 개념입니다. 읽는 여러분들이 잘 이해하셨으면 좋겠습니다. :-)

성장과제 2.

성장과제 2는 ItemDecoration을 활용해 RecyclerView의 리스트 간 간격을 설정하고, 구분선을 그려주는 것입니다. 이 부분은 코드 위주보다는 왜 ItemDecoration을 사용하는지에 조금 더 집중해서 보도록 하겠습니다.

ItemDecoration이란?

RecyclerView의 ItemView를 꾸며주고 싶으면 어떻게 해야할까요? 혹은 RecyclerView의 ItemView 간 간격을 지정하고 싶다면 어떻게 해야할까요? 가장 쉬운 방식은, RecyclerView의 ViewHolder가 보관하는 그 View의 레이아웃에 margin을 지정하고 구분선을 그려주는 방법일 것입니다. 하지만 이 방법에는 단점이 있습니다.

  1. xml에서 그리는 구분선과 ItemDecoration이 그리는 구분선의 성능 차이가 존재한다. xml에서 View를 그리는 작업이 더 무겁다.
  2. xml에서 구분선을 그리게 되면, ItemView를 Drag and Drop하거나 Swipe할 때 구분선이 같이 움직인다.

1번은 이해하기에 단순합니다. 성능의 차이가 있고, ItemDecoration을 사용하는 것이 성능 측면에서 이득이라는데, 반박할 수 있나요? 저는 없습니다. 반면에 2번같은 경우에는 실제로 눈에 보이는 부분이기 때문에, 실제로 한 번 보면 이해가 잘 될 것 같습니다. 그래서 가져왔습니다. 아래 이미지는 제가 구현한 RecyclerView이고, xml에서 구분선을 그린 후 ItemView를 왼쪽으로 swipe한 모습입니다.

View의 레이아웃에 구분선을 그렸습니다. Swipe를 했더니 Item만 이동하는 것이 아니라 구분선까지 이동을 해버립니다. 2번이 무슨 말인지 이제 이해가 되시나요?

Android 공식문서는 ItemDecoration을 어떻게 설명하고 있는지 간단하게만 보고 코드를 보겠습니다.

ItemDecoration을 사용하면 앱에서 Adapter의 ItemView에 구분선을 그릴 수 있고 간격을 줄 수 있다고 합니다.

그렇다면 ItemDecoration을 활용해서 어떻게 구현해야 할까요? 제 코드를 가볍게 훑어보겠습니다.

// MyItemDecoration.kt
package co.kr.sopt_seminar_30th.util

import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView

class MyItemDecoration(
    private val myOffset: Int,
    private val myRound: Int,
    private val myColor: Int
) :
    RecyclerView.ItemDecoration() {
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        super.getItemOffsets(outRect, view, parent, state)
        val offset = myOffset.dpToPx()
        outRect.set(offset, offset, offset, offset)
    }

    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDraw(c, parent, state)
        val strokeWidth = 5
        val paint = Paint().apply {
            this.color = myColor
            this.style = Paint.Style.STROKE
            this.strokeWidth = strokeWidth.dpToPx().toFloat()
        }

        parent.children.forEach { child ->
            c.drawRoundRect(
                (child.left).toFloat(),
                (child.top).toFloat(),
                (child.right).toFloat(),
                (child.bottom).toFloat(),
                myRound.dpToPx().toFloat(),
                myRound.dpToPx().toFloat(),
                paint
            )
        }
    }
}

2개의 메서드를 오버라이드해서 구현했습니다.

먼저 getItemOffsets()는, 아이템 간의 간격을 지정해주는 메서드입니다. 생성자로 받은 myOffset 값을 dp 단위로 받아서, px 단위로 변환한 후 해당 myOffset 값만큼 여백을 지정해주는 메서드입니다.

다음으로, onDraw 메서드입니다. 이 메서드는 구분선을 그려주는 메서드입니다. 저의 경우에는 Canvas 클래스 안에 있는 drawRoundRect 메서드를 사용했는데, 구분선을 그리는 부분은 어떤 코드가 명확히 정해져있는 것보다는 개발자 자신이 어떤 구분선을 그릴 것인지에 따라 다르기 때문에 자세하게 하나하나 설명하기 애매한 부분이 있습니다. 저의 경우는 RecyclerView의 자식 View들 각각에 myRound만큼의 round값을 갖고 myColor 색상의 5dp짜리 구분선을 그리도록 했습니다.

구분선을 어떤 식으로 그릴 것인지는 개발자 본인의 자유이나, onDraw 메서드와 함께 짚어봐야 할 메서드가 하나 있습니다. 바로 onDrawOver 메서드입니다. 둘의 차이점은 구분선을 먼저 그리느냐 나중에 그리느냐의 차이입니다. onDraw는 구분선을 먼저 그린 후 그 위에 아이템을 그립니다. 따라서 아이템이 구분선을 덮게 될 수도 있습니다. 반면 onDrawOver의 경우에는 아이템을 먼저 그린 후 그 위에 구분선을 그립니다. 따라서 구분선이 아이템을 덮게 될 수 있습니다. 상황에 맞는 메서드를 오버라이드해서 사용하시면 될 것 같습니다.

이렇게 만든 MyItemDecoration을 RecyclerView에 어떻게 적용할까요? RecyclerView를 초기화하는 코드(Fragment 코드)에 아래 코드를 추가하면 됩니다.

binding.rvHomeFollower.addItemDecoration(
	MyItemDecoration(
    	5,
        10,
		ContextCompat.getColor(requireContext(), R.color.purple_100)
    )
)

성장과제 3.

성장과제 3은 RecyclerView의 Item을 이동시키거나 삭제할 수 있도록 구현하는 것입니다. 이를 위해 저는 ItemTouchHelper라는 클래스의 SimpleCallback이라는 클래스를 상속받아 구현했습니다. ItemTouchHelper는 무엇일까요? 공식문서의 설명을 한 번 보겠습니다.

Swipe해서 사라지게 하는 행위나 Drag and Drop을 지원하기 위해 추가하는 Utility 클래스라고 합니다. RecyclerView와 Callback 클래스와 함께 동작한다고 합니다. 제 코드와 함께 보겠습니다. 저는 가장 기본적인 Drag and Drop과 Swipe를 구현했습니다.

// MyItemTouchHelperForFollower.kt
package co.kr.sopt_seminar_30th.util

import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import co.kr.sopt_seminar_30th.domain.entity.follower.FollowerInformation
import co.kr.sopt_seminar_30th.presentation.ui.adapter.HomeFollowerAdapter

class MyItemTouchHelperForFollower(
    private val recyclerViewAdapter: HomeFollowerAdapter,
    private val updateData: () -> (Unit),
    private val removeData: (FollowerInformation) -> (Unit)
) :
    ItemTouchHelper.SimpleCallback(
        ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT,
        ItemTouchHelper.LEFT
    ) {
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        recyclerViewAdapter.moveItem(viewHolder.adapterPosition, target.adapterPosition)
        return true
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        val removedFollower: FollowerInformation =
            recyclerViewAdapter.getItemList()[viewHolder.adapterPosition]
        recyclerViewAdapter.removeItem(viewHolder.adapterPosition)
        removeData(removedFollower)
    }

    override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
        super.clearView(recyclerView, viewHolder)
        updateData()
    }
}

하나하나 뜯어보겠습니다.

MyItemTouchHelperForFollower 클래스는 ItemTouchHelper.SimpleCallback을 상속받은 클래스입니다. SimpleCallback 클래스의 생성자로는 2가지 인자를 넣어줘야 합니다. 첫 번째 인자는 어느 방향으로 Drag 가능하게 할 것인지에 대한 dragDirs입니다. 두 번째 인자는 어느 방향으로 Swipe 가능하게 할 것인지에 대한 swipeDirs입니다. 저의 FollowerRecyclerView는 GridLayout이기 때문에 상하좌우 모두 드래그가 가능해야 합니다. 따라서 dragDirs는 네 방향 모두 허용시켰고, 삭제의 경우 익숙한 Action인 왼쪽으로 스와이프만 가능하게 설정했습니다.

MyItemTouchHelperForFollower 클래스의 생성자로는 HomeFollowerAdapter 타입의 recyclerViewAdapter를 인자로 받습니다. 그 밑에 있는 updateDataremoveData는 제가 따로 적용해본, Room에 아이템 이동 및 삭제에 대한 내용을 반영시키기 위해 사용하는 람다입니다. 이 포스트에서는 다루지 않을 것이기 때문에 recyclerView만 인자로 받는다고 생각하셔도 무방합니다.

2개의 람다를 제외하고 설명하기엔 onMove() 메서드와 onSwiped() 메서드로 충분할 것 같습니다. onMove() 메서드부터 Android 공식문서의 설명을 보겠습니다.

onMove() 메서드는 ItemHelper가 드래그된 아이템을 예전 position에서 새로운 position으로 이동시키려고 할 때 호출됩니다. 만약 true를 return할 경우, ItemTouchHelperviewHoldertarget의 position으로 이동했다고 간주합니다.

말이 조금 어렵지만, 다시 한 번 차근차근 되짚어보겠습니다. onMove 메서드는 LongClick을 통해 Item을 드래그해서 새로운 position으로 이동시키려고 할 때 호출됩니다. 만일 이 onMove 메서드 안에서 true를 return하면, viewHolder의 position에서 target의 position으로 이동했다고 간주합니다. 따라서, onMove 메서드 안에는 아이템을 fromPosition(viewHolder의 position)에서 toPosition(target의 position)으로 옮기고 이를 RecyclerView에게 알려줘야 합니다. 이를 코드로 보겠습니다.

override fun onMove(
	recyclerView: RecyclerView,
    viewHolder: RecyclerView.ViewHolder,
    target: RecyclerView.ViewHolder
): Boolean {
	recyclerViewAdapter.moveItem(viewHolder.adapterPosition, target.adapterPosition)
	return true
}

MyItemTouchHelperForFollower 클래스의 생성자로 전달된 recyclerViewAdapterHomeFollowerAdapter 타입입니다. 이 어댑터 내부에 구현된 moveItem을 호출하는 구조입니다. 그렇다면 HomeFollowerAdapter에는 moveItem()이라는 메서드가 어떻게 구현되어 있는지 살펴보겠습니다. 아래 코드는 HomeFollowerAdaptermoveItem() 메서드입니다(Room 데이터베이스에 업데이트하는 것과 관련된 코드는 생략하겠습니다).

fun moveItem(fromPosition: Int, toPosition: Int) {
	itemList.add(toPosition, itemList.removeAt(fromPosition))
    notifyItemMoved(fromPosition, toPosition)
}

생략하고 남은 것은 위 코드 뿐입니다. 먼저, 첫 줄부터 보겠습니다. itemList의 fromPosition index에 위치한 element를 제거함과 동시에 제거한 그 element를 itemList의 toPosition index에 add합니다. 결과적으로, fromPosition에 있던 element를 toPosition으로 이동시킨 것과 같습니다. 이동시킨 후, RecyclerView에게 View를 다시 그려야 한다고 notifyItemMoved를 사용해 알려줍니다. notifyItemMoved가 무엇인지는 도전과제를 설명하면서 자세히 다루겠지만, 간단히 설명하면 fromPosition에 있던 아이템이 toPosition으로 이동했음을 RecyclerView에게 알리는 메서드입니다. 이를 통해 바뀐대로 View를 갱신하게 됩니다.

이어서 onSwiped 메서드를 보겠습니다. 먼저 Android 공식문서의 설명을 먼저 보겠습니다.

onSwiped 메서드는 사용자가 ViewHolder를 Swipe할 때 호출됩니다. Swipe의 방향은 ItemTouchHelper.SimpleCallback의 생성자로 넣어준 swipeDirs입니다. 이번엔 다행히 onMove 메서드보다 쉬운 것 같습니다. 그렇다면 onSwiped 안에 코드는 어떻게 작성해야 할까요? viewHolder의 position의 아이템을 삭제하고 이를 RecyclerView에게 알려줘야 합니다. 바로 코드로 보겠습니다.

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
	recyclerViewAdapter.removeItem(viewHolder.adapterPosition)
}

MyItemTouchHelperForFollower 클래스의 생성자로 전달된 recyclerViewAdapterHomeFollowerAdapter 타입입니다. 이 어댑터 내부에 구현된 removeItem을 호출하는 구조입니다. 그렇다면 HomeFollowerAdapter에는 removeItem()이라는 메서드가 어떻게 구현되어 있는지 살펴보겠습니다. 아래 코드는 HomeFollowerAdapterremoveItem() 메서드입니다(Room 데이터베이스에 업데이트하는 것과 관련된 코드는 생략하겠습니다).

fun removeItem(position: Int) {
	itemList.removeAt(position)
    notifyItemRemoved(position)
}

생략하고 남은 것은 위 코드 뿐입니다. 먼저, 첫 줄부터 보겠습니다. itemList의 position index에 위치한 element를 제거합니다. 제거시킨 후, RecyclerView에게 View를 다시 그려야 한다고 notifyItemRemoved를 사용해 알려줍니다. notifyItemRemoved가 무엇인지는 도전과제를 설명하면서 자세히 다루겠지만, 간단히 설명하면 position에 있던 아이템이 제거되었다고 RecyclerView에게 알리는 메서드입니다. 이를 통해 바뀐대로 View를 갱신하게 됩니다.

이렇게 MyItemTouchHelperForFollower를 구현했으면, 이를 RecyclerView에 적용시켜야 합니다. ItemTouchHelper 적용은 RecyclerView를 초기화하는 함수 안에 아래 코드를 작성하면 됩니다.

ItemTouchHelper(
	MyItemTouchHelperForFollower(homeFollowerAdapter)
).attachToRecyclerView(
	binding.rvHomeFollower
)

구조는 대강 이렇습니다. ItemTouchHelper 객체를 생성할 때 생성자로 MyItemTouchHelperForFollower 객체를 생성해서 넣어줍니다. 이렇게 생성한 ItemTouchHelper 객체를 attachToRecyclerView 메서드를 활용해 RecyclerView에 attach 시켜주는 코드입니다.

도전과제

도전과제 1.

도전과제 1은 Fragment의 보일러 플레이트 코드를 개선하는 과제입니다. 보일러 플레이트 코드란 무엇일까요? Wikipedia에서는 보일러 플레이트 코드, 즉 상용구 코드를 이렇게 설명하고 있습니다.

컴퓨터 프로그래밍에서 상용구 코드 또는 상용구는 수정하지 않거나 최소한의 수정만을 거쳐 여러 곳에 필수적으로 사용되는 코드를 말한다.

Fragment를 생성하면 기본적으로 생성되는 보일러 플레이트 코드가 있습니다. 그 코드를 가져와 보겠습니다.

package co.kr.sopt_seminar_30th.presentation.ui

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import co.kr.sopt_seminar_30th.R

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

/**
 * A simple [Fragment] subclass.
 * Use the [BlankFragment.newInstance] factory method to
 * create an instance of this fragment.
 */
class BlankFragment : Fragment() {
    // TODO: Rename and change types of parameters
    private var param1: String? = null
    private var param2: String? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            param1 = it.getString(ARG_PARAM1)
            param2 = it.getString(ARG_PARAM2)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_blank, container, false)
    }

    companion object {
        /**
         * Use this factory method to create a new instance of
         * this fragment using the provided parameters.
         *
         * @param param1 Parameter 1.
         * @param param2 Parameter 2.
         * @return A new instance of fragment BlankFragment.
         */
        // TODO: Rename and change types and number of parameters
        @JvmStatic
        fun newInstance(param1: String, param2: String) =
            BlankFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_PARAM1, param1)
                    putString(ARG_PARAM2, param2)
                }
            }
    }
}

하지만 우리는 실제로 Fragment를 사용할 때, 틀을 이 정도로 고치고 사용합니다.

class BlankFragment : Fragment() {
	private var _binding: FragmentBlankBinding? = null
    private val binding get() = _binding ?: error("Binding이 초기화되지 않았습니다.")
    
    override fun onCreateView(
    	inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
    	_binding = FragmentBlankBinding.inflate(layoutInflater, container, false)
        return binding.root
	}
    
    override fun onDestroyView() {
    	super.onDestroyView()
        _binding = null
	}
}

그렇다면, 매번 Fragment를 생성할 때마다 저렇게 onCreateView에서 binding 객체를 초기화하고 onDestroyView에서 binding 객체를 null로 만들어주는 작업을 하도록 코드를 작성해야 할까요? 그렇지 않습니다. Kotlin은 함수형 프로그래밍도 지원하면서 동시에 객체지향 프로그래밍도 지원합니다. 우리는 객체지향적인 특성을 활용해 Fragment의 보일러 플레이트 코드를 개선할 수 있습니다.

OOP라고 부르는, 객체지향 프로그래밍에는 여러 중요한 개념들이 존재합니다. 그 중 많은 분들이 핵심 내용으로 추상캡다라고도 부르는, 추상화, 상속, 캡슐화, 다형성을 꼽는 것 같습니다. 보일러 플레이트 코드 개선에 대한 얘기를 하다가 OOP로 갑자기 넘어왔습니다. 눈치채셨나요? 상속다형성을 사용해 보일러 플레이트 코드를 개선합니다.

abstract class BaseFragment<T : ViewDataBinding> : Fragment() {
    private var _binding: T? = null
    protected val binding get() = _binding ?: error("Binding not Initialized")
    abstract val layoutRes: Int
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = DataBindingUtil.inflate(layoutInflater, layoutRes, container, false)
        return binding.root
    }
    
    override fun onDestroyView() {
        _binding = null
        super.onDestroyView()
    }
}

위 코드는 Fragment 클래스를 상속받아서 만든 abstract classBaseFragment 클래스입니다. 제가 실제로 구현한 BaseFragment는 조금 다르지만, 가장 기본적인 틀만 구현했습니다. 먼저 큰 틀만 보겠습니다.

Fragment 클래스를 상속받아서 onCreateView()onDestroyView()를 오버라이드했습니다. 두 메서드 안에서 각각 binding 객체를 초기화해주는 작업과 null을 할당해주는 작업을 하도록 구현했습니다. 그리고 이제 우리는 Fragment를 생성할 때, 이 BaseFragment를 상속받은 Fragment를 만들 것입니다.

조금 더 살펴보면, _binding 같은 경우는 BaseFragment 내부에서만 쓰이기 때문에 private으로 선언한 것을 확인할 수 있습니다. 반면 binding 같은 변수는 BaseFragment를 상속받을, 그러니까 앞으로 우리가 구현할 Fragment들에서 사용해야 합니다. 따라서 protected로 선언해 상속받은 클래스들만 사용할 수 있도록 선언했습니다. 마지막으로 abstract로 선언한 layoutRes는, BaseFragment를 상속받을 클래스라면 반드시 값을 할당해야 합니다. 이 layoutResView에 대한 Layout입니다. 마지막으로 <T: ViewDataBinding>을 통해 BaseFragment에게 어떤 Binding 클래스 타입인지 알려주게 됩니다.

이제 실제로 이 BaseFragment를 활용해 구현하면 얼마나 코드가 단순해지는지 보겠습니다. BlankFragmentFragment 클래스가 아닌 BaseFragment를 상속받도록 구현한 코드를 아래에 첨부하겠습니다.

class BlankFragment : BaseFragment<FragmentBlankBinding>() {
	override val layoutRes: Int get() = R.layout.fragment_blank
}

놀랍게도 끝입니다. 이렇게 짧고 간결하게 해도 되는 이유는 바로 BaseFragment에 이미 다 구현을 해두고, 이 BaseFragment를 상속받기 때문입니다. 이런 Base 클래스는 Fragment 뿐만 아니라 Activity나 ViewModel, Adapter 등 다양한 곳에서 활용할 수 있습니다.

도전과제 2.

도전과제 2는 RecyclerView Adapter의 notifyDataSetChanged의 문제점을 찾고, 이를 해결해보는 과제입니다. 먼저 notifyDataSetChanged의 문제점이 무엇인지부터 알아보고, 이를 해결해보겠습니다.

notifyDataSetChanged의 역할이 무엇일까요?

notifyDataSetChanged에 대해서 Android 공식문서는 위처럼 설명하고 있습니다. 꽤나 흥미로운 내용도 포함되어 있기 때문에 한 번 읽어보겠습니다.

notifyDataSetChanged
등록된 관찰자에게 data set이 changed되었다고 알립니다.
데이터 변경 이벤트에는 두 가지 종류, 아이템 변경과 구조적 변경이 있습니다. 아이템 변경은 단일 아이템 내의 데이터가 변경되었지만 위치의 변동은 없는 경우입니다. 구조적 변경은 data set 내에서 아이템이 삽입, 삭제, 이동될 때 발생합니다.
이 이벤트는 data set의 변경 사항을 지정하지 않고 관찰자로 하여금 기존의 모든 아이템 항목과 data set 구조가 더 이상 유효하지 않다고 가정하게 만듭니다. LayoutManager는 보이는 모든 View를 다시 bind하고 다시 layout해야만 합니다.
....
최후의 수단으로 notifyDataSetChanged를 사용하십시오.

공식문서에 우리가 찾는 답이 있습니다. 한 마디로 정리하자면 notifyDataSetChanged는 모든 View를 다 지우고 다시 그립니다. 눈치가 빠른 분들은 이미 눈치채셨겠지만, 이런 상황이 있을 수 있습니다. RecyclerView의 itemList, 즉 data set이 1,000개가 있다고 가정해 보겠습니다.

아니 ... 1번 아이템을 5번으로 옮기기만 하면 되는데 View를 전부 다시 그리라고?
아니 ... 7번 아이템만 쏙 삭제시켜주면 되는데 View를 전부 다시 그리라고?
아니 ... 8번이랑 9번 사이에 새 아이템을 삽입하기만 하면 되는데 View를 전부 다시 그리라고?

맞습니다. 어디서 변경이 일어났는지 알기만 한다면, 모든 View를 다시 그리는 것이 아니라 변경이 일어난 위치의 View들만 다시 그리는 것이 훨씬 효율적입니다. notifyDataSetChanged는 모든 View를 삭제하고 다시 그리기 때문에 간혹 깜빡임이 발생하는 문제 또한 발견됩니다. 그렇다면 이런 문제를 어떻게 해결할 수 있을까요? 2가지 방법이 있습니다.

  1. 적절한 notify~ 메서드를 사용한다.
  2. DiffUtil.Callback 클래스를 상속받은 클래스를 구현해서 사용한다.

1번을 먼저 볼까요? notifyDataSetChanged에 관한 공식문서 설명 아래 나즈막히 붙어있는 이 메서드들을 보겠습니다.

notifyItemChanged(position) 메서드는 position에 위치한 아이템이 변경되었다고 알리는 메서드입니다. (아이템 변경)
notifyItemInserted(position) 메서드는 position에 아이템이 삽입되었다고 알리는 메서드입니다. (구조적 변경)
notifyItemRemoved(position) 메서드는 position에서 아이템이 삭제되었다고 알리는 메서드입니다. (구조적 변경)
notifyItemRangeChanged(position, itemCount) 메서드는 position을 시작점으로 하여 itemCount만큼의 아이템들이 변경되었다고 알리는 메서드입니다. (아이템 변경)
notifyItemRangeInserted(position, itemCount 메서드는 position을 시작점으로 하여 itemCount만큼의 아이템들이 삽입되었다고 알리는 메서드입니다. (구조적 변경)
notifyItemRangeRemoved(position, itemCount 메서드는 position을 시작점으로 하여 itemCount만큼의 아이템들이 삭제되었다고 알리는 메서드입니다. (구조적 변경)

위에는 나와있지 않지만, notifyItemMoved(fromPosition, toPosition) 메서드는 fromPosition에서 toPosition으로 아이템이 이동했다고 알리는 메서드입니다.

notifyDataSetChanged 대신에 저런 메서드들을 적재적소에 쓰면 되겠구나? 저걸 매번 내가 계산해야 할까...? 간단한 한두가지 작업이면 모르겠는데... 어떻게 하면 더 좋을까🤨😤🤔

여러 notify~ 메서드를 봤지만, 너무 번거롭습니다. 매번 적절하게 호출하기도 어려울 뿐더러(사실 개발자의 능력 부족일지도 모릅니다) 매번 적절하게 호출하기도 어렵습니다(?). 개발자들은 다 비슷한 고충을 겪긴 하나봅니다. 이런 고충을 해결해줄 DiffUtil이라는 친구가 notifyDataSetChanged를 대체할 2번 방법입니다.

DiffUtil이란 무엇일까요? 깔끔하게, 공식문서 맨 첫줄만 읽어보겠습니다.

DiffUtil은 Utility 클래스인데, 두 리스트의 차이를 계산하고 첫 번째 리스트를 두 번째 리스트로 변환하는 업데이트 작업 리스트를 출력하는 Utility 클래스라고 합니다. 쉽게 말해서, 두 리스트를 비교하고 뭐가 다른지 알아서 계산을 척척 해주는 Utility 클래스라는 것이죠. 이 DiffUtil이 좋긴 한가봅니다. 공식문서 스크롤을 조금 더 내려보면 테스트 결과값도 Android 측에서 자랑합니다.

빠르다고 하네요. 이제 그럼 실제 구현을 한 번 보겠습니다. 코드와 함께 보면 더 좋을 것 같아서 이번에는 코드를 먼저 첨부하겠습니다.

class MyDiffUtilCallback(private val oldItemList: List<Any>, private val newItemList: List<Any>): DiffUtil.Callback() {
    override fun getOldListSize(): Int = oldItemList.size

    override fun getNewListSize(): Int = newItemList.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItemList[oldItemPosition]
        val newItem = newItemList[newItemPosition]

        return if(oldItem is FollowerInformation && newItem is FollowerInformation) {
            oldItem.followerName == newItem.followerName
        } else if (oldItem is RepositoryInformation && newItem is RepositoryInformation) {
            oldItem.repositoryName == newItem.repositoryName
        } else {
            false
        }
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldItemList[oldItemPosition] == newItemList[newItemPosition]
}

저는 MyDiffUtilCallback이라는 클래스를 DiffUtil.Callback이라는 클래스를 상속받아서 구현했습니다. 생성자로는 oldItemListnewItemList를 받습니다. 기본적으로 DiffUtil 클래스는 두 리스트의 차이를 계산해주는 역할을 하기 때문에, oldItemListnewItemList가 바로 DiffUtil 클래스가 계산할 때 다룰 두 리스트에 해당됩니다.

4개의 메서드를 오버라이드해야 합니다. 하나하나 살펴보겠습니다.

getOldListSize() 메서드의 경우, oldList의 size를 반환해주면 됩니다.

getNewListSize() 메서드의 경우, newList의 size를 반환해주면 됩니다.

areItemsTheSame() 메서드의 경우, 각각의 리스트에서 꺼내온 두 객체가 같은 아이템인지 확인하는 작업입니다. 만일 아이템에 유일하게 식별할 수 있는(DB로 치면 Primary Key와 같은 기능을 하는) ID가 있다면, areItemsTheSame() 메서드 내에서 두 객체의 ID를 비교해주면 됩니다.

areContentsTheSame() 메서드의 경우, 두 아이템의 ID를 비교하는 것이 아닌 실제 내용물까지도 정확히 일치하는지를 확인하는 메서드입니다.

areItemsTheSame()이랑 areContentsTheSame()은 겉보기에 하는 일이 비슷해 보이는데, 그냥 areContentsTheSame()만 있어도 될 것 같아 보입니다. 왜 저 둘로 분리되어 있을까요?
DiffUtil 클래스는 두 리스트의 차이를 계산할 때, areItemsTheSame()을 호출해 두 아이템의 ID를 비교해서 같은지 다른지 먼저 판단합니다. ID조차 다르다면 내용물을 까볼 필요는 없겠죠? 그래서 다르면 아 얘네 둘 서로 다른 애들임 ~ 처리해 ~라고 하는 반면, 만일 ID가 같다면 내용물까지 같은지 검증할 필요가 있습니다. 그래서 어? ID 같아? 그럼... 까볼까?하고 내용물까지 비교하게 됩니다.
더 빠르게 비교하기 위해서 필요한 개념이라고 생각하시면 될 것 같습니다.

이제 MyDiffUtilCallback 클래스의 코드를 보겠습니다.

class MyDiffUtilCallback(private val oldItemList: List<Any>, private val newItemList: List<Any>): DiffUtil.Callback() {
    override fun getOldListSize(): Int = oldItemList.size

    override fun getNewListSize(): Int = newItemList.size

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem = oldItemList[oldItemPosition]
        val newItem = newItemList[newItemPosition]

        return if(oldItem is FollowerInformation && newItem is FollowerInformation) {
            oldItem.followerName == newItem.followerName
        } else if (oldItem is RepositoryInformation && newItem is RepositoryInformation) {
            oldItem.repositoryName == newItem.repositoryName
        } else {
            false
        }
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = oldItemList[oldItemPosition] == newItemList[newItemPosition]
}

엄청나게 복잡하지는 않습니다. 주목할 것은, areItemsTheSame에서는 ID에 해당하는 필드만 비교하고, areContentsTheSame에서는 두 객체 전체를 비교해야 한다는 것입니다. 또, 저의 경우는 oldItemList와 newItemList 둘 다 List<Any> 타입으로 받아오기 때문에, areItemsTheSame에서 타입에 따라 검사할 필드(ID에 해당하는 필드)를 다르게 했습니다.

이번에는 MyDiffUtilCallback을 구현한 뒤, 어떻게 RecyclerView에 적용하는지 보겠습니다. RecyclerView에서 두 리스트를 비교해야 할 때는 언제일까요? 바로 itemList를 갱신할 때입니다. 해당하는 코드만 보겠습니다.

fun updateItemList(newItemList: List<FollowerInformation>?) {
	newItemList?.let {
    	val diffCallback = MyDiffUtilCallback(itemList, newItemList)
        val diffResult = DiffUtil.calculateDiff(diffCallback)

        itemList.run {
        	clear()
            addAll(newItemList)
            diffResult.dispatchUpdatesTo(this@HomeFollowerAdapter)
		}
	}
}

updateItemList() 메서드는 인자로 newItemList를 받고 리스트를 갱신한 뒤 View를 업데이트하는 메서드입니다. MyDiffUtilCallback 클래스를 인스턴스화시켜 diffCallback이라는 변수를 만든 뒤, DiffUtil 클래스의 calculateDiff() 메서드의 인자로 diffCallback을 넣고 그 결과를 diffResult 변수에 담습니다.. calculateDiff() 메서드는 차이를 계산해주는 메서드입니다. 그 후, itemList를 비우고, newItemList로 채운 뒤, diffResult.dispatchUpdatesTo(this)를 통해 RecyclerView Adapter의 View를 업데이트시킵니다.







감사합니다.

profile
영차영차

0개의 댓글