/ KOTLIN, ANDROID

간편하고 안전하게 레이아웃 내 뷰를 참조하는 방법: 안드로이드 뷰 바인딩

안드로이드 뷰 바인딩 (Android View Binding)은 뷰를 다루는 코드를 보다 쉽게 작성할 수 있게 해 줍니다. 레이아웃 XML 파일에 정의되어 있는 뷰를 자동으로 생성된 클래스를 통해 자바/코틀린 코드에서 참조할 수 있게 해 주며, 코틀린 안드로이드 익스텐션에서 뷰 접근을 위해 제공하는 합성 프로퍼티 (Synthetic property)와 거의 동일한 기능을 제공합니다.

코틀린 안드로이드 익스텐션은 2021년 내로 지원이 중단될 예정입니다. 따라서, 기존에 코틀린 안드로이드 익스텐션에서 제공하는 뷰 바인딩 기능을 사용하고 계셨던 분들은 이를 안드로이드 뷰 바인딩으로 이전해야 합니다.

뷰 바인딩 활성화하기

별도의 플러그인을 설정해야 했던 코틀린 안드로이드 플러그인과 달리, 안드로이드 뷰 바인딩은 안드로이드 빌드 도구에 통합되어 있기에 별도로 플러그인을 추가할 필요가 없습니다.

단, 기본적으로 뷰 바인딩은 비활성화 되어 있으므로 이를 사용하려면 앱 모듈 빌드스크립트 (일반적으로 app/build.gradle)에서 이를 활성화 해야 합니다. 다음은 뷰 바인딩 기능을 활성화하는 예를 보여줍니다.

android {
    ...
    // 안드로이드 뷰 바인딩 기능을 활성화 합니다.
    buildFeatures {
        viewBinding true
    }
}

기존에 코틀린 안드로이드 익스텐션을 사용하고 있었다면, 빌드스크립트에서 플러그인을 제거해 줍니다. (apply plugin: 'kotlin-android-extensions' 제거)

뷰 바인딩 동작 원리

모듈 내에 뷰 바인딩이 활성화되면, 해당 모듈 내 포함된 레이아웃 (*.xml) 파일에 상응하는 바인딩 클래스가 생성되며, 이 클래스에는 루트 뷰와 더불어 ID가 뷰여된 모든 뷰에 대한 참조가 포함됩니다.

작명 규칙

바인딩 클래스 이름은 레이아웃 파일 이름에 따라 결정됩니다. 작명 규칙은 다음과 같습니다.

  1. 레이아웃 파일 이름을 파스칼 케이스 (Pascal case; Upper camel case)로 변환합니다.
  2. 변환된 이름 뒤에 Binding을 붙여줍니다.

예를 들어 activity_main.xml 레이아웃 파일이 있는 경우, 이에 상응하는 바인딩 클래스는 ActivityMainBinding이 됩니다.

뷰 바인딩에서 레이아웃 제외하기

바인딩 클래스를 생성할 필요가 없는 레이아웃이 있다면, 레이아웃 파일의 최상위 뷰에 tools:viewBindingIgnore="true"를 추가하면 해당 레이아웃에 대한 바인딩 클래스가 생성되지 않도록 설정할 수 있습니다.

다음은 tools:viewBindingIgnore="true"를 사용하는 예를 보여줍니다.

<LinearLayout
        ...
        tools:viewBindingIgnore="true" >
    ...
</LinearLayout>

tools:viewBindingIgnore="true" 속성은 레이아웃 파일의 최상위 뷰에서만 사용할 수 있습니다. 루트 뷰가 아닌 하위 뷰에서는 이 속성이 동작하지 않습니다.

바인딩 클래스 내부 살펴보기

바인딩 클래스 파일은 모듈 내 build/generated/data_binding_base_class_source_out 디렉토리에 생성됩니다.

바인딩 클래스는 자바/코틀린 코드에서 레이아웃 내의 뷰에 접근할 수 있게끔 멤버 필드를 자동으로 생성해 줍니다. 필드 이름은 레이아웃 파일에서 지정한 뷰의 ID와 동일합니다.

다음은 생성된 바인딩 클래스의 예를 보여줍니다. 이 코드는 빌드 과정에서 자동으로 생성됩니다.

// Generated by view binder compiler. Do not edit!
package com.androidhuman.example.viewbinding.databinding;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewbinding.ViewBinding;
import com.androidhuman.example.viewbinding.R;
import java.lang.NullPointerException;
import java.lang.Override;
import java.lang.String;

public final class ActivityMainBinding implements ViewBinding {
  @NonNull
  private final LinearLayout rootView;

  @NonNull
  public final Button click;

  @NonNull
  public final TextView helloText;

  private ActivityMainBinding(@NonNull LinearLayout rootView, @NonNull Button click,
      @NonNull TextView helloText) {
    this.rootView = rootView;
    this.click = click;
    this.helloText = helloText;
  }

  @Override
  @NonNull
  public LinearLayout getRoot() {
    return rootView;
  }

