Naver Tech Concert Day-2

05. 안드로이드에서 코루틴은 어떻게 적용할 수 있을까? : 코루틴 적용 및 ReactiveX(RxJava/RxKotlin)와 비교한다면?

  • 발표자 : 권태환 (요기요 / 안드로이드 개발)

  • 동영상 : https://tv.naver.com/v/4635469/list/272653

  • 슬라이드 : https://www.slideshare.net/NaverEngineering/25-121499000

  • 세션설명 : 1.3에 정식으로 포함될 코루틴! 안드로이드에서 코루틴의 적용은 어떻게 할 수 있으며, ReactiveX(RxJava/RxKotlin)과 비교 한다면 좋은점과 부족한 점, 그리고 실무 프로젝트에 적용한 코루틴을 소개해본다.

  • 목차 :

    1. 코루틴 소개(서브루틴과 코루틴)
    
    2. 코루틴을 통해 할 수 있는것?
    
    3. 코틀린 코루틴에서 제공하는 주요 기능들
    
    4. 실제 업무에 적용한 코루틴 소개
    
    5. 코루틴 써보니? RxJava와 비교
    

코루틴 적용 및 ReactiveX(RxJava)와 비교

Kotlin coroutines

coroutines이란?

Subroutine의 사전적 의미 : 평소 사용하는 함수, 메서드 등.

subroutine의 return이 불러지기 전까진 다음 라인을 실행하지 않음.

private fun MutableList<Int>.sum(): Int = this.sumBy{ it }  // sum이 끝나야 return

@Test
fun test() {
    val sum = (0..10).toMutableList().sum()
    println(sum)	// sum이 끝나야 println
}

Coroutines : 1958년 어셈블리에서 언급되었고, 1963년 논문을 통해 설명됨.

코틀린 뿐만 아니라 파이썬, C#에서도 지원함.

  • Entry point 여러 개 허용하는 subroutine (쉽게 쓰레드라 생각하면 됨.)
  • 언제든 일시 정지하고 다시 실행 가능
  • event loops, iterators, 무한 리스트, 파이프 같은 것을 구현하는데 적합

private suspend fun Int.countDown(currentIndex: Int) {
    for(index in this downTo 1) { //countdown from 10 to 1
        tv_message.test = "Now index $currentIndex Countdown $index"	// update text
        delay(200)
    }
    Log.i("TEMP", "Now index $currentIndex Done!")
}

var currentIndex = 0
fab.onClick {
    CoroutineScope(Dispatchers.Main).launch {
        10.countDown(++currentIndex)
    }
}

suspend라는 키워드를 넣어야 코루틴으로 동작되고 delay(기존 Thread.sleep과는 다름)를 사용함.

코루틴에서는 CPU와 관계를 최소한으로 줄여 루틴마다 스위치가 일어날 때 CPU 영향을 적게 받음.

여러 쓰레드에서 하던 부분을 단일 쓰레드에서 처리할 수 있게 해줌.

Dispatchers.Main 지정으로 여러번 click해도 메인 쓰레드에서 동작함.

Kotlin coroutines vs RxJava

Android onClick - RxJava vs coroutines

kotlin coroutines

var currentIndex = 0
fab.onClick {
    CoroutineScope(Dispatchers.Default).launch {	// 새로운 scope를 생성하고 default로 launch (일반 Work thread)
        val job = launch(Dispatchers.Main) {		// launch를 Main thread로 변경
            10.countDown(++currentIndex)
        }
        job.join()							// join()으로 UI thread 종료하기 전까지 대기
    }
}
private suspend fun Int.countDown(currentIndex: Int) {		// 상위 scope thread에 따름(여기선 UI)
    for(index in this downTo 1) { //countdown from 10 to 1
        tv_message.test = "Now index $currentIndex Countdown $index"	// update text
        delay(200)
    }
    Log.i("TEMP", "Now index $currentIndex Done!")
}

RxJava

