【Flutter x HarmonyOS 6】记录页面的逻辑实现

发布时间:2026/6/23 6:26:39
【Flutter x HarmonyOS 6】记录页面的逻辑实现 上次我们聊了记录页面的 UI 设计这篇深入看看记录页面的逻辑实现。记录页面是整个应用的核心数据层——成绩的录入、分组、统计、PB 追踪都在这里完成。这篇我们逐一拆解。一、数据模型1.1 SolveEntry成绩条目class SolveEntry { const SolveEntry({ required this.rawDuration, required this.scramble, required this.recordedAt, this.penalty SolvePenalty.none, this.note , this.groupId SolveGroup.defaultGroupId, this.hiveKey, }); final Duration rawDuration; final String scramble; final DateTime recordedAt; final SolvePenalty penalty; final String note; final String groupId; final int? hiveKey; }关键字段rawDuration原始计时时间。scramble打乱公式。penalty罚时类型无 / 2 / DNF。groupId所属分组。hiveKeyHive 中的主键用于更新和删除。1.2 罚时计算enum SolvePenalty { none, plusTwo, dnf, } Duration? get effectiveDuration { if (isDNF) { return null; } if (hasPlusTwo) { return rawDuration const Duration(seconds: 2); } return rawDuration; }effectiveDuration是统计计算的核心DNF 返回null表示无效成绩。2 在原始时间上加 2 秒。无罚时直接返回原始时间。1.3 SolveGroup成绩分组class SolveGroup { static const String defaultGroupId group_default_333; static const String defaultGroupName 三阶速拧; static const WcaEvent defaultEvent WcaEvent.threeByThree; final String id; final String name; final WcaEvent event; final int colorValue; final DateTime createdAt; final String note; }每个分组绑定一个 WCA 项目有自己的颜色和名称。默认分组是三阶速拧。1.4 PersonalBestRecord个人最佳记录enum PBType { single, // 单次最佳 ao5, // 5次平均最佳 ao12, // 12次平均最佳 ao100; // 100次平均最佳 } class PersonalBestRecord { final PBType type; final Duration duration; final DateTime achievedAt; final Duration? previousBest; final ListString solveIds; final String? scramble; Duration? get improvement previousBest ! null ? previousBest! - duration : null; bool get isNew previousBest null; }PB 记录保存了当前最佳成绩。达成时间。上一次最佳用于计算提升幅度。关联的成绩 ID 列表。二、SolvesController核心控制器SolvesController是记录页面的核心管理成绩和分组的所有业务逻辑class SolvesController extends ChangeNotifier { SolvesController(this._repository, this._groupRepository, this._pbRepository); final SolveRepository _repository; final SolveGroupRepository _groupRepository; final PersonalBestRepository _pbRepository; final ListSolveEntry _entries SolveEntry[]; final MapString, ListSolveEntry _entriesByGroup String, ListSolveEntry{}; final MapString, SolveStatsSummary _statsByGroup String, SolveStatsSummary{}; final MapString, int _countsByGroup String, int{}; ListSolveGroup _groups SolveGroup[]; bool _isInitialized false; String? _selectedGroupId; }内存缓存结构_entries所有成绩的全量列表。_entriesByGroup按分组 ID 索引的成绩列表。_statsByGroup按分组 ID 索引的统计摘要。_countsByGroup按分组 ID 索引的成绩数量。2.1 初始化流程Futurevoid initialize() async { if (_isInitialized) return; await _groupRepository.initialize(); await _repository.initialize(); await _pbRepository.initialize(); final storedGroups _groupRepository.fetchAll(); if (storedGroups.isEmpty) { final defaultGroup await _groupRepository.ensureDefaultGroup(); _groups SolveGroup[defaultGroup]; } else { _groups ListSolveGroup.from(storedGroups); } _selectedGroupId _groups.first.id; _entries ..clear() ..addAll(_repository.fetchAllSolves()); await _migrateLegacyEntries(); _rebuildGroupCaches(); _isInitialized true; notifyListeners(); }初始化步骤初始化三个 Repository。加载分组确保至少有一个默认分组。加载全量成绩。迁移旧数据没有分组的成绩归入默认分组。重建内存缓存。2.2 旧数据迁移Futurevoid _migrateLegacyEntries() async { final knownIds _groups.map((group) group.id).toSet(); final defaultGroup await _groupRepository.ensureDefaultGroup(); if (!knownIds.contains(defaultGroup.id)) { _groups.insert(0, defaultGroup); knownIds.add(defaultGroup.id); } bool mutated false; for (var i 0; i _entries.length; i) { final entry _entries[i]; if (!knownIds.contains(entry.groupId)) { final updated entry.copyWith(groupId: defaultGroup.id); await _repository.updateSolve(updated); _entries[i] updated; mutated true; } } if (mutated) { debugPrint(迁移完成已为旧成绩补齐分组信息); } }这个方法确保所有成绩都有有效的分组 ID兼容从旧版本升级的用户。三、成绩录入3.1 记录新成绩FutureSolveEntry recordSolve( Duration rawDuration, String scramble, SolvePenalty penalty, { String? groupId, }) async { final targetGroupId groupId ?? selectedGroupId; final entry SolveEntry( rawDuration: rawDuration, scramble: scramble, recordedAt: DateTime.now(), penalty: penalty, groupId: targetGroupId, ); final storedEntry await _repository.addSolve(entry); _entries.add(storedEntry); _rebuildGroupCaches(); // 检查并更新 PB await _checkAndUpdatePBs(targetGroupId); notifyListeners(); return storedEntry; }录入流程创建SolveEntry对象。持久化到 Repository。加入内存列表。重建缓存统计、分组索引等。检查 PB个人最佳。通知 UI 刷新。3.2 更新成绩FutureSolveEntry updateSolve({ required SolveEntry entry, SolvePenalty? penalty, String? note, String? groupId, }) async { if (entry.hiveKey null) { throw StateError(该成绩无法编辑); } final targetGroupId groupId ?? entry.groupId; SolveGroup? targetGroup; if (groupId ! null) { targetGroup _groups.firstWhere( (candidate) candidate.id targetGroupId, orElse: () throw StateError(目标分组不存在), ); if (targetGroupId ! entry.groupId) { final sourceGroup _groups.firstWhere( (candidate) candidate.id entry.groupId, orElse: () targetGroup!, ); if (sourceGroup.event ! targetGroup.event) { throw StateError(只能移动到同一项目的分组); } } } final updated entry.copyWith( penalty: penalty ?? entry.penalty, note: note ?? entry.note, groupId: targetGroupId, ); await _repository.updateSolve(updated); _replaceEntry(updated); _rebuildGroupCaches(); notifyListeners(); return updated; }更新成绩时的校验成绩必须有hiveKey可编辑。移动分组时目标分组必须存在。只能移动到同一项目的分组不能把三阶成绩移到二阶分组。3.3 删除成绩Futurevoid deleteSolve(SolveEntry entry) async { if (entry.hiveKey null) { throw StateError(该成绩无法删除); } _entries.removeWhere((current) current.hiveKey entry.hiveKey); await _repository.deleteSolve(entry); _rebuildGroupCaches(); notifyListeners(); }四、分组管理4.1 创建分组FutureSolveGroup createGroup({ required String name, required WcaEvent event, String note , int? colorValue, }) async { final group await _groupRepository.createGroup( name: name, event: event, note: note, colorValue: colorValue, ); _groups.add(group); _selectedGroupId group.id; _rebuildGroupCaches(); notifyListeners(); return group; }创建后自动选中新分组。4.2 删除分组Futurevoid deleteGroup(String groupId, {String? transferTargetGroupId}) async { if (groupId SolveGroup.defaultGroupId) { throw StateError(默认分组无法删除); } if (!_groups.any((group) group.id groupId)) { return; } final fallbackId transferTargetGroupId ?? SolveGroup.defaultGroupId; if (!_groups.any((group) group.id fallbackId)) { final fallback await _groupRepository.ensureDefaultGroup(); _groups.add(fallback); } // 将被删除分组的成绩转移到目标分组 final affectedEntries _entries.where((entry) entry.groupId groupId).toList(); for (final entry in affectedEntries) { final updated entry.copyWith(groupId: fallbackId); await _repository.updateSolve(updated); _replaceEntry(updated); } await _groupRepository.deleteGroup(groupId); _groups.removeWhere((group) group.id groupId); if (_selectedGroupId groupId) { _selectedGroupId fallbackId; } _rebuildGroupCaches(); notifyListeners(); }删除分组的逻辑默认分组不可删除。被删除分组的成绩转移到目标分组或默认分组。如果当前选中的就是被删除的分组自动切换到目标分组。五、统计计算5.1 统计摘要class SolveStatsSummary { const SolveStatsSummary({ required this.best, required this.average, required this.ao5, required this.ao12, }); final SolveAggregateValue best; final SolveAggregateValue average; final SolveAggregateValue ao5; final SolveAggregateValue ao12; }每个统计项使用SolveAggregateValue封装enum SolveAggregateStatus { notEnoughData, valid, dnf } class SolveAggregateValue { final Duration? duration; final SolveAggregateStatus status; bool get isDNF status SolveAggregateStatus.dnf; bool get hasValue status SolveAggregateStatus.valid duration ! null; }三种状态notEnoughData数据不足如 ao5 需要至少 5 次成绩。valid有效值。dnfDNF 过多导致整体无效。5.2 缓存重建void _rebuildGroupCaches() { _entriesByGroup..clear(); for (final entry in _entries) { final groupEntries _entriesByGroup.putIfAbsent(entry.groupId, () SolveEntry[]); groupEntries.add(entry); } for (final groupEntries in _entriesByGroup.values) { groupEntries.sort((a, b) a.recordedAt.compareTo(b.recordedAt)); } _statsByGroup..clear(); _countsByGroup..clear(); for (final group in _groups) { final groupEntries _entriesByGroup[group.id] ?? SolveEntry[]; _countsByGroup[group.id] groupEntries.length; _statsByGroup[group.id] _recalculateStats(groupEntries); } }每次数据变更后重建所有缓存按分组 ID 重新索引成绩。每个分组内的成绩按时间排序。重新计算每个分组的统计摘要。5.3 滚动平均计算SolveAggregateValue _rollingAverage(ListSolveEntry source, int windowSize) { if (source.length windowSize) { return const SolveAggregateValue.notEnough(); } final recentEntries source.sublist(source.length - windowSize); final durations recentEntries.map((entry) entry.effectiveDuration).toList(); final dnfs durations.where((duration) duration null).length; // DNF 2 则整体 DNF if (dnfs 2) { return const SolveAggregateValue.dnf(); } // 去掉最好成绩 final bestIndex _minDurationIndex(durations); if (bestIndex ! null) { durations.removeAt(bestIndex); } // 去掉最差成绩如果有 DNF优先去掉 DNF final worstIndex dnfs 1 ? durations.indexWhere((duration) duration null) : _maxDurationIndex(durations); if (worstIndex ! null worstIndex ! -1) { durations.removeAt(worstIndex); } if (durations.any((duration) duration null)) { return const SolveAggregateValue.dnf(); } final trimmedDurations durations.castDuration(); if (trimmedDurations.isEmpty) { return const SolveAggregateValue.dnf(); } return SolveAggregateValue.valid(_averageDuration(trimmedDurations)); }WCA 标准的滚动平均算法取最近 N 次成绩。DNF 2 次整体 DNF。去掉最好和最差成绩。如果有 1 次 DNF它就是最差成绩被去掉。剩余成绩求平均。六、PB 追踪6.1 PB 检查时机每次记录新成绩后自动检查 PBFutureSolveEntry recordSolve(...) async { // ... 录入成绩 ... await _checkAndUpdatePBs(targetGroupId); notifyListeners(); return storedEntry; }6.2 PB 检查逻辑FutureListPersonalBestRecord _checkAndUpdatePBs(String groupId) async { final group _groups.firstWhere( (g) g.id groupId, orElse: () selectedGroup, ); final event group.event; final eventEntries entriesForEvent(event); if (eventEntries.isEmpty) return []; final newPBs PersonalBestRecord[]; // 检查 Single PB final singlePB await _checkSinglePB(event, eventEntries); if (singlePB ! null) newPBs.add(singlePB); // 检查 Ao5 PB if (eventEntries.length 5) { final ao5PB await _checkAveragePB(event, eventEntries, PBType.ao5, 5); if (ao5PB ! null) newPBs.add(ao5PB); } // 检查 Ao12 PB if (eventEntries.length 12) { final ao12PB await _checkAveragePB(event, eventEntries, PBType.ao12, 12); if (ao12PB ! null) newPBs.add(ao12PB); } // 检查 Ao100 PB if (eventEntries.length 100) { final ao100PB await _checkAveragePB(event, eventEntries, PBType.ao100, 100); if (ao100PB ! null) newPBs.add(ao100PB); } return newPBs; }PB 检查是跨分组的——按项目WcaEvent汇总所有成绩后检查。6.3 单次 PB 检查FuturePersonalBestRecord? _checkSinglePB( WcaEvent event, ListSolveEntry entries, ) async { final validDurations entries .map((e) e.effectiveDuration) .whereTypeDuration() .toList(); if (validDurations.isEmpty) return null; final currentBest validDurations.reduce(_minDuration); final currentPB _pbRepository.getPB(event, PBType.single); // 如果没有旧 PB或者新成绩更好 if (currentPB null || currentBest currentPB.duration) { final bestEntry entries.firstWhere( (e) e.effectiveDuration currentBest, ); final newPB PersonalBestRecord( type: PBType.single, duration: currentBest, achievedAt: bestEntry.recordedAt, previousBest: currentPB?.duration, scramble: bestEntry.scramble, solveIds: [bestEntry.hiveKey?.toString() ?? ], ); await _pbRepository.savePB(event, newPB); return newPB; } return null; }6.4 平均 PB 检查FuturePersonalBestRecord? _checkAveragePB( WcaEvent event, ListSolveEntry entries, PBType type, int windowSize, ) async { if (entries.length windowSize) return null; final sortedEntries ListSolveEntry.from(entries) ..sort((a, b) a.recordedAt.compareTo(b.recordedAt)); // 计算所有可能的滚动平均找出最佳 Duration? bestAverage; ListSolveEntry? bestWindow; for (var i 0; i sortedEntries.length - windowSize; i) { final window sortedEntries.sublist(i, i windowSize); final avgValue _rollingAverage(window, windowSize); if (avgValue.hasValue avgValue.duration ! null) { if (bestAverage null || avgValue.duration! bestAverage) { bestAverage avgValue.duration; bestWindow window; } } } if (bestAverage null || bestWindow null) return null; final currentPB _pbRepository.getPB(event, type); if (currentPB null || bestAverage currentPB.duration) { final newPB PersonalBestRecord( type: type, duration: bestAverage, achievedAt: bestWindow.last.recordedAt, previousBest: currentPB?.duration, solveIds: bestWindow .map((e) e.hiveKey?.toString() ?? ) .where((id) id.isNotEmpty) .toList(), ); await _pbRepository.savePB(event, newPB); return newPB; } return null; }平均 PB 的检查逻辑按时间排序所有成绩。遍历所有可能的窗口计算每个窗口的滚动平均。取最佳平均。与当前 PB 比较如果更好则更新。6.5 PB 持久化class PersonalBestRepository { static const String _boxName personal_bests; Futurevoid savePB(WcaEvent event, PersonalBestRecord record) async { final key _makeKey(event, record.type); await box.put(key, record.toMap()); } PersonalBestRecord? getPB(WcaEvent event, PBType type) { final key _makeKey(event, type); final data box.get(key); if (data null) return null; return PersonalBestRecord.fromMap(data); } String _makeKey(WcaEvent event, PBType type) { return ${event.code}_${type.name}; // 例如 333_single } }PB 使用 Hive 存储key 格式为项目代码_类型如333_single、333_ao5。七、跨分组统计记录页面有项目统计视图按 WCA 项目汇总所有分组的成绩/// 获取有记录的所有 WCA 项目 ListWcaEvent getEventsWithRecords() { final events WcaEvent{}; for (final group in _groups) { final count _countsByGroup[group.id] ?? 0; if (count 0) { events.add(group.event); } } final sorted events.toList()..sort((a, b) a.code.compareTo(b.code)); return sorted; } /// 获取指定 WCA 项目的所有成绩跨所有分组 ListSolveEntry entriesForEvent(WcaEvent event) { final result SolveEntry[]; for (final group in _groups) { if (group.event event) { final groupEntries _entriesByGroup[group.id] ?? SolveEntry[]; result.addAll(groupEntries); } } result.sort((a, b) b.recordedAt.compareTo(a.recordedAt)); return result; } /// 获取指定 WCA 项目的统计数据跨所有分组 SolveStatsSummary statsForEvent(WcaEvent event) { final eventEntries SolveEntry[]; for (final group in _groups) { if (group.event event) { eventEntries.addAll(_entriesByGroup[group.id] ?? SolveEntry[]); } } eventEntries.sort((a, b) a.recordedAt.compareTo(b.recordedAt)); return _recalculateStats(eventEntries); }这些方法遍历所有分组按项目汇总成绩和统计实现项目统计视图的数据支撑。八、Tab 栏可见性控制记录页面有一个独立的控制器控制 Tab 栏的显示/隐藏class RecordsTabBarVisibilityController extends ChangeNotifier { bool _isVisible true; bool get isVisible _isVisible; void toggle() { _isVisible !_isVisible; notifyListeners(); } }在页面中使用AnimatedCrossFade实现平滑切换AnimatedCrossFade( duration: const Duration(milliseconds: 300), crossFadeState: isTabBarVisible ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: Column(children: [/* Tab 栏 */]), secondChild: const SizedBox.shrink(), ),九、打乱收藏9.1 ScrambleFavoritesControllerclass ScrambleFavoritesController extends ChangeNotifier { final ScrambleFavoriteRepository _repository; final ListScrambleFavorite _favorites ScrambleFavorite[]; FutureScrambleFavorite addFavorite({ required String scramble, required WcaEvent event, String group , String note , Duration? duration, }) async { if (exists(scramble: scramble, event: event, group: group)) { throw StateError(该公式已收藏); } final favorite ScrambleFavorite( scramble: scramble, event: event, group: group, note: note, duration: duration, createdAt: DateTime.now(), ); final stored await _repository.add(favorite); _favorites.insert(0, stored); _sortByCreatedAtDesc(); notifyListeners(); return stored; } }收藏逻辑添加前检查是否已存在去重。新收藏插入列表头部。按创建时间倒序排列。十、总结这篇我们深入梳理了记录页面的逻辑实现数据模型SolveEntry成绩、SolveGroup分组、PersonalBestRecordPB罚时通过effectiveDuration统一处理。SolvesController核心控制器管理成绩的录入、更新、删除维护内存缓存。旧数据迁移确保所有成绩都有有效分组 ID兼容版本升级。分组管理创建、更新、删除分组删除时自动转移成绩。统计计算WCA 标准的滚动平均算法去最好去最差求平均缓存重建机制。PB 追踪跨分组按项目检查支持 Single / Ao5 / Ao12 / Ao100 四种类型。跨分组统计按 WCA 项目汇总所有分组的成绩和统计。Tab 栏控制独立的可见性控制器AnimatedCrossFade 平滑切换。打乱收藏去重检查按时间倒序排列。记录页面的逻辑实现体现了数据即状态的设计理念成绩变更触发缓存重建和 PB 检查UI 通过ChangeNotifier自动响应。