在这里插入图片描述

浏览历史这个功能,用户可能不会天天用,但真要找之前看过的文章时,没有它就很抓狂。特别是那种"昨天看到一篇文章挺好的,想再看一遍,但忘了标题"的场景,浏览历史就是救星。

今天这篇文章,咱们就来完整实现浏览历史功能。不只是简单地记录和展示,还要考虑按日期分组、滑动删除、批量清空这些实用的交互细节。

浏览历史和收藏有什么不同

乍一看,浏览历史和收藏功能挺像的,都是存储一堆文章然后展示出来。但仔细想想,它们的使用场景完全不同。

收藏是主动行为,用户觉得这篇文章好,点一下收藏,以后想看随时能找到。收藏的文章通常不多,用户对每一篇都有印象。

浏览历史是被动记录,用户只要点进去看了,就自动记录下来。历史记录会越来越多,用户可能都不记得自己看过什么。

这两个区别决定了实现上的差异:

存储策略不同:收藏可以无限存,浏览历史需要限制数量或者定期清理,不然数据会越来越大。

展示方式不同:收藏按收藏时间排序就行,浏览历史最好按日期分组,方便用户找"昨天看的"或"上周看的"。

删除逻辑不同:收藏删除要谨慎,最好有确认;浏览历史删除可以更随意,支持滑动删除单条记录。

想清楚这些,代码就好写了。

页面的基本结构

先看看项目里浏览历史页面的基础代码:

import 'package:flutter/material.dart';

class HistoryScreen extends StatelessWidget {
  const HistoryScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('浏览历史'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete_outline),
            onPressed: () {
              _showClearDialog(context);
            },
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.history,
              size: 80,
              color: Colors.grey[400],
            ),
            const SizedBox(height: 16),
            Text(
              '暂无浏览历史',
              style: TextStyle(
                fontSize: 16,
                color: Colors.grey[600],
              ),
            ),
          ],
        ),
      ),
    );
  }

这是一个空状态的页面,显示"暂无浏览历史"。AppBar右边有个删除按钮,点击可以清空所有历史。

注意这里用的是StatelessWidget。为什么?因为浏览历史的数据会用Provider来管理,页面本身不需要维护状态。数据变化时,Provider会通知页面重建。

清空历史的确认对话框

清空是个危险操作,得让用户确认:

void _showClearDialog(BuildContext context) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('清空历史'),
      content: const Text('确定要清空所有浏览历史吗?'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () {
            Navigator.pop(context);
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('已清空历史')),
            );
          },
          child: const Text('确定'),
        ),
      ],
    ),
  );
}

对话框的文案要简洁明了。"清空历史"作为标题,"确定要清空所有浏览历史吗?"作为内容,用户一眼就能理解要做什么。

操作完成后用SnackBar给个反馈,让用户知道清空成功了。

创建浏览历史的数据模型

浏览历史不只是存储文章,还要记录浏览时间。咱们创建一个专门的模型:

class HistoryItem {
  final NewsArticle article;
  final DateTime viewedAt;
  
  HistoryItem({
    required this.article,
    required this.viewedAt,
  });

article是文章本身,viewedAt是浏览时间。为什么要单独记录浏览时间,而不是用文章的发布时间?因为用户可能今天看了一篇上周发布的文章,浏览历史应该按浏览时间排序,而不是发布时间。

接下来是序列化方法,用于本地存储:

  Map<String, dynamic> toJson() {
    return {
      'article': article.toJson(),
      'viewedAt': viewedAt.toIso8601String(),
    };
  }
  
  factory HistoryItem.fromJson(Map<String, dynamic> json) {
    return HistoryItem(
      article: NewsArticle.fromJson(json['article']),
      viewedAt: DateTime.parse(json['viewedAt']),
    );
  }
}

toJson把对象转成Map,方便存到SharedPreferences或数据库。fromJson是反向操作,从存储中读取数据时用。

时间用toIso8601String()格式化,这是一种标准格式,解析时不会出问题。

用Provider管理浏览历史

浏览历史需要在多个地方使用:详情页要添加记录,历史页要展示和删除。用Provider来全局管理最合适。

class HistoryProvider extends ChangeNotifier {
  List<HistoryItem> _history = [];
  static const int _maxHistoryCount = 100;
  
  List<HistoryItem> get history => _history;

_history存储所有历史记录,_maxHistoryCount限制最大数量。为什么要限制?因为浏览历史会越来越多,不限制的话,存储空间会被占满,加载也会变慢。100条对于大部分用户来说足够了。

初始化时加载历史数据

