쾌락코딩

수신 객체 지정 람다 - with와 apply

|

코틀린은 자바에는 없는 수신 객체 지정 람다라는 것을 가지고 있다. 처음 이 용어를 접하면 단번에 무슨 뜻인지 알아차리기 쉽지 않다. with와 apply라는 라이브러리 함수가 수신 객체 지정 람다인데, 말 그대로 람다 함수를 쓸 때 내가 자주 쓰고싶은 객체를 미리 지정해서 사용하는 람다라고 생각 하면 될 것 같다.

예제코드는 대부분 kolin in action에서 제공하는 코드이다.

with

어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다면 좋을 것이다. 먼저 with를 사용하지 않는 일반적인 코드를 보자. without_with 코드에서 result변수가 꽤 많이 나온다. 이 정도 반복은 봐줄만 하지만, 이코드가 훨씬 더 길거나 result를 더 자주 반복해야 했다면 상당히 귀찮은 작업의 반복이 될 수 있다.

이제 with를 사용한 코드를 보자. with1

withwhen이나 if문 처럼 언어가 제공하는 특별한 구문처럼 보이지만, 사실은 파리미터가 2개 있는 함수다. 첫 번째 파라미터는 stringbuilder이고, 두 번째 파라미터는 람다이다.

with함수가 어떻게 구현 되어있는지 살펴보면 이해가 더 쉽다. with2

첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다. 즉, 두 번째 인자로 받은 람다함수의 this가 곧 첫 번째 인자로 받은 객체가 된다고 생각하면 된다.

앞의 alphabet 함수를 더 리팩토링해서 불필요한 stringbuilder 변수를 없앨 수 도 있다. with3

with가 반환하는 값은 람다 코드를 실행한 결과며, 그 결과는 람다 식의 본문에 있는 마지막 식의 값이다. 그러나 때로는 람다의 결과 대신, 람다 함수 안에서 이리 저리 조작을 가한 수신 객체를 리턴 하고 싶은 경우가 있다. 그럴 때는 apply 함수를 사용할 수 있다.

aplly

apply 함수는 with와 거의 같다. 단 한 가지 차이는, 자신에게 전달된 객체(수신 객체)를 반환한다는 점 뿐이다. apply가 어떻게 구현되어있는지 보자. with3

apply가 확장 함수로 정의되어 있는 것을 알 수 있다. apply의 수신 객체가 전달받은 람다의 수신 객체가 되는 것이다. with3 이 함수에서 apply를 실행한 결과는 StringBuilder 객체다. 따라서 그 객체의 toString을 호출해서 String 객체를 얻을 수 있다.

이런 apply 함수는 객체의 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화해야 하는 경우 유용하다. 자바에서 별도의 Builder 객체가 하는 역할을 담당하는 것이다.

자바스크립트의 치명적인 단점이 뭐라고 생각하시나요?

|

언젠가 신입 웹 개발자를 채용하는 최종 면접에서 이런 질문을 받은 기억이있다.

자바스크립트의 치명적인 단점이 하나 있는데, 뭐라고 생각하시나요?

글쎄 치명적인 단점이라… 나름 대기업 CTO분이신데 이렇게 주관적인 질문을 주시네요…?? 지금 돌이켜보면 내가 지금보다도 훨씬 더 부족했던 시기에 보았던 면접이고 공부량도 많지 않던 때라 어처구니 없던 대답을 했던걸로 기억한다.

객체지향 프로그래밍과 함수형 프로그래밍을 모두 지원하다보니 미숙한 개발자에게 혼란을 주는 것이 큰 단점이라고 생각합니다(그래 떨어질만했다).

이제와서 느끼는거지만 함수형과 객체지향을 모두 지원하는건 정말이지 강력한 무기이다. 다만, 이 질문을 받았던 당시의 나는 함수형 프로그래밍이란 것을 정복하고 싶었으나 깊게 공부하기에는 내공이 너무 부족했던지라 학습의 어려움을 하소연하고 온 것이 아니었나 싶다.

면접을 본지 꽤 오래 되었지만, 아직도 가끔 면접관이 원했던 답이 무엇이었을까 생각해보곤 한다. 자바스크립트의 단점이 얼마나 많은데 어떻게 당신의 생각과 취향에 맞는 단점을 골라 말하리. 게다가 어떤 힌트도없는 단 한 문장의 질문만으로..

