В  iOS 9 Apple добавила поиск spotlight для сторонних приложений. Это особенно важно для приложений с однотипным контентом: новостные приложения, почтовые клиенты, афиши мероприятий. Отдел iOS разработки холдинга Rambler&Co уже интегрировал поиск в некоторые из своих приложений.

В данной статье будет освещена интеграция CoreSpotlight в уже существующий проект и рассказано о возникших проблемах и их решениях.

Search API


Для начала, чтобы представить целостную картину по текущей функциональности и местах её применения, стоит прояснить, какие возможности по поиску контента в сторонних приложениях доступны в iOS 9 на текущий момент.
На данный момент Search API состоит из следующих компонентов:

NSUserActivity — класс,  сохраняющий и восстанавливающий отображаемый контент и пользовательскую активность. Этот класс был представлен еще в iOS 8 для реализации Handoff (возможность продолжить работу в том же приложении на другом устройстве.). В iOS 9 этот класс также используется при открытии приложения из результатов поиска. Он представляет собой модель, которая содержит:
  • информацию для восстановления состояния интерфейса;
  • описание способов взаимодействия с этой информацией;
  • способы сохранения этой информации в системе.

Есть возможность сделать эту активность публичной, то есть она будет появляться в результатах поиска не только этого устройства, но и у других пользователей. Стоит обратить внимание, что эта возможность начнет работать только через несколько месяцев.

CoreSpotlight — фреймворк, позволяющий разработчикам индексировать контент приложения для последующего отображения в результах поиска spotlight. В нем представлены две модели:
  1. атрибуты для отображения в поиске;
  2. свойства для идентификации контента.

CoreSpotlight также включает в себя методы для добавления и удаления объектов из поиска.

Web Markup — технология, позволяющая Apple-боту проиндексировать контент сайта (специальные метатеги) для отображения его в результах поиска и открытия в приложении.

О Web Markup и NSUserActivity более подробно можно узнать из WWDC 2015, документации и ссылок в конце статьи.

Постановка задачи


Мы рассмотрим интеграцию CoreSpotlight на примере приложения «Рамблер.Почта». В приложении добавится поиск папок, писем, вложений и контактов, которые хранятся в базе данных (CoreData). К моменту обновления эта база уже будет заполнена. Исходя из этого, первым требованием будет первичная индексация всех хранящихся данных.

Поскольку все эти объекты могут создаваться, удаляться и меняться, требуется поддерживать консистентность между основной базой данных и индексированными объектами. Поскольку операций по изменению данных может быть много, и они могут накладываться друг на друга, следует реализовать систему так, что бы она обрабатывала наборы изменений.

Исходя из описанного выше требования, необходимо «схлопывать» количество операций на идексацию. При нескольких изменениях одного объекта достаточно обработать только одну операцию обновления. При удалении объекта нет смысла обрабатывать уже существующие для него запросы на индексацию изменений.

В дальнейшем планируется интеграция в другие проекты, поэтому необходимо, чтобы система индексации была максимально независимой, умела индексировать любые виды объектов, а также легко интегрировалась, модифицировалась и расширялась.

Так как индексация может быть длительной (особенно первичная), и может быть прервана, нужно хранить её состояние. То есть необходимо сохранять все необработанные изменения, и запускать их индексирование при перезапуске приложения. При этом необходимо реализовать систему так, чтобы максимально сократить количество обращений к базе, а количество вспомогательных объектов для хранения изменений было меньше количества изменяемых объектов.

Структура


После постановки задачи мы можем разделить нашу систему на несколько модулей, каждый из которых будет отвечать за одну из функциональных задач. При этом модули, работающие с индексируемыми объектами, будут закрыты протоколами, для того, чтобы при необходимости можно было написать свою реализацию модуля, а система по-прежнему продолжила бы работать.

Источник данных<ChangeProvider>. Класс, реализующий этот протокол, предоставляет монитору данные об изменении объектов. Для каждой сущности, которую нужно индексировать, должен быть свой провайдер. Для удобства реализован базовый класс, который иниацилизируется NSFetchedResultsController’ом, выставляет себя в качестве делегата и проксирует все события об изменениях в IndexerMonitor (о нем рассказано дальше).

@protocol RamblerChangeProvider <NSObject>

@property (weak, nonatomic) id<RamblerChangeProviderDelegate> delegate;

@end

@protocol RamblerChangeProviderDelegate <NSObject>

- (void)changeProvider:(id<RamblerChangeProvider>)changeProvider
       didChangeObject:(id)object
            changeType:(RamblerChangeType)changeType;
- (void)processChanges;
- (NSArray *)obtainObjectsForInitialIndexing;

@end

@interface RamblerFetchedResultsControllerChangeProvider : NSObject <RamblerChangeProvider>

+ (instancetype)changeProviderWithFetchedResultsController:(NSFetchedResultsController *)controller;

@end

