/ KOTLIN, ANDROID EXTENSIONS, RECYCLERVIEW

Kotlin Android Extensions - 리사이클러의 뷰홀더에서 올바르게 사용하는 방법

중요: 2021년 중으로 코틀린 안드로이드 익스텐션 플러그인 지원이 중단될 예정이며, 이 시점 이후부터는 익스텐션을 더이상 사용할 수 없습니다.

아래 글에 소개된 기능을 사용하고 싶으신 분들은 안드로이드 뷰 바인딩 (Android View Binding)을 대신 사용하실 수 있습니다. 자세한 사용법은 이 포스트를 참고하세요.


코틀린 안드로이드 익스텐션(이하 ‘안드로이드 익스텐션’)은 코틀린으로 안드로이드 앱을 개발하는 분들에게 매우 유용한 플러그인입니다.

하지만, 이를 리사이클러뷰(RecyclerView)의 뷰홀더(ViewHolder)에 사용할 때 주의하지 않으면 자칫 애플리케이션의 성능을 저하시킬 수 있습니다.

안드로이드 익스텐션의 동작 원리

안드로이드 익스텐션은 Activity, Fragment, View 클래스를 혹은 이들 클래스를 상속한 클래스에서 사용할 수 있으며, 코틀린 코드에서 XML 레이아웃에 정의된 뷰의 인스턴스에 바로 접근할 수 있도록 합니다.

코틀린 코드에서 뷰 인스턴스에 접근할 수 있도록 지원하기 위해 안드로이드 익스텐션은 클래스 내에 뷰 ID 이름으로 된 가상의 프로퍼티를 생성하며, 이렇게 코틀린 코드 외부의 요소와의 조합을 통해 만들어진 프로퍼티를 ‘합성 프로퍼티(synthetic property)’라 부릅니다.

다음은 안드로이드 익스텐션을 사용하는 간단한 예를 보여줍니다. 다음은 activity_main.xml 레이아웃을 컨텐츠 뷰로 사용하는 MainActivity에서 activity_main.xml 레이아웃 내 뷰인 tvMessage의 인스턴스에 접근하는 코드입니다.

[activity_main.xml]

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

    <TextView
        android:id="@+id/tvActivityMainMessage"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginLeft="12dp"
        android:layout_marginRight="12dp"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium" />

</FrameLayout>

[MainActivity.kt]

class MainActivity : AppCompatActivity() {

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

        // 안드로이드 익스텐션은 뷰 ID를 기반으로 합성 프로퍼티를 만들어줍니다.
        // 합성 프로퍼티인 tvActivityMainMessage를 사용하여 해당 뷰의 인스턴스에 접근합니다.
        tvActivityMainMessage.text = "Hello, Kotlin!"
    }
}

안드로이드 익스텐션에서 생성해준 합성 프로퍼티를 통해 뷰의 인스턴스에 접근하면, 내부적으로는 findViewById() 메서드를 통해 해당 뷰의 인스턴스를 반환합니다.

findViewById() 메서드는 호출시 드는 비용이 큽니다. 때문에 매번 findViewById() 메서드를 통해 뷰의 인스턴스에 접근한다면 성능에 좋지 않은 영향을 줍니다. 이를 방지하기 위해, 안드로이드 익스텐션은 클래스 내부에 캐시를 추가하여 뷰의 인스턴스를 재활용 할 수 있도록 합니다.

이러한 동작은 안드로이드 익스텐션을 사용한 코틀린 코드를 자바 코드로 변환한 결과를 통해 쉽게 확인할 수 있습니다. 앞의 MainActivity 클래스는 다음과 같은 형태의 자바 코드로 변환됩니다.

public final class MainActivity extends AppCompatActivity {

   // 뷰 인스턴스를 저장하는 캐시입니다.
   private HashMap _$_findViewCache;

   protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      this.setContentView(2131361818);

