Jak ograniczyć wyszukiwanie (na podstawie prędkości pisania) w iOS UISearchBar?

80

Mam część UISearchBar elementu UISearchDisplayController, która służy do wyświetlania wyników wyszukiwania zarówno z lokalnego CoreData, jak i zdalnego interfejsu API. Chcę osiągnąć „opóźnienie” wyszukiwania w zdalnym API. Obecnie dla każdego wpisanego przez użytkownika znaku wysyłane jest żądanie. Ale jeśli użytkownik pisze szczególnie szybko, wysyłanie wielu żądań nie ma sensu: pomogłoby poczekać, aż przestanie pisać. Czy jest na to sposób?

Czytanie dokumentacji sugeruje zaczekanie, aż użytkownicy wyraźnie dotkną wyszukiwania, ale nie uważam tego za idealne w moim przypadku.

Problemy z wydajnością. Jeśli operacje wyszukiwania mogą być wykonywane bardzo szybko, możliwe jest aktualizowanie wyników wyszukiwania w trakcie wpisywania przez użytkownika poprzez zaimplementowanie metody searchBar: textDidChange: w obiekcie delegata. Jeśli jednak operacja wyszukiwania zajmuje więcej czasu, przed rozpoczęciem wyszukiwania w metodzie searchBarSearchButtonClicked: należy poczekać, aż użytkownik naciśnie przycisk Wyszukaj. Zawsze wykonuj operacje wyszukiwania w wątku w tle, aby uniknąć blokowania wątku głównego. Dzięki temu Twoja aplikacja reaguje na użytkownika podczas wyszukiwania i zapewnia lepsze wrażenia użytkownika.

Wysyłanie wielu żądań do API nie jest problemem z lokalną wydajnością, a jedynie z unikaniem zbyt dużej szybkości żądań na zdalnym serwerze.

Dzięki

maggix
źródło
1
Nie jestem pewien, czy tytuł jest poprawny. To, o co prosisz, nazywa się „odbiciem”, a nie „dławieniem”.
V_tredue

Odpowiedzi:

132

Spróbuj tej magii:

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

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

Zwróć uwagę, że ten przykład wywołuje metodę o nazwie reload, ale możesz sprawić, by wywoływała dowolną metodę!

malhal
źródło
to działa świetnie ... nie wiedziałem o metodzie cancelPreviousPerformRequestsWithTarget!
jesses.co.tt
nie ma za co! To świetny wzór i może być używany do różnych rzeczy.
malhal,
Tak bardzo przydatne! To jest prawdziwe voodoo
Matteo Pacini
2
Odnośnie „przeładowania” ... musiałem się nad tym zastanowić przez kilka dodatkowych sekund ... To odnosi się do metody lokalnej, która faktycznie wykona to, co chcesz zrobić, gdy użytkownik przestanie pisać na 0,5 sekundy. Metodę można nazwać, jak chcesz, na przykład searchExecute. Dzięki!
blalond
to nie działa dla mnie ... nadal uruchamia funkcję "przeładuj" za każdym razem, gdy zmieniana jest litera
Andrey
52

Dla osób, które potrzebują tego w Swift 4 i nowszych :

Zachowaj prostotę z DispatchWorkItempolubieniem tutaj .


lub użyj starego sposobu 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)
}

EDYCJA: Wersja 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)
}
func reload() {
    print("Doing things")
}
VivienG
źródło
1
Dobra odpowiedź! Dopiero co dodałem trochę ulepszeń, możesz to sprawdzić :)
Ahmad F,
Dzięki @AhmadF, myślałem o aktualizacji SWIFT 4. Zrobiłeś to! : D
VivienG,
1
W przypadku Swift 4 użyj DispatchWorkItemzgodnie z pierwszą sugestią powyżej. Działa elegancko niż selektory.
Teffi
21

Ulepszony Swift 4:

Zakładając, że już się dostosowujesz UISearchBarDelegate, jest to ulepszona wersja odpowiedzi VivienG w wersji 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)
}