프론트엔드 개발자로서 자질을 물어보기 위해 했던 질문이라면, 아마 “브라우저에 종속적입니다”라는 대답이 적절했을까? 분명 브라우저에서 돌아가는 자바스크립트는 브라우저에 종속적일 수 밖에 없다. 내 블로그의 “크로스브라우징이란?” 포스팅의 인기가 많은 이유도 자바스크립트가 브라우저에 종속적이기 때문에 생기는 문제가 많기 때문이다. 쉽게 말해 내가 작성한 자바스크립트의 코드가 어떤 브라우저에서 돌아가느냐에 따라 개별적으로 신경써줘야 할 부분이 많다는 것이고, 한참 잘 돌아가는 코드가 어느날 갑자기 브라우저의 정책이나 지원에 따라 이상하게 작동할 수도 있다는 것이다. 분명 단점이다.

혹은, 아주 일반적인 자바스크립트의 단점을 듣고 싶었던 것이라면 어떤 답이 적절했을까? 블록 수준의 스코프를 지원하지 않는 점? 호이스팅? 언제 어떻게 변할지 예측하기 귀찮은 this바인딩? this의 경우는 정말 이해하기 어려운 코드를 만들 수도 있으므로 정말 잘 살펴 사용해야한다. 잘만 사용한다면 좋겟지만, 대부분의 경우 라이브러리나 도구를 개발하는 사람들이 언어의 맥락을 교모히 왜곡시켜 특정한 요건을 구현하는데 사용해오는 정도인 것 같다. 면접관에게 this의 바인딩 문제를 대답했다면 글쎄, 왠지 원하는 답은 아니었을 것 같다.

최근에 떠오르는 치명적인 단점은 자바스크립트가 언어 차원에서 immutable함을 제공해주지 않는 다는 것이다. 함수형 프로그래밍을 지원하는 것이 장점인 자바스크립트인데, 함수형과 땔래야 땔 수 없는 관계인 불변성을 지원하지 않는다니 꽤나 모순적이다. 아니 모순을 넘어서, 함수형이 대세로 떠어로는 요즘같은 때에는 치명적이라고 대답해도 괜찮지 않았을까?

자바스크립트 객체는 너무나도, 너무나도 동적이다. 언제건 속성을 추가, 삭제, 수정할 수 있다. 클래스 밖에서도 언제든 클래스의 속성을 바꿀 수 있다. 함수형 프로그래밍에서 불변성은 정말 중요한 개념인데 자바스크립트는 너무나 쉽게 객체를 변경시킬 수 있다. 클래스와 관련된 다양한 간편 구문이 추가되었지만, 그것과 무관하게 객체의 속성은 언제든지 자유롭게 변경될 수 있다. 기본형 값은 바꿀 수 없지만, 기본형을 가리키는 변수의 상태는 바뀐다.

const라는 키워드만으로는 함수형프로그래밍이 요구하는 수준의 불변성을 실현하기 어렵다. const로 객체를 선언할 경우 객체의 주소값 자체를 변경시킬 순 없어도, 객체 내부의 속성이 변경되는 것 까지는 막지 못하기 때문이다. 즉 객체의 속성값에 어떤 값이 추가되든 수정되든 삭제되든 지지고 볶든 삶아 먹든 기존 객체의 주소값은 동일한데, 그것까지 const가 캐치할 순 없다.

물론 자바스크립트 개발자들이 이런 현상을 두고도 가만히 있을리가 없다. Object.assign, Object.freeze등 불변성을 지키기위한 언어적 측면의 노력이 있지만 커다란 시스템에서 깊은 깊이까지의 불변성을 보장해 주지 못한다. 그래서 우리는 immutable.js같은 라이브러리의 도움을 받아야만 한다.

면접관이 원햇던 대답이 궁금하긴 하다. 가끔은 내게 질문을 던졌던 면접관을 찾아가서 “당신이 원했던 대답이 이 중에 있습니까?”라고 물어고 싶기도 하다. 그러나 그 분께서 원하는 대답을 들은들 무슨 의미가 있나 싶다. 분명 그것 말고도 더 많은 단점이 존재하고 사람마다 느끼는 단점의 온도차가 심한 것을.

