【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:所属分组。hiveKey:Hive 中的主键,用于更新和删除。
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 List<String> 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 List<SolveEntry> _entries = <SolveEntry>[];
final Map<String, List<SolveEntry>> _entriesByGroup = <String, List<SolveEntry>>{};
final Map<String, SolveStatsSummary> _statsByGroup = <String, SolveStatsSummary>{};
final Map<String, int> _countsByGroup = <String, int>{};
List<SolveGroup> _groups = <SolveGroup>[];
bool _isInitialized = false;
String? _selectedGroupId;
}
内存缓存结构:
_entries:所有成绩的全量列表。_entriesByGroup:按分组 ID 索引的成绩列表。_statsByGroup:按分组 ID 索引的统计摘要。_countsByGroup:按分组 ID 索引的成绩数量。
2.1 初始化流程
Future<void> 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 = List<SolveGroup>.from(storedGroups);
}
_selectedGroupId = _groups.first.id;
_entries
..clear()
..addAll(_repository.fetchAllSolves());
await _migrateLegacyEntries();
_rebuildGroupCaches();
_isInitialized = true;
notifyListeners();
}
初始化步骤:
- 初始化三个 Repository。
- 加载分组,确保至少有一个默认分组。
- 加载全量成绩。
- 迁移旧数据(没有分组的成绩归入默认分组)。
- 重建内存缓存。
2.2 旧数据迁移
Future<void> _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 记录新成绩
Future<SolveEntry> 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 更新成绩
Future<SolveEntry> 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 删除成绩
Future<void> 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 创建分组
Future<SolveGroup> 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 删除分组
Future<void> 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:有效值。dnf:DNF 过多导致整体无效。
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(List<SolveEntry> 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.cast<Duration>();
if (trimmedDurations.isEmpty) {
return const SolveAggregateValue.dnf();
}
return SolveAggregateValue.valid(_averageDuration(trimmedDurations));
}
WCA 标准的滚动平均算法:
- 取最近 N 次成绩。
- DNF >= 2 次,整体 DNF。
- 去掉最好和最差成绩。
- 如果有 1 次 DNF,它就是最差成绩,被去掉。
- 剩余成绩求平均。
六、PB 追踪
6.1 PB 检查时机
每次记录新成绩后,自动检查 PB:
Future<SolveEntry> recordSolve(...) async {
// ... 录入成绩 ...
await _checkAndUpdatePBs(targetGroupId);
notifyListeners();
return storedEntry;
}
6.2 PB 检查逻辑
Future<List<PersonalBestRecord>> _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 检查
Future<PersonalBestRecord?> _checkSinglePB(
WcaEvent event,
List<SolveEntry> entries,
) async {
final validDurations = entries
.map((e) => e.effectiveDuration)
.whereType<Duration>()
.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 检查
Future<PersonalBestRecord?> _checkAveragePB(
WcaEvent event,
List<SolveEntry> entries,
PBType type,
int windowSize,
) async {
if (entries.length < windowSize) return null;
final sortedEntries = List<SolveEntry>.from(entries)
..sort((a, b) => a.recordedAt.compareTo(b.recordedAt));
// 计算所有可能的滚动平均,找出最佳
Duration? bestAverage;
List<SolveEntry>? 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';
Future<void> 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 项目
List<WcaEvent> 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 项目的所有成绩(跨所有分组)
List<SolveEntry> 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 ScrambleFavoritesController
class ScrambleFavoritesController extends ChangeNotifier {
final ScrambleFavoriteRepository _repository;
final List<ScrambleFavorite> _favorites = <ScrambleFavorite>[];
Future<ScrambleFavorite> 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(分组)、PersonalBestRecord(PB),罚时通过
effectiveDuration统一处理。 - SolvesController:核心控制器,管理成绩的录入、更新、删除,维护内存缓存。
- 旧数据迁移:确保所有成绩都有有效分组 ID,兼容版本升级。
- 分组管理:创建、更新、删除分组,删除时自动转移成绩。
- 统计计算:WCA 标准的滚动平均算法(去最好去最差求平均),缓存重建机制。
- PB 追踪:跨分组按项目检查,支持 Single / Ao5 / Ao12 / Ao100 四种类型。
- 跨分组统计:按 WCA 项目汇总所有分组的成绩和统计。
- Tab 栏控制:独立的可见性控制器,AnimatedCrossFade 平滑切换。
- 打乱收藏:去重检查,按时间倒序排列。
记录页面的逻辑实现,体现了"数据即状态"的设计理念:成绩变更触发缓存重建和 PB 检查,UI 通过 ChangeNotifier 自动响应。
更多推荐



所有评论(0)