AppSearch

AppSearch 是一种高性能设备端搜索解决方案,用于管理本地存储的结构化数据。它包含用于将数据编入索引和通过全文搜索来检索数据的 API。应用可以使用 AppSearch 提供自定义的应用内搜索功能,使用户即使在离线状态下也能搜索内容。

在 AppSearch 中编入索引和进行搜索的示意图

AppSearch 提供以下功能:

  • 一种 I/O 占用率低的移动设备优先快速存储实现
  • 快速高效地对大型数据集进行索引编制和查询
  • 多语言支持,例如英语和西班牙语
  • 相关性排名和使用情况评分

由于 I/O 使用率较低,与 SQLite 相比,AppSearch 在大型数据集索引和搜索方面的延迟时间更短。AppSearch 通过支持单个查询简化了跨类型查询,而 SQLite 合并了多个表中的结果。

为了说明 AppSearch 的功能,我们以一款音乐应用为例,该应用管理用户喜欢的歌曲并允许用户轻松搜索这些歌曲。用户可从世界各地欣赏不同语言的歌曲名称,而 AppSearch 本身支持编入索引和查询。当用户按歌名或音乐人姓名搜索歌曲时,应用只需将请求传递给 AppSearch,即可快速高效地检索匹配的歌曲。该应用会显示结果,让用户能够快速开始播放他们喜欢的歌曲。

初始设置

如需在应用中使用 AppSearch,请将以下依赖项添加到应用的 build.gradle 文件中:

Groovy

dependencies {
    def appsearch_version = "1.1.0-alpha03"

    implementation "androidx.appsearch:appsearch:$appsearch_version"
    // Use kapt instead of annotationProcessor if writing Kotlin classes
    annotationProcessor "androidx.appsearch:appsearch-compiler:$appsearch_version"

    implementation "androidx.appsearch:appsearch-local-storage:$appsearch_version"
    // PlatformStorage is compatible with Android 12+ devices, and offers additional features
    // to LocalStorage.
    implementation "androidx.appsearch:appsearch-platform-storage:$appsearch_version"
}

Kotlin

dependencies {
    val appsearch_version = "1.1.0-alpha03"

    implementation("androidx.appsearch:appsearch:$appsearch_version")
    // Use annotationProcessor instead of kapt if writing Java classes
    kapt("androidx.appsearch:appsearch-compiler:$appsearch_version")

    implementation("androidx.appsearch:appsearch-local-storage:$appsearch_version")
    // PlatformStorage is compatible with Android 12+ devices, and offers additional features
    // to LocalStorage.
    implementation("androidx.appsearch:appsearch-platform-storage:$appsearch_version")
}

AppSearch 概念

下图说明了 AppSearch 的概念及其交互。

客户端应用及其与以下 AppSearch 概念的交互的示意图:AppSearch 数据库、架构、架构类型、文档、会话和搜索。 图 1. AppSearch 概念图:AppSearch 数据库、架构、架构类型、文档、会话和搜索。

数据库和会话

AppSearch 数据库是符合数据库架构的一系列文档。客户端应用通过提供其应用上下文和数据库名称来创建数据库。数据库只能由创建数据库的应用打开。打开数据库后,系统会返回会话以与数据库进行交互。会话是调用 AppSearch API 的入口点,并会一直保持打开状态,直到客户端应用将其关闭。

架构和架构类型

架构表示 AppSearch 数据库中数据的组织结构。

架构由表示唯一数据类型的架构类型组成。架构类型由包含名称、数据类型和基数的属性组成。将架构类型添加到数据库架构后,系统就可以创建该架构类型的文档并将其添加到数据库。

文档

在 AppSearch 中,一个数据单位表示为一个文档。AppSearch 数据库中的每个文档都由其命名空间和 ID 唯一标识。当只需要查询一个来源(例如用户帐号)时,可以使用命名空间来分隔来自不同来源的数据。

文档包含创建时间戳、存留时间 (TTL) 以及可用于在检索期间进行排名的得分。还会为文档分配架构类型,用于描述文档必须具有的其他数据属性。

文档类是文档的抽象。它包含表示文档内容的带注解字段。默认情况下,文档类的名称设置架构类型的名称。

文档会编入索引,并可通过提供查询来搜索。如果文档包含查询中的字词或与其他搜索规范匹配,则将匹配该文档并包含在搜索结果中。结果按其得分和排名策略排序。搜索结果以可依序检索的页面表示。

