Flutter for OpenHarmony垃圾分类指南App实战:搜索实现
摘要:本文介绍了垃圾分类App搜索功能的实现,采用GetX状态管理方案。核心内容包括:1) 使用响应式变量管理搜索状态(关键词、结果、历史记录等);2) 实现搜索方法及历史记录处理逻辑;3) 设计搜索页面布局,根据状态动态切换显示内容;4) 构建实时搜索输入框,支持自动触发搜索和历史记录保存。该方案通过GetX的响应式特性简化状态管理,提升搜索体验,确保结果快速准确显示。

搜索功能可以说是这个垃圾分类App的核心了。用户不知道某个东西该扔哪个桶,打开App搜一下就知道了。所以这块的体验必须做好,响应要快,结果要准。
搜索状态管理
搜索涉及到的状态挺多的:输入的关键词、搜索结果、是否正在搜索、搜索历史。我们用GetX的响应式变量来管理:
class SearchPageController extends GetxController {
// 当前搜索的关键词
final searchText = ''.obs;
// 搜索结果列表
final searchResults = <GarbageItem>[].obs;
// 是否正在搜索(用于控制显示搜索结果还是搜索历史)
final isSearching = false.obs;
// 搜索历史(关键词列表)
final searchHistory = <String>[].obs;
为啥用.obs:加了
.obs的变量就变成响应式的了,值一变,界面上用Obx包着的地方就会自动刷新。不用手动调setState,代码会简洁很多。
.obs是GetX提供的响应式扩展,它的原理是:
- 创建一个
Rx<T>类型的包装对象 - 当你读取
.value时,GetX会记录哪个Obx在使用这个值 - 当你修改
.value时,GetX会通知所有使用这个值的Obx重建
这种机制比Flutter原生的setState更精准,因为它只会重建真正依赖这个值的Widget,而不是整个页面。
搜索方法的实现:
/// 执行搜索
/// [keyword] 搜索关键词
void search(String keyword) {
// 更新搜索关键词
searchText.value = keyword;
// 如果关键词为空,清空结果并退出搜索状态
if (keyword.isEmpty) {
searchResults.clear();
isSearching.value = false;
return;
}
// 进入搜索状态
isSearching.value = true;
// 调用数据层的搜索方法
searchResults.value = GarbageData.searchItems(keyword);
}
逻辑很简单:关键词为空就清空结果,不为空就去数据里搜。GarbageData.searchItems是我们封装的搜索方法,后面会讲到。
这个方法的设计遵循了几个原则:
- 单一职责:只负责搜索,不处理UI
- 防御性编程:先检查空值
- 状态同步:搜索状态和结果同时更新
搜索历史的处理
用户搜过的关键词要记下来,方便下次快速搜索:
/// 添加关键词到搜索历史
/// [keyword] 要添加的关键词
void addToHistory(String keyword) {
// 空关键词不添加
if (keyword.isEmpty) return;
// 如果已存在,先删除旧的(避免重复)
searchHistory.removeWhere((h) => h == keyword);
// 插入到列表最前面(最近搜的排第一)
searchHistory.insert(0, keyword);
// 限制历史记录数量,最多20条
if (searchHistory.length > 20) {
searchHistory.removeLast();
}
// 持久化存储(可选)
_saveHistory();
}
这段代码有几个细节:
- 先把已存在的相同关键词删掉,再插入到最前面,这样最近搜的永远排第一
- 限制最多存20条,太多了也没意义,还占空间
- 可以加上持久化存储,这样App重启后历史记录还在
为什么要先删后加?假设用户的搜索历史是['电池', '塑料瓶', '纸巾'],用户又搜了一次"电池"。如果直接插入,列表会变成['电池', '电池', '塑料瓶', '纸巾'],有重复。先删后加的话,列表会变成['电池', '塑料瓶', '纸巾'],"电池"移到了最前面,没有重复。
清空和删除单条历史:
/// 清空所有搜索历史
void clearHistory() {
searchHistory.clear();
_saveHistory();
}
/// 删除单条搜索历史
/// [keyword] 要删除的关键词
void removeFromHistory(String keyword) {
searchHistory.remove(keyword);
_saveHistory();
}
/// 持久化存储搜索历史
void _saveHistory() {
// 使用GetStorage或SharedPreferences保存
final storage = GetStorage();
storage.write('searchHistory', searchHistory.toList());
}
/// 加载搜索历史
void _loadHistory() {
final storage = GetStorage();
final history = storage.read<List>('searchHistory');
if (history != null) {
searchHistory.value = history.cast<String>();
}
}
void onInit() {
super.onInit();
_loadHistory(); // 控制器初始化时加载历史记录
}
搜索页面的布局
搜索页面分两部分:上面是输入框,下面根据状态显示搜索结果或者搜索历史:
class SearchPage extends StatelessWidget {
const SearchPage({super.key});
Widget build(BuildContext context) {
final controller = Get.find<SearchPageController>();
final homeController = Get.find<HomeController>();
return Scaffold(
appBar: AppBar(title: const Text('搜索')),
body: Column(
children: [
// 搜索输入框
_buildSearchInput(controller),
// 搜索结果或搜索历史
Expanded(
child: Obx(() {
// 根据搜索状态显示不同内容
if (controller.isSearching.value) {
return _buildSearchResults(controller, homeController);
}
return _buildSearchHistory(controller);
}),
),
],
),
);
}
Obx的妙用:
isSearching变了,Obx里面的内容就会重新build。正在搜索就显示结果列表,没在搜索就显示历史记录。这种条件渲染在Flutter里很常见。
页面结构说明:
Column把页面分成上下两部分- 搜索输入框固定在顶部
Expanded让下面的内容占据剩余空间Obx监听isSearching的变化,动态切换显示内容
搜索输入框
输入框要支持实时搜索,用户每输入一个字就触发一次搜索:
Widget _buildSearchInput(SearchPageController controller) {
return Container(
margin: EdgeInsets.all(16.w),
child: TextField(
// 每次输入变化都触发搜索
onChanged: controller.search,
// 用户按回车或点键盘搜索按钮时触发
onSubmitted: (value) {
controller.addToHistory(value);
},
onChanged绑定搜索方法,实现实时搜索。onSubmitted是用户按回车或者点键盘上的搜索按钮时触发,这时候把关键词加到历史记录里。
为什么要区分onChanged和onSubmitted?
onChanged:每次输入变化都触发,用于实时搜索onSubmitted:用户明确表示"我要搜索这个"时触发,用于记录历史
这样设计的好处是:用户输入过程中可以看到实时结果,但只有明确提交的关键词才会被记录到历史。
输入框的装饰:
decoration: InputDecoration(
hintText: '输入垃圾名称搜索',
// 搜索图标
prefixIcon: const Icon(Icons.search),
// 清除按钮(有内容时显示)
suffixIcon: Obx(() => controller.searchText.value.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.search('');
// 同时清空输入框
// 需要用TextEditingController来实现
},
)
: const SizedBox()),
// 输入框样式
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.r),
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
),
),
);
}
清除按钮的显示逻辑:输入框有内容时显示清除按钮,没内容时显示空的
SizedBox。用Obx包着,searchText变了按钮就会自动显示或隐藏。
关于输入框样式的设计:
prefixIcon:前置图标,放搜索图标suffixIcon:后置图标,放清除按钮filled: true:启用填充背景borderSide: BorderSide.none:去掉边框线borderRadius:圆角,让输入框看起来像胶囊
搜索结果的展示
搜索结果用列表展示,每个结果显示图标、名称和分类:
Widget _buildSearchResults(
SearchPageController controller,
HomeController homeController,
) {
// 空结果的处理
if (controller.searchResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 搜不到的图标
Icon(Icons.search_off, size: 64.sp, color: Colors.grey),
SizedBox(height: 16.h),
Text(
'未找到相关结果',
style: TextStyle(fontSize: 16.sp, color: Colors.grey),
),
SizedBox(height: 8.h),
Text(
'试试其他关键词',
style: TextStyle(fontSize: 14.sp, color: Colors.grey),
),
],
),
);
}
空结果的处理很重要,不能就显示个空白页面,得告诉用户"没找到,换个词试试"。这种设计叫做"空状态设计",是用户体验设计的重要组成部分。
好的空状态设计应该包含:
- 视觉元素:图标或插图,让页面不那么单调
- 说明文字:告诉用户发生了什么
- 引导文字:告诉用户下一步可以做什么
有结果时的列表:
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16.w),
itemCount: controller.searchResults.length,
itemBuilder: (context, index) {
final item = controller.searchResults[index];
return Card(
margin: EdgeInsets.only(bottom: 8.h),
child: ListTile(
// 物品图标(emoji)
leading: Text(item.icon, style: TextStyle(fontSize: 28.sp)),
// 物品名称
title: Text(item.name),
// 分类名称
subtitle: Text(item.typeName),
每个结果右边还有个分类标签,用对应的颜色显示:
// 分类标签
trailing: Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
decoration: BoxDecoration(
// 分类颜色的20%透明度作为背景
color: _getTypeColor(item.type).withOpacity(0.2),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
item.typeName,
style: TextStyle(
fontSize: 12.sp,
color: _getTypeColor(item.type),
),
),
),
// 点击结果的处理
onTap: () {
// 记录到最近搜索
homeController.addRecentSearch(item);
// 跳转到详情页
Get.toNamed(Routes.itemDetail, arguments: item);
},
),
);
},
);
}
点击结果的处理:先把这个物品加到最近搜索记录里,然后跳转到详情页。这样用户在"搜索历史"里就能看到自己查过的东西。
搜索历史的展示
没有在搜索时,显示搜索历史和热门搜索:
Widget _buildSearchHistory(SearchPageController controller) {
return SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 搜索历史
if (controller.searchHistory.isNotEmpty) ...[
_buildHistorySection(controller),
SizedBox(height: 24.h),
],
// 热门搜索
_buildHotSearch(controller),
],
),
);
}
Widget _buildHistorySection(SearchPageController controller) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'搜索历史',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
),
GestureDetector(
onTap: () => _showClearHistoryDialog(controller),
child: Text(
'清空',
style: TextStyle(fontSize: 14.sp, color: Colors.grey),
),
),
],
),
SizedBox(height: 12.h),
// 历史标签
Wrap(
spacing: 8.w,
runSpacing: 8.h,
children: controller.searchHistory.map((keyword) {
return GestureDetector(
onTap: () => controller.search(keyword),
onLongPress: () => controller.removeFromHistory(keyword),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16.r),
),
child: Text(keyword, style: TextStyle(fontSize: 14.sp)),
),
);
}).toList(),
),
],
);
}
历史标签支持两种交互:
- 点击:直接搜索这个关键词
- 长按:删除这条历史记录
热门搜索
没有搜索历史的时候,显示一些热门搜索词:
Widget _buildHotSearch(SearchPageController controller) {
// 热门搜索词列表
final hotItems = ['塑料瓶', '电池', '剩饭', '纸巾', '玻璃', '药品'];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'热门搜索',
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
),
SizedBox(height: 12.h),
// Wrap组件会自动换行
Wrap(
spacing: 8.w, // 水平间距
runSpacing: 8.h, // 行间距
children: hotItems.map((item) {
return GestureDetector(
onTap: () {
controller.search(item);
controller.addToHistory(item);
},
Wrap组件会自动换行,标签多了也不会挤在一起。点击热门词直接触发搜索,省得用户自己输入。
标签的样式:
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
decoration: BoxDecoration(
// 主题色的浅色背景
color: AppTheme.primaryColor.withOpacity(0.1),
// 胶囊形状
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
item,
style: TextStyle(
fontSize: 14.sp,
color: AppTheme.primaryColor,
),
),
),
);
}).toList(),
),
],
);
}
胶囊形状的标签,背景是主题色的浅色版本,看着清爽又不会太抢眼。
颜色映射方法
根据垃圾类型返回对应颜色:
/// 根据垃圾类型获取对应的颜色
Color _getTypeColor(GarbageType type) {
switch (type) {
case GarbageType.recyclable:
return AppTheme.recyclableColor; // 蓝色
case GarbageType.hazardous:
return AppTheme.hazardousColor; // 红色
case GarbageType.kitchen:
return AppTheme.kitchenColor; // 绿色
case GarbageType.other:
return AppTheme.otherColor; // 灰色
}
}
这个方法在好几个地方都会用到,保证整个App里分类颜色的一致性。可以考虑把它放到一个工具类里,或者作为GarbageType枚举的扩展方法:
extension GarbageTypeExtension on GarbageType {
Color get color {
switch (this) {
case GarbageType.recyclable:
return AppTheme.recyclableColor;
case GarbageType.hazardous:
return AppTheme.hazardousColor;
case GarbageType.kitchen:
return AppTheme.kitchenColor;
case GarbageType.other:
return AppTheme.otherColor;
}
}
}
// 使用方式
final color = item.type.color;
搜索算法的实现
GarbageData.searchItems方法的实现:
class GarbageData {
/// 搜索垃圾物品
/// [keyword] 搜索关键词
/// 返回匹配的物品列表
static List<GarbageItem> searchItems(String keyword) {
if (keyword.isEmpty) return [];
final lowerKeyword = keyword.toLowerCase();
return allItems.where((item) {
// 匹配名称
if (item.name.toLowerCase().contains(lowerKeyword)) return true;
// 匹配描述
if (item.description.toLowerCase().contains(lowerKeyword)) return true;
// 匹配别名
if (item.aliases.any((alias) =>
alias.toLowerCase().contains(lowerKeyword))) return true;
return false;
}).toList();
}
}
搜索会同时匹配名称、描述和别名。比如用户搜"瓶",能找到"塑料瓶"、“玻璃瓶”;搜"饮料",也能找到描述里包含"饮料"的物品。
搜索策略:这里用的是简单的包含匹配。如果数据量大或者需要更智能的搜索(比如拼音搜索、模糊匹配),可以考虑用专门的搜索库或者后端搜索服务。
性能优化
1. 防抖处理
实时搜索可能会触发太频繁,可以加个防抖:
Timer? _debounceTimer;
void search(String keyword) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_doSearch(keyword);
});
}
2. 搜索结果缓存
相同关键词的搜索结果可以缓存:
final _searchCache = <String, List<GarbageItem>>{};
List<GarbageItem> searchItems(String keyword) {
if (_searchCache.containsKey(keyword)) {
return _searchCache[keyword]!;
}
final results = _doSearch(keyword);
_searchCache[keyword] = results;
return results;
}
搜索功能的实现涵盖了Flutter开发中很多常见的场景:状态管理、列表渲染、条件显示等等。把这些东西串起来,就是一个完整的搜索模块了。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)