【翻譯】UITableView和UICollectionView的流暢滾動

As most iOS developers know, displaying sets of data is a rather common task in building a mobile app. Apple’s SDK provides two components to help carry out such a task without having to implement everything from scratch: A table view (UITableView) and a collection view (UICollectionView).

作為一個iOS開發者,經常要展示大量的數據。蘋果SDK提供了兩個組件UITableView和UICollectionView,來幫助開發更好地完成這個任務,而不是從零開始。

Table views and collection views are both designed to support displaying sets of data that can be scrolled. However, when displaying a very large amount of data, it could be very tricky to achieve a perfectly smooth scrolling. This is not ideal because it negatively affects the user experience.

Table views以及 collection views被設計用來支持展示能滾動的數據集。然而,當展示大量的數據的時候,要想保持完美的滾動,就比較棘手,更可能是傷害用戶體驗。

As a member of the iOS dev team for the Capital One Mobile app, I’ve had the chance to experiment with table views and collection views; this post reflects my personal experience in displaying large amounts of scrollable data. In it, we’ll review the most important tips to optimize the performance of the above mentioned SDK components. This step is paramount to achieving a very smooth scrolling experience. Note that most of the following points apply to both UITableView and UICollectionView as they share a good amount of their “under the hood” behavior. A few points are specific to UICollectionView, as this view puts additional layout details on the shoulders of the developer.

作為一個Capital One移動應用的iOS開發者,我有機會去體驗table views以及collection views,這篇文章記敘了我個人在展示大量可滾動數據的個人經歷。在文章中,我們總結了最重要的幾個技巧來優化上面提到的兩個組件。

Let’s begin with a quick overview of the above mentioned components.
UITableView is optimized to show views as a sequence of rows. Since the layout is predefined, the SDK component takes care of most of the layout and provides delegates that are mostly focused on displaying cell content.
UICollectionView, on the other hand, provides maximum flexibility as the layout is fully customizable. However, flexibility in a collection view comes at the cost of having to take care of additional details regarding how the layout needs to be performed.

讓我們來快速地總覽一下上面提到的組件。UITableView被優化于展示一系列的行,它的layout被預先定義了,這個SDK組件預置了大部分的layout以及提供了展示cell內容的delegate。UICollectionView,因為layout的完全可自定義,提供了最大程度上的靈活性。同時,collection view的靈活性也有代價,它需要處理額外的執行layout的細節。

UITableView 和 UICollectionView的通用處理技巧

** Tips Common to both UITableView and UICollectionView **

NOTE: I am going to use UITableView for my code snippets. But the same concepts apply to UICollectionView as well.

注意:我的代碼片段主要使用UITableView,但是它也適用于UICollectionView。

Cell渲染是個關鍵的任務

** Cells Rendering is a Critical Task **

The main interaction between UITableView and UITableViewCell can be described by the following events:

UITableView and UITableViewCell的主要交互可以用下面的事件來描述:

For all the above events, the table view is passing the index (row) for which the interaction is taking place. Here’s a visualization of the UITableViewCell lifecycle:

對于上面的事件,table view傳遞了正在交互的索引index,下面是 UITableViewCell的生命周期的可視化:


First off, the tableView(_:cellForRowAt:) method should be as fast as possible. This method is called every time a cell needs to be displayed. The faster it executes, the smoother scrolling the table view will be.

首先, tableView(_:cellForRowAt:) 方法應當盡可能快,這個方法在每次一個cell要被展示的時候都會被調用,它每次執行得越快, table view滾動就會越流暢。

There are a few things we can do in order to make sure we render the cell as fast as possible. The following is the basic code to render a cell, taken fromApple’s documentation:

為了更快地渲染cell,我們能做一些事情,下面是渲染一個cell的基礎代碼,來自Apple’s documentation

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // Table view cells are reused and should be dequeued using a cell identifier.
    let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath)
    
    // Configure the cell ...
    
    return cell
}

After fetching the cell instance that is about to be reused (dequeueReusableCell(withIdentifier:for:)), we need to configure it by assigning the required values to its properties. Let’s take a look at how we can make our code execute quickly.

定義Cell的View Model

Define the View Model for the Cells

One way is to have all the properties we need to show be readily available and just assign those to the proper cell counterpart. In order to achieve this, we can take advantage of the MVVM pattern. Let’s assume we need to display a set of users in our table view. We could define the Model for the User as:

一個方法是讓所有需要顯示的屬性隨時可用,只要賦值給對應的cell就行。為了做到這點,我們可以利用MVVM模式。假設我們需要在table view中展示一系列的 users ,我們定義 User 的Model如下所示:

enum Role: String {
    case Unknown = "Unknown"
    case User = "User"
    case Owner = "Owner"
    case Admin = "Admin"

