Như các bạn đã biết thì hiển thị một tập hợp các dữ liệu là một trong những task phổ biến nhất trong quá trình xây dựng một ứng dụng. Apple SDK đã cung cấp cho chúng ta 2 công cụ để làm việc này, đó là UITableView và UICollectionView. 

Table view và collection view đều được thiết kế để hỗ trợ việc hiển thị dữ liệu mà có thể cuộn được. Tuy nhiên khi khối lượng dữ liệu cần hiển thị là rất lớn thì chúng ta còn cần phải đảm bảo việc mượt mà trong các thao tác vuốt, cuộn nữa. Bài viết này dựa trên kinh nghiệm của riêng tôi với table view và collection view trong việc hiển thị dữ liệu lớn một cách mượt mà. 

Đầu tiên chúng ta hãy cùng nhau nhìn lướt qua 2 component trên để nắm được những thành phần cơ bản của chúng. UITableView được tối ưu để hiển thị dữ liệu theo các dòng - row hay còn gọi là cell. Việc hiển thị dữ liệu được thực hiện qua các delegates.

UICollectionView thì linh hoạt hơn, chúng ta có thể tùy chỉnh bố cục, layout cho các thành phần được hiển thị trong đó. Tuy nhiên, để có được sự linh hoạt đó thì chúng ta cũng phải thực hiện nhiều tác vụ chi tiết mà vẫn đảm bảo được performance của ứng dụng.
Ở trong bài viết này, tôi sẽ áp dụng phương pháp cho TableView, và tất nhiên các bạn cũng có thể áp dụng nó cho Collection View

Sự tương tác giữa UITableView và UITableViewCell được mô tả qua những event sau:

Trong tất cả những event trên, Table View sẽ truyền vào một giá trị index (row) tương ứng với từng dòng dữ liệu. Dưới đây là mô tả một vòng đời của một đối tượng UITableViewCell:

Đầu tiên, method  tableView(_:cellForRowAt:) sẽ cần phải thực thi càng nhanh càng tốt. Method này được gọi mỗi lần một cell chuẩn bị được hiển thị, tốc độ thực thi càng nhanh thì tác vụ cuộn hay vuốt sẽ càng trở nên mượt mà.

Để làm được việc này thì chúng ta có thể làm một vài thao tác theo chỉ dẫn của tài liệu Apple như sau:


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
}

Sau khi khai báo một cell instance có thể tái sử dụng được (dequeueReusableCell(withIdentifier:for:)), chúng ta cần phải config nó bằng việc gán những giá trị cần thiết cho các property của nó.

Tham khảo các khóa học lập trình online, onlab, và thực tập lập trình tại TechMaster

Khởi tạo View Model cho đối tượng Cell

Có một cách để tất cả những property mà chúng ta cần hiển thị trở nên dễ dàng truy cập và gán giá trị vào hơn đó là sử dụng mô hình  MVVM. Giả dụ rằng khi chúng ta cần hiển thị một tập hợp những user trong table view thì có thể định nghĩa lớp Model cho user như sau:

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
    }
}

Sau đó chúng ta định nghĩa View Model cho User như sau:

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
    }
}

Đổ dữ liệu theo cách bất đồng bộ và Cache View Models:

Sau khi chúng ta đã khai báo Model và View Model, hãy dung chúng để đổ dữ liệu cho user qua web service, và tất nhiên chúng ta muốn mang đến trải nghiệm tốt nhất có thể, do đó chúng ta phải lưu ý những thứ sau:

  • Tránh block main thread trong khi đổ dữ liệu.
  • Update table View ngay sau khi nhận dữ liệu về.

Điều này có nghĩa rằng chúng ta sẽ đổ dữ liệu một theo hướng bất đồng bộ. Task này sẽ được thực thi qua một controller riêng biệt để tách những logic của việc đổ dữ liệu ra khỏi Model và ViewModel :

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
            }
        }
    }

}

Bây giờ chúng ta có thể nhận dữ liệu và update tableView theo hướng bất đồng bộ như sau:

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()
                }
            }
        }
    }

    [...]

}

Chúng ta có thể dùng đoạn mã trên để truyền dữ liệu theo nhiều cách khác nhau:

  • Đặt vào viewDidLoad(). khi chúng ta load table view một lần duy nhất.
  • Đặt vào viewWillAppear(_:). khi tableView cần phải load nhiều lần.
  • Còn lại tùy theo yêu cầu của người dùng, chúng ta có thể đặt khối lệnh này ở trong method thực thi yêu cầu đó. Ví dụ như kéo xuống để refesh..vv

Load ảnh bất đồng bộ và lưu lại cache

Việc load và hiển thị ảnh ở trong cell là rất phổ biến, và để cho ra một tác vụ mượt mà nhất có thể, chúng ta hiển nhiên không muốn block main thread để tải ảnh. Có một cách để load ảnh bất đồng bộ đó là tạo một lớp wrapper qua 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()
    }

}

Ở đay chúng ta sẽ load từng tấm ảnh tại background thread và chỉ update UI khi mà đã có đủ data cần có. Bên cạnh đó chúng ta cũng có thể sử dụng những thư viện như SDWebImage hay AlamofireImage.

Tùy chỉnh Cell

Để tận dụng hoàn toàn những lợi ích từ View Model, chúng ta có thể tùy chỉnh User cell bằng cách subclass nó ( từ UITableViewCell cho TableView và từ UICollectionViewCell cho collection view). Hướng tiếp cận ở đây đó là tạo một outlet cho từng property của Model mà cần được hiển thị và khởi tạo chúng từ 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
    }
    
}

Sử dụng Opaque Layers và tránh dùng Gradients

Sử dụng layer trong suốt hay kiểu Gradients đòi hỏi một khối lượng tính toán lớn, do vậy cũng sẽ ảnh hưởng đến performance. Vì thế nếu có thể, chúng ta nên tránh sử dụng chúng, cũ thể chúng ta có thể dùng màu RGB, ví dụ như 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
    }

Sau khi chỉnh sửa xong, chúng ta tổng hợp tất cả các thứ lại như sau:

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
}

Tổng kết

Như vậy là qua bài viết trên, chúng ta đã có thể tìm ra một trong những ccách làm tối ưu performance cho tableView, collectionView nói riêng và ứng dụng của chúng ta nói chung. Bạn có thể download ví dụ trên tại đây.

Nguồn bài viết: https://medium.com/capital-one-developers/smooth-scrolling-in-uitableview-and-uicollectionview-a012045d77f#.w3beenys6