쾌락코딩

이모티콘 컨테이너 UI 구현 (채팅, like 카카오톡)

|

카카오톡 이모티콘 컨테이너 UI 모습

서론

채팅 앱을 개발하다 보면, 참으로 많은 부분에서 “카카오톡 처럼 해주세요” 라는 요구사항이 들어온다. 메세지 공지 UI는 물론이고 검색, 첨부 파일 관리, 설정 화면 등등 모범이 될 만한 UI들이 많기 때문인 것 같다. 실제로 채팅 앱을 개발 하다보니 카카오톡에서 당연하게 쓰고 있던 사소한 기능과 UI들이 실제로 구현하기에는 상당히 까다롭고 어려운 작업들이 많았다는 것을 느낄 수 있었다.

그 중에서도 생각 보다 구현이 까다로웠던 부분이 이모티콘 컨테이너 UI 였다. 위의 GIF에서 보이듯이 키보드가 보여지고 있는 상태에서 이모티콘 버튼을 클릭하면 키보드가 내려감과 동시에 이모티콘 컨테이너가 나오는데, 마치 키보드 뒤에서 기다리고 있었다는 듯이 이모티콘 컨테이너가 자연스럽게 모습을 드러낸다.

어색한 UI

카카오톡 이모티콘 컨테이너 UI에 대해서 깊게 살펴보지 않았을 때에는 단순하게 구현 했었다. UI도 UI지만 우선은 기능 개발이 급했기 때문에, 1.키보드 내림 2.이모티콘 컨테이너 띄움 순서로 구현해 놓았다. 그러나 이런 UI는
1. 채팅 내용을 담은 view가 키보드를 따라 밑으로 쭉 내려갔다가 2. 이모티콘 컨테이너가 올라감에 따라 다시 위로 쭉 올라가는 UI가 된다. 화면이 크게 한번 깜빡이는 것과 비슷하다. 이를 개선하기 위해 카카오톡을 유심히 살펴보았고, 나름대로 구현한 방법을 공유하려 한다.

훌륭한 UI

채팅앱의 경우, 대부분 UI가 비슷할 것 같다. 맨 위에 툴바가 있고, 가운데 리사이클러뷰가 있고, 맨 아래 Edit Text가 있는 형태다. Edit Text를 클릭하면 당연히 키보드가 올라온다.

이때 중요한 점은 windowSoftInputMode 이다. 안드로이드 개발자라면 기본적으로 알아야 할 지식일 뿐만 아니라, 카카오톡 같은 UI를 구현하기 위해서 필자가 사용한 핵심 내용이기 때문에 꼭 숙지하자(“android adjustResize” 라고만 구글링 해도 좋은 자료가 많이 나온다).

키보드가 올라오면 Edit Text를 가리지 않도록 하기 위해, 그리고 toolbar가 화면 위로 Over되지 않도록 하기 위해 adjustResize 속성을 넣어 주게 된다.

<activity android:windowSoftInputMode="adjustResize" ... >

그런데 adjustResize속성을 사용하면 키보드가 올라올 때 키보드의 높이에 따라 Activity의 사이즈가 재 조정 된다. Activity가 키보드 위에서 부터 시작하도록 사이즈가 줄어들었기 때문에 실제로 키보드 뒤에 이모티콘 컨테이너를 넣는건 불가능해 보인다. 여러분이 어떠한 뷰를 사용해 이모티콘 컨테이너를 만들었다 하더라도 이미 키보드 뒤의 영역은 view를 넣을 수 있는 공간(Activity)이 아니기 때문이다. 키보드가 올라와있는 상태에서 이모티콘 버튼을 누르면, 마치 키보드 뒷 공간에 이미 뷰가 존재했던것 처럼 느껴졌었지만, 실제로 adjustResize모드에서는 그게 불가능하다는 결론이 나왔다.

그럼 도대체 어떻게 저런 UI가 나올수 있을까를 한참 고민하던 중에, 필요할 때만 android:windowSoftInputMode를 동적으로 바꿔주면 가능하지 않을까 라는 생각이 들었다.

