Rx kotlin cover

Rx와 Kotlin으로 간결하고 명료하게 모든 것을 조립해보세요!

안드로이드 개발자들을 위한 수준 있는 독립 컨퍼런스인 Droid Knights에서 “Compose everything: Rx & Kotlin”이라는 주제로 강연된 내용입니다.


소개

Compose everything이라는 주제로 Rx와 Kotlin에 대해 말씀드릴 예정인데, Kotlin 보다는 주로 Rx에 대해 설명하겠습니다.

Kotlin & RxJava

Kotlin은 Java 가상머신에서 돌아가는 Jetbrain에서 만든 또 다른 언어입니다. 최근 1.1이 되면서 더 많은 주목을 받고 있습니다.

RxJava는 ReactiveX에서 함수형(Functional)이라는 단어를 설명하듯 옵저버블 스트림 상에서 간결한 인풋과 아웃풋 펑션을 이용해서 복잡한 상태 없는 프로그램 환경을 만드는 것입니다.

이번 강연에서는 RxJava를 사용하면 어떻게 간결한 프로그램 환경을 만들 수 있는지 보여드리겠습니다.

예제 : EditText

rx-kotlin-example-1

위 화면과 같은 간단한 예제 앱을 생각해 보겠습니다.


class HelloFragment : BaseFragment() {

    override val layoutId: Int = R.layout.fragment_hello

    override fun onViewCreated(view: View,
                               savedInstanceState: Bundle?) {

        // 툴바 정의
        toolbar
            .navigationClicks()
            .subscribe { activity.onBackPressed() }

        // EditText 정의
        inputName
            .textChanges()
            .subscribe {
                val hello = getString(R.string.hello_message)
                textHello.text = hello.format(it)
            }
   }
}

먼저 백 키가 있고, EditText 박스가 있으므로 툴바와 EditText를 정의했습니다. 저는 Kotlin Android Extensions를 사용해서 FindById를 사용하지 않고 바로 선언했습니다. 또 RxBinding - Kotlin라는 뷰 관련 옵저버블을 바인딩해주는 RxBinding 중 Kotlin 라이브러리를 사용했습니다. 이렇게 해서 내비게이션 클릭이 일어나거나 텍스트가 변경될 때 옵저버블 스트림으로 내용을 뺄 수 있습니다. 그다음 subscribe를 통해서 일련의 행동을 하도록 했습니다.

앞서 말씀드린 함수형처럼 변수 선언도 없고 명확한 흐름으로 진행되는 것을 확인할 수 있습니다.

예제 : Counter

rx-kotlin-example2

버튼을 누르면 숫자가 증가하거나 감소하는 직관적인 카운터 예제입니다.


class CounterFragment : BaseFragment() {

    override val layoutId: Int = R.layout.fragment_counter

    override fun onViewCreated(view: View,
                               savedInstanceState: Bundle?) {
        // plus / minus 이벤트 정의
        val inc = buttonInc.clicks()
            .map { +1 }

        val dec = buttonDec.clicks()
            .map { -1 }

        Observable.merge(inc, dec)
            .scan(0) { acc: Int, value: Int -> acc + value }
            .subscribe { textValue.text = it.toString() }
    }
}

이런 개발 뉴스를 더 만나보세요

