在这里插入图片描述

最近浏览功能看起来简单,但它背后隐藏着巨大的商业价值。每一次用户浏览商品,都是一次宝贵的行为数据。通过分析这些数据,我们可以了解用户的购物意图、偏好变化、决策周期。这篇文章会从用户行为追踪的角度,详细讲解如何实现一个既实用又能产生数据价值的最近浏览系统。

为什么最近浏览如此重要

很多开发者把最近浏览当作一个简单的历史记录功能,这其实低估了它的价值。

帮助用户找回商品 - 用户经常会浏览很多商品,然后忘记某个商品在哪里。最近浏览可以帮助用户快速找回之前看过的商品,减少用户的挫败感。我自己就经常遇到这种情况,看了一个商品觉得不错,但没有立即购买,过几天想买的时候却找不到了。

缩短购买决策周期 - 用户可能需要多次浏览同一个商品才会决定购买。最近浏览让用户可以快速回到之前看过的商品,不需要重新搜索,这可以缩短购买决策周期。特别是对于价格较高的商品,用户往往需要反复比较才会下单。

提供行为数据 - 用户浏览了哪些商品、浏览了多少次、浏览的时间间隔,这些数据可以帮助我们了解用户的购物意图。比如,用户多次浏览同一个商品,说明他对这个商品很感兴趣,可能只是在等待降价。这时候如果能推送一个优惠券,转化率会很高。

支持个性化推荐 - 根据用户的浏览历史,可以推荐类似的商品。这比基于搜索关键词的推荐更准确,因为浏览行为更能反映用户的真实兴趣。用户可能搜索"手机",但实际上只对某个价位段的手机感兴趣,浏览记录可以帮助我们发现这一点。

根据电商行业的数据,有最近浏览功能的应用,用户的回访率比没有这个功能的应用高 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('清空'),
)

提供清空按钮 - 尊重用户的隐私选择。

总结

这篇文章实现了一个功能完整的最近浏览系统,包括浏览追踪、浏览列表、浏览管理、数据分析等功能。

最近浏览功能看起来简单,但它背后有很多细节需要考虑。一个好的最近浏览系统不仅可以帮助用户找回商品,还可以为个性化推荐提供数据支持。

关键要点:

  • 使用 StatelessWidgetAnimatedBuilder 来实现响应式的浏览列表
  • 使用 List 来存储浏览记录,保持时间顺序
  • 限制数量 避免数据无限增长
  • 去重处理 确保商品只出现一次
  • 持久化存储 保存到本地和服务器
  • 隐私保护 提供清空功能和隐私设置
  • 数据分析 利用浏览数据进行个性化推荐

代码都来自实际项目,可以直接运行。下一篇我们会实现商店首页功能,讲解如何设计一个吸引用户的商店首页。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