Set bold to TextView in DataBinding

도입

보통 XML 에서 TextView 에 bold 효과를 주려면 android:textStyle="bold"라는 코드를 사용하게 된다.

데이터바인딩을 이용해서 어떤 값의 상태가 true 이면 bold 를 주고, 아니면 normal 를 주는 코드를 사용하고 싶을때, 이렇게 해볼 수도 있다.

android:textStyle="@{viewMode.selectedValue == 1 ? bold : normal}

이렇게 하면 bold 라는 것을 찾을 수 없어 오류가 나게 된다.

이 때, 이를 비슷하게나마 구현하려면 BindingAdapter 를 구현해서 사용하면 된다.

해결 방법

@BindingAdapter("android:textStyle")
public static void setTypeface(TextView textView, String style) {
    switch (style) {
        case "bold":
            textView.setTypeface(null, Typeface.BOLD);
            break;
        default:
            textView.setTypeface(null, Typeface.NORMAL);
            break;
    }
}

네임스페이스는 android 원본 그대로를 사용하고, 안에서는 String 값으로 분기를 나눠서 setTypeface 를 부르기만 하면 된다.

그리고 값을 적용하기 위해 아래 코드를 사용하면 된다.
android:textStyle='@{viewModel.mSelectedTab == 1 ? "bold" : "normal"}'


특성 이미지 출처: https://www.learn2crack.com/2016/06/android-data-binding-example.html

 

Databinding – ObservableField with MVVM (MVVM 4)

도입

Android Architecture Components 의 도입 이후, 안드로이드 앱에 MVVM 구조를 도입하는 자체가 쉬워졌다.

ViewModel 페이지를 보면 ViewModel 클래스를 상속하여 ViewModelProviders 로 통해 가져오게 하는 걸 볼 수 있는데, 주로 조건에 따라 뷰를 숨기고 표시하기 위해 간단히만 소개하자면..

  • XML 의 variable 선언 공간에 해당 ViewModel 를 선언
  • XML 에서 android:visibility=”@{viewModel.mSelectedMode ? View.GONE : View.VISIBLE}” 조건문을 사용
  • Activity/Fragment 에서 DataBinding 객체를 불러올 때 ViewModel 설정

이렇게 될 것이다.

그런데, 언젠가는 mSelectedMode 는 누군가에 의해서 (사용자의 UI 반응이든, Data Source의 반응이든) 변경되는데, 이 때 아무리 값을 바뀌어도 visibility 는 변경되지 않는다.

이를 해결하기 위해서 DataBinding 에는 Observable 란 개념이 있고, 여기에서는 mSelectedMode 자체를 ObservableField<Boolean> (또는 ObservableBoolean) 를 선언하면 된다.

하지만, 그렇게 쉽게 해결되지는 않는다. 실제로 실행시켜보면 BaseObservable 라는 객체를 상속시켜야 된다는 Exception 이 발생한다.

하지만 이미 우리는 ViewModel 라는 객체를 상속중이어서, 직접 BaseObservable 내용을 구현시켜줘야 한다.

여기서는 그 방법을 살펴볼 것이다.

BaseViewModel 구현

BaseObservable 구현체는 약 5~60줄 정도 되기에, 모든 ViewModel 에 넣기에는 적합하지 않다. 따라서 BaseViewModel 란 객체를 만들고, 그 곳에서 구현할 것이다.

BaseViewModel 자체는 ViewModel 객체 (정확히는 ApplicationContext 사용을 위해 AndroidViewModel 를 사용할 것이다) 를 상속하고, Observable 와 LifecycleObserver 를 구현시킬 것이다.

public abstract class BaseViewModel extends AndroidViewModel implements LifecycleObserver, Observable {

    private PropertyChangeRegistry mCallbacks;

    public BaseViewModel(@NonNull Application application) {
        super(application);
    }

    @Override
    public void addOnPropertyChangedCallback(OnPropertyChangedCallback onPropertyChangedCallback) {
        if (mCallbacks == null) {
            mCallbacks = new PropertyChangeRegistry();
        }

        mCallbacks.add(onPropertyChangedCallback);
    }

    @Override
    public void removeOnPropertyChangedCallback(OnPropertyChangedCallback onPropertyChangedCallback) {
        if (mCallbacks == null) {
            mCallbacks = new PropertyChangeRegistry();
        }

        mCallbacks.remove(onPropertyChangedCallback);
    }
}

그리고 추가적으로 PropertyChangeRegistry 에 등록한 객체가 변경되었음을 알려주는 메서드를 추가적으로 구현한다.

public void notifyChange() {
    synchronized (this) {
        if (mCallbacks == null) {
            return;
        }
    }
    mCallbacks.notifyCallbacks(this, 0, null);
}

public void notifyPropertyChanged(int fieldId) {
    synchronized (this) {
        if (mCallbacks == null) {
            return;
        }
    }
    mCallbacks.notifyCallbacks(this, fieldId, null);
}

사용

@InjectViewModel
public class SignupViewModel extends BaseViewModel {
    public ObservableField<Boolean> mSelectedMode = new ObservableField<>(false); 

    @Inject
    public SignupViewModel(@NonNull Application application) {
        super(application);
    }

    public void setSelectedMode(boolean selectedMode) {
        mSelectedMode.set(selectedMode);
    }
}

이제 어딘가에서 setSelectedMode 메서드를 호출해 값을 변경하거나, mSelectedMode 자체를 변화시키면 그에 따라 UI도 자동으로 변경된다.

이를 이용해서 일정 조건일 때 뷰를 숨기고 – 보이고 등의 작업 (예를 들어 No Data available 작업) 을 쉽게 할 수 있게 된다.

 

 

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