Android Studio 4.1에서 제거된 Kotlin Android Extensions을 알아보자.



개인 광고 영역

안드로이드 4.1 버전에서 새로운 프로젝트 생성 시 기본 플러그인으로 제공하던 apply plugin: ‘kotlin-android-extensions’이 제거되고, 기본 ‘com.android.application’과 ‘kotlin-android’ 만 남게 되었다.

더 이상 ‘kotlin-android-extensions’을 기본으로 제공하지 않는다.

사용하려면 직접 추가해 사용할 수 있고, 이미 사용하던 것 역시 사용이 가능하다.

사실 ‘kotlin-android-extensions’은 좋은 플러그인은 아니다. findViewById의 반복적인 작업을 제거하려고 만들어졌고, 내부적인 캐시를 통해 재사용성을 높였을 뿐이다.

하지만 모든 곳에서 재사용성을 지켜주지 않는다. RecyclerView의 ViewHolder에서는 지켜주지 않는다. 이를 알고 쓰는 사람은 생각보다 많지 않다.


이번 글에서는

  • 새롭게 바뀔 Android Studio 4.2 버전의 코드를 살펴본다.
  • kotlin-android-extensions의 문제점을 알아본다.


kotlinx.android.synthetic 대신 findViewById를 사용

벌써 1년 전에 reddit에 이 글이 올라왔다.

Why kotlinx synthetic is no longer a recommended practice - reddit 그리고 1년이 지난 지금에서야 Android Studio 4.1에서 플러그인도 제거했다.

그간 Android Studio는 kotlinx.android.synthetic 플러그인을 추가되어 있었으나, 이를 사용하지 않고 findViewById를 활용하고 있었다.

이젠 기본 플러그인에서 퇴출되었기에 사용하고 싶다면 개발자가 직접 적용해야 한다.

대신 작년 Google IO에서 소개되었던 ViewBinding이 이제 기본으로 자리 잡았다.

안드로이드 4.0.1 버전에서 생성한 기본 프로젝트에는 findViewById를 사용하는 코드가 남아있다.

class MainActivity : AppCompatActivity() {

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

        findViewById<FloatingActionButton>(R.id.fab).setOnClickListener { view ->
            // ...
        }
    }
}

안드로이드 4.2 Preview 현재 버전에서는 kotlin-android-extensions도 제거되었고, ViewBinding을 기본으로 사용한다.

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

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

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setSupportActionBar(binding.toolbar)

        binding.fab.setOnClickListener { view ->

        }
    }
}


왜 지웠을까?

당연하게도 이렇게 편하게 사용하라고 만들어둔 kotlin-android-extensions을 완전히 제거한 부분에 의아해 할 개발자들은 매우 많다.

import만 하면 자동으로 view를 찾아주고, 굳이 변수로 생성할 필요도 없고, 심지어 내부에서 캐싱까지 하는데 이 편한 걸 왜 지우나?

필자도 동의하는 부분이고, 과거에 필자도 매우 편하게 사용했었다.

하지만 그간 사용해보면서 경험한 바로는 생각보다 실수 생길 여지가 많고, 불편한 점이 있었다. 그리고 이러한 오류는 빌드 타임이 아닌 런타임에 생겼으며, RecyclerView에서 문제가 생겼다.

그래서 필자도 ViewBinding과 DataBinding을 활용한 이후에는 kotlin-android-extensions을 굳이 사용하려고 하지 않는다.


실수할 여지가 매우 많다.

어떤 실수가 발생할까? 레이아웃 몇 개 없고, 간단한 샘플에서는 딱히 문제 생길 부분은 없다.

필자가 경험한 실수들을 한번 살펴보도록 하겠다.


동일한 id를 가진 서로 다른 widget을 주의하라

서로 다른 레이아웃 2개에 하나는 Button, 하나는 TextView를 생성하고, 동일한 id를 사용하여 widget을 만들었다.

<!-- sample_one.xml -->
<Button
    android:id="@+id/btn"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

<!-- fragment_first.xml -->
<TextView
    android:id="@+id/btn"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