아이디어 : windowSoftInputMode 속성 이용하기

windowSoftInputMode속성에는 adjustNothing이라는 속성이 있다. 이 속성을 적용하면 키보드가 올라오고 내려갈 때 Activity와 view에 아무런 영향도 없다. 그저 키보드가 올라오면 키보드 영역만큼 화면이 가려지는 것이고, 내려가면 단지 내려가는 것이다. 즉, adjustNothing 일때는 adjustResize와는 다르게 activity 크기를 조절하지 않는다는 말이고, 키보드가 화면을 덮어버리는 방식이다.

화면을 덮어버린다. 즉 우리가 이모티콘 컨테이너 뷰를 만들어 놓으면, 그 뷰를 덮어버릴 수 있다는 이야기이다. 평소에는 adjustResize를 유지하다가 이모티콘 컨테이너를 보여줄때와 숨길때만 잠시 adjustNothing로 바꾸자.

xml 구성

view 구성은 아래와 같다.

<ConstraintLayout ...>
    <ToolBar .../>
    <RecyclerView .../> // 메세지들이 노출되는 공간
    <EditText .../>     // 화면 아랫쪽 텍스트 입력 필드
    <EmoticonContainer
        android:id="@+id/emoticon_container"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="gone"
        app:layout_constraintTop_toBottomOf="EditText"
    .../>
</ConstraintLayout>

EmoticonContainerEditText 아래쪽에 배치하고 우선 visibilitygone으로 해놓는다. 채팅방에 들어가자마자 이모티콘 컨테이너를 띄울 필요는 없으니까.

동시에 heightwrap_content로 해놓자. 좋은 UI를 위해서, 추후에 키보드 높이로 다시 세팅해줄 것이다.

이모티콘 버튼 클릭시 호출되는 코드 구성

// 이모티콘 버튼 클릭시 매번 호출되는 코드
lifecycleScope.launch {
      if (showContainer) { // 상황 1
          window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
          emoticon_container.visibility = View.VISIBLE
          hideKeyboard()
          delay(100)
          window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
      } else { // 상황 2
          window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
          showKeyboard() // 키보드 올리는 코드
          delay(100)
          emoticon_container.visibility = View.GONE
          window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
      }
  }

상황을 가정해보자.


상황 1. 키보드가 올라와 있는 상태에서 사용자가 이모티콘 버튼 클릭(키보드는 내려가고, 마치 이모티콘 컨테이너가 뒤에 있었다는 듯 나타난다, 이때 리사이클러 뷰를 포함한 다른 뷰 들은 전혀 미동이 없어야 한다)

코드에서는 if문에 걸리는 상황이다. 이모티콘 컨테이너를 띄워야 하더라도 당장 키보드를 내려선 안된다.

  1. adjustNothing로 바꿔 준다. 이제 키보드 뒤에 이모티콘 뷰 를 넣을 수 있는 조건이 되었다(adjustResize 모드 였다면 키보드 위쪽에 이모티콘 컨테이너, 그 위에 EditText가 배치되어 버림).
  2. gone이었던 컨테이너를 visible로 바꿔 주자(여기서 중요한 점은 컨테이너의 높이가 키보드의 높이와 동일해야 한다는 점인데, 이 방법은 뒤에서 다룬다). 키보드 뒤에 컨테이너가 숨어져있는 상태가 되었다.
  3. 컨테이너를 가리고 있는 키보드를 hide()시켜주자. hide 시키면 뒤에 숨어있던 컨테이너가 자연스럽게 보이며, 키보드가 내려 갔어도 컨테이너 높이 때문에 editText를 비롯한 다른 뷰 들은 여전히 미동 없이 그 자리에 있다.
  4. 키보드를 내리자마자 delay를 0.1초 정도 주었다. hideKeyboard()를 호출 했다고 해서 키보드가 바로 사라지는게 아니라 끝까지 내려가는데 시간이 걸리기 때문이다. 만약 delay를 주지 않고 곧바로 adjustResize로 설정 해버린다면 키보드가 내려가는 도중에 적용이 되어 버려서 이모티콘 컨테이너가 내려가던 키보드 위에 달라붙게 된다.
  5. 딜레이 이후 다시 adjustResize로 바꿔주자.

