搜索

搜索:保留第四版正文机制,以所有者—状态—结果合同、事件轨迹和章专属故障完成可重放验收。

学习目标

  • 能沿“搜索”的用户事件解释Android组件、状态所有者、线程与销毁边界。
  • 能围绕“在 App 中添加搜索功能:使用 SearchView 控件、搜索建议提供器和 SharedPreferences 持久化搜索历史。”改出一个可运行结果,并用前后状态而非组件数量验收。
  • 能在旋转、进程重建、拒权、离线或无效输入中选择适用反例,定位首个状态分叉。
  • 能用请求或消息时间线、取消、乱序与失败恢复独立重放结论,并标明第四版机制与现代targetSdk政策的边界。

为什么搜索不只是"一个输入框"?

你用手机 App 时几乎每天都在用它:微信搜聊天记录、设置里搜 Wi-Fi、地图搜地址。看起来只是一个输入框,但背后的用户期待远超一个普通的输入框——它应该在应用栏的位置、有语音输入按钮、输入时弹出历史建议、点击搜索后键盘自动收起且显示结果。

如果不用专门为搜索设计的控件,这些效果你都得自己造轮子:监听输入变化、维护历史列表、弹出建议弹窗、处理键盘收起……这就是为什么 Android 提供了一个完整的搜索框架——SearchView、SearchManager、建议提供器三者配合,把上面这些需求包装成一套开箱即用的方案。

这章要解决的核心问题:怎么给你的 App 加一个专业级的搜索功能,而不是只扔一个 EditText 了事。

SearchView 与搜索框架

SearchView:应用栏里的专业搜索框

是 Android 为搜索场景深度定制的一个控件。它比普通 EditText 多了这些能力:

  • 搜索建议下拉列表(自动弹出、自动消失)
  • 语音搜索按钮(如果你的设备支持)
  • 与键盘的自动配合(提交后收起,不影响返回栈)
  • 可配置的外观(图标化/展开、提示文字、搜索图标)

searchable.xml:搜索配置宣言

Android 的搜索框架通过一个 XML 配置文件来声明搜索行为:

<!-- res/xml/searchable.xml -->
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
    android:label="@string/app_name"
    android:hint="搜索联系人..."
    android:searchSuggestAuthority="com.example.app.SearchSuggestionProvider"
    android:searchSuggestSelection=" ?" />

这个文件告诉系统:这个 App 支持搜索、搜索建议由哪个 ContentProvider 提供、提示文字是什么。

处理搜索查询:onQueryTextSubmit

SearchView 通过 OnQueryTextListener 回调通知你搜索事件:

searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
    override fun onQueryTextSubmit(query: String?): Boolean {
        // 用户按下"搜索"按钮(键盘上的回车或放大镜图标)
        performSearch(query ?: "")
        return true
    }
 
    override fun onQueryTextChange(newText: String?): Boolean {
        // 用户每输入一个字符就回调一次——适合做即时过滤
        filterData(newText ?: "")
        return true
    }
})

onQueryTextSubmit 只在用户确认搜索时触发一次。onQueryTextChange 每次输入变化都触发——如果你的数据集很大,不要在这里做全量查询。

代码逐段拆解:完整的搜索功能

第一步:在菜单中放置 SearchView

<!-- res/menu/fragment_main.xml -->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/menu_item_search"
        android:title="搜索"
        app:actionViewClass="android.widget.SearchView"
        app:showAsAction="ifRoom|collapseActionView" />
</menu>

app:actionViewClass="android.widget.SearchView" 是关键——它告诉系统这个菜单项不是一个普通按钮,而是展开为一个 SearchView 控件。

第二步:在代码中获取并配置 SearchView

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    super.onCreateOptionsMenu(menu, inflater)
    inflater.inflate(R.menu.fragment_main, menu)
 
    val searchItem = menu.findItem(R.id.menu_item_search)
    val searchView = searchItem.actionView as SearchView
 
    searchView.apply {
        queryHint = "搜索联系人..."
        setOnQueryTextListener(object : SearchView.OnQueryTextListener {
            override fun onQueryTextSubmit(query: String?): Boolean {
                query?.let { performSearch(it) }
                return true
            }
            override fun onQueryTextChange(newText: String?): Boolean {
                newText?.let { filterList(it) }
                return true
            }
        })
    }
}

第三步:用 SharedPreferences 保存搜索历史

class SearchHistoryManager(context: Context) {
    private val prefs = context.getSharedPreferences("search_history", Context.MODE_PRIVATE)
    private val maxHistory = 10
 
    fun addQuery(query: String) {
        val currentList = getHistory().toMutableList()
        currentList.remove(query)  // 去重:已存在的先删掉
        currentList.add(0, query)  // 最新的放最前面
        if (currentList.size > maxHistory) {
            currentList.removeAt(currentList.lastIndex)
        }
        // 序列化为 JSON 字符串存入 SharedPreferences
        prefs.edit().putString(KEY_HISTORY, Gson().toJson(currentList)).apply()
    }
 
