Flutter for OpenHarmony:从零搭建幸运大转盘app(十)数据统计
用户抽了这么多次奖,总想看看自己的"战绩"。这一篇我们来实现数据统计页面,展示抽奖次数、中奖率、连续抽奖天数等数据。

用户抽了这么多次奖,总想看看自己的"战绩"。这一篇我们来实现数据统计页面,展示抽奖次数、中奖率、连续抽奖天数等数据。
为什么需要数据统计
数据统计不仅仅是展示数字,它有几个重要作用:
- 增加用户粘性:连续天数、最佳记录等数据会激励用户每天回来
- 提供成就感:看到自己的"战绩"会让用户有满足感
- 游戏化设计:里程碑机制让简单的抽奖变得更有趣
- 数据洞察:用户可以了解自己的中奖概率和偏好
统计指标设计
在设计统计功能之前,我先梳理了需要追踪的核心指标:
| 指标 | 说明 | 计算方式 |
|---|---|---|
| 总抽奖次数 | 用户累计抽奖的次数 | 每次抽奖 +1 |
| 中奖率 | 真正中奖的比例 | 中奖次数 / 总次数 × 100% |
| 当前连续天数 | 连续每天抽奖的天数 | 每天首次抽奖时计算 |
| 最佳连续记录 | 历史最长连续天数 | 取连续天数的最大值 |
| 各奖品中奖次数 | 每种奖品抽中的次数 | 按奖品名称分组统计 |
统计管理器
先在 lib/utils/ 目录下创建 stats_manager.dart,实现统计逻辑:
class StatsManager extends ChangeNotifier {
static final StatsManager _instance = StatsManager._internal();
factory StatsManager() => _instance;
StatsManager._internal();
final StorageService _storage = StorageService();
int _totalSpins = 0;
int _consecutiveDays = 0;
int _bestStreak = 0;
DateTime? _lastSpinDate;
int get totalSpins => _totalSpins;
int get consecutiveDays => _consecutiveDays;
int get bestStreak => _bestStreak;
DateTime? get lastSpinDate => _lastSpinDate;
}
四个核心指标:总抽奖次数、当前连续天数、最佳连续记录、上次抽奖日期。
单例模式的应用
统计管理器使用单例模式,确保整个应用只有一个实例:
static final StatsManager _instance = StatsManager._internal();
factory StatsManager() => _instance;
StatsManager._internal();
这样做的好处:
- 任何页面获取的都是同一个实例
- 数据状态全局一致
- 避免重复初始化和内存浪费
为什么继承 ChangeNotifier
ChangeNotifier 是 Flutter 提供的简单状态管理方案:
class StatsManager extends ChangeNotifier {
void recordSpin() {
_totalSpins++;
notifyListeners(); // 通知所有监听者
}
}
// 在页面中监听
_statsManager.addListener(_onDataChanged);
当数据变化时调用 notifyListeners(),所有注册的监听者都会收到通知并更新 UI。
初始化统计数据
应用启动时从本地存储加载数据:
Future<void> init() async {
if (_initialized) return;
_totalSpins = _storage.totalSpins;
_consecutiveDays = _storage.consecutiveDays;
_bestStreak = _storage.bestStreak;
_lastSpinDate = _storage.lastSpinDate;
_checkConsecutiveDays();
_initialized = true;
notifyListeners();
}
_initialized 标志防止重复初始化。加载数据后立即检查连续天数是否中断。
记录抽奖
每次抽奖时调用 recordSpin 方法:
void recordSpin() {
_totalSpins++;
_updateConsecutiveDays();
_saveStats();
notifyListeners();
}
增加总次数,更新连续天数,保存数据,通知监听者。这四步缺一不可。
连续天数计算
连续天数的逻辑稍微复杂一点,需要处理多种情况:
void _updateConsecutiveDays() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
if (_lastSpinDate == null) {
_consecutiveDays = 1;
} else {
final lastDate = DateTime(_lastSpinDate!.year, _lastSpinDate!.month, _lastSpinDate!.day);
final difference = today.difference(lastDate).inDays;
if (difference == 0) {
// 同一天,不增加
} else if (difference == 1) {
// 连续天数+1
_consecutiveDays++;
} else {
// 断了,重新计数
_consecutiveDays = 1;
}
}
if (_consecutiveDays > _bestStreak) {
_bestStreak = _consecutiveDays;
}
_lastSpinDate = now;
}
日期比较的技巧
注意这里创建了"纯日期"对象来比较:
final today = DateTime(now.year, now.month, now.day);
final lastDate = DateTime(_lastSpinDate!.year, _lastSpinDate!.month, _lastSpinDate!.day);
这样做是为了忽略时分秒,只比较日期部分。如果直接用 now.difference(_lastSpinDate) 可能会因为时间差不足 24 小时而计算错误。
四种情况的处理
| 情况 | difference | 处理方式 |
|---|---|---|
| 第一次抽奖 | - | 连续天数 = 1 |
| 同一天再次抽奖 | 0 | 不变 |
| 连续第二天 | 1 | 连续天数 + 1 |
| 中断超过一天 | > 1 | 连续天数 = 1 |
同时更新最佳记录,取连续天数和历史最佳的较大值。
检查连续是否中断
应用启动时检查连续天数是否已经中断:
void _checkConsecutiveDays() {
if (_lastSpinDate == null) return;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final lastDate = DateTime(_lastSpinDate!.year, _lastSpinDate!.month, _lastSpinDate!.day);
final difference = today.difference(lastDate).inDays;
if (difference > 1) {
_consecutiveDays = 0;
_saveStats();
}
}
如果超过 1 天没抽奖,连续天数归零。这个检查在 init() 中调用,确保用户看到的是最新状态。
为什么归零而不是设为 1
这里有个细节:中断后设为 0 而不是 1。因为用户今天还没抽奖,只有抽奖后才会变成 1。如果直接设为 1,会给用户错误的信息。
保存统计数据
数据变化后需要持久化保存:
Future<void> _saveStats() async {
await _storage.setTotalSpins(_totalSpins);
await _storage.setConsecutiveDays(_consecutiveDays);
await _storage.setBestStreak(_bestStreak);
if (_lastSpinDate != null) {
await _storage.setLastSpinDate(_lastSpinDate!);
}
}
使用 StorageService 保存到本地,下次启动时可以恢复。
统计页面布局
RankingPage 分三个区域:核心数据卡片、连续抽奖卡片、中奖统计:
Widget build(BuildContext context) {
final prizeStats = _getPrizeStats();
final sortedPrizes = prizeStats.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Scaffold(
appBar: AppBar(
title: const Text('数据统计'),
centerTitle: true,
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatsCards(),
const SizedBox(height: 24),
_buildStreakCard(),
const SizedBox(height: 24),
_buildPrizeStatsSection(sortedPrizes),
],
),
),
);
}
级联操作符的妙用
注意这行代码:
final sortedPrizes = prizeStats.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
.. 是 Dart 的级联操作符,它允许对同一个对象连续调用多个方法。等价于:
final sortedPrizes = prizeStats.entries.toList();
sortedPrizes.sort((a, b) => b.value.compareTo(a.value));
级联操作符让代码更简洁,特别适合链式调用。
核心数据卡片
顶部两个卡片并排显示总抽奖次数和中奖率:
Widget _buildStatsCards() {
return Row(
children: [
Expanded(
child: _buildStatCard(
icon: Icons.casino,
title: '总抽奖次数',
value: '${_statsManager.totalSpins}',
color: Colors.blue,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
icon: Icons.emoji_events,
title: '中奖率',
value: '${_getWinRate().toStringAsFixed(1)}%',
color: Colors.amber,
),
),
],
);
}
中奖率的计算
中奖率的计算排除"谢谢参与":
double _getWinRate() {
if (_prizeManager.records.isEmpty) return 0;
final wins = _prizeManager.records.where((r) => !r.prize.name.contains('谢谢参与')).length;
return wins / _prizeManager.records.length * 100;
}
where 方法过滤出真正中奖的记录,然后计算比例。注意要处理空列表的情况,避免除以零。
数字格式化
toStringAsFixed(1) 保留一位小数:
75.333.toStringAsFixed(1) // "75.3"
100.0.toStringAsFixed(1) // "100.0"
这样中奖率显示为 “75.3%” 而不是 “75.33333…%”。
统计卡片样式
单个卡片的样式设计:
Widget _buildStatCard({
required IconData icon,
required String title,
required String value,
required Color color,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, color.withOpacity(0.7)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.white, size: 28),
const SizedBox(height: 12),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
title,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 14,
),
),
],
),
);
}
渐变背景配合彩色阴影,让卡片有立体感和品质感。
卡片设计原则
好的数据卡片应该遵循以下原则:
- 数字突出:数值用大字号、粗体,一眼就能看到
- 标签次要:标签用小字号、浅色,不抢夺注意力
- 图标辅助:图标帮助用户快速理解数据含义
- 颜色区分:不同卡片用不同颜色,便于区分
连续抽奖卡片
展示当前连续天数和最佳记录:
Widget _buildStreakCard() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.local_fire_department, color: Colors.orange.shade600, size: 24),
),
const SizedBox(width: 12),
const Text(
'连续抽奖',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _buildStreakItem(
label: '当前连续',
value: '${_statsManager.consecutiveDays}',
unit: '天',
color: Colors.orange,
),
),
Container(width: 1, height: 50, color: Colors.grey.shade200),
Expanded(
child: _buildStreakItem(
label: '最佳记录',
value: '${_statsManager.bestStreak}',
unit: '天',
color: Colors.red,
),
),
],
),
const SizedBox(height: 16),
_buildStreakProgress(),
],
),
);
}
分隔线的设计
两个数据之间用一条细线分隔:
Container(width: 1, height: 50, color: Colors.grey.shade200),
这种设计比用 Divider 更灵活,可以精确控制高度和颜色。
连续天数项
单个连续天数的展示:
Widget _buildStreakItem({
required String label,
required String value,
required String unit,
required Color color,
}) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: color,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
unit,
style: TextStyle(fontSize: 14, color: color),
),
),
],
),
const SizedBox(height: 4),
Text(label, style: TextStyle(color: Colors.grey.shade600, fontSize: 12)),
],
);
}
数字和单位用 crossAxisAlignment: CrossAxisAlignment.end 底部对齐,单位稍微上移一点(bottom: 4),视觉上更协调。
里程碑进度
用圆形图标展示连续天数的里程碑:
Widget _buildStreakProgress() {
const milestones = [3, 7, 14, 30];
final currentStreak = _statsManager.consecutiveDays;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('里程碑', style: TextStyle(color: Colors.grey.shade600, fontSize: 12)),
const SizedBox(height: 8),
Row(
children: milestones.map((milestone) {
final achieved = currentStreak >= milestone;
return Expanded(
child: Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: achieved ? Colors.orange : Colors.grey.shade200,
shape: BoxShape.circle,
),
child: Center(
child: achieved
? const Icon(Icons.check, color: Colors.white, size: 20)
: Text(
'$milestone',
style: TextStyle(
color: Colors.grey.shade500,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 4),
Text(
'$milestone天',
style: TextStyle(
fontSize: 10,
color: achieved ? Colors.orange : Colors.grey.shade500,
),
),
],
),
);
}).toList(),
),
],
);
}
达成的里程碑显示橙色打勾,未达成的显示灰色数字。
里程碑的游戏化设计
里程碑是一种常见的游戏化设计手法:
- 3 天:入门级,容易达成,给用户信心
- 7 天:一周,有一定挑战性
- 14 天:两周,需要一定毅力
- 30 天:一个月,高级成就
这种递进式的目标设计能持续激励用户。
map 方法的使用
milestones.map((milestone) {
// 返回一个 Widget
}).toList()
map 方法将列表中的每个元素转换为 Widget,最后用 toList() 转回列表。这是 Flutter 中常用的列表渲染方式。
中奖统计
统计每种奖品的中奖次数:
Map<String, int> _getPrizeStats() {
final stats = <String, int>{};
for (final record in _prizeManager.records) {
stats[record.prize.name] = (stats[record.prize.name] ?? 0) + 1;
}
return stats;
}
遍历所有记录,按奖品名称分组计数。?? 0 处理首次出现的情况。
更简洁的写法
也可以用 fold 方法实现:
Map<String, int> _getPrizeStats() {
return _prizeManager.records.fold<Map<String, int>>(
{},
(stats, record) {
stats[record.prize.name] = (stats[record.prize.name] ?? 0) + 1;
return stats;
},
);
}
fold 是函数式编程中的"归约"操作,将列表归约为单个值(这里是 Map)。
中奖统计区域
按次数排序后展示:
Widget _buildPrizeStatsSection(List<MapEntry<String, int>> sortedPrizes) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.purple.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.bar_chart, color: Colors.purple.shade600, size: 24),
),
const SizedBox(width: 12),
const Text(
'中奖统计',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Spacer(),
Text(
'共 ${_prizeManager.records.length} 次',
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
),
],
),
const SizedBox(height: 16),
if (sortedPrizes.isEmpty)
_buildEmptyState()
else
...sortedPrizes.map((entry) => _buildPrizeStatItem(entry.key, entry.value)),
],
),
);
}
右上角显示总次数,方便用户了解整体情况。
奖品统计项
每个奖品用进度条展示占比:
Widget _buildPrizeStatItem(String prizeName, int count) {
final total = _prizeManager.records.length;
final percentage = total > 0 ? count / total : 0.0;
Color color = Colors.grey;
for (final prize in _prizeManager.prizes) {
if (prize.name == prizeName) {
color = prize.color;
break;
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(prizeName, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(
'$count 次 (${(percentage * 100).toStringAsFixed(1)}%)',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: percentage,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 8,
),
),
],
),
);
}
进度条颜色跟奖品颜色一致,视觉上更直观。
LinearProgressIndicator 的使用
LinearProgressIndicator 是 Flutter 内置的进度条组件:
LinearProgressIndicator(
value: 0.75, // 进度值,0.0 到 1.0
backgroundColor: Colors.grey, // 背景色
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue), // 进度条颜色
minHeight: 8, // 高度
)
ClipRRect 用来给进度条添加圆角,让它看起来更柔和。
监听数据变化
在 initState 中注册监听:
void initState() {
super.initState();
_statsManager.addListener(_onDataChanged);
_prizeManager.addListener(_onDataChanged);
}
void _onDataChanged() {
if (mounted) setState(() {});
}
void dispose() {
_statsManager.removeListener(_onDataChanged);
_prizeManager.removeListener(_onDataChanged);
super.dispose();
}
用户抽奖后,统计页面会自动更新。
mounted 检查的重要性
if (mounted) setState(() {});
mounted 检查确保 Widget 还在树中。如果用户快速切换页面,可能在回调触发时 Widget 已经被销毁,直接调用 setState 会报错。
记得移除监听器
在 dispose 中移除监听器非常重要:
void dispose() {
_statsManager.removeListener(_onDataChanged);
_prizeManager.removeListener(_onDataChanged);
super.dispose();
}
如果不移除,会导致:
- 内存泄漏:Widget 无法被垃圾回收
- 报错:尝试更新已销毁的 Widget
在抽奖时记录统计
在 WheelPage 的 _spin 方法中调用统计记录:
void _spin() {
if (_isSpinning) return;
// 检查抽奖次数
if (!_lotteryManager.canSpin) {
_showNoChanceDialog();
return;
}
// 消耗一次抽奖机会
_lotteryManager.useChance();
// 记录统计
_statsManager.recordSpin();
// ... 开始转盘动画
}
每次抽奖都会触发统计记录,数据实时更新。
扩展:添加更多统计维度
可以考虑添加更多有趣的统计:
1. 时间维度统计
// 今日抽奖次数
int get todaySpins {
final today = DateTime.now();
return _records.where((r) =>
r.time.year == today.year &&
r.time.month == today.month &&
r.time.day == today.day
).length;
}
// 本周抽奖次数
int get weekSpins {
final now = DateTime.now();
final weekStart = now.subtract(Duration(days: now.weekday - 1));
return _records.where((r) => r.time.isAfter(weekStart)).length;
}
2. 幸运指数
// 根据中奖率计算幸运指数
String get luckyLevel {
final rate = _getWinRate();
if (rate >= 80) return '🌟 超级幸运';
if (rate >= 60) return '✨ 非常幸运';
if (rate >= 40) return '🍀 比较幸运';
if (rate >= 20) return '😊 一般般';
return '💪 继续加油';
}
3. 最常中的奖品
String? get mostWonPrize {
final stats = _getPrizeStats();
if (stats.isEmpty) return null;
return stats.entries.reduce((a, b) => a.value > b.value ? a : b).key;
}
小结
这篇文章我们实现了数据统计功能,涵盖了以下知识点:
- 单例模式的应用
- ChangeNotifier 状态管理
- 日期比较和连续天数计算
- 级联操作符的使用
- 数据卡片的设计原则
- 里程碑的游戏化设计
- LinearProgressIndicator 进度条
- 监听器的注册和移除
- mounted 检查的重要性
数据统计页面让用户能看到自己的抽奖历史、中奖率、连续天数等数据,增加了应用的可玩性和用户粘性。下一篇是这个系列的最后一篇,我们来做个总结,聊聊可以扩展的方向。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)