private val clickEventSubject = PublishSubject.create<View>()	// onClick 처리를 위한 Subject 생성
private var currentIndex = 0
fab.setOnClickListener {
    clickEventSubject.onNext(it)		// 클릭이 이루어지면 onNext를 호출하며 View를 넘겨줌.
}
clickEventSubject
.throttleFirst(500, TimeUnit.MILLISECONDS)	// 첫 번째만 처리하기 위한 throttleFirst (500ms 이후에 동작하게 함.)
.observeOn(Schedulers.io())					// 스케쥴러를 가지고 io 쓰레드에서 동작하게 함.
.map {
    currentIndex++							// index 증가.
}
.switchMap {									// switch에 2개의 zip을 묶음.
    Observable.zip(Observable.range(0, 10),		// 1 부터 10 출력을 위한 range
                  Observable.interval(200, TimeUnit.MILLISECONDS),	// 200ms interval
                   BiFunction<Int, Long, Int> { range, _ ->
                       10 - range
                   })
}
.observeOn(AndroidSchedulers.mainThread())		// UI thread로 변경하고, 메시지 노출
.subscribe({
    tv_message.text = "Now index $currentIndex Countdown $index"
}, {})

Subject, ObserveOn 등등 10 몇가지를 알아야 이해를 할 수 있음.

Rx는 observeOn 등 몇가지 지정을 하고 subject를 통해야 실행을 함.

Coroutines은 CoroutineScope를 지정하고 launch를 하면 실행이 됨.

suspend는 RxJava 처럼 subscribe가 올 때 처리하고 싶을때 선언하고 사용하면 되고, CoroutineScope 안에서만 호출이 가능함.

RxJava

  • 장점
    • Observable과 Streams
    • 기존 Thread보다 간단한 코드로 처리 (어디선가 callback이 올거라 보고 처리해야 함.)
    • stream을 통해 데이터 처리 용이
    • Thread간 교체가 간단
    • RxJava를 활용한 수 많은 라이브러리 활용 가능 (Retrofit 등등)
      • 예제도 많고 문서도 잘 되어 있음.
  • 단점
    • 용어를 모르면 코드 활용 이유를 알 수 없음
    • 처음 학습 비용이 높음

Kotlin coroutines

  • 장점
    • 함수 형태라 읽기 쉬움
    • light-weight threads
    • 모든 routine 동작을 개발자가 처리 가능
    • 처음 학습 비용이 낮음
  • 단점
    • 아직은 필요한 라이브러리를 구현해서 사용해야 함. (RxView와 같은 라이브러리 개발 필요)
      • 직접 만들 수 있고, 문서도 잘 되어 있음.

Kotlin도 Coroutines 처럼 처음은 쉽지만 그 다음부터 어려워짐.

Kotlin coroutines의 장단점

  • 장점
    • 함수 형태라 읽기 쉬움
    • light-weight threads
    • 모든 routine 동작을 개발자가 처리 가능
  • 단점
    • 아직은 필요한 라이브러리를 구현해서 사용해야 함. (RxView와 같은 라이브러리 개발 필요)

Kotlin coroutines 이 제공하는 것들

Kotlin 1.3과 함께 coroutines 1.0 정식 릴리즈됨.

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.0.0'

주의할 점 : 코루틴 0.25버전에서부터 deprecated 되었던 부분들이 1.0에서 다 삭제됨. 릴리즈 문서 참고.

  • Kotlin 다양한 platform 제공

    • Server-side

    • Desktop

    • Mobile Application

  • 다양한 언어에서 제공하던 주요 라이브러리 제공

    • C#, ECMAScript : async/await
    • Go : channels, select
    • C#, Python : generators/yield

Kotlin coroutines guide

Coroutines : https://kotlinlang.org/docs/reference/coroutines/coroutines-guide.html

Android Coroutines - codelab : https://codelabs.developers.google.com/codelabs/kotlin-coroutines/index.html

blocking과 no-blocking

@Test
fun test() {
    GlobalScope.launch {
        delay(300L)			// non-blocking
        println("World!")
    }
    pirntln("Hello,")
    Thread.sleep(500L)		// block main thread. UI 쓰레드에서 동작이라 화면이 멈춤
}
  • Thread.sleep() : Thread 처리 전까지 Main Thread가 멈춤. UI 쓰레드에선 최대한 안써야 함.
  • delay : 별도의 coroutine에서 동작하여 멈추지 않고 넘어감.

그래서 runBlocking을 제공함. (아래 두가지 코드는 동일하게 동작함.)

@Test
fun test() {
    CoroutineScope(Dispatchers.Unconfined).launch {
        delay(300L)
        println("World!")
    }
    pirntln("Hello,")
    runBlocking {
        delay(500L)
    }
}
@Test
fun test() = runBlocking {
    CoroutineScope(Dispatchers.Unconfined).launch {
        delay(300L)
        println("World!")
    }
    pirntln("Hello,")    
    delay(500L)
}