  @NonNull
  public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater) {
    return inflate(inflater, null, false);
  }

  @NonNull
  public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater,
      @Nullable ViewGroup parent, boolean attachToParent) {
    View root = inflater.inflate(R.layout.activity_main, parent, false);
    if (attachToParent) {
      parent.addView(root);
    }
    return bind(root);
  }

  @NonNull
  public static ActivityMainBinding bind(@NonNull View rootView) {
    // The body of this method is generated in a way you would not otherwise write.
    // This is done to optimize the compiled bytecode for size and performance.
    int id;
    missingId: {
      id = R.id.click;
      Button click = rootView.findViewById(id);
      if (click == null) {
        break missingId;
      }

      id = R.id.hello_text;
      TextView helloText = rootView.findViewById(id);
      if (helloText == null) {
        break missingId;
      }

      return new ActivityMainBinding((LinearLayout) rootView, click, helloText);
    }
    String missingId = rootView.getResources().getResourceName(id);
    throw new NullPointerException("Missing required view with ID: ".concat(missingId));
  }
}

위의 바인딩 클래스는 아래 레이아웃 파일을 기반으로 생성되었습니다. 아래 코드와 위의 코드를 번갈아 확인해보면, 바인딩 클래스가 어떻게 구성되는지 조금 더 잘 이해할 수 있습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/hello_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!" />

    <Button
        android:id="@+id/click"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="Click me!" />

</LinearLayout>

액티비티에서 사용하기

액티비티에서 뷰 바인딩을 사용하려면 액티비티 내 onCreate()에서 바인딩 클래스를 설정해야 합니다. 설정 절차는 다음과 같습니다.

  1. 생성된 바인딩 클래스의 inflate() 메서드를 사용하여 액티비티에서 사용할 바인딩 클래스의 인스턴스를 생성합니다.
  2. 바인딩 클래스의 getRoot() 메서드를 통해 레이아웃 내 최상위 뷰의 인스턴스를 얻습니다.
  3. setContentView() 메서드에 이전 단계에서 획득한 최상위 뷰의 인스턴스를 넘겨줍니다.

다음은 액티비티에서 뷰 바인딩을 설정하는 예를 보여줍니다.

class MainActivity : AppCompatActivity() {

    // 액티비티에서 사용할 레이아웃의 뷰 바인딩 클래스
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 뷰 바인딩 클래스의 인스턴스를 생성합니다.
        binding = ActivityMainBinding.inflate(layoutInflater)
        
        // 생성된 뷰를 액티비티에 표시합니다.
        setContentView(binding.root)
    }
}

이제 뷰 바인딩 클래스 인스턴스로 레이아웃 내 뷰를 참조할 수 있습니다. 다음은 간단한 예를 보여줍니다.

// showSecondActibity 버튼에 클릭 리스터를 지정합니다.
binding.showSecondActivity.setOnClickListener {
    startActivity(Intent(this, SecondActivity::class.java))
}

프래그먼트에서 사용하기

프래그먼트에서 뷰 바인딩을 사용하려면 프래그먼트 내 onCreateView()에서 바인딩 클래스를 설정해야 합니다. 설정 절차는 다음과 같습니다.

  1. 생성된 바인딩 클래스의 inflate() 메서드를 사용하여 액티비티에서 사용할 바인딩 클래스의 인스턴스를 생성합니다.
  2. 바인딩 클래스의 getRoot() 메서드를 통해 레이아웃 내 최상위 뷰의 인스턴스를 얻습니다.
  3. onCreateView() 메서드의 반환값으로 이전 단계에서 획득한 최상위 뷰의 인스턴스를 넘겨줍니다.

다음은 프래그먼트에서 뷰 바인딩을 설정하는 예를 보여줍니다.

class SecondFragment : Fragment() {

    private var _binding: FragmentSecondBinding? = null;

    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentSecondBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

프래그먼트는 뷰에 비해 수명이 더 깁니다. 바인딩 클래스는 뷰에 대한 참조를 가지고 있으므로, 뷰가 제거될 때 호출되는 onDestroyView() 콜백 내에서 바인딩 클래스의 인스턴스도 함께 정리해 주어야 합니다.

뷰 바인딩과 findViewById는 무엇이 다른가요?

사실, findViewById()를 사용해도 액티비티 혹은 프래그먼트 내 뷰를 참조할 수 있습니다. 하지만, 뷰 바인딩은 findViewById() 대비 다음과 같은 장점이 있습니다.

  • 널 안전 (Null Safety): 바인딩 클래스에서 제공하는 필드는 각 뷰를 직접 참조하게 구성되어 있습니다. 따라서, 잘못된 ID를 대입하여 널 포인터 오류가 발생하는 등의 문제가 일어나지 않습니다. 또한, 특정 구성 (configuration)에서만 접근할 수 있는 뷰가 있는 경우 이는 @Nullable로 표시되므로 뷰 참조시 실수를 방지할 수 있습니다.
  • 타입 안정성 (Type safecy): 바인딩 클래스 내 필드는 레이아웃 내 선언된 뷰의 타입을 갖습니다. 따라서 잘못된 타입으로 캐스팅 (예: ImageViewTextView로 캐스팅)하는 실수를 원천 봉쇄할 수 있습니다.

이러한 특성 덕분에, 레이아웃과 코드 사이에 일치하지 않는 부분이 있다면 컴파일 오류가 발생하게 됩니다. 따라서, 앱 배포 후 발생할 수 있는 런타임 오류를 미연에 방지할 수 있습니다.

이 글에서 사용한 예제 프로젝트 소스 코드는 Github 내 kunny/blog_samples 저장소에서 확인할 수 있습니다.

추가 리소스

kunny

커니

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

Read More