[연재] 코틀린 프로젝트 - 02 - 3단계: 데이터의 구성과 RecyclerView의 어댑터

RecyclerView를 사용한 데이터의 구성

이번 단계에서는 영화목록을 불러들이기 위한 데이터 클래스와 어댑터를 생성할 것입니다. 영화 정보와 로딩 표시를 위해 RecylerView에 특정 뷰를 반환 하거나 나타내는 위임된 어댑터가 필요합니다. 이 어댑터에 영화 정보 표시 뷰와 로딩 중 표시를 위한 뷰 두개가 사용될 것입니다. 이 두개의 뷰를 선택할 수 있도록 ViewType을 두가지 종류를 정의합니다. 각 아이템은 인자로서 위임된 어댑터에 전달됩니다. 여기서 세부적인 데이터를 보여줄 수 있도록 합니다. 두개의 뷰를 보여줄 것이므로 어댑터도 두개 종류MovieItemAdapterLoadingItemAdapter 로 만들 예정입니다. 

  • MOVIE: 새로운 영화 정보 내용을 표시
  • LOADING: 로딩 중(프로그래스바)임을 표시

 


두 종류의 뷰와 어댑터

 

이렇게 두개를 정의한 후 이것을 RecyclerView에 하나의 아이템으로 붙이게 됩니다. RecyclerView는 다양한 어댑터를 붙일 수 있고 연속 데이터를 계속 보여 줄 수 있기 때문에 자주 사용되는 뷰 입니다. 개념은 앞서 살펴본 것과 같이 ViewHolder, LayoutManager등의 추가 요소의 도움이 필요합니다. 

데이터 클래스의 생성

1. 먼저 영화 정보를 위해 패키지에 data/를 만들고 그 안에 Models.kt 파일에 데이터 클래스를 정의합니다. 이때 주석에 문서화 할 수 있도록 프로퍼티 설명을 @Property와 함께 작성합니다.

영화정보를 위한 데이터 클래스 data/Models.kt
...
/**
 * 각각의 영화 아이템을 위한 데이터 클래스 정의
 * @property vote_count 투표수
 * @property vote_average 투표 평균 점수
 * @property title 영화명
 * @property release_date 출시일
 * @property poster_path 포스터의 위치
 * @property overview 영화 설명
 */
data class MovieItem(
        val vote_count: Int,
        val vote_average: Float,
        val title: String,
        val release_date: String,
        val poster_path: String,
        val overview: String?
)

이제 영화 정보를 정의할 수 있도록 데이터 클래스에 프로퍼티들로 정의해 줍니다. 데이터 클래스는 게터와 세터, toString(), equals(), hashCode() 등이 내부적으로 자동 생성되기 때문에 자바 보다 상당히 축약된 형태로 선언할 수 있습니다. 

이제 이 클래스는 안드로이드의 서로 다른 요소에서 통신을 하기 위해 Serializable 혹은 Parcelable 인터페이스를 상속해 구현할 수 있습니다. 안드로이드의 Parcel과 바인더 시스템을 사용하는 Parcelable은 Serializable 보다 좋은 성능으로 서로 다른 개체간 통신을 구현할 수 있습니다. 

다만 구현해야하는 생성자, 프로퍼티, 메서드가 매번 같은 형태로 작성되기 때문에 보일러 플레이트 코드에 해당됩니다. 이것을 일일이 작성한다는 것은 지루한 작업이 될 수 있죠. 따라서, 이것을 에디터의 플러그인에서 제공하는 Parcelable 코드 생성기를 이용하면 해당 클래스에서 Parcelable에 필요한 코드를 자동적으로 생성시킬 수 있습니다. 그러면 플러그인을 이용해 봅시다.

2. AndroidStudio의 [File → Settings → Plugins → Browse Repositories]에서 플러그인이 설치되어 있는지 확인합니다. 


Android Studio의 Settings에서 Plugins 메뉴에서 생성기를 설치

 

3. 그러면 MovieItem 클래스명 위에서 [Alt+Insert]를 누르고 코드를 생성할 수 있습니다. 


Parcelable 코드의 생성

 

