成就系统实现方式
Flutter 实现游戏化成就系统:让工具类 App 更有趣
工具类应用往往缺乏用户粘性,本文分享如何在 Flutter 应用中实现一套完整的成就系统,通过游戏化设计提升用户参与度和留存率。
为什么需要成就系统?
相册整理是一个"用完即走"的场景,用户清理完照片就没有再打开的动力。我们希望通过成就系统:
- 增加使用动力:给用户设定目标,激励持续使用
- 提供正向反馈:每次操作都有意义,积累成就感
- 延长用户生命周期:通过连续使用奖励提高留存
系统架构设计
整体架构
┌─────────────────────────────────────────────────────────┐
│ UI 层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ 成就页面 │ │ 解锁弹窗 │ │ 设置页入口 │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Service 层 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ AchievementService │ │
│ │ - 统计数据管理 │ │
│ │ - 成就解锁判定 │ │
│ │ - 连续使用追踪 │ │
│ │ - 解锁通知发布 │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Data 层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ 成就定义 │ │ 用户统计 │ │ 成就进度 │ │
│ │ (静态配置) │ │ (SQLite) │ │ (SQLite) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────┘
核心模型设计
1. 成就定义模型
/// 成就等级枚举
enum AchievementTier {
bronze, // 青铜
silver, // 白银
gold, // 黄金
diamond, // 钻石
}
/// 成就类型枚举
enum AchievementType {
cleanup, // 清理类
storage, // 存储类
streak, // 连续使用类
special, // 特殊行为类
}
/// 成就定义模型
class AchievementDefinition {
final String id; // 唯一标识
final String name; // 成就名称
final String description; // 成就描述
final AchievementType type; // 成就类型
final AchievementTier tier; // 成就等级
final String icon; // 图标名称
final int targetValue; // 解锁所需目标值
final String? statKey; // 关联的统计键
const AchievementDefinition({
required this.id,
required this.name,
required this.description,
required this.type,
required this.tier,
required this.icon,
required this.targetValue,
this.statKey,
});
String get tierName {
switch (tier) {
case AchievementTier.bronze: return '青铜';
case AchievementTier.silver: return '白银';
case AchievementTier.gold: return '黄金';
case AchievementTier.diamond: return '钻石';
}
}
}
2. 用户统计模型
/// 用户统计数据模型
class UserStats {
final int totalPhotosCleared; // 总清理照片数
final int totalVideosCleared; // 总清理视频数
final int totalStorageFreed; // 总释放空间(字节)
final int currentStreak; // 当前连续使用天数
final int longestStreak; // 最长连续使用天数
final DateTime? lastActiveDate; // 最后使用日期
final int batchDeleteCount; // 批量删除次数
final int similarPhotoCleanCount; // 相似照片清理次数
final int totalFavorited; // 收藏照片数
final DateTime updatedAt; // 更新时间
/// 通过键名获取统计值
int getStatValue(String key) {
switch (key) {
case 'totalPhotosCleared': return totalPhotosCleared;
case 'totalVideosCleared': return totalVideosCleared;
case 'totalMediaCleared': return totalPhotosCleared + totalVideosCleared;
case 'totalStorageFreedMB': return (totalStorageFreed / (1024 * 1024)).floor();
case 'totalStorageFreedGB': return (totalStorageFreed / (1024 * 1024 * 1024)).floor();
case 'currentStreak': return currentStreak;
case 'batchDeleteCount': return batchDeleteCount;
case 'similarPhotoCleanCount': return similarPhotoCleanCount;
case 'totalFavorited': return totalFavorited;
default: return 0;
}
}
}
3. 成就进度模型
/// 用户成就进度模型
class AchievementProgress {
final String achievementId; // 成就ID
final int currentValue; // 当前进度值
final bool unlocked; // 是否已解锁
final DateTime? unlockedAt; // 解锁时间
final DateTime updatedAt; // 更新时间
/// 从数据库 Map 转换
factory AchievementProgress.fromMap(Map<String, dynamic> map) {
return AchievementProgress(
achievementId: map['achievement_id'] as String,
currentValue: map['current_value'] as int? ?? 0,
unlocked: (map['unlocked'] as int?) == 1,
unlockedAt: map['unlocked_at'] != null
? DateTime.fromMillisecondsSinceEpoch(map['unlocked_at'] as int)
: null,
updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int),
);
}
/// 转换为数据库 Map
Map<String, dynamic> toMap() {
return {
'achievement_id': achievementId,
'current_value': currentValue,
'unlocked': unlocked ? 1 : 0,
'unlocked_at': unlockedAt?.millisecondsSinceEpoch,
'updated_at': updatedAt.millisecondsSinceEpoch,
};
}
}
成就定义配置
采用静态配置的方式定义所有成就,便于维护和扩展:
class AchievementDefinitions {
static const List<AchievementDefinition> all = [
// ========== 清理类成就(多级递进)==========
AchievementDefinition(
id: 'cleanup_photo_1',
name: '初次整理',
description: '清理第一张照片',
type: AchievementType.cleanup,
tier: AchievementTier.bronze,
icon: 'photo_cleanup',
targetValue: 1,
statKey: 'totalPhotosCleared',
),
AchievementDefinition(
id: 'cleanup_photo_100',
name: '整理能手',
description: '累计清理100张照片',
type: AchievementType.cleanup,
tier: AchievementTier.silver,
icon: 'photo_cleanup',
targetValue: 100,
statKey: 'totalPhotosCleared',
),
AchievementDefinition(
id: 'cleanup_photo_1000',
name: '整理大师',
description: '累计清理1000张照片',
type: AchievementType.cleanup,
tier: AchievementTier.diamond,
icon: 'photo_cleanup',
targetValue: 1000,
statKey: 'totalPhotosCleared',
),
// ========== 存储类成就 ==========
AchievementDefinition(
id: 'storage_1gb',
name: '空间管家',
description: '累计释放1GB存储空间',
type: AchievementType.storage,
tier: AchievementTier.gold,
icon: 'storage',
targetValue: 1,
statKey: 'totalStorageFreedGB',
),
// ========== 连续使用类成就 ==========
AchievementDefinition(
id: 'streak_7',
name: '一周坚持',
description: '连续7天使用应用',
type: AchievementType.streak,
tier: AchievementTier.silver,
icon: 'streak',
targetValue: 7,
statKey: 'currentStreak',
),
// ========== 特殊行为类成就 ==========
AchievementDefinition(
id: 'similar_clean_1',
name: '火眼金睛',
description: '首次清理相似照片',
type: AchievementType.special,
tier: AchievementTier.bronze,
icon: 'similar',
targetValue: 1,
statKey: 'similarPhotoCleanCount',
),
];
/// 按类型获取成就
static List<AchievementDefinition> getByType(AchievementType type) {
return all.where((a) => a.type == type).toList();
}
/// 根据统计键获取关联成就
static List<AchievementDefinition> getByStatKey(String statKey) {
return all.where((a) => a.statKey == statKey).toList();
}
}
核心服务实现
AchievementService
class AchievementService extends GetxService {
late final DatabaseService _dbService;
static const String _achievementTable = 'achievement_progress';
static const String _userStatsTable = 'user_stats';
/// 当前用户统计数据(响应式)
final Rx<UserStats> userStats = UserStats().obs;
/// 所有成就进度
final RxMap<String, AchievementProgress> achievementProgress =
<String, AchievementProgress>{}.obs;
/// 新解锁的成就(用于显示通知)
final RxList<AchievementDefinition> newlyUnlocked =
<AchievementDefinition>[].obs;
/// 初始化服务
Future<AchievementService> init() async {
_dbService = Get.find<DatabaseService>();
await _ensureTablesExist();
await _loadData();
await _checkAndUpdateStreak();
return this;
}
/// 增加统计值
Future<void> incrementStat(String statKey, {int amount = 1}) async {
final currentValue = userStats.value.getStatValue(statKey);
// 更新对应的统计字段
switch (statKey) {
case 'totalPhotosCleared':
await _updateUserStats(totalPhotosCleared: currentValue + amount);
break;
case 'totalVideosCleared':
await _updateUserStats(totalVideosCleared: currentValue + amount);
break;
case 'batchDeleteCount':
await _updateUserStats(batchDeleteCount: currentValue + amount);
break;
case 'similarPhotoCleanCount':
await _updateUserStats(similarPhotoCleanCount: currentValue + amount);
break;
case 'totalFavorited':
await _updateUserStats(totalFavorited: currentValue + amount);
break;
}
// 检查相关成就
await _checkAchievementsByStatKey(statKey);
// 如果是清理类,也检查总清理数
if (statKey == 'totalPhotosCleared' || statKey == 'totalVideosCleared') {
await _checkAchievementsByStatKey('totalMediaCleared');
}
}
/// 增加释放空间统计
Future<void> addStorageFreed(int bytes) async {
final newTotal = userStats.value.totalStorageFreed + bytes;
await _updateUserStats(totalStorageFreed: newTotal);
// 检查存储相关成就(MB 和 GB 两个维度)
await _checkAchievementsByStatKey('totalStorageFreedMB');
await _checkAchievementsByStatKey('totalStorageFreedGB');
}
/// 根据统计键检查相关成就
Future<void> _checkAchievementsByStatKey(String statKey) async {
final relatedAchievements = AchievementDefinitions.getByStatKey(statKey);
for (final achievement in relatedAchievements) {
await _checkAndUnlockAchievement(achievement);
}
}
/// 检查并解锁成就
Future<void> _checkAndUnlockAchievement(AchievementDefinition achievement) async {
var progress = achievementProgress[achievement.id];
// 如果已解锁,跳过
if (progress?.unlocked == true) return;
// 获取当前统计值
final currentValue = achievement.statKey != null
? userStats.value.getStatValue(achievement.statKey!)
: 0;
// 判断是否达成
final isUnlocked = currentValue >= achievement.targetValue;
// 更新进度
progress = AchievementProgress(
achievementId: achievement.id,
currentValue: currentValue,
unlocked: isUnlocked,
unlockedAt: isUnlocked ? DateTime.now() : null,
);
achievementProgress[achievement.id] = progress;
await _saveAchievementProgress(progress);
// 如果新解锁,添加到通知列表
if (isUnlocked) {
newlyUnlocked.add(achievement);
logger.d('🏆 成就解锁: ${achievement.name}');
}
}
/// 获取成就进度百分比
double getAchievementProgressPercent(String achievementId) {
final achievement = AchievementDefinitions.getById(achievementId);
if (achievement == null) return 0;
final progress = achievementProgress[achievementId];
final currentValue = progress?.currentValue ??
(achievement.statKey != null
? userStats.value.getStatValue(achievement.statKey!)
: 0);
return (currentValue / achievement.targetValue).clamp(0.0, 1.0);
}
/// 获取已解锁成就数量
int get unlockedCount =>
achievementProgress.values.where((p) => p.unlocked).length;
/// 获取总成就数量
int get totalCount => AchievementDefinitions.all.length;
}
连续使用天数追踪
/// 检查并更新连续使用天数
Future<void> _checkAndUpdateStreak() async {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final lastActive = userStats.value.lastActiveDate;
if (lastActive == null) {
// 首次使用
await _updateUserStats(
currentStreak: 1,
longestStreak: 1,
lastActiveDate: today,
);
} else {
final lastActiveDay = DateTime(
lastActive.year, lastActive.month, lastActive.day
);
final daysDiff = today.difference(lastActiveDay).inDays;
if (daysDiff == 0) {
// 今天已经记录过,不做处理
return;
} else if (daysDiff == 1) {
// 连续使用,天数+1
final newStreak = userStats.value.currentStreak + 1;
final newLongest = newStreak > userStats.value.longestStreak
? newStreak
: userStats.value.longestStreak;
await _updateUserStats(
currentStreak: newStreak,
longestStreak: newLongest,
lastActiveDate: today,
);
} else {
// 中断了,重新开始
await _updateUserStats(
currentStreak: 1,
lastActiveDate: today,
);
}
}
// 检查连续使用相关成就
await _checkAchievementsByStatKey('currentStreak');
}
触发点埋点
在业务代码中埋入成就触发点:
照片删除触发
// photo_delete_mixin.dart
void _updateAchievementStats(int deletedCount, List<AssetEntity> deletedPhotos) {
try {
if (!Get.isRegistered<AchievementService>()) return;
final achievementService = Get.find<AchievementService>();
// 增加清理照片数量
achievementService.incrementStat('totalPhotosCleared', amount: deletedCount);
// 如果删除数量大于1,算作一次批量删除
if (deletedCount > 1) {
achievementService.incrementStat('batchDeleteCount');
}
// 异步计算释放的存储空间
_calculateAndAddStorageFreed(achievementService, deletedPhotos);
} catch (e) {
logger.w('更新成就统计失败: $e');
}
}
收藏触发
// photo_clean_controller.dart
void _handleFavoriteAction(AssetEntity photo, int index, direction) {
// ... 其他逻辑
// 更新收藏成就
_updateFavoriteAchievement();
}
void _updateFavoriteAchievement() {
try {
if (!Get.isRegistered<AchievementService>()) return;
final achievementService = Get.find<AchievementService>();
achievementService.incrementStat('totalFavorited');
} catch (e) {
logger.w('更新收藏成就失败: $e');
}
}
相似照片清理触发
// photo_similar_mixin.dart
case SimilarPhotoAction.deleteSelected:
// ... 删除逻辑
// 更新相似照片清理成就
_updateSimilarPhotoAchievement();
break;
void _updateSimilarPhotoAchievement() {
try {
if (!Get.isRegistered<AchievementService>()) return;
final achievementService = Get.find<AchievementService>();
achievementService.incrementStat('similarPhotoCleanCount');
} catch (e) {
logger.w('更新相似照片成就失败: $e');
}
}
解锁通知监听
在主控制器中监听成就解锁,弹出通知:
// main_controller.dart
void _initAchievementListener() {
Future.delayed(const Duration(milliseconds: 500), () {
if (Get.isRegistered<AchievementService>()) {
final achievementService = Get.find<AchievementService>();
ever(achievementService.newlyUnlocked, (List<AchievementDefinition> unlocked) {
if (unlocked.isNotEmpty) {
// 显示第一个新解锁的成就
final achievement = unlocked.first;
AchievementUnlockDialog.show(achievement);
// 清除已显示的通知
Future.delayed(const Duration(milliseconds: 100), () {
achievementService.clearNewlyUnlocked();
});
}
});
}
});
}
解锁弹窗动画
class AchievementUnlockDialog extends StatefulWidget {
final AchievementDefinition achievement;
static Future<void> show(AchievementDefinition achievement) async {
await Get.dialog(
AchievementUnlockDialog(achievement: achievement),
barrierDismissible: true,
);
}
@override
State<AchievementUnlockDialog> createState() => _AchievementUnlockDialogState();
}
class _AchievementUnlockDialogState extends State<AchievementUnlockDialog>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
// 弹性缩放动画
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
);
// 淡入动画
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeIn),
);
_controller.forward();
}
@override
Widget build(BuildContext context) {
final tierColor = _getTierColor(widget.achievement.tier);
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _opacityAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: Dialog(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: tierColor.withOpacity(0.3),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 成就图标(带等级颜色边框)
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: tierColor, width: 3),
),
child: Icon(
_getAchievementIcon(widget.achievement.icon),
color: tierColor,
size: 40,
),
),
const SizedBox(height: 16),
Text('🎉 成就解锁'),
Text(widget.achievement.name,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
// 等级标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: tierColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(widget.achievement.tierName,
style: TextStyle(color: tierColor)),
),
Text(widget.achievement.description),
FilledButton(
onPressed: () => Get.back(),
style: FilledButton.styleFrom(backgroundColor: tierColor),
child: const Text('太棒了!'),
),
],
),
),
),
),
);
},
);
}
Color _getTierColor(AchievementTier tier) {
switch (tier) {
case AchievementTier.bronze: return const Color(0xFFCD7F32);
case AchievementTier.silver: return const Color(0xFF9E9E9E);
case AchievementTier.gold: return const Color(0xFFFFB300);
case AchievementTier.diamond: return const Color(0xFF4FC3F7);
}
}
}
成就设计原则
1. 多级递进
同一类型的成就设置多个等级,让用户始终有下一个目标:
初次整理(1张) → 小试牛刀(10张) → 整理能手(100张) → 清理达人(500张) → 整理大师(1000张)
2. 难度梯度
| 等级 | 难度 | 示例 |
|---|---|---|
| 🥉 青铜 | 轻松达成 | 清理1张照片、连续3天使用 |
| 🥈 白银 | 需要一定积累 | 清理100张、连续7天使用 |
| 🥇 黄金 | 需要持续使用 | 清理500张、释放1GB |
| 💎 钻石 | 重度用户才能达成 | 清理1000张、释放5GB |
3. 行为引导
通过成就引导用户使用更多功能:
- 批量操作 → 引导用户使用批量删除
- 火眼金睛 → 引导用户使用相似照片检测
- 收藏家 → 引导用户使用收藏功能
4. 即时反馈
每次操作都能看到进度变化,解锁时立即弹出通知。
总结
通过这套成就系统,我们实现了:
- 数据驱动:基于
statKey自动关联统计数据和成就 - 解耦设计:成就定义、统计追踪、解锁判定分离
- 响应式更新:使用 GetX 的 Rx 类型实现 UI 自动更新
- 持久化存储:SQLite 保存用户进度,重启不丢失
- 优雅的动画:弹性缩放 + 淡入效果的解锁弹窗
这套架构可以轻松扩展新成就,只需在 AchievementDefinitions 中添加配置即可。
本文基于「回留」相册整理应用的实际开发经验。
分类:
标签:
版权申明
本文系作者 @MrZhang1899 原创发布在成就系统实现方式。未经许可,禁止转载。
评论
-- 评论已关闭 --
全部评论