[Android] Kotlin Delegates in Android: Android 개발에 Properties Delegate 활용하기

[Android] Kotlin Delegates in Android: Android 개발에 Properties Delegate 활용하기

원문 Kotlin Delegates in Android: Utilizing the power of Delegated Properties in Android development을 번역한 글입니다.
Delegate에 대한 설명과 함께 Android 개발에서 자주 사용되는 코드들을 예제로 쉽게 설명해줍니다. 중복되는 코드들을 Delegate를 통해 개선해볼 수 있을 것 같네요 🙂


Kotlin은 어플리케이션 개발에 도움이 되는 기능을 가진 아주 멋진 언어입니다. 그 중 하나가 Delegate Propertis입니다. 이 글에서 Delegate가 안드로이드 개발에 어떤 도움이 되는지 살펴보겠습니다.

  • Basics
  • Fragment arguments
  • SharedPreferences delegates
  • View delegates
  • Conclusion

Basics

먼저 Delegate란 무엇이고, 어떻게 동작할까요? 어려워 보일 수도 있지만 정말 복잡하지 않습니다.

Delegate는 프로퍼티의 값을 제공하고, 그 값의 변화를 처리하는 클래스일 뿐입니다. 이를 통해 프로퍼티의 getter-setter 로직을 분리된 클래스로 이동 또는 위임(delegate)할 수 있고, 그 로직을 재사용할 수도 있습니다.

param이라는 String 프로퍼티가 항상 앞, 뒤 공백이 제거된 문자열을 가져야 한다고 해봅시다. 그럼 프로퍼티의 setter를 아래처럼 구현할 수 있습니다:

class Example {

  var param: String = ""
      set(value) {
          field = value.trim()
      }
}

Kotlin syntax에 대해 잘 모르겠다면, Kotlin 문서의 Properties를 참고해주세요.

그럼 이 로직을 다른 클래스에서 재사용하고 싶은 경우 어떻게 해야 할까요? 이제 Delegate가 등장할 때입니다:

class TrimDelegate : ReadWriteProperty<Any?, String> {

    private var trimmedValue: String = ""

    override fun getValue(
        thisRef: Any?,
        property: KProperty<*>
    ): String {
        return trimmedValue
    }

    override fun setValue(
        thisRef: Any?,
        property: KProperty<*>, value: String
    ) {
        trimmedValue = value.trim()
    }
}

위와 같이 Delegate는 프로퍼티의 getter, setter 2개의 메서드를 갖는 클래스입니다. KProperty의 인스턴스인 property와 이 프로퍼티를 갖는 객체를 나타내는 thisRef를 제공합니다. 이게 다예요! 그리고 아래처럼 새로 생성한 Delegate를 사용할 수 있습니다.

class Example {

    var param: String by TrimDelegate()
}

동일한 동작을 아래처럼 쓸 수 있습니다.

class Example {

    private val delegate = TrimDelegate()
    var param: String
        get() = delegate.getValue(this, ::param)
        set(value) {
            delegate.setValue(this, ::param, value)
        }
}

::param은 프로퍼티에 대해 KProperty인스턴스를 반환합니다.

보시는 것처럼 Delegate에는 모호한 부분이 없습니다. 간단하면서도 아주 유용합니다. 이제 몇 가지 Android 사용 예제를 보겠습니다.

공식 문서에서 Delegate에 대해 더 알아볼 수 있습니다.

Fragment arguments

Fragment에 파라미터들을 넘겨야 할 때가 자주 있습니다. 보통 아래와 같은 코드로 구현됩니다.

class DemoFragment : Fragment() {
   private var param1: Int? = null
   private var param2: String? = null
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let { args ->
           param1 = args.getInt(Args.PARAM1)
           param2 = args.getString(Args.PARAM2)
       }
   }
   companion object {
       private object Args {
           const val PARAM1 = "param1"
           const val PARAM2 = "param2"
       }
       fun newInstance(param1: Int, param2: String): DemoFragment =
           DemoFragment().apply {
               arguments = Bundle().apply {
                   putInt(Args.PARAM1, param1)
                   putString(Args.PARAM2, param2)
               }
           }
   }
}

static method인 newInstance 안에서 fragment를 생성합니다. fragment의 argument에 파라미터들을 넣고, onCreate 시점에 다시 꺼냅니다.

Argument 관련 로직을 프로퍼티 getter, setter로 분리해서 코드를 좀 더 깔끔하게 만들 수 있습니다.

class DemoFragment : Fragment() {
   private var param1: Int?
       get() = arguments?.getInt(Args.PARAM1)
       set(value) {
           value?.let {
               arguments?.putInt(Args.PARAM1, it)
           } ?: arguments?.remove(Args.PARAM1)
       }
   private var param2: String?
       get() = arguments?.getString(Args.PARAM2)
       set(value) {
           arguments?.putString(Args.PARAM2, value)
       }
   companion object {
       private object Args {
           const val PARAM1 = "param1"
           const val PARAM2 = "param2"
       }
       fun newInstance(param1: Int, param2: String): DemoFragment =
           DemoFragment().apply {
               this.param1 = param1
               this.param2 = param2
           }
   }
}