통신을 위한 Parcelable 구현 data/Models.kt
...
data class MovieItem(
    val vote_count: Int,
    val vote_average: Float,
    val title: String,
    val release_date: String,
    val poster_path: String,
    val overview: String?
) : Parcelable {
    constructor(source: Parcel) : this(
        source.readInt(),
        source.readFloat(),
        source.readString(),
        source.readString(),
        source.readString(),
        source.readString()
    )

    override fun describeContents() = 0

    override fun writeToParcel(dest: Parcel, flags: Int) = with(dest) {
        writeInt(vote_count)
        writeFloat(vote_average)
        writeString(title)
        writeString(release_date)
        writeString(poster_path)
        writeString(overview)
    }

    companion object {
        @JvmField
        val CREATOR: Parcelable.Creator<MovieItem> = object : Parcelable.Creator<MovieItem> {
            override fun createFromParcel(source: Parcel): MovieItem = MovieItem(source)
            override fun newArray(size: Int): Array<MovieItem?> = arrayOfNulls(size)
        }
    }
}

코드를 자동적으로 생성해 주기는 하지만 그래도 여전히 코드가 줄어들지 않습니다. 

4. 이번에는 Parcelable을 구현하는 또 다른 방법으로 안드로이드 코틀린 확장의 실험적 기능을 이용하는 것입니다. 모듈의 build.gradle에 넣어둔 다음 블록으로 @Parcelize 어노테이션을 사용해 봅시다.

모듈의 빌드 스크립트에 추가 build.gradle
...
androidExtensions { // 안드로이드 확장의 실험적 특징 추가
    experimental = true
}
dependencies {
...

 

5. 그러면 data/Models.kt의 기존 코드를 수정해 다음과 같이 최종적으로 데이터 클래스를 구성해 보겠습니다. 

영화정보를 위한 데이터 클래스 data/Models.kt
...
@Parcelize
data class MovieItem(
        val vote_count: Int,
        val vote_average: Float,
        val title: String,
        val release_date: String,
        val poster_path: String,
        val overview: String?
) : Parcelable {
}

단순히 @Parcelize 어노테이션으로 구현 내용이 비어 있어도 됩니다. 필요한 코드는 어노테이션을 통해 내부적으로 자동 생성됩니다! 코드는 훨씬 읽기 좋아졌습니다. 다만, 내부의 읽기와 쓰기를 사용자가 변경해 구현하려면 추가적으로 구현 루틴은 작성해 주어야 합니다. 여기서는 기본적인 형태를 사용하므로 빈 블록 형태로 둡니다.

6. 이제 RecyclerView에 나타낼 수 있도록 일정량의 영화 정보 아이템을 가지도록 List를 가지는 MovieList 데이터 클래스를 만듭니다.

영화 목록을 위한 데이터 클래스 data/Models.kt
...
/**
 * 영화 목록을 위한 데이터 클래스 정의
 * @constructor 페이지와 결과 영화 목록 List 설정
 * @property page 페이지를 나타내며 한페이지당 20개의 영화목록
 * @property results 한 페이지의 영화목록20개를 List에 구성
 */
@Parcelize
data class MovieList(
        var page: Int?,
        val results: List<MovieItem>) : Parcelable {
}
...

RecyclerView의 아이템으로 한번 로드 할 때마다 20개씩 보여주도록 합니다. 우리가 사용할 영화 서버에서 한 페이지당 20개씩 읽을 수 있습니다. 20개의 영화 목록을 저장하도록 results에 MovieItem을 List 형식으로 선언합니다. 이 데이터 클래스도 @Parcelize 어노테이션을 이용해 Parcelable 가능한 클래스로 정의합니다.

인터페이스와 어댑터 클래스들

이번에는 데이터 클래스의 정보를 나타내는 어댑터들을 만들 차례입니다. 먼저 전체 어댑터 구조를 위해 클래스 다이어그램을 통해서 이해하고 소스의 주요 포인트를 하나씩 체크해 봅시다. 

 


두가지 자료형을 위한 어댑터 구조

 

MovieItemAdapter와 LoadingItemAdapter를 묶을 ItemAdapter 인터페이스를 정의하고 뷰홀더 생성과 연결을 위한 메서드를 준비합니다. 인터페이스를 구현하는 하위 클래스에는 ItemAdapter의 메서드인 onCreateViewHolder()와 onBindViewHolder()를 오버라이드 해서 구현해야 합니다. MovieItemAdapter에서는 추가적으로 내부에 포함된 MovieViewHolder라는 이너 클래스를 통해 뷰 홀더를 생성 및 연결합니다. 이너 클래스를 사용하면 바깥 클래스의 멤버에 접근할 수 있었죠. 따라서 bind()메서드에서 바깥 클래스인 MovieItemAdapter의 프로퍼티인 viewActions에 접근할 것입니다.

LoadingItemAdapter 클래스에는 단순 로딩을 나타내는 레이아웃을 띄우도록 LoadingViewHolder에서 리소스의 item_loading 을 띄울 것입니다. 

이제 MovieAdapter클래스는 delegatedAdapter라는 SparsArrayCompat<ItemAdapter>()로 선언된 객체 변수를 가지고 있습니다. 이것은 향후 MOVIE 와 LOADING 형태에 따른 아이템을 저장하기 위해 준비된 배열입니다. 이 클래스에는 새로운 아이템을 추가하거나 읽기, 위치 이동, 지우는 등의 역할을 하면서 RecyclerView의 요소들을 제어할 것입니다. 이제 하나씩 구현해 봅시다.

ViewType 인터페이스

패키지에 있는 ui/ 하위에 adapter/ 패키지를 하나 더 만듭니다. 여기에 어댑터를 위한 소스를 만들어 보겠습니다. 

1. 먼저 ViewType은 두개의 위임된 어댑터 자료형을 구별하기 위해 만듭니다. 

뷰타입을 가져오기위한 인터페이스 ui/adapter/ViewType.kt
...
interface ViewType {
    fun getViewType(): Int
}

ItemAdapter의 구조

2. 먼저 상위에 선언된 ItemAdapter는 다음과 같습니다. 

어댑터를 위한 상위 인터페이스 ui/adapter/ItemAdapter.kt
...
interface ItemAdapter {

    fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder

    fun onBindViewHolder(holder: RecyclerView.ViewHolder, item: ViewType)
}

 

3. 이것을 구현하는 클래스는 반드시 두개의 메서드를 오버라이드해 구현하도록 합니다. 

ItemAdapter를 구현한 영화목록을 위한 클래스 ui/adapter/MovieItemAdapter.kt
...
class MovieItemAdapter : ItemAdapter { // (1)

    override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder { // (2)
        return MovieViewHolder(parent)
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, item: ViewType) { // (3)
        holder as MovieViewHolder
        holder.bind(item as MovieItem) // (4)
    }

    // 이너 클래스에서는 바깥 클래스의 프로퍼티 등을 접근 할 수 있다.
    inner class MovieViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
            parent.inflate(R.layout.item_movie)) {

        private val imgPoster = itemView.img_poster
        private val overview = itemView.tv_overview
        private val releaseDate = itemView.tv_release_date
        private val voteCount = itemView.tv_vote_count
        private val tvTitle = itemView.tv_title
        private val tvAverage = itemView.rate_vote_avg
        private val btnReserve = itemView.btn_reserve

        fun bind(item: MovieItem) { // (5)
            imgPoster.loadImg(item.poster_path)  // (6) ImageView의 확장 함수
            overview.text = item.overview
            releaseDate.text = item.release_date
            voteCount.text = "${item.vote_count} 투표"
            tvTitle.text = item.title
            tvAverage.rating = item.vote_average / 2  // (7)
        }
    }
}

