上次我们聊了记录页面的 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();
}

初始化步骤:

  1. 初始化三个 Repository。
  2. 加载分组,确保至少有一个默认分组。
  3. 加载全量成绩。
  4. 迁移旧数据(没有分组的成绩归入默认分组)。
  5. 重建内存缓存。

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;
}

录入流程:

  1. 创建 SolveEntry 对象。
  2. 持久化到 Repository。
  3. 加入内存列表。
  4. 重建缓存(统计、分组索引等)。
  5. 检查 PB(个人最佳)。
  6. 通知 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();
}

删除分组的逻辑:

  1. 默认分组不可删除。
  2. 被删除分组的成绩转移到目标分组(或默认分组)。
  3. 如果当前选中的就是被删除的分组,自动切换到目标分组。

五、统计计算

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);
  }
}

每次数据变更后,重建所有缓存:

  1. 按分组 ID 重新索引成绩。
  2. 每个分组内的成绩按时间排序。
  3. 重新计算每个分组的统计摘要。

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 标准的滚动平均算法:

  1. 取最近 N 次成绩。
  2. DNF >= 2 次,整体 DNF。
  3. 去掉最好和最差成绩。
  4. 如果有 1 次 DNF,它就是最差成绩,被去掉。
  5. 剩余成绩求平均。

六、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 的检查逻辑:

  1. 按时间排序所有成绩。
  2. 遍历所有可能的窗口,计算每个窗口的滚动平均。
  3. 取最佳平均。
  4. 与当前 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_single333_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;
  }
}

收藏逻辑:

  • 添加前检查是否已存在(去重)。
  • 新收藏插入列表头部。
  • 按创建时间倒序排列。

十、总结

这篇我们深入梳理了记录页面的逻辑实现:

  1. 数据模型:SolveEntry(成绩)、SolveGroup(分组)、PersonalBestRecord(PB),罚时通过 effectiveDuration 统一处理。
  2. SolvesController:核心控制器,管理成绩的录入、更新、删除,维护内存缓存。
  3. 旧数据迁移:确保所有成绩都有有效分组 ID,兼容版本升级。
  4. 分组管理:创建、更新、删除分组,删除时自动转移成绩。
  5. 统计计算:WCA 标准的滚动平均算法(去最好去最差求平均),缓存重建机制。
  6. PB 追踪:跨分组按项目检查,支持 Single / Ao5 / Ao12 / Ao100 四种类型。
  7. 跨分组统计:按 WCA 项目汇总所有分组的成绩和统计。
  8. Tab 栏控制:独立的可见性控制器,AnimatedCrossFade 平滑切换。
  9. 打乱收藏:去重检查,按时间倒序排列。

记录页面的逻辑实现,体现了"数据即状态"的设计理念:成绩变更触发缓存重建和 PB 检查,UI 通过 ChangeNotifier 自动响应。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