runBlocking

  • runBlocking 은 함수의 처리가 끝날때까지 대기
  • delay()를 걸어두면 delay 시간만큼 대기하고 return
    • Android에서 runBlocking을 UI에서 잘못 사용하면 멈추는 현상 발생
  • delay를 이용해 non-blocking을 할 수 있음
  • runBlocking, CoroutineScope, GlobalScope 안에서 동작해야 함

CoroutineScope, GlobalScope

CoroutineScope

  • 가장 기본적인 Scope
  • Thread 형태를 지정(Main, Default, IO 등을 지정)
    • CoroutineScope(Main, Default, IO, ...)
  • launch, async 등을 통해 scope를 실행
  • 중요한 점
    • Activity/Fragment LifeCycle에 따라야 함
      • onDestroy() : cancel하도록 코드 추가
    • CoroutineScope(/* thread type */).launch { } 로 실행
    • launch { }의 return job의 동작을 지정 가능
      • join() : scope 동작이 끝날때까지 대기하며, suspend에서 호출 가능
      • cancel() : 동작을 종료하도록 호출
      • start() : scope가 아직 시작하지 않을 경우 start, scope의 상태를 확인

CoroutineScope의 interface 정의

@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope = ContextScope(if (context[Job] != null) context else context + Job())


public interface CoroutineScope {
    @Deprecated(level = DeprecationLevel.HIDDEN, message = "Deprecated in favor of top-level extension property")
    public val isActive: Boolean
    	get() = coroutineContext[Job]?.isActive ?: true
    
    /** Context of this scope. */
    public val coroutineContext: CoroutineContext
}

CoroutineScope 활용 - Delay 대신 job.join()

@Test
fun test() = runBlocking {
    val job = CoroutineScope(Dispatchers.Unconfined).launch {	// 새로운 scope를 생성하고 default로 launch. (Work Thread로 동작함)
        //launch에서 CoroutinScope에서 지정한 default Thread로 사용. (별도 지정 없으면 부모를 따라감)
        delay(300L)
        println("World!")
    }
    pirntln("Hello,")
    // delay(500L) <-- 이 부분은 CoroutineScope에서 네트워크 같은 처리시 의미가 없어짐.
    job.join()		// join()으로 default thread 종료하기 전까지 대기
}

GlobalScope

전역에서 돌아가야할 경우

  • CoroutineScope 상속 받아 구현

  • Demon, Application 등에서 사용

  • Application의 lifetime에 따라 동작하는 scope에서 사용 추천

  • GlobalScope는 Dispatchers.Unconfirned(worker thread) 에서 동작

  • GlobalScope.launch(/* thread type */) { } 로 실행.

    위에서 thread가 이미 지정되어 있어 launch에서 쓰레드 타입을 지정. 기본은 Default임.

GlobalScope API 코드

object GlobalScope : CoroutineScope {
    /**
     * @suppress **Deprecated**: Deprecated in favor of top-level extension property
     */
    @Deprecated(level = DeprecationLevel.HIDDEN, 
                message = "Deprecated in favor of top-level extension property") 
    override val isActive: Boolean
    	get() = true
    
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
    	get() = EmptyCoroutineContext
}

GlobalScope 사용 예

fun ReceiveChannel<Int>.sqrt(): ReceiveChannel<Double> = 
GlobalScope.produce(Dispatchers.Unconfined) {
    for(number in this@sqrt) {
        send(Math.sqrt(number.toDouble()))
    }
}

suspend

  • suspend를 활용하여 함수 분할을 할 수 있음.
  • suspend로 만들어진 함수는 사용하기 전까지는 동작하지 않음.
  • suspend 키워드를 사용하는 함수는 CoroutineScope에서만 사용할 수 있음.

suspend 사용 예

suspend fun CorountineScope.loadData(body: suspend CoroutineScope.(item: String) -> Unit) {
    val item = ""
    delay(100L)
    body(item)
}
CoroutineScope(Dispatchers.Main).launch {
    loadData { item ->
        // Coroutine scope 정의
    }
}

Job

  • CoroutineScope의 return에는 job 객체를 넘겨줌.
  • job을 통해 routine의 취소, 실행, 종료를 대기할 수 있음
    • job.cancel() : 종료하도록 유도함.
    • job.join() : 종료를 대기하며 호출은 suspend에서 가능함.
    • job.start() : coroutine의 시작을 확인할 수 잇으며, 시작 상태라면 true