AppSearch 提供适用于搜索的自定义功能,例如过滤条件、页面大小配置和摘要。

平台存储与本地存储

AppSearch 提供了两种存储解决方案:LocalStorage 和 PlatformStorage。借助 LocalStorage,您的应用可管理位于应用数据目录中的应用专属索引。借助 PlatformStorage,您的应用可为系统级中央索引提供内容。中央索引内的数据只能访问您的应用贡献的数据以及其他应用明确与您共享的数据。LocalStorage 和 PlatformStorage 共用同一个 API,可以根据设备的版本互换:

Kotlin

if (BuildCompat.isAtLeastS()) {
    appSearchSessionFuture.setFuture(
        PlatformStorage.createSearchSession(
            PlatformStorage.SearchContext.Builder(mContext, DATABASE_NAME)
               .build()
        )
    )
} else {
    appSearchSessionFuture.setFuture(
        LocalStorage.createSearchSession(
            LocalStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                .build()
        )
    )
}

Java

if (BuildCompat.isAtLeastS()) {
    mAppSearchSessionFuture.setFuture(PlatformStorage.createSearchSession(
            new PlatformStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                    .build()));
} else {
    mAppSearchSessionFuture.setFuture(LocalStorage.createSearchSession(
            new LocalStorage.SearchContext.Builder(mContext, DATABASE_NAME)
                    .build()));
}

使用 PlatformStorage,您的应用可以与其他应用安全地共享数据,允许这些应用搜索您应用的数据。只读应用数据共享是通过证书握手授予的,以确保其他应用有权读取数据。如需详细了解此 API,请参阅 setSchemaTypeVisibilityForPackage() 文档。

此外,已编入索引的数据可显示在系统界面 surface 上。应用可以选择停止在系统界面 surface 中显示其部分或全部数据。如需详细了解此 API,请参阅 setSchemaTypeDisplayedBySystem() 文档。

功能 LocalStorage (compatible with Android 4.0+) PlatformStorage (compatible with Android 12+)
Efficient full-text search
Multi-language support
Reduced binary size
Application-to-application data sharing
Capability to display data on System UI surfaces
Unlimited document size and count can be indexed
Faster operations without additional binder latency

在 LocalStorage 或 PlatformStorage 之间进行选择时,还需要考虑其他权衡因素。由于 PlatformStorage 将 Jetpack API 封装在 AppSearch 系统服务之上,因此与使用 LocalStorage 相比,对 APK 大小的影响微乎其微。但是,这也意味着,在调用 AppSearch 系统服务时,AppSearch 操作会导致额外的 binder 延迟。借助 PlatformStorage,AppSearch 会限制应用可以编入索引的文档数量和文档大小,以确保实现高效的集中索引。

AppSearch 使用入门

本部分中的示例展示了如何使用 AppSearch API 与假设的记事应用集成。

编写文档类

与 AppSearch 集成的第一步是编写一个文档类,用于描述要插入数据库的数据。使用 @Document 注解将类标记为文档类。您可以使用文档类的实例将文档放入并从数据库中检索文档。

以下代码定义了一个 Note 文档类,其中包含一个带有 @Document.StringProperty 注解的字段,用于将 Note 对象的文本编入索引。

Kotlin

@Document
public data class Note(

    // 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 note's text for this
    // document class.
    @Document.StringProperty(indexingType = AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES)
    val text: String
)

Java

@Document
public class Note {

  // Required field for a document class. All documents MUST have a namespace.
  @Document.Namespace
  private final String namespace;

  // Required field for a document class. All documents MUST have an Id.
  @Document.Id
  private final String id;

  // 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
  private final int score;

  // Optional field for a document class, used to index a note's text for this
  // document class.
  @Document.StringProperty(indexingType = StringPropertyConfig.INDEXING_TYPE_PREFIXES)
  private final String text;

  Note(@NonNull String id, @NonNull String namespace, int score, @NonNull String text) {
    this.id = Objects.requireNonNull(id);
    this.namespace = Objects.requireNonNull(namespace);
    this.score = score;
    this.text = Objects.requireNonNull(text);
  }

  @NonNull
  public String getNamespace() {
    return namespace;
  }

  @NonNull
  public String getId() {
    return id;
  }

  public int getScore() {
    return score;
  }

  @NonNull
  public String getText() {
     return text;
  }
}

打开数据库

您必须先创建数据库,然后才能使用文档。以下代码会创建一个名为 notes_app 的新数据库,并为 AppSearchSession 获取 ListenableFuture,后者表示与数据库的连接,并提供用于数据库操作的 API。