  HistoryProvider() {
    _loadHistory();
  }
  
  Future<void> _loadHistory() async {
    final prefs = await SharedPreferences.getInstance();
    final historyJson = prefs.getString('browsing_history');
    
    if (historyJson != null) {
      final List<dynamic> decoded = jsonDecode(historyJson);
      _history = decoded
          .map((item) => HistoryItem.fromJson(item))
          .toList();
      notifyListeners();
    }
  }

在构造函数里调用_loadHistory,应用启动时就把历史数据加载到内存。从SharedPreferences读取JSON字符串,解析成HistoryItem列表。

notifyListeners()通知所有监听者数据变化了,如果历史页面已经打开,会自动刷新。

添加浏览记录

  Future<void> addHistory(NewsArticle article) async {
    // 先检查是否已经存在
    final existingIndex = _history.indexWhere(
      (item) => item.article.id == article.id
    );
    
    if (existingIndex != -1) {
      // 已存在,移到最前面并更新时间
      _history.removeAt(existingIndex);
    }
    
    // 添加到最前面
    _history.insert(0, HistoryItem(
      article: article,
      viewedAt: DateTime.now(),
    ));
    
    // 超过最大数量,删除最旧的
    if (_history.length > _maxHistoryCount) {
      _history = _history.sublist(0, _maxHistoryCount);
    }
    
    await _saveHistory();
    notifyListeners();
  }

这段代码处理了几个细节:

去重:如果用户重复看同一篇文章,不应该出现多条记录。先检查是否已存在,存在就删掉旧的。

更新时间:重复浏览时,把记录移到最前面,并更新浏览时间。这样"最近浏览"永远是真正最近看的。

限制数量:添加新记录后,如果超过最大数量,删掉最旧的。用sublist截取前100条。

持久化:每次修改后都调用_saveHistory保存到本地。

保存历史数据

  Future<void> _saveHistory() async {
    final prefs = await SharedPreferences.getInstance();
    final historyJson = jsonEncode(
      _history.map((item) => item.toJson()).toList()
    );
    await prefs.setString('browsing_history', historyJson);
  }

把历史列表转成JSON字符串,存到SharedPreferences。这里用的key是browsing_history,和其他数据区分开。

删除单条记录

  Future<void> removeHistory(String articleId) async {
    _history.removeWhere((item) => item.article.id == articleId);
    await _saveHistory();
    notifyListeners();
  }

根据文章ID删除对应的记录。removeWhere会删除所有匹配的元素,虽然理论上同一篇文章只会有一条记录,但用removeWhere更安全。

清空所有历史

  Future<void> clearHistory() async {
    _history.clear();
    await _saveHistory();
    notifyListeners();
  }

清空很简单,直接clear然后保存。

检查是否已浏览

  bool hasViewed(String articleId) {
    return _history.any((item) => item.article.id == articleId);
  }

这个方法可以用来在列表里标记已读文章,比如把已读文章的标题变灰。

在新闻详情页记录浏览历史

用户点进新闻详情页时,要自动记录浏览历史。看看怎么在详情页里调用:

class NewsDetailScreen extends StatefulWidget {
  final NewsArticle article;
  
  const NewsDetailScreen({super.key, required this.article});
  
  
  State<NewsDetailScreen> createState() => _NewsDetailScreenState();
}

class _NewsDetailScreenState extends State<NewsDetailScreen> {
  
  void initState() {
    super.initState();
    _recordHistory();
  }
  
  void _recordHistory() {
    final historyProvider = context.read<HistoryProvider>();
    historyProvider.addHistory(widget.article);
  }

initState里调用_recordHistory,页面一创建就记录。用context.read而不是context.watch,因为这里只是写入数据,不需要监听变化。

为什么在initState里记录,而不是在build里? 因为build可能会被多次调用(比如页面重建),而initState只会调用一次。如果在build里记录,同一篇文章可能会被记录多次。

有人可能会问:用户点进去马上退出,也算浏览吗?这个看产品需求。如果想更精确,可以加个延时,比如停留3秒以上才记录:


void initState() {
  super.initState();
  Future.delayed(const Duration(seconds: 3), () {
    if (mounted) {
      _recordHistory();
    }
  });
}

mounted检查是必要的,因为用户可能在3秒内就退出了,这时候页面已经销毁,调用Provider会报错。

完善浏览历史页面

现在Provider有了,来完善历史页面的展示逻辑:

class HistoryScreen extends StatelessWidget {
  const HistoryScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('浏览历史'),
        actions: [
          Consumer<HistoryProvider>(
            builder: (context, historyProvider, child) {
              if (historyProvider.history.isEmpty) {
                return const SizedBox();
              }
              return IconButton(
                icon: const Icon(Icons.delete_outline),
                onPressed: () {
                  _showClearDialog(context, historyProvider);
                },
              );
            },
          ),
        ],
      ),
      body: Consumer<HistoryProvider>(
        builder: (context, historyProvider, child) {
          if (historyProvider.history.isEmpty) {
            return _buildEmptyState();
          }
          return _buildHistoryList(historyProvider);
        },
      ),
    );
  }

