往期回顾:
- 【HarmonyOS 6】基于API23的底部悬浮导航
- 【HarmonyOS 6】底部悬浮导航的沉浸光感适配(API23)
- 【HarmonyOS 6】底部悬浮导航的迷你栏适配(API23)
- 【Flutter x HarmonyOS 6】记录页面的UI设计
- 【Flutter x HarmonyOS 6】挑战页面的UI设计
上一篇我们从 UI 设计的角度梳理了挑战页面,这篇我们深入聊聊挑战功能的业务逻辑实现。
挑战功能的核心是:用户设定目标时间,完成指定数量的计时,系统计算平均成绩并判断是否达标。这背后涉及数据模型、状态管理、平均计算等多个环节。
一、数据模型设计
挑战功能涉及三个核心数据模型:Challenge、ChallengeAttempt、ChallengeSolve。
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();
}
初始化时:
- 初始化 Repository。
- 加载所有挑战。
- 加载每个挑战的尝试记录。
- 通知监听者刷新 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;
}
创建挑战时:
- 生成唯一 ID。
- 构造
Challenge对象。 - 持久化到 Repository。
- 添加到内存列表(插入到头部)。
- 初始化空的尝试记录列表。
- 通知监听者。
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;
}
添加成绩时:
- 检查尝试是否存在、是否已结束。
- 将新成绩追加到列表。
- 如果成绩数量达到
windowSize,调用_finalizeAttempt完成挑战。 - 持久化并通知监听者。
三、挑战完成逻辑
当成绩数量达到要求时,调用 _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,
);
}
完成逻辑:
- 取前
windowSize个成绩的有效时间。 - 调用
computeAverageOfN计算平均。 - 如果平均是 DNF 或无有效值,标记为失败。
- 否则比较平均值与目标时间,判断是否成功。
四、平均计算算法
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));
}
算法规则:
- DNF 判定:如果有 2 个或以上 DNF,整体结果为 DNF。
- 去掉最好:找到最小值并移除。
- 去掉最差:如果有 1 个 DNF,移除它;否则移除最大值。
- 计算平均:剩余成绩求平均值。
以 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 序列化
每个数据模型都实现了 toMap 和 fromMap:
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。
七、总结
这篇我们深入梳理了挑战功能的业务逻辑实现:
- 数据模型:Challenge(配置)、ChallengeAttempt(尝试)、ChallengeSolve(成绩)。
- 状态管理:ChallengesController 管理挑战列表和尝试记录。
- 挑战流程:创建挑战 → 开始尝试 → 添加成绩 → 完成判定。
- 平均计算:去掉最好和最差,剩余求平均,DNF >= 2 则整体 DNF。
- 数据持久化:使用 Hive 存储,支持序列化和反序列化。
- 观察惩罚:15 秒观察,超时自动判罚。
挑战功能的实现,体现了 Flutter 应用中典型的分层架构:数据模型 → Repository → Controller → UI,各层职责清晰,便于维护和扩展。