Android Jetpack – Lifecycle in JDK8

도입

Android Jetpack 중에서 Lifecycle-Aware Components라는 컴포넌트가 있다. 이 컴포넌트는 Activity나 Fragment 같은 안드로이드의 자체 Lifecycle를 가지는 클래스의 Lifecycle 이벤트를 비-Activity 클래스, 즉 ViewModel 이나 Manager 등에 전달할 수 있는 역할을 가진다.

사용 방법은 대체로 간단한데, 받는 쪽에서는 LifecycleObserver 를 구현하고 @OnLifecycleEvent(ON_RESUME) 의 어노테이션을 부착하면 되고, Activity 에서는 lifecycle.addObserver(viewModel) 를 사용하면 ViewModel 등에서 ON_RESUME, ON_PAUSE, ON_DESTROY 등의 이벤트를 받을 수 있다.

@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
open fun onDestroy() {
    compositeDisposable.clear()
}

다만 이 Lifecycle-aware Components를 사용할 때에 JDK 8를 이상으로 하고 JDK 8의 언어 지원 기능을 사용하는 프로젝트라면 사용 방법이 다른데, 이에 대해 알아보려 한다.

JDK 8 언어 지원 기능 사용시

Lifecycle 의 클래스 주석에는 아래와 같은 내용이 있다.

 * If you use <b>Java 8 Language</b>, then observe events with {@link DefaultLifecycleObserver}.
 * To include it you should add {@code "androidx.lifecycle:common-java8:<version>"} to your
 * build.gradle file.
 * <pre>
 * class TestObserver implements DefaultLifecycleObserver {
 *     {@literal @}Override
 *     public void onCreate(LifecycleOwner owner) {
 *         // your code
 *     }
 * }
 * </pre>

Java 8에서는 Default Interface라고 하는, 인터페이스에 기본적인 코드 형태를 가질 수 있게 하는 기능이 신규로 나오면서 더 이상 Annotation Processor와 Reflection을 기반으로 하는 @OnLifecycleEvent가 필요가 없어진 것이다. (또한 Java 8이 안드로이드의 메인 스트림이 되면 @OnLifecycleEvent를 Deprecated 할 예정이라고도 한다.)

기존에 사용하던 OnLifecycleEvent를 DefaultLifecycleObserver로 변경하려면 Gradle에 아래 의존성을 추가한다.

 implementation group: 'android.arch.lifecycle', name: 'common-java8', version: '1.1.1'

문서 작성일(2018-12-04) 기준 최신 버전은 1.1.1로,  Maven Repository 사이트에서 최신 버전을 참고하면 된다.

의존성을 추가한 뒤에는 LifecycleObserver를 구현하는 대신 DefaultLifecycleObserver 를 구현받고 onResume, onPause 등을 오버라이드 하면 된다.

override fun onDestroy(owner: LifecycleOwner) {
        super.onDestroy(owner)
        compositeDisposable.clear()
}

오류:  Super call to java default methods are prohibited in jvm target 1.6

이는 Kotlin compiler가 JVM 타겟을 1.8이 아닌 1.6으로 잡고 있어서 발생하는 상황이다. 해결을 위해서는 아래 옵션을 build.gradle 의 android 섹션 밑에 두면 된다.

kotlinOptions {
        jvmTarget = '1.8'
}

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 보다도 스트림 소개 글이 더 길어진 것 같은 기분이다(..)

** 추가

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