搜索
搜索:保留第四版正文机制,以所有者—状态—结果合同、事件轨迹和章专属故障完成可重放验收。
学习目标
- 能沿“搜索”的用户事件解释Android组件、状态所有者、线程与销毁边界。
- 能围绕“在 App 中添加搜索功能:使用 SearchView 控件、搜索建议提供器和 SharedPreferences 持久化搜索历史。”改出一个可运行结果,并用前后状态而非组件数量验收。
- 能在旋转、进程重建、拒权、离线或无效输入中选择适用反例,定位首个状态分叉。
- 能用请求或消息时间线、取消、乱序与失败恢复独立重放结论,并标明第四版机制与现代targetSdk政策的边界。
为什么搜索不只是"一个输入框"?
你用手机 App 时几乎每天都在用它:微信搜聊天记录、设置里搜 Wi-Fi、地图搜地址。看起来只是一个输入框,但背后的用户期待远超一个普通的输入框——它应该在应用栏的位置、有语音输入按钮、输入时弹出历史建议、点击搜索后键盘自动收起且显示结果。
如果不用专门为搜索设计的控件,这些效果你都得自己造轮子:监听输入变化、维护历史列表、弹出建议弹窗、处理键盘收起……这就是为什么 Android 提供了一个完整的搜索框架——SearchView、SearchManager、建议提供器三者配合,把上面这些需求包装成一套开箱即用的方案。
这章要解决的核心问题:怎么给你的 App 加一个专业级的搜索功能,而不是只扔一个 EditText 了事。
SearchView 与搜索框架
SearchView:应用栏里的专业搜索框
↡SearchView 是 Android 提供的搜索专用输入控件。它不只是个带图标的输入框——它内建了搜索建议下拉列表、语音搜索按钮、自动收起键盘、以及跟 SearchManager 的深度集成。通常放在 Toolbar 或 ActionBar 中。是 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 做序列化比手动拼接分隔符更可靠——你不会被用户输入的逗号或换行符搞崩解析逻辑。
① 菜单声明 SearchView
在 res/menu/ 中添加 <item>,设置 app:actionViewClass="android.widget.SearchView" 和 app:showAsAction="ifRoom|collapseActionView"。actionViewClass 是关键——它告诉系统把菜单项渲染成 SearchView 控件而非普通按钮。
SearchView 输入 query,OnQueryTextListener 把每次输入(onQueryTextChange)和提交 (ACTION_SEARCH)分发出来;处理阶段先用 ~300ms 防抖合并连续按键,再由 ViewModel / Repository 过滤本地数据或发网络请求,最后让 RecyclerView 刷新出结果。 集成靠 menu xml 的 app:actionViewClass 与 searchable.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"一节。- 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、设备配置和初始状态,触发“输入、提交、清空、旋转、冷启动和旧响应返回”
冻结入口:26. SearchView and SharedPreferences
记录查询状态所有者、偏好存储与 PhotoGallery Repository的初始编辑文本、已提交查询、持久偏好、请求 ID 和结果
观察:输入事件、提交时刻、preference 值、请求 ID 和结果断言中的“26. SearchView and SharedPreferences”轨迹
预期:由查询状态所有者、偏好存储与 PhotoGallery Repository提交编辑文本、已提交查询、持久偏好、请求 ID 和结果,并持续满足“轻量偏好只保存已确认查询,当前结果对应最新有效请求”
实验二:事件与生命周期轨迹
沿五次转换逐步执行“输入、提交、清空、旋转、冷启动和旧响应返回”。每一步只允许查询状态所有者、偏好存储与 PhotoGallery Repository按职责提交状态,并持续核对“轻量偏好只保存已确认查询,当前结果对应最新有效请求”。
Deterministic event replay
搜索:事件轨迹
不变量:轻量偏好只保存已确认查询,当前结果对应最新有效请求
交付证据:输入事件、提交时刻、preference 值、请求 ID 和结果断言
实验三:章专属反例与同输入恢复
注入“每个字符都立即写偏好并发请求,旧响应覆盖新输入”,保存第一个偏离点;撤销后以完全相同的 SDK、设备状态和用户事件重放。只有输入事件、提交时刻、preference 值、请求 ID 和结果断言一起恢复才算修复。
Fault · cancel · restore
搜索:反例与恢复
故障:每个字符都立即写偏好并发请求,旧响应覆盖新输入
第 1 次使用相同 SDK、设备配置、初始状态与用户事件
保持正常输入不变,仅注入“每个字符都立即写偏好并发请求,旧响应覆盖新输入”
轻量偏好只保存已确认查询,当前结果对应最新有效请求
输入事件、提交时刻、preference 值、请求 ID 和结果断言