본문 바로가기

개발 공부 정리

안드로이드 프레임워크 뷰 콜백함수에 코루틴 적용하기

이번에 회사에서 코루틴 공부를 할 일이 생겼는데 괜찮은 문서를 발견해서 읽고 한글로 번역해봤습니다.

해당 문서를 이해하려면 먼저 코루틴에 대한 기초적인 이해가 필요합니다.

 

문서 링크 : https://medium.com/androiddevelopers/suspending-over-views-19de9ebd7020

 



코틀린 코루틴은 비동기 문제들을 동기적 코드로 설계할 수 있게 도와준다. 그런데 대부분의 코루틴 사용법들을 보면 I / O 작업이나 동시성 문제들에만 집중한다. 코루틴은 쓰레드들 간의 모델링 문제에도 훌륭하지만, 또 같은 쓰레드 안에서 비동기 문제들을 모델링할 수 있다.
이러한 이점을 활용하기 좋은 곳이 있는데 그 곳이 바로 안드로이드 뷰 시스템이다.

 

Android views 💘 callbacks

 

안드로이드 뷰 시스템은 콜백 스타일을 좋아한다. 얼마나 좋아하는지 알려주자면 프레임워크의 뷰와 위젯 클래스들에는 80개 이상의 콜백이 있고 Android Jetpack 에는 200개 이상이나 있다. (물론 non-UI 라이브러리 포함이지만... 여튼 그만큼 많다.)

흔히 콜백 스타일이 쓰이는 곳은 다음과 같다.

 

  • AnimatorListener: Animator 가 끝나는 시점을 알기 위해
  • RecyclerView.OnScrollListener : 언제 스크롤 상태가 변하는지 알기 위해
  • View.OnLayoutChangeListener : 언제 뷰가 배치되는지 알기 위해

콜백 API 예시 - AnimatorListener

 

그리고 이런 API 들 중 몇몇은 비동기 action 을 수행하기 위해 Runnable 객체를 받는다. 예를 들면 View.post(), View.postDelayed() 등등이 있다.

 

이렇게 많은 콜백들이 있는 이유는 안드로이드에서 사용자 인터페이스 프로그래밍은 태생적으로 asynchrounous 하기 때문이다. 뷰의 사이즈를 측정하거나 레이아웃을 그리는 등의 모든 행위는 asynchrounous 하게 수행된다. 일반적으로 보통 뷰와 같은 것들은 시스템에 순회를 요청하고 그 다음 시스템이 리스너들을 트리거하는 콜을 전파한다.

 

KTX extension functions

 

이렇게 많은 API 들을 위해 구글팀은 개발자들의 공학도 짓을 향상시켜주기 위해 JetPack 에 확장함수를 추가했다. 예를 들면 View.doOnPreDraw(), View.doOnLayout(), Animator.doOnEnd() 등이 있다.

 

그런데 이러한 확장함수들은 사실 예전 콜백스타일 방식을 코틀린 친화적인 람다 스타일 API 로 바꾼 것일 뿐이다. 물론 이 것 자체로 API 들이 더 나아졌지만 우리는 여전히 콜백 방식을 다른 형태로 다루고 있을 뿐이다.

 

이번에 asynchronous operations 에 대해 얘기하고 있으니 어떻게 하면 코루틴으로 부터 이점을 얻을 수 있을까?

 

suspendCancellableCoroutine

 

그전에 먼저 알고 있어야 하는게 susnpendCancellableCoroutine 이다. 코틀린 코루틴 라이브러리에는 콜백 기반 API 를 suspending 함수로 감싸주게 도와주는 몇개의 코루틴 빌더 함수들이 있는데 가장 기초적인 API 는 suspendCoroutine() 이고 cancellable 할 수 있는 버전은 suspendCancellableCoroutine() 함수다.

 

양 쪽 방향으로 취소되는 것에 대한 처리를 도와주기 때문에 왠만하면 항상 suspendCancellableCoroutine() 함수를 사용하기를 추천한다.

양 쪽 방향이란 다음을 말한다.

 

#1. 코루틴은 비동기 동작이 pending 되는 동안 취소될 수 있다.

코루틴이 돌고있는 스코프에 따라서, 만약 뷰가 뷰 계층에서 제거되어진다면 코루틴도 취소될 것이다. 예를 들면 프래그먼트가 스택에서 pop 될 때가 있다. 이런 방식으로 취소되는 것을 다루는 것은 아무 비동기 작업을 취소할 수 있게 도와주고 진행 중이었던 리소스들을 정리할 수 있게 도와준다.

 

#2. 코루틴이 suspend 되는 동안 비동기 UI 작업이 취소되거나 에러를 던지는 경우

모든 작업들이 취소나 에러를 던지진 않지만 몇몇은 그러는 애들도 있다. 밑에 곧 나올 Animator 처럼 말이다. 우리는 이러한 작업들의 상태를 코루틴에 전파해야한다. 함수 호출하는 사람한테 에러를 처리할 수 있게 하면서 말이다.

 

Wait for a view to be laid out

 

이제 다음 레이아웃을 보여주기 위해 기다리고 있는 작업을 보여주는 예를 한번 보자. (텍스트뷰의 텍스트를 바꾼 뒤 레이아웃이 자신의 새로운 사이즈를 알 때 까지 기다리고 있는 상황이다.)

 

 

이 함수는 오직 한가지 방향(코루틴 → 작업)으로의 취소만 지원한다. 왜냐하면 레이아웃이 우리가 볼 수 있는 에러상태를 가지고 있지 않기 때문이다. 그래서 아래처럼 작업이 가능하다.

 

 