Celem implementacji cancelPreviousPerformRequests (withTarget :) jest zapobieżenie ciągłemu wywoływaniu reload()dla każdej zmiany w pasku wyszukiwania (bez jego dodawania, jeśli wpiszesz "abc", reload()zostanie wywołane trzykrotnie na podstawie liczby dodanych znaków) .

Poprawa jest: w reload()sposób ma parametr nadawcy czyli pasek wyszukiwania; W ten sposób dostęp do jego tekstu - lub dowolnej metody / właściwości - byłby dostępny po zadeklarowaniu go jako właściwości globalnej w klasie.

Ahmad F.
źródło
Jest to dla mnie bardzo pomocne, parsowanie z obiektem paska wyszukiwania w selektorze
Hari Narayanan
Właśnie próbowałem w OBJC - (void) searchBar: (UISearchBar *) searchBar textDidChange: (NSString *) searchText {[NSObject cancelPreviousPerformRequestsWithTarget: self selector: @selector (validateText :) obiekt: searchBar]; [self performSelector: @selector (validateText :) withObject: searchBar afterDelay: 0.5]; }
Hari Narayanan
18

Dzięki temu linkowi znalazłem bardzo szybkie i czyste podejście. W porównaniu z odpowiedzią Nirmit nie ma „wskaźnika ładowania”, jednak wygrywa pod względem liczby wierszy kodu i nie wymaga dodatkowych kontroli. Najpierw dodałem dispatch_cancelable_block.hplik do mojego projektu (z tego repozytorium ), a następnie zdefiniowałem następującą zmienną klasy:__block dispatch_cancelable_block_t searchBlock; .

Mój kod wyszukiwania wygląda teraz tak:

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

Uwagi:

  • loadPlacesAutocompleteForInputJest częścią LPGoogleFunctions biblioteki
  • searchBlockDelay jest zdefiniowany w następujący sposób poza @implementation :

    static CGFloat searchBlockDelay = 0,2;

maggix
źródło
1
Link do wpisu na blogu wydaje mi się martwy
jeroen
1
@jeroen masz rację: niestety wygląda na to, że autor usunął bloga ze swojej strony internetowej. Repozytorium na GitHubie, które odnosiło się do tego bloga, wciąż działa, więc możesz sprawdzić kod tutaj: github.com/SebastienThiebaud/dispatch_cancelable_block
maggix
kod wewnątrz searchBlock nigdy nie jest wykonywany. Czy potrzeba więcej kodu?
itinance
12

Szybki hack wyglądałby tak:

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

Za każdym razem, gdy zmienia się widok tekstu, licznik czasu jest unieważniany, co powoduje, że nie uruchamia się. Tworzony jest nowy zegar, który uruchamia się po 1 sekundzie. Wyszukiwanie jest aktualizowane tylko wtedy, gdy użytkownik przestanie pisać na 1 sekundę.

duci9y
źródło
Wygląda na to, że mieliśmy takie samo podejście, a to nie wymaga nawet dodatkowego kodu. Chociaż requestNewDataFromServermetoda wymaga modyfikacji, aby uzyskać parametr zuserInfo
maggix,
Tak, zmodyfikuj go według swoich potrzeb. Koncepcja jest taka sama.
duci9y
3
ponieważ licznik czasu nigdy nie jest uruchamiany w tym podejściu, zorientowałem się, że brakuje tutaj jednej linii: [[NSRunLoop mainRunLoop] addTimer: timer forMode: NSDefaultRunLoopMode];
itinance
@itinance Co masz na myśli? Licznik czasu znajduje się już w bieżącej pętli uruchamiania, gdy tworzysz go za pomocą metody w kodzie.
duci9y
To szybkie i zgrabne rozwiązanie. Możesz również użyć tego w innych żądaniach sieciowych, tak jak w mojej sytuacji, pobieram nowe dane za każdym razem, gdy użytkownik przeciąga swoją mapę. Zwróć uwagę, że w Swift będziesz chciał utworzyć wystąpienie obiektu timera, wywołując plik scheduledTimer....
Glenn Posadas
5

Rozwiązanie Swift 4 oraz kilka ogólnych uwag:

