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();
    }
}

 

 

Kotlin – filterIsInstance with reflection (NumberPicker text color)

최근 개발하면서 NumberPicker 를 사용하는 일이 잦아졌는데, NumberPicker의 텍스트 색상까지 변경할 수 있어야 했었다.

대충 인터넷에 찾아본 바로는 선택된 텍스트 색상은 NumberPicker의 child 중에서 EditText 인 것을 찾아 텍스트 색상을 설정하면 되고, 선택되지 않은 부분(바퀴 부분) 은 mSelectorWheelPaint 라는 private field 에 접근하여 수정한다는 것 같다.

1. 기존에는?

보통 자바로 짜면, 아래와 비슷한 코드가 나올 것이다.

/**
 * TestPicker.java
 * Created by Pyxis on 2017. 9. 2..
 */

public class TestPicker extends NumberPicker{
    public TestPicker(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public TestPicker(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void setTextColor(int color) {
        int count = getChildCount();

        for (int i = 0; i < count - 1; i++) {
            View view = getChildAt(i);
            if (view instanceof EditText) {
                try {
                    EditText editText = (EditText) view;
                    editText.setTextColor(color);

                    Field selectorWheelPaint = NumberPicker.class.getDeclaredField("mSelectorWheelPaint");
                    selectorWheelPaint.setAccessible(true);
                    ((Paint)selectorWheelPaint.get(this)).setColor(color);
                    invalidate();
                } catch (Exception e) {
                    Log.e(TestPicker.class.getSimpleName(), "Reflection failed!");
                }
            }
        }
    }
}

NumberPicker 클래스를 상속받는 클래스를 만들어, setTextColor 란 메소드를 추가적으로 만든다.

setTextColor 안에는 자식의 갯수만큼 반복을 돌려, 해당 View의 실제 타입이 EditText인지 체크해서 텍스트 색상을 설정하거나, mSelectorWheelPaint에 접근해 색상을 설정하는 것이다.

2 .코틀린에서

코틀린에서 instanceOf의 역할을 하는 것은 is인데, 이런 식으로 사용한다.if (view is EditText)

위에 있는 자바를 그대로 변환해보자.

class TestPicker constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : NumberPicker(context, attrs, defStyleAttr) {

    fun setTextColor(color: Int) {
        val count = childCount

        for (i in 0 until count) {
            val view = getChildAt(i)
            if (view is EditText) {
                tryCatch {
                    view.setTextColor(color)

                    val selectorWheelPaint = NumberPicker::class.java.getDeclaredField("mSelectorWheelPaint")
                    selectorWheelPaint.isAccessible = true
                    (selectorWheelPaint.get(this) as Paint).color = color
                    invalidate()
                }
            }
        }
    }
}

지난 글에서 코드를 조금이라도 줄여줄 수 있게 만든 tryCatch 메소드를 사용하고,  until 이라는 infix 메소드를 사용해서 간편하게 표현했다.

3. filterIsInstance 소개

코틀린에도 자바8에서 소개된 Stream API가 있는데, .map,  .filter, .flatMap  등을 전부 제공한다.

이 기능을 사용하여 일일히 for-loop를 돌리지 않아도 처리가 가능하다.

3-1. 그전에 Stream API 가 뭐지?

Stream API는 자바8부터 소개된 기능으로 컬렉션의 요소를 하나씩 참조해서 처리할 수 있게 하는 객체다.

이해하려면 이 글을 보는 것이 아니라 다른 글을 보는 것이 좋을 정도기는 한데, 일단 예제 코드를 짜보자.

A, a, B, b 4개의 문자열이 담긴 ArrayList를 모두 대문자로 바꿔 출력하는 코드이다.

public static void main(String... args) {
        ArrayList<String> list = new ArrayList();
        list.add("A");
        list.add("a");
        list.add("B");
        list.add("b");

        System.out.println("original list");
        for (String item : list) {
            System.out.println(item);
        }
        System.out.println();

        System.out.println("~JDK7, Modify UPPERCASE");
        for (String item : list) {
            System.out.println(item.toUpperCase());
        }

        System.out.println();

        System.out.println("Using Stream API, Modify UPPERCASE");
        list.stream()
                .map(String::toUpperCase)
                .forEach(System.out::println);
    }

출력 결과는 아래와 같다.

original list
A
a
B
b

~JDK7, Modify UPPERCASE
A
A
B
B

Using Stream API, Modify UPPERCASE
A
A
B
B

3-2 조금 더 복잡한 예제

지금은 간단한 예제지만, 만일 아래와 같은 로직을 구현한다고 해보자.

  • 유저 리스트를 데이터베이스로부터 가져온다.
  • 유저 타입이 BANNED 인 유저를 필터링한다.
  • 필터링한 유저 리스트를 오름차순 정렬한다.
  • 한명 한명씩 출력한다.

유저 클래스는 아래와 같다.

 public class User {
        public static final int USER_OK = 0;
        public static final int USER_BANNED = 1;
        
        private int status = USER_OK;
        private long id = 0;
        private String name = "";
        private String email = "";
        public String getName() {
            return name;
        }

        @Override
        public String toString() {
            return "USER{id = " + id + " , name = " + name + " , email = " + email + ")";
        }
}

그러면, 기존 방식대로 구현해보자.

List<User> userList = db.getAllUserList();

ArrayList<User> bannedList = new ArrayList<>();
for (User user : userList) {
    if (user.status == User.USER_BANNED) {
        bannedList.add(user);
    }
}

Collections.sort(bannedList, new Comparator<User>() {
    @Override
    public int compare(User o1, User o2) {
        return o1.getName().compareTo(o2.getName());
    }
});

for (User user : bannedList) {
    System.out.println(user.toString());
}

이번엔 스트림으로 구현해보자.

List<User> userList = db.getAllUserList();

userList.stream()
        .filter(user -> user.status == User.USER_BANNED)
        .sorted(Comparator.comparing(User::getName))
        .forEach(System.out::println);

조금 그림과 같이 설명하자면…

Stream API diagram with example code

아이디어 출처: Processing Data with Java SE 8 Streams, Part 1 by Raoul-Gabriel Urm (http://www.oracle.com/technetwork/articles/java/ma14-java-se-8-streams-2177646.html)

  • 맨 위에서는 데이터베이스에서 가져온 유저 전체 리스트가 있다.
  • 그 다음, filter 로 type가 USER_BANNED 인 유저만 가져온다.
  • 그다음, Comparator.comparing 로 유저의 이름을 기준으로 정렬한다.
  • 마지막으로 한 개씩 꺼내서 순서대로 출력한다.

이전까지 구현했던 코드에 비해 많이 간결해졌음을 알 수 있다.

이처럼 스트림은 처리 코드를 람다로 제공하고, 중간 과정이 별도로 있어 변수를 추가로 선언하지 않아도 사용이 가능하다는 장점이 있다.

그 외에도 병렬 처리 프로그래밍에서 동시성에 대한 보장을 해주기도 한다.


 

… 이제 본론으로 돌아와서, 코틀린의 스트림(?)도 자바 스트림과 비슷하다.

다만 List의 확장 메소드로 제공하기에 사용하기 전에 Stream 객체를 얻을 필요와 없다는 점과 다양한 필터를 제공한다는 점에서 약간 다르다.

filterIsInstance 는 말 그대로 Instance, 즉 주어진 객체의 실제 타입을 체크해서 맞는 객체만 리턴한다.

자바라면, .filter(user -> user instanceof User) 정도가 된다.

실제 사용할 때에는 .filterIsInstance<Int>() 식으로 체크하고 싶은 타입을 선언하면 된다.

4. 실제로 사용해보자.

class TestPicker constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : NumberPicker(context, attrs, defStyleAttr) {
    fun setTextColor(color: Int) {
        val count = childCount

        (0 until count)
                .map { getChildAt(it) }
                .filterIsInstance<EditText>()
                .forEach {
                    tryCatch {
                        it.setTextColor(color)
                        val selectorWheelPaint = NumberPicker::class.java.getDeclaredField("mSelectorWheelPaint")
                        selectorWheelPaint.isAccessible = true
                        (selectorWheelPaint.get(this) as Paint).color = color
                        invalidate()
                    }
                }
    }
}

(0 until count) 라는 조금 특별한 문구가 보이는데, 0 에서 부터 count – 1 까지의 IntRange 를 리턴한다.

IntRange는 Itreable 를 구현하고 있어 Iterable.map의 사용이 가능하다.

원본 코드는 아래와 같다.

/**
 * Returns a range from this value up to but excluding the specified [to] value.
 * [this] 로 주어진 값부터 [to] 로 주어진 범위를 리턴하는데, value 자체는 제외한다.
 * 
 * If the [to] value is less than or equal to [Int.MIN_VALUE] the returned range is empty.
 * 만일 [to] 가 Int.MIN_VALUE 보다 적거나 같을 경우, 리턴되는 Range는 비어있습니다.
 */
public infix fun Int.until(to: Int): IntRange {
    if (to <= Int.MIN_VALUE) return IntRange.EMPTY
    return this .. (to - 1).toInt()
}

그다음 map로 자식들의 리스트를 넣고, filterIsInstance로 타입이 EditText 인 것을 찾아 forEach로 하나씩 수행한다.

그 뒤는 변환한 코틀린 코드와 다를 바가 없어진다.

마무리

내가 코틀린을 적극적으로 쓰게 된 이유중 하나가 당시 안드로이드는 Java 8 language feature 지원이 매우 빈약했기에 나름대로 대안을 찾은 것이다.

그나저나, 정작 글의 키워드였던 filterIsInstance 보다도 스트림 소개 글이 더 길어진 것 같은 기분이다(..)

** 추가

도식도의 사진이 이미지 최적화로 인해 깨진 것 같아서, 원본 링크를 남겨두니 필요한 사람이 있으면 이 쪽을 참고하면 괜찮을 것이다.

Android Studio 3.0 – Java 8 language feature

2017년 3월 15일, Android Studio 내부에서 Java 8 language feature을 활성화하기 위해 사용하던 Jack compiler 가 Deprecated 되었다.

처음엔 Jack toolchain 을 통해 Java 8 지원 추가를 테스트하였으나, 시간이 지남에 따라 Annotation processors(어노테이션 처리기), Bytecode analyzers and rewriters(바이트 코드 분석, 작성기) 들이 Jack로 전환하는 비용이 높아서 앞으로 Java 8 언어 기능들이 안드로이드 빌드 시스템에서 기본적으로 지원되게 할 예정이라고 한다.

물론 Jack 대신 Retrolambda를 쓰고 있었기에 문제 없었다. 그리고 안드로이드 스튜디오 3.0 Preview가 나오면서 위에 밝혀진 ‘Java 8 언어 기능에 대한 안드로이드 빌드 시스템 내부 지원’ 이 Desugar 란 이름으로 돌아왔다.

Desugar 구조

Desugar 의 구조는 아래와 같다. (출처: Android Developer, Use Java 8 language features)

desugar structure

 

.java 가 javac 를 통해 컴파일 되면 class가 나오는데, 이 class를 기반으로 desugar 작업을 통하여 나온 결과물을 dex 화 시키는 것이다.

즉 desugar를 말로 표현하자면 javac – dex 사이에 Java 8 언어 기능을 사용한 코드를 하위 호환에 맞게 변환시키는 과정이다.

물론 기존과 같이 SDK 버전(minSdkVersion)에 따라 작동 범위 차이는 있다.

Java 8 Language Feature, API

Java 8 언어 기능 / API호환되는 minSdkVersion
람다식모두 지원됨. 하지만 람다에 잡히는 모든 값이 serializable 해야함
메서드 참조모두 지원됨.
형식 주석모두 지원됨. 하지만 타입 어노테이션에 대한 정보는 컴파일 시간에 활성화된다. (런타임 상 정보는 나오지 않음) ElementType.TYPE_USE, ElementType.TYPE_PARAMETER 외의 TYPE들은 24 이상부터 사용 가능
기본, 정적 인터페이스모두 지원됨
반복 가능한 주석모두 지원됨
annotation.Repeatable24 이상
AnnotatedElement.getAnnotationByType(Class)24 이상
스트림24 이상
Functional Interface24 이상
Method.isDefault()24 이상
Function util24 이상

desugar 가 도입되면서 그레들 플러그인에도 변경점이 생겼는데, 바로 Retrolambda 와 Jack, DexGuard를 사용중일 경우 desugar를 도입하도록 경고를 표시하는 점이다.

warning of using desugar

아직까지는 위 3개를 사용중이면 위 3개가 우선적으로 작동하지만 좀 더 desugar가 안정화 되면 마이그레이션 할 예정이다.

결국은 Java8를 컴파일 옵션으로 두는 프로젝트면 기본적으로 활성화되게 되어, 별도 플러그인이 필요없어진 셈이다.

만일 비활성화 하고 싶다면 gradle.properties 파일에 android.enableDesugar=false 를 두면 되는거같다.