상황 2. 이모티콘 컨테이너가 띄워져 있는 상태에서 사용자가 다시 이모티콘 아이콘을 클릭하여 키보드가 올라오는 상황(키보드가 자연스럽게 이모티콘 컨테이너를 덮는 UI가 되어야 한다)

코드에서는 else문에 걸리는 상황이다. 이때도 성급히 키보드를 올려버리면 안된다.

  1. adjustNothing로 바꿔 준다. 이제 키보드가 이모티콘 컨테이너를 덮을 수 있는 조건이 되었다.
  2. 키보드를 올려 키보드가 이모티콘 컨테이너를 덮어버리게 하자.
  3. 다 덮을 때 까지 delay를 0.1s 주자.
  4. 이모티콘 컨테이너를 gone으로 바꾸자.
  5. 아름다운 UI가 동작했으니, 아무일 없었다는듯 adjustResize를 적용시키자.

이렇게 하면 이모티콘 버튼을 계속 누르더라도 카카오톡과 동일한 아주 자연스러운 UI로 동작하게 된다. 컨테이너가 올라와있는 상태에서 Edit Text를 클릭하는 상황은 위 코드에 없지만, else문과 동일한 로직으로 사용 가능하다.

이모티콘 컨테이너 높이를 키보드 높이로 조정하기

키보드가 이모티콘 컨테이너를 덮는다 하더라도, 컨테이너의 높이가 키보드의 높이와 다르면 아무런 소용이 없다. 키보드 높이와 컨테이너의 높이가 완벽히 동일해야 한다.

// onCreate()
var rootHeight = -1
root_view.viewTreeObserver.addOnGlobalLayoutListener {
    if (rootHeight == -1) rootHeight = root_view.height // 매번 호출되기 때문에, 처음 한 번만 값을 할당해준다.
    val visibleFrameSize = Rect()
    root_view.getWindowVisibleDisplayFrame(visibleFrameSize)
        val heightExceptKeyboard = visibleFrameSize.bottom - visibleFrameSize.top
		// 키보드를 제외한 높이가 디바이스 root_view보다 높거나 같다면, 키보드가 올라왔을 때가 아니므로 거른다.
        if (heightExceptKeyboard < rootHeight) {
                // 키보드 높이
                val keyboardHeight = rootHeight - heightExceptKeyboard
        }
}

addOnGlobalLayoutListener 메서드를 사용하여 root_view의 높이를 알 수 있다. 리스너로 등록한 코드 블럭은 레이아웃의 크기나 위치가 바뀔 때 마다 호출이 되는데, 키보드가 올라오고 내려갈 때 역시 매번 호출된다.

키보드가 올라왔을 때를 기회 삼아서 키보드 높이를 구할 수 있는데, 키보드 높이 = 전체 높이 - 키보드를 제외한 높이이다.

키보드를 제외한 높이는 getWindowVisibleDisplayFrame 를 사용하여 구할 수 있다.

val visibleFrameSize = Rect()
root_view.getWindowVisibleDisplayFrame(visibleFrameSize)
val heightExceptKeyboard = visibleFrameSize.bottom - visibleFrameSize.top

최종적으로 이렇게 구한 keyboardHeight 를 이모티콘 컨테이너의 height로 할당해주면 된다.

Kotlin exhaustive with sealed class and when

|

프로그래밍을 하다보면 열거형(Enum)이 필요할 때가 굉장히 많다. 대표적인 예로 요일(월,화,수…일)이나 방향(동,서,남,북)처럼 특정 카테고리 안에서 열거할 수 있는 값들을 나타낼 때 쓰인다. 더 나아가서 우리가 창조해낸 객체지향 세상에서 얼마든지 다양한 열거형들이 추가로 생겨날 수 있다.