Są to wszystkie rozsądne podejścia, ale jeśli chcesz wzorowego zachowania automatycznego wyszukiwania, naprawdę potrzebujesz dwóch oddzielnych liczników czasu lub komunikatów.

Idealnym zachowaniem jest to, że 1) automatyczne wyszukiwanie jest uruchamiane okresowo, ale 2) niezbyt często (ze względu na obciążenie serwera, przepustowość sieci komórkowej i możliwość powodowania zacinania się interfejsu użytkownika) oraz 3) uruchamia się szybko, gdy tylko nastąpi przerwa w wpisywanie przez użytkownika.

Możesz osiągnąć to zachowanie za pomocą jednego długoterminowego timera, który uruchamia się zaraz po rozpoczęciu edycji (sugeruję 2 sekundy) i może działać niezależnie od późniejszej aktywności, plus jeden krótkoterminowy zegar (~ 0,75 sekundy), który jest resetowany co zmiana. Wygaśnięcie któregokolwiek z liczników uruchamia automatyczne wyszukiwanie i resetuje oba liczniki.

Efektem netto jest to, że ciągłe wpisywanie powoduje automatyczne przeszukiwanie co długie sekundy, ale pauza gwarantuje uruchomienie automatycznego wyszukiwania w ciągu krótkich sekund.

Możesz zaimplementować to zachowanie w bardzo prosty sposób za pomocą poniższej klasy AutosearchTimer. Oto jak z niego korzystać:

// 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 obsługuje własne czyszczenie po zwolnieniu, więc nie musisz się o to martwić w swoim własnym kodzie. Ale nie podawaj timera silnego odniesienia do siebie, bo utworzysz cykl odniesienia.

Poniższa implementacja wykorzystuje liczniki czasu, ale jeśli wolisz, możesz ją przekształcić pod kątem operacji wysyłkowych.

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

}
GSnyder
źródło
3

Zobacz poniższy kod, który znalazłem na kontrolkach kakao. Wysyłają żądanie asynchronicznie w celu pobrania danych. Być może pobierają dane z lokalnego, ale możesz spróbować ze zdalnym interfejsem API. Wyślij żądanie asynchroniczne do zdalnego interfejsu API w wątku w tle. Postępuj zgodnie z poniższym linkiem:

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

Nirmit Dagly
źródło
Cześć! W końcu miałem czas, aby przyjrzeć się twojej sugerowanej kontroli. Jest zdecydowanie interesujący i nie wątpię, że wielu na tym skorzysta. Jednak myślę, że znalazłem krótsze (i moim zdaniem czystsze) rozwiązanie z tego wpisu na blogu, dzięki inspiracji z twojego linku: sebastienthiebaud.us/blog/ios/gcd/block/2014/04/09/…
maggix
@maggix link, który podałeś, wygasł. Czy możesz zaproponować inny link.
Nirmit Dagly
Aktualizuję wszystkie linki w tym wątku. Użyj tego w mojej odpowiedzi poniżej ( github.com/SebastienThiebaud/dispatch_cancelable_block )
maggix
Spójrz również na to, jeśli używasz Map Google. Jest to zgodne z iOS 8 i napisane w Objective-c. github.com/hkellaway/HNKGooglePlacesAutocomplete
Nirmit Dagly
3

Możemy użyć 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;
}

Więcej o ograniczaniu wykonywania bloków za pomocą GCD

Jeśli używasz ReactiveCocoa , rozważ throttlemetodę onRACSignal

Oto ThrottleHandler w Swift, który Cię interesuje

onmyway133
źródło
Uważam, że github.com/SebastienThiebaud/dispatch_cancelable_block/blob/ ... też jest przydatny
onmyway133
3

Wersja Swift 2.0 rozwiązania 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)
}
William T.
źródło
-1

Możesz używać DispatchWorkItemz Swift 4.0 lub nowszym. To dużo łatwiejsze i ma sens.

Możemy wykonać wywołanie API, gdy użytkownik nie pisał przez 0,25 sekundy.

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

Możesz przeczytać cały artykuł na ten temat tutaj

batuhankrbb
źródło