Android onClick 잘 활용하기

연속해서 빠르게 입력하는 경우를 막을 경우

RxJava 버튼 처리

  • 첫 번째 이벤트만 허용하기 위해서

    • throttleFirst 활용

      • 시간으로 first의 시간을 지정하여 문제 발생 외 처리 필요.

        (문제점 : 특정 시간 후에 다음 이벤트가 발생함)

Coroutine으로 처리

  • 첫 번째 이벤트만 허용하기 위해서
    • 버튼의 상태를 변경해서 처리?
  • coroutine에서는 그럴 필요 없음.
  • GlobalScope + actor 활용

Coroutine GlobalScope.actor<T> 활용하기

private fun View.onClick(action: suspend (View) -> Unit) {
    // 5. 이 때 Higher-Order function 정의는 suspend가 포함되어야 함.
    val event = GlobalScope.actor<View>(Dipatchers.Main) { // 1. Singletone의 GlobalScope 활용
        for(event in channel) {						// 2. actor 이용 event 받기
        	action(event) // 4. 받은 event를 Higher-Order function으로 넘겨서 정의하도록 함.
        }
    }
    setOnClickListener {
        event.offer(it)								// 3. actor에 offer로 event 보내기
    }
}
var currentIndex = 0
fab.onClick {
    10.countDown(currentIndex++)				// 6. 람다 표현으로 countDonw 구현
}

Android Coroutines

안드로이드에서 코루틴 활용

  • UnitTest/Main Thread에서 활용하기 쉽게 Dispatchers 하나로 활용
    • UnitTest에서 Main thread를 활용할 수 없기에 기본으로 처리 할 수 있도록 작성
  • CoroutineScope를 Base에 작성하여 release를 쉽게 하도록 처리
  • onClick에 coroutine 활용을 위한 GlobalScope 적용

Dispatchers 정의(UnitTest/Default Thread)

sealed class DispatchersProviderSealed {
    open val main: CoroutineContext by lazy { Dispatchers.Main }
    open val default: CoroutineContext by lazy { Dispatchers.default }
}

/**
 * 기타 Thread를 위한 Dispatchers 정의
 */
object DispatchersProvider: DispatchersProviderSealed()

/**
 * Unit Test를 위한 Dispatchers 정의
 */
// Rx 기준으로 볼때 테스트시 전부 백그라운드 쓰레드로 돌려야할 경우
object TestDispatchersProvider: DispatchersProviderSealed() {
    override val main: CoroutineContext = Dispatchers.Unconfined
    override val default: CoroutineContext = Dispatchers.Unconfined
}

CoroutineScope를 상속받아 구현

abstract class CoroutineScopeActivity: AppCompatActivity(), CoroutineScope {
    private val job: Job = Job()	// Job을 미리 생성하여 CoroutineContext에 미리 지정할 수 있음
    
    override val coroutineContext: CoroutineContext	// Activity에서 사용할 기본 Context를 정의
    	get() = Dispatchers.Main + job
    
    override fun onDestroy() {
        super.onDestroy()
        job.cancel()				// onDestroy()에서 job을 종료하도록 함
    }
}
  • CoroutineScope를 상속받아 구현하면 기본 CoroutineScope로 정의되어 있음.
    • launch, actor<E> 를 사용하면 코드 간결 및 자동으로 종료 처리해 줌.
    • Default 상위 Activity에서 정의한 CoroutineScope의 Thread를 활용함.
    • 필요시 launch, actor에서 Thread를 언제든 변경 가능함.
  • 다양한 CoroutineScope 구현
    • ViewModel
    • LifeCycleObservable
    • Fragment

CoroutineScope 상속받은 Activity에서 routine 사용하기

abstract class CoroutineScopeActivity: CoroutineScopeActivity() {
    launch {
        // UI Thread에서 처리
    }
    launch(Dispatchers.Default) {
        // Default Thread에서 처리
    }
    actor<Generic Type> {
        // UI Thread에서 event 처리
        for(event in channel) action(event)
    }
    actor<Generic Type>(Dispatchers.Default) {
        // Default Thread에서 처리
        for(event in channel) action(event)
    }
}