    static func get(from: String) -> Role {
        if from == User.rawValue {
            return .User
        } else if from == Owner.rawValue {
            return .Owner
        } else if from == Admin.rawValue {
            return .Admin
        }
        return .Unknown
    }
}

struct User {
    let avatarUrl: String
    let username: String
    let role: Role
    
    init(avatarUrl: String, username: String, role: Role) {
        self.avatarUrl = avatarUrl
        self.username = username
        self.role = role
    }
}

Defining a View Model for the User is straightforward:

定義User的View Model比較直接:

struct UserViewModel {
    let avatarUrl: String
    let username: String
    let role: Role
    let roleText: String
    
    init(user: User) {
        // Avatar
        avatarUrl = user.avatarUrl
        
        // Username
        username = user.username
        
        // Role
        role = user.role
        roleText = user.role.rawValue
    }
}

異步獲取數據以及緩存View Model

** Fetch Data Asynchronously and Cache View Models**

Now that we have defined our Model and View Model, let’s get them to work! We are going to fetch the data for the users through a web service. Of course, we want to implement the best user experience possible. Therefore, we will take care of the following:

現在我們定義了Model和 View Model,現在用起來!我們通過網絡服務來獲取user的數據,當然我們想要實現最好的用戶體驗,因此我們就有下面的處理:

  • Avoid blocking the main thread while fetching data.
    在獲取數據的時候避免阻塞主線程
  • Updating the table view right after we retrieve the data.
    檢索數據以后立即更新table view

This means we will be fetching the data asynchronously. We will perform this task through a specific controller, in order to keep the fetching logic separated from both the Model and the View Model, as follows:

這意味著我們將要異步獲取數據。為了保持獲取數據的邏輯和
Model以及View Model獨立,我們通過一個特定的controller來執行這個任務,如下所示:

class UserViewModelController {

    fileprivate var viewModels: [UserViewModel?] = []

    func retrieveUsers(_ completionBlock: @escaping (_ success: Bool, _ error: NSError?) -> ()) {
        let urlString = ... // Users Web Service URL
        let session = URLSession.shared
        
        guard let url = URL(string: urlString) else {
            completionBlock(false, nil)
            return
        }
        let task = session.dataTask(with: url) { [weak self] (data, response, error) in
            guard let strongSelf = self else { return }
            guard let data = data else {
                completionBlock(false, error as NSError?)
                return
            }
            let error = ... // Define a NSError for failed parsing
            if let jsonData = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [[String: AnyObject]] {
                guard let jsonData = jsonData else {
                    completionBlock(false,  error)
                    return
                }
                var users = [User?]()
                for json in jsonData {
                    if let user = UserViewModelController.parse(json) {
                        users.append(user)
                    }
                }

                strongSelf.viewModels = UserViewModelController.initViewModels(users)
                completionBlock(true, nil)
            } else {
                completionBlock(false, error)
            }
        }
        task.resume()
    }

    var viewModelsCount: Int {
        return viewModels.count
    }

    func viewModel(at index: Int) -> UserViewModel? {
        guard index >= 0 && index < viewModelsCount else { return nil }
        return viewModels[index]
    }
    
}

private extension UserViewModelController {

    static func parse(_ json: [String: AnyObject]) -> User? {
        let avatarUrl = json["avatar"] as? String ?? ""
        let username = json["username"] as? String ?? ""
        let role = json["role"] as? String ?? ""
        return User(avatarUrl: avatarUrl, username: username, role: Role.get(from: role))
    }

    static func initViewModels(_ users: [User?]) -> [UserViewModel?] {
        return users.map { user in
            if let user = user {
                return UserViewModel(user: user)
            } else {
                return nil
            }
        }
    }

}

Now we can retrieve the data and update the table view asynchronously as shown in the following code snippet:

如下面的代碼片段中所示,現在我們能夠檢索數據并異步更新table view:

class MainViewController: UITableViewController {

    fileprivate let userViewModelController = UserViewModelController()

    override func viewDidLoad() {
        super.viewDidLoad()

        userViewModelController.retrieveUsers { [weak self] (success, error) in
            guard let strongSelf = self else { return }
            if !success {
                DispatchQueue.main.async {
                    let title = "Error"
                    if let error = error {
                        strongSelf.showError(title, message: error.localizedDescription)
                    } else {
                        strongSelf.showError(title, message: NSLocalizedString("Can't retrieve contacts.", comment: "The message displayed when contacts can’t be retrieved."))
                    }
                }
            } else {
                DispatchQueue.main.async {
                    strongSelf.tableView.reloadData()
                }
            }
        }
    }

    [...]

}

We can use the above snippet to fetch the users data in a few different ways:

