fbpx

Blog

Реализация full-text поиска данных в android-приложениях через AppSearch и RxJava

Совсем недавно Google анонсировал библиотеку для локального поиска документов AppSearch. Библиотека пока находится на стадии alpha-версии, но тем не менее уже можно применить её и рассмотреть ряд возможностей. В этой статье мы разработаем небольшое приложение для локального поиска разного рода документов и отобразим их пользователю для демонстрации работы AppSearch.

Введение

AppSearch – это высокопроизводительная библиотека для поиска на устройстве для управления локально хранящимися структурированными данными. Она содержит API-интерфейсы для индексирования данных и извлечения данных с помощью полнотекстового поиска. Приложения могут использовать AppSearch для поиска разнородных локальных данных. Таким образом, даже если пользователь отключен от сети Интернет – то вы сможете осуществить поиск не только по данным на текущем экране, а среди всех когда-то сохранённых данных.

AppSearch предлагает следующие возможности:

  • Быстрая, mobile-first реализация для хранения данных
  • Высокоэффективное индексирование и запросы к большим наборам данных
  • Поддержка мультиязычности, например поиска среди контента на английском языке и испанском
  • Рейтинг релевантности и оценка использования

Из-за меньшего количества операций ввода-вывода AppSearch предлагает меньшую задержку для индексации и поиска в больших наборах данных по сравнению с SQLite. AppSearch упрощает запросы перекрестного типа (типа join), поддерживая отдельные запросы, тогда как SQLite объединяет результаты из нескольких таблиц.

Чтобы проиллюстрировать возможности AppSearch, давайте рассмотрим пример приложения для поиска медиаконтента. Пользователь пытается найти контент и ему не важно, фильм это, сериал, а может быть это актёр или песня. Более того, контент может быть на разных языках. Соответственно, если в приложении мы уже сохранили такие данные – то, с помощью AppSearch мы не ограничиваем пользователя каким-то одним типом контента, а позволяем отобразить всё, что удовлетворяет запросу. 

Для иллюстрации работы AppSearch можно взглянуть на следующую диаграмму, взятую из официальной документации:

База данных AppSearch и сессия

База данных AppSearch – это набор документов, соответствующих схеме базы данных. Android-приложения создают базу данных, предоставляя контекст своего приложения и имя базы данных. Базы данных могут быть открыты только приложением, которое их создало. При открытии базы данных возвращается сессия для взаимодействия с базой данных. Сессия является точкой входа для вызова API-интерфейсов AppSearch и остается открытой до тех пор, пока не будет закрыта клиентским приложением.

Схема и типы схемы

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

Документы

В AppSearch единица данных представлена ​​в виде документа. Каждый документ в базе данных AppSearch уникально идентифицируется своим пространством имен и идентификатором. Пространства имен используются для разделения данных из разных источников, когда требуется запросить только один источник, например учетные записи пользователей. Документы содержат отметку времени создания, время жизни (TTL) и оценку, которая может использоваться для ранжирования во время поиска. Документу также назначается тип схемы, который описывает дополнительные свойства данных, которые должен иметь документ. Класс документа – это абстракция документа. Он содержит аннотированные поля, которые представляют содержимое документа. По умолчанию имя класса документа задает имя типа схемы.

Поиск

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

Итак, мы разобрались с основными компонентами и теперь можно начать интегрировать AppSearch в наше приложение.

Добавление в проект

Чтобы начать работать с AppSearch сперва нужно добавить библиотеку. Открой build.gradle и добавьте kapt плагин:

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
}

После этого добавьте библиотеку:

dependencies {
    def appsearch_version = "1.0.0-alpha02"
    implementation "androidx.appsearch:appsearch:$appsearch_version"
    // Use kapt instead of annotationProcessor if writing Kotlin classes
    kapt "androidx.appsearch:appsearch-compiler:$appsearch_version"
    implementation "androidx.appsearch:appsearch-local-storage:$appsearch_version"
    implementation 'com.google.guava:guava:27.0.1-android'
}

Нажмите Sync Now. Отлично! Теперь мы готовы использовать AppSearch

Создание документов MovieDocument и PersonDocument

Первым шагом для работы App Search необходимо описать класс так называемого документа для вставки в базу данных. Это можно сделать, используя аннотацию @Document. В следующем примере мы создадим класс-документ MovieDocument, используя аннотацию @Document.StringProperty для индексации текста объекта MovieDocument:

