Blind(Overlay) image using Glide Transformation

도입

안드로이드에서 웹으로부터 이미지를 불러오는 데에 사용되는 대표적 라이브러리엔 Glide, Fresco, Picasso 등이 있는데, 이 세 개의 라이브러리는 Transformation 라고 하는 기능을 제공한다.

쉽게 설명하자면, 다운받은 이미지를 이미지 뷰에 띄우기 전, 다운받은 이미지를 조작할 수 있게 해주는 기능으로, Glide v4 기준 총 2개의 메서드로 구성된다.

  • protected Bitmap transform(BItmapPool,  Bitmap, int, int): Bitmap를 파라미터로 받아 조작 후 Bitmap를 반환하는 메서드이다. 여기서 주로 변형 작업을 시행한다.
  • public void updateDiskCacheKey(MessageDigest): 디스크 캐시 키를 업데이트 할 필요가 있을 때 사용되는 메서드이다.

이 기능을 사용해서 다운받은 이미지에 투명도 20%의 검은색 배경화면을 올려 블라인드 처리를 해보려 한다.

구현

먼저, BitmapTransformation 을 상속하는 새 클래스를 만들고, 두 개의 메서드를 구현한다. BitmapTransformation 자체가 abstract이기 때문에 바로 구현이 될 것이다.

public class OverlayTransformation extends BitmapTransformation {

       @Override
       protected Bitmap transform(@NonNull BitmapPool pool, @NonNull Bitmap toTransform, int outWidth, int outHeight) {
           return null;
       }

       @Override
       public void updateDiskCacheKey(MessageDigest messageDigest) {

       }
   }

먼저 updateDiskCacheKey는 지금 작성하는 데엔 중요하지 않으므로, 데이터를 대충 채워넣는다.

@Override
public void updateDiskCacheKey(MessageDigest messageDigest) {
    messageDigest.update("overlaytransformation".getBytes());
}

그 다음, OverlayTransformation 의 필드에 Paint를 추가하고, 생성자에서 initialize 해준다.

private Paint mBlindPaint;

public OverlayTransformation() {
    mBlindPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mBlindPaint.setAntiAlias(true);
    mBlindPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
    mBlindPaint.setStyle(Paint.Style.FILL);
    mBlindPaint.setColor(Color.argb(35, 0, 0, 0));
}

마지막으로 transform 메서드에서 원본 비트맵 위에 Paint를 올려준다.

@Override
protected Bitmap transform(@NonNull BitmapPool pool, @NonNull Bitmap toTransform, int outWidth, int outHeight) {
    Bitmap result = Bitmap.createBitmap(toTransform.getWidth(), toTransform.getHeight(), Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(result);
    canvas.drawBitmap(toTransform, new Matrix(), null);
    canvas.drawRect(0, 0, toTransform.getWidth(), toTransform.getHeight(), mBlindPaint);
    return result;
}

사용할땐 다음과 같이 사용하면 된다.

Glide.with(imageView.getContext()).load(url).apply(new RequestOptions().transform(new BlindTransformation())).into(imageView);

마무리

생각보다 잘 쓰면 꽤나 강력한 기능이기도 하다.

위에서 쓴 오버레이 말고도, 예를 들어 이미지에 ‘Approved’ 같은 딱지를 붙인다거나, RenderScript를 이용해 Blur 처리를 한다던가, Mask를 적용할 수 있다.

이미 wasabeef/glide-transformation  라이브러리에서 대부분 해주니 필요한 기능이 있으면 직접 구현해보는 거도 나쁘진 않을 것 같다.

커스텀 뷰의 XML 속성 파싱 라이브러리, AttributeParser 소개

분명히 블로그 조회수 1만 찍으면 mvvm에 관해 올린다고 몇일 전 언급한 적이 있지만…

도입

최근에 앱을 개발하면서 자주 커스텀 뷰를 많이 쓰게 된다.

보통 앱들 보면 타이틀 바 라던가 그런 공통적 요소가 많이 있는데, 한 번만 잘 설계해두면 xml의 속성만을 바꾸는 것으로 쉽게 되니 빠르게 개발하기엔 좋다.

그러나… XML의 속성이 10개 이상 넘어갈 경우에는 무심코 머리를 짚게 된다.

val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CombinedButton) ?: return
textPrimary = typedArray.getString(R.styleable.CombinedButton_textPrimary)
textSecondary = typedArray.getString(R.styleable.CombinedButton_textSecondary)
textPrimaryColor = typedArray.getColor(R.styleable.CombinedButton_textPrimaryColor, Color.BLACK)
textSecondaryColor = typedArray.getColor(R.styleable.CombinedButton_textSecondaryColor, Color.BLACK)
textPrimarySize = typedArray.getDimension(R.styleable.CombinedButton_textPrimarySize, resources.getDimensionPixelSize(R.dimen.combined_text_view_default_size).toFloat())
textSecondarySize = typedArray.getDimension(R.styleable.CombinedButton_textSecondarySize, resources.getDimensionPixelSize(R.dimen.combined_text_view_default_size).toFloat())
textExtraSpace = typedArray.getInt(R.styleable.CombinedButton_textExtraSpace, 1)
fontPrimaryText = typedArray.getString(R.styleable.CombinedButton_fontPrimaryText)
fontSecondaryText = typedArray.getString(R.styleable.CombinedButton_fontSecondaryText)
textPrimaryStyle = typedArray.getInt(R.styleable.CombinedButton_textPrimaryStyle, 0)
textSecondaryStyle = typedArray.getInt(R.styleable.CombinedButton_textSecondaryStyle, 0)