그리고 해당 xml을 사용하는 Fragment에서 아래와 같이 import를 시도한다. 하지만 추천이 레이아웃 전부다에서 일어난다.

sample_one

여기서 실수로 sample_one을 선택했고, 아래와 같이 import 되었다.

import kotlinx.android.synthetic.main.sample_one.*

사실 이대로도 빌드에 생기는 문제는 존재하지 않는다. import 되었고, 단순 컴파일 시에 아래와 같이 나올 테니깐

((Button)this._$_findCachedViewById(id.btn)).setOnClickListener((OnClickListener)null.INSTANCE);

이 코드에서 이미 문제점이 있다. import 실수로 인해 Button을 찾고 있다.

결국 런타임에 즉시 오류가 발생한다.

이 샘플은 극단적인 케이스일 수 있다. 저렇게 유사한 이름을 서로 다른 뷰에서 사용하는 경우가 흔치 않을 테니깐.

이 경우는 id에 극단적으로 어떤 화면에서 사용할 것인지 정보를 모두 적어주어 해결 아닌 해결을 할 수 있는데, 각각의 화면 이름을 넣어주는 케이스이다.

실제로 이렇게 작성하는 경우도 흔하다. findViewById 역시 동일한 문제는 생길 수 있으니깐.

<TextView
    android:id="@+id/fragment_first_btn"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />


동일한 id를 가진 동일한 widget을 주의하라

이번엔 동일한 id를 가지고, include를 이용해 레이아웃에 추가했다. 그리고 이를 kotlin-android-extensions을 이용하여 import 했다.

<!-- sample_one.xml -->
<Button
    android:id="@+id/btn"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

<!-- fragment_first.xml -->
<include layout="@layout/sample_one" />

<!-- fragment_second.xml -->
<include layout="@layout/sample_one" />

이렇게 2개의 fragment에 sample_one 레이아웃을 include 했다. 그리고 Fragment에서 아래와 같이 import 2개가 발생했다.

fragment_first 레이아웃을 import 했고, sample_one의 btn을 import 했다.

import kotlinx.android.synthetic.main.fragment_first.*
import kotlinx.android.synthetic.main.sample_one.btn

그러고 나서 수정사항이 생겼는데, 공통의 버튼은 필요 없고, fragment_first에서 새롭게 디자인한 필요해 추가했다. 이름은 동일하다.

<!-- fragment_first.xml -->
<!-- 제거 하고 <include layout="@layout/sample_one" /> -->

<!-- 추가 -->
<Button
    android:id="@+id/btn"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

그리고 import는 수정하지 않았다. 코드가 많고, 어차피 import를 새로 하라고 뜨지도 않는다. 심지어 정상 동작한다.

이미 여기서부터 잘못되었다.

나의 레이아웃은 fragment_first를 가리켜야 한다. 그런데 동일한 이름이라고 sample_one을 가리키고 있다. 심지어 오류도 없다.

일단 동작하니깐 개발자는 아무 문제 없다고 여기고, 코드 리뷰에서도 발견하기 어렵다. 왜냐면 동작을 하기 때문이고, import는 변경되었다고 나오지 않기 때문이다.

이 역시 위에서 봤던 것처럼 이름을 변경하면 되는 것 아닌가? 할 수 있다.

역시 동일하다. 이름을 명확하게 하면 해결 될 수 있다. 근데 모두가 그럴거라는 생각을 가진다면 이것 역시 문제일 수 있다.

이미 잘 돌아가니 손도 안되고 있다가, 나중에 전혀 다른 곳에서 이슈가 터질 수 있다. 이 화면은 그대로 써야 하는데 공통으로 사용하던 레이아웃 이름을 변경하거나, 그 레이아웃의 widget id 이름을 변경했다거나 한다면 문제가 생길 수 있다. 나와 아무런 관련 없는 코드가 오류가 날 수 있다.


이런 문제들은 사소할 수 있다.

그렇다 생각보다 사소한 문제일 수 있다. 근데 한두 번 경험하다 보면 귀찮은 문제이다. 빌드가 5분이 걸려 겨우 끝났는데 시작하자마자 오류가 났다면? 또 수정하고 또 기다려야 한다.

