Flutter for OpenHarmony 商城App实战 - 搜索实现

搜索功能是电商应用的必备功能。一个好的搜索系统可以帮助用户快速找到他们想要的商品,提升用户体验和转化率。这篇文章会详细讲解如何实现一个功能完整的搜索系统,包括搜索输入、搜索历史、热门搜索、搜索结果、搜索建议等功能。
搜索功能的战略意义
搜索功能看似简单,但它对应用的成功有很大的影响。
快速定位商品 - 用户可能知道他们想要什么商品,但不知道在哪个分类中。搜索功能可以让用户快速找到他们想要的商品。这对于提升用户满意度很关键。
提升转化率 - 当用户能够快速找到他们想要的商品时,他们更可能进行购买。这直接提升了应用的转化率。根据研究,有搜索功能的应用的转化率比没有搜索功能的应用高 30-40%。
了解用户需求 - 通过分析搜索关键词,可以了解用户的需求,改进商品推荐和库存管理。这些数据对于产品决策很有价值。
支持个性化推荐 - 根据用户的搜索历史,可以为用户提供个性化的商品推荐。这可以提升用户的购物体验。
降低用户流失 - 如果用户无法快速找到他们想要的商品,他们可能会离开应用。一个好的搜索系统可以降低用户流失。
根据数据统计,大约 50-60% 的用户会使用搜索功能来找商品。这说明搜索功能是一个很重要的功能。在某些应用中,搜索功能甚至是用户最常用的功能。
搜索系统的架构设计
一个好的搜索系统应该包含以下几个部分:
搜索输入框 - 用户输入搜索关键词的地方。应该清晰易用,支持快速清除。
搜索历史 - 显示用户最近搜索过的关键词。这样用户可以快速重复搜索。搜索历史应该被持久化到本地存储。
热门搜索 - 显示当前热门的搜索关键词。这可以帮助用户发现新的商品。热门搜索通常从服务器获取。
搜索结果 - 显示与搜索关键词相关的商品。应该按相关性排序。搜索结果应该支持分页加载。
搜索建议 - 在用户输入时显示搜索建议。这可以帮助用户快速找到他们想要的商品。搜索建议应该被缓存。
加载状态 - 显示搜索正在进行中。这给用户反馈,让用户知道应用正在处理他们的请求。
空状态处理 - 当没有搜索结果时,应该显示友好的提示。这可以改善用户体验。
搜索页面的实现
让我们先看一下如何实现搜索页面。首先定义搜索页面的 Widget:
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
State<SearchPage> createState() => _SearchPageState();
}
这里使用 StatefulWidget 因为搜索页面需要管理搜索输入、搜索结果等状态。StatefulWidget 允许我们在用户交互时更新 UI。
为什么选择 StatefulWidget? 搜索页面需要响应用户的输入,管理搜索状态,显示加载指示器等。这些都需要状态管理。
搜索页面的状态定义
class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController();
final _recentSearches = ['iPhone', '运动鞋', '笔记本电脑', '手表'];
final _hotSearches = ['夏季特卖', '新品上市', '热销榜单', '清仓特价'];
bool _isSearching = false;
List<String> _results = [];
_searchController 是一个 TextEditingController,用来管理搜索输入框的内容。通过 controller,我们可以获取用户输入的文本,也可以以编程方式清除输入框。
_recentSearches 是一个列表,包含了用户最近搜索过的关键词。这个列表应该从本地存储加载。
_hotSearches 是一个列表,包含了当前热门的搜索关键词。这个列表通常从服务器获取。
_isSearching 是一个布尔值,表示是否正在搜索。当用户输入搜索关键词时,这个值会被设置为 true,显示一个加载指示器。
_results 是一个列表,包含了搜索结果。当搜索完成时,这个列表会被填充。
资源清理
void dispose() {
_searchController.dispose();
super.dispose();
}
dispose 是 StatefulWidget 的生命周期方法,在 Widget 被销毁时调用。这里调用 _searchController.dispose() 来释放 TextEditingController 占用的资源。
为什么需要释放资源? TextEditingController 会占用内存和系统资源。如果不释放,会导致内存泄漏。这是一个很常见的错误。在实际项目中,忘记释放资源会导致应用性能下降。
搜索逻辑的实现
void _search(String query) {
if (query.trim().isEmpty) {
setState(() { _isSearching = false; _results = []; });
return;
}
首先检查搜索关键词是否为空。query.trim() 移除关键词前后的空格。这样可以避免用户输入空格导致的问题。
如果关键词为空,就清空搜索结果并返回。这样可以避免不必要的搜索。
setState(() => _isSearching = true);
Future.delayed(const Duration(milliseconds: 300), () {
if (!mounted) return;
setState(() { _isSearching = false; _results = List.generate(10, (i) => '$query 搜索结果 ${i + 1}'); });
});
然后设置 _isSearching 为 true,显示加载指示器。
接下来使用 Future.delayed() 来模拟网络请求的延迟。在实际项目中,这里应该是真实的网络请求。
if (!mounted) return 检查 Widget 是否还在树中。如果 Widget 已经被销毁,就不执行后续代码。这可以避免在已销毁的 Widget 上调用 setState,这会导致错误。
最后生成搜索结果并设置 _isSearching 为 false,隐藏加载指示器。
搜索输入框的实现
搜索输入框应该清晰易用,让用户可以快速输入搜索关键词。这是搜索功能的入口点,所以设计很重要。
AppBar 中的搜索输入框
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: '搜索商品...',
border: InputBorder.none,
Scaffold 是一个基础的页面结构组件。它提供了 AppBar、body 等基础结构。
AppBar 是页面的顶部栏。这里把搜索输入框放在 AppBar 中,这样可以充分利用屏幕空间。这是一个很常见的设计模式。
TextField 是一个文本输入框。这是用户输入搜索关键词的地方。
controller: _searchController 将输入框与 TextEditingController 关联。这样可以获取用户输入的内容。
autofocus: true 让输入框在页面加载时自动获得焦点。这样用户可以立即开始输入,不需要点击输入框。这改善了用户体验。
hintText: ‘搜索商品…’ 是输入框的提示文字。当输入框为空时,会显示这个文字。提示文字应该清晰地说明用户应该输入什么。
border: InputBorder.none 移除输入框的边框。这样输入框看起来更简洁,与 AppBar 融为一体。
清除按钮的实现
suffixIcon: _searchController.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); _search(''); }) : null,
suffixIcon 是输入框右边的图标。这里显示一个清除按钮。
_searchController.text.isNotEmpty 检查输入框是否有内容。只有当有内容时,才显示清除按钮。这样可以避免在输入框为空时显示无用的按钮。
_searchController.clear() 清除输入框的内容。
_search(‘’) 清除搜索结果。这样用户点击清除按钮后,会回到初始状态。
搜索事件的处理
onChanged: _search,
onSubmitted: _search,
onChanged 回调在用户输入时触发。这样可以实时搜索。当用户输入每个字符时,都会触发搜索。这给用户实时的反馈。
onSubmitted 回调在用户按下回车键时触发。这样用户可以通过按回车键来搜索。这是一个标准的交互模式。
实时搜索的优缺点:
- 优点 - 用户可以看到实时的搜索结果,不需要按回车键
- 缺点 - 可能会产生大量的网络请求,消耗服务器资源
在实际项目中,可以使用防抖(debounce)来优化实时搜索。防抖可以减少网络请求的数量。
搜索输入框的完整实现
让我们看一下搜索输入框的完整代码,包括所有的装饰和事件处理:
),
),
body: _isSearching
这里关闭了 AppBar 的 title 配置,然后开始定义页面的 body。body 是页面的主要内容区域,会根据搜索状态显示不同的内容。
为什么要分离 AppBar 和 body? 这样可以让搜索输入框始终显示在顶部,而下面的内容可以根据搜索状态动态变化。这是一个很常见的设计模式。
搜索输入框的样式优化
在实际项目中,可能需要对搜索输入框进行更多的样式优化:
decoration: InputDecoration(
hintText: '搜索商品...',
hintStyle: TextStyle(color: Colors.grey.shade400),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
filled: true,
fillColor: Colors.grey.shade100,
),
hintStyle 定义了提示文字的样式。这里设置为灰色,这样可以与输入的文字区分开。
contentPadding 定义了输入框内部的内边距。这样可以让文字不会紧贴输入框的边缘。
filled: true 让输入框有背景颜色。
fillColor 定义了输入框的背景颜色。这里使用浅灰色,这样可以让输入框看起来更突出。
搜索输入框的光标样式
cursorColor: Theme.of(context).colorScheme.primary,
cursorWidth: 2,
cursorRadius: const Radius.circular(2),
cursorColor 定义了光标的颜色。这里使用主题的主色,这样可以保持应用的视觉一致性。
cursorWidth 定义了光标的宽度。2 像素是一个合理的宽度。
cursorRadius 定义了光标的圆角。这样光标看起来更柔和。
搜索结果的显示
搜索结果应该清晰易读,让用户可以快速找到他们想要的商品。搜索结果的展示方式对用户体验有很大的影响。
搜索中的状态
body: _isSearching
? const LoadingView(label: '搜索中...')
当 _isSearching 为 true 时,显示一个加载指示器。
LoadingView 是一个自定义的加载指示器组件。它通常显示一个旋转的加载动画和一个文字提示。
为什么需要加载指示器? 加载指示器给用户反馈,让用户知道应用正在处理他们的请求。没有加载指示器的话,用户可能会认为应用没有响应。
搜索结果列表
: _results.isNotEmpty
? ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _results.length,
itemBuilder: (context, index) {
return ShopCard(
child: ListTile(
leading: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8)
),
child: const Icon(Icons.image, color: Colors.grey)
),
当有搜索结果时,显示一个列表。
ListView.builder 是一个高效的列表组件。它只会构建可见的 Widget。这对于大量数据很重要。
padding: const EdgeInsets.all(16) 添加了内边距,让内容不会紧贴屏幕边缘。
leading 显示商品的图片。这里使用一个占位符。在实际项目中,这里应该显示真实的商品图片。
Container 用来创建一个固定大小的容器。这里用来显示商品图片的占位符。
BoxDecoration 定义了容器的样式,包括背景颜色和圆角。圆角可以让图片看起来更美观。
商品信息的显示
title: Text(_results[index]),
subtitle: Text('¥${(19.99 + index * 5).toStringAsFixed(2)}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
title 显示商品的名称。
subtitle 显示商品的价格。使用 toStringAsFixed(2) 来格式化价格,保留两位小数。
trailing 显示一个右箭头,表示这是一个可点击的项目。这是一个标准的 UI 模式。
onTap 回调在用户点击时触发。这里应该导航到商品详情页面。
搜索结果卡片的完整结构
在实际项目中,搜索结果卡片通常包含更多的信息。让我们看一下一个更完整的实现:
title: Text(
_results[index],
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('¥${(19.99 + index * 5).toStringAsFixed(2)}'),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.star, size: 12, color: Colors.amber),
const SizedBox(width: 4),
Text('4.5 (${100 + index * 10}条评价)', style: Theme.of(context).textTheme.bodySmall),
],
),
],
),
maxLines: 2 限制商品名称最多显示 2 行。如果名称太长,就会被截断。
overflow: TextOverflow.ellipsis 如果商品名称超过 2 行,就用省略号表示。这样可以避免文字溢出。
fontWeight: FontWeight.w500 让商品名称看起来更突出。
Column 用来在 subtitle 中显示多行信息。这样可以显示价格、评分等多个信息。
Row 用来水平排列评分图标和评分文字。
Icons.star 是一个星形图标,表示评分。
搜索结果的加载更多
当用户滚动到列表底部时,应该自动加载更多的搜索结果。这可以改善用户体验:
itemBuilder: (context, index) {
// 如果是最后一项,显示加载指示器
if (index == _results.length - 1 && _hasMore) {
_loadMore();
return const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
);
}
return ShopCard(
child: ListTile(
if (index == _results.length - 1 && _hasMore) 检查是否是最后一项,并且还有更多的结果。
_loadMore() 触发加载更多的结果。
CircularProgressIndicator() 显示一个加载指示器,让用户知道正在加载更多的结果。
搜索历史的显示
当没有搜索结果时,显示搜索历史和热门搜索。
: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('搜索历史', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _recentSearches.map((s) => ActionChip(
avatar: const Icon(Icons.history, size: 16),
label: Text(s),
onPressed: () {
_searchController.text = s;
_search(s);
}
)).toList(),
),
Wrap 用来水平排列搜索历史。如果空间不足,会自动换行。这样可以在小屏幕上也能显示所有的选项。
ActionChip 是一个操作芯片组件。用户可以点击来执行操作。
avatar 显示芯片左边的图标。这里使用历史图标来表示搜索历史。
label 显示芯片的文字。
onPressed 回调在用户点击芯片时触发。这里设置搜索关键词并执行搜索。
spacing: 8 是芯片之间的间距。
runSpacing: 8 是行之间的间距。
热门搜索的实现
const SizedBox(height: 24),
Text('热门搜索', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _hotSearches.map((s) => ActionChip(
avatar: const Icon(Icons.local_fire_department, size: 16, color: Colors.orange),
label: Text(s),
onPressed: () {
_searchController.text = s;
_search(s);
}
)).toList(),
),
热门搜索的实现与搜索历史类似,但使用了不同的图标(火焰图标)来表示热门。
const SizedBox(height: 24) 在搜索历史和热门搜索之间添加了间距。这样可以让两个部分看起来更分离。
Icons.local_fire_department 是一个火焰图标,表示热门。
color: Colors.orange 让火焰图标显示为橙色,这样可以吸引用户的注意力。
搜索历史和热门搜索的数据来源
在实际项目中,这些数据应该从服务器获取,而不是硬编码:
Future<void> _loadSearchData() async {
try {
// 从本地存储加载搜索历史
final prefs = await SharedPreferences.getInstance();
final recentHistory = prefs.getStringList('search_history') ?? [];
// 从服务器加载热门搜索
final hotSearches = await _api.getHotSearches();
setState(() {
_recentSearches = recentHistory.take(10).toList();
_hotSearches = hotSearches;
});
} catch (e) {
print('Failed to load search data: $e');
}
}
void initState() {
super.initState();
_loadSearchData();
}
_loadSearchData() 方法在页面初始化时调用,加载搜索历史和热门搜索。
SharedPreferences.getInstance() 获取本地存储的实例。
prefs.getStringList(‘search_history’) 从本地存储获取搜索历史。
_api.getHotSearches() 从服务器获取热门搜索。
setState() 更新页面的状态,显示加载的数据。
搜索历史的删除功能
用户应该能够删除不需要的搜索历史。这可以通过长按搜索历史项来实现:
children: _recentSearches.map((s) => ActionChip(
avatar: const Icon(Icons.history, size: 16),
label: Text(s),
onPressed: () {
_searchController.text = s;
_search(s);
},
onDeleted: () async {
// 从本地存储删除搜索历史
final prefs = await SharedPreferences.getInstance();
final history = prefs.getStringList('search_history') ?? [];
history.remove(s);
await prefs.setStringList('search_history', history);
setState(() {
_recentSearches.remove(s);
});
},
)).toList(),
onDeleted 回调在用户点击芯片上的删除按钮时触发。
这个实现从本地存储删除搜索历史,然后更新页面的状态。
为什么需要删除功能? 用户可能想要清除某些搜索历史,比如误输入的搜索词。提供删除功能可以让用户更好地管理他们的搜索历史。
搜索功能的优化
搜索建议的实现
在实际项目中,应该在用户输入时显示搜索建议。这可以帮助用户快速找到他们想要的商品。搜索建议通常是根据用户的输入和热门搜索词生成的。
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController();
List<String> _suggestions = [];
void _updateSuggestions(String query) {
if (query.isEmpty) {
setState(() => _suggestions = []);
return;
}
// 从服务器获取搜索建议
_api.getSuggestions(query).then((suggestions) {
if (mounted) {
setState(() => _suggestions = suggestions);
}
});
}
_updateSuggestions() 方法在用户输入时调用,获取搜索建议。
为什么需要搜索建议? 搜索建议可以帮助用户快速找到他们想要的商品。用户不需要输入完整的商品名称,就可以看到相关的建议。这可以提升搜索的效率。
搜索建议的显示
搜索建议应该在用户输入时显示在输入框下方。这可以通过一个下拉菜单来实现:
Stack(
children: [
TextField(
controller: _searchController,
onChanged: _updateSuggestions,
decoration: InputDecoration(
hintText: '搜索商品...',
border: OutlineInputBorder(),
),
),
if (_suggestions.isNotEmpty)
Positioned(
top: 56,
left: 0,
right: 0,
child: Container(
color: Colors.white,
child: ListView.builder(
shrinkWrap: true,
itemCount: _suggestions.length,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.search, size: 18),
title: Text(_suggestions[index]),
onTap: () {
_searchController.text = _suggestions[index];
_search(_suggestions[index]);
setState(() => _suggestions = []);
},
);
},
),
),
),
],
)
Stack 用来叠加输入框和建议列表。
Positioned 用来定位建议列表,让它显示在输入框下方。
top: 56 表示建议列表距离顶部 56 像素,这样它会显示在输入框下方。
ListView.builder 用来显示建议列表。
onTap 回调在用户点击建议时触发。这里设置搜索关键词并执行搜索。
搜索建议的优化
为了提升搜索建议的性能,可以使用缓存和防抖:
Timer? _suggestionsTimer;
final Map<String, List<String>> _suggestionsCache = {};
void _updateSuggestions(String query) {
_suggestionsTimer?.cancel();
if (query.isEmpty) {
setState(() => _suggestions = []);
return;
}
// 检查缓存
if (_suggestionsCache.containsKey(query)) {
setState(() => _suggestions = _suggestionsCache[query]!);
return;
}
// 延迟 300 毫秒后获取建议
_suggestionsTimer = Timer(const Duration(milliseconds: 300), () async {
try {
final suggestions = await _api.getSuggestions(query);
if (mounted) {
// 缓存建议
_suggestionsCache[query] = suggestions;
setState(() => _suggestions = suggestions);
}
} catch (e) {
print('Failed to get suggestions: $e');
}
});
}
void dispose() {
_suggestionsTimer?.cancel();
super.dispose();
}
_suggestionsTimer 用来实现防抖。当用户输入时,会取消之前的 Timer,然后创建一个新的 Timer。
_suggestionsCache 用来缓存搜索建议。这样可以避免重复的网络请求。
延迟 300 毫秒 可以减少网络请求的数量。用户通常会在 300 毫秒内继续输入。
if (mounted) 检查 Widget 是否还在树中。这可以避免在已销毁的 Widget 上调用 setState。
搜索历史的持久化
搜索历史应该被保存到本地存储,这样用户下次打开应用时,搜索历史仍然存在。这是一个很重要的功能,可以改善用户体验。
Future<void> _addToSearchHistory(String query) async {
final prefs = await SharedPreferences.getInstance();
// 获取现有的搜索历史
final history = prefs.getStringList('search_history') ?? [];
// 移除重复的搜索词
history.remove(query);
// 添加新的搜索词到开头
history.insert(0, query);
// 只保留最近的 20 个搜索词
if (history.length > 20) {
history.removeRange(20, history.length);
}
// 保存到本地存储
await prefs.setStringList('search_history', history);
}
这个方法将搜索词添加到搜索历史中。它会:
- 移除重复的搜索词 - 如果用户搜索过相同的词,就移除旧的记录
- 将新的搜索词添加到开头 - 最新的搜索词显示在最前面
- 只保留最近的 20 个搜索词 - 这样可以避免搜索历史变得太长
为什么要限制搜索历史的数量? 如果搜索历史太长,会占用更多的存储空间。而且用户通常只关心最近的搜索词。
搜索历史的加载
在页面初始化时,应该从本地存储加载搜索历史:
Future<List<String>> _loadSearchHistory() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList('search_history') ?? [];
}
void initState() {
super.initState();
_loadSearchHistory().then((history) {
if (mounted) {
setState(() {
_recentSearches = history;
});
}
});
}
_loadSearchHistory() 方法从本地存储加载搜索历史。
initState() 是 StatefulWidget 的生命周期方法,在 Widget 创建时调用一次。这是加载初始数据的最佳时机。
if (mounted) 检查 Widget 是否还在树中。这可以避免在已销毁的 Widget 上调用 setState。
搜索历史的清空功能
用户应该能够清空所有的搜索历史。这可以通过一个清空按钮来实现:
Future<void> _clearSearchHistory() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('清空搜索历史'),
content: const Text('确定要清空所有搜索历史吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('确定'),
),
],
),
);
if (confirmed == true) {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('search_history');
setState(() {
_recentSearches = [];
});
}
}
showDialog() 显示一个确认对话框。这样可以防止用户误操作。
AlertDialog 是一个对话框组件。
actions 定义了对话框的按钮。
Navigator.of(context).pop() 关闭对话框并返回结果。
prefs.remove(‘search_history’) 从本地存储删除搜索历史。
为什么需要确认对话框? 清空搜索历史是一个不可逆的操作。显示确认对话框可以防止用户误操作。
搜索结果的缓存
搜索结果应该被缓存,这样用户重复搜索相同的关键词时,可以快速获取结果。缓存可以大大提升搜索的性能。
class SearchCache {
static final Map<String, List<Product>> _cache = {};
static final Map<String, DateTime> _cacheTime = {};
static const Duration _cacheDuration = Duration(hours: 1);
static List<Product>? get(String query) {
if (!_cache.containsKey(query)) {
return null;
}
final cacheTime = _cacheTime[query];
if (cacheTime != null &&
DateTime.now().difference(cacheTime) > _cacheDuration) {
_cache.remove(query);
_cacheTime.remove(key);
return null;
}
return _cache[query];
}
static void set(String query, List<Product> results) {
_cache[query] = results;
_cacheTime[query] = DateTime.now();
}
static void clear() {
_cache.clear();
_cacheTime.clear();
}
}
这个类实现了一个简单的搜索结果缓存。
_cache 存储了缓存的搜索结果。
_cacheTime 存储了每个缓存的时间。
_cacheDuration 定义了缓存的有效期。超过这个时间的缓存会被删除。这样可以确保搜索结果不会太过时。
get() 方法获取缓存的数据。如果缓存已过期,就返回 null。
set() 方法保存数据到缓存。
clear() 方法清除所有缓存。
缓存的内存管理
为了避免缓存占用过多的内存,可以实现一个 LRU(最近最少使用)缓存策略:
class LRUSearchCache {
static final Map<String, List<Product>> _cache = {};
static final Map<String, DateTime> _cacheTime = {};
static final Map<String, int> _accessCount = {};
static const int _maxCacheSize = 50;
static const Duration _cacheDuration = Duration(hours: 1);
static List<Product>? get(String query) {
if (!_cache.containsKey(query)) {
return null;
}
final cacheTime = _cacheTime[query];
if (cacheTime != null &&
DateTime.now().difference(cacheTime) > _cacheDuration) {
_cache.remove(query);
_cacheTime.remove(query);
_accessCount.remove(query);
return null;
}
// 更新访问计数
_accessCount[query] = (_accessCount[query] ?? 0) + 1;
return _cache[query];
}
static void set(String query, List<Product> results) {
// 如果缓存已满,删除最少使用的项
if (_cache.length >= _maxCacheSize) {
final leastUsedKey = _accessCount.entries
.reduce((a, b) => a.value < b.value ? a : b)
.key;
_cache.remove(leastUsedKey);
_cacheTime.remove(leastUsedKey);
_accessCount.remove(leastUsedKey);
}
_cache[query] = results;
_cacheTime[query] = DateTime.now();
_accessCount[query] = 1;
}
static void clear() {
_cache.clear();
_cacheTime.clear();
_accessCount.clear();
}
}
_accessCount 记录每个缓存项的访问次数。
_maxCacheSize 定义了缓存的最大大小。当缓存满时,会删除最少使用的项。
reduce() 方法找到访问次数最少的项。
为什么需要 LRU 缓存? LRU 缓存可以自动删除最少使用的项,这样可以避免缓存占用过多的内存。
使用缓存的搜索
Future<List<Product>> _searchProducts(String query) async {
// 先检查缓存
final cached = SearchCache.get(query);
if (cached != null) {
return cached;
}
try {
// 从服务器获取数据
final results = await _api.searchProducts(query);
// 保存到缓存
SearchCache.set(query, results);
return results;
} catch (e) {
print('Failed to search products: $e');
rethrow;
}
}
这个方法在搜索商品时,先检查缓存。如果缓存存在且未过期,就直接返回缓存的数据。否则,从服务器获取数据并保存到缓存。
缓存的好处:
- 减少网络请求 - 不需要每次都从服务器获取数据
- 提升搜索速度 - 本地缓存的数据加载速度很快
- 节省流量 - 减少网络流量消耗
- 离线支持 - 用户可以在离线状态下查看缓存的数据
搜索功能的最佳实践
1. 快速响应
搜索应该快速响应用户的输入。不应该让用户等待太长时间。
如何实现快速响应?
- 使用防抖(debounce)来减少网络请求的数量
- 使用缓存来避免重复的网络请求
- 在后台线程中执行搜索,不要阻塞 UI 线程
2. 清晰的搜索结果
搜索结果应该清晰易读。应该显示商品的图片、名称、价格等关键信息。
搜索结果应该包含:
- 商品图片 - 帮助用户快速识别商品
- 商品名称 - 清晰地说明商品是什么
- 商品价格 - 帮助用户了解商品的成本
- 商品评分 - 帮助用户了解商品的质量
- 库存状态 - 让用户知道商品是否有货
3. 搜索建议
在用户输入时显示搜索建议可以帮助用户快速找到他们想要的商品。
搜索建议的来源:
- 热门搜索词 - 显示当前热门的搜索词
- 用户搜索历史 - 显示用户之前搜索过的词
- 自动完成 - 根据用户的输入自动完成搜索词
4. 搜索历史
显示搜索历史可以让用户快速重复搜索。这对于用户想要再次搜索相同的商品很有用。
搜索历史的管理:
- 限制数量 - 只保留最近的 20 个搜索词
- 支持删除 - 让用户可以删除不需要的搜索历史
- 支持清空 - 让用户可以一次性清空所有搜索历史
5. 热门搜索
显示热门搜索可以帮助用户发现新的商品。这对于用户想要了解当前热门商品很有用。
热门搜索的更新:
- 定期更新 - 热门搜索应该定期更新,反映当前的趋势
- 基于数据 - 热门搜索应该基于真实的搜索数据
- 个性化 - 可以根据用户的地区、兴趣等来个性化热门搜索
6. 搜索过滤
提供搜索过滤选项可以让用户更精确地搜索。比如,用户可以按价格范围、评分等来过滤搜索结果。
常见的搜索过滤选项:
- 价格范围 - 让用户按价格范围来过滤
- 评分 - 让用户只看高评分的商品
- 品牌 - 让用户按品牌来过滤
- 库存状态 - 让用户只看有货的商品
搜索性能优化
1. 使用 ListView.builder
使用 ListView.builder 而不是 ListView 可以提升性能。ListView.builder 只会构建可见的 Widget。
为什么这很重要? 如果有 1000 个搜索结果,使用 ListView 会一次性构建所有 1000 个 Widget。这会消耗大量的内存和 CPU。而 ListView.builder 只会构建屏幕上可见的 Widget,比如 10 个。当用户滚动时,不可见的 Widget 会被销毁,新的 Widget 会被构建。这样可以大大减少内存消耗。
2. 搜索结果分页
如果搜索结果很多,应该使用分页加载。这样可以减少初始加载时间。
class PaginatedSearchResults extends StatefulWidget {
final String query;
const PaginatedSearchResults({required this.query});
State<PaginatedSearchResults> createState() => _PaginatedSearchResultsState();
}
class _PaginatedSearchResultsState extends State<PaginatedSearchResults> {
int _currentPage = 1;
final int _pageSize = 20;
List<Product> _products = [];
bool _isLoading = false;
bool _hasMore = true;
void initState() {
super.initState();
_loadMore();
}
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
try {
final newProducts = await _api.searchProducts(
query: widget.query,
page: _currentPage,
pageSize: _pageSize,
);
setState(() {
_products.addAll(newProducts);
_currentPage++;
_hasMore = newProducts.length == _pageSize;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
print('Failed to load products: $e');
}
}
Widget build(BuildContext context) {
return ListView.builder(
itemCount: _products.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _products.length) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
} else if (_hasMore) {
_loadMore();
return const SizedBox.shrink();
}
return const SizedBox.shrink();
}
return ProductTile(product: _products[index]);
},
);
}
}
这个组件实现了分页加载。当用户滚动到列表底部时,会自动加载下一页的商品。
分页的好处:
- 减少初始加载时间 - 不需要一次性加载所有结果
- 节省流量 - 只加载用户需要的数据
- 提升性能 - 减少内存消耗
3. 搜索结果缓存
缓存搜索结果可以提升性能。当用户重复搜索相同的关键词时,可以快速获取结果。
4. 搜索建议缓存
缓存搜索建议可以减少网络请求。这样可以提升搜索建议的响应速度。
5. 防抖搜索
使用防抖可以减少网络请求的数量。防抖会延迟搜索,直到用户停止输入。
Timer? _debounceTimer;
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_search(query);
});
}
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
这个实现使用 Timer 来实现防抖。当用户输入时,会取消之前的 Timer,然后创建一个新的 Timer。只有当用户停止输入 500 毫秒后,才会执行搜索。
防抖的好处:
- 减少网络请求 - 不需要为每个字符都发送网络请求
- 节省服务器资源 - 减少服务器的负担
- 提升用户体验 - 搜索结果更稳定
防抖的实际应用
在搜索输入框中使用防抖:
TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: '搜索商品...',
border: OutlineInputBorder(),
),
)
onChanged 回调在用户输入时触发。这里调用 _onSearchChanged() 来实现防抖。
防抖的性能对比
让我们看一下防抖对性能的影响:
不使用防抖:
- 用户输入 “iPhone”(6 个字符)
- 会发送 6 次网络请求
- 服务器需要处理 6 次请求
使用防抖(500ms):
- 用户输入 “iPhone”(6 个字符)
- 只会发送 1 次网络请求
- 服务器只需要处理 1 次请求
性能提升: 减少了 83% 的网络请求
防抖延迟的选择
防抖延迟的选择很重要。延迟太短可能无法有效减少请求,延迟太长可能会让用户感到搜索不够实时:
// 快速搜索(延迟 300ms)
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_search(query);
});
// 标准搜索(延迟 500ms)
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_search(query);
});
// 慢速搜索(延迟 1000ms)
_debounceTimer = Timer(const Duration(milliseconds: 1000), () {
_search(query);
});
300ms - 适合网络速度快的场景,用户可以看到更实时的搜索结果
500ms - 适合大多数场景,是一个很好的平衡
1000ms - 适合网络速度慢的场景,可以减少网络请求
与其他功能的集成
搜索功能应该与应用的其他功能紧密集成。这样可以提升用户体验。
与分类的集成 - 用户可以从分类页面访问搜索功能。在分类页面中,可以添加一个搜索按钮,让用户可以快速搜索该分类中的商品。
与商品详情的集成 - 用户可以从搜索结果点击商品来查看详情。搜索结果应该显示足够的信息,让用户可以快速判断是否是他们想要的商品。
与购物车的集成 - 用户可以从搜索结果直接添加商品到购物车。这样可以减少用户的操作步骤。
与收藏的集成 - 用户可以从搜索结果直接收藏商品。这样用户可以快速保存他们感兴趣的商品。
与推荐的集成 - 根据用户的搜索历史,可以为用户提供个性化的商品推荐。这可以提升用户的购物体验。
搜索功能的常见问题
1. 搜索结果为空
当搜索结果为空时,应该显示友好的提示。这可以改善用户体验。
if (_results.isEmpty && !_isSearching) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
const Text('未找到相关商品'),
const SizedBox(height: 8),
const Text('试试其他关键词'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.of(context).pushNamed(AppRoutes.categories),
child: const Text('浏览分类'),
),
],
),
);
}
这个实现显示了一个友好的空状态提示。用户可以点击 “浏览分类” 按钮来浏览商品分类。
搜索结果为空的原因分析
搜索结果为空可能有多种原因。我们可以根据不同的原因显示不同的提示:
if (_results.isEmpty && !_isSearching) {
String message = '未找到相关商品';
String suggestion = '试试其他关键词';
// 根据搜索词的长度判断
if (_searchController.text.length < 2) {
message = '搜索词太短';
suggestion = '请输入至少 2 个字符';
} else if (_searchController.text.contains('特殊字符')) {
message = '搜索词包含特殊字符';
suggestion = '请移除特殊字符后重试';
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(message),
const SizedBox(height: 8),
Text(suggestion),
],
),
);
}
为什么需要分析原因? 不同的原因需要不同的解决方案。通过显示具体的原因,可以帮助用户更快地解决问题。
2. 搜索超时
如果搜索超时,应该显示错误提示并提供重试选项。
if (_searchError != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 80, color: Colors.red.shade400),
const SizedBox(height: 16),
Text('搜索失败: $_searchError'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => _search(_searchController.text),
child: const Text('重试'),
),
],
),
);
}
这个实现显示了一个错误提示和重试按钮。用户可以点击重试按钮来重新搜索。
搜索超时的处理
为了更好地处理搜索超时,可以实现一个超时机制:
Future<List<Product>> _searchWithTimeout(String query) async {
try {
final results = await _api.searchProducts(query)
.timeout(const Duration(seconds: 10), onTimeout: () {
throw TimeoutException('搜索超时,请检查网络连接');
});
return results;
} on TimeoutException catch (e) {
setState(() => _searchError = e.message);
return [];
} catch (e) {
setState(() => _searchError = '搜索失败: $e');
return [];
}
}
timeout() 方法设置了一个 10 秒的超时时间。如果搜索在 10 秒内没有完成,就会抛出 TimeoutException。
onTimeout 回调在超时时触发。这里抛出一个自定义的异常。
TimeoutException 是一个自定义的异常类,用来表示搜索超时。
3. 搜索性能问题
如果搜索性能不好,可以考虑以下优化:
- 使用防抖 - 减少网络请求的数量
- 使用缓存 - 避免重复的网络请求
- 使用分页 - 减少初始加载时间
- 优化搜索算法 - 在服务器端优化搜索算法
搜索性能的监控
为了监控搜索性能,可以记录搜索的耗时:
Future<void> _searchWithMetrics(String query) async {
final startTime = DateTime.now();
try {
final results = await _api.searchProducts(query);
final duration = DateTime.now().difference(startTime);
print('搜索耗时: ${duration.inMilliseconds}ms');
// 如果搜索耗时超过 2 秒,记录警告
if (duration.inSeconds > 2) {
print('警告: 搜索耗时过长');
}
setState(() {
_results = results;
_isSearching = false;
});
} catch (e) {
print('搜索失败: $e');
}
}
DateTime.now() 获取当前时间。
difference() 计算两个时间之间的差异。
inMilliseconds 获取毫秒数。
为什么需要监控性能? 通过监控性能,可以发现性能问题,并及时优化。
搜索功能的高级特性
1. 搜索历史的同步
在多设备场景下,可以将搜索历史同步到服务器。这样用户在不同设备上可以看到相同的搜索历史。
Future<void> _syncSearchHistory() async {
final prefs = await SharedPreferences.getInstance();
final history = prefs.getStringList('search_history') ?? [];
try {
await _api.syncSearchHistory(history);
} catch (e) {
print('Failed to sync search history: $e');
}
}
搜索历史同步的实现
为了实现搜索历史的同步,需要在以下时机触发同步:
// 在添加搜索历史后同步
Future<void> _addToSearchHistory(String query) async {
final prefs = await SharedPreferences.getInstance();
final history = prefs.getStringList('search_history') ?? [];
history.remove(query);
history.insert(0, query);
if (history.length > 20) {
history.removeRange(20, history.length);
}
await prefs.setStringList('search_history', history);
// 同步到服务器
await _syncSearchHistory();
}
// 在应用启动时同步
void initState() {
super.initState();
_syncSearchHistory();
}
// 在应用进入后台时同步
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_syncSearchHistory();
}
}
didChangeAppLifecycleState() 是一个生命周期回调,在应用状态改变时触发。
AppLifecycleState.paused 表示应用进入后台。
为什么需要在应用进入后台时同步? 这样可以确保搜索历史在应用关闭前被同步到服务器。
2. 搜索分析
通过分析搜索数据,可以了解用户的需求,改进商品推荐和库存管理。
Future<void> _trackSearch(String query) async {
try {
await _api.trackSearch(
query: query,
timestamp: DateTime.now(),
userId: _currentUserId,
);
} catch (e) {
print('Failed to track search: $e');
}
}
搜索分析的详细实现
为了更详细地分析搜索数据,可以记录更多的信息:
class SearchAnalytics {
final String query;
final DateTime timestamp;
final String userId;
final int resultCount;
final int searchDuration;
final bool hasClicked;
final String? clickedProductId;
SearchAnalytics({
required this.query,
required this.timestamp,
required this.userId,
required this.resultCount,
required this.searchDuration,
required this.hasClicked,
this.clickedProductId,
});
Map<String, dynamic> toJson() => {
'query': query,
'timestamp': timestamp.toIso8601String(),
'userId': userId,
'resultCount': resultCount,
'searchDuration': searchDuration,
'hasClicked': hasClicked,
'clickedProductId': clickedProductId,
};
}
Future<void> _trackSearchAnalytics(SearchAnalytics analytics) async {
try {
await _api.trackSearchAnalytics(analytics.toJson());
} catch (e) {
print('Failed to track search analytics: $e');
}
}
SearchAnalytics 是一个数据类,用来存储搜索分析数据。
toJson() 方法将数据转换为 JSON 格式,方便发送到服务器。
resultCount 记录搜索结果的数量。
searchDuration 记录搜索的耗时。
hasClicked 记录用户是否点击了搜索结果。
clickedProductId 记录用户点击的商品 ID。
3. 搜索排序
除了按相关性排序,还可以提供其他排序选项,比如按价格、评分、销量等排序。
enum SearchSortBy {
relevance,
priceAsc,
priceDesc,
rating,
sales,
}
Future<List<Product>> _searchProducts(
String query, {
SearchSortBy sortBy = SearchSortBy.relevance,
}) async {
return await _api.searchProducts(
query: query,
sortBy: sortBy.toString(),
);
}
搜索排序的 UI 实现
为了让用户可以选择排序方式,可以添加一个排序菜单:
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: '搜索商品...',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
PopupMenuButton<SearchSortBy>(
onSelected: (sortBy) {
setState(() => _sortBy = sortBy);
_search(_searchController.text);
},
itemBuilder: (context) => [
const PopupMenuItem(
value: SearchSortBy.relevance,
child: Text('综合排序'),
),
const PopupMenuItem(
value: SearchSortBy.priceAsc,
child: Text('价格从低到高'),
),
const PopupMenuItem(
value: SearchSortBy.priceDesc,
child: Text('价格从高到低'),
),
const PopupMenuItem(
value: SearchSortBy.rating,
child: Text('评分最高'),
),
const PopupMenuItem(
value: SearchSortBy.sales,
child: Text('销量最高'),
),
],
child: const Icon(Icons.sort),
),
],
)
PopupMenuButton 是一个弹出菜单组件。
onSelected 回调在用户选择菜单项时触发。
itemBuilder 定义了菜单项。
为什么需要多种排序方式? 不同的用户有不同的需求。有些用户想要最便宜的商品,有些用户想要评分最高的商品。提供多种排序方式可以满足不同用户的需求。
总结
这篇文章实现了一个功能完整的搜索系统,包括搜索输入、搜索历史、热门搜索、搜索结果、搜索建议等功能。
搜索功能是电商应用的必备功能。一个好的搜索系统可以帮助用户快速找到他们想要的商品,提升用户体验和转化率。
关键要点:
- 使用 StatefulWidget 来管理搜索状态
- 使用 ListView.builder 来高效地显示搜索结果
- 使用缓存 来提升搜索性能
- 使用防抖 来减少网络请求
- 显示搜索历史和热门搜索 来帮助用户快速搜索
- 提供搜索建议 来改善用户体验
- 处理空状态和错误状态 来提升用户体验
代码都来自实际项目,可以直接运行。下一篇我们会实现收藏功能,讲解如何让用户收藏他们喜欢的商品。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)