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

 

 

Databinding – implement custom BindingAdapter (MVVM 3)

도입

최근 MVVM 을 도입하면서 Databinding 을 써야될 때가 왔는데, 정작 쓰려고 하니 좀 어려운 부분이 많았다.

이 글에서는 커스텀 뷰의 editText 에 onTextChanged 를 구현하는 작업을 설명하려 한다.

커스텀 뷰 구조

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
        <import type="android.view.View" />
        <variable
            name="inputView"
            type="..." />
    </data>
    <FrameLayout>
        <EditText
            android:id="@+id/editText"
            ... />
        <ImageView
            android:id="@+id/imgValid"
            ... />
    </FrameLayout>
</layout>

기본적인 EditText 에 regex 를 통한 valid 기능이 들어간 심플한 커스텀 뷰이다.

물론, 여기에 editText 의 hintText, inputType 등을 집어넣어서 바인딩 할 수도 있다.

문제

MVVM 의 규칙 중 프로세스 로직은 ViewModel 에 위임한다는 조건이 있어, XML 자체에 ViewModel 에 대한 의존성을 넣고 XML -> ViewModel 로 텍스트 변화나 버튼 클릭 등 이벤트를 Activity/Fragment 가 아닌 ViewModel 자체가 받게 했다.

보통, EditText 에 onTextChanged 를 붙이려면, 아래와 같이 구현하고

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <import type="android.view.View" />

        <variable
            name="viewModel"
            type="SignupHeightViewModel" />
    </data>

    <LinearLayout>

        ...

        <LinearLayout>

            <EditText
                android:id="@+id/editHeight"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_marginLeft="15dp"
                android:layout_weight="1"
                android:background="@drawable/transparent"
                android:gravity="right|center_vertical"
                android:inputType="number"
                android:onTextChanged="@{(text, start, before, count) -> viewModel.onHeightChanged(text)}"
                android:textColor="#000"
                android:textColorHint="#000000"
                android:textSize="15sp" />

            <TextView />
        </LinearLayout>

       ...
    </LinearLayout>
</layout>

ViewModel 에 메서드를 구현하면 된다.

public void onHeightChanged(CharSequence text) {
    if (!TextUtils.isEmpty(text)) {
        mHeight = Integer.parseInt(text.toString());
    } else {
        mHeight = 0;
    }
}

그런데, EditText 를 포함하는 커스텀 뷰에 onTextChanged 를 주면 찾을 수 없다며 오류가 난다.

이를 해결하는 방법은 DataBindingComponent 를 직접 구현하는 것이다.

DataBindingComponent 구현

public class BindingComponent implements DataBindingComponent {
    private final BindAdapter mAdapter;
    public BindingComponent(Context context) {
        this.mAdapter = new BindAdapter(context);
    }
    public static BindingComponent create(Context context) {
        return new BindingComponent(context);
    }
    @Override
    public BindAdapter getBindAdapter() {
        return mAdapter;
    }
}

사실상 공통 코드라 설명만 하자면, BindAdapter 라는 클래스를 DataBindingComponent 의 요소로서 사용할 수 있게 해주는 것이다.

데이터 바인딩 객체 생성이나 기본 컴포넌트 설정엔 이 클래스를 활용하고, 실제 기능을 붙이는 곳은 BindAdapter 인 셈이다.

기본 컴포넌트 설정엔 DataBindingUtil.setDefaultComponent(BindingComponent.create(this)); 을, 데이터 바인딩 객체 생성때는 DataBindingUtil.setContentView (Activity, layoutId, DataBindingComponent)를 사용한다.

BindAdapter 구현

기본 형태는 다음과 같다.

public class BindAdapter {
    final Context mContext;
    
    public BindAdapter(Context context) {
        mContext = context;
    }
}

데이터바인딩의 새 요소를 만들 때에는 @BindingAdapter(“name”) 가 달린 메서드를 구현하는데, 구조는 다음과 같다.

@BindingAdapter("onTextChanged")
public void bindOnTextChanged(InputView inputView, OnTextChanged on)

@BindingAdapter 에 들어가는 string 는 그 자체가 요소의 이름이 되어, app:onTextChanged 로 사용할 수 있게 해준다.

메서드의 첫번째 파라미터는 주입될 대상 뷰, 즉 여기서는 커스텀 뷰가 될 것이고, 후자에는 주입할 객체이다. 보통 String, int 등 자료형이 들어갈수도, 인터페이스 자체가 들어갈 수도 있다.

그러면 주입할 인터페이스를 정의한다.

public interface OnTextChanged {
        void onTextChanged(CharSequence s, int start, int before, int count);
}

원본 TextWatcher 가 제공하는 onTextChanged 그대로 사용했다.

이제 bindOnTextChanged 안을 구현하면 되는데, 아래의 순서를 거친다.

  1. TextWatcher 변수 구현 – onTextChanged 메서드에 on 파라미터에 대해 null-check 후 인터페이스 invoke
  2. ListenerUtil 로 기존에 등록된 TextWatcher 를 찾고, 있으면 제거
  3. 새로 만든 TextWatcher 부착

이를 코드로 구현하면 다음과 같다.

/**
 * binding 'onTextChanged' method with InputView
 * <p>
 * reference: https://android.googlesource.com/platform/frameworks/data-binding/+/android-6.0.0_r7/extensions/baseAdapters/src/main/java/android/databinding/adapters/TextViewBindingAdapter.java#299
 *
 * @param inputView
 * @param on
 */
