John Smith
John Smith

Reputation: 591

Swift | Force cancel execution of code

I know there have been I would say similar questions, but none of them fits my case -> see my already tried attempts below.

So what I'm trying to do is make a advanced searchBar. Therefore, everytime the searchParam changed, I need to execute code to check whether my TableViewItems (Array) match the criteria -> filter.

When smo. for example types 3 characters, my code checks this string. But this will take a while to get all results.

The Problem: When smo. then types the 4th character, I want to stop the previous execution and start a new one with the string of 4.


My Attempts:


Current Code: (not very important)

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        self.stop = true
        if people != nil {

            if searchText.isEmpty {
                searchedPeople = people
                self.stop = false
            } else {
                self.searchedPeople = []
                self.tableView.reloadData()
                workItem = DispatchWorkItem(qos: .userInitiated, flags: []) {
                    outter: for i in 0...searchText.count - 1 {
                        if self.stop { self.stop = false;break outter } //
                        if i == 0 {
                            inner: for person in self.people! {
                                if self.stop { self.stop = false;break outter } //
                                let name = (person.Vorname != nil ? person.Vorname! + " " : "") + (person.Nachname ?? "")
                                if name.lowercased().contains(searchText.lowercased()) {
                                    print("Found: \(name), \(searchText), Exact Match")
                                    self.searchedPeople?.append(person);DispatchQueue.main.async { self.tableView.reloadData()}; continue inner
                                }
                            }
                        } else {
                            let firstP = searchText.prefix(i+1)
                            let lastP = searchText.suffix(searchText.count - i + 1)

                            inner2: for person in self.people! {
                                if self.stop { self.stop = false;break outter } //
                                let name = (person.Vorname != nil ? person.Vorname! + " " : "") + (person.Nachname ?? "")
                                if name.lowercased().contains(firstP.lowercased()) && (name.lowercased().contains(lastP.lowercased())) {
                                    print("Found: \(name), \(searchText), First: \(firstP), Last: \(lastP)")
                                    self.searchedPeople?.append(person)
                                    DispatchQueue.main.async { self.tableView.reloadData()}
                                    continue inner2
                                }
                            }
                        }
                    }
                }
                //execute
                DispatchQueue.async(execute: workItem)

            }
        }
    }

Upvotes: 1

Views: 1460

Answers (3)

Paulw11
Paulw11

Reputation: 114975

I am not sure that cancelling the operation is your real issue. Your search code is pretty inefficient. Refactoring so that the outer loop is your people array rather than your search string will make the operation much faster.

You can also compute a lot of values once.

Using the code below I was able to find 1438 matches from 24636 candidates in 0.32 seconds (or 0.23 seconds if I commented out the print "Found:...").

func search(_ people: [Person], for searchText: String) {
        guard !people.isEmpty  else {
            print("No people")
            return
        }

        let lcSearch = searchText.lowercased()

        print("Starting search for \(searchText)")
        var searchedPeople = [Person]()
        for person in people {
            let personName = ((person.firstName ?? "") + " " + (person.surname ?? "")).lowercased()
            if personName.contains(lcSearch) {
                searchedPeople.append(person)
                continue
            }
            for i in 1..<lcSearch.count {
                let split = lcSearch.index(lcSearch.startIndex, offsetBy: i)
                let firstP = lcSearch.prefix(upTo: split)
                let lastP = lcSearch.suffix(from: split)

                if personName.contains(firstP) && personName.contains(lastP) {
                    print("Found: \(personName), \(searchText), First: \(firstP), Last: \(lastP)")
                    searchedPeople.append(person)
                    break
                }
            }
        }

        print("Done.  Found \(searchedPeople.count)")
    }

Upvotes: 0

Charles Srstka
Charles Srstka

Reputation: 17060

Your problem is, essentially, that you're checking global state when you should be checking local state. Let's say you've got operation 'L' going on, and you've just typed an 'e', so operation 'Le' is going to start. Here's what roughly happens:

func updateMyPeepz() {
    // At this point, `self.workItem` contains operation 'L'

    self.workItem.cancel() // just set isCancelled to true on operation 'L'
    self.workItem = DispatchWorkItem { /* bla bla bla */ }
    // *now* self.workItem points to operation 'Le'!
}

So later, in the work item for operation 'L', you do this:

if self.workItem.isCancelled { /* do something */ }

Unfortunately, this code is running in operation 'L', but self.workItem now points to operation 'Le'! So while operation 'L' is cancelled, and operation 'Le' is not, operation 'L' sees that self.workItem—i.e. operation 'Le'—is not cancelled. And thus, the check always returns false, and operation 'L' never actually stops.

When you use the global boolean variable, you have the same problem, because it's global and doesn't differentiate between the operations that should still be running and the ones that shouldn't (in addition to atomicity issues you're already going to introduce if you don't protect the variable with a semaphore).

Here's how to fix it:

func updateMyPeepz() {
    var workItem: DispatchWorkItem? = nil // This is a local variable

    self.currentWorkItem?.cancel() // Cancel the one that was already running

    workItem = DispatchWorkItem { /* bla */ } // Set the *local variable* not the property

    self.currentWorkItem = workItem // now we set the property
    DispatchQueue.global(qos: whatever).async(execute: workItem) // run it
}

Now, here's the crucial part. Inside your loops, check like this:

if workItem?.isCancelled ?? false // check local workItem, *not* self.workItem!
// (you can also do workItem!.isCancelled if you want, since we can be sure this is non-nil)

At the end of the workItem, set workItem to nil to get rid of the retain cycle (otherwise it'll leak):

workItem = nil // not self.workItem! Nil out *our* item, not the property

Alternatively, you can put [weak workItem] in at the top of the workItem block to prevent the cycle—then you don't have to nil it out at the end, but you should be sure to use ?? false instead of ! since you always want to assume that a weak variable can conceivably go nil at any time.

Upvotes: 3

sudo
sudo

Reputation: 5804

Edit: The 10 second delay is most likely due to some issue updating the UI. I just tested updating a shared boolean between the main queue and a dispatch work item on a background queue, and there was no apparent propagation delay of that magnitude. However, there's debate over whether this is safe here and here.

The self.tableView.reloadData() happening in the async blocks of the work item and also in the sync part before the work item... I'm not sure what behavior that'll create since you're relying on the order of the reloadData calls in the GCD queues behind the scenes. It'd be more predictable if your work items just built a local array of results and didn't semi-directly interact with the UI.

One proposal, not necessarily the best cause it's been a while since I've used Dispatch: Have your work items find the array of results then update a shared [String: [Person]] dict mapping search strings to result arrays (lock needed? not sure) when they're done. Then you could use DispatchWorkItem.notify (example) to run code on the main queue that updates the UI table when a work item finishes, using the shared dictionary result that matches the current typed search string or doing nothing if no result matches.

P.S. This is a lot of manual work, and I might be missing an easier way. I know CoreData automates the task of searching and updating a UI table with its NSFetchedResultsController, but that's a whole different setup.

Upvotes: 1

Related Questions