Kotlin 協程 讓咱們能夠用同步代碼來創建異步問題的模型。這是很是好的特性,可是目前大部分用例都專一於 I/O 任務或是併發操做。其實協程不只在處理跨線程的問題有優點,還能夠用來處理同一線程中的異步問題。html
我認爲有一個地方能夠真正從中受益,那就是在 Android 視圖系統中使用協程。java
Android 視圖 💘 回調
Android 視圖系統中尤爲熱衷於使用回調: 目前在 Android Framework 中,view 和 widgets 類中的回調有 80+ 個,在 Jetpack 中回調的數目更是超過了 200 個 (這裏也包含了沒有界面的依賴庫)。android
最多見的用法有如下幾項:git
- AnimatorListener 獲取動畫結束相關的事件
- RecyclerView.OnScrollListener 獲取滑動狀態變動事件
- View.OnLayoutChangeListener 獲取 View 佈局改變的事件
而後還有一些經過接受 Runnable 來執行異步操做的API,好比 View.post()、View.postDelayed() 等等。github
正是由於 Android 上的 UI 編程從根本上就是異步的,因此形成了如此之多的回調。從測量、佈局、繪製,到調度插入,整個過程都是異步的。一般狀況下,一個類 (一般是 View) 調用系統方法,一段時間以後系統來調度執行,而後經過回調觸發監聽。編程
KTX 擴展方法
上述說起的 API,在 Jetpack 中都增長了擴展方法來提升開發效率。其中 View.doOnPreDraw()方法是我最喜歡的一個,該方法對等待下一次繪製被執行進行了極大的精簡。其實還有不少我經常使用的方法,好比 View.doOnLayout()、Animator.doOnEnd()。api
可是這些擴展方法也是僅止步於此,他們只是將舊風格的回調 API 改爲了 Kotlin 中比較友好的基於 lambda 風格的 API。雖然用起來很優雅,但咱們只是在用另外一種方式處理回調,這仍是沒有解決複雜的 UI 的回調嵌套問題。既然咱們在討論異步操做,那在這種狀況下,咱們可使用協程優化這些問題麼?併發
使用協程解決問題
這裏假定您已經對協程有必定的理解,若是接下來的內容對您來講會有些陌生,能夠經過咱們今年早期的系列文章進行回顧: 在 Android 開發中使用協程 | 背景介紹。app
掛起函數 (Suspending functions) 是協程的基礎組成部分,它容許咱們以非阻塞的方式編寫代碼。這種特性很是適用於咱們處理 Android UI,由於咱們不想阻塞主線程,阻塞主線程會帶來性能上的問題,好比: jank。異步
suspendCancellableCoroutine
在 Kotlin 協程庫中,有不少協程的構造器方法,這些構造器方法內部可使用掛起函數來封裝回調的 API。最主要的 API 是 suspendCoroutine() 和 suspendCancellableCoroutine(),後者是能夠被取消的。
咱們推薦始終使用 suspendCancellableCoroutine(),由於這個方法能夠從兩個維度處理協程的取消操做:
#1: 能夠在異步操做完成以前取消協程。若是某個 view 從它所在的層級中被移除,那麼根據協程所處的做用域 (scope),它有可能會被取消。舉個例子: Fragment 返回出棧,經過處理取消事件,咱們能夠取消異步操做,並清除相關引用的資源。
#2: 在協程被掛起的時候,異步 UI 操做被取消或者拋出異常。並非全部的操做都有已取消或出錯的狀態,可是這些操做有。就像後面 Animator 的示例中那樣,咱們必須把這些狀態傳遞到協程中,讓調用者能夠處理錯誤的狀態。
等待 View 被佈局完成
讓咱們看一個例子,它封裝了一個等待 View 傳遞下一次佈局事件的任務 (好比說,咱們改變了一個 TextView 中的內容,須要等待佈局事件完成後才能獲取該控件的新尺寸):
suspend fun View.awaitNextLayout() = suspendCancellableCoroutine<Unit> { cont -> // 這裏的 lambda 表達式會被當即調用,容許咱們建立一個監聽器 val listener = object : View.OnLayoutChangeListener { override fun onLayoutChange(...) { // 視圖的下一次佈局任務被調用 // 先移除監聽,防止協程泄漏 view.removeOnLayoutChangeListener(this) // 最終,喚醒協程,恢復執行 cont.resume(Unit) } } // 若是協程被取消,移除該監聽 cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) } // 最終,將監聽添加到 view 上 addOnLayoutChangeListener(listener) // 這樣協程就被掛起了,除非監聽器中的 cont.resume() 方法被調用 }
此方法僅支持協程中一個維度的取消 (#1 操做),由於佈局操做沒有錯誤狀態供咱們監聽。
接下來咱們就能夠這樣使用了:
viewLifecycleOwner.lifecycleScope.launch { // 將該視圖設置爲不可見,再設置一些文字 titleView.isInvisible = true titleView.text = "Hi everyone!" // 等待下一次佈局事件的任務,而後才能夠獲取該視圖的高度 titleView.awaitNextLayout() // 佈局任務被執行 // 如今,咱們能夠將視圖設置爲可見,並其向上平移,而後執行向下的動畫 titleView.isVisible = true titleView.translationY = -titleView.height.toFloat() titleView.animate().translationY(0f) }
咱們爲 View 的佈局建立了一個 await 函數。用一樣的方法能夠替代不少常見的回調,好比 doOnPreDraw(),它是在 View 獲得繪製時調用的方法;再好比 postOnAnimation(),在動畫的下一幀開始時調用的方法,等等。
做用域
不知道您有沒有發現這樣一個問題,在上面的例子中,咱們使用了 lifecycleScope 來啓動協程,爲何要這樣作呢?
爲了不發生內存泄漏,在咱們操做 UI 的時候,選擇合適的做用域來運行協程是極其重要的。幸運的是,咱們的 View 有一些範圍合適的 Lifecycle。咱們可使用擴展屬性 lifecycleScope 來得到一個綁定生命週期的 CoroutineScope。
LifecycleScope 被包含在 AndroidX 的 lifecycle-runtime-ktx 依賴庫中,能夠在 這裏****找到更多信息
咱們最經常使用的生命週期的持有者 (lifecycle owner) 就是 Fragment 中的 viewLifecycleOwner,只要加載了 Fragment 的視圖,它就會處於活躍狀態。一旦 Fragment 的視圖被移除,與之關聯的 lifecycleScope 就會自動被取消。又因爲咱們已經爲掛起函數中添加了對取消操做的支持,因此 lifecycleScope 被取消時,全部與之關聯的協程都會被清除。
等待 Animator 執行完成
咱們再來看一個例子來加深理解,此次是等待 Animator 執行結束:
suspend fun Animator.awaitEnd() = suspendCancellableCoroutine<Unit> { cont -> // 增長一個處理協程取消的監聽器,若是協程被取消, // 同時執行動畫監聽器的 onAnimationCancel() 方法,取消動畫 cont.invokeOnCancellation { cancel() } addListener(object : AnimatorListenerAdapter() { private var endedSuccessfully = true override fun onAnimationCancel(animation: Animator) { // 動畫已經被取消,修改是否成功結束的標誌 endedSuccessfully = false } override fun onAnimationEnd(animation: Animator) { // 爲了在協程恢復後的不發生泄漏,須要確保移除監聽 animation.removeListener(this) if (cont.isActive) { // 若是協程仍處於活躍狀態 if (endedSuccessfully) { // 而且動畫正常結束,恢復協程 cont.resume(Unit) } else { // 不然動畫被取消,同時取消協程 cont.cancel() } } } }) }
這個方法支持兩個維度的取消,咱們能夠分別取消動畫或者協程:
#1: 在 Animator 運行的時候,協程被取消 。咱們能夠經過 invokeOnCancellation 回調方法來監聽協程什麼時候被取消,這能讓咱們同時取消動畫。
#2: 在協程被掛起的時候,Animator 被取消 。咱們經過 onAnimationCancel() 回調來監聽動畫被取消的事件,經過調用協程的 cancel() 方法來取消掛起的協程。
這就是使用掛起函數等待方法執行來封裝回調的基本使用了。🏅
組合使用
到這裏,您可能有這樣的疑問,"看起來不錯,可是我能從中收穫什麼呢?" 單獨使用其中某個方法,並不會產生多大的做用,可是若是把它們組合起來,便能發揮巨大的威力。
下面是一個使用 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() } }
這是一個很常見的使用案例,您能夠把這些動畫放進 AnimatorSet 中來實現一樣的效果。
可是這裏使用的方法適用於不一樣類型的異步操做: 咱們使用一個 ValueAnimator,一個 RecyclerView 的平滑滾動,以及一個 Animator 來舉例:
viewLifecycleOwner.lifecycleScope.launch { // #1: ValueAnimator imageView.animate().run { alpha(0f) start() awaitEnd() } // #2: RecyclerView smooth scroll recyclerView.run { smoothScrollToPosition(10) // 該方法和其餘方法相似,等待當前的滑動完成,咱們不須要刻意關注實現 // 代碼能夠在文末的引用中找到 awaitScrollEnd() } // #3: ObjectAnimator ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run { start() awaitEnd() } }
試着用 AnimatorSet 實現一下吧🤯!若是不用協程,那就意味着咱們要監聽每個操做,在回調中執行下一個操做,這回調層級想一想均可怕。
經過把不一樣的異步操做轉換爲協程的掛起函數,咱們得到了簡潔明瞭地編排它們的能力。
咱們還能夠更進一步...
**若是咱們但願 ValueAnimator 和平滑滾動同時開始,而後在二者都完成以後啓動 ObjectAnimator,該怎麼作呢?**那麼在使用了協程以後,咱們可使用 async() 來併發地執行咱們的代碼:
viewLifecycleOwner.lifecycleScope.launch { val anim1 = async { imageView.animate().run { alpha(0f) start() awaitEnd() } } val scroll = async { recyclerView.run { smoothScrollToPosition(10) awaitScrollEnd() } } // 等待以上兩個操做所有完成 anim1.await() scroll.await() // 此時,anim1 和滑動都完成了,咱們開始執行 ObjectAnimator ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run { start() awaitEnd() } }
可是若是您還想讓滾動延遲執行怎麼辦呢? (相似 Animator.startDelay 方法) 那麼使用協程也有很好的實現,咱們能夠用 delay() 方法:
viewLifecycleOwner.lifecycleScope.launch { val anim1 = async { // ... } val scroll = async { // 咱們但願在 anim1 完成後,延遲 200ms 執行滾動 delay(200) recyclerView.run { smoothScrollToPosition(10) awaitScrollEnd() } } // … }
若是咱們想重複動畫,那麼咱們可使用 repeat() 方法,或者使用 for 循環實現。下面是一個 view 淡入淡出 3 次的例子:
viewLifecycleOwner.lifecycleScope.launch { repeat(3) { ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run { start() awaitEnd() } } }
您甚至能夠經過重複計數來實現更精妙的功能。假設您但願淡入淡出在每次重複中逐漸變慢:
viewLifecycleOwner.lifecycleScope.launch { repeat(3) { repetition -> ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run { // 第一次執行持續 150ms,第二次:300ms,第三次:450ms duration = (repetition + 1) * 150L start() awaitEnd() } } }
在我看來,這就是在 Android 視圖系統中使用協程能真正發揮做用的地方。咱們就算不去組合不一樣類型的回調,也能建立複雜的異步變換,或是將不一樣類型的動畫組合起來。
經過使用與咱們應用中數據層相同的協程開發原語,還能使 UI 編程更便捷。對於剛接觸代碼的人來講, await 方法要比看似會斷開的回調更具可讀性。
最後
但願經過本文,您能夠進一步思考協程還能夠在哪些其餘的 API 中發揮做用。
接下來的文章中,咱們將探討如何使用協程來組織一個複雜的變換動畫,其中也包括了一些常見 View 的實現,感興趣的讀者請繼續關注咱們的更新。