Flutter滑動體驗對齊原生-滑動曲線篇

語言: CN / TW / HK

自從使用了Flutter以來,閒魚在享受著跨端帶來的提效的同時,流暢度一直是困擾了我們許久的問題,也是被外界吐槽得比較多的地方。所以我們在過去半年,重新牽起了流暢度優化這件事情,目標只有一個,那就是 拉平Flutter和Native的滑動體驗

我們把這個目標拆分為了兩個:

  • 滑動曲線優化,拉齊手感

  • 渲染效能優化,減少掉幀

本文主要跟大家介紹一下我們在優化滑動曲線手感方面的一些經驗。和優化渲染單純的想辦法提升指標不同,滑動曲線感覺是否舒服因人而異,那麼我們該如何去評判一個滑動曲線是否優秀呢?說實話我們一開始也沒有很好的答案。既然這樣,那我們的目標首先是需要把Flutter和Native的滑動曲線進行對齊,先找回習慣的感覺再考慮下一步的優化。

滑動對比

我們先使用platform_tests對比一下Flutter的滑動和Native的差異

(影片的左側列表是Native,右側列表是Flutter)

Android

iOS

從影片的表現中,我們可以看出來iOS的脫手滑動略有差異但還是基本符合預期的,Android的脫手滑動就比較糟糕了看起來明顯阻尼比Native大很多。

Android滑動曲線阻尼問題

找滑動曲線程式碼的過程就不在這邊過多贅述了,如果在建立滑動容器(ListView/GridView/...)時沒有傳入特定的physics,則Flutter會根據平臺使用相應的ScrollPhysics(Android對應ClampingScrollPhysics,iOS對應BouncingScrollPhysics)。在發生Fling的時候會呼叫 ScrollPhysics.createBallisticSimulation() 構建一個Simulation,這個Simulation(ClampingScrollSimulation/BouncingScrollSimulation)就是最終控制脫手滑動動畫引數的類。

問題分析

在ClampingScrollSimulation建構函式中,會根據傳入的初速度,並結合一些小學二年級就學過的運動學公式計算出最終的距離以及滑動的總時間(因為沒找到確定性的資料,只能根據引數推測,這邊先不展開討論)

計算出總時長和總距離後,就可以根據當前時長的比例(動畫執行的時間/總時長),帶入公式計算出當前應該位移的距離,最後再加上初始位置即可得到最終Viewport的偏移量。

這個公式是對Android的滑動曲線進行擬合產生的,擬合過程可以看註釋,但是這個擬合併不完美。我們對這個d/t函式進行求導(即速度和時間的關係)可以發現,它的導數在0-1的區間內並不是單調遞減的,這意味著在滑動接近結束的時候會有一個速度增加的情況,體感上在Clamping曲線滑動動畫的末尾感覺上會有一個輕微的吸附感也說明了這一點。所以我們需要把Clamping的實現改一下,把Android的Scroller中計算滑動距離的程式碼弄過來。

但是我們在上面的例子中,並沒有發現這個吸附的感覺,這是不是說明了這條曲線根本就沒能完整地跑完呢?我們可以進一步加一些Log看一下。我們在 double x(double time) 方法中把時間和position打出來,發現了一個問題,time一直在清零,position也一直在變。這說明了這個動畫一直在被重新啟動,我們簡單改一下程式碼,強行讓動畫不要重啟,使用同樣的ADB指令進行滑動,通過Log對比可以發現這確實會導致滑動速度更快地衰減。

那麼我們就把問題拆分成了兩個:

  • 解決發生Layout時的動畫重啟問題

  • Clamping的實現方式對齊Android的Scroller

問題解決

  • 動畫重啟問題

我們在 ClampingScrollSimulation 的建構函式中斷點,可以發現當Fling過程中如果Item高度發生改變,則會觸發 RenderViewport.performLayout() ,從而觸發 ScrollPositionWithSingleContext.goBallistic() ,這個方法會以當前的狀態為起點重新啟動fling動畫,這顯然是不合理的,特別是當前滑動和邊界無關的時候沒必要因為佈局改變而重啟動畫,這會導致一次Fling動畫無法完整做完,移 動的軌跡自然也就無法貼合預期中設計好的d/t曲線。

