Flutter for OpenHarmony 商城App实战 - 最近浏览实现
文章摘要 最近浏览功能是电商应用中具有重要商业价值的隐藏资产。文章从用户行为分析角度,阐述了该功能的四大价值:帮助用户找回商品、缩短决策周期、提供行为数据、支持个性化推荐。通过分析用户浏览行为的探索性、时效性、重复性等特点,作者提出了一个基于Flutter的高效实现方案。方案采用全局状态管理,使用StatelessWidget实现无状态UI,通过AnimatedBuilder监听数据变化,并详细解

最近浏览功能看起来简单,但它背后隐藏着巨大的商业价值。每一次用户浏览商品,都是一次宝贵的行为数据。通过分析这些数据,我们可以了解用户的购物意图、偏好变化、决策周期。这篇文章会从用户行为追踪的角度,详细讲解如何实现一个既实用又能产生数据价值的最近浏览系统。
为什么最近浏览如此重要
很多开发者把最近浏览当作一个简单的历史记录功能,这其实低估了它的价值。
帮助用户找回商品 - 用户经常会浏览很多商品,然后忘记某个商品在哪里。最近浏览可以帮助用户快速找回之前看过的商品,减少用户的挫败感。我自己就经常遇到这种情况,看了一个商品觉得不错,但没有立即购买,过几天想买的时候却找不到了。
缩短购买决策周期 - 用户可能需要多次浏览同一个商品才会决定购买。最近浏览让用户可以快速回到之前看过的商品,不需要重新搜索,这可以缩短购买决策周期。特别是对于价格较高的商品,用户往往需要反复比较才会下单。
提供行为数据 - 用户浏览了哪些商品、浏览了多少次、浏览的时间间隔,这些数据可以帮助我们了解用户的购物意图。比如,用户多次浏览同一个商品,说明他对这个商品很感兴趣,可能只是在等待降价。这时候如果能推送一个优惠券,转化率会很高。
支持个性化推荐 - 根据用户的浏览历史,可以推荐类似的商品。这比基于搜索关键词的推荐更准确,因为浏览行为更能反映用户的真实兴趣。用户可能搜索"手机",但实际上只对某个价位段的手机感兴趣,浏览记录可以帮助我们发现这一点。
根据电商行业的数据,有最近浏览功能的应用,用户的回访率比没有这个功能的应用高 25-35%。这说明最近浏览功能对于提升用户粘性很重要。
用户浏览行为的特点
在设计最近浏览功能之前,我们需要了解用户的浏览行为有哪些特点。这些特点会影响我们的设计决策。
浏览是探索性的 - 用户在购物时通常会浏览很多商品,但只会购买其中的一小部分。根据统计,用户平均浏览 15-20 个商品才会购买 1 个。这意味着浏览记录会比购买记录多很多,我们需要合理地管理这些数据,不能无限制地存储。
浏览有时效性 - 用户对商品的兴趣会随时间变化。一周前浏览的商品,用户可能已经不感兴趣了,或者已经在别的地方买了。所以最近浏览应该按时间排序,最新的在前面。过期的记录应该自动清理。
浏览有重复性 - 用户可能会多次浏览同一个商品。这种重复浏览是一个重要的信号,说明用户对这个商品很感兴趣,可能在犹豫要不要买。我们应该记录这种行为用于分析,但在显示时只显示一次,避免列表中出现重复的商品。
浏览有上下文 - 用户浏览商品时,可能是在比较多个商品。了解用户在同一时间段内浏览了哪些商品,可以帮助我们理解用户的购物意图。比如用户连续浏览了几款不同品牌的耳机,说明他在选购耳机,我们可以推荐更多耳机相关的商品。
浏览有场景差异 - 用户在不同场景下的浏览行为是不同的。比如在通勤时可能只是随便看看,而在晚上可能是认真选购。理解这些差异可以帮助我们更好地服务用户。
最近浏览系统的架构设计
基于对用户行为的理解,我们来设计最近浏览系统的架构。一个好的架构应该能够满足当前的需求,同时也要考虑未来的扩展。
浏览记录存储 - 存储用户浏览过的商品。需要考虑存储的数量限制、去重策略、排序方式。我们选择使用 List 来存储,因为需要保持时间顺序。
浏览记录追踪 - 在用户浏览商品时,自动记录浏览行为。这个过程应该是无感知的,不影响用户体验。追踪应该在页面加载完成后进行,避免阻塞 UI。
浏览记录展示 - 展示用户的浏览历史。需要考虑列表的布局、商品信息的展示、与其他功能的联动。列表应该支持高效滚动,图片应该有缓存。
浏览记录管理 - 允许用户清空浏览记录。有些用户可能不希望保留浏览历史,我们应该尊重用户的隐私。清空操作应该有确认提示。
数据分析接口 - 提供浏览数据的分析接口。这些数据可以用于个性化推荐、用户画像等。分析应该在后台进行,不影响前台性能。
最近浏览页面的实现
让我们先看一下最近浏览页面的基本结构。这个页面的职责很简单:展示用户的浏览历史,并提供清空功能。
class RecentViewedPage extends StatelessWidget {
const RecentViewedPage({super.key});
这里使用 StatelessWidget 而不是 StatefulWidget。这个选择很重要,让我解释一下原因。
为什么选择 StatelessWidget? 最近浏览列表的数据来自全局状态,页面本身不需要管理任何状态。当用户在其他页面浏览商品时,这个页面会自动更新。这种设计让代码更简洁,也更容易维护。如果使用 StatefulWidget,我们还需要处理状态同步的问题,增加了复杂度。
全局状态的好处 - 浏览记录是跨页面共享的数据。用户在商品详情页面浏览商品,浏览记录页面应该能看到。使用全局状态可以自动实现这种同步,不需要手动传递数据。
获取全局状态
Widget build(BuildContext context) {
final appState = AppStateScope.of(context);
AppStateScope.of(context) 获取全局的 AppState 实例。这是一个 InheritedWidget 的用法,可以在 Widget 树的任何位置获取共享的状态。
为什么使用这种方式? 相比于直接传递参数,使用 InheritedWidget 可以避免"参数透传"的问题。如果用参数传递,每一层 Widget 都需要接收并传递这个参数,代码会很冗余。
页面的基本结构
return SimpleScaffoldPage(
title: '最近浏览',
SimpleScaffoldPage 是一个自定义的脚手架组件,提供了统一的页面结构。它包含了 AppBar、返回按钮等通用元素。使用统一的脚手架可以保证应用中所有页面的风格一致。
title: ‘最近浏览’ 设置页面的标题。这个标题会显示在 AppBar 中,让用户知道当前在哪个页面。标题应该简洁明了,不要太长。
页面的操作按钮区域
actions: [
AnimatedBuilder(
animation: appState,
actions 是 AppBar 中的操作按钮区域。这里会放置清空按钮。操作按钮应该放在右上角,这是用户习惯的位置。
AnimatedBuilder 是一个监听器组件。当 appState 改变时,会自动重新构建子组件。这样清空按钮可以根据浏览记录的状态来显示或隐藏。
为什么用 AnimatedBuilder? 因为我们需要根据浏览记录是否为空来决定是否显示清空按钮。AnimatedBuilder 可以监听 appState 的变化,当浏览记录改变时自动更新 UI。
清空按钮的条件显示
builder: (context, _) {
if (appState.recentViewed.isEmpty) return const SizedBox();
appState.recentViewed.isEmpty 检查浏览记录是否为空。如果为空,就不显示清空按钮。
return const SizedBox() 返回一个空的 Widget。当没有浏览记录时,清空按钮的位置会显示为空。SizedBox 是一个轻量级的 Widget,不会占用任何空间。
为什么需要这个判断? 如果浏览记录为空,显示清空按钮是没有意义的。用户点击也不会有任何效果。隐藏这个按钮可以让界面更简洁,也避免用户困惑。这是一个小细节,但好的用户体验就是由这些小细节组成的。
清空按钮的实现
return TextButton(
onPressed: () {
appState.clearRecentViewed();
TextButton 是一个文字按钮。相比于 IconButton,TextButton 更适合这种操作性的按钮,因为文字更能表达按钮的功能。用户一看就知道这个按钮是用来清空的。
appState.clearRecentViewed() 调用全局状态的方法来清空浏览记录。这个方法会清除所有的浏览记录,并通知所有监听器。调用这个方法后,所有监听 appState 的 Widget 都会自动更新。
清空后的用户反馈
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已清空'))
);
},
child: const Text('清空'),
);
},
),
],
ScaffoldMessenger.of(context).showSnackBar 显示一个 SnackBar 提示。这样用户可以确认操作已经完成。
为什么需要 SnackBar? 清空操作是一个破坏性的操作,用户需要知道操作是否成功。SnackBar 是一种轻量级的反馈方式,不会打断用户的操作流程。它会在屏幕底部短暂显示,然后自动消失。
const Text(‘清空’) 按钮的文字。使用 const 可以避免不必要的重建,提升性能。
页面主体内容的实现
页面的主体内容是浏览记录列表。这个列表需要处理两种情况:有浏览记录和没有浏览记录。
监听状态变化
child: AnimatedBuilder(
animation: appState,
builder: (context, _) {
final recentViewed = appState.recentViewed;
AnimatedBuilder 再次出现,这次是用来监听浏览记录的变化。当用户在其他页面浏览商品时,这个列表会自动更新。
appState.recentViewed 获取浏览记录列表。这个列表是按时间排序的,最新的在前面。返回的是一个不可修改的列表,外部代码不能直接修改它。
为什么要用局部变量? 把 appState.recentViewed 赋值给局部变量 recentViewed,可以避免多次访问属性。虽然性能差异很小,但这是一个好习惯。
空状态的判断
if (recentViewed.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
recentViewed.isEmpty 检查浏览记录是否为空。如果为空,显示空状态提示。
Center 将内容居中显示。空状态应该在屏幕中央,这样用户可以清楚地看到。
Column 垂直排列多个元素。空状态通常包含图标、标题、描述和操作按钮。
mainAxisSize: MainAxisSize.min 让 Column 只占用必要的空间。如果不设置这个,Column 会占满整个屏幕高度,内容就不会居中了。
空状态的图标
children: [
Icon(Icons.history, size: 80, color: Colors.grey.shade400),
Icons.history 使用历史图标来表示浏览记录。这个图标很直观,用户一看就知道这是浏览历史相关的页面。选择合适的图标很重要,它可以帮助用户快速理解页面的功能。
size: 80 设置图标的大小。80 像素的图标足够大,可以吸引用户的注意力。太小的图标会显得不够重要,太大的图标会显得突兀。
Colors.grey.shade400 使用灰色来表示空状态。灰色给人一种"空"的感觉,符合空状态的语义。不要使用太深的颜色,那样会显得太沉重。
空状态的文字提示
const SizedBox(height: 16),
const Text('暂无浏览记录'),
SizedBox(height: 16) 在图标和文字之间添加间距。16 像素的间距让内容不会太拥挤,也不会太分散。
Text(‘暂无浏览记录’) 是主要的提示文字。这个文字清晰地告诉用户当前的状态。文字应该简洁明了,不要太长。
空状态的次要提示
const SizedBox(height: 8),
const Text('浏览过的商品会显示在这里'),
SizedBox(height: 8) 在两行文字之间添加较小的间距。8 像素的间距让两行文字有关联感,用户知道它们是一组信息。
Text(‘浏览过的商品会显示在这里’) 是次要的提示文字。这个文字告诉用户这个页面的功能,引导用户去浏览商品。好的空状态不仅告诉用户当前状态,还要告诉用户如何改变这个状态。
空状态的操作按钮
const SizedBox(height: 24),
ShopButton(
label: '去逛逛',
icon: Icons.shopping_bag,
SizedBox(height: 24) 在文字和按钮之间添加较大的间距。24 像素的间距让按钮更突出,用户更容易注意到。
ShopButton 是一个自定义的按钮组件。它提供了统一的按钮样式,包括图标和文字。使用统一的按钮组件可以保证应用中所有按钮的风格一致。
label: ‘去逛逛’ 设置按钮的文字。"去逛逛"是一个友好的、鼓励性的文字,比"浏览商品"更有亲和力。好的文案可以提升用户的点击意愿。
icon: Icons.shopping_bag 设置按钮的图标。购物袋图标表示去购物,符合按钮的功能。图标和文字配合使用,可以让按钮更直观。
按钮的点击事件
onPressed: () => Navigator.of(context).pushNamed(AppRoutes.shop)
),
],
),
);
}
Navigator.of(context).pushNamed(AppRoutes.shop) 导航到商店页面。用户点击按钮后,会进入商店,可以开始浏览商品。
为什么需要操作按钮? 空状态不应该是一个死胡同。用户看到空状态后,应该知道下一步该做什么。操作按钮提供了一个明确的行动指引,可以引导用户去浏览商品。
用户体验思考 - 一个好的空状态设计应该包含三个要素:告诉用户当前状态、解释为什么是这个状态、提供改变状态的方法。我们的设计满足了这三个要素。
浏览列表的实现
当用户有浏览记录时,需要显示一个列表来展示这些记录。这个列表需要高效、美观、易用。
使用 ListView.builder
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: recentViewed.length,
ListView.builder 是一个高效的列表组件。它只会构建可见的 Widget,这对于长列表来说很重要。如果使用普通的 ListView,会一次性构建所有的 Widget,当列表很长时会很慢。
padding: const EdgeInsets.all(16) 添加内边距。16 像素的内边距让列表项不会贴到屏幕边缘,看起来更舒适。
itemCount: recentViewed.length 设置列表项的数量。ListView.builder 需要知道总共有多少项,这样它才能正确地计算滚动范围。
构建列表项
itemBuilder: (context, index) {
final product = recentViewed[index];
itemBuilder 回调用来构建每个列表项。这个回调会被调用多次,每次构建一个列表项。只有当列表项即将显示在屏幕上时,才会调用这个回调。
recentViewed[index] 获取第 index 个浏览记录。注意这里直接使用下标访问,因为 recentViewed 是一个 List。
为什么使用 ListView.builder? 浏览记录可能会很多(我们限制了 20 条),使用 ListView.builder 可以确保只构建可见的 Widget,提升性能。如果用户快速滚动,不可见的 Widget 会被回收,新的 Widget 会被创建。
列表项的容器
return Padding(
padding: const EdgeInsets.only(bottom: 12),
Padding 在列表项之间添加间距。12 像素的底部间距让列表项之间有适当的分隔,不会显得太拥挤。
为什么用 Padding 而不是 margin? 在 Flutter 中,很多 Widget 没有 margin 属性,使用 Padding 包裹是一种通用的做法。而且 Padding 的语义更清晰。
使用卡片包装
child: ShopCard(
child: ListTile(
ShopCard 是一个自定义的卡片组件。它提供了统一的卡片样式,包括圆角、阴影等。使用卡片可以让列表项看起来更有层次感。
ListTile 是一个标准的列表项组件。它提供了 leading、title、subtitle、trailing 等位置,非常适合展示商品信息。ListTile 已经处理了很多细节,比如文字溢出、触摸反馈等。
为什么使用 ListTile? ListTile 是 Flutter 提供的标准列表项组件,它的布局已经经过精心设计,符合 Material Design 规范。使用它可以减少代码量,也能保证一致的用户体验。
商品图片的显示
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
leading 是 ListTile 的左侧区域,通常用来显示图标或图片。在这里我们显示商品的图片。
ClipRRect 用来裁剪子组件的圆角。这样图片会有圆角效果,看起来更美观。
borderRadius: BorderRadius.circular(8) 设置 8 像素的圆角。这个圆角大小和卡片的圆角一致,保持视觉统一。
加载网络图片
child: Image.network(
product.imageUrl,
width: 60,
height: 60,
fit: BoxFit.contain,
Image.network 从网络加载图片。product.imageUrl 是商品的图片地址。
width: 60, height: 60 设置图片的大小。60x60 像素的图片在列表中显示刚好合适,不会太大也不会太小。
fit: BoxFit.contain 设置图片的填充方式。contain 会保持图片的宽高比,不会裁剪图片。这样可以完整地显示商品图片。
图片加载失败的处理
errorBuilder: (_, __, ___) => Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey)
)
),
),
errorBuilder 是 Image.network 的错误处理回调。当图片加载失败时,会显示这个 Widget。
Container 创建一个占位容器。这个容器的大小和图片一样,保持布局的一致性。
color: Colors.grey.shade200 设置容器的背景色。浅灰色表示这是一个占位符。
Icon(Icons.image, color: Colors.grey) 显示一个图片图标。这个图标告诉用户这里应该是一张图片。
为什么需要 errorBuilder? 网络图片可能会加载失败,比如网络不好、图片地址失效等。如果不处理这种情况,用户会看到一个空白或者错误的图片。errorBuilder 提供了一个优雅的降级方案。
参数说明 - errorBuilder 的三个参数分别是 context、error、stackTrace。这里我们不需要使用这些参数,所以用下划线 _ 来忽略它们。这是 Dart 的一个惯例。
商品标题的显示
title: Text(
product.title,
maxLines: 2,
overflow: TextOverflow.ellipsis
),
title 是 ListTile 的主标题区域。这里显示商品的名称。
product.title 获取商品的标题。这个标题来自商品数据。
maxLines: 2 限制标题最多显示 2 行。商品标题可能很长,限制行数可以保持列表项的高度一致。
overflow: TextOverflow.ellipsis 设置文字溢出时显示省略号。当标题超过 2 行时,会在末尾显示 “…”。
为什么限制 2 行? 商品标题的长度不一,有些可能很长。如果不限制行数,列表项的高度会不一致,影响视觉效果。2 行通常足够显示商品的主要信息,同时保持列表的整齐。
商品价格的显示
subtitle: Text('¥${product.priceUsd.toStringAsFixed(2)}'),
subtitle 是 ListTile 的副标题区域。这里显示商品的价格。
product.priceUsd 获取商品的价格。这个价格是美元价格。
toStringAsFixed(2) 将价格格式化为两位小数。比如 29.9 会显示为 29.90。这样价格的格式更统一。
¥ 是人民币符号。在实际项目中,应该根据用户的地区设置来显示不同的货币符号。
为什么价格放在 subtitle? 价格是商品的重要信息,但不是最重要的。用户首先看到的应该是商品名称,然后才是价格。subtitle 的位置和样式正好符合这个优先级。
收藏按钮的集成
最近浏览列表中的每个商品都应该有一个收藏按钮,方便用户快速收藏。
trailing: IconButton(
icon: Icon(
appState.isFavorite(product.id)
? Icons.favorite
: Icons.favorite_border,
trailing 是 ListTile 的右侧区域。这里放置收藏按钮。
IconButton 是一个图标按钮。用户可以点击来收藏或取消收藏。
appState.isFavorite(product.id) 检查商品是否已收藏。这个方法返回一个布尔值。
Icons.favorite 是实心心形图标,表示已收藏。
Icons.favorite_border 是空心心形图标,表示未收藏。
收藏按钮的颜色
color: appState.isFavorite(product.id)
? Colors.red
: null
),
Colors.red 红色表示已收藏。红色的心形是一个通用的收藏标识,用户一看就懂。
null 未收藏时使用默认颜色。这样按钮会使用主题的默认颜色,通常是灰色。
为什么用红色? 红色的心形是一个全球通用的"喜欢"或"收藏"的标识。用户不需要学习就能理解它的含义。
收藏按钮的点击事件
onPressed: () => appState.toggleFavorite(product.id),
),
appState.toggleFavorite(product.id) 切换收藏状态。点击后,商品的收藏状态会改变。如果已收藏就取消收藏,如果未收藏就添加收藏。
为什么在浏览列表中集成收藏功能? 用户浏览过的商品,很可能是他们感兴趣的。在浏览列表中提供收藏功能,可以让用户快速收藏,不需要再次进入商品详情页面。这是一个很实用的功能。
列表项的点击事件
onTap: () => Navigator.of(context).pushNamed(AppRoutes.shop),
),
),
);
},
);
},
),
);
}
}
onTap 是 ListTile 的点击回调。用户点击列表项时会触发。
Navigator.of(context).pushNamed(AppRoutes.shop) 导航到商店页面。在实际项目中,应该导航到商品详情页面,并传递商品 ID。
为什么需要点击事件? 用户点击浏览记录,通常是想再次查看这个商品。提供点击事件可以让用户快速进入商品详情页面,查看更多信息或者购买。
浏览记录的追踪
浏览记录的追踪是在商品详情页面完成的。当用户打开商品详情页面时,会自动记录浏览行为。这个过程应该是无感知的。
追踪的时机
// Add to recent viewed
WidgetsBinding.instance.addPostFrameCallback((_) {
appState?.addRecentViewed(product);
});
WidgetsBinding.instance.addPostFrameCallback 在当前帧渲染完成后执行回调。这样可以确保 UI 已经构建完成。
为什么使用 addPostFrameCallback? 直接在 build 方法中修改状态可能会导致问题。Flutter 不允许在构建过程中修改状态,因为这可能导致无限循环。使用 addPostFrameCallback 可以确保在 UI 构建完成后再修改状态,避免潜在的错误。
空安全处理
appState?.addRecentViewed(product);
appState?.addRecentViewed(product) 将商品添加到浏览记录。这个方法会处理去重和排序。
为什么使用 ?. 空安全操作符。appState 可能为 null,使用 ?. 可以避免空指针异常。如果 appState 为 null,这行代码不会执行任何操作。
追踪的原则 - 追踪应该是无感知的,不影响用户体验。用户不应该感觉到追踪的存在,页面加载速度不应该受到影响。
浏览记录的状态管理
浏览记录的状态由全局的 AppState 管理。让我们看一下相关的代码实现。
浏览记录的存储结构
final List<Product> _recentViewed = [];
_recentViewed 是一个私有的 List,存储浏览过的商品。使用 List 而不是 Set,因为我们需要保持顺序。
为什么使用 List? 浏览记录需要按时间排序,最新的在前面。List 可以保持元素的顺序,而 Set 不能。而且 List 支持通过索引访问元素,这在显示列表时很有用。
返回不可修改的列表
List<Product> get recentViewed => List.unmodifiable(_recentViewed);
List.unmodifiable 返回一个不可修改的列表。这样外部代码不能直接修改 _recentViewed,必须通过提供的方法来修改。
为什么返回不可修改的列表? 这是一种防御性编程。如果外部代码可以直接修改列表,可能会导致状态不一致。比如,外部代码直接添加了一个商品,但没有调用 notifyListeners(),UI 就不会更新。返回不可修改的列表可以确保状态只能通过 AppState 的方法来修改。
添加浏览记录的方法
void addRecentViewed(Product product) {
_recentViewed.removeWhere((p) => p.id == product.id);
removeWhere 移除已存在的相同商品。这样可以避免重复,同时更新商品的位置。
§ => p.id == product.id 是一个判断条件。如果商品的 ID 相同,就移除它。我们通过 ID 来判断是否是同一个商品,而不是通过对象引用。
为什么先移除? 用户可能会多次浏览同一个商品。如果不移除旧的记录,列表中会有重复的商品。先移除再插入可以确保商品只出现一次。
插入到列表开头
_recentViewed.insert(0, product);
insert(0, product) 将商品插入到列表的开头。这样最新浏览的商品会显示在最前面。
为什么插入到开头? 用户最关心的是最近浏览的商品。把最新的放在最前面,用户可以更快地找到。这符合"最近使用"的设计原则。
限制浏览记录的数量
if (_recentViewed.length > 20) {
_recentViewed.removeLast();
}
_recentViewed.length > 20 检查浏览记录是否超过 20 条。
removeLast() 移除最后一条记录。最后一条是最早浏览的,优先移除。
为什么限制 20 条? 浏览记录太多会占用内存,也会影响列表的性能。20 条是一个合理的数量,足够用户找回最近浏览的商品,又不会太多。这个数字可以根据实际需求调整。
为什么移除最后一条? 最后一条是最早浏览的,用户可能已经不感兴趣了。移除最早的记录,保留最新的记录,符合用户的使用习惯。
通知监听器
notifyListeners();
}
notifyListeners() 通知所有监听器状态已改变。这样 UI 会自动更新。所有使用 AnimatedBuilder 监听 appState 的 Widget 都会重新构建。
为什么需要 notifyListeners? AppState 继承自 ChangeNotifier,它使用观察者模式来通知状态变化。如果不调用 notifyListeners(),监听器不会收到通知,UI 不会更新。
清空浏览记录的方法
void clearRecentViewed() {
_recentViewed.clear();
notifyListeners();
}
clear() 清空列表中的所有元素。这是一个 O(n) 的操作,但对于 20 条记录来说,性能不是问题。
notifyListeners() 通知所有监听器状态已改变。清空后,浏览列表页面会显示空状态。
为什么需要清空功能? 有些用户可能不希望保留浏览历史,比如在公共设备上使用应用。提供清空功能可以让用户控制自己的数据,这是对用户隐私的尊重。
浏览记录的持久化
目前的实现中,浏览记录只保存在内存中。当应用关闭后,浏览记录会丢失。在实际项目中,应该将浏览记录持久化到本地存储。
保存到本地存储
Future<void> _saveRecentViewedToLocal() async {
final prefs = await SharedPreferences.getInstance();
SharedPreferences 是一个简单的键值存储。它可以存储字符串、整数、布尔值等。对于浏览记录这种简单的数据,SharedPreferences 是一个不错的选择。
await 等待 SharedPreferences 实例化完成。这是一个异步操作,因为需要读取本地文件。
序列化浏览记录
final jsonList = _recentViewed.map((p) => p.toJson()).toList();
await prefs.setString('recent_viewed', jsonEncode(jsonList));
}
_recentViewed.map(§ => p.toJson()) 将商品列表转换为 JSON 列表。每个商品都需要实现 toJson 方法。
jsonEncode 将 JSON 列表转换为字符串。SharedPreferences 只能存储字符串,所以需要先序列化。
prefs.setString 将字符串保存到本地存储。使用 ‘recent_viewed’ 作为键名。
为什么使用 JSON? JSON 是一种通用的数据格式,可以方便地序列化和反序列化。而且 JSON 字符串可以直接存储到 SharedPreferences 中。
从本地加载浏览记录
Future<void> _loadRecentViewedFromLocal() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString('recent_viewed');
prefs.getString 从本地存储读取字符串。如果键不存在,返回 null。
为什么需要从本地加载? 应用启动时,需要恢复之前的浏览记录。这样用户可以看到之前的浏览历史,不会因为应用重启而丢失。
反序列化浏览记录
if (jsonString != null) {
final jsonList = jsonDecode(jsonString) as List;
_recentViewed.clear();
jsonString != null 检查是否有保存的数据。如果是第一次使用应用,可能没有保存的数据。
jsonDecode 将字符串解析为 JSON 列表。
_recentViewed.clear() 先清空现有的记录,避免重复。
恢复商品对象
_recentViewed.addAll(
jsonList.map((json) => Product.fromJson(json)).toList()
);
notifyListeners();
}
}
Product.fromJson 将 JSON 转换为商品对象。每个商品都需要实现 fromJson 方法。
_recentViewed.addAll 添加从本地加载的记录。
notifyListeners() 通知监听器数据已加载。这样 UI 会显示加载的浏览记录。
为什么需要 clear? 如果不清空,可能会有重复的记录。比如,应用启动时加载了本地记录,然后用户又浏览了一些商品,再次加载就会有重复。
在合适的时机调用
// 在 AppState 构造函数中加载
AppState() {
_loadRecentViewedFromLocal();
}
构造函数中加载 - 应用启动时,从本地加载浏览记录。这样用户可以看到之前的浏览历史。
添加后保存
void addRecentViewed(Product product) {
// ... 添加逻辑
notifyListeners();
_saveRecentViewedToLocal(); // 保存到本地
}
添加后保存 - 每次添加浏览记录后,保存到本地。这样即使应用崩溃,也不会丢失数据。
清空后保存
void clearRecentViewed() {
_recentViewed.clear();
notifyListeners();
_saveRecentViewedToLocal(); // 保存到本地
}
清空后保存 - 清空浏览记录后,也要保存到本地。这样下次启动时,浏览记录确实是空的。
浏览数据的分析价值
浏览记录不仅仅是一个历史记录,它还包含了丰富的用户行为数据。通过分析这些数据,可以为用户提供更好的服务。
浏览频率分析
记录用户浏览每个商品的次数,可以了解用户的兴趣程度。
class BrowsingAnalytics {
final Map<int, int> _browseCount = {};
_browseCount 记录每个商品的浏览次数。key 是商品 ID,value 是浏览次数。使用 Map 可以快速查询和更新。
记录浏览次数
void recordBrowse(int productId) {
_browseCount[productId] = (_browseCount[productId] ?? 0) + 1;
}
recordBrowse 记录一次浏览。每次用户浏览商品时调用。
(_browseCount[productId] ?? 0) + 1 如果商品之前没有被浏览过,默认次数是 0,然后加 1。
获取浏览次数
int getBrowseCount(int productId) {
return _browseCount[productId] ?? 0;
}
getBrowseCount 获取某个商品的浏览次数。可以用来判断用户对商品的兴趣程度。
为什么需要浏览频率分析? 用户多次浏览同一个商品,说明他对这个商品很感兴趣。这个信息可以用于个性化推荐、价格提醒等功能。比如,用户浏览了某个商品 5 次,我们可以推送一个优惠券来促进转化。
获取高频浏览商品
List<int> getFrequentlyBrowsed({int limit = 10}) {
final sorted = _browseCount.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return sorted.take(limit).map((e) => e.key).toList();
}
}
getFrequentlyBrowsed 获取浏览次数最多的商品。这些商品可能是用户最感兴趣的。
sort 按浏览次数降序排序。
take(limit) 取前 limit 个商品。
浏览时间分析
记录用户浏览商品的时间,可以了解用户的兴趣变化。
class BrowsingTimeAnalytics {
final Map<int, DateTime> _lastBrowseTime = {};
_lastBrowseTime 记录每个商品的最后浏览时间。
记录浏览时间
void recordBrowseTime(int productId) {
_lastBrowseTime[productId] = DateTime.now();
}
recordBrowseTime 记录浏览时间。每次用户浏览商品时调用。
获取距离上次浏览的时间
Duration? getTimeSinceLastBrowse(int productId) {
final lastTime = _lastBrowseTime[productId];
if (lastTime == null) return null;
return DateTime.now().difference(lastTime);
}
getTimeSinceLastBrowse 获取距离上次浏览的时间。可以用来判断用户的兴趣是否还在。
为什么需要浏览时间分析? 用户的兴趣会随时间变化。一周前浏览的商品,用户可能已经不感兴趣了。通过分析浏览时间,可以更准确地了解用户的当前兴趣。
获取最近浏览的商品
List<int> getRecentlyBrowsed({Duration within = const Duration(hours: 24)}) {
final now = DateTime.now();
return _lastBrowseTime.entries
.where((e) => now.difference(e.value) <= within)
.map((e) => e.key)
.toList();
}
}
getRecentlyBrowsed 获取最近一段时间内浏览的商品。比如获取 24 小时内浏览的商品。
where 过滤出时间范围内的商品。
为什么需要这个功能? 最近浏览的商品更能反映用户的当前兴趣。可以用于首页推荐、购物车提醒等场景。
浏览类别分析
分析用户浏览的商品类别,可以了解用户的兴趣偏好。
class CategoryAnalytics {
final Map<String, int> _categoryCount = {};
_categoryCount 记录每个类别的浏览次数。
记录类别浏览
void recordCategory(String category) {
_categoryCount[category] = (_categoryCount[category] ?? 0) + 1;
}
recordCategory 记录一次类别浏览。每次用户浏览商品时,记录商品的类别。
获取热门类别
List<String> getTopCategories({int limit = 5}) {
final sorted = _categoryCount.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return sorted.take(limit).map((e) => e.key).toList();
}
getTopCategories 获取浏览次数最多的类别。这些类别可能是用户最感兴趣的。
获取类别分布
Map<String, double> getCategoryDistribution() {
final total = _categoryCount.values.fold(0, (a, b) => a + b);
if (total == 0) return {};
return _categoryCount.map((k, v) => MapEntry(k, v / total));
}
}
getCategoryDistribution 获取类别的分布比例。可以用来了解用户的兴趣分布。
为什么需要类别分析? 通过分析用户浏览的类别,可以了解用户的兴趣偏好。比如,用户经常浏览电子产品,就可以推荐更多的电子产品。这比随机推荐更有效。
基于浏览记录的个性化推荐
浏览记录是个性化推荐的重要数据来源。通过分析用户的浏览历史,可以推荐用户可能感兴趣的商品。
相似商品推荐
根据用户浏览的商品类别,推荐相同类别的其他商品。
class SimilarProductRecommender {
Future<List<Product>> getRecommendations(
List<Product> recentViewed,
{int limit = 10}
) async {
if (recentViewed.isEmpty) return [];
recentViewed 是用户的浏览记录。
limit 限制推荐的数量。
提取浏览类别
final categories = recentViewed
.map((p) => p.category)
.toSet()
.toList();
categories 获取浏览商品的类别。使用 toSet() 去重,避免重复的类别。
获取推荐商品
final recommendations = await _fetchProductsByCategories(categories);
_fetchProductsByCategories 从服务器获取相同类别的商品。这是一个异步操作,需要网络请求。
排除已浏览的商品
final viewedIds = recentViewed.map((p) => p.id).toSet();
return recommendations
.where((p) => !viewedIds.contains(p.id))
.take(limit)
.toList();
}
}
viewedIds 获取已浏览商品的 ID。用于排除已浏览的商品。
where 过滤掉已浏览的商品。推荐的商品应该是用户没有看过的。
为什么排除已浏览的商品? 用户已经看过的商品,再推荐给他意义不大。推荐新的商品可以帮助用户发现更多感兴趣的商品。
价格区间推荐
根据用户浏览商品的价格,推荐相似价格区间的商品。
class PriceRangeRecommender {
Future<List<Product>> getRecommendations(
List<Product> recentViewed,
{int limit = 10}
) async {
if (recentViewed.isEmpty) return [];
计算价格区间
final prices = recentViewed.map((p) => p.priceUsd).toList();
final minPrice = prices.reduce((a, b) => a < b ? a : b);
final maxPrice = prices.reduce((a, b) => a > b ? a : b);
prices 获取浏览商品的价格列表。
minPrice, maxPrice 计算价格区间。使用 reduce 找出最小值和最大值。
扩展价格区间
final expandedMin = minPrice * 0.8;
final expandedMax = maxPrice * 1.2;
expandedMin, expandedMax 扩展价格区间 20%。这样可以推荐稍微便宜或稍微贵一点的商品。
为什么扩展价格区间? 用户可能对稍微便宜或稍微贵一点的商品也感兴趣。扩展价格区间可以提供更多的选择,增加推荐的多样性。
获取推荐商品
return await _fetchProductsByPriceRange(expandedMin, expandedMax, limit);
}
}
_fetchProductsByPriceRange 从服务器获取价格区间内的商品。
浏览记录的隐私保护
浏览记录包含用户的隐私信息,需要妥善保护。用户应该能够控制自己的数据。
本地加密存储
对于敏感数据,应该使用加密存储。
class EncryptedStorage {
final FlutterSecureStorage _storage = const FlutterSecureStorage();
FlutterSecureStorage 是一个安全存储库。它会自动加密数据,使用系统的安全存储机制。
加密保存
Future<void> saveEncrypted(String key, String value) async {
await _storage.write(key: key, value: value);
}
saveEncrypted 加密保存数据。数据会被自动加密后存储。
读取解密
Future<String?> readEncrypted(String key) async {
return await _storage.read(key: key);
}
readEncrypted 读取并解密数据。
为什么需要加密? 浏览记录可能包含敏感信息,比如用户浏览了什么商品。加密可以防止数据被恶意应用读取。
用户隐私控制
用户应该能够控制是否记录浏览历史。
class PrivacySettings {
bool _trackingEnabled = true;
trackingEnabled 是否启用浏览追踪。用户可以选择不记录浏览历史。
设置追踪开关
void setTrackingEnabled(bool value) {
_trackingEnabled = value;
if (!value) {
_clearLocalHistory();
}
}
setTrackingEnabled 设置追踪开关。关闭时清空本地记录。
为什么需要用户控制? 用户应该有权控制自己的数据。有些用户可能不希望被追踪,我们应该尊重他们的选择。这也是 GDPR 等隐私法规的要求。
性能优化
浏览记录功能需要考虑性能,特别是在数据量大的情况下。
列表性能优化
使用 ListView.builder 而不是 ListView。
return ListView.builder(
itemCount: recentViewed.length,
itemBuilder: (context, index) {
return _buildProductItem(recentViewed[index]);
},
);
ListView.builder 只构建可见的 Widget。这对于长列表来说很重要。
为什么不用 ListView? ListView 会一次性构建所有的 Widget,当列表很长时会很慢。ListView.builder 按需构建,性能更好。
图片缓存
使用带缓存的图片组件。
child: CachedNetworkImage(
imageUrl: product.imageUrl,
width: 60,
height: 60,
fit: BoxFit.contain,
CachedNetworkImage 是一个带缓存的网络图片组件。它会自动缓存下载的图片。
加载中占位符
placeholder: (context, url) => Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: const CircularProgressIndicator(strokeWidth: 2),
),
placeholder 图片加载中时显示的占位符。显示一个加载指示器,让用户知道图片正在加载。
错误占位符
errorWidget: (context, url, error) => Container(
width: 60,
height: 60,
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey),
),
),
errorWidget 图片加载失败时显示的 Widget。
为什么需要图片缓存? 浏览记录中的图片可能会被多次显示。缓存可以避免重复下载,提升加载速度,也节省用户的流量。
防抖处理
避免频繁的浏览记录操作。
class DebouncedBrowsingTracker {
Timer? _debounceTimer;
final Duration _debounceDuration = const Duration(milliseconds: 500);
_debounceTimer 防抖定时器。
_debounceDuration 防抖时间,500 毫秒。
防抖记录
void trackBrowse(Product product, Function(Product) onTrack) {
_debounceTimer?.cancel();
_debounceTimer = Timer(_debounceDuration, () {
onTrack(product);
});
}
trackBrowse 记录浏览。如果用户快速切换商品,只记录最后一个。
为什么需要防抖? 用户可能快速浏览多个商品,每个商品只停留很短的时间。防抖可以避免记录这些无意义的浏览,只记录用户真正感兴趣的商品。
常见问题和解决方案
问题 1: 浏览记录不更新
问题描述 - 用户浏览了商品,但浏览记录页面没有显示。
原因 - 可能是 AnimatedBuilder 没有正确监听 AppState,或者 notifyListeners() 没有被调用。
解决方案:
确保在添加浏览记录后调用 notifyListeners():
void addRecentViewed(Product product) {
_recentViewed.removeWhere((p) => p.id == product.id);
_recentViewed.insert(0, product);
notifyListeners(); // 必须调用
}
确保 AnimatedBuilder 正确监听 AppState:
AnimatedBuilder(
animation: appState, // 必须指定正确的对象
builder: (context, _) {
// 构建 UI
},
)
为什么会出现这个问题? 如果忘记调用 notifyListeners(),监听器不会收到通知,UI 不会更新。
问题 2: 浏览记录重复
问题描述 - 同一个商品在浏览记录中出现多次。
原因 - 可能是去重逻辑有问题。
解决方案:
先移除已存在的相同商品,再插入:
void addRecentViewed(Product product) {
_recentViewed.removeWhere((p) => p.id == product.id);
_recentViewed.insert(0, product);
}
为什么会出现这个问题? 如果不先移除已存在的商品,直接插入会导致重复。
问题 3: 图片加载失败
问题描述 - 浏览记录中的商品图片显示为空白或错误图标。
原因 - 网络问题或图片地址失效。
解决方案:
使用 errorBuilder 提供降级方案:
Image.network(
product.imageUrl,
errorBuilder: (_, __, ___) => Container(
color: Colors.grey.shade200,
child: const Icon(Icons.image, color: Colors.grey),
),
)
为什么会出现这个问题? 网络图片可能会加载失败。使用 errorBuilder 可以提供一个优雅的降级方案。
问题 4: 清空后数据恢复
问题描述 - 用户清空浏览记录后,重新打开应用,浏览记录又出现了。
原因 - 清空时没有同步到本地存储。
解决方案:
清空后同步到本地存储:
void clearRecentViewed() {
_recentViewed.clear();
notifyListeners();
_saveRecentViewedToLocal();
}
为什么会出现这个问题? 如果只清空内存中的数据,不清空本地存储,下次启动时会从本地存储加载数据。
问题 5: 性能问题
问题描述 - 浏览记录页面滚动卡顿。
原因 - 可能是使用了 ListView 而不是 ListView.builder,或者图片没有缓存。
解决方案:
使用 ListView.builder:
return ListView.builder(
itemCount: recentViewed.length,
itemBuilder: (context, index) {
// 构建列表项
},
);
使用图片缓存:
CachedNetworkImage(
imageUrl: product.imageUrl,
)
为什么会出现这个问题? ListView 会一次性构建所有 Widget,当列表很长时会很慢。图片没有缓存会导致重复下载。
最近浏览与其他功能的联动
最近浏览功能不是孤立的,它应该与应用的其他功能联动,提供更好的用户体验。
与收藏功能的联动
在浏览记录中,用户可以直接收藏商品:
trailing: IconButton(
icon: Icon(
appState.isFavorite(product.id)
? Icons.favorite
: Icons.favorite_border,
),
onPressed: () {
appState.toggleFavorite(product.id);
},
),
为什么需要联动? 用户浏览过的商品,很可能是他们感兴趣的。在浏览记录中提供收藏功能,可以让用户快速收藏,不需要再次进入商品详情页面。
与购物车的联动
在浏览记录中,用户可以直接添加商品到购物车。可以通过长按显示操作菜单:
onLongPress: () {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add_shopping_cart),
title: const Text('加入购物车'),
onTap: () {
appState.addToCart(product);
Navigator.pop(context);
},
),
],
),
);
},
onLongPress 长按显示操作菜单。
showModalBottomSheet 显示底部弹出菜单。
为什么使用长按? 长按是一种常见的交互方式,用于显示更多操作。这样可以保持界面简洁,同时提供更多功能。
与搜索功能的联动
根据浏览记录,可以在搜索页面显示推荐搜索词:
final keywords = recentViewed
.map((p) => p.category)
.toSet()
.take(5)
.toList();
keywords 从浏览记录中提取类别作为推荐关键词。
为什么需要联动? 根据用户的浏览历史推荐搜索词,可以帮助用户更快地找到想要的商品。
高级功能扩展
浏览时长追踪
记录用户在每个商品上停留的时间,可以更准确地了解用户的兴趣。
class BrowsingDurationTracker {
final Map<int, DateTime> _startTimes = {};
final Map<int, Duration> _totalDurations = {};
_startTimes 记录开始浏览的时间。
_totalDurations 记录总浏览时长。
开始追踪
void startTracking(int productId) {
_startTimes[productId] = DateTime.now();
}
startTracking 开始追踪。在用户进入商品详情页面时调用。
停止追踪
void stopTracking(int productId) {
final startTime = _startTimes[productId];
if (startTime != null) {
final duration = DateTime.now().difference(startTime);
_totalDurations[productId] =
(_totalDurations[productId] ?? Duration.zero) + duration;
_startTimes.remove(productId);
}
}
stopTracking 停止追踪。在用户离开商品详情页面时调用。
为什么需要追踪浏览时长? 用户在商品上停留的时间越长,说明他对这个商品越感兴趣。这个信息比单纯的浏览次数更有价值。
智能清理
自动清理过期的浏览记录,保持数据的新鲜度。
class SmartBrowsingCleaner {
final Duration _maxAge;
SmartBrowsingCleaner({Duration maxAge = const Duration(days: 30)})
: _maxAge = maxAge;
_maxAge 最大保留时间,默认 30 天。
清理过期记录
List<BrowsingRecord> cleanOldRecords(List<BrowsingRecord> records) {
final now = DateTime.now();
return records.where((r) => now.difference(r.timestamp) <= _maxAge).toList();
}
cleanOldRecords 清理超过最大保留时间的记录。
为什么需要智能清理? 浏览记录会随时间积累,如果不清理,会占用大量存储空间。智能清理可以自动清理过期和重复的记录,保持数据的新鲜度。
最佳实践总结
1. 无感知追踪
浏览追踪应该是无感知的,不影响用户体验:
WidgetsBinding.instance.addPostFrameCallback((_) {
appState?.addRecentViewed(product);
});
在帧渲染完成后追踪 - 不阻塞 UI 构建。用户不会感觉到任何延迟。
2. 合理的数量限制
浏览记录应该有数量限制,避免无限增长:
if (_recentViewed.length > 20) {
_recentViewed.removeLast();
}
20 条是一个合理的数量 - 足够用户找回最近浏览的商品,又不会太多。
3. 去重处理
同一个商品只应该出现一次:
_recentViewed.removeWhere((p) => p.id == product.id);
_recentViewed.insert(0, product);
先移除再插入 - 确保商品只出现一次,同时更新位置到最前面。
4. 友好的空状态
当没有浏览记录时,显示友好的提示:
Icon(Icons.history, size: 80),
Text('暂无浏览记录'),
Text('浏览过的商品会显示在这里'),
ShopButton(label: '去逛逛'),
图标 + 文字 + 按钮 - 告诉用户当前状态和下一步操作。
5. 与其他功能联动
浏览记录应该与收藏、购物车等功能联动:
trailing: IconButton(
icon: Icon(appState.isFavorite(product.id) ? Icons.favorite : Icons.favorite_border),
onPressed: () => appState.toggleFavorite(product.id),
),
在浏览记录中提供收藏功能 - 方便用户快速操作。
6. 数据持久化
浏览记录应该保存到本地存储:
await prefs.setString('recent_viewed', jsonEncode(jsonList));
保存到 SharedPreferences - 应用关闭后数据不会丢失。
7. 尊重用户隐私
提供清空功能,让用户可以控制自己的数据:
TextButton(
onPressed: () {
appState.clearRecentViewed();
},
child: const Text('清空'),
)
提供清空按钮 - 尊重用户的隐私选择。
总结
这篇文章实现了一个功能完整的最近浏览系统,包括浏览追踪、浏览列表、浏览管理、数据分析等功能。
最近浏览功能看起来简单,但它背后有很多细节需要考虑。一个好的最近浏览系统不仅可以帮助用户找回商品,还可以为个性化推荐提供数据支持。
关键要点:
- 使用 StatelessWidget 和 AnimatedBuilder 来实现响应式的浏览列表
- 使用 List 来存储浏览记录,保持时间顺序
- 限制数量 避免数据无限增长
- 去重处理 确保商品只出现一次
- 持久化存储 保存到本地和服务器
- 隐私保护 提供清空功能和隐私设置
- 数据分析 利用浏览数据进行个性化推荐
代码都来自实际项目,可以直接运行。下一篇我们会实现商店首页功能,讲解如何设计一个吸引用户的商店首页。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)