내가 써놓은 자바스크립트의 단점들 뿐만 아니라 더 많고 많은 단점들 중에서, 운좋게 면접관이 생각했던 단점 한 개를 찍어 맞췄다면 합격에 가까워 졌을 수도 있었겠다라는 생각을 하니 면접도 참 운이 중요하겠다는 생각이든다. 이야기가 왜 여기까지 흘러오고 있는지 모르겠지만 면접에서 떨어진 모든 숨은 보석들이 너무 낙심하거나 자책하지 않았으면 좋겠다. 면접은 정말 운이 끼치는 영향이 생각보다 크기 때문이다.

Attempt to invoke virtual method 'boolean androidx.recyclerview.widget.RecyclerView$ViewHolder.shouldIgnore()' on a null object reference 에러

|

증상

안드로이드에서 recyclerView를 사용하던 중 아래와 같은 에러가 날 때가 있다.

java.lang.NullPointerException: Attempt to invoke virtual method 'boolean androidx.recyclerview.widget.RecyclerView$ViewHolder.shouldIgnore()' on a null object reference
        at androidx.recyclerview.widget.RecyclerView.findMinMaxChildLayoutPositions(RecyclerView.java:4101)
        at androidx.recyclerview.widget.RecyclerView.dispatchLayoutStep1(RecyclerView.java:3835)
        at androidx.recyclerview.widget.RecyclerView.onMeasure(RecyclerView.java:3330)
        at android.view.View.measure(View.java:23169)
        at androidx.constraintlayout.widget.ConstraintLayout.internalMeasureChildren(ConstraintLayout.java:1227)
        at androidx.constraintlayout.widget.ConstraintLayout.onMeasure(ConstraintLayout.java:1572)
        at android.view.View.measure(View.java:23169)
        at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:6749)
        at android.widget.FrameLayout.onMeasure(FrameLayout.java:185)
        at android.view.View.measure(View.java:23169)
        at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:6749)
        at android.widget.FrameLayout.onMeasure(FrameLayout.java:185)
        at android.view.View.measure(View.java:23169)
        at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:6749)
        at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1535)
        at android.widget.LinearLayout.measureVertical(LinearLayout.java:825)
        ...

해결

구글링 해보니 원인이 상당히 다양한것 같다. 불행히도 나에게 맞는 해결법은 없었는데, 나의 경우 recycler뷰에 addView를 해주는 코드 때문에 에러가 났다. 기존에 사용하던 일반 스크롤 뷰를 리사이클러 뷰로 바꾸는 과정에서 지우지 않은 코드가 있었던 것이다.

아래 코드와 같이 recyclerView에 addView를 해주면 Attempt to invoke virtual method 'boolean androidx.recyclerview.widget.RecyclerView$ViewHolder.shouldIgnore()' on a null object reference 에러가난다. recyclerView에는 꼭 어댑터를 연결해서 데이터를 바인딩해주자.

myRecyclerView.addView(userImageView)

멀티 뷰 타입 RecyclerView(리사이클러뷰) 만들어보기 (feat. Kotlin)

|

이전 포스트에서는 아주 기본적인 리사이클러뷰의 사용법에 대해서 다뤄보았다. 따라서 뷰의 모양이 끝까지 일정한 채팅방 목록 리스트같은 경우 쉽게 구현할 수 있게 되었다.

이제는 여러개의 뷰 타입, 즉 리사이클러뷰 내에서 단 한 개의 뷰 형태만을 쭉쭉 랜더링하는게 아니라 다수의 뷰 형태를 가지는 객체들을 랜더링하는 방법을 알아보자. 말이 어렵다면… 아래 이미지와 같은 샘플 앱을 하나의 리사이클러뷰로 만들어볼 것이다. 결과물미리보기

아래로 스크롤을 내리면 카테고리 2번도 있는데…(풀 스크린 찍는 방법을 모르겠다ㅠ)

아무튼 이번 예제에서는 총 3개의 다른 뷰 형태를 가지는 리사이클러 뷰를 다뤄볼 것이다. 세가지 다른 뷰 형태란 아래와 같다.

  • 단순 카테고리 명을 표시하는, 텍스트만 있는 뷰
  • 글 아래에 이미지가 크게 박힌 뷰
  • 사진 오른쪽에 사진 제목과 컨텐츠 내용이 담긴 뷰

사전 준비

