이 글은 기존 운영했던 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은 필드만 적용 가능하게 해야된다는 걸 생각해보면, 아래와 같이 정리할 수 있다.
그리고 이를 코드로 작성해보자.
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줄 미만의 코드로 어노테이션 파싱 기능을 만들 수 있다는 건 정말로 큰 장점이기도 하다.
물론 디버깅 할 때 어려워진다는 단점은 있지만, 코드가 직관적으로 된다는 것도 무시할 수도 없게 된다.