onClick 처리

  • Higher-Order function + kotlin Extensions을 활용.
  • GlobalScope을 활용하거나, CoroutineScope을 활용 가능.
    • 동작 범위에 따라서 GlobalScope, CoroutineScope을 선택함이 좋음.

onClick 만들기, background에서 처리하고, UI에 던져주기

class CoroutinesSendChannelOnClickEvent<E>(
    private val view: View,							// View: click을 위한 View
    private val bgBody: suspend (item: View) -> E,	// background : Higher-Order Function
    private val dispatcherProvider: DispatchersProviderSealed = DispatchersProvider,	// Provider : 지정
    private val job: Job? = null) {					// Job : 종료 처리를 위한 job 추가
    
    fun consumeEach(uiBody: (item: E) -> Unit): CoroutinesSendChannelOnClickEvent<E> {
        val clickActor = CoroutineScope(dispatcherProvider.main + (job ?: EmptyCoroutineContext)).actor<View> {
            this.channel.map(context = dispatcherProvider.default, transform = bgBody).consumeEach(uiBody)
        }
        view.setOnClickListener { clickActor.offer(it) }	// Offer 처리를 위한 CoroutineScope 생성
        return this
    }
}
fun <E> View.onClick(dispatcherProvider: DispatchersProviderSealed = DispatchersProvider,
                     job: Job? = null, bgBody: suspend (item: View) -> E): CoroutinesSendChannelOnClickEvent<E> =
CoroutinesSendChannelOnClickEvent(this, bgBody, dispatcherProvider, job)

infix fun <E> CoroutinesSendChannelOnClickEvent<E>.consume(uiBody: (item: E) -> Unit) {	// 생성을 간단하게 하기 위한 function 2개
    this.consumeEach(uiBody)
}
fab.onClick(job = job) {		// click을 처리하고, background에서 loadnetwork()
    loadNetwork()
} consume {
    tv_message.text = it
}

private suspend fun loadNetwork(): String {		// Temp load network
    delay(300)
    return "currentIndex ${currentIndex++}"
}

Retrofit2 kotlin coroutines adapter

  • JakeWharton 배포

  • Dependency 추가

    • implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'
  • Deferred<E>를 활용하여 return을 처리할 수 있음.

    interface RetrofitService {
        @GET("/posts")
        fun getPosts(): Deferred<Response<List<Post>>>
    }
    // 초기화시키는 코드
    object RetrofitFactory {
        const val BASE_URL = “https://url"
        
        fun makeRetrofitService(): RetrofitService {
            return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addCallAdapterFactory(CoroutineCallAdapterFactory())
            .build().create(RetrofitService::class.java)
        } 
    }
    // 사용하는 코드
    launch {
        val request = service.getPosts() 
        val response = request.await() 
        if (response.isSuccessful) { 
            // Pass in the response.body() to your adapter 
        } else {
            toast("Error ${response.code()}") 
        }
    }

Q & A

Q : Dispatcher.Unconfined는 부모 코루틴 컨텍스트에서 코루틴을 실행하고 제일 처음 suspend 함수 이후에는 해당 suspend에서 사용했던 쓰레드에서 동작하는 걸로 알고 있음. 예제에서 Unconfined를 쓰셨던데 안드로이드에서 활용 가능한 부분이 있는지?

A : 테스트 코드에서 활용하기 위해 넣은 거임. UI Thread(Main Thread)에서 활용하기 위해 넣어야 함.

Q : Rx를 안쓰고 Coroutine 기반 라이브러리들로 코드 베이스를 변화시키거나 변화시킬 계획인지?

A : Coroutine을 조금씩 적용할 예정이며 Retrofit을 사용하기 위해 새로 팔 예정임. onClick에 먼저 적용 중.

Q : 코루틴이 rx java의 operator 기능들의 유연함을 못따라간다고 들었는데 어떤지?

A : 유연함을 못따라가는 것은 맞음. 아직 라이브러리들이 없음. 기본적인 틀만 제공 중인 상태.

'IT > 행사' 카테고리의 다른 글

[행사] Naver Tech Concert Day-1 요약  (0) 2019.01.14
[행사] Naver Tech Concert Day-2 06  (1) 2019.01.14
[행사] Naver Tech Concert Day-2 04  (0) 2019.01.14
[행사] Naver Tech Concert Day-2 03  (0) 2019.01.14
[행사] Naver Tech Concert Day-2 02  (0) 2019.01.14

+ Recent posts