recyclerView와 cardView, material디자인을 설치할 것이다. 아래코드를 그냥 복사 붙여넣기해도 되지만, 시간이 지남에따라 버전업 되는 부분은 알아서 할 수 있으리라 믿는다.

    implementation 'androidx.recyclerview:recyclerview:1.0.0'
    implementation 'com.google.android.material:material:1.0.0'
    implementation 'androidx.cardview:cardview:1.0.0'

또한, 코드중에 snow라는 이름의 이미지 파일을 사용하는데, 인터넷에 아무 사진이나 다운받아서 프로젝트에 넣어도 무방하고, 깃헙의 예제코드를 그대로 다운받아서 실행해봐도 무방하다.

프로젝트 구조

프로젝트구조

layout

우선 가장 먼저 리사이클러뷰를 담을 메인 layout은 아래와 같다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <androidx.appcompat.widget.Toolbar
            android:layout_width="match_parent"
            android:layout_height="50dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:id="@+id/toolbar">
        <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="Toolbar"
                android:gravity="center"
        />
    </androidx.appcompat.widget.Toolbar>

    <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintTop_toBottomOf="@id/toolbar"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
    />
</androidx.constraintlayout.widget.ConstraintLayout>

toolbar를 사용하기 때문에 style파일로 들어가서 아래와 같이 NoActionBar로 수정해주어야한다.

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

</resources>

위에서 언급한 3가지 뷰 타입에 대해서 각각의 레이아웃을 살펴보자.

카테고리명을 담당할 뷰 레이아웃 (text_type.xml)

<LinearLayout
        xmlns:card_view="http://schemas.android.com/apk/res-auto"
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/card_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:layout_marginStart="24dp"
>
    <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:textAlignment="textStart"
            android:layout_height="wrap_content"
            android:textColor="@color/colorTitle"
            android:textSize="20sp"
    />
</LinearLayout>

제목 아래에 사진이 크게 박혀있는 뷰 레이아웃 (image_type)

<com.google.android.material.card.MaterialCardView
        xmlns:card_view="http://schemas.android.com/apk/res-auto"
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/card_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        card_view:cardElevation="10dp">
    <LinearLayout
            android:layout_width="match_parent"
            android:orientation="vertical"
            android:layout_height="wrap_content">
        <TextView
                android:id="@+id/title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:padding="10dp"
        />
        <ImageView
                android:id="@+id/background"
                android:layout_width="match_parent"
                android:layout_height="150dp"
                android:scaleType="centerCrop"
                android:src="@drawable/snow"
        />
    </LinearLayout>
</com.google.android.material.card.MaterialCardView>

진 오른쪽에 사진 제목과 컨텐츠 내용이 담긴 뷰 레이아웃 (image_type2.xml)

<com.google.android.material.card.MaterialCardView
        xmlns:card_view="http://schemas.android.com/apk/res-auto"
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools" android:id="@+id/card_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        card_view:cardElevation="10dp">

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

        <ImageView
                android:layout_width="181dp"
                card_view:srcCompat="@drawable/snow"
                android:id="@+id/imageView2"
                card_view:layout_constraintTop_toTopOf="parent" card_view:layout_constraintStart_toStartOf="parent"
                card_view:layout_constraintBottom_toBottomOf="parent"
                card_view:layout_constraintVertical_bias="0.0" android:layout_height="125dp"/>
        <TextView
                android:text="제목"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" card_view:layout_constraintTop_toTopOf="parent"
                android:id="@+id/titleView" card_view:layout_constraintStart_toEndOf="@+id/imageView2"
                android:layout_marginStart="8dp" card_view:layout_constraintEnd_toEndOf="parent"
                android:layout_marginEnd="8dp" card_view:layout_constraintHorizontal_bias="0.524"
                android:layout_marginTop="28dp" android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
        />
        <TextView
                android:text="내용"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                card_view:layout_constraintBottom_toBottomOf="parent"
                android:id="@+id/contentView" card_view:layout_constraintStart_toEndOf="@+id/imageView2"
                android:layout_marginStart="8dp" card_view:layout_constraintEnd_toEndOf="parent"
                android:layout_marginEnd="8dp" android:layout_marginTop="8dp"
                card_view:layout_constraintTop_toBottomOf="@+id/titleView"
                card_view:layout_constraintHorizontal_bias="0.512" card_view:layout_constraintVertical_bias="0.102"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

