Kotlin Coroutines 안드로이드 OnClick에 활용하기



개인 광고 영역

이전 글에서 Coroutines과 안드로이드에 적용하기 위한 플러그인 적용 방법을 알아보았다.

이번 글에서는 적용한 코드를 좀 더 자세하게 알아보고, Android에서 onClick과 함께 사용할 경우 유용한 Coroutine 사용 방법을 알아본다.

Countdown 코드 살펴보기

이전 글에 사용한 샘플 코드를 그대로 가져와보았다.

fun setup() {
    val job = GlobalScope.launch(Dispatchers.Main) { // launch coroutine in the main thread
        for (i in 10 downTo 1) { // countdown from 10 to 1
            tv_message.text = "Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        tv_message.text = "Done!"
    }
    fab.setOnClickListener {
        job.cancel() // cancel coroutine on click
    }
}

위 코드를 통해 이번 글에서 알아볼 부분을 분리해보았다.

  • 코루틴을 실행하는 블록
  • 코루틴의 스레드 형태를 어떻게 가져갈지 정의(Dispatchers.Main, Dispatchers.Default)

위와 같은 코드를 확인할 수 있다. 각각을 구분하여 글을 정리하고, onClick에서 사용할 부분을 좀 더 정리해본다.


코루틴을 실행하는 블록

코투린을 실행하는 블록은 두 가지를 제공하고 있다. 위의 샘플 코드에서는 GlobalScope을 사용하였다.

GlobalScope

GlobalScope는 싱글톤으로 전역 범위의 스레드에서 유용하게 사용할 수 있는데, 일반적인 어플리케이션이 종료되기 전까지 동작이 필요한 경우 유용하다.

GlobalScope의 원래 코드는 다음과 같이 기본 EmptyCoroutineContext를 사용하고, isActive는 항상 true를 리턴하고 있다. 문서상 GlobalScope는 launch 또는 async와 함께 사용하는 것은 좋은 방법이 아니며, 일반적인 코드에서는 매번 새로 생성하는 CoroutineScope을 사용하라고 한다.

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) {
    send(Math.sqrt(number))
  }
}

CoroutineScope

CoroutineScope는 모든 하위 Scope의 확장 형태를 가진다. 위의 GlobalScope 역시 CoroutineScope을 상속받아 구현하고 있다. CoroutineScope의 coroutineContext 상속에 따라 컨텍스트와 취소 동작을 사용할지 지정한다.

모든 Coroutine builder(like launch, async, etc)와 모든 하위 scope(like coroutineScope, withContext, etc)에서 메인 Job(CoroutineScope의 Job)에 의해 동작을 처리할 수 있다. CoroutineScope 내부에서 정의한 Coroutine은 모든 동작이 완료될 때까지 기다렸다가 다음을 처리하도록 동작한다.

아래와 같이 CoroutineScope을 정의하였는데, wait -> Done으로 넘어가려면 1초를 대기하고 넘어간다. 이러한 형태를 CoroutineScope()의 정의에 따라서 동작하게 된다.

CoroutineScope(Dispatchers.Main).launch {
    tv_message.text = "wait"
    delay(1000)
    tv_message.text = "Done!"
}

안드로이드에서 CoroutineScope을 잘 사용하려면 Coroutine 라이프 사이클과 함께 사용하는 게 좋다. 이러한 샘플 코드는 문서에 잘 정의되어 있는데 다음과 같은 Activity를 만들어두고, job을 활용할 수 있도록 정의하고 사용하도록 안내하고 있다.

class MyActivity : AppCompatActivity(), CoroutineScope {

  // Job을 등록할 수 있도록 초기화
  lateinit var job: Job

  // 기본 Main Thread 정의와 job을 함께 초기화
  override val coroutineContext: CoroutineContext
      get() = Dispatchers.Main + job

  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      job = Job()
  }

  // 작업 중이던 모든 job을 종 children을 종료처리
  override fun onDestroy() {
      super.onDestroy()
      job.cancel() // Cancel job on activity destroy. After destroy all children jobs will be cancelled automatically
  }

  /*
   * Note how coroutine builders are scoped: if activity is destroyed or any of the launched coroutines
   * in this method throws an exception, then all nested coroutines are cancelled.
   */
    fun loadDataFromUI() = launch { // <- extension on current activity, launched in the main thread
       val ioData = async(Dispatchers.IO) { // <- extension on launch scope, launched in IO dispatcher
           // blocking I/O operation
       }
       // do something else concurrently with I/O
       val data = ioData.await() // wait for result of I/O
       draw(data) // can draw in the main thread
    }
}

scope 선택

GlobalScope와 CoroutineScope의 용도에 적절하게 사용하는 게 좋다. CoroutineScope에서는 꼭 Job을 별도로 만들어서 이를 활용함이 좋은데 저렇게 미리 만들어두는 게 좋다. 아래쪽에서 설명할 것이지만, 별도로 만드는 scope의 경우는 Android AAC Lifecycle을 함께 활용하여 처리하는 방법도 가능하다.