하지만 여전히 같은 코드를 프로퍼티마다 써줘야 하고, 프로퍼티가 많아질수록 번거로워 집니다. 게다가 argument에 대한 명시적인 동작들이 조금 복잡해 보이기도 합니다.

코드를 좀 더 예쁘게 만들 순 없을까요? 당연히 있죠! 예상하신 것처럼 property delegate를 사용할 것입니다.

먼저 준비가 조금 필요합니다. Fragment의 argument는 Bundle 객체에 저장되는데, 값의 타입에 따라 저장하는 메서드도 분리되어 있습니다. 임의의 타입인 값을 Bundle에 넣고, 지원하지 않는 타입이면 예외를 던지는 확장 함수를 만들어 봅시다.

fun <T> Bundle.put(key: String, value: T) {
   when (value) {
       is Boolean -> putBoolean(key, value)
       is String -> putString(key, value)
       is Int -> putInt(key, value)
       is Short -> putShort(key, value)
       is Long -> putLong(key, value)
       is Byte -> putByte(key, value)
       is ByteArray -> putByteArray(key, value)
       is Char -> putChar(key, value)
       is CharArray -> putCharArray(key, value)
       is CharSequence -> putCharSequence(key, value)
       is Float -> putFloat(key, value)
       is Bundle -> putBundle(key, value)
       is Parcelable -> putParcelable(key, value)
       is Serializable -> putSerializable(key, value)
       else -> throw IllegalStateException("Type of property $key is not supported")
   }
}

자 이제 Delegate를 생성할 준비가 되었습니다:

class FragmentArgumentDelegate<T : Any> :
   ReadWriteProperty<Fragment, T> {

   @Suppress("UNCHECKED_CAST")
   override fun getValue(
       thisRef: Fragment,
       property: KProperty<*>
   ): T {
       val key = property.name
       return thisRef.arguments
           ?.get(key) as? T
           ?: throw IllegalStateException("Property ${property.name} could not be read")
   }

   override fun setValue(
       thisRef: Fragment,
       property: KProperty<*>, value: T
   ) {
       val args = thisRef.arguments
           ?: Bundle().also(thisRef::setArguments)
       val key = property.name
       args.put(key, value)
   }
}

Delegate는 Fragment argument로부터 프로퍼티 값을 읽습니다. 그리고 프로퍼티 값이 변경되면, Delegate는 Fragment argument를 다시 가져옵니다 (또는 Fragment가 아직 Bundle을 가지지 않으면 새로운 Bundle을 생성하고 set합니다). 이 arguments에 위에서 만든 확장 함수 Bundle.put을 이용하여 새 값을 씁니다.

ReadWriteProperty는 두 가지 타입 파라미터를 갖는 제네릭 인터페이스입니다. 예제에서는 첫 번째를 Fragment로 지정했는데, 이는 이 Delegate가 Fragment 내부 프로퍼티에 대해서만 사용가능하다는 것을 의미합니다. thisRef를 통해 Fragment 인스턴스에 접근하고, 그 arguments를 관리할 수 있습니다.

프로퍼티의 이름을 Argument의 키로 사용하기 때문에, 더 이상 키를 상수로 저장하지 않아도 됩니다.

ReadWriteProperty의 두 번째 파라미터는 프로퍼티의 값의 타입을 나타냅니다. 위 예제에서 명시적으로 non-nullable 타입으로 지정했고, 읽을 수 없는 값이면 예외를 발생시켰습니다. Fragment에서 non-nullable 프로퍼티를 사용하도록 강제하고, 귀찮은 null 체크를 하지 않아도 됩니다.

하지만 nullable 프로퍼티가 필요할 때도 있습니다. 그럼 다른 Delegate를 만들어서, argument를 읽을 수 없으면 예외를 발생시키지 않고 null을 반환하도록 만들어 봅시다

class FragmentNullableArgumentDelegate<T : Any?> :
   ReadWriteProperty<Fragment, T?> {

   @Suppress("UNCHECKED_CAST")
   override fun getValue(
       thisRef: Fragment,
       property: KProperty<*>
   ): T? {
       val key = property.name
       return thisRef.arguments?.get(key) as? T
   }

   override fun setValue(
       thisRef: Fragment,
       property: KProperty<*>, value: T?
   ) {
       val args = thisRef.arguments
           ?: Bundle().also(thisRef::setArguments)
       val key = property.name
       value?.let { args.put(key, it) } ?: args.remove(key)
   }
}