Consumer监听HistoryProvider的变化。如果历史为空,显示空状态;否则显示历史列表。

AppBar的删除按钮也用Consumer包裹,历史为空时隐藏按钮。这是个小细节,但能避免用户点击一个没用的按钮。

空状态的展示

Widget _buildEmptyState() {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(
          Icons.history,
          size: 80,
          color: Colors.grey[400],
        ),
        const SizedBox(height: 16),
        Text(
          '暂无浏览历史',
          style: TextStyle(
            fontSize: 16,
            color: Colors.grey[600],
          ),
        ),
        const SizedBox(height: 8),
        Text(
          '浏览过的新闻会出现在这里',
          style: TextStyle(
            fontSize: 14,
            color: Colors.grey[500],
          ),
        ),
      ],
    ),
  );
}

空状态不只是显示"暂无浏览历史",还加了一句引导文字"浏览过的新闻会出现在这里",告诉用户这个页面是干什么的。

图标用Icons.history,和页面主题呼应。颜色用灰色,不抢眼但能看到。

历史列表的展示

Widget _buildHistoryList(HistoryProvider historyProvider) {
  return ListView.builder(
    padding: const EdgeInsets.all(16),
    itemCount: historyProvider.history.length,
    itemBuilder: (context, index) {
      final item = historyProvider.history[index];
      return _buildHistoryItem(context, item, historyProvider);
    },
  );
}

ListView.builder构建列表,懒加载,性能好。padding给列表加个边距,不贴边。

单条历史记录的展示

可以直接复用NewsCard组件:

Widget _buildHistoryItem(
  BuildContext context, 
  HistoryItem item, 
  HistoryProvider historyProvider
) {
  return Dismissible(
    key: Key(item.article.id),
    direction: DismissDirection.endToStart,
    background: Container(
      alignment: Alignment.centerRight,
      padding: const EdgeInsets.only(right: 20),
      color: Colors.red,
      child: const Icon(Icons.delete, color: Colors.white),
    ),
    onDismissed: (direction) {
      historyProvider.removeHistory(item.article.id);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: const Text('已删除'),
          action: SnackBarAction(
            label: '撤销',
            onPressed: () {
              historyProvider.addHistory(item.article);
            },
          ),
        ),
      );
    },
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        NewsCard(article: item.article),
        Padding(
          padding: const EdgeInsets.only(left: 12, bottom: 8),
          child: Text(
            _formatViewedTime(item.viewedAt),
            style: TextStyle(
              fontSize: 12,
              color: Colors.grey[500],
            ),
          ),
        ),
      ],
    ),
  );
}

这段代码有几个亮点:

Dismissible实现滑动删除:用户从右往左滑动,可以删除这条记录。direction: DismissDirection.endToStart限制只能从右往左滑。

删除时的背景:滑动时显示红色背景和删除图标,让用户知道这是删除操作。

撤销功能:删除后显示SnackBar,带一个"撤销"按钮。用户误删可以立即恢复。这个细节很重要,能减少用户的焦虑感。

显示浏览时间:在NewsCard下面显示浏览时间,比如"2小时前"、“昨天”。

格式化浏览时间

String _formatViewedTime(DateTime viewedAt) {
  final now = DateTime.now();
  final difference = now.difference(viewedAt);
  
  if (difference.inMinutes < 1) {
    return '刚刚';
  } else if (difference.inMinutes < 60) {
    return '${difference.inMinutes}分钟前';
  } else if (difference.inHours < 24) {
    return '${difference.inHours}小时前';
  } else if (difference.inDays == 1) {
    return '昨天';
  } else if (difference.inDays < 7) {
    return '${difference.inDays}天前';
  } else {
    return DateFormat('MM月dd日').format(viewedAt);
  }
}

时间格式化要人性化。"2小时前"比"14:30"更直观,用户一眼就知道是最近看的。超过一周的显示具体日期。