NewsItemAdapter는 ItemAdapter 인터페이스로부터 구현 되었으므로 (2)번과 (3)번 메서드가 오버라이드 되어 구현되었습니다. onCreateViewHolder()는 MovieViewHolder 이너클래스의 객체를 생성해 반환하고 onBindViewHolder()는 데이터를 연결하는 과정을 하게 됩니다. 이때 데이터를 위한 클래스인 MovieItem클래스가 필요합니다. (4)번 bind()를 호출 하면서 (5)번의 내용처럼 데이터를 서로 연결하는 바인딩 작업을 하게 됩니다. 저장되어 있는 데이터를 레이아웃 리소스 id에 해당하는 변수에 할당하면서 UI를 나타낼 수 있게 됩니다. 

(6)번은 이미지 로드를 위해서 사용할 메서드 입니다. 아직은 ImageView에는 loadImg() 메서드가 없으므로 확장 함수를 통해서 ImageView에 추가할 것입니다. (7)번의 RatingBar 설정은 10점을 기준으로 별 5개에 나타나게 하기 위해 2로 나눴습니다. 

Picasso의 추가

4. 그러면 이미지뷰를 의한 확장 함수를 추가하기 위해 먼저 Picasso 라이브러리를 모듈의 build.gradle에 추가합니다. 