생각보다 사소한 문제로 인해 생기는 시간 낭비는 매우 스트레스일 수 있다.

그렇다고 레이아웃 이름을 모두 어떤 레이아웃에서 사용할 거라고 prefix_ 정의로 모든 명칭을 집어넣는다면 그것 역시 문제일 수 있다.

  • 처음엔 A 화면에서만 쓰는 레이아웃이라서 a_widget_name으로 지었다.
  • 갑자기 D 화면에서도 이를 가져다 써야 한다. 그렇다면 네이밍 규칙이 맞지 않는다.
  • 어떡할 것인가?

이미 보편적이지 않다. 네이밍 규칙을 지키려면 새로운 파일을 다시 만들어야 한다. 그렇게 하면 관리는 누가할 것인가? 결국 파편화 문제가 생길 수 있다.

혼자 다하는 경우엔 이런 문제는 아무것도 아닐 수 있다.


ViewHolder에서는 캐싱을 해주지 않는다.

ViewHolder에서는 캐싱을 해주지 않는다. ViewHolder는 재사용성을 높이려고 만들어졌는데, 정작 ViewHolder에서 Kotlin-android-extensions을 사용한다면 캐싱을 해주지 않는다.

Kotlin Android Extensions - 리사이클러의 뷰홀더에서 올바르게 사용하는 방법 - 커니님이 작성한 글을 참고하라.

생각보다 많은 사람들이 이를 모르고 활용한다. 이유는 디컴파일을 해보지 않으면 알 수 없기 때문이다.

결국 이런 부분을 공통으로 해결하기 위해 BaseViewHolder가 필요하다.

간단하게 왜 그런지 만 보고 넘기겠다.

아래와 같이 ViewHolder를 생성하고, Kotlin android extensions을 사용했다.

import kotlinx.android.synthetic.main.sample_one.view.*

class SampleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    fun onBind() {
        itemView.btn.setOnClickListener {

        }
    }
}

이 코드로 보았을 땐 당연하게도 위에서 보았던 디컴파일 코드처럼 cache를 해서 가져올 것 같이 생겼다.(왜냐면 동일한 코드이니 동일한 결과가 나와야 한다고 생각할 테니)

디컴파일 결과를 통해 확인하면 _findCachedViewById를 제공하지 않음을 알 수 있다.

public final void onBind() {
   View var10000 = this.itemView;
   Intrinsics.checkNotNullExpressionValue(var10000, "itemView");
   ((Button)var10000.findViewById(id.btn)).setOnClickListener((OnClickListener)null.INSTANCE);
}

결국 onBind를 호출할 때마다 findViewById를 매번 하는 것이다. 재사용성을 높여야 하는 RecyclerView.ViewHolder에서 이를 제공하지 않는다.

다행히 위 커니님의 글을 통한 방법을 사용하거나, 그냥 옛날 방식대로 findViewById를 직접 사용하는 방법이 있다.

생각보다 많은 샘플이나 코드에서 이를 발견하기 쉽지 않다.


결과적으로

처음엔 마냥 좋았다. 쓰기 편하고, id만 정의하면 바로 사용할 수 있었으니.

하지만 오랜 시간 몇 가지 경험과 알고 쓰지 않으면 성능 상 이점도 없는 kotlin-android-extensions을 알고 나선 이럴 거면 굳이 써야 하나? 라는 생각을 해보았다.

오히려 더 사용하기 간편해진 findViewById를 쓰는 편이 오히려 편할 수 있었죠.

개발은 실수를 줄이는 방법으로 진행되어야 더 효율적인 개발이 가능합니다.

뭐 돌고 돌아 findViewById로 갔다가 이제서야 ViewBinding을 사용할 수 있는 환경이 만들어지고 있습니다.


결국 ViewBinding

돌고 돌아 ViewBinding을 사용하는 게 좋다.

kotlin-android-extensions은 아마도 레이아웃에 포함되어 있을 거야? 이지만 ViewBinding은 스스로 포함되어 있는 구성요소를 명확화하여 가져온다.

수정이 생기더라도, 명확하게 수정해 줘야 한다. 그러다 보니 런타임에서 발생할 수 있는 오류도 줄어든다.