Kotlin

val context: Context = getApplicationContext()
val sessionFuture = LocalStorage.createSearchSession(
    LocalStorage.SearchContext.Builder(context, /*databaseName=*/"notes_app")
    .build()
)

Java

Context context = getApplicationContext();
ListenableFuture<AppSearchSession> sessionFuture = LocalStorage.createSearchSession(
       new LocalStorage.SearchContext.Builder(context, /*databaseName=*/ "notes_app")
               .build()
);

设置架构

您必须先设置架构,然后才能将文档放入数据库并从数据库中检索文档。数据库架构由不同类型的结构化数据组成,也称为“架构类型”。以下代码通过提供文档类作为架构类型来设置架构。

Kotlin

val setSchemaRequest = SetSchemaRequest.Builder().addDocumentClasses(Note::class.java)
    .build()
val setSchemaFuture = Futures.transformAsync(
    sessionFuture,
    { session ->
        session?.setSchema(setSchemaRequest)
    }, mExecutor
)

Java

SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder().addDocumentClasses(Note.class)
       .build();
ListenableFuture<SetSchemaResponse> setSchemaFuture =
       Futures.transformAsync(sessionFuture, session -> session.setSchema(setSchemaRequest), mExecutor);

将文档放入数据库

添加架构类型后,您可以将该类型的文档添加到数据库中。 以下代码使用 Note 文档类构建器构建架构类型为 Note 的文档。它会设置文档命名空间 user1 以表示此示例的任意用户。然后,文档会插入数据库中,并附加一个监听器来处理 put 操作的结果。

Kotlin

val note = Note(
    namespace="user1",
    id="noteId",
    score=10,
    text="Buy fresh fruit"
)

val putRequest = PutDocumentsRequest.Builder().addDocuments(note).build()
val putFuture = Futures.transformAsync(
    sessionFuture,
    { session ->
        session?.put(putRequest)
    }, mExecutor
)

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

            // Gets map of failed results from Id to AppSearchResult
            val failedResults = result?.failures
        }

        override fun onFailure(t: Throwable) {
            Log.e(TAG, "Failed to put documents.", t)
        }
    },
    mExecutor
)

Java

Note note = new Note(/*namespace=*/"user1", /*id=*/
                "noteId", /*score=*/ 10, /*text=*/ "Buy fresh fruit!");

PutDocumentsRequest putRequest = new PutDocumentsRequest.Builder().addDocuments(note)
       .build();
ListenableFuture<AppSearchBatchResult<String, Void>> putFuture =
       Futures.transformAsync(sessionFuture, session -> session.put(putRequest), mExecutor);

Futures.addCallback(putFuture, new FutureCallback<AppSearchBatchResult<String, Void>>() {
   @Override
   public void onSuccess(@Nullable AppSearchBatchResult<String, Void> result) {

     // Gets map of successful results from Id to Void
     Map<String, Void> successfulResults = result.getSuccesses();

     // Gets map of failed results from Id to AppSearchResult
     Map<String, AppSearchResult<Void>> failedResults = result.getFailures();
   }

   @Override
   public void onFailure(@NonNull Throwable t) {
      Log.e(TAG, "Failed to put documents.", t);
   }
}, mExecutor);

您可以使用本部分中介绍的搜索操作来搜索已编入索引的文档。以下代码会在数据库中对字词“fruit”执行查询,以查找属于 user1 命名空间的文档。

Kotlin

val searchSpec = SearchSpec.Builder()
    .addFilterNamespaces("user1")
    .build();

val searchFuture = Futures.transform(
    sessionFuture,
    { session ->
        session?.search("fruit", searchSpec)
    },
    mExecutor
)
Futures.addCallback(
    searchFuture,
    object : FutureCallback<SearchResults> {
        override fun onSuccess(searchResults: SearchResults?) {
            iterateSearchResults(searchResults)
        }

        override fun onFailure(t: Throwable?) {
            Log.e("TAG", "Failed to search notes in AppSearch.", t)
        }
    },
    mExecutor
)

Java

SearchSpec searchSpec = new SearchSpec.Builder()
       .addFilterNamespaces("user1")
       .build();

ListenableFuture<SearchResults> searchFuture =
       Futures.transform(sessionFuture, session -> session.search("fruit", searchSpec),
       mExecutor);