Индексатор<Indexer>. Протокол объекта, который занимается индексацией данных из базы. Для каждой сущности, которую нужно индексировать, добавляется свой собственный объект-индексатор. Он умеет обрабатывать набор изменений и отдавать уникальный идентификатор для переданного ему объекта и объект по идентификатору. Для удобства также был реализован базовый класс, от которого нужно наследоваться. Индексатор создает операцию обработки батча и возвращает её монитору.

@protocol RamblerIndexer <NSObject>

- (NSOperation *)operationForIndexBatch:(RamblerIndexTransactionBatch *)batch
                    withCompletionBlock:(RamblerErrorBlock)block;

- (BOOL)canIndexObjectWithIdentifier:(NSString *)identifier;
- (NSString *)identifierForObject:(id)object;
- (id)objectForIdentifier:(NSString *)object;

@end

@interface RamblerIndexerBase : NSObject <RamblerIndexer>

@property (strong, nonatomic) CSSearchableIndex *searchableIndex;

- (CSSearchableItem *)searchableItemForObject:(id)object;
- (BOOL)canIndexObjectWithType:(NSString *)objectType;

@end

Для работы необходимо переопределить два метода из RamblerIndexerBase и последние три метода из протокола RamblerIndexer.

Генератор идентификаторовIndexIdentifierFormatter — вспомогательный объект для генерации и разбития идентификатора на «значимые части» (части строки, которые необходимы для восстановления) для поиска исходного объекта. Например, идентификатор для письма будет выглядеть следующим образом: "RCMMessage_129_Inbox". «RCMMessage» — тип объекта, "129" — идентификатор письма в папке, «Inbox» — имя папки в которой находится сообщения. По этой информации можно однозначно определить тип объекта и найти его.

@protocol RamblerIndexIdentifierFormatter <NSObject>
	
- (NSString *)identifierForObject:(id)object;
	
- (BOOL)isCorrectIdentifier:(NSString *)identifier;

@end

	
@interface RamblerMessageIndexIdentifierFormatter : NSObject
	
- (NSNumber *)messageUIDFromIdentifier:(NSString *)identifier;
- (NSString *)folderNameFromIdentifier:(NSString *)identifier;
	
@end

Хранилище измененийStateStorage — модуль, отвечающий за сохранение изменений для их последующей обработки. Он получает на вход транзакцию, хранящую в себе тип измения, тип измененного объекта и его идентификатор, после чего сохраняет её в базу. Для каждого типа индексируемого объекта существует одна запись в этой базе. Каждый объект содержит в себе OrderSet’ы идентификаторов различных типов изменений. Это необходимо для того, чтобы идентификаторы для одного типа изменения объекта не повторялись.

	
@interface RamblerIndexerStateStorage : NSObject

- (void)insertTransaction:(RamblerIndexTransaction *)transaction;
- (void)insertTransactionsArray:(NSArray<NSArray *> *)transactionsArray
                     changeType:(RamblerChangeType)changeType;
 - (RamblerIndexTransactionBatch *)obtainTransactionBatch;
- (void)removeProcessedBatch:(RamblerIndexTransactionBatch *)batch
- (BOOL)shouldPerformInitialIndexing;

@end

Ядро системы (Монитор)IndexerMonitor  -  данный модуль связывает все остальные части системы. Монитор содержит в себе:
  • набор индексаторов и провайдеров для каждого типа объектов;
  • StateStorage для сохранения изменений, пришедших от провайдеров;
  • очередь, в которую кладет операцию для индексации набора измений;
  • CSSearchableIndex, и выступает его делегатом.


@interface RamblerIndexerMonitor : NSObject

- (void)startMonitor;
- (void)stopMonitor;

- (void)addIndexer:(id<RamblerIndexer>)indexer withChangeProvider:(id<RamblerChangeProvider>)changeProvider;

@end

Алгоритм работы




Весь процесс можно разбить на два логических периода:
  • Сохранение изменения — на этом этапе собираются события об изменениях и сохраняются в базу. Этапы этого периода отмечены на схеме красными маркерами.
  • Индексация изменений — получения набора изменений и их индексация. Этапы периода отмечены фиолетовыми маркерами.

Этапы сохранения изменения:


  1. С помощью делегатного метода NSFetchedResultsController сообщает провайдеру (<ChangeProvider>) об изменении объекта в исходной базе.

    - (void)controller:(NSFetchedResultsController *)controller
       didChangeObject:(NSManagedObject *)anObject
           atIndexPath:(NSIndexPath *)indexPath
         forChangeType:(NSFetchedResultsChangeType)type
          newIndexPath:(NSIndexPath *)newIndexPath;
    

  2. Провайдер вызывает делегатный метод у монитора, в котором передает себя, объект и тип изменения.
    - (void)changeProvider:(id<RamblerChangeProvider>)changeProvider
            didChangeObject:(id)object
                    changeType:(RamblerChangeType)changeType;
    

  3. Монитор определяет по провайдеру связанный с ним индексатор и запрашивает у него идентификатор для данного объекта. Индексатор для этого использует IndexIdentifierFormatter. Далее монитор формирует транзакцию из идентификатора и типа изменения, а затем передает её в StateStorage.
  4. StateStorage запрашивает или создает IndexState (NSManagedObject) для текущего типа объекта и заполняет его OrderSet’ы (insertIdentifiers, updateIdentifiers, deleteIdentifiers). Выставляет дату модификации, необходимую для определения релевантности изменений. И записывает изменения в базу.

