你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 使用AsyncDisplayKit提升UICollectionView和UITableView的滾動性能

使用AsyncDisplayKit提升UICollectionView和UITableView的滾動性能

編輯:IOS開發基礎

5501.jpg

  • 本文由CocoaChina--夜微眠(github)翻譯

  • 作者:@Todd Kramer

  • 原文:Improving UICollectionView & UITableView Scrolling Performance With AsyncDisplayKit


目標:使用AsyncDisplayKit和Alamofire的異步下載、緩存以及圖像解碼 來提升UICollectionView的滾動性能。
上一篇教程 Downloading & Caching Images Asynchronously In Swift With Alamofire (使用Alamofire異步下載以及緩存圖片)中,我描述了如何使用Alamofire和AlamofireImage 庫異步下載和緩存圖片,進而顯示在UICollectionView中。通過使用這些庫可以輕松實現滾動流暢的滾動視圖和集合視圖,但是如果你的UI很復雜,圖片很多,有可能不能達到60fps

此次教程中我們使用Facebook AsyncDisplayKit庫重建Glacier Scenics工程,AsyncDisplayKit有很多我們提升滾動流暢所需要的工具以及圖片異步下載功能(如果你對緩存不感興趣)。如果需要實現緩存的話,Alamofire和AlamofireImage還是可以派上用場。

AsyncDisplayKit 概覽

AsyncDisplayKit用相關node類,替換了UIView和它的子類,而且是線程安全的。它可以異步解碼圖片,調整圖片大小以及對圖片和文本進行渲染。在大部分項目中,主要的目標就是實現圖片異步解碼。UIImage顯示之前必須要先解碼完成,而且解碼還是同步的。尤其是在UICollectionView/UITableView 中使用 prototype cell顯示大圖,UIImage的同步解碼在滾動的時候會有明顯的卡頓。

另外一個很吸引人的點是AsyncDisplayKit可以把view層次結構轉成layer。因為復雜的view層次結構開銷很大,如果不需要view特有的功能(例如點擊事件),就可以使用AsyncDisplayKit 的layer backing特性從而獲得一些額外的提升。

AsyncDisplayKit還有很多其他的特性,最後要提到就是基於node把UICollectionView 和 UITableView 替換為 ASCollectionView 和 ASTableView 的特性。替換的類可以使用UIkit中大量的數據源和 delegate方法,這樣便於你很快適應從UIKit部分到基於node架構的變化。

盡管AsyncDisplayKit基於node的架構,但每個node都有相應UIView 屬性。這樣你可以添加不需要與node類有交互的子視圖。

設置

下圖就是我們完成的工程

GlacierScenics-1.png

工程依賴
使用CocoaPods獲取AsyncDisplayKit依賴,下面是Podfile

platform :ios, '8.0'  
use_frameworks!

target 'GlacierScenics' do  
  pod 'AsyncDisplayKit'
end

數據

圖片的名稱和URL從property list(plist)文件獲取,分別是兩個帶有"name" and "imageURL"的數組。

Storyboard

項目中的Storyboard很簡單,是因為AsyncDisplayKit不支持Storyboard,所以相關約束都用代碼實現。我們只需要一個navigation controller 和root view controller(等下會被設成PhotosViewController)

ASCollectionView默認圖片下載

第一步從plist裡讀取數據。我們先定義一個簡單的struct GlacierScenic 存照片信息

struct GlacierScenic {  
    let name: String
    let photoURLString: String
}

這就可以了。下一步我們創建一個數據管理器從plist讀取和存儲照片信息。

class PhotosDataManager {

    static let sharedManager = PhotosDataManager()
    private var photos = [GlacierScenic]()
    
    func allPhotos() -> [GlacierScenic] {
        if !photos.isEmpty { return photos }
        guard let data = NSArray(contentsOfFile: dataPath()) as? [NSDictionary] else { return photos }
        for photoInfo in data {
            let name = photoInfo["name"] as! String
            let urlString = photoInfo["imageURL"] as! String
            let glacierScenic = GlacierScenic(name: name, photoURLString: urlString)
            photos.append(glacierScenic)
        }
        return photos
    }
    