      // 캐시에 저장된 뷰 인스턴스를 꺼내 쓰는 코드로 변환됩니다.
      ((TextView)this._$_findCachedViewById(id.tvActivityMainMessage)).setText((CharSequence)"Hello, Kotlin!");
   }

   // 캐시에 저장된 뷰 인스턴스를 반환합니다.
   public View _$_findCachedViewById(int var1) {
      if(this._$_findViewCache == null) {
         this._$_findViewCache = new HashMap();
      }

      View var2 = (View)this._$_findViewCache.get(Integer.valueOf(var1));

      // 캐시에 저장된 뷰 인스턴스가 없다면
      // findViewById() 메서드를 사용하여 인스턴스를 받아와 캐시에 저장합니다.
      if(var2 == null) {
         var2 = this.findViewById(var1);
         this._$_findViewCache.put(Integer.valueOf(var1), var2);
      }

      return var2;
   }

   public void _$_clearFindViewByIdCache() {
      if(this._$_findViewCache != null) {
         this._$_findViewCache.clear();
      }
   }
}

리사이클러뷰 뷰홀더에서 안드로이드 익스텐션 사용하기

리사이클러뷰는 항목을 화면에 표시하기 위해 뷰홀더를 사용합니다. 이 때, 뷰홀더에 표시해야 하는 값을 지정할 때 안드로이드 익스텐션을 사용하면 뷰홀더에서 표시하는 뷰들의 인스턴스를 일일이 선언할 필요가 없어 매우 편리합니다.

뷰홀더에서 안드로이드 익스텐션을 사용하는 다양한 방법을 예제를 통해 알아보겠습니다. 에제에서 사용할 뷰홀더의 레이아웃은 다음과 같이 ImaveViewTextView로 구성되어 있습니다.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="72dp"
    android:background="?attr/selectableItemBackground"
    android:paddingLeft="12dp"
    android:paddingRight="12dp">

    <ImageView
        android:id="@+id/ivItemRepositoryProfile"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:layout_centerVertical="true" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_centerVertical="true"
        android:layout_marginLeft="12dp"
        android:layout_marginStart="12dp"
        android:layout_toEndOf="@+id/ivItemRepositoryProfile"
        android:layout_toRightOf="@+id/ivItemRepositoryProfile"
        android:orientation="vertical">

        <TextView
            android:id="@+id/tvItemRepositoryName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:maxLines="1"
            android:textAppearance="@style/TextAppearance.AppCompat.Medium" />

        <TextView
            android:id="@+id/tvItemRepositoryLanguage"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textAppearance="@style/TextAppearance.AppCompat.Small" />

    </LinearLayout>

</RelativeLayout>

뷰홀더 내부에서 직접 사용하기

안드로이드 익스텐션은 Activity, Fragment, View의 인스턴스를 통해 뷰의 인스턴스에 접근하는 방법도 지원합니다.

따라서, 다음과 같이 ViewHolder.itemView 인스턴스를 통해 뷰 인스턴스에 접근하도록 코드를 작성할 수 있습니다.

class SearchAdapter : RecyclerView.Adapter<SearchAdapter.RepositoryHolder>() {

    private var items: MutableList<GithubRepo> = mutableListOf()

    ...

    override fun onBindViewHolder(holder: RepositoryHolder, position: Int) {
        items[position].let { repo ->

            // ViewHolder.itemView 범위 내에서 코드를 수행합니다.
            with(holder.itemView) {

                // 뷰홀더 내 ImageView 인스턴스에 접근합니다.
                GlideApp.with(context)
                        .load(repo.owner.avatarUrl)
                        .placeholder(placeholder)
                        .into(ivItemRepositoryProfile)

                // 뷰홀더 내 TextView 인스턴스에 접근합니다.
                tvItemRepositoryName.text = repo.fullName
                tvItemRepositoryLanguage.text = if (TextUtils.isEmpty(repo.language))
                    context.getText(R.string.no_language_specified)
                else
                    repo.language

                setOnClickListener { listener?.onItemClick(repo) }
            }
        }
    }
    ...
}

하지만, 앞의 코드와 같이 구현하면 애플리케이션 성능에 좋지 않은 영향을 줍니다. 과연 어디가 문제일까요? 코틀린 코드가 자바 코드로 어떻게 변환되는지 살펴보면 그 답을 알 수 있습니다.

다음은 자바 코드로 변환된 SearchAdapter 클래스의 코드 중 onBindViewHolder() 부분을 보여줍니다.

public final class SearchAdapter extends Adapter {
   ...