@Document
data class MovieDocument(
    // Required field for a document class. All documents MUST have a namespace.
    @Document.Namespace
    val namespace: String,
    // Required field for a document class. All documents MUST have an Id.
    @Document.Id
    val id: String,
    // Optional field for a document class, used to set the score of the
    // document. If this is not included in a document class, the score is set
    // to a default of 0.
    @Document.Score
    val score: Int,
    // Optional field for a document class, used to index a movie's text for this
    // document class.
    @Document.StringProperty(indexingType = AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES)
    val title: String,
    @Document.StringProperty(indexingType = AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES)
    val text: String,
    @Document.StringProperty
    val poster: String?
)

Давайте рассмотрим аннотации, которые тут используются:

  • @Document.Namespace. Обязательное поле пространство имен – это произвольная строка, предоставленная пользователем, которая может использоваться для группировки похожих документов во время запроса или удаления. Индексирование документа с определенным идентификатором заменяет любые существующие документы с тем же идентификатором в этом пространстве имен
  • @Document.Id Обязательное поле уникальный идентификатор документа
  • @Document.Score Необязательное поле score (оценка). Оценка – это не зависящая от запроса мера качества документа по сравнению с другими однотипными документами. Это один из вариантов сортировки запросов.
  • @Document.StringProperty Необязательное поле для класса документа, используемое для индексации текста фильма.

При этом у Document.StringProperty есть два параметра:

  • AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES – поле, помеченное таким параметром должно возвращаться для запросов, которые либо полностью совпадают, либо запросить совпадения токенов, указанных в этом свойстве. Пример. Свойство с “fool”  должно  соответствовать запросу для “foo”.
  • AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_EXACT_TERMS Содержимое этого свойства должно возвращаться только для запросов, соответствующих точным токенам фигурирующим в этом поле. Пример. Свойство с “fool” НЕ должно соответствовать запросу для “foo”.

Аналогичным образом опишем и документ PersonDocument.

Отлично, мы описали документы для поиска, теперь нам необходимо создать схему базы данных для дальнейшей записи документов и поиска.

Открытие базы данных и создание схемы

Перед работой с документами необходимо создать базу данных. Следующий код создает новую базу данных с именем movies_demo_db и получает ListenableFuture для AppSearchSession, который представляет соединение с базой данных и предоставляет API-интерфейсы для операций с базой данных.

val sessionFuture: ListenableFuture<AppSearchSession> = LocalStorage.createSearchSession(
       LocalStorage.SearchContext.Builder(activity?.applicationContext!!, "movies_demo_db")
           .build()
   )

Вы должны установить схему, прежде чем вы сможете вставлять документы и получать документы из базы данных. Схема базы данных состоит из различных типов структурированных данных, называемых «типами схемы». Следующий код устанавливает схему, предоставляя класс документа как тип схемы. Этот метод находится в файле AppSearchRepository.kt – для удобства все методы работы с App Search вынесены в отдельный репозиторий.

fun setSchema(documentClasses: Collection<Class<*>>) {
    val setSchemaRequest =
        SetSchemaRequest.Builder().addDocumentClasses(documentClasses)
            .build()
   // Created ListenableFuture
    val setSchemaFuture = Futures.transformAsync(
        sessionFuture,
        { session ->
            session?.setSchema(setSchemaRequest)
        }, threadPoolExecutor
    )
    Futures.addCallback(
        setSchemaFuture,
        object : FutureCallback<SetSchemaResponse> {
            override fun onSuccess(result: SetSchemaResponse?) {
                Log.d(TAG, "SetSchemaResponse success. $result")
            }
            override fun onFailure(t: Throwable) {
                Log.d(TAG, "Failed to put documents. $t")
            }
        },
        threadPoolExecutor
    )
}

Ура, мы создали сессию для работы с БД и указали нужные схемы документов. Теперь, давайте попытаемся сохранить данные, а затем произвести поиск. Но перед этим немного поговорим про ListenableFuture.

Обзор Future, ListenableFuture, Executor

Интерфейс java.util.concurrent.Future описывает API для работы с задачами, результат которых мы планируем получить в будущем: методы получения результата, методы проверки статуса. Future представляет собой результат асинхронных вычислений: вычисление, которое, возможно, еще не закончилось или уже дало результат. Future может быть указателем на выполняемые вычисления, обещанием службы предоставить нам результат.

ListenableFuture позволяет вам регистрировать обратные вызовы, которые будут выполняться после завершения вычисления или, если вычисление уже завершено, немедленно. Это простое дополнение позволяет эффективно поддерживать многие операции, которые базовый интерфейс Future не поддерживает. Базовая операция, добавляемая ListenableFuture, – это addListener (Runnable, Executor), которая указывает, что когда вычисление, представленное этим Future, выполнено, указанный Runnable будет запущен на указанном Executor. Более подробно про ListenableFuture можно почитать тут.