아마 1월 31일 쯔음에 Reflection 을 이용해서 비슷한 걸 만든 기억이 있지만, 오히려 기존에 했던 것과 그렇게 차이가 나지 않아 안 쓰게 되었다.

그러면, 좀 더 자동화되는 라이브러리를 만들어서 혼자 잘 쓰자! 라고 생각해서 심야 영화를 본 뒤 카페에서 공부하면서 작업한 결과, 오늘 소개한 AttributeParser 가 완성되게 되었다.

이론 기술

처음으로 Reflection 기반의 Annotation Processing 가 아닌, Annotation Processor 를 통한 Annotation Processing 기반으로 작성되었다. 가볍게 쓰고 + private 같은거를 싹다 무시하고 개발하기에는 Reflection가 최고이지만 각각 장단점이 있길 마련이다.

florent37의 Dagger-Auto-inject 를 포크해서 기능 추가할 때 살짝 Annotation Processor 를 공부한 적은 있지만, 처음부터 설계하고 작성하기는 처음이다.

간단히 Annotation Processor 에 대해 설명하자면, 컴파일 시간에 대상 어노테이션을 긁어 모아 클래스 파일을 생성하고, 그걸 사용하는 개념이다. Dagger, DataBinding, ButterKnife, Glide, PermissionDispatcher 등 이미 많은 라이브러리에서 사용되고 있고, 런타임 시간이 아닌 컴파일 시간에 실행되 오버헤드가 없고, 런타임에 포함되는 코드가 적거나 없고, 실제로 생성된 코드를 보며 디버깅 하기 쉽다는 등 장점이 많다.

그러면 왜 지금까지 Reflection만을 사용했는가.. 하면, annotation processor 는 컴파일 시간때 클래스 파일을 생성하므로 JavaPoet 같은라이브러리를 사용하여 자바 코드를 직접 작성해야 한다. 보통 코드를 짤 때 IDE의 지원을 받아서 실제로 키보드로 치는 코드 양은 적으나 annotation processor 를 위해 작성하는 코드는 IDE의 지원을 일절 받을 수 없는 맨땅의 코드라 처음 입문하기가 상당히 어렵다. 시간도 많이 들어가기도 하고. 그에 대비해 Reflection 는 상대적으로 빠른 시간 안에 기능을 구현할 수 있어 Reflection 을 적극적으로 사용하게 된 것이다.

그러다가 실제로 작성해보고 싶기도 했고, 자동화의 끝판왕은 자동 코드 파일 생성이라고 굳게 믿고 있기 때문에 Annotation Processor로 구현하기로 마음을 먹은 것이다.

설계

1월 31일날 만든 AttrParser 의 단점을 먼저 살펴보자.

맨 위의 TypeArray.getString() 을 반복하지 않아도 된다는 장점은 있지만…

  1. 쓸데없이 annotation 을 하나로 통합해서 type를 계속 적어줘야 한다.
  2. 같은 이유로 default value를 설정할 때, boolean 이나 float 를 int형으로 바꿔서 하고 있어 매우 헷갈린다
  3. Index를 수동으로 적어줘야 한다.

라는 단점이 있다. 개인적으로 느낀거지만 이전과 다를 바가 없다고 느꼈다.

그래서, 이번 AttributeParser 는 아래 사항을 주안점으로 삼았다.

  1. Annotation Processor 를 이용한 코드 파일 생성
  2. 각 type에 따른 Annotation 분리
  3. 자동 로그 출력 기능 (타입, 변수명, 실제 값까지)

Annotation Processor를 사용하기 위해서는 총 2개의 모듈이 필요하다. 하나는 annotation 이나 기타 클래스를 담은 일반 라이브러리와 annotation 모듈을 의존하는 compiler 모듈이다. 그리고 데모 앱에서는 이 두개 모듈을 각각 implementation, annotationProcessor (또는 kapt) 로 구성할 필요가 있다.

어노테이션들은 다음같이 구성했다.

