如何在 iOS UISearchBar 中限制搜索(基于打字速度)?

Posted

技术标签:

【中文标题】如何在 iOS UISearchBar 中限制搜索(基于打字速度)?【英文标题】:How to throttle search (based on typing speed) in iOS UISearchBar? 【发布时间】:2014-06-20 14:50:05 【问题描述】:

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

阅读documentation 建议等到用户明确点击搜索,但我认为这并不理想。

性能问题。如果搜索操作可以很 可以快速地更新搜索结果,因为用户是 通过实现 searchBar:textDidChange: 方法在 委托对象。但是,如果搜索操作需要更多时间,您 应该等到用户点击搜索按钮后才开始 在 searchBarSearchButtonClicked: 方法中搜索。始终执行 搜索操作后台线程以避免阻塞主线程 线。这可以让您的应用在搜索时响应用户 运行并提供更好的用户体验。

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

谢谢

【问题讨论】:

我不确定标题是否正确。您要求的是“去抖动”而不是“节流”。 【参考方案1】:

试试这个魔法:

- (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 版本:

 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)
 

注意这个例子调用了一个名为 reload 的方法,但是你可以让它调用任何你喜欢的方法!

【讨论】:

这很好用...不知道 cancelPreviousPerformRequestsWithTarget 方法! 不客气!这是一个很棒的模式,可以用于各种事情。 非常有用!这才是真正的巫术 关于“重新加载”...我不得不再考虑几秒钟...这是指本地方法,它会在用户停止输入后实际执行您想要执行的操作0.5 秒。该方法可以随心所欲地调用,例如 searchExecute。谢谢! 这对我不起作用......每次更改字母时它都会继续运行“重新加载”功能【参考方案2】:

对于 Swift 4 以后需要此功能的人

使用DispatchWorkItem 保持简单,例如here。


或者使用旧的 Obj-C 方式:

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)

编辑:SWIFT 3 版本

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")

【讨论】:

好答案!我只是对其进行了一点改进,您可以check it out :) 感谢@AhmadF,我正在考虑进行 SWIFT 4 更新。你做到了! :D 对于 Swift 4,使用DispatchWorkItem,如上所述。它比选择器更优雅。【参考方案3】:

改进的 Swift 4+:

假设您已经符合UISearchBarDelegate,这是VivienG's answer 的改进 Swift 4 版本:

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)

实现cancelPreviousPerformRequests(withTarget:)的目的是为了防止搜索栏的每次变化都连续调用reload()(不加的话,如果你输入“abc”,reload()会根据基础被调用3次添加的字符数)。

改进是:在reload()方法中有sender参数,即搜索栏;因此,通过将其声明为类中的全局属性,可以访问其文本(或其任何方法/属性)。

【讨论】:

对我很有帮助,用选择器中的搜索栏对象解析 我刚刚在 OBJC 中尝试过 - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(validateText:) object:searchBar]; [self performSelector:@selector(validateText:) withObject:searchBar afterDelay:0.5]; 【参考方案4】:

感谢this link,我找到了一种非常快速和干净的方法。与 Nirmit 的答案相比,它缺少“加载指示器”,但是它在代码行数方面胜出,并且不需要额外的控制。我首先将dispatch_cancelable_block.h 文件添加到我的项目中(来自this repo),然后定义了以下类变量:__block dispatch_cancelable_block_t searchBlock;

我的搜索代码现在看起来像这样:

- (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]; 
    );

注意事项:

loadPlacesAutocompleteForInput 是LPGoogleFunctions 库的一部分

searchBlockDelay@implementation之外定义如下:

静态 CGFloat searchBlockDelay = 0.2;

【讨论】:

博客文章的链接对我来说似乎已死 @jeroen 你是对的:不幸的是,作者似乎从他的网站上删除了该博客。 GitHub 上引用该博客的存储库仍在运行,因此您可能需要在此处检查代码:github.com/SebastienThiebaud/dispatch_cancelable_block searchBlock 内部的代码永远不会执行。是否需要更多代码?【参考方案5】:

快速破解如下:

- (void)textViewDidChange:(UITextView *)textView

    static NSTimer *timer;
    [timer invalidate];
    timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(requestNewDataFromServer) userInfo:nil repeats:NO];

每次文本视图更改时,计时器都会失效,导致它不会触发。创建一个新的计时器并设置为在 1 秒后触发。搜索仅在用户停止输入 1 秒后更新。

【讨论】:

