ListView와 ViewHolder Pattern

- 8 mins

Prerequisites

ListView는 현재 RecyclerView로 대체되어 deprecated된 뷰 컨테이너(뷰 그룹)입니다. 하지만 ViewHolder pattern의 내부 원리를 이해하기 위해서는 ListView를 사용하여 CustomAdapter를 구현하는 게 좋을 것이라 판단하여 포스팅하게 됐습니다.

예제 실행 화면입니다. 이해를 돕기 위해서 첨부하겠습니다. 1556460781713

ListView

ListView는 여러개의 View들을 담는 뷰 컨테이너입니다.

1556462318133

위 그림에서 보이듯, 스크린 사이즈에는 제약이 있기에 모든 데이터를 보여주지는 못합니다. 덧붙여, 퍼포먼스를 희생해가며 보이지 않는 모든 데이터를 한번에 패치하여 ListView가 가지고 있을 필요도 없습니다.

때문에 ListView는 내부적으로 스크린에 보여지는 View들만을 생성하고 스크롤링될 때마다 어댑터의 getView 메소드를 통해 새로운 뷰를 불러옵니다.

* 예제에서는 화면에 보이는 Layout View들을 ListView에 담습니다. Layout View는 위 그림에서 하나의 View를 의미합니다. 예제에서 Layout View는 ImageView와 두 개의 TextView로 이루어져 있습니다. Layout View도 View이기에 의미에 혼선이 있을 수 있으므로, 앞으로 Layout View를 Layout 객체라고 표현하겠습니다.

굵은 글씨로 된 작업이 어댑터의 getView가 하는 작업들이고 이를 그림으로 표현하면 아래와 같습니다.

1556460788238

제가 짠 어댑터의 코드는 아래와 같습니다.

CustomAdapter.kt

class CustomAdapter (private val ctx: Context) : BaseAdapter() {

    override fun getCount(): Int = initData().size

    override fun getItem(p0: Int): Any? = null

    override fun getItemId(p0: Int): Long = 0
    
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? {
        var view = convertView
        if (view == null) {
            view = LayoutInflater.from(ctx).inflate(R.layout.row, null)
        }
        var flag = view?.findViewById<ImageView>(R.id.imageView)
        var nation = view?.findViewById<TextView>(R.id.textView2)
        var capital = view?.findViewById<TextView>(R.id.textView3)

        val dataArray = initData()
        dataArray[position].let {
            flag?.setImageResource(it["flag"] as Int)
            nation?.text = it["nation"] as String
            capital?.text = it["capital"] as String
        }

        return view
    }

    // initData는 단순히 데이터를 초기화를 위한 메소드입니다.
    private fun initData(): ArrayList<HashMap<String, Any>> {
        val flags = intArrayOf (
            R.drawable.imgflag1,
            R.drawable.imgflag2,
            R.drawable.imgflag3,
            R.drawable.imgflag4,
            R.drawable.imgflag5,
            R.drawable.imgflag6,
            R.drawable.imgflag7,
            R.drawable.imgflag8
        )

        val nations = arrayOf("토고", "프랑스", "스위스", "스페인", "일본", "독일", "브라질", "대한민국")
        val capitals = arrayOf("로메", "파리", "베른", "마드리드", "도쿄", "베를린", "브라질리아", "서울")

        val list = ArrayList<HashMap<String, Any>>()

        val len = flags.size
        var i = 0
        while (i < len) {
            val map = HashMap<String, Any>()
            map["flag"] = flags[i]
            map["nation"] = nations[i]
            map["capital"] = capitals[i]
            list.add(map)
            i++
        }
        return list
    }
}

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val adapter = CustomAdapter(this)
        listView.adapter = adapter
    }
}

문제점

위 코드(getView)는 크게 두 가지 문제점을 가지고 있습니다.

  1. 매 getView 요청마다 불필요한 inflate가 일어날 수 있다.

  2. 매 getView 요청마다 불필요하게 findViewById를 한다.

위 두가지 연산(inflate, findViewById)은 꽤 큰 비용을 수반하는 작업입니다.

따라서 스크롤 시 매끄럽지 못한 사용자 경험을 제공할 수 밖에 없습니다.

ListView with ‘ViewHolder’

그림으로 표현하면 아래와 같은 모양이 됩니다.

1556462319356

이를 코드로 표현하면 아래와 같습니다.

CustomAdapter.kt

class CustomAdapter (private val ctx: Context) : BaseAdapter() {
    data class ViewHolder(var flag: ImageView?, var nation: TextView?, var capital: TextView?)

. . .
. . .
. . .

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? {
        var view = convertView
        if (view == null) {
            view = LayoutInflater.from(ctx).inflate(R.layout.row, null)
            val flag = view?.findViewById<ImageView>(R.id.imageView)
            val nation = view?.findViewById<TextView>(R.id.textView2)
            val capital = view?.findViewById<TextView>(R.id.textView3)

            val dataArray = initData()
            dataArray[position].let {
                flag?.setImageResource(it["flag"] as Int)
                nation?.text = it["nation"] as String
                capital?.text = it["capital"] as String
            }

            val holder = ViewHolder(flag, nation, capital)
            view.tag = holder // layout 객체에 holder를 바인딩
        } else {
            val holder = view.tag as ViewHolder
            val dataArray = initData()
            dataArray[position].let {
                with(holder) {
                    flag?.setImageResource(it["flag"] as Int)
                    nation?.text = it["nation"] as String
                    capital?.text = it["capital"] as String
                }
            }
        }
        return view
    }

. . .
. . .
. . .
rss facebook twitter github gitlab youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora quora