프로젝트에 필요한 세가지 뷰 레이아웃 작성은 모두 끝났다. 이제 이전 포스트에서도 그랬듯이 데이터를 만들고, 어댑터를 만들어주고, 레이아웃 매니저만 정해주면 끝난다.

Kotlin Code

model

Model.kt 클래스는 랜더링 하고 싶은 데이터를 가지고 있을 클래스이다.

data class Model(val type: Int, val text: String, val data: Int, val contentString: String?) {
    companion object {
        const val TEXT_TYPE = 0
        const val IMAGE_TYPE = 1
        const val IMAGE_TYPE_2 = 2
    }
}
  • 첫 번째 인자 값 : 우리가 만든 3가지 형태의 뷰들 중, 어떤 형태의 뷰인지 Int값으로 넘겨줄 것이다. 그 Int은 ViewTypeEnum 을 사용한다.
  • 두 번째 인자 값 : 텍스트를 입력받을 파라미터이다. 텍스트 하나만 랜더링 하는 뷰에서는 그 텍스트를, 텍스트1개 이미지1개인 뷰에서 텍스트를, 텍스트2 이미지1개인 뷰에서는 제목 부분을 담당할 String이다.
  • 세 번째 인자 값 : 이미지가 필요한 뷰라면 이미지를 넣어줄 파라미터.
  • 네 번째 인자 값 : 텍스트2 이미지1개인 image_type1.xml 뷰에서 제목 아래의 컨텐츠 부분의 값을 담당할 String이다.

MainActivity

MainActivity이전 포스트와 동일하게 어댑터와 레이아웃 매니저를 설정해주는 부분이다. 더불어 데이터 리스트에 값을 만들어 어댑터에 넣어줄 것이다.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(toolbar)

        val list = listOf(
            Model(Model.TEXT_TYPE, "카테고리 1번!", 0, null)
            Model(Model.IMAGE_TYPE, "텍스트뷰 아래에 이미지가 있는 뷰타입.", R.drawable.snow, null)
            Model(Model.IMAGE_TYPE_2, "안녕, 제목부분이 될거야", R.drawable.snow, "내용부분!")
            Model(Model.IMAGE_TYPE, "다시 한 번 텍스트 옆에 이미지가 있는 뷰타입", R.drawable.snow, null)
            Model(Model.IMAGE_TYPE_2, "제목2!!", R.drawable.snow, "사진에 대한 설명?")

            Model(Model.TEXT_TYPE, "카테고리 2번!", 0, null)
            Model(Model.IMAGE_TYPE, "새로운 카테고리 시작!.", R.drawable.snow, null)
            Model(Model.IMAGE_TYPE, "다음생엔 울창한 숲의 이름모를 나무로 태어나 평화로이 살다가 누군가의 유서가 되고 싶다.", R.drawable.snow, null)
            Model(Model.IMAGE_TYPE_2, "제목부분.", R.drawable.snow, "내용부분")
        )

        val adpater = MultiViewTypeAdapter(list)
        recycler_view.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false)
        recycler_view.adapter = adpater
    }
}

대망의 MultiViewTypeAdapter

어쩌면 이전 포스트를 보던 중

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
    val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
    Log.d("tag1" , "onCreateViewHolder")
    return MyViewHolder(view)
}

이부분의 viewType인자가 무엇일까.

viewType변수명에서 느낄 수 있듯이 viewType이 구분되어 들어오는 값이다. 이 viewType은 어디서 넘어올까?

onCreateViewHolder가 호출되기 전, getItemViewType(position: Int): Int함수가 먼저 호출되어 리턴 값이 넘겨지는 것이다. 따라서 우리는 이 함수에서 적절히 뷰타입을 구분하여 리턴해주면 된다. 뷰타입을 구분하는 방법이야 여러가지가 있겠지만, 우리는 처음 Model객체를 생성할 때 부터 첫 번째 인자로 type 값을 구분해서 넣어줬으므로 그대로 넘겨주면 된다.

