【Flutter x HarmonyOS 6】挑战功能的业务逻辑实现

往期回顾:

上一篇我们从 UI 设计的角度梳理了挑战页面,这篇我们深入聊聊挑战功能的业务逻辑实现

挑战功能的核心是:用户设定目标时间,完成指定数量的计时,系统计算平均成绩并判断是否达标。这背后涉及数据模型、状态管理、平均计算等多个环节。


一、数据模型设计

挑战功能涉及三个核心数据模型:ChallengeChallengeAttemptChallengeSolve

1.1 Challenge:挑战配置

dart 复制代码
class Challenge {
  const Challenge({
    required this.id,
    required this.name,
    required this.event,
    required this.averageType,
    required this.targetDuration,
    required this.includeInRecords,
    required this.enableInspectionAndPenalty,
    required this.createdAt,
    this.targetGroupId,
    this.updatedAt,
  });

  final String id;
  final String name;
  final WcaEvent event;
  final ChallengeAverageType averageType;
  final Duration targetDuration;
  final bool includeInRecords;
  final String? targetGroupId;
  final bool enableInspectionAndPenalty;
  final DateTime createdAt;
  final DateTime? updatedAt;
}

各字段含义:

字段 类型 含义
id String 唯一标识
name String 挑战名称
event WcaEvent 魔方项目(如三阶)
averageType ChallengeAverageType 挑战类型(Ao5 / Ao12)
targetDuration Duration 目标时间
includeInRecords bool 是否计入记录/统计
targetGroupId String? 写入的分组 ID
enableInspectionAndPenalty bool 是否启用 15s 观察

1.2 ChallengeAverageType:挑战类型

dart 复制代码
enum ChallengeAverageType {
  ao5,
  ao12,
}

extension ChallengeAverageTypeX on ChallengeAverageType {
  int get windowSize => switch (this) {
        ChallengeAverageType.ao5 => 5,
        ChallengeAverageType.ao12 => 12,
      };

  String get label => switch (this) {
        ChallengeAverageType.ao5 => 'Ao5',
        ChallengeAverageType.ao12 => 'Ao12',
      };
}

通过 windowSize 扩展属性,Ao5 对应 5 次,Ao12 对应 12 次。

1.3 ChallengeAttempt:挑战尝试

dart 复制代码
class ChallengeAttempt {
  const ChallengeAttempt({
    required this.id,
    required this.challengeId,
    required this.windowSize,
    required this.targetDuration,
    required this.startedAt,
    required this.solves,
    this.finishedAt,
    this.averageStatus,
    this.averageDuration,
    this.succeeded,
  });

  final String id;
  final String challengeId;
  final int windowSize;
  final Duration targetDuration;
  final DateTime startedAt;
  final DateTime? finishedAt;
  final ChallengeAverageStatus? averageStatus;
  final Duration? averageDuration;
  final bool? succeeded;
  final List<ChallengeSolve> solves;

  bool get isFinished => finishedAt != null;
}

一次挑战尝试包含:

  • 开始时间 startedAt、结束时间 finishedAt
  • 所有成绩 solves
  • 计算结果:平均状态、平均时间、是否成功。

1.4 ChallengeSolve:单次成绩

dart 复制代码
class ChallengeSolve {
  const ChallengeSolve({
    required this.rawDuration,
    required this.scramble,
    required this.recordedAt,
    this.penalty = SolvePenalty.none,
    this.note = '',
    this.linkedSolveHiveKey,
  });

  final Duration rawDuration;
  final String scramble;
  final DateTime recordedAt;
  final SolvePenalty penalty;
  final String note;
  final int? linkedSolveHiveKey;

  bool get isDNF => penalty == SolvePenalty.dnf;

  Duration? get effectiveDuration {
    if (isDNF) {
      return null;
    }
    if (penalty == SolvePenalty.plusTwo) {
      return rawDuration + const Duration(seconds: 2);
    }
    return rawDuration;
  }
}

