Flutter for OpenHarmony 进阶:体育计分系统与数据持久化深度解析
本文深入探讨了Flutter在鸿蒙平台上开发体育计分系统的关键技术。文章重点介绍了多种计分规则的支持实现,包括标准25分制、15分制等不同比赛规则的定义与切换机制。同时详细讲解了数据持久化方案,通过MatchRecord和SetRecord模型实现比赛记录的存储与读取。系统支持比赛数据统计分析、UI主题切换等功能,展示了Flutter在跨平台开发中的企业级应用能力。通过这个案例,开发者可以掌握状态
·
欢迎加入开源鸿蒙跨平台社区:开源鸿蒙跨平台开发者社区
Flutter for OpenHarmony 进阶:体育计分系统与数据持久化深度解析
文章目录
摘要

体育计分系统不仅是记录分数的工具,更是理解状态管理、数据持久化、UI同步等高级技术的绝佳案例。本文深入讲解排球计分系统的进阶实现,包括多种计分规则支持、历史数据存储、数据统计分析、UI主题切换等核心技术点。通过本文学习,读者将掌握Flutter在鸿蒙平台上开发企业级应用的完整技巧。
一、多种计分规则支持
1.1 规则配置类
enum ScoringRule {
standard25, // 标准25分制
standard15, // 标准15分制
rally25, // 每球得分25分制
custom, // 自定义规则
}
class RuleConfig {
final ScoringRule type;
final int winningScore; // 获胜分数
final int minLead; // 最小领先分差
final int setsToWin; // 获胜局数
final bool hasTieBreak; // 是否有决胜局
final int tieBreakScore; // 决胜局分数
final String name;
final String description;
const RuleConfig({
required this.type,
required this.winningScore,
required this.minLead,
required this.setsToWin,
required this.hasTieBreak,
required this.tieBreakScore,
required this.name,
required this.description,
});
// 预设规则
static const standardVolleyball = RuleConfig(
type: ScoringRule.standard25,
winningScore: 25,
minLead: 2,
setsToWin: 3,
hasTieBreak: true,
tieBreakScore: 15,
name: '标准排球',
description: '前四局25分,决胜局15分',
);
static const miniVolleyball = RuleConfig(
type: ScoringRule.standard15,
winningScore: 15,
minLead: 2,
setsToWin: 3,
hasTieBreak: false,
tieBreakScore: 15,
name: '迷你排球',
description: '每局15分,三局两胜',
);
static const beachVolleyball = RuleConfig(
type: ScoringRule.rally25,
winningScore: 21,
minLead: 2,
setsToWin: 2,
hasTieBreak: true,
tieBreakScore: 15,
name: '沙滩排球',
description: '每局21分,两局制,决胜局15分',
);
}
1.2 规则切换实现
class ScoreboardPage extends StatefulWidget {
const ScoreboardPage({super.key});
State<ScoreboardPage> createState() => _ScoreboardPageState();
}
class _ScoreboardPageState extends State<ScoreboardPage> {
RuleConfig _currentRule = RuleConfig.standardVolleyball;
void _changeRule(RuleConfig newRule) {
if (_matchStarted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认切换规则'),
content: const Text('切换规则将重置当前比赛,确定继续吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
setState(() {
_currentRule = newRule;
_resetMatch();
});
},
child: const Text('确定'),
),
],
),
);
} else {
setState(() {
_currentRule = newRule;
});
}
}
}
二、数据持久化
2.1 比赛记录模型
class MatchRecord {
final String id;
final DateTime date;
final RuleConfig rule;
final String teamAName;
final String teamBName;
final int teamASets;
final int teamBSets;
final List<SetRecord> sets;
final int totalDuration; // 总时长(秒)
MatchRecord({
required this.id,
required this.date,
required this.rule,
required this.teamAName,
required this.teamBName,
required this.teamASets,
required this.teamBSets,
required this.sets,
required this.totalDuration,
});
// 转换为JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'date': date.toIso8601String(),
'rule': rule.type.name,
'teamAName': teamAName,
'teamBName': teamBName,
'teamASets': teamASets,
'teamBSets': teamBSets,
'sets': sets.map((s) => s.toJson()).toList(),
'totalDuration': totalDuration,
};
}
// 从JSON创建
factory MatchRecord.fromJson(Map<String, dynamic> json) {
return MatchRecord(
id: json['id'] as String,
date: DateTime.parse(json['date'] as String),
rule: _getRuleFromType(json['rule'] as String),
teamAName: json['teamAName'] as String,
teamBName: json['teamBName'] as String,
teamASets: json['teamASets'] as int,
teamBSets: json['teamBSets'] as int,
sets: (json['sets'] as List)
.map((s) => SetRecord.fromJson(s as Map<String, dynamic>))
.toList(),
totalDuration: json['totalDuration'] as int,
);
}
static RuleConfig _getRuleFromType(String type) {
switch (type) {
case 'standard25':
return RuleConfig.standardVolleyball;
case 'standard15':
return RuleConfig.miniVolleyball;
case 'rally25':
return RuleConfig.beachVolleyball;
default:
return RuleConfig.standardVolleyball;
}
}
}
class SetRecord {
final int setNumber;
final int teamAScore;
final int teamBScore;
final int duration;
SetRecord({
required this.setNumber,
required this.teamAScore,
required this.teamBScore,
required this.duration,
});
Map<String, dynamic> toJson() {
return {
'setNumber': setNumber,
'teamAScore': teamAScore,
'teamBScore': teamBScore,
'duration': duration,
};
}
factory SetRecord.fromJson(Map<String, dynamic> json) {
return SetRecord(
setNumber: json['setNumber'] as int,
teamAScore: json['teamAScore'] as int,
teamBScore: json['teamBScore'] as int,
duration: json['duration'] as int,
);
}
}
2.2 存储服务
import 'dart:io';
import 'dart:convert';
class MatchStorageService {
static const String _matchesDir = 'matches';
static const String _indexFile = 'matches_index.json';
// 保存比赛记录
Future<void> saveMatch(MatchRecord match) async {
final directory = await _getMatchesDirectory();
final file = File('${directory.path}/${match.id}.json');
await file.writeAsString(jsonEncode(match.toJson()));
await _updateIndex(match);
}
// 获取所有比赛记录
Future<List<MatchRecord>> getAllMatches() async {
final directory = await _getMatchesDirectory();
final indexFile = File('${directory.path}/$_indexFile');
if (!await indexFile.exists()) {
return [];
}
final indexData = await indexFile.readAsString();
final List<dynamic> index = jsonDecode(indexData);
final matches = <MatchRecord>[];
for (final item in index) {
final matchFile = File('${directory.path}/${item['id']}.json}');
if (await matchFile.exists()) {
final matchData = await matchFile.readAsString();
matches.add(MatchRecord.fromJson(jsonDecode(matchData)));
}
}
// 按日期倒序排列
matches.sort((a, b) => b.date.compareTo(a.date));
return matches;
}
// 删除比赛记录
Future<void> deleteMatch(String matchId) async {
final directory = await _getMatchesDirectory();
final file = File('${directory.path}/$matchId.json');
if (await file.exists()) {
await file.delete();
await _removeFromIndex(matchId);
}
}
// 获取比赛目录
Future<Directory> _getMatchesDirectory() async {
final appDir = await getApplicationDocumentsDirectory();
final matchesDir = Directory('${appDir.path}/$_matchesDir');
if (!await matchesDir.exists()) {
await matchesDir.create(recursive: true);
}
return matchesDir;
}
// 更新索引
Future<void> _updateIndex(MatchRecord match) async {
final directory = await _getMatchesDirectory();
final indexFile = File('${directory.path}/$_indexFile');
List<Map<String, dynamic>> index = [];
if (await indexFile.exists()) {
final indexData = await indexFile.readAsString();
index = List<Map<String, dynamic>>.from(jsonDecode(indexData));
}
index.add({
'id': match.id,
'date': match.date.toIso8601String(),
'teamA': match.teamAName,
'teamB': match.teamBName,
'scoreA': match.teamASets,
'scoreB': match.teamBSets,
});
await indexFile.writeAsString(jsonEncode(index));
}
// 从索引移除
Future<void> _removeFromIndex(String matchId) async {
final directory = await _getMatchesDirectory();
final indexFile = File('${directory.path}/$_indexFile');
if (!await indexFile.exists()) return;
final indexData = await indexFile.readAsString();
final index = List<Map<String, dynamic>>.from(jsonDecode(indexData));
index.removeWhere((item) => item['id'] == matchId);
await indexFile.writeAsString(jsonEncode(index));
}
}
2.3 使用SharedPreferences存储配置
import 'package:shared_preferences/shared_preferences.dart';
class SettingsService {
static const String _defaultRuleKey = 'default_rule';
static const String _themeKey = 'theme_mode';
static const String _soundEnabledKey = 'sound_enabled';
static const String _vibrationEnabledKey = 'vibration_enabled';
// 保存默认规则
Future<void> saveDefaultRule(ScoringRule rule) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_defaultRuleKey, rule.name);
}
// 获取默认规则
Future<ScoringRule?> getDefaultRule() async {
final prefs = await SharedPreferences.getInstance();
final ruleName = prefs.getString(_defaultRuleKey);
if (ruleName != null) {
return ScoringRule.values.firstWhere(
(r) => r.name == ruleName,
orElse: () => ScoringRule.standard25,
);
}
return null;
}
// 保存主题模式
Future<void> saveThemeMode(String themeMode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeKey, themeMode);
}
// 获取主题模式
Future<String?> getThemeMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_themeKey);
}
// 保存声音设置
Future<void> saveSoundEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_soundEnabledKey, enabled);
}
// 获取声音设置
Future<bool> getSoundEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_soundEnabledKey) ?? true;
}
}
三、数据统计分析
3.1 统计数据模型
class MatchStatistics {
final int totalMatches;
final int teamAWins;
final int teamBWins;
final double teamAWinRate;
final double teamBWinRate;
final int totalSets;
final int longestMatch;
final int shortestMatch;
final double averageDuration;
MatchStatistics({
required this.totalMatches,
required this.teamAWins,
required this.teamBWins,
required this.teamAWinRate,
required this.teamBWinRate,
required this.totalSets,
required this.longestMatch,
required this.shortestMatch,
required this.averageDuration,
});
}
class StatisticsService {
// 计算统计数据
static MatchStatistics calculateStatistics(List<MatchRecord> matches) {
if (matches.isEmpty) {
return MatchStatistics(
totalMatches: 0,
teamAWins: 0,
teamBWins: 0,
teamAWinRate: 0.0,
teamBWinRate: 0.0,
totalSets: 0,
longestMatch: 0,
shortestMatch: 0,
averageDuration: 0.0,
);
}
int teamAWins = 0;
int teamBWins = 0;
int totalSets = 0;
int longestMatch = 0;
int shortestMatch = 999999;
int totalDuration = 0;
for (final match in matches) {
if (match.teamASets > match.teamBSets) {
teamAWins++;
} else {
teamBWins++;
}
totalSets += match.teamASets + match.teamBSets;
totalDuration += match.totalDuration;
if (match.totalDuration > longestMatch) {
longestMatch = match.totalDuration;
}
if (match.totalDuration < shortestMatch) {
shortestMatch = match.totalDuration;
}
}
return MatchStatistics(
totalMatches: matches.length,
teamAWins: teamAWins,
teamBWins: teamBWins,
teamAWinRate: teamAWins / matches.length,
teamBWinRate: teamBWins / matches.length,
totalSets: totalSets,
longestMatch: longestMatch,
shortestMatch: shortestMatch == 999999 ? 0 : shortestMatch,
averageDuration: totalDuration / matches.length,
);
}
}
3.2 统计图表UI