按日期分组展示

如果历史记录很多,全部堆在一起不好找。按日期分组会更清晰:

Map<String, List<HistoryItem>> _groupByDate(List<HistoryItem> history) {
  final Map<String, List<HistoryItem>> grouped = {};
  
  for (final item in history) {
    final dateKey = _getDateKey(item.viewedAt);
    if (!grouped.containsKey(dateKey)) {
      grouped[dateKey] = [];
    }
    grouped[dateKey]!.add(item);
  }
  
  return grouped;
}

String _getDateKey(DateTime date) {
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  final yesterday = today.subtract(const Duration(days: 1));
  final itemDate = DateTime(date.year, date.month, date.day);
  
  if (itemDate == today) {
    return '今天';
  } else if (itemDate == yesterday) {
    return '昨天';
  } else if (now.difference(date).inDays < 7) {
    return '本周';
  } else if (now.year == date.year && now.month == date.month) {
    return '本月';
  } else {
    return DateFormat('yyyy年MM月').format(date);
  }
}

_groupByDate把历史记录按日期分组,返回一个Map。key是日期标签(“今天”、"昨天"等),value是该日期的记录列表。

_getDateKey生成日期标签。今天和昨天用文字,本周内的归到"本周",本月内的归到"本月",更早的显示具体年月。

分组列表的展示

Widget _buildGroupedHistoryList(HistoryProvider historyProvider) {
  final grouped = _groupByDate(historyProvider.history);
  final dateKeys = grouped.keys.toList();
  
  return ListView.builder(
    padding: const EdgeInsets.all(16),
    itemCount: dateKeys.length,
    itemBuilder: (context, index) {
      final dateKey = dateKeys[index];
      final items = grouped[dateKey]!;
      
      return Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildDateHeader(dateKey),
          ...items.map((item) => _buildHistoryItem(context, item, historyProvider)),
        ],
      );
    },
  );
}

Widget _buildDateHeader(String dateKey) {
  return Padding(
    padding: const EdgeInsets.only(top: 16, bottom: 8),
    child: Text(
      dateKey,
      style: const TextStyle(
        fontSize: 16,
        fontWeight: FontWeight.bold,
        color: Colors.grey,
      ),
    ),
  );
}

外层ListView遍历日期分组,每个分组包含一个日期标题和该日期的所有记录。

日期标题用灰色加粗,和内容区分开。padding让标题和上一组有间距。

搜索历史记录

如果历史记录很多,用户可能想搜索特定的文章。加个搜索功能:

class _HistoryScreenState extends State<HistoryScreen> {
  String _searchQuery = '';
  
  List<HistoryItem> _filterHistory(List<HistoryItem> history) {
    if (_searchQuery.isEmpty) {
      return history;
    }
    return history.where((item) {
      return item.article.title.contains(_searchQuery) ||
             item.article.summary.contains(_searchQuery) ||
             item.article.source.contains(_searchQuery);
    }).toList();
  }

_filterHistory根据搜索关键词过滤历史记录。搜索范围包括标题、摘要和来源。

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: TextField(
          decoration: InputDecoration(
            hintText: '搜索浏览历史...',
            border: InputBorder.none,
            hintStyle: TextStyle(color: Colors.grey[400]),
          ),
          style: const TextStyle(fontSize: 16),
          onChanged: (value) {
            setState(() {
              _searchQuery = value;
            });
          },
        ),
        actions: [
          // 清空按钮
        ],
      ),
      body: Consumer<HistoryProvider>(
        builder: (context, historyProvider, child) {
          final filteredHistory = _filterHistory(historyProvider.history);
          
          if (filteredHistory.isEmpty) {
            return _buildEmptyState();
          }
          return _buildHistoryList(filteredHistory);
        },
      ),
    );
  }
}

把AppBar的title换成TextField,用户可以直接输入搜索。输入时实时过滤,不用点搜索按钮。

注意这里页面改成了StatefulWidget,因为需要管理搜索关键词的状态。

在新闻列表标记已读

浏览历史还有个用途:在新闻列表里标记哪些文章已经看过了。

class NewsCard extends StatelessWidget {
  final NewsArticle article;
  final bool showReadIndicator;

  const NewsCard({
    super.key, 
    required this.article,
    this.showReadIndicator = false,
  });

  
  Widget build(BuildContext context) {
    final historyProvider = context.watch<HistoryProvider>();
    final hasRead = historyProvider.hasViewed(article.id);
    
    return Card(
      margin: const EdgeInsets.only(bottom: 16),
      child: InkWell(
        onTap: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => NewsDetailScreen(article: article),
            ),
          );
        },
        child: Opacity(
          opacity: hasRead && showReadIndicator ? 0.6 : 1.0,
          child: Column(
            // ... 原有内容
          ),
        ),
      ),
    );
  }
}