我們可以通過幾種方式來使用上面獲取users 數據的代碼片段:

  • Only the when loading the table view the first time, by placing it in
    第一次加載table view,放置在viewDidLoad().
  • Every time the table view is displayed, by placing it in
    每次table view顯示,放置在viewWillAppear(_:).
  • On user demand (for instance via a pull-down-to-refresh), by placing it in the method call that will take care of refreshing the data.用戶需求(比如下拉刷新),放置在負責更新數據的方法中

The choice depends on how often the data can be changing on the backend. If the data is mostly static or not changing often the first option is better. Otherwise, we should opt for the second one.

數據改變的頻率不同,選擇也不同。如果數據大多數時候靜止不怎么改變,首選項應該更好,不然就是第二種。

異步加載和緩存圖片

** Load Images Asynchronously and Cache Them**

It’s very common to have to load images for our cells. Since we’re trying to get the best scrolling performance possible, we definitely don’t want to block the main thread to fetch the images. A simple way to avoid that is to load images asynchronously by creating a simple wrapper around URLSession:

對于cell而言,加載圖片算是挺普遍的需求了。既然我們想要最好的滾動性能,那么我們就不能因為獲取圖片而造成主線程阻塞。一個簡單的方法就是創造一個在URLSession上的簡單封裝來的異步加載圖片。

extension UIImageView {

    func downloadImageFromUrl(_ url: String, defaultImage: UIImage? = UIImageView.defaultAvatarImage()) {
        guard let url = URL(string: url) else { return }
        URLSession.shared.dataTask(with: url, completionHandler: { [weak self] (data, response, error) -> Void in
            guard let httpURLResponse = response as? NSHTTPURLResponse where httpURLResponse.statusCode == 200,
                let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
                let data = data where error == nil,
                let image = UIImage(data: data)
            else {
                return
            }
        }).resume()
    }

}

This lets us fetch each image using a background thread and then update the UI once the required data is available. We can improve our performances even further by caching the images.

這讓我們能夠用背景線程來獲取每個圖片,以及在圖片可用的時候更新UI,我們能夠甚至能夠通過緩存圖片來改善性能。

In case we don’t want - or can’t afford - to write custom asynchronous image downloading and caching ourselves, we can take advantage of libraries such as SDWebImage or AlamofireImage. These libraries provide the functionality we’re looking for out-of-the-box.

在我們并不想或者不能去寫自定義的異步圖像下載和緩存,我們可以利用現成的庫, 類似SDWebImage或者 AlamofireImage,這些庫提供了開箱即用的功能。

自定義Cell

** Customize the Cell**

In order to fully take advantage of the cached View Models, we can customize the User cell by subclassing it (from UITableViewCell for table views and from UICollectionViewCell for collection views). The basic approach is to create one outlet for each property of the Model that needs to be shown and initialize it from the View Model:

為了完全利用緩存View Model的優勢,我們可以自定義User cell作為它的子類
(就像UITableViewCell作為table view和UICollectionViewCell作為 collection views。基礎方式是為需要顯示的Model的每個屬性創建出口,并通過View Model來初始化。

class UserCell: UITableViewCell {
    @IBOutlet weak var avatar: UIImageView!
    @IBOutlet weak var username: UILabel!
    @IBOutlet weak var role: UILabel!
    
    func configure(_ viewModel: UserViewModel) {
        avatar.downloadImageFromUrl(viewModel.avatarUrl)
        username.text = viewModel.username
        role.text = viewModel.roleText
    }
    
}

使用不透明的圖層以及避免漸變

Use Opaque Layers and Avoid Gradients

Since using a transparent layer or applying a gradient requires a good amount of computation, if possible, we should avoid using them to improve scrolling performance. In particular, we should avoid changing the alpha value and preferably use a standard RGB color (avoid UIColor.clear) for the cell and any image it contains:

使用透明圖層或者應用漸變需要大量的計算,如果可能,我們應當避免使用它們來保證滾動性能。特別地,我們對cell以及它包含的圖片應當避免改變alpha值以及使用標準RGB顏色(避免 UIColor.clear)。

class UserCell: UITableViewCell {
    @IBOutlet weak var avatar: UIImageView!
    @IBOutlet weak var username: UILabel!
    @IBOutlet weak var role: UILabel!
    
    func configure(_ viewModel: UserViewModel) {
        setOpaqueBackground()
        
        [...]
    }
    
}

private extension UserCell {
    static let defaultBackgroundColor = UIColor.groupTableViewBackgroundColor

    func setOpaqueBackground() {
        alpha = 1.0
        backgroundColor = UserCell.defaultBackgroundColor
        avatar.alpha = 1.0
        avatar.backgroundColor = UserCell.defaultBackgroundColor
    }
}

優化cell渲染

Putting Everything Together: Optimized Cell Rendering

At this point, configuring the cell once it’s time to render it should be easy peasy and really fast because:

此時,在渲染cell的時候配置它會變得異常簡單和快捷,因為:

  • We are using the cached View Model data.
    我們使用存儲了的view model數據。

  • We are fetching the images asynchronously.
    我們異步獲取圖片

Here’s the updated code:
下面是更新的代碼:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell", for: indexPath) as! UserCell
    
    if let viewModel = userViewModelController.viewModel(at: (indexPath as NSIndexPath).row) {
        cell.configure(viewModel)
    }
    
    return cell
}