@BindingAdapter("onTextChanged")
public void bindOnTextChanged(InputView inputView, OnTextChanged on) {
    final TextWatcher newValue = new TextWatcher() {
        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }
        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
            if (on != null) {
                on.onTextChanged(inputView.mBinding.editText.getText(), start, before, count);
            }
        }
        @Override
        public void afterTextChanged(Editable s) {
        }
    };

    final TextWatcher oldValue = ListenerUtil.trackListener(inputView.mBinding.editText, newValue, R.id.textWatcher);
    if (oldValue != null) {
        inputView.mBinding.editText.removeTextChangedListener(oldValue);
    }

    inputView.mBinding.editText.addTextChangedListener(newValue);
}

실제 사용

BindAdapter 를 만들고 DataBindingComponent 를 설정해주면 커스텀 요소를 쓸 수 있게 된다.

app:onTextChanged="@{(text, start, before, count) -> viewModel.onSchoolChanged(text)}"

참고로, viewModel::onSchoolChanged가 가장 깔끔하겠지만, 그렇게 되면 ViewModel 에 start, before, count 등의 쓰지 않는 파라미터를 구현해야 한다는 제약점으로 인해 XML 자체에서 파라미터를 선택해 반환해주는 형태를 취했다.

외전

@BindingAdapter("android:text")
public static void bindFloat(TextView view, float value) {
    view.setText(String.valueOf(value));
}

@BindingAdapter("android:text")
public static void bindDouble(TextView view, double value) {
    view.setText(String.valueOf(value));
}

@BindingAdapter("android:text")
public static void bindInt(TextView view, int value) {
    view.setText(String.valueOf(value));
}

@BindingAdapter("android:text")
public static void bindLong(TextView view, long value) {
    view.setText(String.valueOf(value));
}

@BindingAdapter("android:text")
public static void bindBoolean(TextView view, boolean value) {
    view.setText(String.valueOf(value));
}

int, double, float 등을 생각없이 넣었을 때에도 작동할 수 있게 보조해주는 @BindingAdapter 도 비슷하게 구현해보았다.

위의 app:onTextChanged 와 다르게 android 네임스페이스를 바로 작성했다는 것이 차이점이다.

What’s new in Android Studio 3.1

도입

안드로이드 생태계에 큰 변화를 주었던 Android Studio 3.0 이 업데이트 되고 난 후 약 4개월 가량 지난 지금, Android Studio 3.1 이 정식 배포되었습니다.

따라서 본 글에서는 Android Studio 3.1 에 어떠한 변경점이 생겼는지 알아보려 합니다. 본 글은 Android Developers blog 의 Android Studio 3.1 글에서 요약하고 번역했습니다.

개발 분야

코틀린 Lint Check

2017년 5월 코틀린이 안드로이드의 공식 언어로 지정된 이후로 안드로이드 스튜디오에 대한 코틀린 지원을 계속해왔는데, 이제 Lint (코드 검사) 기능을 Commend Line 단에서 실행시킬 수 있게 되었다.

아주 간단히, ./gradlew lint를 입력하면 된다.

데이터베이스 수정

inline된 SQL, Room 쿼리 문을 작성할 때 좀 더 쉽게 작성할 수 있게 도와준다.

인텔리제이 업데이트

Intellij IDEA 2017.3. 에 변경된 부분을 포함한다. 주로 자동완성시 캐스팅 기능 강화, Stream 체이닝에서의 null-check, NonNull/Nullable 부착에 대한 Quick Fix, StringBuilder -> Stream collect 변환, sort 기능에 대한 Stream 변환, Deprecated 된 메서드에서 권장되는 코드로의 빠른 변환, JetBrains 계정을 통한 IDEA 설정 동기화 등이 추가되었다. 더 자세한 사항은 What’s new in Intellij IDEA 를 참고하자.

빌드 분야

새로운 컴파일러

Dex 를 컴파일하는 DX Compiler 가 대체되고, 새로운 D8 Dex Compiler 가 도입되었다. 소개하기로는 10~15%의 컴파일 시간이 개선되었다고 한다. 자세한 사항은 Next-generation Dex Compiler Now in Preview 를 참고하자.

빌드 결과 창 개선

이제 트리-뷰로 어느 부분에서 오류가 발생했는지 빠르게 보여주게 되었다.

테스트 분야

에뮬레이터 스냅샷

에뮬레이터 개선을 통해 6초 이내에 에뮬레이터 세션을 다시 시작할 수 있게 해주는 기능이다. 모든 에뮬레이터에 적용되며, 설정을 통해서 더 자세히 설정할 수 있다.

최신 버전 시스템 이미지 전달

24(누가) – 27(오레오) 버전의 에뮬레이터에는 구글 플레이 스토어와 구글 API가 내장된다. 추가적으로 윈도우 프레임이 없는 가상 기기를 만들 수 있으며, 이를 통해 18:9 화면 비율이나 DisplayCutout 를 테스트할 수 있게 된다.

최적화/디버깅 분야

C++ CPU 프로파일

Android Studio 3.0 에서 발표된 Android profiler 기능이 좀 더 강화되어, C++ 코드를 프로파일링 할 수 있게 되었다.

네트워크 스레드 & Request 프로파일

네트워크 스레드나 Request에 대해서도 프로파일링 할 수 있게 되었다.

마무리

마이너 버전만큼 변경되는 사항 자체는 적지만, 개인적으로 느끼기에는 Intellij IDEA 2017.3 에 적용된 Stream 변환 기능과 D8 컴파일러가 제일 마음에 든 것 같다.

3.1이 안정화 버전에 올랐다고 해도, 이미 3.2에 대한 베타가 진행되고 있기 때문에 그쪽도 사용해보는 것이 좋을 것 같다.