Этапы индексации изменений:


  1. По определенному событию, например вызову делегатного метода индексатора, запуску мониторинга или по выходу из бэкграунда, монитор понимает, что нужно начать индексацию, если она в данный момент не совершается.
  2. Монитор запрашивает у StateStorage набор изменений в виде объекта IndexTransactionBatch, который содержит изменения только для одного вида объекта (создается из IndexState).
  3. Монитор, используя метод
    - (BOOL)canIndexObjectWithType:(NSString *)objectType
    находит первый индексатор, который может обработать набор изменений с определенным типом объекта, и передает ему этот набор для создания операции индексации.
  4. Индексатор создает операцию и возвращает её монитору.
  5. Монитор добавляет операцию в очередь на обработку. По вызову блока завершения операции, если индексация прошла успешно, batch передастся StateStorage’у для удаления всех обработанных идентификаторов из базы. После этого можно обрабатывать следующие изменения.

Операция:


  1. объединяет insertIdentifiers и updateIdentifiers, оставляя только уникальные идентификаторы, после чего удаляет из получившегося OrderSet’a идентификаторы из deleteIdentifiers;
  2. запрашивает у индексатора объекты по индексам для индексации;
  3. для получившихся объектов запрашивает массив CSSearchableItem’ов и отдает их на индексацию CSSearchableIndex;
  4. по завершению передает deleteIdentifiers в CSSearchableIndex для удаления из результатов поиска;
  5. после этого вызывается блок завершения, который передавался в метод.

Первичная индексация


При первом запуске нашей системы монитор запрашивает у StateStorage’a, была ли произведена первичная индексация. StateStorage в свою очередь смотрит, существует ли хотя бы один IndexState. Если не существует ни одной записи, значит первичная индексация еще не произошла. Монитор запрашивает у всех провайдеров список объектов для первичной индексации, создавая из них массив массивов транзакций (по одному массиву для каждого типа объектов). Все это передается на сохранение в StateStorage.

StateStorage сохраняет все эти изменения за один performBlockAndWait, что гарантирует нам атомарность. В результате сохранятся либо все изменения, либо ни одного (если приложение выключили), и в таком случае первичная индексация будет производиться заново.

Итог


Мы получили систему, которая может быть легко расширена и встроена для индексации объекта любого типа, а также удовлетворяет всем другим поставленным нами в начале статьи требованиям.

Для интеграции нам потребуется определить несколько собственных индексаторов, которые будет наследовать базовый функционал класса RamblerIndexerBase, а также несколько провайдеров, если объекты не хранятся в базе. В противном случае можно воспользоваться классом RamblerFetchedResultsControllerChangeProvider. После этого вся система собирается вместе, либо вручную, либо с использованием DI фреймворка, например Typhoon.

Некоторые особенности работы:
  • Монитор обязательно должен обрабатывать делегатные методы CSSearchableIndex для повторной индексации объектов. Также нужно знать, что в iOS существует ограничение на количество проиндексированных объектов: это 10К объектов и примерно 130 МБ общего размера индексации.
  • Индексацию стоит производить одновременно только в одном потоке. Именно поэтому мы используем очередь операций.
  • Не следует использовать пробелы для идентификаторов.
  • Для повышения релевантности поисковых результатов стоит реализовывать NSUserActivity и использовать тот же идентификатор и те же атрибуты, как и в реализации CoreSpotlight. Для этого можно использовать уже подготовленный индексатор.
  • Рекомендуется использовать от пяти до десяти ключевых слов.

Представленная в данной статье система в скором времени будет опубликована на GitHub, мы сообщим об этом в твиттере Rambler.iOS, а также добавим ссылку в эту статью.

Обработку открытия результатов поиска мы рассмотрим в следующей части. Кроме этого, мы затронем такие темы, как разнесение логики для различных вариантов запуска приложения (по push-уведомлению, из результатов поиска, обычный запуск), обработка входных параметров при открытии (userInfo из notification’а, NSUserActivity), способы перехода на нужный экран (например, ручное собирание стека навигации с несколькими сторибордами).

Полезные ссылки:


Документация:
iOS Search API Best Practices and FAQs
App Search Programming Guide
NSUserActivity Class Reference
Core Spotlight Framework Reference

Видео с WWDC 2015:
WWDC 2015 Introducing Search APIs
Seamless Linking to Your App

Примеры кода:
WWDC 2015: Introducing Search APIs by Andres Ibanez
iOS 9: Introducing Search APIs by Davis Allie

Комментарии (0)