    func dataPath() -> String {
        return NSBundle.mainBundle().pathForResource("GlacierScenics", ofType: "plist")!
    }
    
}

接下來看下view controller代碼

import UIKit  
import AsyncDisplayKit

class PhotosViewController: UIViewController {

    var collectionView: ASCollectionView!
    var photosDataSource = PhotosDataSource()
    
    //MARK: - View Controller Lifecycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        configureCollectionView()
    }
    
    override func prefersStatusBarHidden() -> Bool {
        return true
    }
    
    override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
        
        coordinator.animateAlongsideTransition({ (context) -> Void in
            self.collectionView.frame.size = self.view.frame.size
            self.collectionView.reloadData()
            }, completion: nil)
    }
    
    //MARK: - Collection View
    
    func configureCollectionView() {
        let layout = UICollectionViewFlowLayout()
        layout.minimumInteritemSpacing = 1
        layout.minimumLineSpacing = 1
        var frame = view.frame
        if let navigationBar = navigationController?.navigationBar {
            frame.size.height -= navigationBar.frame.height
        }
        collectionView = ASCollectionView(frame: frame, collectionViewLayout: layout)
        collectionView.backgroundColor = UIColor.blackColor()
        collectionView.asyncDataSource = photosDataSource
        view.addSubview(collectionView)
        collectionView.reloadData()
    }
    
}

我們這所需要做的就是配置collection view  以及處理不同大小size classes之間的轉場變化。

這裡有一些注意事項:

  • 第一,ASCollectionView使用asyncDataSource和asyncDelegate。這個很重要,因為ASCollectionView也有標准的data Source 和 delegate。所以獲取數據源和委托的時候不要混淆。

  • 第二,ASCollectionView構造器需要UICollectionViewLayout參數,但是不是所有的布局配置能生效。一個很重要的例子就是cell大小,這個需要用另外的方法處理。

  • 最後,collectionView的布局屬性有個方法invalidateLayout不起作用(問題),所以我們不使用viewWillTransitionToSize方法。

現在我們需要實現上邊設置的data source

import UIKit  
import AsyncDisplayKit

class PhotosDataSource: NSObject, ASCollectionDataSource {

    func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return PhotosDataManager.sharedManager.allPhotos().count
    }

    func collectionView(collectionView: ASCollectionView, nodeForItemAtIndexPath indexPath: NSIndexPath) -> ASCellNode {
        let glacierScenic = glacierScenicAtIndex(indexPath)
        return PhotoCollectionViewCellNode(glacierScenic: glacierScenic)
    }

    func glacierScenicAtIndex(indexPath: NSIndexPath) -> GlacierScenic {
        let photos = PhotosDataManager.sharedManager.allPhotos()
        return photos[indexPath.row]
    }

}

這段代碼很簡單。我們用一個section顯示圖片。接著我們返回一個新的collection view cell node (AsyncDisplayKit的 ASCellNode 子類)。

要注意的是AsyncDisplayKit 用nodeForItem 方法替換了cellForItem方法,也就需要在collection view上注冊reuse identifiers。

最後就是PhotoCollectionViewCellNode。

import UIKit  
import AsyncDisplayKit

class PhotoCollectionViewCellNode: ASCellNode {