참고로 구글에서 2019년 IO 발표에서 소개한 ViewBinding은 아래(???가 ViewBinding) 비교한 것 중에 가장 좋다.

image_08


ViewBinding 소개영상


ViewBinding

ViewBinding - 문서 링크

다만 Activity와 Fragment에서 사용하는 방법이 조금 다르다.

class MainActivity : AppCompatActivity() {

    // 늦은 초기화를 이용하여 binding을 불러온다.
    private lateinit var binding: ActivityMainBinding

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

        // 늦은 초기화의 binding에 대치시킨다.
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root) // contentView에 등록

        setSupportActionBar(binding.toolbar) // layout에 포함되어있는 Toolbar를 가져온다.

        // layout에 포함된 fab를 찾는다.
        binding.fab.setOnClickListener { view -> }
    }
}

Fragment는 Nullable 처리를 위해 추가적인 코드가 필요하다. 다행히 Android Studio 4.2 Preview 최신 버전에서는 가이드를 잘 해주고 있다.

class SecondFragment : Fragment() {

    private var _binding: FragmentSecondBinding? = null

    // This property is only valid between onCreateView and
    // onDestroyView.
    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 onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.buttonSecond.setOnClickListener { }
    }

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

null 처리를 자동으로 해주면 좋겠으나, 현재로는 Nullable 값을 하나 만들고, kotlin property를 사용해 _binding!! 을 치환하는 형태로 사용한다.

onDestroyView 이후에 이를 호출하면 즉시 오류 발생할 테니 주의하여 사용해야 한다.


ViewBinding의 include layout 사용법 - include

include 레이아웃을 사용하는 경우가 있다. 아래 글에서 사용방법이 잘 나와있어 링크를 건다.

Exploring Android View Binding in Depth - Medium

다음과 같이 공용으로 사용할 layout이 있다.

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

    <Button
        android:id="@+id/btn"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

이때는 include를 이용하여 추가하게 된다. include에서 id를 추가해 준다.

<include
    android:id="@+id/sub_view"
    layout="@layout/sample_one" />

그리고 실제 Fragment에서는 아래와 같이 추가한다.

binding.subView.btn.setOnClickListener {
  // ...
}

결국 binding 한 View에서 include 한 subView에 접근하고, btn을 접근할 수 있다.


ViewBinding의 include layout 사용법 - merge 레이아웃 include

이번엔 merge를 이용한 경우의 include 레이아웃 사용하는 방법이다.

위의 include보다는 조금 더 복잡해진다.

아래와 같이 merge 레이아웃을 하나 생성한다.

<?xml version="1.0" encoding="utf-8"?>
<merge 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"
    tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">

    <Button
        android:id="@+id/btn"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</merge>

그리고 아래와 같이 사용할 부분에 include 한다.

<include
    layout="@layout/sample_one" />

그리고 Fragment에서 아래와 같이 작업을 추가한다. 이미 binding 한 View가 있고, 여기서 merge할 layout을 다시 bind 한다.

아래와 같이 해주면 되겠다. 처음 작성한 코드와 크게 다르지 않다.

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

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // 생략
        val subView = SampleOneBinding.bind(binding.root)
        subView.btn.setOnClickListener {
            // 생략
        }
    }
    // 생략
}


마무리

이 글을 정리하면

  • Kotlin android extensions이 항상 좋은 것은 아니다.
  • kotlin android extensions은 실수할 여지가 매우 많다.
  • 일부는 _findCachedViewById를 이용하여 성능 향상에 도움을 주지만 그렇지 않은 경우도 있다.
  • 잘 알고 써야 하는 kotlin android extensions보다 ViewBinding으로 명확하게 사용하는 편이 좋다.
  • Android Studio 4.1부터는 플러그인도 기본 제공하지 않지만 추가해 사용하는 것은 가능하다.
  • Android Studio 4.2부터는 findViewById 대신 ViewBinding을 기본 샘플로 제공한다.
  • ViewBinding을 잘 활용하면 편하다.
  • Fragment에서는 ViewBinding의 Nullable 처리를 필요로 한다.


About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments