💡 Intro
영화 선택 미션부터 DataBinding을 사용하기 시작했다. 하지만 지금까지 내가 사용하고 있는 방식은 ViewBinding과 다를 것이 없다.
DataBinding은 xml에 데이터를 넣어 뷰(Activity or Fragment)에서 처리하지 않아도 되는 강력한 기능을 제공하는데 이해가 부족해서 이 부분을 사용하지 못하고 있었던 것이다.
장바구니 step1&2 미션의 리뷰 중 다음과 같은 리뷰가 있었다.
ProductViewHolder와 CartProductViewHolder는 거의 유사한 코드로 작성되어 있습니다.
중복 코드를 개선해보면 어떨까요?
처음에는 중복코드를 줄이기 위한 추상화를 생각했다. 어떤 방식으로 하는게 좋을까 조언을 구하다 산군을 만났고, DataBinding의 바인딩 어댑터를 사용해서 Glide를 적용해 보는것도 좋을 것 같다는 말을 해줬다.
테코톡 주제가 DataBinding이었는데 어디가서 바인딩 어댑터 정도는 사용해봐야 하지 않을까? 하는 생각도 들었고, 좋은 방법인 것 같아 적용해보면서 어떤 이점이 있는지 느껴보기로 했다.
❓ BindingAdapter가 뭐야
사용해보기에 앞서 간단하게 바인딩 어댑터에 대해 알아보자.
바인딩 어댑터는 DataBinding 라이브러리에서 제공하는 기능 중 하나로, xml레이아웃 파일에서 뷰와 데이터를 바인딩하기 위해 사용된다. 바인딩 어댑터를 사용하면 xml파일에서 사용자 정의된 어트리뷰트를 선언하고, 해당 어트리뷰트에 대한 동작을 정의할 수 있다.
바인딩 어댑터는 @BindingAdapter 어노테이션과 정적 메서드를 사용하여 구현된다. 해당 메서드는 DataBinding 라이브러리가 xml파일에서 특정 어트리뷰트를 발견할 때 호출된다. 메서드의 파라미터로 바인딩할 뷰 객체와 해당 어트리뷰트에 설정된 값을 전달받는다. 이후 메서드 내에서 뷰에 대한 동작을 수행하거나 속성 값을 설정할 수 있다.
사용자 정의된 어트리뷰트니, 정적 메서드니 어려운 말들일 수 있지만 사용하는 방법은 아주 간단하니 걱정하지 말자.
❗️ BindingAdapter를 사용해보자
바인딩 어댑터에 대해 알아보았으니 사용하는 방법에 대해 알아보자. 뷰홀더에 이미지를 붙이기 위해 Glide를 사용하는 것을 예시로 들 것이다.
이미지뷰에 Glide를 사용하기 위해서는 다음과 같은 코드를 사용해야 한다.
Glide.with(binding.root.context)
.load(productItem.imageUrl)
.error(R.drawable.ic_launcher_background)
.into(binding.productImage)
별로 안되네~ 라고 생각할 수 있지만, 여러 종류의 뷰홀더를 사용한다면? 4줄이나 되는(줄이려면 줄일 수 있겠지만) 같은 코드를 모든 뷰홀더 클래스에 구현해줘야한다. 결국 코드의 중복이 일어나는 것이다. 이를 바인딩 어댑터를 사용하여 해결할 수 있다.
object BindingAdapter
object GlideBindingAdapter {
@BindingAdapter("app:imageUrl")
@JvmStatic
fun loadImage(imageView: ImageView, url: String) {
Glide.with(imageView.context)
.load(url)
.error(R.drawable.ic_launcher_background)
.into(imageView)
}
}
@BindingAdapter 어노테이션을 사용하고 () 안에 커스텀하게 정의할 어트리뷰트의 이름을 정의하면 된다. 지금은 app:imageUrl 이라고 정의했기 때문에 xml에서 같은 이름으로 설정해줄 수 있다. 만약 @BindingAdapter("imageUrl") 로 정의했다면 imageUrl로 접근할 수 있다.
정의해준 loadImage 메서드는 전달받은 url을 이미지 뷰에 띄우는 역할을 한다.
그리고 두 개의 파라미터를 전달받는데, 첫 번째 파라미터는 어트리뷰트와 연결된 뷰의 유형을 결정하고, 두 번째 파라미터는 지정된 속성의 결합 표현식에서 허용되는 유형을 결정한다. 쉽게 말하면 첫 번째 인자로 어떤 뷰를 사용할 것인지, 두 번째 인자로 어떤 자료형을 줄 것인지 정하면 되는 것이다.
우리는 이제 뷰홀더에 4줄의 코드를 추가하는 대신 레이아웃 파일에서 정의해준 어트리뷰트인 app:imageUrl을 활용할 수 있다.
app: prefix는 바인딩어뎁터에서의 사용을 권장하지 않아요.
왜냐하면, 기본적으로 제공해주는 속성인지 바인딩어뎁터에서 제공해주는 속성인지 모르기 때문이에요.
추가로 남겨보면 위 코드에 대해 리뷰어님이 간단한 코멘트를 위와 같이 남겨주셨고, 어트리뷰트명을 변경했다.
❗️참고❗️
주문 미션으로 들어가면서 페어인 써니의 코드로 진행을 했는데, 나와 바인딩 어댑터를 구현한 방식이 달랐다. 나는 오브젝트 내의 함수로 구현했지만, 써니는 최상위 함수로 두었다. 차이점을 살펴보니 어노테이션이 없었고 왜 그렇지? 하고 찾아보다 다음과 같은 이유임을 알게 되었다.
앞에서 바인딩 어댑터는 @BindingAdapter 어노테이션과 정적 메서드를 사용하여 구현된다고 했다.
코틀린은 패키지 수준 함수를 정적 메서드로 나타낸다. 또한 코틀린은 객체 또는 컴패니언 객체에 정의된 함수에 대한 정적 메서드를 생성할 수 있다. @JvmStatic 어노테이션을 사용하면 컴파일러는 클래스의 정적 메서드와 객체 자체의 인스턴스 메서드를 모두 생성한다. (더 알고 싶다면 이 글을 참고하자.)
그래서 만약 BindingAdapter를 Object로 만드는 경우 함수에 @JvmStatic 어노테이션을 필수로 붙여 정적 메서드로 만들어줘야 하지만, 패키지 수준의 메서드로 만드는 경우 자동으로 정적 메서드로 되기 때문에 @JvmStatic을 붙이지 않아도 되는 것이다.
Layout (item_product.xml)
아이템 하나를 보여주는 뷰홀더의 xml레이아웃은 다음과 같다.
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<variable
name="product"
type="woowacourse.shopping.feature.list.item.ProductView.ProductItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
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">
<ImageView
android:id="@+id/product_image"
android:layout_width="154dp"
android:layout_height="154dp"
app:imageUrl="@{product.imageUrl}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:src="@drawable/ic_launcher_background"/>
<TextView
android:id="@+id/product_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="@dimen/product_main_item_text_size"
android:layout_marginTop="@dimen/margin_small"
android:text="@{product.name}"
app:layout_constraintTop_toBottomOf="@id/product_image"
app:layout_constraintStart_toStartOf="@id/product_image"
app:layout_constraintEnd_toEndOf="@id/product_image"
android:textColor="@color/black"
android:maxLines="1"
android:ellipsize="end"/>
<TextView
android:id="@+id/product_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="@dimen/product_main_item_text_size"
android:text="@{@string/price_format(product.price)}"
app:layout_constraintTop_toBottomOf="@id/product_name"
app:layout_constraintStart_toStartOf="@id/product_image"
app:layout_constraintEnd_toEndOf="@id/product_image"
tools:text="23000원"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
DataBinding을 사용하기 위해 <layout> 태그로 감싸줬고, 미리 정의해둔 Product를 사용하기 위해 <data> 태그안에 정의했다.
ImageView를 보면 app:imageUrl 이라는 어트리뷰트를 사용하고 있고 우리가 앞에서 만든 어트리뷰트다.
더 나아가서..
사용하는 것은 쉽다. 그런데 저렇게만 정의해준다고 어떻게 사용할 수 있는 것일까 궁금하지 않은가? 나는 궁금하다. 그래서 찾아봤다.
정의해준 loadImage() 메서드를 사용하는 곳을 찾아보면 위와 같다.
이 바인딩 클래스들은 레이아웃에서 DataBinding을 사용함으로써 빌드 시점에 생성되는 클래스들이다. ItemProductBindingImpl을 살펴보자. (더 자세한 내용은 이 글을 참고하자.)
무튼 이어 나가면 ItemProductBindingImpl 클래스는 여러 메서드를 가지고 있지만 여기서 주목해야 하는 메서드는 executeBindings() 이다. 이 메서드에서 우리가 만든 GlideBindingAdapter 오브젝트의 loadImage() 메서드를 사용하는 것을 볼 수 있다. 바인딩 어댑터를 생성하면 DataBinding이 내부적으로 바인딩 어댑터의 함수를 사용하는 클래스를 만들기 때문에 우리는 추가적인 코드 작성없이 간단하게 구현이 가능한 것이다.
TextViewBindingAdapter는 DataBinding 라이브러리가 자동으로 생성해주는 클래스이며 setText() 메서드를 호출함으로써 텍스트 뷰의 텍스트를 액티비티 혹은 프래그먼트에서 코드를 추가하지 않고 변경할 수 있게 도와준다.