코루틴의 스레드 형태를 어떻게 가져갈지 정의(Dispatchers.Main, Dispatchers.Default)

코루틴도 RxJava와 같이 스레드 형태를 여러 개 제공하고 있다. 안드로이드에서는 Dispatchers.Main과 Dispatchers.Default을 활용하게 될 것이다.

Dispatchers.Main

Dispatchers.Main은 안드로이드 용으로 제공하는 thread이다. Java Handler가 기본으로 초기화되어 사용된다.

Dispatches execution onto Android main thread and provides native [delay] support.

Dispatchers.Default

Dispatchers.Default은 모든 launch, async, etc에서 사용하는데, ContinuationInterceptor을 지정하지 않을 경우 기본으로 사용하는 CoroutineDispatcher이다.

이 Dispatches는 JVM에서 공유된 스레드 풀에 의해 동작한다. Dispatches.Default에서 사용하는 최대 스레드 수는 CPU 코어 수와 같은데 최소 두 개를 사용하고 있다.


Android OnClick에 따른 적절한 coroutine 처리 - 일단 알아보자

onClick을 아래와 같이 생성해보았다. 아래 코드로는 무슨 일이 사실 일어나는지 알 수 없다.

private var count = 0
btn_start.setOnClickListener {
    count++
    CoroutineScope(Dispatchers.Main).launch {
        for (i in 10 downTo 1) { // countdown from 10 to 1
            tv_message.text = "Now Click $count Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        tv_message.text = "Done!"
    }
}

이제 앱을 실행하고, start 버튼을 N 번 눌러본다. 아래 그림처럼 계속적인 버튼을 눌렀다면 무슨 일이 일어날까?

click
click

결과는 아래와 같다. 결과의 이해를 돕기 위해 그림과 텍스트를 추가해보았다. 결과는 병렬로 계속 움직인다.

Now Click 1 Countdown 5 ...
Now Click 2 Countdown 5 ...
Now Click 1 Countdown 4 ...
Now Click 3 Countdown 5 ...
...
Now Click 1 Countdown 1 ...
Done!
Now Click 5 Countdown 5 ...
Now Click 4 Countdown 4 ...
Now Click 3 Countdown 1 ...
Done!

첫 번째 클릭의 결과로 Now Click 1 Countdown 5 … 이 먼저 보이고, 이어서 버튼을 누르면 Now Click 2 Countdown 5 …이 잠깐 보였다가 다시 1번이 노출된다. 이런식으로 반복적으로 클릭을 발생하면 버튼 누른 만큼 N 번의 결과물이 노출되는걸 확인할 수 있다. 좀 더 자세하게 그림을 참고하길 바란다.

coroutines-example-01
coroutines-example-01

사용자 액션에 따라 무작위로 동작해야 하는 경우도 있지만, 보통 그렇지 않다. 이전 동작이 모두 끝나고 나면 다음 동작을 해야 하는 경우도 있고, 중간중간 사용자의 액션을 처리해야 하는 경우도 있다.


actor을 이용해보자

먼저 예를 들어보자.

네트워크를 통해 데이터를 받아오고, 이를 노출한다.

이때 위와 같이 N 번 클릭을 무작위로 받는 코드를 작성한다면? 결과는 당연히 네트워크도 N 번 동작하게 된다. 기존 이벤트를 취소하는 것도 적절한 방법은 아니다.

그래서 actor을 이용할 수 있는데, 아래와 같이 코드를 수정해보았다.

private var count = 0
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContentView(R.layout.activity_main)

  btn_start.onClick {
    count++
    for (i in 10 downTo 1) { // countdown from 10 to 1
        tv_message.text = "Now Click $count Countdown $i ..." // update text
        delay(500) // wait half a second
    }
    tv_message.text = "Done!"
  }
}

private fun View.onClick(action: suspend (View) -> Unit) {
  // launch one actor
  val event = GlobalScope.actor<View>(Dispatchers.Main) {
    for (event in channel) action(event)
  }

  setOnClickListener {
    event.offer(it)
  }
}

위 코드는 여러 번 누르더라도 기존 action을 모두 처리하기 전에는 다음으로 넘어가지 않는다.

coroutines-example-02
coroutines-example-02


마무리

간단하게 Android에서 Coroutine을 어떤 식으로 활용할 수 있을지 살펴보았다. RxJava를 활용한다면 throttleFirst의 형태를 만들어야 하겠지만 kotlin coroutine에서는 간단하게 Actor을 활용할 수 있다. 다만 GlobalScope가 아닌 매번 생성하는 CoroutineScope을 활용한다면 또 다른 이야기이긴 하다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Facebook Comments


Comments