편리를 위해 몇 가지 함수를 생성해 보겠습니다.(필수는 아니고 단지 보기 좋게 하려는 목적입니다.)

fun <T : Any> argument(): ReadWriteProperty<Fragment, T> =
   FragmentArgumentDelegate()
fun <T : Any> argumentNullable(): ReadWriteProperty<Fragment, T?> =
   FragmentNullableArgumentDelegate()

그럼 Delegate를 사용하는 코드는 아래와 같습니다:

class DemoFragment : Fragment() {
   private var param1: Int by argument()
   private var param2: String by argument()
   companion object {
       fun newInstance(param1: Int, param2: String): DemoFragment =
           DemoFragment().apply {
               this.param1 = param1
               this.param2 = param2
           }
   }
}

꽤 깔끔하지 않나요?

SharedPreferences delegate

다음 앱 실행시 빠르게 값을 가져오기 위해 메모리에 값을 저장하는 경우도 자주 있습니다. 예를 들어, 사용자가 앱을 커스터마이즈할 수 있는 몇 가지 설정을 저장해야 합니다. 흔한 방법 중 하나가 SharedPreferences를 사용하여 key-value 데이터를 저장하는 방법입니다.

3가지의 값을 저장하고 가져오는 역할을 하는 클래스가 있다고 가정해봅시다.

class Settings(context: Context) {

    private val prefs: SharedPreferences =
        PreferenceManager.getDefaultSharedPreferences(context)

    fun getParam1(): String? {
        return prefs.getString(PrefKeys.PARAM1, null)
    }

    fun saveParam1(param1: String?) {
        prefs.edit().putString(PrefKeys.PARAM1, param1).apply()
    }

    fun getParam2(): Int {
        return prefs.getInt(PrefKeys.PARAM2, 0)
    }

    fun saveParam2(param2: Int) {
        prefs.edit().putInt(PrefKeys.PARAM2, param2).apply()
    }

    fun getParam3(): String {
        return prefs.getString(PrefKeys.PARAM3, null)
            ?: DefaulsValues.PARAM3
    }

    fun saveParam3(param3: String) {
        prefs.edit().putString(PrefKeys.PARAM2, param3).apply()
    }

    companion object {
        private object PrefKeys {
            const val PARAM1 = "param1"
            const val PARAM2 = "param2"
            const val PARAM3 = "special_key_param3"
        }

        private object DefaulsValues {
            const val PARAM3 = "defaultParam3"
        }
    }
}

여기서 Default SharedPreferences를 가져오는 로직과 각 값들을 가져오고 저장하는 메서드를 제공하고 있습니다. 또한 param3는 다른 값들과는 조금 다르게 다른 형식의 key와 default 값을 사용하도록 했습니다.

코드를 보면 중복된 부분들이 좀 보입니다. 물론 중복되는 부분들을 private method로 옮길 수도 있습니다. 하지만 그렇게 하더라도 여전히 거추장스러운 코드가 남아있게 됩니다. 그리고 다른 클래스에서 이 로직을 재사용하고 싶은 경우엔 어떻게 해야 할까요? Delegate가 코드를 얼마나 더 깔끔하게 만들 수 있는지 살펴보겠습니다.

좀 더 흥미롭게 하기 위해, 조금 다른 방식을 써 볼까요? 이번에는 Object expressions를 사용하여 SharedPreferences 클래스의 확장 함수를 만들어 보겠습니다.

fun SharedPreferences.string(
    defaultValue: String = "",
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, String> =
    object : ReadWriteProperty<Any, String> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getString(key(property), defaultValue)
        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: String
        ) = edit().putString(key(property), value).apply()
    }

SharedPreferences 확장 함수는 익명의 ReadWriteProperty를 반환합니다.

Delegate는 key 함수를 사용하여 Preferences로부터 String으로 값을 읽습니다. 기본으로 키는 프로퍼티의 이름이어서 상수에 저장하거나 전달할 필요가 없습니다. 하지만 Preferences 내부에서의 키 충돌이 걱정되거나 명시적으로 키에 접근하고 싶은 경우, 커스텀 키를 전달할 수 있는 옵션이 있습니다. 또한 Preferences에서 값을 찾지 못한 경우의 default 값도 전달할 수 있습니다. Settings 예제가 동작하도록 하려면 String?Int 타입을 위한 거의 동일한 로직의 Delegate가 2개 더 필요합니다.

fun SharedPreferences.stringNullable(
    defaultValue: String? = null,
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, String?> =
    object : ReadWriteProperty<Any, String?> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getString(key(property), defaultValue)

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: String?
        ) = edit().putString(key(property), value).apply()
    }

fun SharedPreferences.int(
    defaultValue: Int = 0,
    key: (KProperty<*>) -> String = KProperty<*>::name
): ReadWriteProperty<Any, Int> =
    object : ReadWriteProperty<Any, Int> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ) = getInt(key(property), defaultValue)

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: Int
        ) = edit().putInt(key(property), value).apply()
    }