С помощью метода transformAsync (в который мы и передали ExecutorService) мы создаем ListenableFuture для последующего выполнения.  И в Futures.addCallback добавляем нашу задачу и регистрируем callback. Аналогично работают и другие методы для сохранения и поиска данных. В следующем разделе мы реализуем метод сохранения данных.

Сохранение данных в БД

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

val putRequest = PutDocumentsRequest.Builder().addDocuments(documents).build()

Создаём ListenableFuture для выполнения задачи сохранения:

val putFuture = Futures.transformAsync(
    sessionFuture,
    { session ->
        session?.put(putRequest)
    }, threadPoolExecutor
)

Регистрируем callback для получения результата. Кроме того, для удобства метод сохранения и поиска обёрнуты в Observable, чтобы можно было вызывать эти методы в Rx-цепочке. В итоге весь метод сохранения будет выглядеть так:

fun saveData(documents: List<Any>): Observable<Boolean> {
    val putRequest = PutDocumentsRequest.Builder().addDocuments(documents).build()
    val putFuture = Futures.transformAsync(
        sessionFuture,
        { session ->
            session?.put(putRequest)
        }, threadPoolExecutor
    )
    return Observable.create { emitter ->
        Futures.addCallback(
            putFuture,
            object : FutureCallback<AppSearchBatchResult<String, Void>?> {
                override fun onSuccess(result: AppSearchBatchResult<String, Void>?) {
                    // Gets map of successful results from Id to Void
                    val successfulResults = result?.successes
                    Log.d(TAG, "successfulResults" + result?.successes)
                    // Gets map of failed results from Id to AppSearchResult
                    val failedResults = result?.failures
                    Log.d(TAG, "failedResults" + result?.failures)
                    emitter.onNext(true)
                }
                override fun onFailure(t: Throwable) {
                    Log.e(TAG, "Failed to put documents. $t")
                    emitter.onError(t)
                }
            },
            threadPoolExecutor
        )
    }
}

Реализация поиска и отображения данных

val searchSpec = SearchSpec.Builder()
    .addFilterNamespaces(nameSpace)
    .build()

В данном случае мы просто указываем фильтр документа по namespace. В этом случае мы будем искать только те документы, которые имеют указанные пространства имен. Если не задано, поиск будет выполняться по всем пространствам имен. Кроме этого, есть еще несколько параметров:

  • setResultCountPerPage() – Устанавливает количество результатов на страницу в возвращаемом объекте. По умолчанию количество результатов на странице – 10.
  • setRankingStrategy() – позволяет задать стратегию ранжирования результатов.
  • setOrder() – позволяет задать сортировку (ORDER_DESCENDING, ORDER_ASCENDING). Например ORDER_DESCENDING означает, что результаты с более высокими баллами идут первыми.
  • setResultGrouping() позволяет группировать документы, например по namespace