코틀린에서는 언어 자체에서 Sealed class라는 개념을 지원한다. Enum을 사용하면 각각의 타입을 나타내는 값들이 싱글톤으로 인식되는 반면, Sealed class를 사용하면 특정 타입의 객체를 여러개 생성해 낼 수 있으며, 싱글톤으로도 사용이 가능하다.

Sealed class가 익숙하지 않다면 공식문서를 참조하자.

when

우선 when의 특징을 간략하게 짚고 넘어가보자. whenif와 마찬가지로 식(expression)과 문(statement)로 사용이 가능하다. 식으로 쓰인다는 말은 반환되는 값이 있다는 말로 이해할 수 있다. 아래의 예제를 보자.

carbon (4)

위의 코드는 ifwhen이 식(expression)으로 사용 되어 값을 반환한다. 값을 반환하도록 사용되었기 때문에 when 에서는 꼭 else가 있어야만 한다. 만약 else가 없다면 fruit가 “apple”, “orange”, “banana”가 아닐 때 어떤 값을 반환해야 하는지 정할 수 없으므로 컴파일 에러가 난다.

반면 if, when을 문(statement)로 사용할 때는 값을 반환하지 않는다. 아래 예제는 statement로 사용하는 예제이다.

앞선 예제와는 다르게 값을 반환하지 않는다. 따라서 else문이 없어도 상관없다. 조건에 해당하지 않으면 아무 일도 일어나지 않을 뿐 컴파일에러, 런 타임에러는 일어나지 않는다.

when + sealed class

Sealed class의 효력은 when과 함께할 때 강력하게 나타난다. Sealed classwhen expression을 함께 쓰는 경우를 상상해보자. Sealed class를 사용하는 이유는 어떤 값이 특정한 카테고리 안에 속해 있음을 보장하기 위해서이다. UI를 그리는 코드를 작성하다 보면 데이터를 요청한 이후의 상태는 크게 세가지로 나눌 수 있을 것이다. loading이거나, 요청에 실패(Error) 했거나, 성공(Success) 하여 값을 받아왔거나. 이를 아래 처럼 나타낼 수 있고, 실제로 사용한다면 expression 보다는 statement로 사용할 일이 많다.

여기서 조심 해야할 부분은 when이 statement로 사용되었기 때문에, uiStateLoaindg, Error, Success에 걸리지 않더라도 컴파일 에러가 나지 않고, 런 타임 때 코드는 아무런 행동도 하지 않는다는 것이다. 물론 지금 상황에선 uiState는 세가지 상태만 존재하니 무조건 조건에 걸릴것이다.

변화 대응에 안전한 코드로 만들기

다른 누군가가 필요에 의하여 UiStateRetry라는 상태 값을 추가하면 어떻게 될까?

아까 보안던 when문에서 컴파일 에러를 띄워주지 않기 때문에 Retry 상태 체크를 까먹을 확률이 매우 높다. else를 사용할 수 있긴 하지만 Retry가 아니라 다른 값이 추가된다면 그에 대응하지 못한다.

먼 훗날 본인 혹은 다른 개발자가 UiState에 다른 상태를 추가하게 될 경우, 컴파일 에러를 발생시켜 이를 파악하고자 한다면 아래처럼 when을 식(expression)으로 사용할 수도 있다.

image

when을 식으로 사용하면 꼭 값을 반환해야 하기에 Retry를 체크하라는 에러가 뜬다. 그러나 좋은 방법은 아닌 것 같다. 먼 훗날 hello라는 변수를 보면 “사용하지도 않는 변수가 왜 있지?”라며 코드의 가독성을 해칠 가능성이 보인다.

또 다른 방법으로는 아래 이미지와 같이 let을 사용하여 컴파일 에러를 유발할 수 있는데, 이것 역시 먼 훗날 다른 개발자가 보기에는 혼란을 일으킬 수 있는 코드이다.

image