class StatisticsPage extends StatelessWidget {
final List<MatchRecord> matches;
const StatisticsPage({super.key, required this.matches});
Widget build(BuildContext context) {
final stats = StatisticsService.calculateStatistics(matches);
return Scaffold(
appBar: AppBar(
title: const Text('数据统计'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildOverviewCard(stats),
const SizedBox(height: 16),
_buildWinRateCard(stats),
const SizedBox(height: 16),
_buildMatchHistoryCard(matches),
],
),
);
}
Widget _buildOverviewCard(MatchStatistics stats) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('比赛概览', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem('总场次', '${stats.totalMatches}', Icons.sports_volleyball),
_buildStatItem('总局数', '${stats.totalSets}', Icons.format_list_numbered),
_buildStatItem('最长', _formatTime(stats.longestMatch), Icons.timer),
_buildStatItem('平均', _formatTime(stats.averageDuration.toInt()), Icons.schedule),
],
),
],
),
),
);
}
Widget _buildWinRateCard(MatchStatistics stats) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('胜率分析', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildTeamWinRate('主队', stats.teamAWins, stats.teamAWinRate, Colors.blue),
),
const SizedBox(width: 16),
Expanded(
child: _buildTeamWinRate('客队', stats.teamBWins, stats.teamBWinRate, Colors.red),
),
],
),
],
),
),
);
}
Widget _buildTeamWinRate(String name, int wins, double rate, Color color) {
return Column(
children: [
Text(name, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: color)),
const SizedBox(height: 8),
Text('$wins 场', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('${(rate * 100).toStringAsFixed(1)}%', style: TextStyle(fontSize: 14, color: Colors.grey.shade600)),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: rate,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 8,
),
),
],
);
}
Widget _buildStatItem(String label, String value, IconData icon) {
return Column(
children: [
Icon(icon, size: 32, color: Colors.blue),
const SizedBox(height: 8),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
);
}
String _formatTime(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${secs}s';
} else {
return '${secs}s';
}
}
}
四、主题切换
4.1 主题配置
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
useMaterial3: true,
);
}
static ThemeData get darkTheme {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
);
}
static ThemeData get blueTheme {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
primary: Colors.blue,
secondary: Colors.lightBlue,
),
useMaterial3: true,
);
}
static ThemeData get greenTheme {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.green,
primary: Colors.green,
secondary: Colors.lightGreen,
),
useMaterial3: true,
);
}
static ThemeData get orangeTheme {
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.orange,
primary: Colors.orange,
secondary: Colors.deepOrange,
),
useMaterial3: true,
);
}
}
4.2 主题切换实现
class ThemeProvider with ChangeNotifier {
static const String _defaultTheme = 'blue';
static const List<String> _availableThemes = [
'blue',
'green',
'orange',
];
String _currentTheme = _defaultTheme;
String get currentTheme => _currentTheme;
List<String> get availableThemes => _availableThemes;
ThemeData get themeData {
switch (_currentTheme) {
case 'green':
return AppTheme.greenTheme;
case 'orange':
return AppTheme.orangeTheme;
default:
return AppTheme.blueTheme;
}
}
void setTheme(String theme) {
if (_availableThemes.contains(theme)) {
_currentTheme = theme;
notifyListeners();
_saveTheme(theme);
}
}
Future<void> _saveTheme(String theme) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme', theme);
}
Future<void> loadTheme() async {
final prefs = await SharedPreferences.getInstance();
final savedTheme = prefs.getString('theme') ?? _defaultTheme;
setTheme(savedTheme);
}
}
五、总结
本文深入讲解了排球计分系统的进阶实现,主要内容包括:
- 多种规则:规则配置、规则切换
- 数据持久化:比赛记录存储、配置保存
- 统计分析:胜率计算、数据分析
- 主题切换:多主题支持、动态切换
这些技术可以应用到各种需要数据持久化和统计分析的应用中。
欢迎加入开源鸿蒙跨平台社区: 开源鸿蒙跨平台开发者社区
更多推荐


所有评论(0)