Injection by Annotation in Kotlin :: parse AttributeSet

뭔가 엄청 오랜만의 포스팅인 것 같다.

1월을 마무리 하기에 좋은 포스팅은, 역시 코드 포스팅이다.

그래서, 오늘은 리플렉션 기술을 이용한 Annotation 파싱을 해보려 한다.


도입

최근 회사에서 작업할때나, 개인적으로 작업할때나 커스텀 뷰를 만들어서 사용할 때가 매우 많아졌다.

공통 요소를 한 파일에 구현하고, 사용할 화면에 추가만 해주면 되기 때문이다.

이 5개가 전부 커스텀 뷰를 구현해놓은 라이브러리고, 여러 프로젝트에서 아주 잘 쓰이고 있다.

보통 커스텀 뷰를 만들 때 XML에 app:**** 라는 속성을 붙이는데 사용자가 작성한 속성을 코드에 가져오려면 Attributes 란 인터페이스를 이용해 TypedArray를 가져오고, 다음과 같은 코드를 구현해야 한다.

val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CombinedCheckBox) ?: return

textPrimary = typedArray.getString(R.styleable.CombinedCheckBox_textPrimary)
textSecondary = typedArray.getString(R.styleable.CombinedCheckBox_textSecondary)
textPrimaryColor = typedArray.getColor(R.styleable.CombinedCheckBox_textPrimaryColor, Color.BLACK)
textSecondaryColor = typedArray.getColor(R.styleable.CombinedCheckBox_textSecondaryColor, Color.BLACK)
textPrimarySize = typedArray.getDimension(R.styleable.CombinedCheckBox_textPrimarySize, resources.getDimensionPixelSize(R.dimen.combined_text_view_default_size).toFloat())
textSecondarySize = typedArray.getDimension(R.styleable.CombinedCheckBox_textSecondarySize, resources.getDimensionPixelSize(R.dimen.combined_text_view_default_size).toFloat())
textExtraSpace = typedArray.getInt(R.styleable.CombinedCheckBox_textExtraSpace, 1)
fontPrimaryText = typedArray.getString(R.styleable.CombinedCheckBox_fontPrimaryText)
fontSecondaryText = typedArray.getString(R.styleable.CombinedCheckBox_fontSecondaryText)
textPrimaryStyle = typedArray.getInt(R.styleable.CombinedCheckBox_textPrimaryStyle, 0)
textSecondaryStyle = typedArray.getInt(R.styleable.CombinedCheckBox_textSecondaryStyle, 0)

typedArray.recycle()

딱 봐도 엄청 지루한 작업이다.

이걸 어노테이션을 이용해 자동으로 파싱하게 한다면 어떨까? 란 생각을 떠올렸고, 그것이 오늘 다뤄볼 Injection by Annotation 이다.

설계

위 코드를 분석해보면, TypedArray로부터 값을 추출하려면 R.styleable ~ 로 되는 index값(int), 해당 속성의 포맷, 그리고 기본값이 있다.

그리고 당연히 Annotation은 필드만 적용 가능하게 해야된다는 걸 생각해보면, 아래와 같이 정리할 수 있다.

  • 적용 대상(AnnotationTarget) -> 필드(FIELD)
  • 주 파라미터(value) -> index값
  • 부 파라미터 1 -> enum 값, String, Color, Dimension 등…
  • 부 파라미터 2 -> int 값

그리고 이를 코드로 작성해보자.

enum class AttrType {
    Boolean, Color, Dimension, DimensionPixelSize, Drawable, Float, Int, Integer, ResourceId, String
}

@Target(AnnotationTarget.FIELD)
annotation class BindAttr(val value: Int, val type: AttrType = AttrType.Int, val defValue: Int = 0)

AttrType 란 enum 클래스에 각 속성을 넣고, annotation 클래스를 구현한다.

타겟은 FIELD로, 주 파라미터는 index값으로 value란 이름을, type와 defValue는 각각 기본값으로 Int와 0을 집어넣었다.

실제 사용 예제는 다음과 같아진다.

textExtraSpace = typedArray.getInt(R.styleable.CombinedCheckBox_textExtraSpace, 1)
fontPrimaryText = typedArray.getString(R.styleable.CombinedCheckBox_fontPrimaryText)

->>

@BindAttr(R.styleable.CombinedCheckBox_textExtraSpace)
private int mTextExtraSpace;

@BindAttr(value = R.styleable.CombinedCheckBox_fontPrimaryText, type = AttrType.String)
private String mFontPrimaryText;

어노테이션이 적용된 필드 추출

그다음 해야될 것은, 액티비티나 뷰 로부터 클래스 객체와 TypedArray 객체를 받는 것이다.

fun inject(array: TypedArray, receiver: Any) {

}

그 다음으론 receiver 의 javaClass 를 받는다.

var cls = receiver.javaClass

receiver.javaClass 에서 receiver 는 Any, 즉 어떤 타입도 되지만 javaClass 를 뒤에 붙여주면 해당 코드를 실행한 클래스의 객체가 반환된다.

만일 TitleView 에서 불렀다면 Class<TitleView> 가 반환된다.

그 다음, do-while 문을 걸어서 해당 클래스의 선언된 필드를 가지고 오자.