    fun getHistory(): List<String> {
        val json = prefs.getString(KEY_HISTORY, "[]") ?: "[]"
        val type = object : TypeToken<List<String>>() {}.type
        return Gson().fromJson(json, type)
    }
 
    companion object {
        private const val KEY_HISTORY = "queries"
    }
}

搜索历史的本质:一个去重 + 限制条数 + 按时间排序的列表,用 SharedPreferences 持久化。Gson 做序列化比手动拼接分隔符更可靠——你不会被用户输入的逗号或换行符搞崩解析逻辑。

分步1 / 4

① 菜单声明 SearchView

res/menu/ 中添加 <item>,设置 app:actionViewClass="android.widget.SearchView"app:showAsAction="ifRoom|collapseActionView"actionViewClass 是关键——它告诉系统把菜单项渲染成 SearchView 控件而非普通按钮。

应用内搜索流程:输入 → 监听 → 防抖 → 取数 → 刷新结果输入处理结果SearchView(AppBar 菜单中)用户输入 queryOnQueryTextListener 触发onQueryTextChange / 提交触发 ACTION_SEARCHdebounce 防抖(可选)约 300ms 合并连续输入,避免每键一次请求ViewModel / Repository 按 query 取数过滤本地数据 或 发网络请求结果列表(RecyclerView)刷新显示搜索结果menu xml:app:actionViewClass把菜单项渲染成 SearchView 集成进应用栏searchable.xml声明可搜索元数据:提示文字 / 建议提供器查询防抖 debounce ~300msonQueryTextChange 频率极高,过滤前先去抖
应用内搜索的完整链路:用户在应用栏的 SearchView 输入 query,OnQueryTextListener 把每次输入(onQueryTextChange)和提交 (ACTION_SEARCH)分发出来;处理阶段先用 ~300ms 防抖合并连续按键,再由 ViewModel / Repository 过滤本地数据或发网络请求,最后让 RecyclerView 刷新出结果。 集成靠 menu xml 的 app:actionViewClasssearchable.xml 元数据。

容易踩的坑

小结

  • SearchView 是 Android 为搜索定制的控件,通过菜单配置 app:actionViewClass 集成到应用栏
  • searchable.xml 声明搜索元数据(提示文字、建议提供器),OnQueryTextListener 处理查询提交和输入变化
  • 搜索建议通过 ContentProvider 提供,SearchRecentSuggestionsProvider 可快速实现最近搜索记录
  • SharedPreferences + Gson 序列化是一种轻量级的搜索历史存储方案——去重 + 限制条数 + 时间排序
  • onQueryTextChange 回调频率极高,需要防抖或异步处理避免阻塞主线程

练习

问题 1:“第26章 SearchView and SharedPreferences”覆盖哪些正式节点和项目主线?

问题 2:怎样建立本页最小可执行实验?

问题 3:为什么只在正常点击路径运行不能证明完成?

问题 4:怎样设计能推翻当前实现的反例?

问题 5:从第4版语境迁移到现代目标SDK时如何控制变量?

问题 6:本页达到独立交接标准需要什么?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

SearchView

Android 为搜索场景深度定制的输入控件,内建搜索建议下拉列表、语音搜索按钮、与键盘自动配合。通常放在 Toolbar 中,通过 app:actionViewClass="android.widget.SearchView" 配置。详见本章"SearchView"一节。

SharedPreferences

Android 提供的一种轻量级键值对持久化存储方式。适合存储少量简单数据(如设置项、搜索历史),内部用 XML 文件存储。写入用 apply()(异步)或 commit()(同步,不推荐主线程)。详见本章"搜索历史"一节。

ContentProvider

Android 四大组件之一,用于管理结构化数据并提供跨应用/跨进程的数据访问接口。搜索建议就是一种特殊的 ContentProvider 应用——SearchView 通过 ContentResolver 调用 ContentProvider.query() 来获取建议列表。详见本章"搜索建议"一节。

“搜索”不使用未获授权的纸书正文;InformIT出版信息授权电子版完整目录只用于确认第四版32章、269个正式目录节点和时代语境,第四版官方勘误用于识别工具链变更。下列中文解释、图示、交互、代码与练习均为独立教学重写,平台行为再以Android Developers的一手文档复核。

为什么“搜索”必须回到可观察状态

“搜索”的学习结果不是记住类名,而是能预测“在 App 中添加搜索功能:使用 SearchView 控件、搜索建议提供器和 SharedPreferences 持久化搜索历史。”在一次输入、一次重建和一次失败中的不同状态,并指出哪条Android合同产生差异。

第四版机制逐项深读

26. SearchView and SharedPreferences

在“搜索”中,“26. SearchView and SharedPreferences”把查询输入、提交事件和轻量偏好持久化分开;防抖、空查询、进程重建与旧响应竞争都要用可重放事件序列验证。