effectiveDuration 计算有效成绩:

  • DNF 返回 null
  • +2 返回原始时间 + 2 秒。
  • 无惩罚返回原始时间。

二、状态管理:ChallengesController

ChallengesController 是挑战功能的核心控制器,继承自 ChangeNotifier

dart 复制代码
class ChallengesController extends ChangeNotifier {
  ChallengesController(this._repository);

  final ChallengeRepository _repository;

  bool _isInitialized = false;
  List<Challenge> _challenges = <Challenge>[];
  final Map<String, List<ChallengeAttempt>> _attemptsByChallenge =
      <String, List<ChallengeAttempt>>{};

  bool get isInitialized => _isInitialized;
  List<Challenge> get challenges => List.unmodifiable(_challenges);

  List<ChallengeAttempt> attemptsForChallenge(String challengeId) {
    return List.unmodifiable(_attemptsByChallenge[challengeId] ?? const <ChallengeAttempt>[]);
  }
}

2.1 初始化

dart 复制代码
Future<void> initialize() async {
  if (_isInitialized) {
    return;
  }
  await _repository.initialize();
  _challenges = _repository.fetchAllChallenges();
  for (final challenge in _challenges) {
    _attemptsByChallenge[challenge.id] =
        _repository.fetchAttemptsForChallenge(challenge.id);
  }
  _isInitialized = true;
  notifyListeners();
}

初始化时:

  1. 初始化 Repository。
  2. 加载所有挑战。
  3. 加载每个挑战的尝试记录。
  4. 通知监听者刷新 UI。

2.2 创建挑战

dart 复制代码
Future<Challenge> createChallenge({
  required String name,
  required ChallengeAverageType averageType,
  required Duration targetDuration,
  required bool includeInRecords,
  required bool enableInspectionAndPenalty,
  required WcaEvent event,
  String? targetGroupId,
}) async {
  final id = _repository.generateChallengeId();
  final now = DateTime.now();
  final challenge = Challenge(
    id: id,
    name: name,
    event: event,
    averageType: averageType,
    targetDuration: targetDuration,
    includeInRecords: includeInRecords,
    targetGroupId: includeInRecords ? targetGroupId : null,
    enableInspectionAndPenalty: enableInspectionAndPenalty,
    createdAt: now,
    updatedAt: null,
  );
  await _repository.upsertChallenge(challenge);
  _challenges = <Challenge>[challenge, ..._challenges];
  _attemptsByChallenge[challenge.id] = <ChallengeAttempt>[];
  notifyListeners();
  return challenge;
}

创建挑战时:

  1. 生成唯一 ID。
  2. 构造 Challenge 对象。
  3. 持久化到 Repository。
  4. 添加到内存列表(插入到头部)。
  5. 初始化空的尝试记录列表。
  6. 通知监听者。

2.3 开始挑战尝试

dart 复制代码
Future<ChallengeAttempt> startAttempt(Challenge challenge) async {
  final attemptId = _repository.generateAttemptId();
  final attempt = ChallengeAttempt(
    id: attemptId,
    challengeId: challenge.id,
    windowSize: challenge.averageType.windowSize,
    targetDuration: challenge.targetDuration,
    startedAt: DateTime.now(),
    solves: const <ChallengeSolve>[],
  );
  await _repository.upsertAttempt(attempt);
  final current = _attemptsByChallenge[challenge.id] ?? <ChallengeAttempt>[];
  _attemptsByChallenge[challenge.id] = <ChallengeAttempt>[attempt, ...current];
  notifyListeners();
  return attempt;
}

开始挑战时,创建一个 ChallengeAttempt

  • windowSize 从挑战配置获取(5 或 12)。
  • targetDuration 从挑战配置获取。
  • solves 初始为空列表。

2.4 添加成绩

dart 复制代码
Future<ChallengeAttempt> addSolveToAttempt(
  String attemptId,
  ChallengeSolve solve,
) async {
  final current = _repository.findAttemptById(attemptId);
  if (current == null) {
    throw StateError('挑战尝试不存在');
  }
  if (current.isFinished) {
    return current;
  }

  final nextSolves = <ChallengeSolve>[...current.solves, solve];
  var next = current.copyWith(solves: nextSolves);

  if (nextSolves.length >= current.windowSize) {
    next = _finalizeAttempt(next);
  }

  await _repository.upsertAttempt(next);

  final list = _attemptsByChallenge[current.challengeId];
  if (list != null) {
    final index = list.indexWhere((a) => a.id == next.id);
    if (index != -1) {
      list[index] = next;
    }
  }
  notifyListeners();
  return next;
}

添加成绩时:

  1. 检查尝试是否存在、是否已结束。
  2. 将新成绩追加到列表。
  3. 如果成绩数量达到 windowSize,调用 _finalizeAttempt 完成挑战。
  4. 持久化并通知监听者。

三、挑战完成逻辑

当成绩数量达到要求时,调用 _finalizeAttempt 完成挑战:

dart 复制代码
ChallengeAttempt _finalizeAttempt(ChallengeAttempt attempt) {
  final durations = attempt.solves
      .take(attempt.windowSize)
      .map((solve) => solve.effectiveDuration)
      .toList();

  final aggregate = computeAverageOfN(durations);

  if (aggregate.isDNF) {
    return attempt.copyWith(
      finishedAt: DateTime.now(),
      averageStatus: ChallengeAverageStatus.dnf,
      averageDuration: null,
      succeeded: false,
    );
  }

  if (!aggregate.hasValue) {
    return attempt.copyWith(
      finishedAt: DateTime.now(),
      averageStatus: ChallengeAverageStatus.dnf,
      averageDuration: null,
      succeeded: false,
    );
  }

  final avg = aggregate.duration!;

  final succeeded = avg <= attempt.targetDuration;
  return attempt.copyWith(
    finishedAt: DateTime.now(),
    averageStatus: ChallengeAverageStatus.valid,
    averageDuration: avg,
    succeeded: succeeded,
  );
}

完成逻辑:

  1. 取前 windowSize 个成绩的有效时间。
  2. 调用 computeAverageOfN 计算平均。
  3. 如果平均是 DNF 或无有效值,标记为失败。
  4. 否则比较平均值与目标时间,判断是否成功。

四、平均计算算法

computeAverageOfN 实现了 WCA 标准的滚动平均算法:

dart 复制代码
SolveAggregateValue computeAverageOfN(List<Duration?> durations) {
  if (durations.isEmpty) {
    return const SolveAggregateValue.notEnough();
  }

  final dnfs = durations.where((value) => value == null).length;
  if (dnfs >= 2) {
    return const SolveAggregateValue.dnf();
  }

  final working = List<Duration?>.from(durations);

  // 去掉最好成绩
  int? minIndex;
  Duration? currentMin;
  for (var i = 0; i < working.length; i++) {
    final value = working[i];
    if (value == null) {
      continue;
    }
    if (currentMin == null || value < currentMin) {
      currentMin = value;
      minIndex = i;
    }
  }
  if (minIndex != null) {
    working.removeAt(minIndex);
  }

  // 去掉最差成绩(DNF 视为最差)
  int? worstIndex;
  if (dnfs == 1) {
    worstIndex = working.indexWhere((value) => value == null);
  } else {
    Duration? currentMax;
    int? currentIndex;
    for (var i = 0; i < working.length; i++) {
      final value = working[i];
      if (value == null) {
        continue;
      }
      if (currentMax == null || value > currentMax) {
        currentMax = value;
        currentIndex = i;
      }
    }
    worstIndex = currentIndex;
  }

  if (worstIndex != null && worstIndex != -1) {
    working.removeAt(worstIndex);
  }

  if (working.any((value) => value == null)) {
    return const SolveAggregateValue.dnf();
  }

  final trimmed = working.cast<Duration>();
  if (trimmed.isEmpty) {
    return const SolveAggregateValue.dnf();
  }

  final totalMs = trimmed.fold<int>(0, (sum, duration) => sum + duration.inMilliseconds);
  return SolveAggregateValue.valid(Duration(milliseconds: totalMs ~/ trimmed.length));
}