package pyxis.uzuki.live.attribute.parser.annotation

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.SOURCE)
annotation class AttrInt(val value: String = "", val defValue: Int = 0)

총 5줄 정도의 아주 간단한 어노테이션이다.

대충 잡은 설계로는, CustomView 어노테이션으로 커스텀 뷰 단위를 나누고, 그 안에서 Attr 어노테이션의 위치와 필드명, 타입을 찾는다. 그리고 서로 매핑하여 데이터를 가공하고,  CustomView 어노테이션이 붙은 클래스를 기반으로 하는 커스텀 클래스를 만든다. 그 안에서 각각의 값을 가지고 있기 위한 field들과 적용하기 위한 메서드들이 생성될 것이다.

컴파일러 모듈은 조금 더 많은데, 각각 어노테이션을 매핑하기 위한 모델이나 홀더, 그리고 유틸과 상수값들을 정의하고, AttributeParserProcessor 란 클래스에서 이 클래스들을 사용하여 생성하는 역할을 한다.

AttributeParserProcessor 에서는 처음으로 지정된 어노테이션이 어느 위치에 있는지 전부 찾아서 매핑한 다음, CustomView 어노테이션의 갯수만큼 반복을 돌려서 안에 있는 Attr 어노테이션 등을 파싱하는 것이다.

Reflection 과 다르게 Annotation Processor 는 런타임 코드에 바로 주입될 수 없으므로 파싱한 결과는 우선 생성된 클래스의 private static 필드로서 가지고 있고, 사용자가 apply 메서드 등을 부르면 해당 값에 설정하는 역할을 할 것이다.

이런 과정을 거쳐서 생성된 클래스가 바로 밑의 클래스이다.

package pyxis.uzuki.live.attribute.parser;

import android.content.res.TypedArray;
import android.util.AttributeSet;
import java.lang.String;
import pyxis.uzuki.live.attribute.parser.demo.R;
import pyxis.uzuki.live.attribute.parser.demo.StyleView;

public class StyleViewAttributes {
  private static boolean booleanTest;

  private static int colorTest;

  private static float dimensionTest;

  private static int intTest;

  private static float floatTest;

  private static int resourceTest;

  private static String stringTest;

  private R r;

  public static void apply(StyleView styleView, AttributeSet set) {
    apply(styleView, styleView.getContext().obtainStyledAttributes(set, R.styleable.StyleView));
  }

  public static void apply(StyleView styleView, TypedArray array) {
    bindAttributes(array);

    styleView.booleanTest = booleanTest;
    styleView.colorTest = colorTest;
    styleView.dimensionTest = dimensionTest;
    styleView.intTest = intTest;
    styleView.floatTest = floatTest;
    styleView.resourceTest = resourceTest;
    styleView.stringTest = stringTest;
  }

  public static void printVariables() {
    android.util.Log.d("StyleView", "==================== StyleView ====================" + 
    "\nboolean booleanTest = " + booleanTest +  
    "\nint colorTest = " + colorTest +  
    "\nfloat dimensionTest = " + dimensionTest +  
    "\nint intTest = " + intTest +  
    "\nfloat floatTest = " + floatTest +  
    "\nint resourceTest = " + resourceTest +  
    "\njava.lang.String stringTest = " + stringTest +  
    "\n====================================================");
  }

  private static void bindAttributes(TypedArray array) {
    if (array == null) return;

    booleanTest = array.getBoolean(R.styleable.StyleView_booleanTest, false);
    colorTest = array.getColor(R.styleable.StyleView_colorTest, 0);
    dimensionTest = array.getDimension(R.styleable.StyleView_dimensionTest, 0.0f);
    intTest = array.getInt(R.styleable.StyleView_intTest, 0);
    floatTest = array.getFloat(R.styleable.StyleView_floatTest, 0.0f);
    resourceTest = array.getResourceId(R.styleable.StyleView_resourceTest, 0);
    stringTest = array.getString(R.styleable.StyleView_stringTest);

    array.recycle();
  }
}

물론, 사용자가 위의 클래스를 신경 쓸 필요는 전혀 없다. 단순히 apply 에 필요한 파라미터만 주입해주면 값이 설정되는 구조이다.

마무리

평소에 그나마 공부를 해왔던 덕분에, 실제로 만드는 시간은 약 7시간 정도 걸린 것 같다.

실제로 써보니 Reflection 보다 강력한 부분도 있었고, 좀 안 좋은 부분도 있었지만 설계만 잘 하면 오래토록 쓸 수 있을 것 같다.

마지막으로, 오늘 만든 라이브러리는 당연하겠지만 Github에 업로드 되어있다.

WindSekirun/AttributeParser

bintray 에 업로드도 요청했으니, 아마 오늘 밤쯤에는 올라가지 않을까 싶다.

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

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