+ 버튼을 누르면 +1로, - 버튼을 누르면 -1로 매핑했습니다. 그런 다음 옵저버블이 제공하는 merge`로 두 스트림을 합칩니다.

rx-kotlin-merge merge란 첫 번째 스트림과 두 번째 스트림을 하나의 스트림으로 합쳐 주는 것입니다. 카운트이므로 누적이 돼야 하는데 명령형 프로그램에서 변수에 누적시키는 것과 달리 RxJava에서는 scan이라는 함수로 상태 변수를 선언하지 않고 해당 값을 계속 유지할 수 있습니다. +1이나 -1 값이 들어올 때마다 그 상태 누적 값을 계속 변화시킨 방법으로 화면에 표시합니다.

rx-kotlin-scan

scan 오퍼레이터를 살펴보면 위 그림처럼 빨간색이 초깃값 0이며, +1인 초록색이 합쳐져서 1 값으로, -1인 파란색이 합쳐져서 0 값으로 변화됩니다. 비슷한 오퍼레이터로는 아래 그림에 표현한 reduce가 있는데 스트림이 끝났을 때 값을 전달한다는 점이 다릅니다.

여기까지 간단한 예제를 살펴봤으니 이제 좀 더 복잡한 HTTP를 살펴보겠습니다.

예제 : HTTP

rx-kotlin-example3

버튼을 클릭하면 네트워크가 진행되는 동안 로딩이 처리되고, 값이 리턴되면 이를 표현해주는 예제입니다. 이런 내용도 Rx를 사용해서 쉽게 구현할 수 있습니다.


override fun onViewCreated(view: View,
                           savedInstanceState: Bundle?) {
    progress.hide()

    buttonUser.clicks()
        .doOnNext { progress.show() }
        .switchMap { api.getUsers() }
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe({ users ->
            val index = Random().nextInt(users.size)
            val user = users[index]
            textName.text = user.name
            textEmail.text = user.email
            progress.hide()
        }, {
            progress.hide()
        })
}

앞 예제와 마찬가지로 Kotlin Android ExtensionsRxBinding - Kotlin을 사용해서 뷰에 클릭 이벤트가 발생하면 doOnNext { progress.show() }로 뷰에 프로그레스 바를 보이게 했습니다. 그 다음 네트워크를 통해 데이터를 가져오기 위해 RetrofitRxJava2 Adapter를 사용해서 값을 받아왔습니다. 이때 화면에 보여주기 위해 switchMap을 사용해서 스트림을 변경합니다.

rx-kotlin-switchmap

위쪽이 클릭 이벤트, 아래쪽이 클릭 스트림입니다. 위쪽에서 첫 번째 클릭이 일어나면 아래쪽 붉은 사각형 도형들이 진행되면서 정상적으로 종료되고, 다시 클릭이 일어나면 초록색 도형이 진행되다가 종료되기 전에 클릭이 일어나서 캔슬되고 파란색 도형들이 진행되고 있습니다.

switchMap 여러 번 클릭을 해도 클릭이 잘 캔슬됩니다. 비슷하게는 flatMap이나 concatMap이 있는데 네트워크 상황을 캔슬하는 것이 번거로우므로 switchMap를 사용하는 것이 효율적으로 처리할 수 있습니다.

안드로이드에서 UI 스레드에서만 화면에 그릴 수 있으므로 RxAndroid에서 제공하는 AndroidSchedulers로 메인 스레드로 스트림의 스케줄러를 변경하고, 반환된 값을 화면에 그렸습니다.

상태에 따라 단순히 함수만 구현하는 방식으로 만들 수 있습니다. 단, RxJava를 사용할 때 함정이 있습니다.

RxJava의 함정

Memory Leaks

먼저 메모리 릭을 말씀드리겠습니다. RxJava를 사용할 때 스트림을 취소해주지 않으면 계속 돌기 때문에 위험합니다.


override fun onViewCreated(view: View,
                           savedInstanceState: Bundle?) {

    // Printed for 1 hour if not killed
    Observable.interval(1, TimeUnit.SECONDS)
        .take(3600)
        .subscribe(::println)
}

위 코드와 같이 한 시간 동안 1초마다 로그를 찍는 긴 시간의 작업이 있다면 백 버튼을 눌러서 프래그먼트를 벗어나더라도 로그에는 계속 찍히고 있습니다.


/**
 * ...
 *
 * Warning: The created observable keeps a strong
 * reference to view. Unsubscribe to free this reference.
 *
 * Warning: The created observable uses
 * View.setOnClickListener to observe clicks.
 * Only one observable can be used for a view at a time.
 */

public static Observable<Object> clicks(@NonNull View view)

또한, RxBinding의 경고에서도 볼 수 있듯 참조를 반드시 끊어줘야 합니다.


abstract class BaseFragment : Fragment() {

    protected val disposables by lazy {
        CompositeDisposable()
    }

    // ...

    override fun onDestroyView() {
        disposables.clear()
        super.onDestroyView()
    }
}

이를 해결하기 위해서는 CompositeDisposable이라는 어레이 스트림에 만들어진 코드를 넣어두고 한 번에 취소하는 방법을 사용합니다. CompositeDisposable 변수를 선언하고 생명 주기 변경이 일어나면 clear로 지워줍니다.


override fun onViewCreated(view: View,
                           savedInstanceState: Bundle?) {

    Observable.interval(1, TimeUnit.SECONDS)
        .take(3600)
        .subscribe(::println)
        .apply { disposables.add(this) }
}

// apply 실제 구현
public inline
fun <T> T.apply(block: T.() -> Unit): T { block(); return this }

위와 같이 subscribe를 하면 반환되는 disposable을 disposables에 넣어두고 관리합니다. apply는 Kotlin에서 제공하는 표준 API로 disposables에 넣어둘 수 있습니다. apply 구현은 제네릭으로 여러 타입에 쓸 수 있는 형태입니다.


class MainFragment : BaseFragment() {
    // ...
    override fun onViewCreated(view: View,
                               savedInstanceState: Bundle?) {
        val hello = buttonHello.clicks()
            .map { HelloActivity::class }
        val counter = buttonCounter.clicks()
            .map { CounterActivity::class }

        Observable.mergeArray(hello, counter)
            .subscribe { start(it) }
            .apply { disposables.add(this) }
    }
    // ...
}

RxBiding을 만들 때도 마찬가지로, 스트림들을 만들고 이들을 하나로 합쳐서 disposables에 넣어두면 메모리 릭 방지에 도움이 됩니다. 어떤 버튼이 클릭되면 액티비티가 실행되는 예제로, mergeArray를 해서 subscribe하고 apply로 disposables에 넣었습니다. 이때 머지한 스트림 하나만 추가하더라도 앞서 생성한 스트림들이 모두 disposable하게 됩니다. Rx에서 생성한 스트림들을 생명 주기에 따라 처리할 수 있는 해법을 보여드렸습니다. 하지만 아직 회전 부분에도 함정이 남아 있습니다.

Configuration changes

프로그레스가 진행되고 있는 도중에 기기를 회전하면 어떤 상황이 발생할까요? 다시 화면을 그리면서 0%가 됩니다. 이런 경우는 두 가지 케이스가 있습니다.

rx-kotlin-conf-example1

클릭 이벤트로 로딩이 일어나는데 switchMap에 의해 결과가 화면에 그려지다가, 화면 회전으로 disposable들이 지워지면 아무것도 남지 않는 상황이 벌어집니다.

rx-kotlin-conf-example2

클릭으로 로딩이 일어났지만, 결과를 받기 전에 화면이 회전되는 두 번째 경우도 생각할 수 있습니다. 이 두 경우를 해결하기 위해 물론 상태를 복원하거나 화면 회전을 막는 방법이 있겠지만, Cache와 Subject를 사용해서 생명 주기와 관계없이 Rx로 해결하는 방법을 알아보겠습니다.

rx-kotlin-proxy1

Proxy라는 스트림을 하나 만들고 UI에서 발생하는 모든 이벤트를 생명 주기와 관계없는 곳으로 넘겼습니다. 이 안에서 어떻게 해결하는지 보여드리겠습니다.

rx-kotlin-proxy2

클릭이 일어나면 발생하는 이벤트를 Subject를 이용해서 onNext로 넘겼습니다. 이렇게 하면 회전이 일어나고 view2가 생겨서 복원이 일어나도 원래 있던 데이터를 그릴 수 있도록 했습니다. 예전에 진행하고 있던 view1와 view2가 다른 스트림이어서 생기는 문제는 cache를 이용해서 해결했는데, 진행되던 데이터를 끊고 cache를 통해서 view2 스트림에서 가져와서 받을 수 있게 했습니다.

rx-kotlin-proxy3

HTTP 스트림이 끝나기 전에 다시 구독하는 경우로, 클릭 이벤트가 일어났을 때 http에 데이터를 넘기고, 화면 회전이 일어나면 다시 시작하는 view2에 다시 붙일 수 있습니다. 이런 방식으로 기기를 회전해도 매끄럽게 진행하는 것을 Rx를 사용해서 간결하게 만들 수 있습니다.

Compose everything

제가 나름대로 그간 여러 고민 끝에 만들어낸 패턴들을 kotliin-maze에 정리해서 공개했습니다. 주목적은 앞서 말씀드린 대로 옵저버블을 사용해서 데이터를 처리하고 애플리케이션을 구현하는 방법을 소개하는 것입니다. 맨 처음에 보여드린 예제 앱과 같은 화면을 하나하나 설명하겠습니다.

View Model


data class UsersModel(
    val user: User = User(0, "", ""),
    val loading: Boolean = false,
)

userloading을 이뮤터블로 선언합니다. 이 두 값에는 쓸 수 없고 생성만 할 수 있습니다. Java에서는 객체 복사를 위해 equals 등을 일일이 처리해야 하는데 Kotlin에서 제공하는 Data Class의 경우 data 키워드를 붙이면 작업을 하지 않아도 되므로 이를 사용했습니다.

Maze & Listener

다음으로 Maze라는 객체를 선언합니다. Fragment를 차근차근 살펴볼까요?


class UsersFragment : BaseFragment(), MazeListener<UsersModel> {

    override val layoutId: Int = R.layout.fragment_users

    private val maze by lazy { Maze(UsersModel()) }

    @Inject lateinit var api: Api

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        MazeApp.comp.inject(this)
    }

maze를 선언하고 초깃값으로 제일 처음에 화면에 그릴 값을 넘김니다.


    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        maze.attach(this, arrayOf(
            toolbar.navigationClicks()
                .map { ClickEvent(R.id.homeAsUp) },
            buttonUser.clicks()
                .map { ClickEvent(R.id.buttonUser) }
        ))
    }

    override fun onDestroyView() {
        maze.detach()
        super.onDestroyView()
    }
    

Maze 설정 부분은 화면이 생길 때와 없어질 때의 값을 넣어줍니다. 생성 시에는 사용자가 이벤트를 발생시킬 수 있는 툴바의 백 키와 클릭에 대한 이벤트를 만들고, 없어질 때는 이를 끊어주기 위해 detach 함수를 실행했습니다.


    override fun main(sources: Sources<UsersModel>) = usersMain(sources, api)

    override fun render(prev: UsersModel, curr: UsersModel) {
        if (curr.loading) {
            progress.show()
            return
        }

        progress.hide()

        textName.text = curr.user.name
        textEmail.text = curr.user.email
    }

Main Function

다음으로 main과 render 함수를 구현합니다. main은 실제 동작하는 코드에 대한 순수한 함수를 구현하며, render는 뷰 모델에 따라 화면만 단순하게 그려줍니다.


    override fun navigate(navigation: Navigation) {
        when (navigation) {
            is Back -> activity?.onBackPressed()
        }
    }

    override fun finish() = maze.finish()

    override fun error(t: Throwable) {
        t.printStackTrace()
    }
}

백 버튼이 눌릴 때는 navigate, maze가 끝나는 경우 finish, 에러가 생기는 경우 error에 구현했습니다.


fun usersMain(sources: Sources<UsersModel>, api: Api): Sinks<UsersModel> {
    // Data flow
    // ㄴ Input
    val click = sources.event
        .clicks(R.id.buttonUser)
        .shareReplay(1)

    val loading = click
        .withLatestFrom(sources.model,
            BiFunction { _: ClickEvent, model: UsersModel ->
                model.copy(loading = true)
            })

    val users = click
        .switchMap { api.getUsers() }
        .withLatestFrom(sources.model,
            BiFunction { users: Users, model: UsersModel ->
                val index = Random().nextInt(users.size)
                model.copy(user = users[index], loading = false)
            })

    val back = sources.event
        .clicks(R.id.homeAsUp)
        .map { Back() }

    val model = Observable
        .merge(loading, users)
        .cacheWithInitialCapacity(1)

    return Sinks(model, back)
}

이중 메인 함수는 클래스와 관계없는 순수한 함수로, 다시 자세히 살펴보겠습니다. 메인 함수의 Sources가 input, Sinks가 output이 됩니다. Data Flow의 input은 클릭 이벤트와, 클릭 이벤트가 발생하면 일어나는 로딩과 http로 데이터를 가져오는 두 가지 스트림을 만듭니다. 한 줄기의 스트림이 두 가지로 갈라진 상황으로, 먼저 로딩이 반환되며, 데이터가 반환되면 화면에 그려줍니다.

rx-kotlin-with

이 중 withLatestFrom 함수는 로딩의 경우 클릭 이벤트가 발생했을 때 예전에 있던 모델과 결합해서 로딩의 값만 바꿔주는 구조입니다. 비슷한 함수로 combineLatest 함수는 두 스트림이 발생했을 때 계속 반환을 해줍니다.

Data Flow의 output에서는 merge(loading, users)로 두 개로 갈라진 스트림을 다시 합쳐준 다음 반환합니다. 전체 메인 함수 흐름을 정리하자면 Input으로 들어온 스트림을 두 개로 갈라서 각각 처리한 후 반환을 해서, 맨 처음 예제와 같은 동작을 하게 합니다.

rx-kotlin-json

이런 동작을 JSON으로 살펴보면 두 가지로 갈라지면서 loading 값을 true로 변경하고, 로딩이 끝나면 user 정보를 넣어주면서 loading이 false로 바뀝니다. 프래그먼트나 액티비티의 멤버 값을 설정하지 않고도 이런 작업이 가능하다는 데모를 보여드렸습니다.

더 많은 예제

지금 시간에 소개할 수는 없지만 무한 스크롤이나 애니메이션 등 다양한 예제를 GitHub에서 만나볼 수 있습니다.

테스트 방법

제 예제들의 경우 메인 함수에서 작업한 후 화면에 그려주는 형식인데, HTTP 예제의 경우에는 순수하지 않은 함수를 순수하게 만들기 위해 원하는 결과만 반환하도록 테스트했습니다.


@Test
fun testUiStream() {
    val user = User(0, "name", "a@a.com")
    val users = Observable.just(listOf(user))
    given(api.getUsers()).willReturn(users)

    // 1. initialize maze streams
    val sinks = usersMain(sources, api)

    // 2. make test observer
    val testObserver = makeTestObserver(sources, sinks)

    // 3. click
    sources.event(ClickEvent(R.id.buttonUser))

    // 4. tests
    testObserver.assertNoErrors()
    testObserver.assertValues(
        UsersModel(loading = true),
        UsersModel(user = user, loading = false)
    )
    testObserver.onComplete()
}

input을 설정하고 로직적인 부분만 테스트하며, 테스트 옵저버를 만들어서 클릭 이벤트를 만들고 모델에 대해서만 테스트했습니다.

결론

제품을 코딩할 때 이런 방식을 사용하면 메인 함수만 구현하고, 여기서 구현한 로직만 화면에 그리기 때문에 간단하다는 장점이 있습니다. 또한 함수를 여러 개 만들고 이를 조합해서 어떻게 화면에 보일 것일지만 고민하면 되므로 재사용성이 좋습니다. 다만 학습 비용이 많이 들기 때문에 팀 규모가 큰 경우 도입하지 않을 수 있다는 단점이 있습니다. 올바른 방향인지에 대한 개인적인 고민도 있었지만, 최근 비슷한 내용의 슬라이드를 Jake Wharton도 공개한 것을 보고 고무적이었습니다.


본 영상과 글은 Droid Knights의 비디오 스폰서인 Realm에서 제공합니다. 모바일 개발자가 더 나은 앱을 더 빠르게 만들도록 돕는 Realm 모바일 데이터베이스Realm 모바일 플랫폼을 통해 핵심 로직에 집중하고 개발 효율을 높여 보세요! 공식 문서에서 단 몇 분 만에 시작할 수 있습니다. 또한 Realm 홈페이지에서는 모바일 개발자를 위한 다양한 최신 기술 뉴스와 튜토리얼을 제공하고 있으니 즐겨찾기하고 자주 들러 주세요!

다음: Realm Java를 사용하면 안드로이드 앱 모델 레이어를 효과적으로 작성할 수 있습니다.

General link arrow white

컨텐츠에 대하여

2017년 3월에 진행한 Droid Knights 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

허재위

안드로이드 개발자입니다. 코틀린과 Rx로 개발하는 것을 좋아합니다.

4 design patterns for a RESTless mobile integration »

close