這個問題比較明確,所以解決問題的思路也很明確,就是在Fling的過程中不要重啟動畫了,而是去更新一些相關的變數,使得動畫能夠合理的繼續完成。一開始我是希望更新Simulation中和邊界有關的的相關引數,但是因為這個方案有個始終繞不過去的型別檢查(https://github.com/flutter/flutter/pull/96512)Flutter團隊的同學認為不能通過。所以誕生了下面的新方案(https://github.com/flutter/flutter/pull/100133)

首先解決更新時機的問題,當 RenderViewport.performLayout() 被呼叫的時候,會回撥 ScrollActivity.applyNewDimensions 在慣性滑動的過程中的ScrollActivity是BallisticScrollActivity

之前提到的 ScrollPositionWithSingleContext.goBallistic() 也就是在這個方法中被呼叫,所以我們在這邊做一下修改,讓其不再呼叫 goBallistic() ,而是呼叫 updateBallisticAnimation() 生成一個新的Simulation,並將其更新到AnimationController中。

updateBallisticAnimation() 中,我們還是使用 createBallisticSimulation() 來建立Simulation。這裡重要的一點是,因為我們動畫時間沒有清零,所以我們建立Simulation的時候一定要用初始的ScrollMetrics和Velocity來建立。因為佈局變化有可能會帶來相應的邊界變化,所以這裡只將相應的ScrollExtent(也就是邊界值)更新為最新的值即可。

Android滑動曲線對齊

做完上述的操作後,我們看到了熟悉的吸附的效果,但這並不是我們想要的。為了徹底對齊與Native的體驗,擬合曲線肯定是滿足不了我們的,下一步我們需要將Clamping的公式徹底換成Android的Scroller.java中的實現。這時候有的小朋友可能要問了:“都要換掉它了,那我們剛才費這勁去修復它幹嘛呢?”

翻譯Scroller.java的程式碼到dart這個工作並不難,並且這部分工作其實有一位Google的同學已經做了(https://github.com/flutter/flutter/pull/77497),但是這個PR在合入後不久就被Revert了,被Revert的原因是在某些場景下會導致滑動停不下來。是的,就是因為上面提到的動畫重啟的問題,導致了這個滑動無法停止。所以我會在動畫重啟的PR被合入後,Reland這個Android曲線的PR。現在我們先來看看最後的效果,不能說是十分相似只能說是一摸一樣了。

滑動速度引發的問題

我們把曲線修改完成後開開心心地在搜尋場景上線了,但是沒過多久就傳來噩耗,產品和互動同學一致覺得我們的滑動比以前快了很多,會影響到使用者體驗。

但是讓我把曲線調回到那滑不動的樣子,我是拒絕的。難道就沒有一個方法能讓使用者在用的時候想仔細看的時候慢想滑走的時候快嗎?算了,我們先想想看怎麼讓他慢下來吧。

如何合理的給曲線減速

Android和iOS兩條曲線,這麼多的引數,到底該把哪個調低在能在不影響滑動曲線的整體形態的同時降低體感速度呢?在很久以前Flutter其實是給iOS做過一個減速的,在 BouncingScrollPhysics 建立 BouncingScrollSimulation 時,給初速度乘了0.91。我們最初使用的曲線也是這個減速版本的,所以切換到正常的曲線後才會顯得比較快。這個0.91在一次PR中被刪除了(https://github.com/flutter/flutter/pull/59623)原因是導致了iOS曲線阻尼過大,但是實際上背後的原因,其實是上面提到的動畫重啟問題,因為動畫重啟導致速度多次乘了0.91,才使得滑動速度加速衰減。那麼現在問題解決了,我們是不是也可以嘗試通過這種方式對曲線進行減速。iOS的滑動公式比較簡單,我們就通過iOS來分析,Android也是同樣的道理。

先把初速度衰減後的曲線畫出來看看。可以發現其實整體曲線的形態是沒有發生變化的,只是滑動的距離減少了,這樣就顯得比較慢了。所以給初速度增加衰減值這個方案,是比較符合我們的預期的。

如何讓滑動能快能慢

減速的問題解決了,接下來該思考一下想快的時候該怎麼快起來。那麼使用者怎麼樣的一個行為才表示他現在想快點滑呢?我認為滑動速度其實一定程度上反映了使用者的意願,當用戶滑動速度快的時候,天然就表示了他希望能快點滑走。順著這個思路,如果我們給初速度的衰減值再增加一個衰減值(套起來了)讓他在速度慢的時候衰減值大,速度快的時候衰減值小,就可以解決這個問題了。

這時候,靈光一現,突然想到了一個小學二年級就學過的拋物線 y=ax^2+bx+c ,中間低兩邊高恰好滿足我的訴求,當用戶滑動得很慢或很快的時候不增加衰減,當用戶在中速滑動的時候給出最大衰減。為了方便在線上做實驗以及資料分析,我們儘量把引數縮減到一個,我選擇了對稱的圖形(強迫症),所以必過兩個點(0,1)和(1,1),並且頂點的x座標也確定為了0.5,剩下一個頂點y座標,初中學的頂點式就不多說了吧,直接上程式碼。

最終效果

最終滑動的效果符合我們的預期,上線進行了分桶實驗,我們得出了一個最佳實踐的衰減頂點值在0.7左右,效果大概如影片所示。調整曲線的按鈕是臨時增加的,切換不同曲線並且使用同一個adb命令進行滑動,可以看出來使用新的曲線在快速滑動的情況下會比之前的曲線多滑動一到兩屏。

總結和展望

本文給大家詳細介紹了閒魚優化滑動曲線手感的方案,雖然過程中遇到了不少問題,我們還算圓滿地完成了任務,將Flutter的滑動手感對齊到了原生,並且根據業務場景進行了曲線速度優化。如果大家需要對曲線進行優化可以提前合入以下兩個PR,上文提到的減速方案也可以根據業務情況酌情使用。

  • 修復動畫重啟問題:https://github.com/flutter/flutter/pull/100133

  • Android曲線復刻:https://github.com/flutter/flutter/pull/77497

後續我們會在此基礎上繼續進行優化,包括但不限於以下幾點:

  1. 推進本文提到的幾個PR的合入

  2. 結合業務資料並配合互動設計師,對滑動手感進行更精細的控制

  3. 在滑動的過程中根據速度的不同,結合圖片庫進行圖片載入控制,以提升滑動流暢度

當然了最後一點更偏向於效能優化,下一篇文章也會給大家介紹一下這段時間所做的流暢度相關的效能優化,敬請期待(一定不咕)。