您的当前位置:首页正文

【翻译】UITableView和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的主要交互可以用下面的事件来描述:

  • The table view is requesting the cell that needs to be displayed
    table view请求将要被展示的cell().
  • The table view is about to display the cell
    table view将要展示cell().
  • The cell has been removed from the table view
    cell要从table view里面取出().

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的生命周期的可视化:


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
}

定义Cell的View Model

Define the View Model for the Cells

一个方法是让所有需要显示的属性随时可用,只要赋值给对应的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,放置在.
  • Every time the table view is displayed, by placing it in
    每次table view显示,放置在.
  • 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**

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,我们能够甚至能够通过缓存图片来改善性能。

自定义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

override func viewDidLoad() {
   [...]
   tableView.estimatedRowHeight = ... // Estimated default row height
   tableView.rowHeight = UITableViewAutomaticDimension
}
  • Pre-calculating all the row heights at once.
    提前一次性算好所有行的高度
  • Return the cached value when is called.
    当 被调用时返回存储好的高度

UICollectionView的专属小技巧

Tips Specific to UICollectionView

计算你的Cell Size

Calculate your Cell Size

@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.
    旋转设备
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    collectionView?.collectionViewLayout.invalidateLayout()
}

动态调整Cell Layout

Dynamically Adjust Cell Layout

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

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

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

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

结束语

Conclusion

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。