Searching Flickr

在“搜索”中,“Searching Flickr”的API密钥与用户数据不得进入仓库或截图;测试使用受控fixture并记录服务合同版本。

Using SearchView

在“搜索”中,“Using SearchView”把查询输入、提交事件和轻量偏好持久化分开;防抖、空查询、进程重建与旧响应竞争都要用可重放事件序列验证。

Simple Persistence with SharedPreferences

在“搜索”中,“Simple Persistence with SharedPreferences”把查询输入、提交事件和轻量偏好持久化分开;防抖、空查询、进程重建与旧响应竞争都要用可重放事件序列验证。

Polishing Your App

在“搜索”中,“Polishing Your App”把查询输入、提交事件和轻量偏好持久化分开;防抖、空查询、进程重建与旧响应竞争都要用可重放事件序列验证。

Editing SharedPreferences with Android KTX

在“搜索”中,“Editing SharedPreferences with Android KTX”把查询输入、提交事件和轻量偏好持久化分开;防抖、空查询、进程重建与旧响应竞争都要用可重放事件序列验证。

Challenge: Polishing Your App Some More

在“搜索”中,“Challenge: Polishing Your App Some More”把查询输入、提交事件和轻量偏好持久化分开;防抖、空查询、进程重建与旧响应竞争都要用可重放事件序列验证。

“搜索”验收回顾

“搜索”只有在请求或消息时间线、取消、乱序与失败恢复能够从相同基线再次得到相同断言时才通过;第四版机制与现代平台政策分别记录,不用新API名称掩盖旧行为。

← 上一页:Looper、Handler和HandlerThread · 下一页:WorkManager →

章专属可重放状态实验

先预测“输入、提交、清空、旋转、冷启动和旧响应返回”发生后,查询状态所有者、偏好存储与 PhotoGallery Repository应怎样改变编辑文本、已提交查询、持久偏好、请求 ID 和结果;再操作三个实验。第四版示例与当前 Android 政策分别记录,实验不把新 API 名称倒填为原书内容。

实验一:所有者—状态—结果合同

选择任一正式目录节点和正常/边界场景,检查它是否真的进入本章状态合同。目录标题只有同时出现在解释、可视状态和交付证据中才算覆盖。

Owner · state · observable result

搜索:状态合同

把 SearchView 输入、提交、防抖、SharedPreferences 与网络结果竞争分开

验证场景

第四版正式目录节点

search · 正常任务

26. SearchView and SharedPreferences固定 SDK、设备配置和初始状态,触发“输入、提交、清空、旋转、冷启动和旧响应返回”

状态所有者查询状态所有者、偏好存储与 PhotoGallery Repository
受控状态编辑文本、已提交查询、持久偏好、请求 ID 和结果
触发事件输入、提交、清空、旋转、冷启动和旧响应返回

冻结入口:26. SearchView and SharedPreferences

记录查询状态所有者、偏好存储与 PhotoGallery Repository的初始编辑文本、已提交查询、持久偏好、请求 ID 和结果

观察:输入事件、提交时刻、preference 值、请求 ID 和结果断言中的“26. SearchView and SharedPreferences”轨迹

预期:由查询状态所有者、偏好存储与 PhotoGallery Repository提交编辑文本、已提交查询、持久偏好、请求 ID 和结果,并持续满足“轻量偏好只保存已确认查询,当前结果对应最新有效请求”

实验二:事件与生命周期轨迹

沿五次转换逐步执行“输入、提交、清空、旋转、冷启动和旧响应返回”。每一步只允许查询状态所有者、偏好存储与 PhotoGallery Repository按职责提交状态,并持续核对“轻量偏好只保存已确认查询,当前结果对应最新有效请求”。

Deterministic event replay

搜索:事件轨迹

选择一次状态转换1 / 5

不变量:轻量偏好只保存已确认查询,当前结果对应最新有效请求

交付证据:输入事件、提交时刻、preference 值、请求 ID 和结果断言

实验三:章专属反例与同输入恢复

注入“每个字符都立即写偏好并发请求,旧响应覆盖新输入”,保存第一个偏离点;撤销后以完全相同的 SDK、设备状态和用户事件重放。只有输入事件、提交时刻、preference 值、请求 ID 和结果断言一起恢复才算修复。

Fault · cancel · restore

搜索:反例与恢复

故障:每个字符都立即写偏好并发请求,旧响应覆盖新输入

1. 冻结输入一致

第 1 次使用相同 SDK、设备配置、初始状态与用户事件

2. 注入边界一致

保持正常输入不变,仅注入“每个字符都立即写偏好并发请求,旧响应覆盖新输入”

3. 检查所有者一致

轻量偏好只保存已确认查询,当前结果对应最新有效请求

4. 核对结果一致

输入事件、提交时刻、preference 值、请求 ID 和结果断言

讨论

评论区加载中…