加一个showReadIndicator参数,控制是否显示已读标记。如果开启,已读文章会变成60%透明度,视觉上变淡。

在首页使用时:

NewsCard(
  article: article,
  showReadIndicator: true,
)

这样用户一眼就能看出哪些文章已经看过了,避免重复点击。

历史记录的自动清理

历史记录不能无限增长,需要定期清理。除了限制最大数量,还可以清理太旧的记录:

Future<void> _cleanOldHistory() async {
  final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30));
  
  _history.removeWhere((item) => item.viewedAt.isBefore(thirtyDaysAgo));
  
  await _saveHistory();
  notifyListeners();
}

这个方法删除30天前的记录。可以在应用启动时调用:

HistoryProvider() {
  _loadHistory().then((_) {
    _cleanOldHistory();
  });
}

先加载历史,再清理旧记录。这样用户不会感知到清理过程。

导出浏览历史

有些用户可能想导出自己的浏览历史,比如做数据备份或者分析自己的阅读习惯:

Future<String> exportHistory() async {
  final exportData = {
    'exportDate': DateTime.now().toIso8601String(),
    'totalCount': _history.length,
    'history': _history.map((item) => {
      'title': item.article.title,
      'source': item.article.source,
      'url': item.article.url,
      'viewedAt': item.viewedAt.toIso8601String(),
    }).toList(),
  };
  
  return const JsonEncoder.withIndent('  ').convert(exportData);
}

导出的数据包括导出时间、总数量和每条记录的关键信息。用格式化的JSON,方便阅读。

在页面上加个导出按钮:

IconButton(
  icon: const Icon(Icons.download),
  onPressed: () async {
    final historyProvider = context.read<HistoryProvider>();
    final jsonString = await historyProvider.exportHistory();
    
    // 分享或保存文件
    await Share.share(jsonString, subject: '我的浏览历史');
  },
)

一些优化细节

优化一:避免重复添加

如果用户快速多次点击同一篇文章,可能会触发多次添加。加个防抖:

String? _lastAddedId;
DateTime? _lastAddedTime;

Future<void> addHistory(NewsArticle article) async {
  // 防抖:同一篇文章1秒内不重复添加
  if (_lastAddedId == article.id && 
      _lastAddedTime != null &&
      DateTime.now().difference(_lastAddedTime!).inSeconds < 1) {
    return;
  }
  
  _lastAddedId = article.id;
  _lastAddedTime = DateTime.now();
  
  // 原有的添加逻辑
}

优化二:批量删除

除了清空全部,还可以支持批量删除:

Future<void> removeHistoryBatch(List<String> articleIds) async {
  _history.removeWhere((item) => articleIds.contains(item.article.id));
  await _saveHistory();
  notifyListeners();
}

在页面上可以加个多选模式,让用户勾选要删除的记录。

优化三:按来源筛选

如果用户想看某个来源的所有历史:

List<HistoryItem> getHistoryBySource(String source) {
  return _history.where((item) => item.article.source == source).toList();
}

可以在页面上加个筛选器,按来源过滤历史记录。

常见问题排查

问题一:浏览历史没有记录

检查详情页是否正确调用了addHistory。确认Provider是否正确注册到了Widget树。

问题二:历史记录丢失

检查SharedPreferences的读写是否正确。可能是key写错了,或者JSON解析失败。加日志打印调试。

问题三:滑动删除不生效

检查Dismissible的key是否唯一。如果多条记录的key相同,Flutter会混淆。

问题四:性能问题

如果历史记录很多,列表滚动可能会卡。确保用ListView.builder而不是ListViewchildren。考虑减少每条记录的Widget复杂度。

写在最后

浏览历史看起来是个简单功能,但要做好需要考虑很多细节。

从数据层面,要考虑去重、限制数量、定期清理、持久化存储。

从交互层面,要支持滑动删除、批量清空、撤销操作、搜索过滤。

从展示层面,要按日期分组、显示浏览时间、标记已读状态。

这些细节加起来,才能让用户觉得这个功能好用。

最后提醒一点:浏览历史涉及用户隐私,要在隐私政策里说明你会记录用户的浏览行为。同时要提供清空功能,让用户能删除自己的历史记录。这是对用户的尊重。

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

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