Safe load Glide with ActivityReference, Default interface

도입

최근 ‘앱이 죽었다’ 라고 리포트가 돌아오면, 왠만하면 Glide의 ‘You cannot start a load for a destroyed activity’ 문제인 것 같다.

요점은, 이미 사라진 액티비티에 로드 작업을 실행할 수 없다는 것이다.

이 방법을 해결하기 위해서는 여러 방법이 있다.

  1. 로드 전 Activity.isDestroyed 나 Activity.isFinishing 를 체크한다.
  2. Activity context 대신 절대로 사라지지 않는 Application context 를 삽입한다.
  3. RequestManager 를 한번 불러오고 계속 재활용 한다.
  4. onPause 에 Glide.with(this).pauseRequest() 를 삽입한다.

하지만 1, 2번에는 문제점이 있다.

1번 방법을 사용할 경우 코드가 더러워 진다는 점도 있고, 2번의 경우에는 Activity Lifecycle를 통제하지 못하기 떄문에 가능하면 추천하지 않는 방법이다.

그래서, 오랜만에 일찍 퇴근하고 온 겸 나름대로 합리적으로 해결할 방법을 연구해보았다.

문제 해결을 위한 조건

이 문제를 좀 더 깔끔하게 해결하는 방법에 대한 조건은 다음과 같다.

  1. Activity.isDestroyed 나 isFinishing 를 체크하여 안전할 경우에만 반환한다.
  2. Activity 를 사용할 수 없을 때 최종 수단으로 Application Context 를 사용한다.
  3. (선택) 사용하려고 하는 곳에서 Activity 나 Context를 받지 않는다.
  4. (선택) 중복 코드를 사용하고 싶진 않다 (Activity 따로, Fragment 따로, Adapter 따로 하고 싶진 않다)

그리고 이 모든 조건을 충족시키는 방법은 ActivityReference 와 Default interface 이다. ActivityReference 에 대해서는 Hold Activity Reference with WeakReference in Kotlin (MVVM 1) 글에서 설명했으니 Default interface 에 대해 알아보자.

Default Interface란?

Java 8부터 도입된 기능으로 interface 에 default 라는 수식어를 붙임으로서 인터페이스에 기능을 부여할 수 있다. 물론, 이 부여된 기능을 상속하는 것 조차도 가능하다.

안드로이드에서는 Desugar 덕분에 하위 버전에서도 쓸 수 있는데, 잘 활용하면 겉으로는 Interface 지만 실제 클래스에서 사용했을 때 자연스러운 모습을 가질 수 있다.

기본 모습은 다음과 같다.

public interface DefaultInterface {
    
    default void test() {
        System.out.println("test");
    }
}

또한 상속도 가능하다.

class MainActivity : AppCompatActivity(), DefaultInterface {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun test() {
        super.test()
        println("test from MainActivity")
    }

}

기본적으로 자바에서는 다중 상속 문제를 위해 부모 클래스를 하나만 사용할 수 있게 하는 대신, interface 를 여러 개 사용하게 했는데, 이 default interface 덕분에 그 제약에서 벗어나게 된 것이다. Java 8 explained: Default Methods 글에서 Default interface 를 지원하게 된 계기를 알 수 있는데, 요약하면 ‘기존 인터페이스를 활용하게 되면 method를 추가하는 순간 해당 인터페이스를 구현한 모든 클래스에 메서드를 추가해야 된다는 단점이 있기에, default methods 를 구현한 것’ 이라고 말할 수 있다.

단순하게 말해서, 인터페이스는 더 이상 실행 블록이 없는 추상 클래스가 아니라, 실제 메서드를 가지고 있는 클래스이다. 물론, 부모 클래스가 인터페이스를 구현하고 있다면 자식 클래스도 인터페이스를 사용할 수 있고, 하나의 인터페이스가 여러 개의 인터페이스를 상속할 경우 그 하나의 인터페이스를 구현하는 클래스는 상속하는 모든 인터페이스를 쓸 수 있는 것이다.

다중 상속 문제에 관해서는 다른 인터페이스에 같은 형태의 메서드 (InterfaceX -> test(), InterfaceY -> test1()) 로 다이아몬드 문제가 일어날 경우 오류를 내보내고, 부모와 자식 인터페이스가 각각 같은 형태의 메서드로 다이아몬드 문제가 일어날 경우에도 오류를 내보냄으로서 문제를 회피하려고 했다.

어떻게 보면 작은 기능의 단위마다 Interface 를 만들고, 액티비티나 프래그먼트 등에서 인터페이스를 구현 후 사용하기가 가능한 것이다. 이렇게 되면, 왜 이미 BaseActivity, BaseFragment 등이 있는데 왜 도입하려고 하는지도 의문이 가기 쉽다. 하지만 startActivity() 같이 주로 쓰는 기능에 대해 짧고 간단하게 쓰고 싶다면 Utils.startActivity 등과 같이 별도 클래스를 만들고 Utils. 를 계속 붙이거나 static import 하는 방법이 있지만 작성하는 입장에서는 좀 더 코드를 작성해야 된다는 점이 있고, BaseActivity, BaseFragment 등에 만들면 Utils 등을 안 붙이긴 하지만 한 부분을 고쳐야 될 때 다른 부분도 고쳐야 된다는 것이다.

그래서 여기에서 문제를 깔끔하게 해결하기 위해서 Default method 를 적극적으로 도입하려고 하는 것이다.

실제 구현

public interface GlideInterface {