    var loadingIndicator = UIActivityIndicatorView(activityIndicatorStyle: .WhiteLarge)
    var imageNode = ASNetworkImageNode()
    var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .Light))
    var captionContainerNode = ASDisplayNode()
    var captionLabelNode = AttributedTextNode()

    let glacierScenic: GlacierScenic
    var nodeSize: CGSize {
        let spacing: CGFloat = 1
        let screenWidth = UIScreen.mainScreen().bounds.width
        let itemWidth = floor((screenWidth / 2) - (spacing / 2))
        let itemHeight = floor((screenWidth / 3) - (spacing / 2))
        return CGSize(width: itemWidth, height: itemHeight)
    }

    init(glacierScenic: GlacierScenic) {
        self.glacierScenic = glacierScenic
        super.init()
        configure()
    }

    func configure() {
        backgroundColor = UIColor.blackColor()
        configureLoadingIndicator()
        configureImageNode()
        configureCaptionNodes()
    }

    func configureLoadingIndicator() {
        loadingIndicator.center = loadingIndicatorCenter()
        view.addSubview(loadingIndicator)
        loadingIndicator.startAnimating()
        view.addSubview(loadingIndicator)
    }

    func loadingIndicatorCenter() -> CGPoint {
        let centerX = nodeSize.width / 2
        let centerY = nodeSize.height / 2 - captionContainerFrame().height / 2
        return CGPoint(x: centerX, y: centerY)
    }

    func configureImageNode() {
        imageNode.frame = viewFrame()
        imageNode.delegate = self
        imageNode.URL = NSURL(string: glacierScenic.photoURLString)
        addSubnode(imageNode)
    }

    func configureCaptionNodes() {
        configureCaptionBlurView()
        configureCaptionContainerNode()
        configureCaptionLabelNode()
    }

    func configureCaptionBlurView() {
        blurView.frame = captionContainerFrame()
        view.addSubview(blurView)
    }

    func configureCaptionContainerNode() {
        captionContainerNode.frame = captionContainerFrame()
        captionContainerNode.backgroundColor = UIColor.blackColor().colorWithAlphaComponent(0.5)
        addSubnode(captionContainerNode)
    }

    func configureCaptionLabelNode() {
        captionLabelNode.configure(glacierScenic.name, size: 16, textAlignment: .Center)
        let constrainedSize = CGSize(width: nodeSize.width, height: CGFloat.max)
        let labelNodeHeight: CGFloat = captionLabelNode.attributedString!.boundingRectWithSize(constrainedSize, options: .UsesFontLeading, context: nil).height
        let labelNodeYValue = captionContainerFrame().height / 2 - labelNodeHeight / 2
        captionLabelNode.frame = CGRect(x: 0, y: labelNodeYValue, width: nodeSize.width, height: labelNodeHeight)
        captionContainerNode.addSubnode(captionLabelNode)
    }

    func captionContainerFrame() -> CGRect {
        let containerHeight: CGFloat = 35
        return CGRect(x: 0, y: nodeSize.height - containerHeight, width: nodeSize.width, height: containerHeight)
    }

    func viewFrame() -> CGRect {
        return CGRect(x: 0, y: 0, width: nodeSize.width, height: nodeSize.height)
    }

    override func calculateLayoutThatFits(constrainedSize: ASSizeRange) -> ASLayout {
        return ASLayout(layoutableObject: self, size: nodeSize)
    }

}

extension PhotoCollectionViewCellNode: ASNetworkImageNodeDelegate {

    func imageNode(imageNode: ASNetworkImageNode, didLoadImage image: UIImage) {
        loadingIndicator.stopAnimating()
    }

}

你可能已經注意到很多代碼都是layout代碼。是因為AsyncDisplayKit使用動態布局機制。復雜的布局已經超出了這篇教程的范圍,但如果你只需要一個固定的cell大小,那重寫calculateLayoutThatFits方法就可以了。注意,計算型屬性“nodeSize”代碼在類的頂部。

AsyncDisplayKit使得異步下載圖片變得非常簡單,此外ASImageNode可以作為UIImageView的一部分,AsyncDisplayKit還有ASNetworkImageNode子類 ,你只需要把圖片設置URL屬性就可以了。

在這個例子中,我們還需要一個在圖片下載完成時終止加載動畫的加載指示器。因為ASNetworkImageNode有delegate屬性,等下我們可以使用擴展來實現delegate和處理加載指示。delegate還提供了何時圖片解碼完成以及圖片下載失敗的方法。

下一步使用“AttributedTextNode”作為標題,與UILabel不同,ASTextNode沒有默認字體的“text”屬性,它使用attributed string。AttributedTextNode子類提供了一個實用的函數來處理node的attributed string

import UIKit  
import AsyncDisplayKit

class AttributedTextNode: ASTextNode {

    func configure(text: String, size: CGFloat, color: UIColor = UIColor.whiteColor(), textAlignment: NSTextAlignment = .Left) {
        let mutableString = NSMutableAttributedString(string: text)
        let range = NSMakeRange(0, text.characters.count)
        mutableString.addAttribute(NSFontAttributeName, value: UIFont.systemFontOfSize(size), range: range)
        mutableString.addAttribute(NSForegroundColorAttributeName, value: color, range: range)
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = textAlignment
        mutableString.addAttribute(NSParagraphStyleAttributeName, value: paragraphStyle, range: range)
        attributedString = mutableString
    }

}