let을 사용하게 되면 statement처럼 쓴 when을 식(expression)처럼 사용할 수 있다. let 함수는 자신을 호출한 객체를 람다 블록의 수신 객체로 사용하기 때문이다. 즉 when이 무언가를 반환 해야만 하는 것이다. 따라서 컴파일 에러가 난다. 하지만 이것 역시 먼 훗날 다른 개발자가 코드를 볼 때 의미 없어 보이는 .let 블럭을 지울 확률이 높아 보인다.

exhaustive

프로젝트 여기저기서 exhaustive라는 코드가 종종 보여서 구현체를 보았더니 아주 심플하다.

val <T> T.exhaustive: T
    get() = this

exhaustive : (하나도 빠뜨리는 것 없이) 철저한

바로 위에서 let을 사용하여 statement를 expression으로 대체한 원리와 같다. exhaustive라는 의미와 부가적인 코드가 없기 때문에 가독성이 좋고 추후 혼란을 일으킬 여지가 없어보인다.

Launching flow

|

이 포스팅은 공식문서를 보며 공부한 내용입니다.

flow를 사용하면 어딘가로부터 비동기적으로 들어오는 이벤트를 표현하기 쉽습니다. 보통 이런 경우, 비동기적으로 흘러 들어오는 이벤트(값)을 처리하기위해 addEventListener 메서드에다가 코드 블럭을 작성해 놓으면, 나머지 다른 작업(다른 코드 블럭)들 역시 수행시킬 수 있습니다. flow에서는 onEach 연산자가 그 역할을 합니다. 하지만 onEach 는 중간 연산자이기 때문에 그 자체만으로는 기능을 할 수 없고, collect 같은 terminal 연산자가 필요합니다.

onEach 연산자 다음에 collect terminal 연산자를 사용하면, flow를 collect 하는 코드 이후에 배치된 코드들은 collection이 완료되고 나서야 실행이 됩니다. 아래 예제를 보면 “Done”이 events() flow의 collect가 끝난 이후에 찍히는 것을 확인할 수 있습니다.

// Imitate a flow of events
fun events(): Flow<Int> = (1..3).asFlow().onEach { delay(100) }

fun main() = runBlocking<Unit> {
    events()
        .onEach { event -> println("Event: $event") }
        .collect() // <--- Collecting the flow waits
    println("Done")
}
Event: 1
Event: 2
Event: 3
Done

그러나 위의 예시와 같이 사용하면 찜찜한 점이 있습니다. 아마도 대부분의 상황에서는 비동기 적으로 발생하는 event를 계속해서 기다리고만 있는 게 아니라, 다른 코드들이 수행되는 도중에 event가 발생하는 그 순간에만 onEach 코드 블럭을 수행하고 싶을겁니다. launcIn terminal 연산자를 사용하면 그렇게 동작시킬 수 있습니다. collect 대신 launchIn 을 사용하면 flow collection은 다른 코루틴에서 동작하기 때문에 다른 코드 블럭들과 함께 동작하게 됩니다.

fun main() = runBlocking<Unit> {
    events()
        .onEach { event -> println("Event: $event") }
        .launchIn(this) // <--- Launching the flow in a separate coroutine
    println("Done")
}
Done
Event: 1
Event: 2
Event: 3

launchIn 를 사용할 땐 파라미터로 Collection을 수행할 코루틴의 Scope를 넣어주어야 합니다. 위의 예시에는 runBlocking의 coroutineScope를 넣어 주었기 때문에, events() flow가 끝나기 전까지는 runBlocking이 끝나지 않게 됩니다.

실제 애플리케이션에서 CoroutineScope는 특정 lifetime을 갖는 entity로 부터 생깁니다. entity가 lifetime을 다하면 해당 CoroutineScope이 cancel되며 scope에 속해있던 flow 역시 cancel됩니다. 이런점에서 보면 onEach { ... }.launchIn(scope) 구문은 addEventListener 와 비슷해 보입니다. 그러나 CoroutineScope의 종료시 함께 종료되기 때문에 removeEventListener 같은 이벤트 제거 함수가 필요없다는 차이점이 있습니다.