마지막으로 Settings 클래스를 이렇게 정리할 수 있습니다.

class Settings(context: Context) {

    private val prefs: SharedPreferences =
        PreferenceManager.getDefaultSharedPreferences(context)

    var param1 by prefs.stringNullable()
    var param2 by prefs.int()
    var param3 by prefs.string(
        key = { "KEY_PARAM3" },
        defaultValue = "default"
    )
}

이제 훨씬 보기 좋네요. 나중에 새로운 값을 추가해야 한다면 한 줄의 코드만으로 추가할 수 있습니다!

View delegates

3개의 텍스트 필드(title, subtitle, description)를 갖는 커스텀 뷰가 있다고 가정해보겠습니다:

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/tvSubtitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/tvDescription"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

그리고 각 필드의 텍스트에 접근하고 수정하는 메서드가 필요하다고 해봅시다.

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
    var title: String
        get() = tvTitle.text.toString()
        set(value) {
            tvTitle.text = value
        }
    var subtitle: String
        get() = tvSubtitle.text.toString()
        set(value) {
            tvSubtitle.text = value
        }
    var description: String
        get() = tvDescription.text.toString()
        set(value) {
            tvDescription.text = value
        }
    init {
        inflate(context, R.layout.custom_view, this)
    }
}

여기서 레이아웃의 각 뷰에 접근하기 위해 Kotlin Android ExtensionsView binding을 사용했습니다.

다른 클래스로 쉽게 분리할 수 있는 코드가 명확히 보입니다. 이제 Delegate의 도움을 받아서 분리해봅시다!

TextView에 자신의 text에 대한 Delegate를 반환하는 확장 함수를 추가했습니다.

fun TextView.text(): ReadWriteProperty<Any, String> =
    object : ReadWriteProperty<Any, String> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): String = text.toString()

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>, value: String
        ) {
            text = value
        }
    }

그럼 CustomView에서 이렇게 사용할 수 있습니다

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    init {
        inflate(context, R.layout.custom_view, this)
    }

    var title by tvTitle.text()
    var subtitle by tvSubtitle.text()
    var description by tvDescription.text()
}

View는 null이 되면 안 되기 때문에, init 블록에서 View inflating이 완료된 이후에 프로퍼티를 초기화해야 합니다.

기존 코드를 엄청나게 개선했다고 볼 수는 없겠지만, 요점은 Delegate의 장점을 나타내는 것입니다.

물론 TextView로 한정짓지 않아도 됩니다. 예를 들어, view visibility에 대한 Delegate도 만들 수 있습니다(keepBounds는 View가 레이아웃에서 아직 공간을 차지하고 있는지, 아닌지 결정합니다).

fun View.isVisible(keepBounds: Boolean): ReadWriteProperty<Any, Boolean> =
    object : ReadWriteProperty<Any, Boolean> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): Boolean = visibility == View.VISIBLE

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>,
            value: Boolean
        ) {
            visibility = when {
                value -> View.VISIBLE
                keepBounds -> View.INVISIBLE
                else -> View.GONE
            }
        }
    }

ProgressBar의 progress를 위한 Delegate도 만들 수 있습니다.

fun ProgressBar.progress(): ReadWriteProperty<Any, Float> =
    object : ReadWriteProperty<Any, Float> {
        override fun getValue(
            thisRef: Any,
            property: KProperty<*>
        ): Float = if (max == 0) 0f else progress / max.toFloat()

        override fun setValue(
            thisRef: Any,
            property: KProperty<*>, value: Float
        ) {
            progress = (value * max).toInt()
        }
    }

아래 코드는 CustomViewProgressBar가 있다면 위의 Delegate들을 어떻게 쓸 수 있는지 보여줍니다.

class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    init {
        inflate(context, R.layout.custom_view, this)
    }

    var title by tvTitle.text()
    var subtitle by tvSubtitle.text()
    var description by tvDescription.text()

    var progress by progressBar.progress()
    var isProgressVisible by progressBar.isVisible(keepBounds = false)
}

보시는 것처럼 원하는 것을 얼마든지 위임할 수 있습니다!

Conclusion

Android 개발에서 Kotlin property delegate를 사용하는 몇 가지 예제를 살펴봤습니다. 물론 여러분의 어플리케이션에서 이를 사용할 또 다른 많은 방법을 생각해냈을 수도 있습니다(만약 있다면 언제든지 댓글로 알려주세요). 이 포스팅의 목적은 프로퍼티 위임이 얼마나 강력한 도구이고 어떻게 사용하는지 알리기 위함이었습니다. 그리고 여러분이 이제 Delegate를 어떻게 사용할지 생각하는 것에 푹 빠졌으면 좋겠습니다!

Comments