如何节流搜索(基于键入速度)在 iOS UISearchBar?

我有一个 UISearchDisplayController 的 UISearchBar 部分,用于显示来自本地 CoreData 和远程 API 的搜索结果。 我想要实现的是在远程 API 上“延迟”搜索。当前,对于用户输入的每个字符,都会发送一个请求。但是如果用户键入得特别快,那么发送许多请求就没有意义了: 等到他停止键入之后再发送请求会有所帮助。 有办法做到吗?

阅读 文件建议等待,直到用户明确点击搜索,但我不认为它在我的情况下理想。

性能问题。如果搜索操作可以非常 快速地,它是可能的更新搜索结果作为用户 类上实现 searchBar: textDidChange: 方法 但是,如果搜索操作需要更多的时间,则 应该等到用户点击 Search 按钮后才开始 在 searchBarSearchButtonClick: 方法中搜索。始终执行 搜索操作的后台线程,以避免阻塞主 这可以让你的应用程序在搜索时对用户做出响应 运行,并提供更好的用户体验。

向 API 发送许多请求并不是本地性能的问题,而只是避免了远程服务器上过高的请求速率。

谢谢

32940 次浏览

Please see the following code which i've found on cocoa controls. They are sending request asynchronously to fetch the data. May be they are getting data from local but you can try it with the remote API. Send async request on remote API in background thread. Follow below link:

https://www.cocoacontrols.com/controls/jcautocompletingsearch

Thanks to this link, I found a very quick and clean approach. Compared to Nirmit's answer it lacks the "loading indicator", however it wins in terms of number of lines of code and does not require additional controls. I first added the dispatch_cancelable_block.h file to my project (from this repo), then defined the following class variable: __block dispatch_cancelable_block_t searchBlock;.

My search code now looks like this:

- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
if (searchBlock != nil) {
//We cancel the currently scheduled block
cancel_block(searchBlock);
}
searchBlock = dispatch_after_delay(searchBlockDelay, ^{
//We "enqueue" this block with a certain delay. It will be canceled if the user types faster than the delay, otherwise it will be executed after the specified delay
[self loadPlacesAutocompleteForInput:searchText];
});
}

Notes:

  • The loadPlacesAutocompleteForInput is part of the LPGoogleFunctions library
  • searchBlockDelay is defined as follows outside of the @implementation:

    static CGFloat searchBlockDelay = 0.2;

A quick hack would be like so:

- (void)textViewDidChange:(UITextView *)textView
{
static NSTimer *timer;
[timer invalidate];
timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(requestNewDataFromServer) userInfo:nil repeats:NO];
}

Every time the text view changes, the timer is invalidated, causing it not to fire. A new timer is created and set to fire after 1 second. The search is only updated after the user stops typing for 1 second.

Try this magic:

- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText{
// to limit network activity, reload half a second after last key press.
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(reload) object:nil];
[self performSelector:@selector(reload) withObject:nil afterDelay:0.5];
}

Swift version:

 func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
// to limit network activity, reload half a second after last key press.
NSObject.cancelPreviousPerformRequestsWithTarget(self, selector: "reload", object: nil)
self.performSelector("reload", withObject: nil, afterDelay: 0.5)
}

Note this example calls a method called reload but you can make it call whatever method you like!

We can use dispatch_source

+ (void)runBlock:(void (^)())block withIdentifier:(NSString *)identifier throttle:(CFTimeInterval)bufferTime {
if (block == NULL || identifier == nil) {
NSAssert(NO, @"Block or identifier must not be nil");
}


dispatch_source_t source = self.mappingsDictionary[identifier];
if (source != nil) {
dispatch_source_cancel(source);
}


source = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
dispatch_source_set_timer(source, dispatch_time(DISPATCH_TIME_NOW, bufferTime * NSEC_PER_SEC), DISPATCH_TIME_FOREVER, 0);
dispatch_source_set_event_handler(source, ^{
block();
dispatch_source_cancel(source);
[self.mappingsDictionary removeObjectForKey:identifier];
});
dispatch_resume(source);


self.mappingsDictionary[identifier] = source;
}

More on Throttling a block execution using GCD

If you're using ReactiveCocoa, consider throttle method on RACSignal

Here is ThrottleHandler in Swift in you're interested

For people who need this in Swift 4 onwards:

