Injection by Annotation in Kotlin :: parse AttributeSet

WindSekirun (wind.seo)·2022년 4월 26일
0

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2018-01-31

도입

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

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

이 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 가 반환된다.

그 다음, 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

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줄 미만의 코드로 어노테이션 파싱 기능을 만들 수 있다는 건 정말로 큰 장점이기도 하다.

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

profile
Android Developer @kakaobank

0개의 댓글