最後,如上所述,我們能獲取cell node的view屬性來添加沒有相應node classes的subview 。本文例子中就有UIActivityIndicatorView 和 UIVisualEffectView

圖像緩存

AsyncDisplayKit讓異步圖片下載變得非常簡單,但是沒有默認緩存支持。那麼為了實現緩存,我們需要替代AsyncDisplayKit默認的下載器,所以我們用Alamofire和AlamofireImage實現下載和緩存。首先我們先更新Podfile

platform :ios, '8.0'  
use_frameworks!

target 'GlacierScenics' do  
  pod 'AsyncDisplayKit'
  pod 'AlamofireImage', '~> 2.0'
end

警告:運行前,先執行pod install

之前,我們用無參數初始化network image node。AsyncDisplayKit還有另外一個以緩存和下載器為參數的構造器。

緩存和下載器需要遵照ASImageCacheProtocol 和 ASImageDownloaderProtocol 協議。我們工程中緩存和下載及都實現在PhotosDataManager 中,所以我們需要更新PhotosDataManager以實現這些協議並提供緩存。

import UIKit  
import Alamofire  
import AlamofireImage  
import AsyncDisplayKit

class PhotosDataManager: NSObject {

    static let sharedManager = PhotosDataManager()
    private var photos = [GlacierScenic]()

    let photoCache = AutoPurgingImageCache(
        memoryCapacity: 100 * 1024 * 1024,
        preferredMemoryUsageAfterPurge: 60 * 1024 * 1024
    )

    func allPhotos() -> [GlacierScenic] {
        if !photos.isEmpty { return photos }
        guard let data = NSArray(contentsOfFile: dataPath()) as? [NSDictionary] else { return photos }
        for photoInfo in data {
            let name = photoInfo["name"] as! String
            let urlString = photoInfo["imageURL"] as! String
            let glacierScenic = GlacierScenic(name: name, photoURLString: urlString)
            photos.append(glacierScenic)
        }
        return photos
    }

    func cacheImage(url: String, image: Image) {
        photoCache.addImage(image, withIdentifier: url)
    }

    func cachedImage(url: String) -> Image? {
        return photoCache.imageWithIdentifier(url)
    }

    func dataPath() -> String {
        return NSBundle.mainBundle().pathForResource("GlacierScenics", ofType: "plist")!
    }
}

extension PhotosDataManager: ASImageDownloaderProtocol {  
    func downloadImageWithURL(URL: NSURL, callbackQueue: dispatch_queue_t?, downloadProgressBlock: ((CGFloat) -> Void)?, completion: ((CGImage?, NSError?) -> Void)?) -> AnyObject? {
        let request = Alamofire.request(.GET, URL.absoluteString).responseImage { (response) -> Void in
            guard let image = response.result.value else {
                completion?(nil, nil)
                return
            }
            self.cacheImage(URL.absoluteString, image: image)
            completion?(image.CGImage, nil)
        }
        return request
    }

    func cancelImageDownloadForIdentifier(downloadIdentifier: AnyObject?) {
        if let request = downloadIdentifier where request is Request {
            (request as! Request).cancel()
        }
    }
}

extension PhotosDataManager: ASImageCacheProtocol {  
    func fetchCachedImageWithURL(URL: NSURL?, callbackQueue: dispatch_queue_t?, completion: (CGImage?) -> Void) {
        if let url = URL, cachedImage = cachedImage(url.absoluteString) {
            completion(cachedImage.CGImage)
            return
        }
        completion(nil)
    }
}

現在我們加了photoCache 以及兩個函數,一個用於緩存圖片,另外一個用於獲取緩存圖片。緩存最大為100MB,最優為60MB。緩存標識使用圖片的URL,AsyncDisplayKit協議將會在設置network image node的URL屬性後進行傳遞。

接著我們實現協議。第一個協議ASImageCacheProtocol就包含一個方法fetchCachedImageWithURL,用於獲取緩存圖片,如果對於的URL的圖片存在,就返回。否則nil傳給completion block,這樣就會觸發下載圖片。