lanchInjob 을 반환합니다. 반환되는 job을 통해서 해당 scope 전체를 취소하지 않고도 flow만 cancel 시킬 수 있고, join 함수를 사용하여 원하는 시점에 대기할 수 도 있습니다.

코루틴 취소(Coroutine Cancellation)

|

실행중인 코루틴을 취소하는 방법은 공식문서에도 나와있듯 간단하다. job 객체의 cancel() 을 호출하면 취소된다고 한다.

The launch function returns a Job that can be used to cancel the running coroutine:

val job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")

// reulst
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

코루틴 취소가 이렇게 간단해 보이지만 실제 프로젝트에서 사용하다보면 뜻대로 행동하지 않는다. 분명히 cancel()을 시켜주었지만 동작이 멈추지 않고 계속해서 실행되는 경우가 있다.

fun main() = runBlocking {
    val job = launch {
        while(true) {
            println("while")
        }
    }
    delay(1000L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    job.join() // waits for job's completion
    println("main: Now I can quit.")
}

// result
while
while
while
...
...
...

위 코드를 보면 분명 cancel() 해주는 코드가 있지만, "while"만 계속 찍히며 종료되지 않는다. 사실 위 코드가 왜 취소되지 않고 영원히 동작하는가를 파악하지 못했다는 건, 코루틴 Cancellation에 대한 이해 부족도 있지만 코드 플로우를 잘 따라가지 못한 것도 한 몫 한다.

launch로 실행한 코루틴은 메인 쓰레드에서 돌아간다. 코드가 실행되면 launch로 코루틴을 실행 시키고 곧바로 delay를 만나는데, delaysuspend함수이기 때문에 다른 코루틴으로 제어권을 넘긴다. 이때 제어권을 넘겨받은 또다른 코루틴, 즉, 아까 메인 쓰레드에서 launch했던 while문 코드가 실행이 되는데, 이 while문은 조건이 항상 true라서 사실상 메인 쓰레드를 계속 해서 잡고 있는 것이다. delay에서 걸어준 1초가 끝이 났지만, while이 thread를 계속해서 사용 중임으로 resume되지 못한다. 따라서 앱이 종료되지 않음은 물론이고 "main: I'm tired of waiting!" 문장도 찍히지 않는 것이다.

위의 코드에서 luanch의 context를 Dispatcher.Deafult 바꿔주면 다르게 동작한다.

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        while(true) {}
    }
    delay(1000L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancel() // cancels the job
    job.join() // waits for job's completion
    println("main: Now I can quit.")
}

//result
main: I'm tired of waiting!
// 영원히 끝나지 않음.
...

runBlocking 내의 코드는 메인 쓰레드에서, launch 코루틴은 다른 쓰레드에서 돌아가기 때문에 백그라운드의 while문이 메인 쓰레드의 흐름을 방해하지 못한다. 따라서 "main: I'm tired of waiting!" 문장이 찍힌다. 곧바로 백그라운드 jobcancel시키고, job이 종료될 때 까지 join 한다.

일반적으로는 job.cancel() 을 호출 했으니 취소되어 곧바로 job.join()으로 넘어갈 것이라고 생각할 수 있지만 실제로 코드는 그렇게 동작하지 않는다. 위 코드 역시 영원히 끝나지 않는다. 왜 일까? 공식문서에는 이렇게 나와있다.

However, if a coroutine is working in a computation and does not check for cancellation, then it cannot be cancelled

즉, long running loop 같은 작업을 실행 중인 코루틴이 있을 경우, 작업 중에 cancellaion을 체크하여 취소해주지 않는다면 취소되지 않는 다는 말이다. cancel()을 호출해도, 곧바로 코루틴이 완전히 종료되는게 아니라, loop내부에서 isActivefalse인지를 체크해서 명시적으로 종료 해야 한다는 것이다.

그렇다면 가장 처음에 보았던 공식 문서 예제는 어떻게 취소가 되었을까?

suspend 함수가 Exception을 던진다.

val job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")

// reulst
job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