Стратегий ранжирования может быть несколько, они достаточно неплохо описаны в исходниках:

  • public static final int RANKING_STRATEGY_NONE = 0;  – ранжирования нет
  • public static final int RANKING_STRATEGY_DOCUMENT_SCORE = 1 – ранжироваание по заданному Score
  • public static final int RANKING_STRATEGY_CREATION_TIMESTAMP = 2 – ранжироваание по timestamp
  • public static final int RANKING_STRATEGY_RELEVANCE_SCORE = 3 – ранжирование по relevance score
  • public static final int RANKING_STRATEGY_USAGE_COUNT = 4 – ранжирование по частоте использования
  • public static final int RANKING_STRATEGY_USAGE_LAST_USED_TIMESTAMP = 5 – ранжирование по дате последнего использования
  • public static final int RANKING_STRATEGY_SYSTEM_USAGE_COUNT = 6 – судя по всему не поддерживается (Reporting system usage is not supported in the local backend, so this method does nothing
  • public static final int RANKING_STRATEGY_SYSTEM_USAGE_LAST_USED_TIMESTAMP = 7 – судя по всему не поддерживается

После того, как мы определили поиск, необходимо создать ListenableFuture и зарегистрировать callback:

fun search(term: String, nameSpace: String? = ""): Observable<SearchResults> {
    Log.d(TAG, "search: $term")
    val searchSpec = SearchSpec.Builder()
        .addFilterNamespaces(nameSpace)
        .build();
    val searchFuture: ListenableFuture<SearchResults> = Futures.transform(
        sessionFuture,
        { session ->
            session?.search(term, searchSpec)
        },
        threadPoolExecutor
    )
    return Observable.create { emitter ->
        Futures.addCallback(
            searchFuture,
            object : FutureCallback<SearchResults> {
                override fun onSuccess(searchResults: SearchResults?) {
                    searchResults?.let { emitter.onNext(searchResults) }
                }
                override fun onFailure(t: Throwable) {
                    emitter.onError(t)
                }
            },
            threadPoolExecutor
        )
    }
}

Используя Rx через оператор create и emitter передаём полученные данные searchResults: SearchResults. SearchResults – специальный интерфейс через который мы можем получить список результатов через метод getNextPage() Для удобства, создадим метод, получающий SearchResults и отдающий список GenericDocument – как раз те самые документы, которые удовлетворяют нашему поиску:

fun iterateSearchResults(
    searchResults: SearchResults?
): Observable<List<GenericDocument>> {
    val getDocumentsFuture: ListenableFuture<List<GenericDocument>> = Futures.transform(
        searchResults?.nextPage,
        { page: List<SearchResult>? ->
            // Gets GenericDocument from SearchResult.
            val genericDocument: List<GenericDocument>? =
                page?.map { it.genericDocument }?.toList()
            genericDocument
        },
        threadPoolExecutor
    )
    return Observable.create { emitter ->
        Futures.addCallback(
            getDocumentsFuture,
            object : FutureCallback<List<GenericDocument>> {
                override fun onSuccess(result: List<GenericDocument>?) {
                    result?.let {
                        Log.d(TAG, "onSuccess $result")
                        emitter.onNext(result)
                    }
                }
                override fun onFailure(t: Throwable) {
                    Log.d(TAG, "onFailure $t")
                    emitter.onError(t)
                }
            },
            threadPoolExecutor
        )
    }
}

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

Для реализации поиска мы будем использовать RxJava, и подпишемся на поле ввода текста как показано ниже:

compositeDisposable.add(
    binding.searchToolbar.onTextChangedObservable.debounce(1, TimeUnit.SECONDS)
        .filter { it.length > 4 }
        .flatMap { repository.search(it) }
        .flatMap { repository.iterateSearchResults(it) }
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe({ result ->
            val allMovies: MutableList<MovieDocument> = mutableListOf()
            val allPerson: MutableList<PersonDocument> = mutableListOf()
            result?.forEach { genericDocument ->
                val schemaType = genericDocument.schemaType
                try {
                    if (schemaType == "MovieDocument") {
                        // Converts GenericDocument object to MovieDocument object.
                        allMovies.add(genericDocument.toDocumentClass(MovieDocument::class.java))
                    } else if (schemaType == "PersonDocument") {
                        allPerson.add(genericDocument.toDocumentClass(PersonDocument::class.java))
                    }
                } catch (e: AppSearchException) {
                    Log.d(TAG, "Failed to convert GenericDocument $e")
                }
            }
            adapter.clear()
            val movies =
                allMovies.map { MoviePreviewItem(MovieMapper.fromMovieDocument(it)) {} }
                    .toList()
            if (movies.isNotEmpty()) {
                val movieItem = MainCardContainer("Movies", movies)
                binding.eventsRecyclerView.adapter = adapter.apply { add(movieItem) }
            }
            val personItems =
                allPerson.map { PersonItem(PersonMapper.fromPersonDocument(it)) {} }
                    .toList()
            if (personItems.isNotEmpty()) {
                val personResultItem = MainCardContainer("Actors", personItems)
                binding.eventsRecyclerView.adapter = adapter.apply { add(personResultItem) }
            }
        },
            {
                Log.d(TAG, "Error: $it")
            })
)

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

Сначала фильтруем данные по схеме, а затем создаём ячейки для отображения данных. Таким образом, после поиска по строке “Морти” получаем данные из двух документов: MovieDocument и PersonDocument.

Результат поиска по строке "Морти"

Заключение

Надеюсь, в этой статье нам удалось показать пример работы с библиотекой AppSearch для full-text поиска разнородных документов. Как уже было сказано, похожего функционала можно добиться используя SQLite, однако тогда придётся осуществлять join по нескольким таблицам. Кроме этого AppSearch заточен именно под full-text поиск большого количества разнородных документов, идеально если у вас какое-нибудь медиа-приложение с большим количеством контента (Фильмы/Книги/Игры) и вам нужно осуществить поиск не только по описанию, а например названиям, краткому описанию книги, цитатам etc. Библиотека пока на стадии alpha и скорее всего в скором будущем появится возможность использовать Rx или Kotlin coroutines, но пока приходится работать с LinstenableFuture. В качестве упрощения – можно сделать обёртки через RxJava и работать уже с ней. Не забудьте поставить звёздочку исходному коду на GitHub и подписаться на наш телеграм-канал