이렇게 뷰 레이아웃을 위한 await 함수를 만들었다! 흔히들 사용되는 다른 콜백들에도 같은 방식으로 적용할 수 있다. 예를 들면 doOnPreDraw() 함수가 언제 draw pass 가 막 일어날 지를 아는 것과 postOnAnimation() 함수가 다음 애니메이션 프레임이 언제인지 아는 것 등등 말이다.

 

Scope

위의 예시에서 보면 알 수 있겠지만 우리는 지금 Lifecyclescope 를 이용해 코루틴을 launch 했다. 그런데 스코프가 뭘까?

우리가 코루틴을 돌릴 때 쓰는 스코프는 특히 우리가 UI 관련 작업을 하고 있을 때 메모리 릭을 피하기 위해 매우 중요하다. 운좋게도 우리의 뷰를 위해 적절한 라이프사이클들이 존재한다. 우리는 이 라이프사이클 확장 프로퍼티를 통해 해당 라이프사이클에 맞춰진 코루틴 스코프를얻을 수 있다.

 

LifecycleScope 는 AndroidX lifecycle-runtime-ktx library 에서 확인할 수 있다.

흔히들 사용되는 lifecycle owner 는 프래그먼트 뷰가 붙어있는 동안 살아있는 프래그먼트의 viewLifecycleOwner 이다. 한번 프래그먼트의 뷰가 제거되면, 해당 lifecyclescope 또한 자동적으로 취소된다. 그리고 우리는 취소 기능을 우리 suspending 함수에 추가했기 때문에 이런 일들이 생길 때 모든건 자동적으로 처리될 것이다.

 

Waiting for an Animator to finish

 

또 다른 예시를 한번 보자. 이번엔 애니메이션이 끝나길 기다리는 Animator 이다.

 

 

이 함수는 양방향으로 (Animator → coroutine , coroutine → Animator) 개별적으로 취소가 지원된다.

 

#1. Animator 가 돌아가는 동안 코루틴이 취소된 경우

우리는 invokeOnCanellation 콜백을 통해 코루틴이 취소 됐을 때를 알 수 있고 이 시점을 통해 Animator 를 취소할 수 있다.

 

#2. 코루틴이 suspend 되는 동안 Animator 가 취소된 경우

우리는 onAnimationCancel() 콜백을 통해 언제 Animator 가 취소됐는지를 알 수 있고 이 시점을 통해 코루틴을 취소하는 continuation 의 cancel() 함수를 호출할 수 있다. 우리는 이제 콜백 API를 suspending await function 으로 래핑하는 방법에 대한 기초를 배웠다.

 

Orchestrating the band

이제 이렇게 배운 것을 어떻게 쓸 수 있을까? 함수 한개만 보면 많은걸 하지 못할 것 같지만 사실 잘 활용하면 굉장히 강력한 기능이 된다는 것을 알 수 있다. 예를 들면 다음과 같이 Animator.awaitEnd() 함수를 이용해 애니메이션 3개를 순차적으로 돌리는 작업이 있다.

 

 

이 예시 같은 경우는 애니메이션 모두 다 AnimatorSet 에 넣어서 처리할 수도 있을 것이다.

그러나 이러한 테크닉은 다른 타입의 비동기 작업에도 적용된다. 예를 들면 ValueAnimator, RecyclerView 의 smooth control, 그리고 Animator 순서대로 동작해야 하는 경우다.

 

 

위의 작업들을 코루틴 없이 AnimatorSet 을 이용해서 개발해보면 알겠지만... 각 동작마다 많은 리스너들이 필요할 거고 각 리스너에서 다음 동작을 호출해야되고... 여러모로 복잡하고 쉽지 않다는 것을 알 수 있을 것이다.

이렇게 다른 비동기 작업들을 suspend 함수로 모델링 함으로써 우리는 이 작업들을 보다 가독성있고 간결하게 보이게끔 만들 수 있다.

 

이제 위 내용을 조금 더 심화시켜보자.

만약 ValueAnimator 랑 리싸이클러뷰 smoothScroll을 동시에 시작해 끝난 다음 ObjectAnimator 를 시작하게 하려면 어떻게 해야할까?

우리는 지금 코루틴을 이용하고 있으므로 async 를 이용해 둘이 같이 돌게 한 다음 await 으로 작업을 기다리면 된다.

 

 

만약 한쪽에 딜레이를 주고싶으면?

 

 

만약 한 transition 동작을 세번 반복하고 싶으면?

 

 

심지어 반복할 때마다 수치를 계산하는 작업을 더해 더 다양하게 애니메이션 효과를 줄 수도 있다.

 

 

 

[ 결론 ]

 

안드로이드 뷰 시스템에 코루틴을 이용하면 엄청난 효과를 낼 수 있다. 비동기 transition 을 다른 애니메이션들과 혼합하는 등의 복잡한 작업을 콜백에 체이닝을 걸고 또 다음 콜백에 체이닝을 거는... 콜백 지옥식의 개발을 하지 않고 간결하게 만들 수 있는 것이다.

이렇게 데이터 영역에서 쓰던 코루틴 기초원리들을 사용함으로써 우리는 UI 프로그래밍도 더 접근성 있게 만들 수 있다. await 함수는 코드를 처음보는 사람들에게 있어 겉으로 연결이 안되어있는 몇개의 콜백들을 보는 것보다 훨씬 더 가독성 있게 느껴질 것이다.