class MultiViewTypeAdapter(private val list: List<Model>) :
    RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    // getItemViewType의 리턴값 Int가 viewType으로 넘어온다.
    // viewType으로 넘어오는 값에 따라 viewHolder를 알맞게 처리해주면 된다.
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val view: View?
        return when (viewType) {
            Model.TEXT_TYPE -> {
                view = LayoutInflater.from(parent.context).inflate(R.layout.text_type, parent, false)
                TextTypeViewHolder(view)
            }
            Model.IMAGE_TYPE -> {
                view = LayoutInflater.from(parent.context).inflate(R.layout.image_type, parent, false)
                ImageTypeViewHolder(view)
            }
            Model.IMAGE_TYPE_2 -> {
                view = LayoutInflater.from(parent.context).inflate(R.layout.image_type2, parent, false)
                ImageTypeView2Holder(view)
            }
            else -> throw RuntimeException("알 수 없는 뷰 타입 에러")
        }
    }

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

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        Log.d("MultiViewTypeAdapter", "Hi, onBindViewHolder")
        val obj = list[position]
        when (obj.type) {
            Model.TEXT_TYPE -> (holder as TextTypeViewHolder).txtType.text = obj.text
            Model.IMAGE_TYPE -> {
                (holder as ImageTypeViewHolder).title.text = obj.text
                holder.image.setImageResource(obj.data)
            }
            Model.IMAGE_TYPE_2 -> {
                (holder as ImageTypeView2Holder).title.text = obj.text
                holder.content.text = obj.contentString
                holder.image.setImageResource(obj.data)
            }
        }
    }

    // 여기서 받는 position은 데이터의 index다.
    override fun getItemViewType(position: Int): Int {
        Log.d("MultiViewTypeAdapter", "Hi, getItemViewType")
        return list[position].type
    }

    inner class TextTypeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val txtType: TextView = itemView.findViewById(R.id.title)
    }

    inner class ImageTypeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val title: TextView = itemView.findViewById(R.id.title)
        val image: ImageView = itemView.findViewById(R.id.background)
    }

    inner class ImageTypeView2Holder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val title: TextView = itemView.findViewById(R.id.titleView)
        val content: TextView = itemView.findViewById(R.id.contentView)
        val image: ImageView = itemView.findViewById(R.id.imageView2)
    }
}

결국 뷰홀더를 여러개 만들고, onCreateViewHolder에서 데이터에 따라 그에 맞는 뷰홀더를 생성해주는게 전부이다.

사실 뷰 타입을 Modelcompaion object로, 또 숫자로 관리한다는 게 좋진 않지만 여러개의 뷰 타입을 다루는 리사이클러 뷰를 공부하는데는 지장이 없다. 더 좋은 코드로 리팩토링 하고자 한다면, github에서 소스를 다운받은 후, Android RecyclerView Multiple Layout Sealed Class 포스팅 - 영문이나, Kotlin Sealed class를 사용한 UI 상태 관리 포스팅을 읽고 스스로 리팩토링 해보는 것도 좋은 방법이 될 것 같다.

RecyclerView(리사이클러뷰)의 원리와 사용법(feat. Kotlin)

|

카카오톡의 채팅 대화방, pinterest 앱의 수 많은 리스트 데이터들을 효율적으로 렌더링 하기 위해서 안드로이드에서는 어떤 방법을 사용할까? 기본적으로 제공되는 ListView가 있지만, 최근들어 구글에서 공식적으로 제공하고 있는 Recycler View를 사용해 이들을 구현한다.

Recycler View는 ListView가 할 수 있는 모든 일을 할 뿐만 아니라 커스터마이징도 쉬우며, 효율성도 더 좋다. 그럼 Recycler view가 왜 더 좋은지 알아보고, 이를 활용해 간단히 리스트 데이터들을 렌더링 하는 방법을 알아보자.

Recycler view의 재활용성

recycler_view1 개인적으로 위의 그림이 RecyclerView를 이해하는 데 도움이 많이 되었다.

그림을 이해하자면, ListView와는 다르게 RecyclerView는 이름에서 알 수 있듯이 재활용이 가능한 뷰이다. 무엇을 재활용 할까? 오른 쪽 그림을 보자. 파란색 라인 한 개가 채팅방 리스트 한 개라고 가정하자. 전체 채팅방 리스트는 100개가 훌쩍 넘을 수가 있다. 그러나 정작 화면에 보여지는 채팅방 목록은 한 번에 10개 조차 되지 않는다.

매번 사용자가 아래로 스크롤 할 때 마다 맨 위에 위치한 뷰 객체가 새로 삭제되고, 아랫 부분에서 새로 나타날 채팅방 뷰 객체를 새로 생성하면 결국 100개의 뷰 객체가 삭제되고 생성되는 것일 뿐만 아니라, 스크롤을 위아래로 왔다 갔다 하면 수 백개의 뷰 객체가 새로 생성되고 삭제됨을 반복한다.

리사이클러 뷰는 사용자가 아래로 스크롤 한다고 가정했을 때, 맨 위에 존재해서 이제 곧 사라질 뷰 객체를 삭제 하지않고 아랫쪽에서 새로 나타나날 파란색 뷰 위치로 객체를 이동시킨다. 즉 뷰 객체 자체를 재사용 하는 것인데, 중요한 점은 뷰 객체를 재사용 할 뿐이지 뷰 객체가 담고 있는 데이터(채팅방 이름)는 새로 갱신된다는 것이다. 어쨋거나 뷰 객체를 새로 생성하지는 않으므로 효율적인 것이다.

결과적으로 보자면, 맨 처음 화면에 보여질 10개 정도의 뷰 객체만을 만들고, 실제 데이터가 100개든 1000개든 원래 만들어 놓은 10개의 객체만 계속 해서 재사용 하는 것이다.

ViewHolder

위에서 설명 햇듯이, 스크롤을 밑으로 내릴 때, 맨 위에 존재해서 이제 곧 사라질 파란색 뷰 객체는 맨 아래로 이동하여 재활용된다. 즉, 10개의 뷰 객체만 계속해서 위에서 아래로 이동하면서 재사용 되는 것이며, 우리는 딱 10개 정도의 뷰 객체만을 만들어서 가지고 있으면 된다. 10개의 뷰 객체들은 언제든 text라던지 이미지가 바뀔 수 있다(뷰 객체를 재사용 할 뿐이지 재사용 될 때의 데이터는 계속 바껴야 하기 때문이다).

따라서 맨 처음 10개의 뷰객체를 기억하고 있을(홀딩) 객체가 필요한데 이 것이 ViewHolder이다. 나중에 전체 코드를 보겠지만, ViewHolder 코드 부분만 보자면 아래와 같다.

recycler_view1

class MyViewHolder(view: View): RecyclerView.ViewHolder(view) {
    var textField = view.chat_title
}

위 사진의 textView라고 적힌 TextView id 값은 chat_title이다.

우리는 저기 보이는 한 줄의 채팅방 목록의 TextView에 각자 다른 채팅방 이름을 부여할 것이며, 100개의 채팅방 목록을 나열해 볼 것이다. 그러나 100개의 뷰 객체를 하나 하나 모두 만들어 주진 않을 것이며 딱 10개 내외의 MyViewHolder 객체를 만들어서 계속 재사용 해 줄 것이다. MyViewHolder가 textField를 가지고 있음을 기억하자. 스크롤을 내릴 때 마다 이 부분에 데이터만 바꿔주면 뷰 객체는 그대로이면서 데이터만 바뀌게 되는 것이다.

Adapter와 LayoutManager

리사이클러 뷰를 사용하려면 두 가지가 필수적으로 필요하다. AdpaterLayoutManager이다.

Adapter는 100개의 채팅방 제목 이름이 담긴 리스트를 리사이클러 뷰에 바인딩 시켜주기 위한 사전 작업이 이루어 지는 객체이다. 직접 작성해서 리사이클러뷰에 적용시키면 된다.

LayoutManager는 많은 역할을 하지만 간단하게 스크롤을 위아래로 할지, 좌우로 할지를 결정하는 것이라고 생각하자. 실제로는 더 많은 역할을 하지만 자세한건 구글링…ㅎ

이 두가지를 적용하기 전에 100개의 채팅방 제목을 가진 리스트를 생성하고, 리사이클러 뷰에 적용해보자.

// MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_recycler_view)

    val datas = Array(100) {
        "chat $it"
    }

    recycler_view.adapter = MyApdater(datas)
    recycler_view.layoutManager = LinearLayoutManager(this)
}

이제 핵심이 되는 MyAdapter를 구현해보자.

MyAdapter

이 부분은 우선 코드부터 보자.

class MyApdater(private var datas: Array<String>) : RecyclerView.Adapter<MyViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
        Log.d("tag1" , "onCreateViewHolder")
        return MyViewHolder(view)
    }

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

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        Log.d("tag1" , "onBind")
        holder.textField.text = datas[position]
    }
}

class MyViewHolder(view: View): RecyclerView.ViewHolder(view) {
    var textField = view.chat_title
}

MyAdapter의 생성자로 리스트로 뿌려줄 데이터 배열을 받자. 그리고 MyAdapterRecyclerView.Adapter를 상속 받는데, 제네릭 타입으로 ViewHolder를 넣어주어야 한다. 코드에서 넘겨준 MyViewHolder는 맨 아랫줄에 작성되어 있다.

getItemCount

가장 먼저 실행되는 함수는 getItemCount이다. 여기서는 우리가 뿌려줄 데이터의 전체 길이를 리턴하면 된다. 더 깊게 알아볼 필요는 없는 것 같다.

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

onCreateViewHolder

getItemCount다음으로 호출되는 함수는 onCreateViewHolder함수이다. 이름에서 알 수 있듯이 ViewHolder가 생성되는 함수다. 여기서 ViewHolder객체를 만들어 주면 된다.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
    val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
    Log.d("tag1" , "onCreateViewHolder")
    return MyViewHolder(view)
}

위에서 언급했듯이 맨 처음 화면에 보이는 전체 리스트 목록이 딱 10개라면, 위아래 버퍼를 생각해서 13~15개 정도의 뷰 객체가 생성된다. 정확하게 말하자면 뷰 객체를 담고 있는 ViewHolder가 생성되는 것이다. 그래서 onCreateViewHolder함수는 딱 13~15번 정도만 호출되고 더 이상 호출되지 않는다.

return되는 곳에서 MyViewHolder의 생성자에 view 객체를 넘겨주는데, 이 view객체는 아까 사진에서 본 한개의 채팅방 목록이 디자인 되어있는 레이아웃이다. 즉 viewHolder는 그 레이아웃을 인자로 받아서 기억하고 있는 것이다. 이제는 계속해서 재사용되는 뷰 홀더(레이아웃)들에 데이터를 바인딩 해주는 작업만 남았다.

onBindViewHolder

onBindViewHolder함수는 생성된 뷰홀더에 데이터를 바인딩 해주는 함수이다. 이름이 참 직관적이여서 좋다.

예를 들어 데이터가 스크롤 되어서 맨 위에있던 뷰 홀더(레이아웃) 객체가 맨 아래로 이동한다면, 그 레이아웃은 재사용 하되 데이터는 새롭게 바뀔 것이다. 고맙게도 아래에서 새롭게 보여질 데이터의 인덱스 값이 position이라는 이름으로 사용가능하다.

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    Log.d("tag1" , "onBind")
    holder.textField.text = datas[position]
}

즉 아래에서 새롭게 올라오는 데이터가 리스트의 20번째 데이터라면 position으로 20이 들어오는 것이다.

onCreateViewHolderViewHolder를 만들기 위해 13~15번 정도밖에 호출되지 않지만, onBindViewHolder는 스크롤을 해서 데이터 바인딩이 새롭게 필요할 때 마다 호출된다. 스크롤을 무한정 돌린다면, onBindViewHolder도 무한정 호출된다. 무한정 호출된다 하더라도 우리는 딱 13~15개의 뷰 객체만 사용하는 꼴이다.

결과와 로그

결과물은 아래와 같다. recycler_view1 사진 아래로 89개의 데이터가 더 있다. 그러나 코드에서 로그를 찍어 놓아기 때문에 우리는 뷰가 몇 개만 호출되었는지 볼 수 있다. recycler_view1 위에서 예시로 든 숫자들과는 조금 다르지만 모바일 화면과 한 개의 데이터 높이에 따라서 숫자는 얼마든지 달라질 수 있다. 이 앱에서 리스트가 총 12개가 표현이 되므로, 위아래 버퍼를 생각해서 onCreateViewHolder는 딱 17번만 호출된다. 그리고 생성됨과 동시에 데이터 바인딩을 해줘야 하므로 onBind로그가 찍히는 것을 볼 수있다.

첫 화면 이후 스크롤을 아래로 이동시키면 더 이상 onCreateViewHolder는 호출되지 않고 onBind로그만 찍히는 것을 알 수 있다. ViewHolder를 계속 만들지 않고 재사용하기 때문에 데이터만 새롭게 바인딩 해주는 것이다.

읕! 다음에는 한개의 뷰가 아닌 여러개의 뷰 레이아웃이 필요한 상황에서 리사이클러 뷰를 다루는 방법을 다뤄보자.