비밀은 delay() 에 있다. 정확하게 말하자면 suspend 함수가 내부적으로 isActive를 체크하여 isActivefalse일 때 Exception을 던지는 것이다.

공식문서에는 아래의 문장으로 설명되어 있다.

All the suspending functions in kotlinx.coroutines are cancellable. They check for cancellation of coroutine and throw CancellationException when cancelled.

실제로 delay()함수 내부를 보면 suspendCancellableCoroutine 로 구현되어 있음을 알 수 있다.

/**
 * If the [Job] of the current coroutine is cancelled or completed while this suspending function is waiting, this function
 * immediately resumes with [CancellationException].
 */
public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
    }
}

suspendCancellableCoroutine 의 내부 구현을 따라가다 보면 isActive를 체크하는 코드가 보인다.

...
if (resumeMode == MODE_CANCELLABLE) {
            val job = context[Job]
            if (job != null && !job.isActive) {
                val cause = job.getCancellationException()
                cancelResult(state, cause)
                throw recoverStackTrace(cause, this)
            }
        }
...

CancellationException

cancel()의 타겟인 코루틴 내부에 suspend 함수가 있다면, 그 suspend 함수가 CancellationException을 throw한다. 그러나 suspend가 없다면, CancellationException 는 throw되지 않는다. 이런 경우에는 isActive를 체크하여 명시적으로 CancellationException 를 던질 수 있다.

참고자료

Flow Completion

|

flow가 방출한 모든 값을 정상적으로 다 수집했기 때문에 flow가 종료되었든, 또는 에러가 발생해서 flow가 종료되었든 종료 이후에 어떤 액션을 취하고 싶을 수가 있습니다. 어떤 이유에서든 flow가 끝났을 때 액션을 취하기 위해 명령형, 또는 선언형으로 코드를 작성할 수 있습니다.

Imperative finally block

flow collection이 끝났을 때 어떤 행동을 하고자 한다면, try/catch 를 사용하여 명령형으로 코드를 작성 할 수 있습니다.

fun foo(): Flow<Int> = (1..3).asFlow()

fun main() = runBlocking<Unit> {
    try {
        foo().collect { value -> println(value) }
    } finally {
        println("Done")
    }
}
// 결과
1
2
3
Done

Declarative handling

선언형으로 코드를 작성하고 싶다면, onCompletion 중간 연산자를 사용하세요. onCompletion 연산자는 colletion이 끝나고 나면 실행됩니다.

foo()
    .onCompletion { println("Done") }
    .collect { value -> println(value) }

onCompletion의 장점은 선언형이라는 점에서 그치지 않습니다. 가장 큰 장점은 onCompletion 이 받는 람다의 nullable한 Throwable 파라미터 입니다. 즉 onCompletion 이 받는 람다의 파라미터(Throwable)가 null이면 collection이 완전하게 수행되었음을 알 수 있고, null이라면 exception으로 인해 flow가 completion 되었음을 알 수 있습니다. 아래에서 foo() flow가 처음 1을 방출하고 에러를 발생시키는 코드를 볼 수 있습니다.

fun foo(): Flow<Int> = flow {
    emit(1)
    throw RuntimeException()
}

fun main() = runBlocking<Unit> {
    foo()
        .onCompletion { cause -> if (cause != null) println("Flow completed exceptionally") }
        .catch { cause -> println("Caught exception") }
        .collect { value -> println(value) }
}
// 결과
1
Flow completed exceptionally
Caught exception

onCompletioncatch 와는 다르게 exception 을 처리하지는 않습니다. 바로 위의 예제에서 볼 수 있듯이 exceptiononCompletion 이후 계속해서 downstream으로 흐릅니다. 그렇기 때문에 에러 처리는 catch에서 합니다.

Successful completion

onCompletioncatch와 다른 점은, onCompletion은 항상 모든 예외를 받는다는 점입니다. 성공적으로 collection이 완료 되었더라도 null을 수신하고 코드가 실행되는 반면, catch는 전혀 실행되지 않습니다.