추가 라이브러리 build.gradle
...
dependencies {
...
    implementation 'com.android.support:exifinterface:27.1.1'
    // Picasso
    implementation 'com.squareup.picasso:picasso:2.71828'
...

5. AndroidStudio 편집기의 Sync Now를 누르고 빌드 시스템에 반영합니다. 그 다음 확장 함수를 만들어 봅시다.

ImageView에 loadImg 확장 함수 추가하기 utils/Extensions.kt
...
fun ImageView.loadImg(imageUrl: String) {
    if (TextUtils.isEmpty(imageUrl)) {
        Picasso.get().load(R.mipmap.ic_launcher).into(this)
    } else {
        Picasso.get().load(imageUrl)
                .placeholder(R.drawable.img_default) // 로드되지 않은 경우 기본 이미지
                //.resize(300,300)  // 300x300 픽셀
                .centerCrop() // 중앙을 기준으로 잘라내기 (전체 이미지가 약간 잘릴 수 있다)
                .fit() // 이미지 늘림 없이 ImageView에 맞춤
                .into(this) // this인 ImageView에 로드
    }
}

로드되지 않는 이미지를 위해 placeholder를 두면 좋습니다. 이때, 리소스에 drawable 폴더에 img_default.jpg와 같이 기본 이미지를 넣어 둡니다. 


img_default.jpg 기본 이미지를 res/drawable/에 넣기

 

MovieAdapter 클래스

이제 뷰자료형에 따라 뷰홀더를 연결할 수 있게 해주는 어댑터 클래스인 MovieAdapter를 RecyclerView.Adapter 클래스로부터 상속해 생성해 봅시다. 

1. ui/adapter/에 MovieAdapter.kt 파일과 AdapterType.kt을 만들고 다음과 같이 구현합니다. 

MovieAdapter 클래스의 구현 ui/adapter/MovieAdapter.kt
...
class MovieAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    // MOVIE 혹은 LOADING 아이템의 종류를 파악하기 위해
    private var items: ArrayList<ViewType> // (1)

    // 두 종류의 어댑터를 위한 배열 컬렉션
    private var delegateAdapters = SparseArrayCompat<ItemAdapter>() // (2)

    private val loadingItem = object : ViewType { // (3)
        override fun getViewType() = AdapterType.LOADING
    }

    // 생성시 초기화 되는 블록
    init { // (4)
        delegateAdapters.put(AdapterType.LOADING, LoadingItemAdapter())
        delegateAdapters.put(AdapterType.MOVIE, MovieItemAdapter(listener))
        items = ArrayList()
        items.add(loadingItem)
    }
    // (5)
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
            delegateAdapters.get(viewType).onCreateViewHolder(parent)

    // (6)
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        delegateAdapters.get(getItemViewType(position)).onBindViewHolder(holder, items[position])
    }

    override fun getItemCount(): Int = items.size
    // (7) 여러 종류의 보기유형을 나타내기 위해
    override fun getItemViewType(position: Int) = items[position].getViewType()
...

추가적으로 상태를 위한 객체(object) 변수를 준비합니다. 이것을 상수처럼 사용할 것입니다. 

객체 어댑터 형식 만들기 ui/adapter/ AdapterType.kt
...
object AdapterType {
    val NEWS = 1
    val LOADING = 2
}

먼저 (1)번에서 MOVIE나 LOADING에 따른 종류를 파악해 저장할 수 있는 ArrayList<ViewType>으로 생성한 items 변수를 정의 하였습니다. 그리고 (2)번에서 두 종류의 어댑터를 가지는 배열 SparseArrayCompat로 선언된 delegateAdapters 변수를 정의합니다. 

 

Tip: SparseArray

SparseArray는 자바의 HashMap과 비슷한 안드로이드에서 제공하는 좀 더 좋은 성능의 컬렉션 입니다.

(3)번에서 객체 표현식에 따라 명시적인 선언 없이도 객체를 생성할 수 있게 됩니다. ViewType을 구현해 오버라이딩한 getViewType()메서드는 상수 LOADING에 따라 LoadingItemAdapter를 가리키게 됩니다. 이것은 (4)번 초기화 과정에서 종류에 따른 위임된 어댑터를 로딩하도록 delegateAdapters 에 put을 사용해 넣어두고 있습니다.  (5)~(7)은 각각 오버라이딩되어 구현된 메서드들입니다. 특히 (7)번에 의해 특정 뷰로 (6)번에서 붙일 수 있게 됩니다. 여기서는 영화 정보 뷰와 로딩 뷰 두가지 보기 유형이 있습니다.

2. 이제 영화 목록에 대해 추가, 삭제, 가져오기등을 할 수 있도록 메서드를 추가 구현 해 봅니다. 