do {
    cls.declaredFields.filter { it.declaredAnnotations.isNotEmpty() }.forEach { field ->
                    
    }

    try {
        cls = cls.getSuperclass()
    } catch (e: Exception) {
        cls = null
    }

} while (cls != null)

선언된 필드들을 가져올 때 어노테이션이 붙지 않은 필드는 필요 없으므로 미리 리스트에서 제외시키고, forEach 메서드를 호출하게 한다.

여기서 forEach로 오는 파라미터는 1개이니 it(implicit parameter) 를 사용할 수는 있지만, 필드에서 어노테이션 리스트를 가져와 forEach를 사용해야 하니 여기서는 파라미터의 이름을 명시적으로 작성하면 된다.

그 다음 try-catch 문의 경우에는 필드를 가져와서 작업을 시행하고, Class의 슈퍼 클래스, 즉 부모 클래스로 다시 이동시키는 코드이다. 이렇게 하면 TitleView 가 BaseView를 상속하는데, BaseView에도 우리들의 어노테이션이 있으면 같이 파싱할 수 있기 때문이다.

그 다음으로는 해당 field 에서 어노테이션을 찾아서 그 어노테이션이 우리가 만든 BindAttr이면 메서드를 실행하게 해보자.

field.declaredAnnotations.forEach {
    if (it is BindAttr) {
        attachBindAttr(array, field, it, receiver)
    }
}

여기의 forEach 에서는 it를 사용한다.

그래서 attachBindAttr 란 메서드에 TypedArray, Field, BindAttr, Any 를 각각 넘겨준다.

값 설정

private fun attachBindAttr(array: TypedArray, field: Field, bindAttr: BindAttr, receiver: Any) {
        val index = bindAttr.value
        val defValue = bindAttr.defValue
}

attachBindAttr 메서드에서 각각 파싱에 필요한 index와 defValue를 꺼낸다.

이 값들로부터 TypeArray 에서 값을 가져올텐데, 문제는 위에서도 있었지만 메서드가 타입마다 다 다르다는 문제가 있다.

그래서 when 문을 사용해 value 변수를 initialize 시키자.

val value: Any = when (bindAttr.type) {
    AttrType.Boolean -> array.getBoolean(index, (defValue != 0))
    AttrType.Color -> array.getColor(index, defValue)
    AttrType.Dimension -> array.getDimension(index, defValue.toFloat())
    AttrType.DimensionPixelSize -> array.getDimensionPixelSize(index, defValue)
    AttrType.Drawable -> array.getDrawable(index)
    AttrType.Float -> array.getFloat(index, defValue.toFloat())
    AttrType.Int -> array.getInt(index, defValue)
    AttrType.ResourceId -> array.getResourceId(index, defValue)
    AttrType.String -> array.getString(index)
    AttrType.Integer -> array.getInteger(index, defValue)
}

마지막으로 해당 필드에 접근 가능 여부를 true로 하고, 값을 설정한다.

try {
    field.isAccessible = true
    field.set(receiver, value)
} catch (e: Exception) {

}

전체 코드

package com.github.windsekirun.baseapp.module.attrparser

import android.content.res.TypedArray
import pyxis.uzuki.live.richutilskt.utils.tryCatch
import java.lang.reflect.Field

/**
 * PyxisBaseApp
 * Class: AttrParser
 * Created by Pyxis on 2018-01-29.
 *
 * Description:
 */

object AttrParser {

    @JvmStatic
    fun inject(array: TypedArray, receiver: Any) {
        tryCatch {
            var cls = receiver.javaClass

            do {
                cls.declaredFields.filter { it.declaredAnnotations.isNotEmpty() }.forEach { field ->
                    field.declaredAnnotations.forEach {
                        if (it is BindAttr) {
                            attachBindAttr(array, field, it, receiver)
                        }
                    }
                }

                try {
                    cls = cls.getSuperclass()
                } catch (e: Exception) {
                    break
                }

            } while (cls != null)
        }
    }

    private fun attachBindAttr(array: TypedArray, field: Field, bindAttr: BindAttr, receiver: Any) {
        val index = bindAttr.value
        val defValue = bindAttr.defValue

        val value: Any = when (bindAttr.type) {
            AttrType.Boolean -> array.getBoolean(index, (defValue != 0))
            AttrType.Color -> array.getColor(index, defValue)
            AttrType.Dimension -> array.getDimension(index, defValue.toFloat())
            AttrType.DimensionPixelSize -> array.getDimensionPixelSize(index, defValue)
            AttrType.Drawable -> array.getDrawable(index)
            AttrType.Float -> array.getFloat(index, defValue.toFloat())
            AttrType.Int -> array.getInt(index, defValue)
            AttrType.ResourceId -> array.getResourceId(index, defValue)
            AttrType.String -> array.getString(index)
            AttrType.Integer -> array.getInteger(index, defValue)
        }

        try {
            field.isAccessible = true
            field.set(receiver, value)
        } catch (e: Exception) {

        }
    }
}

코드에서 부를때는 AttrParser.inject(array, this) 로 사용하면 된다.

마무리

100줄 미만의 코드로 어노테이션 파싱 기능을 만들 수 있다는 건 정말로 큰 장점이기도 하다.

물론 디버깅 할 때 어려워진다는 단점은 있지만, 코드가 직관적으로 된다는 것도 무시할 수도 없게 된다.