Databinding 반영 시점, RxJava Thread

CmplxN·2020년 9월 11일
0

꼭 이번주에 있었던 문제는 아니지만, 최근 개발하다가 삽질한 이력을 남겨본다.

데이터 바인딩의 반영 시점

배경

View들의 Visibility를 데이터 바인딩을 이용해 변경할 수 있다. 아래 코드를 보자.

android:visibility="@{uiVisible ? View.VISIBLE : View.GONE}"

위처럼 uiVisible를 할당하면 Activity나 Fragment에서 uiVisible을 true / false로 바꿔주기만 해도 visibility가 업데이트 된다.

문제 상황

특정 버튼(btn_open)을 누르면 EditText와 soft keyboard가 뜨고 EditText에 Focus를 줘야했다. EditText역시 위처럼 데이터 바인딩을 사용하고 있었고 아래와 같이 작성했다.

btnOpen.setOnClickListener {
    editTextVisible = true
    showKeyboard()
    editText.requestFocus()
}

그냥 보면 EditText가 보여지고, soft keyboard가 올라오고, EditText에 포커스가 생길 것으로 보인다.
하지만, 실제로는 EditText에 포커스가 생기는 일은 발생하지 않았다.

원인

원인은 editText.requestFocus()가 불린 순간 EditText는 아직 VISIBLE이 아니었던 것이었다. editText.requestFocus()는 GONE인 EditText에 Focus를 줄 수 없었던 것이다.

사실 데이터 바인딩은 변경 사항을 스케쥴링하여 다음 프레임에 반영되도록 하는 방식으로 되어있다.
즉, 데이터(editTextVisible)을 변경했다고 UI도 곧바로 적용되는 것은 아니다. 결국, 위의 코드대로면 다음 프레임에 EditText가 VISIBLE이 될 것이라고 예정될 뿐이지, VISIBLE이 되지는 않는다.

작동원리를 몰랐던 것은 아니지만, 코딩할 때는 editTextVisible = trueeditText.visibility = View.VISIBLE와 같은 것으로 생각하는 실수를 했던 것이다.

해결 방법

첫번째로는 executePendingBindings()를 호출해 즉각적으로 변경 사항을 적용시키는 것이다. 다음 프레임에 적용시킬 것을 강제로 즉시 반영시키면, EditText가 즉시 VISIBLE이 되고, requestFocus()는 성공한다.

btnOpen.setOnClickListener {
   editTextVisible = true
   executePendingBindings()
   showKeyboard()
   editText.requestFocus()
}

둘째로는 아예 EditText를 커스텀 뷰로 만들어 setVisibility()를 오버라이드 하는 것이다. 이경우 visibility가 VISIBLE로 바뀔 때는 항상 focus가 필요했다. 실제로 다른 이유로 EditText는 커스텀 뷰로 만들어 쓰고 있었기에 setVisibility()를 아래처럼 오버라이드 했다.

override fun setVisibility(visibility: Int) {
   super.setVisibility(visibility)
   if (visibility == View.VISIBLE)
       requestFocus()
}

RxJava 스레드 관리

배경

RxJava는 subscribeOn()observeOn()함수에 Scheduler를 인자로 줘서 원하는 스레드 풀의 스레드로 실행 흐름을 정할 수 있다. subscribeOn()로는 최초 스레드를 지정할 수 있으며, 체이닝 거는 위치는 상관없다. 반면 observeOn()로는 체이닝을 한 이후의 스레드를 지정할 수 있고, 체이닝을 거는 위치가 중요하다.

주로 사용하는 스케쥴러로는 AndroidSchedulers.mainThread(), Schedulers.computation(), Schedulers.io() 가 있다. 많은 계산이 동반되는 경우 computation을 사용하고, 네트워크 사용이나 내부 DB에 접근할 때 io를 사용하고, Ui에 적용할 때 mainThread로 돌아온다고 생각하면 될 것이다.

문제 상황

firebase서버에 접근(compF)한 뒤 retrofit을 사용해서 요청(compR)하는 경우가 있었고, Completable을 andThen으로 이어 붙이는 방식으로 Completable을 생성했다. io관련 작업이므로 subscribeOn은 io로, observeOn은 mainThread로 설정했다. 아래와 같은 꼴로 작성했다.

compF
    .andThen(compR)
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(...)

firebase 관련 로직은 요청을 CompleteListener를 등록하고, 받은 결과를 create -> emitter로 처리했다. 아래와 비슷한 꼴을 생각하면 된다.

Completable.create { emitter ->
    firestore
        .(somePath then request)
        .addOnCompleteListener { task ->
            if (task.isSuccessful) {
                emitter.onComplete()
            } else {
                emitter.onError()
            }
        }
    }

retrofit은 결과를 Completable로 받는 식이었다.

fun request(...): Completable

그런데 막상 실행한 결과는 mainThread에서 네트워크를 돌렸다는 Exception의 발생이었다. 분명 io를 지정했는데 실제로는 io에서 동작하지 않은것이다.

원인

우선 Firebase의 addOnCompleteListener()로 등록된 람다함수가 어떻게 동작하는지를 간단하게나마 이해할 필요가 있다.
사실 Firebase는 위에서 설정한 .subscribeOn(Schedulers.io())가 무색하게 서버와 통신은 Firebase가 별도 관리하는 스레드에서 진행한다. 그리고 Listener에 등록했던 람다함수, 즉 emitter.onComplete()는 mainThread에서 실행한다.

사실 .subscribeOn(Schedulers.io())가 의미 없고, mainThread에서 emitter.onComplete()가 호출되는 것이 무슨 상관이냐고 할 수 있다.
하지만 이후의 흐름은 이전에 설정된 Scheduler와 무관하게, 실제 배출(emit)이 일어난 스레드를 따른다. 다시 말하면, 배출(emit)된 아이템은 이후 실제 배출(emit)이 일어난 스레드에서 실행된다.

정리하면, firebase 요청의 결과로 emitter.onComplete()는 mainThread에서 호출되었다. 그리고 andThen으로 되어있던 retrofit 연산은 실제 배출(emit)이 일어난 mainThread에서 실행된 것이다. (우리가 지정했던 Schedulers.io()는 무시한채로)

해결 방법

먼저 가장 간단한 방법은 Retrofit 객체를 만들 때, Retrofit 관련 작업 스레드를 지정해주는 것이다. Retrofit를 빌더 패턴으로 만들 때 아래와 같이 넣어주면 된다.

.addCallAdapterFactory(RxJava2CallAdapterFactory.createWithScheduler(Schedulers.io()))

이렇게 지정해두면 firebase 때문에 mainThread에서 emitter.onComplete()가 불려도 retrofit 작업을 할 때는 io로 넘어가게 되고, Exception은 발생하지 않는다.

둘째는 observeOn()을 retrofit 작업 전에 걸어 다시 Scheduler를 설정하는 것이다.

compF
    .observeOn(Schedulers.io())
    .andThen(compR)
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(...)

이러면 compF에서 mainThread에서 emitter.onComplete()가 불려 mainThread로 넘어가도, retrofit 관련 작업은 io로 넘어가게 되어 Exception이 발생하지 않는다.

그외에도 선후관계가 반드시 필요한게 아니면 두 Completable을 merge로 묶어 처리할 수 있다.

profile
Android Developer

0개의 댓글