UITableView的專屬小技巧

Tips Specific to UITableView

使用Self-Sizing Cells用于可變高度的Cell

Use Self-Sizing Cells for Cells of Variable Height

In case the cells we want to display in our table view have variable height, we can use self sizable cells. Basically, we should create appropriate Auto Layout constraints to make sure the UI components that have variable height will stretch correctly. Then we just need to initialize the estimatedRowHeight and rowHeight property:

在想要展示的cell有可變高度的情況下,我們可以使用 self sizable cells。基本上,我們應當創建適當的的Auto Layout約束來確保可變高度的ui組件正確伸縮,然后我們需要正確初始化 estimatedRowHeightrowHeight屬性。

override func viewDidLoad() {
   [...]
   tableView.estimatedRowHeight = ... // Estimated default row height
   tableView.rowHeight = UITableViewAutomaticDimension
}

NOTE: ****In the unfortunate case we can’t use self-sizing cells (for instance, if support for iOS7 is still required) we’d have to implementtableView(_:heightForRowAt:) to calculate each cell height. It is still possible, though, to improve scrolling performances by:
注意:萬一遇到不能使用self-sizing cells (比如說需要支持iOS7)我們需要實現tableView(_:heightForRowAt:)來計算每個cell的高度。此時就需要通過下面的方式來改變滾動性能。

UICollectionView的專屬小技巧

Tips Specific to UICollectionView

We can easily customize most of our collection view by implementing the appropriate UICollectionViewFlowLayoutDelegate protocol method.
我們能夠非常容易地通過實現合適的 UICollectionViewFlowLayoutDelegate協議來自定義大部分 collection view。

計算你的Cell Size

Calculate your Cell Size

We can customize our collection view cell size by implementing collectionView(_:layout:sizeForItemAt:):

我們能夠通過實現 collectionView(_:layout:sizeForItemAt:)來自定義collection view cell的大小。

@objc(collectionView:layout:sizeForItemAtIndexPath:)
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
    // Calculate the appropriate cell size
    return CGSize(width: ..., height: ...)
}

處理Size Classes以及方向改變

Handle Size Classes and Orientation Changes

We should make sure to correctly refresh the collection view layout when:
我們應當在下面的情況下確保正確地更新:

  • Transitioning to a different Size Class.
    轉化到一個不同的Size Class
  • Rotating the device.
    旋轉設備

This can be achieved by implementing viewWillTransition(to:with:):
可以通過實現 viewWillTransition(to:with:)來達成目標

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    collectionView?.collectionViewLayout.invalidateLayout()
}

動態調整Cell Layout

Dynamically Adjust Cell Layout

In case we need to dynamically adjust the cell layout, we should take care of that by overriding apply(_:) in our custom collection view cell (which is a subclass of UICollectionViewCell):

在需要動態調整cell layout的情況下,我們應該在自定義的custom collection view cell(UICollectionViewCell的子類)中override apply(_:)

override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
    super.apply(layoutAttributes)

    // Customize the cell layout
    [...]
}

For instance, one of the common tasks usually performed inside this method is adjusting the maximum width of a multi-line UILabel, by programmatically setting its preferredMaxLayoutWidth property:

比如,這個方法中一個通用任務是調整多行UILabel的最大寬度,設置 preferredMaxLayoutWidth 屬性。

override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
    super.apply(layoutAttributes)

    // Customize the cell layout
    let width = layoutAttributes.frame.width
    username.preferredMaxLayoutWidth = width - 16
}

結束語

Conclusion

You can find a small sample with the proposed tips forUITableView and UICollectionView here.

你能夠在這里找到示例,UITableView 以及 UICollectionView here.

In this post we examined some common tips to achieve smooth scrolling for both UITableView and UICollectionView. We also presented some specific tips that apply to each specific collection type. Depending on the specific UI requirements, there could be better or different ways to optimize your collection type. However, the basic principles described in this post still apply. And, as usual, the best way to find out which optimizations work best is to profile your app.

這篇文章中我們研究了一些通用的技巧來實現 UITableViewUICollectionView更為流暢的滾動。我們同樣也展示了針對每一種組件的一些特定的技巧。對于你的情況,由于UI交互要求的不同,肯定會有更好或者不同的選擇。然而,文章中基礎原則仍然適用。同樣地,找出優化效果最好的優化方案是分析你的app。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容