   public void onBindViewHolder(@NotNull SearchAdapter.RepositoryHolder holder, int position) {
      Intrinsics.checkParameterIsNotNull(holder, "holder");
      Object var3 = this.items.get(position);
      GithubRepo repo = (GithubRepo)var3;
      View var5 = holder.itemView;

      // 캐시를 사용하지 않고 매번 findViewById() 메소드를 사용하여
      // 뷰 인스턴스에 접근하고 있습니다.
      GlideApp.with(var5.getContext())
          .load(repo.getOwner().getAvatarUrl())
          .placeholder((Drawable)this.placeholder)
          .into((ImageView)var5.findViewById(id.ivItemRepositoryProfile));
      ((TextView)var5.findViewById(id.tvItemRepositoryName)).setText((CharSequence)repo.getFullName());
      ((TextView)var5.findViewById(id.tvItemRepositoryLanguage)).setText(TextUtils.isEmpty((CharSequence)repo.getLanguage())?var5.getContext().getText(2131623973):(CharSequence)repo.getLanguage());
      var5.setOnClickListener((OnClickListener)(new SearchAdapter$onBindViewHolder$$inlined$let$lambda$1(repo, this, holder)));
   }

   ...
}

액티비티 내에서 안드로이드 익스텐션을 사용할 때는 캐시를 사용하여 findViewById() 메서드의 호출을 최소화 했습니다. 하지만, 앞의 코드와 같이 View의 인스턴스를 통해 뷰 인스턴스에 접근하면 캐시 객체를 저장할 공간이 없어 항상 findViewById() 메서드를 사용하는 코드로 변환됩니다.

이렇게 되면 onBindViewHolder() 메서드가 호출될 때마다 매번 findViewById() 메서드를 호출하게 되므로 성능이 떨어지며, 데이터의 수가 증가할수록 그 영향은 더욱 커집니다.

그렇다면, 안드로이드 익스텐션을 어떻게 사용해야 이런 문제를 방지할 수 있을까요?

뷰홀더 내부에 각 뷰를 위한 프로퍼티를 추가하는 방법

뷰홀더 클래스 내부에 각 뷰의 인스턴스를 저장할 수 있는 프로퍼티를 추가하고, 생성자에서 각 뷰의 인스턴스를 일괄로 할당하도록 하면 findViewById() 함수를 한 번만 호출하게 됩니다. 다음은 각 뷰를 위한 프로퍼티를 별도로 갖는 뷰홀더 클래스의 코드입니다.

class RepositoryHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
        LayoutInflater.from(parent.context)
                .inflate(R.layout.item_repository, parent, false)) {

    // 각 뷰의 인스턴스를 저장하는 프로퍼티를 추가합니다.
    // 생성자가 호출되는 시점에 뷰의 인스턴스가 할당됩니다.
    val ivProfile = itemView.ivItemRepositoryProfile

    val tvName = itemView.tvItemRepositoryName

    val tvLanguage = itemView.tvItemRepositoryLanguage
}

뷰홀더 클래스의 코드는 다음과 같은 자바 코드로 변환됩니다. 뷰 인스턴스를 저장할 수 있는 필드와 Getter 메서드가 추가된 것을 확인할 수 있습니다.

public static final class RepositoryHolder extends ViewHolder {
  private final ImageView ivProfile;
  private final TextView tvName;
  private final TextView tvLanguage;

  public final ImageView getIvProfile() {
     return this.ivProfile;
  }

  public final TextView getTvName() {
     return this.tvName;
  }

  public final TextView getTvLanguage() {
     return this.tvLanguage;
  }

  public RepositoryHolder(@NotNull ViewGroup parent) {
     Intrinsics.checkParameterIsNotNull(parent, "parent");
     super(LayoutInflater.from(parent.getContext()).inflate(2131361836, parent, false));
     this.ivProfile = (ImageView)this.itemView.findViewById(id.ivItemRepositoryProfile);
     this.tvName = (TextView)this.itemView.findViewById(id.tvItemRepositoryName);
     this.tvLanguage = (TextView)this.itemView.findViewById(id.tvItemRepositoryLanguage);
  }
}

onBindViewHolder() 메서드의 구현부는 다음과 같이 변경됩니다. 뷰홀더에 선언한 프로퍼티를 직접 사용하도록 바뀌었습니다.

class SearchAdapter : RecyclerView.Adapter<SearchAdapter.RepositoryHolder>() {

    ...