Futures.addCallback(searchFuture,
       new FutureCallback<SearchResults>() {
           @Override
           public void onSuccess(@Nullable SearchResults searchResults) {
               iterateSearchResults(searchResults);
           }

           @Override
           public void onFailure(@NonNull Throwable t) {
               Log.e(TAG, "Failed to search notes in AppSearch.", t);
           }
       }, mExecutor);

遍历 SearchResults

搜索会返回一个 SearchResults 实例,该实例会提供对 SearchResult 对象的页面的访问权限。每个 SearchResult 都包含其匹配的 GenericDocument,这是所有文档都转换为的文档的通用形式。以下代码可获取搜索结果的第一页,并将结果转换回 Note 文档。

Kotlin

Futures.transform(
    searchResults?.nextPage,
    { page: List<SearchResult>? ->
        // Gets GenericDocument from SearchResult.
        val genericDocument: GenericDocument = page!![0].genericDocument
        val schemaType = genericDocument.schemaType
        val note: Note? = try {
            if (schemaType == "Note") {
                // Converts GenericDocument object to Note object.
                genericDocument.toDocumentClass(Note::class.java)
            } else null
        } catch (e: AppSearchException) {
            Log.e(
                TAG,
                "Failed to convert GenericDocument to Note",
                e
            )
            null
        }
        note
    },
    mExecutor
)

Java

Futures.transform(searchResults.getNextPage(), page -> {
  // Gets GenericDocument from SearchResult.
  GenericDocument genericDocument = page.get(0).getGenericDocument();
  String schemaType = genericDocument.getSchemaType();

  Note note = null;

  if (schemaType.equals("Note")) {
    try {
      // Converts GenericDocument object to Note object.
      note = genericDocument.toDocumentClass(Note.class);
    } catch (AppSearchException e) {
      Log.e(TAG, "Failed to convert GenericDocument to Note", e);
    }
  }

  return note;
}, mExecutor);

移除文档

当用户删除记事时,应用会从数据库中删除相应的 Note 文档。这样可确保查询中不再显示相应备注。以下代码会明确请求按 ID 从数据库中移除 Note 文档。

Kotlin

val removeRequest = RemoveByDocumentIdRequest.Builder("user1")
    .addIds("noteId")
    .build()

val removeFuture = Futures.transformAsync(
    sessionFuture, { session ->
        session?.remove(removeRequest)
    },
    mExecutor
)

Java

RemoveByDocumentIdRequest removeRequest = new RemoveByDocumentIdRequest.Builder("user1")
       .addIds("noteId")
       .build();

ListenableFuture<AppSearchBatchResult<String, Void>> removeFuture =
       Futures.transformAsync(sessionFuture, session -> session.remove(removeRequest), mExecutor);

保留到磁盘

应通过调用 requestFlush() 定期将对数据库的更新保存到磁盘。以下代码使用监听器调用 requestFlush(),以确定调用是否成功。

Kotlin

val requestFlushFuture = Futures.transformAsync(
    sessionFuture,
    { session -> session?.requestFlush() }, mExecutor
)

Futures.addCallback(requestFlushFuture, object : FutureCallback<Void?> {
    override fun onSuccess(result: Void?) {
        // Success! Database updates have been persisted to disk.
    }

    override fun onFailure(t: Throwable) {
        Log.e(TAG, "Failed to flush database updates.", t)
    }
}, mExecutor)

Java

ListenableFuture<Void> requestFlushFuture = Futures.transformAsync(sessionFuture,
        session -> session.requestFlush(), mExecutor);

Futures.addCallback(requestFlushFuture, new FutureCallback<Void>() {
    @Override
    public void onSuccess(@Nullable Void result) {
        // Success! Database updates have been persisted to disk.
    }

    @Override
    public void onFailure(@NonNull Throwable t) {
        Log.e(TAG, "Failed to flush database updates.", t);
    }
}, mExecutor);

关闭会话

当应用不再调用任何数据库操作时,应关闭 AppSearchSession。以下代码可关闭之前打开的 AppSearch 会话,并将所有更新保留在磁盘中。

Kotlin

val closeFuture = Futures.transform<AppSearchSession, Unit>(sessionFuture,
    { session ->
        session?.close()
        Unit
    }, mExecutor
)

Java

ListenableFuture<Void> closeFuture = Futures.transform(sessionFuture, session -> {
   session.close();
   return null;
}, mExecutor);

其他资源

如需详细了解 AppSearch,请参阅下面列出的其他资源:

示例

提供反馈

通过以下资源与我们分享您的反馈和想法:

问题跟踪器

报告 bug,以便我们进行修复。