Throttle in Swift

Giới thiệu

Trong lập trình nói chung và iOS nói riêng, chúng ta gặp rất nhiều trường hợp cần gọi request server, ví dụ như trong timeline có thể ấn nút like, trong search bar có thể search mỗi khi search text thay đổi. Sẽ là đơn giản nếu như mỗi lần ấn nút like hoặc searchText thay đổi, chúng ta tiến hành call một request lên server. Tuy nhiên, nếu user ấn nút like hoặc thay đổi searchText liên tục, server sẽ bị spam, gây ra những hậu quả không mong muốn. Để khắc phục điều này, chúng ta cùng tìm hiểu về khái niệm throttle trong Swift qua bài viết dưới đây.

Nội dung

Ý tưởng chính là khi user thực hiện một hành động dẫn đến việc phải call request(tap button/change status/change search text) liên tục trong khoảng thời gian rất ngắn thì chúng ta chưa xử lý call request đó ngay, mà sẽ cho hành động đó được delay một khoảng thời gian là t. Nếu sau khoảng thời gian t1(t1<t), user lại tiếp tục thực hiện hành động mới thì hành động trước đó sẽ bị cancel, thời gian delay được tính lại từ hành động mới. Cứ như vậy, nếu người dùng thực hiện liên tiếp nhiều hành động mà khoảng cách thời gian giữa các hành động nhỏ hơn t, thì chỉ hành động cuối cùng được thực hiện. Rõ ràng, nếu thực hiện được điều này, thì thay vì phải thực hiện call n lần request trong một khoảng thời gian ngắn, user chỉ cần call một request cuối cùng, điều này sẽ cải thiện performance rất nhiều cho ứng dụng.

Thực hiện

Để demo ứng dụng, ta tiến hành tạo một ứng dụng tên là ThrottleSearch, code trên Swift 3, Xcode 8.3.3

Bước 1: Setup UI

Tạo một SearchViewController đơn giản là root của NaviagtionController, trong navigationItem, titleView là một searchBar, rightButton dùng để cancel hành động search, setup searchBar.delegate = self

Bước 2: Request

Tạo file APIManager dùng để call request, tuy nhiên, trong trường hợp này vì không có API thật nên chúng ta sẽ fake server, có chức năng parse words của một đoạn văn bản và trả về những từ có chứa kí tự cần search. Để cho giống với server thật, ta tiến hành thực hiện trên một concurrent queue, chạy async_after 0.1s, như sau:

1. Tạo FakeServer

class FakeSever: NSObject {
    static let shared = FakeSever()
    private let dataSource = "Note, the above syntax of adding seconds as a Double seems to be a source of confusion (esp since we were accustomed to adding nsec). That add seconds as Double syntax works because  deadline is a DispatchTime and, behind the scenes, there is a + operator that will take a Double and add that many seconds to the DispatchTime"
    private let queue = DispatchQueue(label: "ConcurrentQueue", attributes: .concurrent)
    
    func getReponse(searchText: String, completion: @escaping (NSError?, [String]) -> Void) {
        queue.asyncAfter(deadline: .now() + 0.1) {[weak self] in
            guard let this = self else {return}
            let words = this.dataSource.components(separatedBy: " ")
            let results = words.filter{$0.contains(searchText.lowercased())}
            completion(nil, results)
        }
    }
}

2. Tạo APIManager

class APIManager: NSObject {
    static let shared = APIManager()
    
    func getRequest(searchText: String, completion: @escaping (NSError?, [String]) -> Void) {
        FakeSever.shared.getReponse(searchText: searchText) { (error, response) in
            completion(error, response)
        }
    }
}

Bước 3: Search

Mỗi khi người dùng thay đổi text của searchBar, func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {...} sẽ được gọi. Trong function này, sẽ tiến hành gọi request của bước 2, tuy nhiên, nếu user thay đổi searchText liên tục, như đã đề cập ở trên thì sẽ bị spam server. Để khắc phục, chúng ta thực hiện như sau:

1. Tách nội dung cần gọi request ra một func riêng

    func search(searchText: String) {
        APIManager.shared.getRequest(searchText: searchText) {[weak self] (error, response) in
            guard error == nil else {
                return
            }
            self?.dataSources = response
            DispatchQueue.main.async {
                self?.tableView.reloadData()
            }
        }
    }

2. Cancel việc gọi function trước đó:

Trong func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {...}, chúng ta tiến hành cancel các func được gọi trước đó như sau:

        NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(search(searchText:)), object: lastSearchText)

Trong đó: target: object chứa function cần gọi' selector: selector tới function call request, trong trường hợp này là func search(searchText: String) {...} object: parameter của function, vì cancel function ngay trước đó, nên chúng ta truyền lastSearchText

3. Call Request

Sau khi đã cancel việc gọi selector liền trước đó, chúng ta tiến hành call selector bằng việc gọi:

        self.perform(#selector(search(searchText:)), with: searchText, afterDelay: 0.3)

selector: selector tới func cần call request with: parameter của func afterDelay: Khoảng thời gian delay để thực hiện call request

Cuối cùng, chúng ta tiến hành update lastSearchText:

        lastSearchText = searchText

Tiến hành build project, thử tính năng mới bằng cách thay đổi searchText liên tục, bạn sẽ thấy APIManager chỉ call request một lần duy nhất, chứng tỏ rằng chúng ta đã thực hiện thành công.

Kết luận

Ngoài cách trên, một số thư việc Reactive Function Programming như RxSwift cũng cung cấp sẵn tính năng throttle một function. Tuy nhiên, trong điều kiện project không cho phép dùng thư viện ngoài, việc thực hiện custom throttle một func như trên vẫn vô cùng cần thiết để tránh spam server. Hy vọng qua bài viết nhỏ này sẽ giúp các bạn thêm kiến thức khi tiến hành thực hiện những hành động cần gọi request nhiều lần, liên tục mà vẫn đảm bảo được performance cho ứng dụng.