    @NonNull
    default RequestManager Glide() {
        Activity activity = ActivityReference.getActivtyReference();
        if (activity != null) {
            if (Build.VERSION.SDK_INT >= 17 && (!activity.isFinishing() && !activity.isDestroyed())) {
                return Glide.with(activity);
            } else if (Build.VERSION.SDK_INT == 16 && !activity.isFinishing()) {
                return Glide.with(activity);
            } else if (Build.VERSION.SDK_INT < 16) {
                return Glide.with(activity);
            }
        }

        Context context = ActivityReference.getContext();
        return Glide.with(context);
    }

    default void pauseRequest() {
        Glide().pauseRequests();
    }
}

ActivityReference 에서 Activity 객체를 가져와서 null 체크를 하고, 각 SDK 버전에 맞게 해당 화면이 파괴되었는지 아니면 종료되고 있는 중인지 판단한다. isDestroyed 메서드는 API 17에서 추가되었기 때문에 나눌 수 밖에 없었다.

위 activity 에 대한 조건을 거치고도 남았다는 것은 더 이상 activity 를 활용할 수 없다는 뜻이 되어 최종적으로 Application Context를 활용하여 RequestManager 를 null로 내보내는 형태를 취하고 있진 않지만, 아래 메서드처럼 활용할 수도 있다.

default boolean isAvailableUseGlide() {
    Activity activity = ActivityReference.getActivtyReference();
    return activity != null &&
            (Build.VERSION.SDK_INT >= 17 && (!activity.isFinishing() && !activity.isDestroyed()) 
                    || Build.VERSION.SDK_INT == 16 && !activity.isFinishing() || Build.VERSION.SDK_INT < 16);
}

사용

class MainActivity : AppCompatActivity(), GlideInterface {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        Glide.with(this).load("https://picsum.photos/200/300").into(imgTest) // before
        Glide().load("https://picsum.photos/200/300").into(imgTest) // after
    }
}

기존과 거의 동일한 인터페이스로 사용이 가능하여 find-replace 로도 마이그레이션 할 수 있다는 것도 마음에 들긴 하다.

물론 interface 인 만큼 Activity 나 Fragment 가 아닌 곳에서도 사용이 가능하므로, 앞으로도 꽤나 유용하게 쓰여질 것 같다.

추가

AppModule 사용시에는 구현체가 약간 달라진다.

public interface GlideInterface {
    @NonNull
    default GlideRequests Glide() {
        Activity activity = ActivityReference.getActivtyReference();
        if (activity != null) {
            if (Build.VERSION.SDK_INT >= 17 && (!activity.isFinishing() && !activity.isDestroyed())) {
                return GlideApp.with(activity);
            } else if (Build.VERSION.SDK_INT == 16 && !activity.isFinishing()) {
                return GlideApp.with(activity);
            } else if (Build.VERSION.SDK_INT < 16) {
                return GlideApp.with(activity);
            }
        }
        Context context = ActivityReference.getContext();
        return GlideApp.with(context);
    }

    default void pauseRequest() {
        Glide().pauseRequests();
    }
}

 

 

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  라이브러리에서 대부분 해주니 필요한 기능이 있으면 직접 구현해보는 거도 나쁘진 않을 것 같다.

Android Glide -> Fresco 삽질로 얻은 정리

목적: 트윗에 첨부된 이미지, 프로필 사진을 빠르게 불러오기 위해 Glide 대신 Fresco 도입

ImageView -> SimpleDraweeView ( Fresco ) 로 변경.

뭐. 이거야 문제는 없음. 단, 아무리 SimpleDraweeView 가 ImageView 를 부모 클래스로 가지고 있다고 해도, 절대로 setImageDrawable 같은 직접적으로 접근하는 것을 사용하지 않아야. 자세한 내용은 여기로

<com.facebook.drawee.view.SimpleDraweeView
    android:id="@+id/profileImage"
    android:layout_width="50dip"
    android:layout_height="50dip"
    android:layout_alignParentStart="true"
    android:layout_marginLeft="10dip"
    android:layout_marginTop="5dip" />

GenericDraweeHieracy 로 설정해야 되는 것들을 따로 Utils 클래스로 분리

주로 사각형 외곽 원형, 헤더 이미지를 위한 CENTER_CROP + 반투명 검은 배경 설치 정도가 해당됨

public class FrescoUtils {

    public static GenericDraweeHierarchy setRounding(boolean isCircleUseAvater, SimpleDraweeView imageView) {
        GenericDraweeHierarchy hierarchy = imageView.getHierarchy();

        RoundingParams roundingParams = hierarchy.getRoundingParams();
        if (roundingParams == null)
            roundingParams = new RoundingParams();

        roundingParams.setCornersRadius(10);
        roundingParams.setRoundAsCircle(isCircleUseAvater);
        hierarchy.setRoundingParams(roundingParams);

        return hierarchy;
    }

    public static GenericDraweeHierarchy forHeader(SimpleDraweeView imageView) {
        GenericDraweeHierarchy hierarchy = imageView.getHierarchy();
        ColorFilter filter = new PorterDuffColorFilter(0x87000000, PorterDuff.Mode.DARKEN);

        hierarchy.setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP);
        hierarchy.setActualImageColorFilter(filter);
        return hierarchy;
    }

    public static GenericDraweeHierarchy setCenterCrop(SimpleDraweeView imageView) {
        GenericDraweeHierarchy hierarchy = imageView.getHierarchy();

        hierarchy.setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP);
        return hierarchy;
    }
}

결과: 다소 만족

실제 사용하고 있었던 G Pro 2 Android 5.0.2 에서는 별다른 효과가 없었으나, Optimus G Android 6.0 PureNexus 에 설치해보니 스크롤에 끊김이 없음을 확인.

(아마, 더 저사양 기기에서 확실하게 잘 굴러가지 않을까 생각하는데.. 뭐, 테스트 더 해봐야 알겠지.)

여기서 더 해야할 것


본 글은 본인의 Medium에 있던 글을 가져온 것입니다.