投稿文章,作者:一縷殇流化隱半邊冰霜(@halfrost)
前言
關於 iOS 10 UICollectionView的新特性,主要還是體現在如下3個方面
順滑的滑動體驗
現在基本上人人都離不開手機,手機的app也每天都有人在用。一個app的好壞由它的用戶體驗決定。在可以滑動的視圖裡面,必須要更加絲滑柔順才能獲得用戶的青睐。這些UICollectionView的新特性可以讓你們的app比原來更加順滑,而且這些特性只需要你加入少量的代碼即可達到目的。
針對self-sizing的改進
self-sizing的API在iOS8的時候被引進,iOS10中加入更多特性使cell更加容易去適配。
Interactive reordering重排
這個功能在iOS9的時候介紹過了,蘋果在iOS 10的API裡面大大增強了這一功能。
目錄
1.UICollectionViewCell順滑的滑動體驗
2.UICollectionViewCell的Pre-Fetching預加載
3.UITableViewCell的Pre-Fetching預加載
4.針對self-sizing的改進
5.Interactive Reordering
6.UIRefreshControl
一. UICollectionViewCell順滑的滑動體驗
眾所周知,iOS設備已良好的用戶體驗贏得了廣大的用戶群。iOS系統在用戶點擊屏幕會立即做出響應。而且很大一部分的操作是來自於用戶的滑動操作。所以滑動的順滑是使用戶沉浸在app中享受的必要條件。接下來我們就談談iOS 10 中增加了那些新特性。
我們先來看一下之前 UICollectionView 的體驗,假設我們每個cell都是簡單的藍色,實際開發app中,cell會比這復雜很多。 我們先生成100個cell。當用戶滑動不是很快的時候,還感覺不出來卡頓,當用戶大幅度滑動,整個UICollectionView的卡頓就很明顯了。如果整個cell的DataSource又是從網絡加載的,那就更加卡頓了。效果如下圖。
如果這種app上架,用戶使用過後,很可能就直接給1星評價了。但是為什麼會造成這種問題呢?我們來分析一下,我們模擬一下系統如何處理重用機制的,效果如下圖。
在上圖中,我們可以看出,當cell准備加載進屏幕的時候,整個cell都已經加載完成,等待在屏幕外面了。而且更重要的是,在屏幕外面等待加載的cell是整整一行!這一行的cell都已經加載完數據。這是UICollectionView在用戶大幅度滑動時卡頓的根本原因。用專業的術語來說,掉幀。
接下來我們就來詳細的說說掉幀的問題。
當今的用戶是很挑剔的,用戶需要一個很順滑的體驗,只要有一點卡頓,很可能一言不合就卸載app了。要想用戶感覺不到卡頓,那麼我們的app必須幀率達到60幀/秒。用數學換算一下就是每幀16毫秒就必須刷新一次。
我們用圖標來分析一下掉幀的問題。下面會出現2種不同的幀。
第一種情況,下圖是當用戶輕微的上下小幅度滑動。這個時候每個cell的加載壓力都不大,iOS針對這種情況,已經做了很好的優化了,所以用戶感覺不到任何卡頓。這種情況是不會掉幀,用戶也希望能使用如此順滑的app。
第二種情況,當用戶大幅度滑動,每個cell加載的壓力很大,也許需要網絡請求,也許需要讀取數據庫,而且每次都加載一行cell出來,這樣每個cell的加載時間都增加了,加載一行的總時間也就大大增加了,如下圖所示。這樣,不僅僅當前幀在加載cell,總的時間還會擠壓到下一幀的時間裡面去。這種情況下,用戶就感覺到了卡頓了。
我們換種方式在說明一下2種情況下掉幀的情況。我們用下圖的標准來衡量一下上面2種情況。下圖分為2部分,上面紅色的區域,就是表示掉幀的區域,因為高於16ms。紅色和綠色區域的分界線就在16ms處。y軸我們表示的是CPU在主線程中花費的時間。x軸表示的是在用戶滑動中發生的刷新事件。
針對上述掉幀的情況,繪制出實驗數據,如下圖。值得我們關注的是,曲線是很曲折的,非常的不平滑。當用戶大幅度滑動的時候,峰值超過了16ms,當用戶慢速滑動的時候,幀率又能保持在比較順滑的區域。處於綠色區域內的cell加載壓力都是很小的。這就是時而掉幀時而順滑的場景。這種場景下,用戶體驗是很糟糕的。
那怎麼解決這麼問題的呢?我們來看下圖:
上圖中的曲線我們看著就很平緩了,而且這種情況也不會出現掉幀的情況了,每個滑動中的時間都能達到60幀了。這是怎樣做到的呢?因為把每個cell的加載事件都平分了,每個cell不會再出現很忙和很閒的兩個極端。這樣我們就取消了之前的波峰和波谷。從而讓該曲線達到近乎水平的直線。
如何讓每個cell都分攤加載任務的壓力?這就要談到新的cell的生命周期了。
先來看看老的 UICollectionViewCell的聲明周期。當用戶滑動屏幕,屏幕外有一個cell准備加載顯示進來。
這個時候我們把這個cell從reuse隊列裡面拿出來,然後調用prepareForReuse方法。這個方法就給了cell時間,用來重置cell,重置狀態,刷新cell,加載新的數據。
再滑動,我們就會調用cellForItemAtIndexPath方法了。這個方法裡面就是我們開發者自定義的填充cell的方式了。這裡會填充data model,然後賦值給cell,再把cell返回給iOS系統。
當cell馬上要進入屏幕的時候,就會調用willDisplayCell的方法。這個方法給了我們app最後一次機會,為cell進入屏幕做最後的准備工作。執行完willDisplayCell之後,cell就進入屏幕了。
當cell完全離開屏幕之後,就會調用didEndDisplayingCell方法。以上就是在iOS10之前的整個UICollectionViewCell的生命周期。
接下來我們就來看看iOS 10的UICollectionViewCell生命周期是怎麼樣的。
這裡還是和iOS9一樣的,當用戶滑動UICollectionView的時候,需要一個cell,我們就從reuse隊列裡面拿出一個cell,並調用prepareForReuse方法。注意調用這個方法的時間,當cell還沒有進入屏幕的時候,就已經提前調用這個方法了。注意對比和iOS 9的區別,iOS 9 是在cell上邊緣馬上進入屏幕的時候才調用方法,而這裡,cell整個生命周期都被提前了,提前到cell還在設備外面的時候。
這裡還是和之前一樣,在cellForItemAtIndexPath中創建cell,填充數據,刷新狀態等等操作。注意,這裡生命周期也比iOS 9提前了。
用戶繼續滑動,這個時候就有不同了!
這個時候我們並不去調用willDisplayCell方法了!這裡遵循的原則是,何時去顯示,何時再去調用willDisplayCell。
當cell要馬上就需要顯示的時候,我們再調用willDisplayCell方法。
當整個cell要從UICollectionView的可見區域消失的時候,這個時候會調用didEndDisplayingCell方法。接下來發生的事情和iOS9一樣,cell會進入重用隊列中。
如果用戶想要顯示某個cell,在iOS 9 當中,cell只能從重用隊列裡面取出,再次走一遍生命周期。並調用cellForItemAtIndexPath去創建或者生成一個cell。
在iOS 10 當中,系統會把cell保持一段時間。在iOS中,如果用戶把cell滑出屏幕後,如果突然又想回來,這個時候cell並不需要再走一段的生命周期了。只需要直接調用willDisplayCell就可以了。cell就又會重新出現在屏幕中。這就是iOS 10 的整個UICollectionView的生命周期。
上面說的iOS 10裡面的場景同樣適用於多列的情況。 這時我們每次只加載一個cell,而不是每次加載一行的cell。當第一個cell准備好之後再叫第二個cell准備。當2個cell都准備好了之後,接著我們再調用willDisplayCell給每個cell,發送完這個消息之後,cell就會出現在屏幕上了。
這雖然看起來是一個很小的改動,但是這小小的改動就提升了很多的用戶體驗!
讓我們來看看上述的改動對滑動的影響
滑動比iOS 9流程很多,這裡可以看到整個過程都很平緩,不卡頓。
還是和iOS 9一樣,我們來模擬一下系統是如何加載cell的情況。
我們可以很明顯的看到,iOS 系統是一個個的加載cell的,一個cell加載完之後再去加載下一個cell。這裡和iOS 9 的有很大的不同,iOS 9是加載整整一行的cell。
這是因為我們用了新的 UICollectionViewCell的生命周期。整個app完全沒有加一行代碼。現在iOS 10是絲滑的滑動體驗實在是太棒了!!
二. UICollectionViewCell的Pre-Fetching預加載
當我們編譯iOS 10的app的時候,這個Pre-Fetching默認是enable的。當然,如果有一些原因導致你必須用到iOS 10之前老的生命周期,你只需要給collectionView加入新的isPrefetchingEnabled屬性即可。如果你不想用到Pre-Fetching,那麼把這個屬性變成false即可。
collectionView.isPrefetchingEnabled = false
為了最佳實踐一下這個新特性。我們先改變一下我們加載cell的方式。我們把很重的讀取數據的操作,所有內容的創建都放到cellForItemAtIndexPath方法裡面去完成。保證我們在willDisplayCell 和 didEndDisplayCell這兩個方法裡面基本不做其他事情。最後,保證cellForItemAtIndexPath加載的cell都不是從重用隊列裡面拿出來的。
如果這個時候當你用iOS 10編譯出你的app,那麼非常順滑的用戶體驗就會自動的優化出來。
UICollectionView的流暢的滑動解決了,那麼在UICollectionViewCell在加載的時候所花費的時間,怎麼解決呢??
UICollectionViewCell加載的時間取決於DataModel。DataModel很可能回去加載圖片,來自於網絡或者來自於本地的數據庫。這些操作大多數都是異步的操作。為了使data加載更快,iOS 10引入了新的API來解決這個問題。
UICollectionView有2個“小伙伴”,那就是data source和delegate。在iOS 10中,將會迎來第3個“小伙伴”。這個“小伙伴”叫prefetchDataSource。
protocol UICollectionViewDataSourcePrefetching { func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [NSIndexPath]) optional func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [NSIndexPath]) } class UICollectionView : UIScrollView { weak var prefetchDataSource: UICollectionViewDataSourcePrefetching? var isPrefetchingEnabled: Bool }
這個協議裡面只有一個必須要實現的方法——ColletionView prefetchItemsAt indexPaths。這個方法會在prefetchDataSource裡面被調用,用來給你異步的預加載數據的。indexPaths數組是有序的,就是接下來item接收數據的順序,讓我們model異步處理數據更加方便。
在這個協議裡面還有第二個方法CollectionView cancelPrefetcingForItemsAt indexPaths,不過這個方法是optional的。我們可以利用這個方法來處理在滑動中取消或者降低提前加載數據的優先級。
值得說明的是,新增加的這個“小伙伴”prefetchDataSource並不能代替原來的讀取數據的方法,這個預加載僅僅只是輔助加載數據,並不能
刪除原來我們讀取數據的方法。
至此,我們來看看從文章開始到現在,UICollectionView的性能提升了多少。我們還是用掉幀的方法來看看UICollectionView的性能。
上圖是iOS 9 UICollectionView的性能,很明顯的看見,波峰波谷很明顯,並且還掉了8幀,有明顯的卡頓現象。
上圖是iOS 10 UICollectionView的性能,我們可以很明顯的看到,經過iOS 10的優化,整個曲線很明顯平緩了一些,沒有極端的波峰掉幀現象。但是依舊存在少量的波峰快到16ms分界線了。
上圖是iOS 10 + Pre-Fetching API 之後的性能,已經優化的效果很明顯了!整條曲線基本都水平了。近乎完美。但是還是能發現有個別波峰特別高。波峰特別高的地方就是那個cell加載壓力大,時間花的比較長導致的。接下來我們繼續優化!
先來總結一下使用Pre-Fetching API需要注意的地方。
在我們使用Pre-Fetching API的時候,我們一定要保證整個預加載的過程都放在後台線程中進行。合理使用GCD 和 NSOperationQueue處理好多線程。
請切記,Pre-Fetching API是一種自適應的技術。何為自適應技術呢?當我們滑動速度很慢的時候,在這種“安靜”的時期,Pre-Fetching API會默默的在後台幫我們預加載數據,但是一旦當我們快速滑動,我們需要頻繁的刷新,我們不會去執行Pre-Fetching API。
最後,用cancelPrefetchingAPI去迎合用戶的滑動動作的變換,比如說用戶在快速滑動突然發現了有趣的感興趣的事情,這個時候停下來滑動了,甚至快速反向滑動了,或者點擊了事件,進去看詳情了,這些時刻我們都應該開啟cancelPrefetchingAPI。
綜上所述,Pre-Fetching API對於提高UICollectionView的性能提升是很有幫助的,而且並不需要加入太多的代碼。加入少量的代碼就可以獲得巨大的性能提升!
三. UITableViewCell的Pre-Fetching預加載
在iOS 10中,UITableViewCell也跟著UICollectionView一起得到了性能的提升,一樣擁有了Pre-Fetching API。
protocol UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [NSIndexPath]) optional func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [NSIndexPath]) } class UITableView : UIScrollView { weak var prefetchDataSource: UITableViewDataSourcePrefetching? }
這裡和上面 UICollectionView一樣,會調用TableView prefetchRowsAt indexPaths方法。indexPaths還是一個有序數字,順序就是列表上可見的順序。第二個可選的API還是TableView cancelPrefetchingForRowsAt indexPaths,和之前提到的一樣,也是用來取消預加載的。性能的提升和UICollectionView一樣的,對UITableView的性能提升很大!
四. 針對self-sizing的改進
self-sizing API 第一次被引入是在iOS 8,然而現在在iOS 10中得到了一些改進。
在UICollectionView 中有一個固定的類,叫UICollectionViewFlowLayout,iOS已經在這個類中完全支持了self-sizing。為了能開啟這一特性,需要我們開發者為一些不能為0的CGSize的cell設置一下estimated item size。
layout.estimatedItemSize = CGSize(width:50,height:50)
這會告訴UICollectionView我們想要開啟動態計算內容的布局。
至今,我們能有3種方法來動態的布局:
第一種方法是使用autolayout
當我們合理的加上了constrain,當cell加載的時候,就會根據內容動態的加載布局。
第二種方法,如果你不想使用autolayout的方法,想更加手動的控制它,那麼我們就需要重寫sizeThatFits()方法。
第三種方法,終極的方法是重寫preferredLayoutAttributesFittingAttributes()方法。在這個方法裡面不僅僅可以提供size的信息,更可以得到alpha和transform的信息。
所以想指定cell的大小,就可以用上面3個方法之一。
但是實際操作中,我們可以發現,有時候設置一個合適的estimated item size,對於我們來說是很困難的事情。如果flow layout可以用數學的方法動態的計算布局,而不是根據我們給的size去布局,那會是件很酷的事情。
iOS 10中就引入了新的API來解決上述的問題。
layout.estimatedItemSize = UICollectionViewFlowLayoutAutomaticSize
對於開發者,我們需要做的事情,僅僅就是設置好flow layout ,然後給estimatedItemSize設定一個新的常數, 最後UICollectionViewFlowLayout 就會自動計算高度了。
系統會自動計算好所有的布局,包括已經定下來的size的cell,並且還會動態的給出接下來cell的大小的預測。
接下來看2個例子就可以很明顯看出iOS 10針對self-sizing的改進了。
上圖可以看到,iOS 9 的布局是針對單個cell計算的,當改變了單個的cell,其他的cell依舊沒有變化,還是需要重新計算。
這裡例子就可以很明顯的看出差別了。當我們改變了第一個cell的size以後,系統會自動計算出所有的cell的size,並且每一行,每一個section的size都會被動態的計算出來,並且刷新界面!
以上就是iOS 10針對self-sizing的改進。
五. Interactive Reordering
談到重新排列,這是我們就需要類比一下UITableView了,UICollectionView的重新排列就如同UITableView 把cell上下移動,只不過UITableView的重排是針對垂直方向的。
在iOS 9中,引入了UICollectionView的Interactive Reordering,在今年的iOS 10中,又加入了一些新的API。
在上圖中,我們可以看到,我們即使任意拖動cell,整個界面也會重新排列,並且我們改變了cell的大小,整個 UICollectionView 也會重新動態的布局。
我們先來看看iOS 9裡面的API
class UICollectionView : UIScrollView { func beginInteractiveMovementForItem(at indexPath: NSIndexPath) -> Bool func updateInteractiveMovementTargetPosition(_ targetPosition: CGPoint) func endInteractiveMovement() func cancelInteractiveMovement() }
要想開啟interactive movement,我們就需要調用beginInteractiveMovementForItem()方法,其中indexPath代表了我們將要移動走的cell。接著每次手勢的刷新,我們都需要刷新cell的位置,去響應我們手指的移動操作。這時我們就需要調用updateInteractiveMovementTargetPosition()方法。我們通過手勢來傳遞坐標的變化。當我們移動結束之後,就會調用endInteractiveMovement()方法。 UICollectionView 就會放下cell,處理完整個layout,此時你也可以重新刷新model或者處理數據model。如果中間突然手勢取消了,那麼這個時候就應該調用cancelInteractiveMovement()方法。如果我們重新把cell移動一圈之後又放回原位,其實就是取消了移動,那這個時候就應該在cancelInteractiveMovement()方法裡面不用去刷新data source。
在iOS 10中,如果你使用UICollectionViewController,那麼這個重排對於你來說會更加的簡單。
class UICollectionViewController : UIViewController { var installsStandardGestureForInteractiveMovement: Bool }
你只需要把installsStandardGestureForInteractiveMovement這個屬性設置為True即可。CollectionViewController會自動為你加入手勢,並且自動為你調用上面的方法。
以上就是去年iOS 9為我們增加的API。
今年的iOS 10新加入的API是在iOS 9的基礎上增加了翻頁的功能。
UICollectionView繼承自UIScrollView,所以只需要你做的是把isPagingEnabled屬性設置為True,即可開啟分頁的功能。
collectionView.isPagingEnabled = true
開啟分頁之前:
開啟分頁之後就長這樣子:
每次移動一次就會以頁為單位的翻頁。
六.UIRefreshControl
UIRefreshControl現在可以直接在CollectionView裡面使用,同樣的,也可以直接在UITableView裡面使用,並且可以脫離UITableViewController。因為現在RefreshControl成為了ScrollView的一個屬性了。
UIRefreshControl的使用方法很簡單,就三步:
let refreshControl = UIRefreshControl() refreshControl.addTarget(self, action: #selector(refreshControlDidFire(_:)), for: .valueChanged) collectionView.refreshControl = refreshControl
先創建一個refreshControl,再關聯一個action事件,最後把這個新的refreshControl賦給想要的控件的對應的屬性即可。
總結
通過以上,我們談到了以下的知識:
UICollectionView cell pre-fetching預加載機制
UICollectionView and UITableView prefetchDataSource 新增的API
針對self-sizing cells 的改進
Interactive reordering
最後,談談我看了iOS 10 UICollectionView的優化的看法吧,原來有些地方用到AsyncDisplayKit優化UICollectionView速度的,現在可以考慮不用第三方庫優化了,系統自帶的方法可以解決一般性的卡頓的問題了。我感覺iOS 10的UICollectionView才像是一個完整版的,之前的系統優化的都不夠。我還是很看好iOS 10的UICollectionView。