前言 正如大多数iOS开发者所知,显示数据集是构建移动应用程序的一个相当常见的任务。苹果的SDK提供了两个组件来帮助完成这样的任务,而不必从头开始实现所有内容:一个表视图(UITableView
)和一个集合视图(UICollectionView
)。
表视图和集合视图都被设计为支持显示可滚动的数据集。然而,当显示大量数据时,实现完美的平滑滚动可能会非常棘手。这并不理想,因为它会对用户体验产生负面影响。
作为Capital One Mobile
应用iOS开发团队的一员,我有机会尝试表格视图和集合视图;这篇文章反映了我个人在显示大量可滚动数据方面的经验。在本文中,我们将回顾优化上述SDK组件性能的最重要技巧。这一步对于实现流畅的滚动体验至关重要。请注意,下面的大多数要点都适用于UITableView
和UICollectionView
,因为它们共享了大量的“底层”行为。有几点是UICollectionView
特有的,因为这个视图把额外的布局细节放在了开发人员的肩上。
让我们从快速概述上述组件开始。UITableView
被优化为将视图显示为行序列。由于布局是预定义的,SDK组件负责大部分布局,并提供主要关注于显示单元格内容的委托。 另一方面,UICollectionView
提供了最大的灵活性,因为布局是完全可定制的。然而,集合视图的灵活性是以必须考虑如何执行布局的额外细节为代价的。
UITableView和UICollectionView的共同技巧
我将使用UITableView作为我的代码片段。但同样的概念也适用于UICollectionView。
Cells渲染是一项关键任务 UITableView
和UITableViewCell
之间的主要交互可以用以下事件来描述:
表视图正在请求需要显示的单元格(tableView(_:cellForRowAt:)
)。
表视图即将显示单元格(tableView(_:willDisplay:forRowAt:)
)。
单元格已经从表视图中移除(tableView(_: didenddisplay:forRowAt:)
)。
对于上述所有事件,表视图将传递正在进行交互的index(row)。这是UITableViewCell
生命周期的可视化:
首先,tableView(_:cellForRowAt:)
方法应该尽可能快。每次需要显示单元格时调用此方法。它执行得越快,表视图的滚动就会越平滑。
有一些事情我们可以做,以确保我们渲染cell尽可能快。下面是渲染单元格的基本代码,摘自苹果的文档:
1 2 3 4 5 6 7 8 override func tableView (_ tableView : UITableView , cellForRowAt indexPath : IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier" , for: indexPath) return cell }
在获取即将被重用的单元格实例(dequeueReusableCell(withIdentifier:for:)
)之后,我们需要通过为其属性分配所需的值来配置它。让我们看一下如何使代码快速执行。
为单元格定义视图模型 一种方法是让我们需要展示的所有属性都随时可用然后把它们分配给相应的单元格。为了实现这一点,我们可以利用MVVM
模式。假设我们需要在表视图中显示一组用户。我们可以将用户模型定义为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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 } }
为用户定义视图模型很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 struct UserViewModel { let avatarUrl: String let username: String let role: Role let roleText: String init (user : User ) { avatarUrl = user.avatarUrl username = user.username role = user.role roleText = user.role.rawValue } }
异步获取数据和缓存视图模型 现在我们已经定义了模型和视图模型,让我们让它们工作吧!我们将通过web服务为用户获取数据。当然,我们希望实现最好的用户体验。因此,我们将关注以下方面:
在获取数据时避免阻塞主线程。
在检索数据后立即更新表视图。
这意味着我们将异步获取数据。我们将通过一个特定的控制器来执行这个任务,以便将获取逻辑与模型和视图模型分开,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 class UserViewModelController { fileprivate var viewModels: [UserViewModel ?] = [] func retrieveUsers (_ completionBlock : @escaping (_ success: Bool , _ error: NSError ?) -> ()) { let urlString = ... 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 = ... 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 } } } }
现在我们可以检索数据并异步更新表视图,如下面的代码片段所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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() } } } } [... ] }
我们可以使用上面的代码片段以几种不同的方式获取用户数据:
只有在第一次加载表视图时,通过将其放置在viewDidLoad()
中。
每次显示表视图时,通过将其放置在viewWillAppear(_:)
中。
根据用户需求(例如通过下拉刷新),将其放置在负责刷新数据的方法调用中。
选择取决于后端数据更改的频率。如果数据大部分是静态的或不经常更改,则第一种选择更好。否则,我们应该选择第二种。
异步加载图像并缓存它们 为单元格加载图像是很常见的。由于我们试图获得最佳的滚动性能,我们绝对不想阻塞主线程来获取图像。避免这种情况的一个简单方法是通过在URLSession
周围创建一个简单的包装器来异步加载图像:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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() } }
这让我们可以使用后台线程获取每个图像,然后在所需数据可用时更新UI。我们可以通过缓存图像进一步提高性能。
如果我们不想——或者负担不起——自己编写自定义的异步图像下载和缓存,我们可以利用像SDWebImage
或AlamofireImage
这样的库。这些库提供了我们正在寻找的开箱即用的功能。
自定义单元格 为了充分利用缓存的视图模型,我们可以自定义用户单元格的子类(从UITableViewCell
表视图和从UICollectionViewCell
集合视图)。基本的方法是为需要显示的模型的每个属性创建一个出口,并从视图模型中初始化它:
1 2 3 4 5 6 7 8 9 10 11 12 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 } }
使用不透明图层,避免渐变 由于使用透明层或应用渐变需要大量的计算,如果可能的话,我们应该避免使用它们来提高滚动性能。特别是,我们应该避免改变alpha
值,最好使用标准的RGB颜色(避免uiccolor .clear
)用于单元格和它包含的任何图像:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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渲染 在这一点上,在渲染单元格的时候配置它应该很容易,而且非常快,因为:
我们正在使用缓存的视图模型数据。
我们正在异步获取图像。
下面是更新后的代码:
1 2 3 4 5 6 7 8 9 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的特别技巧 对可变高度的单元格使用自调整大小(Self-Sizing)的单元格 如果我们想要在表格视图中显示的单元格具有可变高度,我们可以使用self sizable cells
。基本上,我们应该创建适当的Auto Layout约束,以确保具有可变高度的UI组件能够正确拉伸。然后我们只需要初始化estimatedRowHeight
和rowHeight
属性:
1 2 3 4 5 override func viewDidLoad () { [... ] tableView.estimatedRowHeight = ... tableView.rowHeight = UITableViewAutomaticDimension }
主意:在不幸的情况下,我们不能使用self-sizing cells
(例如,如果支持iOS7仍然是必需的),我们必须实现tableView(_:heightForRowAt:)
来计算每个单元格的高度。但是,仍然可以通过以下方式提高滚动性能:
一次预先计算所有的行高。
当tableView(_:heightForRowAt:)
被调用时返回缓存的值。
UICollectionView的特别技巧 通过实现适当的UICollectionViewFlowLayoutDelegate
协议方法,我们可以轻松地定制大部分集合视图。
计算单元格大小 我们可以通过实现collectionView(_:layout:sizeForItemAt:)
来定制集合视图的单元格大小:
1 2 3 4 5 @objc (collectionView:layout:sizeForItemAtIndexPath:)func collectionView (_ collectionView : UICollectionView , layout collectionViewLayout : UICollectionViewLayout , sizeForItemAt indexPath : IndexPath ) -> CGSize return CGSize (width: ... , height: ... ) }
处理Size Classes和Orientation的改变 我们应该确保在以下情况下正确刷新集合视图布局:
这可以通过实现viewWillTransition(to:with:)
来实现:
1 2 3 4 override func viewWillTransition (to size : CGSize , with coordinator : UIViewControllerTransitionCoordinator ) { super .viewWillTransition(to: size, with: coordinator) collectionView? .collectionViewLayout.invalidateLayout() }
动态调整单元格布局 如果我们需要动态调整单元格布局,我们应该通过在我们的自定义集合视图单元格(它是UICollectionViewCell
的子类)中重写apply(_:)
来处理这个问题:
1 2 3 4 5 6 override func apply (_ layoutAttributes : UICollectionViewLayoutAttributes ) { super .apply(layoutAttributes) [... ] }
例如,在这个方法中执行的常见任务之一是通过编程设置其preferredmaxlayoutidth
属性来调整多行UILabel
的最大宽度:
1 2 3 4 5 6 7 override func apply (_ layoutAttributes : UICollectionViewLayoutAttributes ) { super .apply(layoutAttributes) let width = layoutAttributes.frame.width username.preferredMaxLayoutWidth = width - 16 }
总结 你可以在这里 找到一个关于uitableview
和UICollectionView
建议技巧的小示例。
在这篇文章中,我们研究了一些常见的技巧来实现UITableView
和UICollectionView
的平滑滚动。我们还介绍了一些适用于每种特定集合类型的特定技巧。根据特定的UI需求,可能有更好或不同的方法来优化集合类型。然而,这篇文章中描述的基本原则仍然适用。与往常一样,找出哪种优化最有效的最佳方法是分析你的应用。
参考
Smooth Scrolling in UITableView and UICollectionView
有道翻译