MovieAdapter 클래스의 구현 ui/adapter/MovieAdapter.kt
...
class NewsAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
...

    fun addMovieList(movieList: List<MovieItem>) {
        // 초기 위치 제거 및 알리기
        val initPosition = items.size - 1
        items.removeAt(initPosition)
        notifyItemRemoved(initPosition) // 특정 Position에 데이터를 하나 제거할 때 이벤트 알림

        // 모든 목록을 추가하고 마지막은 로딩용 아이템 추가
        items.addAll(movieList)
        items.add(loadingItem)
        notifyItemRangeChanged(initPosition, items.size + 1 /* 로딩용으로 하나 추가 */)
    }
    // 삭제하고 추가하기
    fun clearAndAddMovieList(movieList: List<MovieItem>) {
        items.clear()
        notifyItemRangeRemoved(0, getLastPosition())

        items.addAll(movieList)
        items.add(loadingItem)
        notifyItemRangeInserted(0, items.size)
    }
    // 뉴스 가져오기
    fun getMovieList(): List<MovieItem> = items
                .filter { it.getViewType() == AdapterType.MOVIE }
                .map { it as MovieItem }

    private fun getLastPosition() = if (items.lastIndex == -1) 0 else items.lastIndex
}

뉴스를 저장하고 나타내기 위한 메서드 설계 입니다. 특히 getMovieList()는 필더와 맵을 사용해 MovieItem의 목록을 반환하도록 하고 있습니다. filter에서는 item의 ViewType이 MOVIE인 것만 골라내도록 하고 map에서는 이 목록으로부터 아이템으로 변환 하기 위해 사용한 람다식 입니다.

테스트용 데이터

3. 이제 RecyclerView가 정상적으로 작동하는지 MovieFragment의 onActivityCreated()에 테스트용 데이터를 만들어서 실행해 보겠습니다. 

어댑터를 통해 테스트 데이터 사용하기 ui/MovieFragment.kt
...
class MovieFragment : Fragment() {
...
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
...
        // (1) 어댑터의 연결
        if (rv_movie_list.adapter == null) {
            rv_movie_list.adapter = MovieAdapter()
        }
         // (2) 테스트용 데이터의 생성
        if (savedInstanceState == null) {
            val movieList = mutableListOf<MovieItem>()
            for (i in 1..10) {
                movieList.add(
                    MovieItem(
                        1234,
                        5.0f,
                        "Test Title $i",
                        "2018-01-01",
                        "https://picsum.photos/480/640?image=$i", // (3) 외부 인터넷 이미지 리소스
                        "Test Overview"
                    )
                )
            }
            // (4) 생성된 데이터를 RecyclerView에 추가
            (rv_movie_list.adapter as MovieAdapter).addMovieList(movieList)
        }
    }
}

(1)번에서 RecyclerView의 합성 프로퍼티에 의해 리소스 id에 직접 adapter를 지정할 수 있습니다. 여기서는 MovieAdapter의 객체가 지정됩니다. 이제 (2)번에서 mutableListOf()를 사용해 테스트용 영화 정보 목록을 for문을 이용해 10개정도 만들어 봅니다. 이렇게 만들어진 영화 목록인 movieList는 (4)번의 addMovieList(movieList)를 호출해 RecyclerView에 아이템으로 나타나게 됩니다. 

4. 이미지 데이터를 테스트할 때는 보통 외부의 이미지 생성 사이트를 이용할 수 있습니다. 여기서는 (3)번 처럼 picsum.photos 사이트를 이용해 이미지 자동 생성을 이용합니다. 이때 인터넷 접근 권한이 필요하므로 AndroidMenifest.xml에 다음과 같이 INTERNET권한을 추가해 줍니다. 

인터넷 사용 권한 주기 AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.acaroom.themovieapp">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
...

이제 드디어 실행을 하고 임의로 생성한 데이터와 이미지가 잘 나타나는지 확인합니다!


첫 로딩에서 아이템 보이기


스크롤 하면 10개의 아이템과 마지막 로딩 아이템을 볼 수 있다.

이렇게 해서 3단계의 구성이 완성 되었습니다! 다음 글에서는 실제 서비스를 연결해 자료를 가져오기 위해 네트워크 연결 및 서비스 처리 방법에 대해 살펴봅니다.

 

 

Note:이 글은 Do it! 코틀린 프로젝트 연재로 작성하고 있습니다!

youngdeok's picture

Language

Get in touch with us

"If you would thoroughly know anything, teach it to other."
- Tryon Edwards -