Flutter 实现游戏化成就系统:让工具类 App 更有趣

工具类应用往往缺乏用户粘性,本文分享如何在 Flutter 应用中实现一套完整的成就系统,通过游戏化设计提升用户参与度和留存率。

为什么需要成就系统?

相册整理是一个"用完即走"的场景,用户清理完照片就没有再打开的动力。我们希望通过成就系统:

  1. 增加使用动力:给用户设定目标,激励持续使用
  2. 提供正向反馈:每次操作都有意义,积累成就感
  3. 延长用户生命周期:通过连续使用奖励提高留存

系统架构设计

整体架构

┌─────────────────────────────────────────────────────────┐
│                      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. 即时反馈

每次操作都能看到进度变化,解锁时立即弹出通知。

总结

通过这套成就系统,我们实现了:

  1. 数据驱动:基于 statKey 自动关联统计数据和成就
  2. 解耦设计:成就定义、统计追踪、解锁判定分离
  3. 响应式更新:使用 GetX 的 Rx 类型实现 UI 自动更新
  4. 持久化存储:SQLite 保存用户进度,重启不丢失
  5. 优雅的动画:弹性缩放 + 淡入效果的解锁弹窗

这套架构可以轻松扩展新成就,只需在 AchievementDefinitions 中添加配置即可。


本文基于「回留」相册整理应用的实际开发经验。

分类: 标签:

评论

-- 评论已关闭 --

全部评论