Keep it simple with a DispatchWorkItem like here.


or use the old Obj-C way:

func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
// to limit network activity, reload half a second after last key press.
NSObject.cancelPreviousPerformRequestsWithTarget(self, selector: "reload", object: nil)
self.performSelector("reload", withObject: nil, afterDelay: 0.5)
}

EDIT: SWIFT 3 Version

func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
// to limit network activity, reload half a second after last key press.
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.reload), object: nil)
self.perform(#selector(self.reload), with: nil, afterDelay: 0.5)
}
@objc func reload() {
print("Doing things")
}

Swift 2.0 version of the NSTimer solution:

private var searchTimer: NSTimer?


func doMyFilter() {
//perform filter here
}


func searchBar(searchBar: UISearchBar, textDidChange searchText: String) {
if let searchTimer = searchTimer {
searchTimer.invalidate()
}
searchTimer = NSTimer.scheduledTimerWithTimeInterval(0.5, target: self, selector: #selector(MySearchViewController.doMyFilter), userInfo: nil, repeats: false)
}

Improved Swift 4+:

Assuming that you are already conforming to UISearchBarDelegate, this is an improved Swift 4 version of VivienG's answer:

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.reload(_:)), object: searchBar)
perform(#selector(self.reload(_:)), with: searchBar, afterDelay: 0.75)
}


@objc func reload(_ searchBar: UISearchBar) {
guard let query = searchBar.text, query.trimmingCharacters(in: .whitespaces) != "" else {
print("nothing to search")
return
}
    

print(query)
}

The purpose of implementing cancelPreviousPerformRequests(withTarget:) is to prevent the continuous calling to the reload() for each change to the search bar (without adding it, if you typed "abc", reload() will be called three times based on the number of the added characters).

The improvement is: in reload() method has the sender parameter which is the search bar; Thus accessing its text -or any of its method/properties- would be accessible with declaring it as a global property in the class.

Swift 4 solution, plus some general comments:

These are all reasonable approaches, but if you want exemplary autosearch behavior, you really need two separate timers or dispatches.

The ideal behavior is that 1) autosearch is triggered periodically, but 2) not too frequently (because of server load, cellular bandwidth, and the potential to cause UI stutters), and 3) it triggers rapidly as soon as there is a pause in the user's typing.

You can achieve this behavior with one longer-term timer that triggers as soon as editing begins (I suggest 2 seconds) and is allowed to run regardless of later activity, plus one short-term timer (~0.75 seconds) that is reset on every change. The expiration of either timer triggers autosearch and resets both timers.

The net effect is that continuous typing yields autosearches every long-period seconds, but a pause is guaranteed to trigger an autosearch within short-period seconds.

You can implement this behavior very simply with the AutosearchTimer class below. Here's how to use it:

// The closure specifies how to actually do the autosearch
lazy var timer = AutosearchTimer { [weak self] in self?.performSearch() }


// Just call activate() after all user activity
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
timer.activate()
}


func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
performSearch()
}


func performSearch() {
timer.cancel()
// Actual search procedure goes here...
}

The AutosearchTimer handles its own cleanup when freed, so there's no need to worry about that in your own code. But don't give the timer a strong reference to self or you'll create a reference cycle.

The implementation below uses timers, but you can recast it in terms of dispatch operations if you prefer.

// Manage two timers to implement a standard autosearch in the background.
// Firing happens after the short interval if there are no further activations.
// If there is an ongoing stream of activations, firing happens at least
// every long interval.


class AutosearchTimer {


let shortInterval: TimeInterval
let longInterval: TimeInterval
let callback: () -> Void


var shortTimer: Timer?
var longTimer: Timer?


enum Const {
// Auto-search at least this frequently while typing
static let longAutosearchDelay: TimeInterval = 2.0
// Trigger automatically after a pause of this length
static let shortAutosearchDelay: TimeInterval = 0.75
}


init(short: TimeInterval = Const.shortAutosearchDelay,
long: TimeInterval = Const.longAutosearchDelay,
callback: @escaping () -> Void)
{
shortInterval = short
longInterval = long
self.callback = callback
}


func activate() {
shortTimer?.invalidate()
shortTimer = Timer.scheduledTimer(withTimeInterval: shortInterval, repeats: false)
{ [weak self] _ in self?.fire() }
if longTimer == nil {
longTimer = Timer.scheduledTimer(withTimeInterval: longInterval, repeats: false)
{ [weak self] _ in self?.fire() }
}
}


func cancel() {
shortTimer?.invalidate()
longTimer?.invalidate()
shortTimer = nil; longTimer = nil
}


private func fire() {
cancel()
callback()
}


}