    override fun onBindViewHolder(holder: RepositoryHolder, position: Int) {
        items[position].let { repo ->
            with(holder) {

                // RepositoryHolder.ivProfile 프로퍼티를 사용합니다.
                GlideApp.with(itemView.context)
                        .load(repo.owner.avatarUrl)
                        .placeholder(placeholder)
                        .into(ivProfile)

                // RepositoryHolder.tvName 프로퍼티를 사용합니다.
                tvName.text = repo.fullName

                // RepositoryHolder.tvLanguage 프로퍼티를 사용합니다.
                tvLanguage.text = if (TextUtils.isEmpty(repo.language))
                    itemView.context.getText(R.string.no_language_specified)
                else
                    repo.language

                itemView.setOnClickListener { listener?.onItemClick(repo) }
            }
        }
    }
    ...
}

이 부분은 다음과 같은 자바 코드로 변환됩니다. findViewById() 대신 생성자 호출 시점에서 할당된 뷰 인스턴스를 받아 사용합니다.

public final class SearchAdapter extends Adapter {

   ...

   public void onBindViewHolder(@NotNull SearchAdapter.RepositoryHolder holder, int position) {
      Intrinsics.checkParameterIsNotNull(holder, "holder");
      Object var3 = this.items.get(position);
      GithubRepo repo = (GithubRepo)var3;

      // 뷰홀더 내부에 저장되어 있는 뷰 인스턴스를 사용합니다.
      GlideApp.with(holder.itemView.getContext())
          .load(repo.getOwner().getAvatarUrl()).placeholder((Drawable)this.placeholder)
          .into(holder.getIvProfile());
      holder.getTvName().setText((CharSequence)repo.getFullName());
      holder.getTvLanguage().setText(TextUtils.isEmpty((CharSequence)repo.getLanguage())?holder.itemView.getContext().getText(2131623973):(CharSequence)repo.getLanguage());
      holder.itemView.setOnClickListener((OnClickListener)(new SearchAdapter$onBindViewHolder$$inlined$let$lambda$1(repo, this, holder)));
   }
   ...
}

처음 방법에 비하면 성능은 개선되었으나, 각 뷰의 인스턴스를 담는 프로퍼티를 수동으로 추가해야 하기에 살짝 불편한 감이 있습니다. 하지만 다음으로 소개할 LayoutContainer를 사용하면 이러한 작업 없이 더 편리하게 안드로이드 익스텐션을 사용할 수 있습니다.

LayoutContainer 사용하기

코틀린 1.1.4 버전부터 지원하기 시작한 LayoutContainer 인터페이스를 사용하면 뷰홀더에서 안드로이드 익스텐션을 더욱 편리하게 사용할 수 있습니다.

아직까지 이 기능은 실험실 기능의 일부로 제공됩니다. (1.2 버전 기준) 따라서 이를 활성화하려면 다음과 같이 빌드스크립트를 수정해야 합니다.

...
apply plugin: 'kotlin-android-extensions'

android {
    ...
}

dependencies {
    ...
}

androidExtensions {

    // 실험실 기능을 활성화합니다.
    experimental = true
}

먼저 LayoutContainer 인터페이스를 구현하는 뷰홀더 클래스를 생성합니다. containerView에는 뷰홀더에서 표시할 최상단 뷰의 인스턴스를 할당하며, 생성자를 통해 바로 할당할 수 있도록 구현했습니다.

abstract class AndroidExtensionsViewHolder(override val containerView: View)
        : RecyclerView.ViewHolder(containerView), LayoutContainer

이와 같이 LayoutContainer를 구현하게끔 뷰홀더를 작성하면, 안드로이드 익스텐션은 이 클래스 내부에 뷰 인스턴스를 캐시할 때 필요한 객체를 추가합니다.

다음은 앞에서 작성한 AndroidExtensionsViewHolder 클래스가 자바 코드로 변환된 모습을 보여줍니다. 액티비티에서 안드로이드 익스텐션을 사용했을 떄와 마찬가지로 뷰 인스턴스를 저장하는 캐시 객체가 클래스 내에 추가된 것을 확인할 수 있습니다.

public abstract static class AndroidExtensionsViewHolder extends ViewHolder implements LayoutContainer {
  @NotNull
  private final View containerView;
  private HashMap _$_findViewCache;

  @NotNull
  public View getContainerView() {
     return this.containerView;
  }

  public AndroidExtensionsViewHolder(@NotNull View containerView) {
     Intrinsics.checkParameterIsNotNull(containerView, "containerView");
     super(containerView);
     this.containerView = containerView;
  }

  public View _$_findCachedViewById(int var1) {
     if(this._$_findViewCache == null) {
        this._$_findViewCache = new HashMap();
     }

     View var2 = (View)this._$_findViewCache.get(Integer.valueOf(var1));
     if(var2 == null) {
        View var10000 = this.getContainerView();
        if(var10000 == null) {
           return null;
        }

        var2 = var10000.findViewById(var1);
        this._$_findViewCache.put(Integer.valueOf(var1), var2);
     }

     return var2;
  }

  public void _$_clearFindViewByIdCache() {
     if(this._$_findViewCache != null) {
        this._$_findViewCache.clear();
     }

  }
}

다음으로 뷰홀더가 앞에서 만든 AndroidExtensionsViewHolder를 상속하도록 변경합니다.

class RepositoryHolder(parent: ViewGroup) : AndroidExtensionsViewHolder(
            LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_repository, parent, false))

onBindViewHolder() 함수는 다음과 같이 구현할 수 있습니다. 뷰 ID로 생성된 합성 프로퍼티를 통해 뷰 인스턴스에 접근하는 모습을 확인할 수 있습니다.

class SearchAdapter : RecyclerView.Adapter<SearchAdapter.RepositoryHolder>() {

    ...

    override fun onBindViewHolder(holder: RepositoryHolder, position: Int) {
        items[position].let { repo ->

            with(holder) {

                // 합성 프로퍼티인 ivItemRepositoryProfile로 뷰 인스턴스에 접근합니다.
                GlideApp.with(holder.itemView.context)
                        .load(repo.owner.avatarUrl)
                        .placeholder(placeholder)
                        .into(ivItemRepositoryProfile)

                // 합성 프로퍼티인 tvItemRepositoryName으로 뷰 인스턴스에 접근합니다.
                tvItemRepositoryName.text = repo.fullName

                // 합성 프로퍼티인 tvItemRepositoryLanguage로 뷰 인스턴스에 접근합니다.
                tvItemRepositoryLanguage.text = if (TextUtils.isEmpty(repo.language))
                    containerView.context.getText(R.string.no_language_specified)
                else
                    repo.language

                containerView.setOnClickListener { listener?.onItemClick(repo) }
            }
        }
    }
    ...
}

이 부분은 다음과 같은 자바 코드로 변환됩니다. findViewById()를 직접 호출하지 않고 캐시를 통해 뷰 인스턴스를 가져오는 것을 확인할 수 있습니다.

public final class SearchAdapter extends Adapter {

   ...

   public void onBindViewHolder(@NotNull SearchAdapter.RepositoryHolder holder, int position) {
      Intrinsics.checkParameterIsNotNull(holder, "holder");
      Object var3 = this.items.get(position);
      GithubRepo repo = (GithubRepo)var3;
      View var10000 = holder.itemView;
      Intrinsics.checkExpressionValueIsNotNull(holder.itemView, "holder.itemView");

      // 캐시를 통해 뷰 인스턴스를 받아옵니다.
      GlideApp.with(var10000.getContext())
          .load(repo.getOwner().getAvatarUrl())
          .placeholder((Drawable)this.placeholder)
          .into((ImageView)holder._$_findCachedViewById(id.ivItemRepositoryProfile));
      TextView var9 = (TextView)holder._$_findCachedViewById(id.tvItemRepositoryName);
      Intrinsics.checkExpressionValueIsNotNull(var9, "tvItemRepositoryName");
      var9.setText((CharSequence)repo.getFullName());
      var9 = (TextView)holder._$_findCachedViewById(id.tvItemRepositoryLanguage);
      Intrinsics.checkExpressionValueIsNotNull(var9, "tvItemRepositoryLanguage");
      var9.setText(TextUtils.isEmpty((CharSequence)repo.getLanguage())?holder.getContainerView().getContext().getText(2131623973):(CharSequence)repo.getLanguage());
      holder.getContainerView().setOnClickListener((OnClickListener)(new SearchAdapter$onBindViewHolder$$inlined$let$lambda$1(repo, this, holder)));
   }
   ...
}
kunny

커니

안드로이드와 오픈소스, 코틀린(Kotlin)에 관심이 많습니다. 한국 GDG 안드로이드 운영자 및 GDE 안드로이드로 활동했으며, 현재 구글에서 애드몹 기술 지원을 담당하고 있습니다.

Read More