算法规则:

  1. DNF 判定:如果有 2 个或以上 DNF,整体结果为 DNF。
  2. 去掉最好:找到最小值并移除。
  3. 去掉最差:如果有 1 个 DNF,移除它;否则移除最大值。
  4. 计算平均:剩余成绩求平均值。

以 Ao5 为例:5 个成绩,去掉最好和最差,剩余 3 个求平均。


五、数据持久化:ChallengeRepository

ChallengeRepository 使用 Hive 进行数据持久化:

dart 复制代码
class ChallengeRepository {
  static const String _challengesBoxName = 'challenges';
  static const String _attemptsBoxName = 'challenge_attempts';

  Box<Map<dynamic, dynamic>>? _challengesBox;
  Box<Map<dynamic, dynamic>>? _attemptsBox;

  Future<void> initialize() async {
    _challengesBox ??= await Hive.openBox<Map<dynamic, dynamic>>(_challengesBoxName);
    _attemptsBox ??= await Hive.openBox<Map<dynamic, dynamic>>(_attemptsBoxName);
  }

  List<Challenge> fetchAllChallenges() {
    final box = _ensureChallengesBox();
    return box.values.map(Challenge.fromMap).toList()
      ..sort((a, b) => b.createdAt.compareTo(a.createdAt));
  }

  Future<Challenge> upsertChallenge(Challenge challenge) async {
    final box = _ensureChallengesBox();
    await box.put(challenge.id, challenge.toMap());
    return challenge;
  }

  Future<void> deleteChallenge(String challengeId) async {
    final challengesBox = _ensureChallengesBox();
    final attemptsBox = _ensureAttemptsBox();

    await challengesBox.delete(challengeId);

    // 同时删除所有相关尝试
    final keysToDelete = <dynamic>[];
    for (final key in attemptsBox.keys) {
      final raw = attemptsBox.get(key);
      if (raw == null) {
        continue;
      }
      final attempt = ChallengeAttempt.fromMap(raw);
      if (attempt.challengeId == challengeId) {
        keysToDelete.add(key);
      }
    }
    for (final key in keysToDelete) {
      await attemptsBox.delete(key);
    }
  }
}

5.1 ID 生成

dart 复制代码
String generateChallengeId() {
  final timestamp = DateTime.now().microsecondsSinceEpoch;
  final seed = _random.nextInt(1 << 20).toRadixString(16).padLeft(5, '0');
  return 'challenge_${timestamp}_$seed';
}

String generateAttemptId() {
  final timestamp = DateTime.now().microsecondsSinceEpoch;
  final seed = _random.nextInt(1 << 20).toRadixString(16).padLeft(5, '0');
  return 'attempt_${timestamp}_$seed';
}

ID 格式:challenge_1234567890_abcde,包含时间戳和随机种子,确保唯一性。

5.2 序列化

每个数据模型都实现了 toMapfromMap

dart 复制代码
// Challenge
Map<String, dynamic> toMap() {
  return <String, dynamic>{
    'id': id,
    'name': name,
    'eventCode': event.code,
    'averageType': averageType.name,
    'targetMs': targetDuration.inMilliseconds,
    'includeInRecords': includeInRecords,
    'targetGroupId': targetGroupId,
    'enableInspectionAndPenalty': enableInspectionAndPenalty,
    'createdAt': createdAt.toIso8601String(),
    'updatedAt': updatedAt?.toIso8601String(),
  };
}

