해당 글은 아래 글의 번역본으로 오역이 있을 수 있다는 점 미리 공유드립니다.
medium.com/androiddevelopers/suspending-over-views-19de9ebd7020
코틀린의 코루틴은 비동기적인 문제들을 마치 동기적으로 처리할수 있도록 도와줍니다. 해당 방식으로 사용되는 것은 매우 좋습니다. 그러나 코루틴을 사용하는 대부분은 동시성의 작업들 혹은 I/O 작업에만 집중하여 처리되는 것 처럼 보입니다. 코루틴은 여러 스레드(멀티 스레드)를 오가며 문제를 해결할 수 있도록 모델링 되어 있습니다. 그러나 하나의 스레드에서도 비동기 문제를 해결할 수 있도록 모델링 할 수 있습니다.(코루틴의 강점)
그리고 위와 같은 방식을 사용해서 큰 효과를 낼 수 있는 한 부분이 안드로이드 뷰 시스템입니다
안드로이드 뷰들의 콜백들(Android views callbacks)
안드로이드 뷰 시스템은 콜백을 사랑합니다(정말 사랑합니다) 따라서 현재에도 안드로이드 프레임 워크내의 위젯 그리고 뷰 클래스들은 80개 이상의 콜백을 제공합니다. 제트팩(UI 관련 라이브러리가 아니여도 포함하였습니다.)에서도 200개 넘게 제공하고 있습니다.
공통적으로 사용하는 예제들을
- AnimatorListener : 애니메이션이 끝났을 때 알려주는 용도
- RecyclerView.OnScrollListener : 스크롤 변화를 감지하여 알려주는 용도
- View.OnLayoutChangeListener : 뷰가 배치될 때 알려주는 용도
것들이 있습니다.
그리고 이러한 View.post 혹은 View.postDelayed 같은 비동기 적인 액션을 수행하기 위해 Runnable 클래스를 사용하는 API 들도 있습니다.
안드로이드는 태생적으로 비동기적인 유저 인터페이스를 제공하기 때문에 수많은 콜백들을 가지고 있습니다. 측정, 배치, 그리기, 디스패치의 추가 등은 모두 비동기적으로 수행됩니다. 일반적으로 무엇가(대부분 뷰)가 시스템으로부터 순회를 요청하고, 시간이 지나 리스너가 호출될 때 시스템으로 부터 요청을 디스패치 하는 식입니다.(기본적인 콜백에 대한 설명)
KTX 확장 함수들(KTX extension functions)
개발자들의 편의를 향상시키기 위해, 구글은 제트팩 안에 뷰 관련 많은 확장함수들을 추가하였습니다.
그중에 제가(저자) 가장 좋아하는 메서드는 다음 뷰가 그려지기 전까지 기다리는 기능을 아주 간단하게 만들어주는 View.doOnPreDraw() 가 있습니다. 또한 매일 사용하는 View.doOnLayout, Animator.doOnEnd() 같은 확장함수들이 있습니다.
그러나 이런 확장함수이 현재는 구시대의 callback API 를 코틀린 친화적인 람다 스타일 API 로 만들어주는 수준정도 입니다. 그들은 충분히 사용하는데 편리하지만, 우리는 여전히 다른 형태의 콜백 스타일(람다 스타일)로 복잡한 UI 명령어들을 어렵게 수행하고 있습니다. 이런 비동기 작업들에 대해서 코루틴이 이점이 있을지 이야기해보겠습니다.
탈출을 위한 코루틴 사용(Coroutines to the rescue)
지금 작성하고 있는 블로그 포스팅은 코루틴을 사용하는데 어려움이 없다고 가정합니다. 만약에 아래의 이야기가 이해가 안되거나 알아 들을 수 없는 내용이라면, 우리가 이전에 포스팅 했었던 시리즈가 도움을 줄것입니다.
medium.com/androiddevelopers/coroutines-on-android-part-i-getting-the-background-3e0e54d20bb(해당 글도 번역 예정입니다)
일시중단 기능은 우리가 논-블록킹 형태로 코드를 작성할 수 있게, 코루틴이 제공하는 기본적인 기능중 하나입니다.
안드로이드 UI 기능을 처리할 때, 메인스레드를 블록해서, jank 같은 문제가 일어나지 않게 하는 것은 중요합니다.
SuspendCancellableCoroutine
코틀린의 코루틴 라이브러리 내에서는 일시중단 기능과 함께 콜백 함수를 래핑하여 사용할 수 있도록 많은 코루틴 빌더들을 제공하고 있습니다. 그중 가장 주요한 API 중 하나는 suspendCoroutine()과 함께 취소할 수 있는 버전인 suspendCancellableCoroutine() 가 있습니다.
그리구 이 둘중에서는 모든 방향으로 취소를 처리할 수 있는, suspendCancellableCoroutine 메서드를 사용하는 것이 좋습니다.
#1. 비동기 작업이 펜딩(보류)될 때 코루틴은 취소될 수 있습니다.(The coroutine can be cancelled while the async operation is pending)
코루틴이 현재 작업하고 있는 스코프 단위에 의존적이고, 코루틴은 뷰가 계층구조에서 제거되는 경우 취소될 수 있습니다.
예시 : 프래그먼트가 스택으로 부터 제거 될때가 있습니다. 이때 비동기 작업을 취소할 수 있고, 진행중인 리소스들을 정리할 수 있습니다.
#2. 비동기 UI 작업은 코루틴이 일시중단 상태일 때, 취소 혹은 에러를 던질 수 있습니다.(The async UI operation is cancelled or throw and error while the coroutine is suspended.)
모든 작업이 취소되어지거나, 에러 상태를 가질 수 있는 것은 아니다. 하지만 아래의 일시중단 가능한 애니메이션(View.awaitNextLayout 등)의 경우에는 코루틴의 상태를 전파하여, 메소드를 호출하는 쪽에서 에러를 처리할 수 있도록 한다.
뷰가 배치될 때 까지 기다리기 (Wait for a view to be laid out)
아래와 같은 예제를 봅시다. 뷰가 다음 레이아웃을 Layout Pass (requestLayout() 할 때 layout pass 한다 라고 말합니다.)할 때까지 기다리는 작업을 진행합니다. 예시 : TextView 의 텍스트가 변경 되고, 그려질 때까지 기다렸다가 새로운 사이즈가 필요할 때 등
suspend fun View.awaitNextLayout() = suspendCancellableCoroutine<Unit> { cont ->
// 해당 람다는 즉시 호출되고 만들어집니다.
// 하나의 콜백 리스너
val listener = object : View.OnLayoutChangeListener {
override fun onLayoutChange(...) {
// 다음 레이아웃이 발생합니다
// 리스너를 제거하여 코루틴에서 메모리릭을 방지합니다
view.removeOnLayoutChangeListener(this)
// Continuation 을 재개시킵니다
// 일시 정지 상태의 코루틴을 깨웁니다
cont.resume(Unit)
}
}
// 만약 코루틴이 취소 된다면, 리스너를 제거합니다
cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) }
// 최종적으로 뷰에 LayoutChangeListner 를 추가합니다.
addOnLayoutChangeListener(listener)
// 해당 스코프 안에서는 코루틴이 지금은 일시정지 상태이지만,
// 위의 listener 안에 cont.resume() 가 불리면 다시 재개 됩니다
}
위의 함수에서는 Layout 의 에러 상태를 감지할 수 있지 않기 때문에, 오직 한방향으로만 취소만을 가능하게 합니다. (위에서 이야기한 #1 상황입니다 - Layout 에러 상태라는 것이 이해가 잘 되지 않았다면 아래 글에 더 설명이 나옵니다)
위의 함수는 아래와 같이 사용될 수 있습니다.
viewLifecycleOwner.lifecycleScope.launch {
// Invisible 상태의 TextView 에 텍스트를 설정해준다
titleView.isInvisible = true
titleView.text = "Hi everyone!"
// 다음 레이아웃이 LayoutPass(requestLayout) 되기를 기다린다
titleView.awaitNextLayout()
// 이쪽으로 내려왔다는 것은 레이아웃이 LayoutPass 되었다는 의미이다
// 뷰를 visible 하게 만들 수 있고, 변환할 수도 있고, 애니메이션을 적용할 수도 있는 상태입니다
titleView.isVisible = true
titleView.translationY = -titleView.height.toFloat()
titleView.animate().translationY(0f)
}
위에서 우리는 View 의 Layout Pass 를 기다리는 함수를 만들었습니다. 매우 많이 사용되는 콜백 스타일의 doOnPreDraw() 혹은 다음 애니메이션 프레임이 들어올 때를 알 수 있는 postOnAnimation() 에도 이와 같은 방식을 적용해 볼 수 있습니다.
범위(Scope)
위의 예제에서는 코루틴이 실행하기 위해서 lifecycleScope 를 사용한 것에 주목해야합니다.
UI 를 터치했을 때, 우연히 발생하는 메모리 릭을 회피하기 위해서 코루틴을 실행하는 스코프는 매우 중요합니다. 운이 좋게도 뷰의 스코프에 적합한 다양한 Lifecycle 들이 제공되고 있습니다. 우리는 단순히 lifecycle 에 맞는 CoroutineScope 를 가지고 있는 lifecycleScope 를 사용하면 됩니다.
LifecycleScope 는 androd x 의 lifecycle-runtime-ktx 라이브러리에서 사용가능합니다. 더 자세한 사항은 여기서 보실 수 있습니다
자주 사용하는 lifecycle owner 로는 프래그먼트의 viewLifecycleOwner (프래그먼트의 뷰가 부착되어 있는 한, active 상태가 입니다.)가 있습니다. 프래그먼트가 제거되면 attached 되어 있는 lifecycleScope 는 자동적으로 취소됩니다. 그리고 우리가 만든 suspend 함수는 취소를 제공하기 때문에 모든 작업은 자동적으로 취소되고 정리되어집니다.(propagated 되어 취소됨)
애니메이션이 끝날 때 까지 기다리기(Waiting for an Animator to finish)
또 다른 예제도 있습니다. 이번 예제는 애니메이션이 끝날 때 까지 기다리능 기능을 만들어보겠습니다
suspend fun Animator.awaitEnd() = suspendCancellableCoroutine<Unit> { cont ->
// invokeOnCancellation 리스너 추가 만약 코루틴이 취소되면
// cancel() 함수를 명시적으로 호출하여, onAnimationCancel 함수가 호출될 수 있도록 합니다
cont.invokeOnCancellation { cancel() }
addListener(object : AnimatorListenerAdapter() {
private var endedSuccessfully = true
override fun onAnimationCancel(animation: Animator) {
// 애니메이션이 취소되면 성공 플래그를 반전시켜줍니다.
endedSuccessfully = false
}
override fun onAnimationEnd(animation: Animator) {
// 리스너를 제거하여, 코루틴의 Continuation 에서 메모리 릭이 생기는 것을 방지합니다.
animation.removeListener(this)
if (cont.isActive) {
// 만약 코루틴이 아직 활성 상태라면
if (endedSuccessfully) {
// 애니메이션은 성공적으로 끝나게 되고, 코루틴을 재개시킵니다.
cont.resume(Unit)
} else {
// 애니메이션이 취소되었다면, 코루틴을 취소합니다.
cont.cancel()
}
}
}
})
}
위의 함수는 모든 방향의 취소를 지원합니다. 코루틴의 취소 뿐 아니라, Animator 의 상태(완료/취소)도 함께 보고 있습니다
#1 코루틴은 애니메이션이 실행되다가 취소될 수 있습니다(The coroutine is cancelled while the animator is running)
invokeOnCancellation 을 이용하여, 코루틴이 취소되었을 때, 애니메이션이 취소(cancel()) 가능하도록 합니다.
#2 애니메이션은 코루틴이 일시중단 상태일 때 취소될 수 있습니다(The animator is cancelled while the coroutine is suspended)
onAnimationCancel() 메서드를 사용하여 애니메이션이 취소되었을 때 Continuation 의 cancel() 함수를 호출하여 일시 중단 상태의 코루틴을 취소가능하게 합니다.
우리는 callback API 를 래핑하여, 일시중단 하면서도 기다릴 수 있는 함수를 만들어봤습니다.
애니메이션의 조율 - 오케스트라 (Orchestrating the band)
현재 당신은 "음 좋은 것 같은데, 이걸 어디에 사용하지 ?" 라고 생각할 수도 있습니다. 이런 함수들이 단독으로 사용될 때는 그렇게 생각할 수 있지만, 다른 것들과 결합하여 사용하는 경우에는 강력한 성능을 발휘합니다.
여기 Animator.awaitEnd() 함수를 사용하는 3개의 애니메이션이 있습니다.
viewLifecycleOwner.lifecycleScope.launch {
ObjectAnimator.ofFloat(imageView, View.ALPHA, 0f, 1f).run {
start()
awaitEnd()
}
ObjectAnimator.ofFloat(imageView, View.TRANSLATION_Y, 0f, 100f).run {
start()
awaitEnd()
}
ObjectAnimator.ofFloat(imageView, View.TRANSLATION_X, -100f, 0f).run {
start()
awaitEnd()
}
}
위의 코드는 imageView 에 AnimatorSet 을 사용해서도 동일한 효과를 낼 수 있습니다.
그러나 이런 기술은 다른 타입의 뷰에 비동기 작업을 수행할 때도 활용됩니다. 첫번째에서는 ValueAnimator 를 RecycleView 에서는 Smooth Scroll 을 그리고 마지막 뷰에서는 translation 작업을 하는 코드를 작성해보겠습니다.
viewLifecycleOwner.lifecycleScope.launch {
// #1: ValueAnimator
imageView.animate().run {
alpha(0f)
start()
awaitEnd()
}
// #2: RecyclerView smooth scroll
recyclerView.run {
smoothScrollToPosition(10)
// Position 이 10까지 이동할 때까지 아래 코드를 시작하지 않고, 대기 합니다
awaitScrollEnd()
}
// #3: ObjectAnimator
ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {
start()
awaitEnd()
}
}
코루틴을 사용하지 않고, AnimatorSet 을 사용하여 이것을 구현한다고 가정해보세요. 아마도 각각의 애니메이션이 끝나는 콜백에 다음 애니메이션 실행 메서드를 넣어줘야하고 이것을 반복하면 코드는 꼬여버립니다(콜백 지옥)
일시 중단 기능을 사용하여 각각의 작업을 비동기적으로 모델링 하는 것은, 표현적이고 간결하게 코드를 조율할 수 있는 능력을 얻게 됨을 의미합니다.
우리는 심지어 더 나아갈 수도 있습니다.
ValueAnimation 와 Smooth scroll 이 동시에 시작되고, 두개가 끝났을 때, ObjectAnimator 를 시작하는 코드를 구현하다고 가정해봅시다.
코루틴을 사용하면, async() 함수를 동시에 시작하여 처리할 수 있습니다.
viewLifecycleOwner.lifecycleScope.launch {
val anim1 = async {
imageView.animate().run {
alpha(0f)
start()
awaitEnd()
}
}
val scroll = async {
recyclerView.run {
smoothScrollToPosition(10)
awaitScrollEnd()
}
}
// anim1, scroll 작업이 모두 끝나는 것을 기다린다
anim1.await()
scroll.await()
// anim1 과 scroll 가 끝이 났을 때, ObjectAnimator 를 시작한다
ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {
start()
awaitEnd()
}
}
그러나 Animator.startDelay 와 유사한 특정 시간의 지연 이후에 scroll 이 시작되기를 원한다면 어떻게 구성해야할까?
물론 코루틴의 delay 함수를 사용하여 처리할 수도 있습니다
viewLifecycleOwner.lifecycleScope.launch {
val anim1 = async {
// ...
}
val scroll = async {
// 200 ms 이후에 scroll 이벤트 실행
delay(200)
recyclerView.run {
smoothScrollToPosition(10)
awaitScrollEnd()
}
}
anim1.await()
scroll.await()
// …
}
만약 트랜지션을 여러번 반복해야한다면 ? 우리는 전체 코드를 repeat() (for-loop) 함수로 감쌀수 있습니다. 여기 뷰를 fade in - out 을 3번 반복하는 예제가 있습니다.(만약 기존 코드를 콜백 스타일로 변경한다면 코드 모양새가 직관적으로 나오지 않을 것입니다)
viewLifecycleOwner.lifecycleScope.launch {
repeat(3) {
ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run {
start()
awaitEnd()
}
}
}
또한 반복 횟수를 받아서 처리할 수도 있습니다. 아래는 각각의 반복시에 점점더 느리게 진행되는 fade in - out 작업입니다.
viewLifecycleOwner.lifecycleScope.launch {
repeat(3) { repetition ->
ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run {
// 1st run will last 150ms, 2nd: 300ms, 3rd: 450ms
duration = (repetition + 1) * 150L
start()
awaitEnd()
}
}
}
나의 생각으로는(저자의 생각) 안드로이드 뷰 시스템에서 코루틴을 사용하는 것은 정말로 큰 파워가 있습니다. 우리는 복잡한 비동기 트랜지션, 각각의 다른 애니메이션의 결합 등의 작업을 콜백 스타일의 체이닝 없이 구현할 수 있습니다.
우리의 앱의 데이터 레이어에서 코루틴을 긴밀하게 사용하고 있는 것 처럼, UI 프로그래밍에서도 더 쉽게 접근할 수 있도록 도와줍니다.
await 함수는 콜백 스타일 보다 새롭게 코드를 접하는 사람들에게 좀더 높은 가독성을 제공합니다.
post.resume() (저자기 여기서 일시중단을 재개한다는 의미로 재미있게 사용한 것 같습니다^^)
해당 포스트에서는 다른 API 와 비교했을 때, 코루틴으로 부터 오는 장점들이 어떤 것들이 있는지 생각하셨기를 희망합니다.
다음 포스트에서는 어떻게 코루틴을 사용하여 더욱 복잡한 트랜지션을 처리하고 조율할 수 있는지 알아보고, 몇가지 일반적인 뷰에 대한 구현도 알아보겠습니다 여기서 찾아보실 수 있습니다
medium.com/androiddevelopers/suspending-over-views-example-260ce3dc9100
'Android > 번역' 카테고리의 다른 글
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 3 (0) | 2021.06.07 |
---|---|
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 2 (0) | 2021.06.06 |
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 1 (1) | 2021.05.25 |
코루틴 플로우(Flow) 읽어보기 (1) | 2021.03.08 |
코틀린으로 알아보는 SOLID 원칙 (2) | 2021.03.03 |