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的主要交互可以用下面的事件来描述:
- The table view is requesting the cell that needs to be displayed
table view请求将要被展示的cell(tableView(_:cellForRowAt:)). - The table view is about to display the cell
table view将要展示cell(tableView(_:willDisplay:forRowAt:)). - The cell has been removed from the table view
cell要从table view里面取出(tableView(_:didEndDisplaying:forRowAt:)).
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组件正确伸缩,然后我们需要正确初始化 estimatedRowHeight 和 rowHeight属性。
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的高度。此时就需要通过下面的方式来改变滚动性能。
- Pre-calculating all the row heights at once.
提前一次性算好所有行的高度 - Return the cached value when tableView(_:heightForRowAt:) is called.
当 tableView(_:heightForRowAt:)被调用时返回存储好的高度
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.
这篇文章中我们研究了一些通用的技巧来实现 UITableView 和 UICollectionView更为流畅的滚动。我们同样也展示了针对每一种组件的一些特定的技巧。对于你的情况,由于UI交互要求的不同,肯定会有更好或者不同的选择。然而,文章中基础原则仍然适用。同样地,找出优化效果最好的优化方案是分析你的app。