You can use DispatchWorkItem with Swift 4.0 or above. It's a lot easier and makes sense.

We can execute the API call when the user hasn't typed for 0.25 second.

class SearchViewController: UIViewController, UISearchBarDelegate {
// We keep track of the pending work item as a property
private var pendingRequestWorkItem: DispatchWorkItem?


func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
// Cancel the currently pending item
pendingRequestWorkItem?.cancel()


// Wrap our request in a work item
let requestWorkItem = DispatchWorkItem { [weak self] in
self?.resultsLoader.loadResults(forQuery: searchText)
}


// Save the new work item and execute it after 250 ms
pendingRequestWorkItem = requestWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250),
execute: requestWorkItem)
}
}

You can read the full article about it from here

  • Disclamer: I am the author.

If you need vanilla Foundation based throttling feature,
If you want just one liner API without going into reactive, combine, timer, NSObject cancel and anything complex,

Throttler can be the right tool to get your job done.

You can use throttling without going reactive as below:

import Throttler


for i in 1...1000 {
Throttler.go {
print("throttle! > \(i)")
}
}


// throttle! > 1000


import UIKit


import Throttler


class ViewController: UIViewController {
@IBOutlet var button: UIButton!
    

var index = 0
    

/********
Assuming your users will tap the button, and
request asyncronous network call 10 times(maybe more?) in a row within very short time nonstop.
*********/
    

@IBAction func click(_ sender: Any) {
print("click1!")
        

Throttler.go {
        

// Imaging this is a time-consuming and resource-heavy task that takes an unknown amount of time!
            

let url = URL(string: "https://jsonplaceholder.typicode.com/todos/1")!
let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
guard let data = data else { return }
self.index += 1
print("click1 : \(self.index) :  \(String(data: data, encoding: .utf8)!)")
}
}
}
    

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
}
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
click1!
2021-02-20 23:16:50.255273-0500 iOSThrottleTest[24776:813744]
click1 : 1 :  {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}

if you want some specific delay seconds:


import Throttler


for i in 1...1000 {
Throttler.go(delay:1.5) {
print("throttle! > \(i)")
}
}


// throttle! > 1000


Swift 5.0

Based on GSnyder response

//
//  AutoSearchManager.swift
//  BTGBankingCommons
//
//  Created by Matheus Gois on 01/10/21.
//


import Foundation




/// Manage two timers to implement a standard auto search in the background.
/// Firing happens after the short interval if there are no further activations.
/// If there is an ongoing stream of activations, firing happens at least every long interval.
public class AutoSearchManager {


// MARK: - Properties


private let shortInterval: TimeInterval
private let longInterval: TimeInterval
private let callback: (Any?) -> Void


private var shortTimer: Timer?
private var longTimer: Timer?


// MARK: - Lifecycle


public init(
short: TimeInterval = Constants.shortAutoSearchDelay,
long: TimeInterval = Constants.longAutoSearchDelay,
callback: @escaping (Any?) -> Void
) {
shortInterval = short
longInterval = long
self.callback = callback
}


// MARK: - Methods


public func activate(_ object: Any? = nil) {
shortTimer?.invalidate()
shortTimer = Timer.scheduledTimer(
withTimeInterval: shortInterval,
repeats: false
) { [weak self] _ in self?.fire(object) }


if longTimer == nil {
longTimer = Timer.scheduledTimer(
withTimeInterval: longInterval,
repeats: false
) { [weak self] _ in self?.fire(object) }
}
}


public func cancel() {
shortTimer?.invalidate()
longTimer?.invalidate()
shortTimer = nil
longTimer = nil
}


// MARK: - Private methods


private func fire(_ object: Any? = nil) {
cancel()
callback(object)
}
}


// MARK: - Constants


extension AutoSearchManager {
public enum Constants {
/// Auto-search at least this frequently while typing
public static let longAutoSearchDelay: TimeInterval = 2.0
/// Trigger automatically after a pause of this length
public static let shortAutoSearchDelay: TimeInterval = 0.75
}
}