看起来我们有相同的方法,而且这个甚至不需要额外的代码。虽然requestNewDataFromServer方法需要修改才能从userInfo获取参数 是的,根据您的需要进行修改。概念是一样的。 由于在这种方法中从未触发计时器,我发现这里缺少一行: [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode]; @itinance 你是什么意思?使用代码中的方法创建计时器时,计时器已经在当前运行循环中。 这是一个快速而简洁的解决方案。您也可以在您的其他网络请求中使用它,例如在我的情况下,每次用户拖动他/她的地图时,我都会获取新数据。请注意,在 Swift 中,您需要通过调用 scheduledTimer... 来实例化计时器对象。【参考方案6】:

Swift 4 解决方案,以及一些通用的 cmets:

这些都是合理的方法,但是如果您想要示范性的自动搜索行为,您确实需要两个单独的计时器或调度。

理想的行为是 1) 定期触发自动搜索,但 2) 不会太频繁(因为服务器负载、蜂窝带宽以及可能导致 UI 卡顿),以及 3) 一旦有用户输入暂停。

您可以使用一个较长时间的计时器来实现此行为,该计时器在编辑开始后立即触发(我建议 2 秒)并且无论以后的活动如何都允许运行,再加上一个短期计时器(约 0.75 秒),即在每次更改时重置。任何一个计时器到期都会触发自动搜索并重置两个计时器。

最终的结果是,连续键入会在较长的几秒内产生一次自动搜索,但保证在短时间内会触发一次自动搜索。

您可以使用下面的 AutosearchTimer 类非常简单地实现此行为。使用方法如下:

// 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...

AutosearchTimer 在释放时会自行处理清理工作,因此您无需在自己的代码中担心这一点。但是不要给计时器一个对 self 的强引用,否则你会创建一个引用循环。

下面的实现使用了计时器,但如果您愿意,您可以根据调度操作对其进行重铸。

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


【讨论】:

【参考方案7】:

请查看我在可可控件上找到的以下代码。他们正在异步发送请求以获取数据。可能他们正在从本地获取数据,但您可以使用远程 API 进行尝试。在后台线程中向远程 API 发送异步请求。请点击以下链接:

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

【讨论】:

嗨!我终于有时间看看你建议的控制。这绝对很有趣,我毫不怀疑许多人会从中受益。但是,我认为我从这篇博文中找到了一个更短(并且在我看来更简洁)的解决方案,这要归功于您的链接中的一些灵感:sebastienthiebaud.us/blog/ios/gcd/block/2014/04/09/… @maggix 您提供的链接现已过期。你能建议任何其他链接吗? 我正在更新此线程中的所有链接。使用下面我的答案中的一个 (github.com/SebastienThiebaud/dispatch_cancelable_block) 如果您使用的是 Google 地图,请查看此内容。这与 iOS 8 兼容并用 Objective-C 编写。 github.com/hkellaway/HNKGooglePlacesAutocomplete【参考方案8】:

我们可以使用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;

更多关于Throttling a block execution using GCD

如果您使用的是ReactiveCocoa,请考虑RACSignal 上的throttle 方法

这里是ThrottleHandler in Swift,你有兴趣

【讨论】:

我觉得github.com/SebastienThiebaud/dispatch_cancelable_block/blob/… 也很有用【参考方案9】:

Swift 2.0 版本的 NSTimer 解决方案:

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)

【讨论】:

【参考方案10】:

您可以在 Swift 4.0 或更高版本中使用DispatchWorkItem。这更容易,也很有意义。

我们可以在用户 0.25 秒内没有输入时执行 API 调用。

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)


您可以从here阅读有关它的完整文章

【讨论】:

【参考方案11】: 免责声明:我是作者。

如果您需要基于香草基础的节流功能, 如果您只想要一个线性 API,而不需要涉及响应式、组合、计时器、NSObject 取消和任何复杂的东西,

Throttler 是完成工作的正确工具。

您可以使用节流而不进行响应,如下所示:

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

如果你想要一些特定的延迟秒数:


import Throttler

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


// throttle! > 1000

【讨论】:

【参考方案12】:

斯威夫特 5.0

基于GSnyder 响应

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


【讨论】:

以上是关于如何在 iOS UISearchBar 中限制搜索(基于打字速度)?的主要内容,如果未能解决你的问题,请参考以下文章

ios 7中yelp和foursquare如何将UISearchBar中的搜索图标保持在左侧

如何在 iOS 中为 UISearchBar 设置最近的搜索历史下拉列表

如何在 iOS 10 的目标 c 中使用 uisearchbar?

iOS7 中 UISearchBar 和 UITableView 的问题

如何在iOS上更改UISearchBar组件的内部背景颜色

UISearchBar 搜索图标在 iOS7 中没有左对齐