iOS dev : on-device spotlight search for app contents

For the AdoreMe iOS app (the ecommmerce startup I’ve been working since last year), a feature I’ve personally wanted to add for a long time while is integration with Spotlight search of the product categories pages.

Lately I’ve started working on it, and would like to share with you some of the insights I discovered and practices I used:

To keep things as decoupled as possible, I created a separate class SpotlightHelper where I placed all search-related functionality.

import MobileCoreServices
import CoreSpotlight

@objc class SpotlightHelper: NSObject {
  @objc static let CATEGORY_SEARCH_DOMAIN = "com.adoreme.category.search"

The public API consists of 2 main methods:

attemptReindexCategories

Checks if spotlight indexing is allowed (by my homegrown feature flag and a/b test utility class), if we need reindexing (the current condition is to index categories only once / month, with the last indexed date saved on device in UserDefaults), then calls the async method that retrieves the categories list and eventually starts the indexing.

@objc public func attemptReindexCategories() {
      guard FeatureGate.isActive(FeatureGate.SPOTLIGHT_INDEX) else {return}
      if checkAndSetIfNeedsReindexing() {
        //... call the method responsible for getting the categories list
        // loadCategoriesArray(completion: {
          // let validCategoriesList = ...
            self.indexSearchableItemsFor(categories: validCategoriesList)
        // })
      }
  }

tryOpenActivity

This is a class method called from AppDelegate in the application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) method. It just checks if the current activity results from a tap on a spotlight search query result and opens the corresponding category screen (in my case, using my deeplink opening engine).

@objc public class func tryOpenActivity(userActivity: NSUserActivity) -> Bool {
       if userActivity.activityType == CSSearchableItemActionType {
           if let categoryId = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
               let link = LinkBuilderCustomScheme.buildCategoryLink(forCategoryId: categoryId)
               UniversalLinkManager.shared.processLink(link, source: "deviceSearch")
           }
           return true
       }
      return false
   }

Now for the actual functionality

I wanted each index category to have a nice thumbnail image, which was loaded from our backend server.

The tricky part was to only add those categories to the index only after the thumbnail image was downloaded. To achieve this, I’m using a global array where, once my async download of the image is complete, I add the CSSearchableItem for the category. When I’m done downloading the thumbnails for all categories, I call the CSSearchableIndex.default().indexSearchableItems.

    func indexSearchableItemsFor(categories: [CategoryModel]){
      for category in categories {
        let isIndexableCategory = self.downloadRemoteDataAndPrepareSearchItem(category: category)
        if isIndexableCategory {
          self.indexableItemsCount = self.indexableItemsCount + 1
        }
      }
    }

    private func downloadRemoteDataAndPrepareSearchItem(category: CategoryModel) -> Bool {
        guard let imageURL = URL(string: category.remoteImageURL) else {
            // skip category from downloading and indexing
            return false
        }

        let searchItem = buildSearchItemFor(category: category)

        // returns true if we've launched an async operation for getting the category's image
        URLSession.shared.dataTask(with: imageURL) { data, response, error in
            if let data = data {
                searchItem.attributeSet.thumbnailData = data
            }

            self.indexableItems.append(searchItem)

            if self.indexableItemsCount == self.indexableItems.count {
                // has finished downloading images, let's index
                CSSearchableIndex.default().indexSearchableItems(self.indexableItems.flatMap({$0})) { (error) in
                    print("finished indexing ; error? \(error?.localizedDescription)")
                }
            }
        }.resume()
        return true
    }


    private func buildSearchItemFor(category: CategoryModel) -> CSSearchableItem {
        let attributeSet = CSSearchableItemAttributeSet( itemContentType: kUTTypeItem as String)
        attributeSet.title = category.name
        attributeSet.keywords = category.name.components(separatedBy: CharacterSet.whitespacesAndNewlines)
        attributeSet.contentDescription = category.metaDescription
        let searchableItem = CSSearchableItem(uniqueIdentifier: category.category_id, domainIdentifier: SpotlightHelper.CATEGORY_SEARCH_DOMAIN, attributeSet: attributeSet)
        return searchableItem
    }

Issues handled

  • In order to allow concurrent access to the indexableItems array without getting into any concurrency problems, my indexableItems array is actually a SynchronizedArray. SynchronizedArray is a nifty array-like atomic access collection inspired by Basem Emara’s blog.
  • I keep a single SpotlightHelper instance, created /retained in my UserSession (a singleton). This prevents accidents with triggering multiple indexing requests at once. The indexing request only happens at most once per user session (and because of the checkAndSetIfNeedsReindexing method, won’t be called again until next month).

Possible issues (areas to improve)

  • I should probably first check/handle if Spotlight indexing has been disabled by the user on device, in order to prevent needless work.
  • In the future I’ll want to remove the spotlight indexed items when the feature flag becomes false (when we want to disable the feature completely)
  • In your own app you’ll probably handle errors when indexing, with CSSearchableIndex indexing batches of items instead of all at once, add error handling and retry.
  • The “last indexed date” flag should be set after you’ve actually succeeded indexing the searchable items, not before. This way, if any error has occurred in the meantime, you’re sure to retry on next user session.

I hope that my real-life example will give some of you you ideas on implementing Spotlight search for your apps as well.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.