factory Challenge.fromMap(Map<dynamic, dynamic> map) {
  // ...
}

六、观察与惩罚机制

挑战支持 WCA 标准的 15 秒观察时间,超时自动判罚。

6.1 观察倒计时

dart 复制代码
void _startInspectionCountdown() {
  final controller = _timerController;
  if (controller == null || controller.isRunning || !controller.isHolding) {
    return;
  }
  _inspectionTimer?.cancel();
  _inspectionPenaltyNotified = false;
  _inspectionStart = DateTime.now();
  controller.startInspection(_inspectionLimit);
  _inspectionTimer = Timer.periodic(
    const Duration(milliseconds: 100),
    _handleInspectionTick,
  );
}

6.2 超时判罚

dart 复制代码
void _handleInspectionTick(Timer timer) {
  final controller = _timerController;
  final start = _inspectionStart;
  if (controller == null || start == null || !controller.isInspection) {
    timer.cancel();
    return;
  }
  final elapsed = DateTime.now().difference(start);
  final remaining = _inspectionLimit - elapsed;
  controller.updateInspectionRemaining(remaining.isNegative ? Duration.zero : remaining);

  if (elapsed > _inspectionPlusTwoLimit) {  // 17 秒
    _applyInspectionPenalty(SolvePenalty.dnf);
  } else if (elapsed > _inspectionLimit) {  // 15 秒
    _applyInspectionPenalty(SolvePenalty.plusTwo);
  }
}

判罚规则:

  • 15-17 秒:自动 +2。
  • 超过 17 秒:自动 DNF。

七、总结

这篇我们深入梳理了挑战功能的业务逻辑实现:

  1. 数据模型:Challenge(配置)、ChallengeAttempt(尝试)、ChallengeSolve(成绩)。
  2. 状态管理:ChallengesController 管理挑战列表和尝试记录。
  3. 挑战流程:创建挑战 → 开始尝试 → 添加成绩 → 完成判定。
  4. 平均计算:去掉最好和最差,剩余求平均,DNF >= 2 则整体 DNF。
  5. 数据持久化:使用 Hive 存储,支持序列化和反序列化。
  6. 观察惩罚:15 秒观察,超时自动判罚。

挑战功能的实现,体现了 Flutter 应用中典型的分层架构:数据模型 → Repository → Controller → UI,各层职责清晰,便于维护和扩展。

相关推荐
Lan_Se_Tian_Ma1 小时前
使用Cursor封装Flutter项目基建框架
前端·人工智能·flutter
天天开发1 小时前
Flutter Widget Previewer使用指南:提升开发效率的利器
前端·javascript·flutter
不爱吃糖的程序媛2 小时前
Harmonybrew:让Homebrew落地OpenHarmony,补齐鸿蒙命令行包管理能力
华为·harmonyos
nashane16 小时前
HarmonyOS 6学习:AI攻略长截图“防抖”与像素级拼接术
学习·华为·harmonyos
想你依然心痛18 小时前
HarmonyOS 6(API 23)实战:基于悬浮导航、沉浸光感与HMAF的“代码哨兵“——AI智能体代码安全审计平台
人工智能·安全·harmonyos·智能体
轻口味18 小时前
HarmonyOS 6.1 全栈实战录 - 09 极光底座:ArkWeb 6.1 性能、安全与视觉插帧全特性深度实战
pytorch·安全·harmonyos
Ww.xh19 小时前
鸿蒙Web组件中Hash路由传登录态方案
前端·哈希算法·harmonyos
nashane19 小时前
HarmonyOS 6学习:Canvas性能优化与长截图流畅实现实战
学习·性能优化·harmonyos
轻口味20 小时前
HarmonyOS 6.1 全栈实战录 - 13 流量增长新引擎:全场景归因与 App Linking 链接深度开发实战
pytorch·深度学习·harmonyos