第二個協議ASImageDownloaderProtocol 包含兩個方法,一個下載另一個取消下載。下載方法裡我們用Alamofire Request下載圖片,如果下載成功則進行緩存,然後調用 completion block。要注意的是,我們也要返回請求對象。如果取消下載,則"cancelImageDownloadForIdentifier"方法會用到它。

在取消方法裡,先檢查下載標識是否存在,request 是不是Request對象,然後在request上調用cancel()方法。

最後,我們替換掉PhotoCollectionViewCellNode 裡ASNetworkImageNode 構造器

func configureImageNode() {  
    let manager = PhotosDataManager.sharedManager
    imageNode = ASNetworkImageNode(cache: manager, downloader: manager)
    imageNode.frame = viewFrame()
    imageNode.delegate = self
    imageNode.URL = NSURL(string: glacierScenic.photoURLString)
    addSubnode(imageNode)
}

Layer Backing

在介紹之前,我們再加一個優化。就是AsyncDisplayKit概覽中提到layer backing。它能夠幫助我們通過將視圖層次結構轉成layer層來提升滾動性能。我們的案例中,

view/node的層次結構不太復雜,但是有兩處可以添加Layer Backing。第一處就是image node,實現起來就一行代碼,將layerBacked 屬性設置為true。

func configureImageNode() {  
    let manager = PhotosDataManager.sharedManager
    imageNode = ASNetworkImageNode(cache: manager, downloader: manager)
    imageNode.frame = viewFrame()
    imageNode.delegate = self
    imageNode.URL = NSURL(string: glacierScenic.photoURLString)
    imageNode.layerBacked = true
    addSubnode(imageNode)
}

第二處就是 container node 以及caption label subnode。

func configureCaptionContainerNode() {  
    captionContainerNode.frame = captionContainerFrame()
    captionContainerNode.backgroundColor = UIColor.blackColor().colorWithAlphaComponent(0.5)
    captionContainerNode.layerBacked = true
    addSubnode(captionContainerNode)
}

func configureCaptionLabelNode() {  
    captionLabelNode.configure(glacierScenic.name, size: 16, textAlignment: .Center)
    let constrainedSize = CGSize(width: nodeSize.width, height: CGFloat.max)
    let labelNodeHeight: CGFloat = captionLabelNode.attributedString!.boundingRectWithSize(constrainedSize, options: .UsesFontLeading, context: nil).height
    let labelNodeYValue = captionContainerFrame().height / 2 - labelNodeHeight / 2
    captionLabelNode.frame = CGRect(x: 0, y: labelNodeYValue, width: nodeSize.width, height: labelNodeHeight)
    captionContainerNode.layer.addSublayer(captionLabelNode.layer)
}

AsyncDisplayKit通過把圖片解碼、大小調整以及圖像文本的渲染放在子線程,從而提升collectionview 和 tableview的滾動性能。也正如剛才看到的,AsyncDisplayKit默認下載不支持緩存,所以使用前需要考慮到AsyncDisplayKit一些不足的地方。

第一,AsyncDisplayKit不支持Storyboard、Xib以及Autolayout,不過並不意味著你不能在項目中使用這些工具,事實上我們依然在這個項目中使用了storyboard。如果你需要用Interface Builder和Autolayout實現collection view,那就需要另外的方法來提高流暢度。當然,如果不使用Autolayout就用程序寫frame這樣可以減少約束相關的消耗。總的來說,如果項目中一定要用到Autolayout,可能就要自己實現異步圖片解碼了。

第二,UITableView 和 UICollectionView一些重要的方法沒有被AsyncDisplayKit替換或繼承。在寫這篇文章前,他們還處於開發階段,有肯能會有所變化。

總的來說,無論用不用AsyncDisplayKit或者其他第三方庫,這個取決於cell和collection view  UI相關細節。雖然有時候你決定自己實現它的一些功能,但是該庫提供

一個很好的處理UITableView 和 UICollectionView性能問題的途徑。

文章中使用的項目源碼存放在"GlacierScenicsAsyncDisplayKit" 文件夾下。

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved