用途
该条目讨论 Kotlin 分页场景中一类隐蔽的搜索缺陷:对分页流(PagingData)执行 filter,例如 pagingData.filter { … },或对已加载快照使用 derivedStateOf 过滤,只会检查已经拉取到的页面。加载边界之后的内容并不是“不在结果里”,而是从未被查询过。搜索应当查询数据存储,而不是查询当前窗口。
主要问题与表现
这个缺陷的代价在于它具有欺骗性:功能看上去是正常的。集合较短时所有页面立即加载,过滤与真正的搜索无法区分;随着集合变长和滚动位置变化,问题才暴露,因此缺陷报告往往是“搜索偶尔漏东西”,复现步骤是“先向下滚动,再搜索”。
推荐形态
正确做法是对同一存储使用两个独立的读取器:分页读取器保持不变;另建一个搜索流,对查询词做 debounce、distinctUntilChanged、flatMapLatest,并直接调用 repository.search(collectionId, q),再以 stateIn 暴露。界面中搜索作为分页列表的兄弟节点(sibling)以覆盖层呈现,而不是分页列表内部的分支,这样底层列表保留滚动位置与手势。
要点与陷阱
- 把第二个数据源穿进分页列表会牵连拖拽重排、原地删除、选择状态和滚动位置等能力;覆盖层方案把这些复杂度留在分页读取器之外。
- 覆盖层复用同一行 composable,但要检查其参数默认值:分页路径隐式传入的必需 modifier 在覆盖层中需显式传入;若分页路径覆盖了某个默认值,两处渲染可能不一致。
- 单字符查询不是搜索:它匹配集合大部分内容,既难读又不便宜,且在每次搜索的第一个按键就触发。应在查询前以长度设门槛,例如 q.length < 2 时返回空列表。
- 只有 debounce 没有 flatMapLatest 时,每个查询仍会执行完毕;debounce 丢弃按键,flatMapLatest 取消在途请求,二者解决不同问题,缺一不可,否则慢查询可能乱序覆盖新结果。
- 转义应放在拥有该调用的数据源层,而不是查询字符串内部;查询词作为绑定参数时通配符仍会生效,需按 like-wildcard-escaping-ids 所述进行包装与转义。
- 采用两次往返而非联表:先由存储的自然查询缩小成员行,再按 id 获取展示行,并按第一个结果的排序重新排序,否则搜索列表顺序随意而分页列表保持集合顺序。
- 关闭搜索必须清空查询,否则重新打开会短暂显示旧结果,且结果流仍保持订阅。
- 当整个集合已在内存中时(如收藏、最常播放、Top-N 榜单等有界查询构建的自动集合),内存过滤是正确的;规则不是“永不过滤”,而是“永不过滤分页源”。同一屏幕上两类分区并存时,应并排保留两种实现并标注。
验证方式
用 grep 查找所有应用于分页源的 filter(匹配 PagingData、LazyPagingItems 并含 filter/search),每一处命中要么是该缺陷,要么是需注释说明的有意内存过滤。确认搜索流同时具备 debounce、latest-wins(flatMapLatest)和长度门槛三项防护;确认 ESCAPE 声明与 escapeForLike/escapeLike 调用成对出现。行为测试分两半:在不滚动的情况下搜索接近末尾的条目必须能找到;滚动到底部后搜索接近开头的条目并关闭搜索,列表必须保持原位置且拖拽重排仍可用;快速输入后删到单字符,结果应稳定在最终词,单字符状态应显示空而不是全集合。
适用场景与限制
适用于使用 Kotlin Paging 构建的长列表加搜索界面。资料未提供具体库版本、依赖配置或完整可运行示例;文中代码为改编示意,落地时需按项目实际仓库接口与 UI 框架调整。相关条目包括 continuation-token-pagination-contract 与 generic-paged-db-accumulator。