Flutter for OpenHarmony PUBG游戏助手App实战:武器统计
武器统计能帮助玩家了解自己最常用的武器:击杀、爆头、命中率、使用频率这些数据一旦可视化,就很容易看出“我到底更擅长步枪还是狙”。下面代码都按“短片段 + 紧跟解释”来写,实际项目里你可以按文件拆开。

武器统计能帮助玩家了解自己最常用的武器:击杀、爆头、命中率、使用频率这些数据一旦可视化,就很容易看出“我到底更擅长步枪还是狙”。
这一篇我不做“把一坨假数据塞进 ListView”那种演示,而是按真实项目的思路走一遍:
- 数据模型要可序列化(后面要接接口/本地缓存)
- 数据来源要可替换(mock 先跑通,后面再换成真实接口)
- 页面要有加载/错误/空态(用户体验上差别很大)
- 代码不要堆在一个 Widget 里(后期维护省很多事)
下面代码都按“短片段 + 紧跟解释”来写,实际项目里你可以按文件拆开。
1. 武器统计模型(可序列化)
class WeaponStat {
final String name;
final int kills;
final int headshots;
final double accuracy;
final int timesUsed;
const WeaponStat({
required this.name,
required this.kills,
required this.headshots,
required this.accuracy,
required this.timesUsed,
});
}
这里我先把模型保持得很“干净”:全是 final 字段,构造函数用 const。好处是:
- 不可变对象更适合在列表里反复渲染
- UI 层不会无意中把数据改乱
extension WeaponStatMapper on WeaponStat {
factory WeaponStatMapper.fromJson(Map<String, dynamic> json) {
return WeaponStat(
name: (json['name'] ?? '') as String,
kills: (json['kills'] ?? 0) as int,
headshots: (json['headshots'] ?? 0) as int,
accuracy: ((json['accuracy'] ?? 0) as num).toDouble(),
timesUsed: (json['timesUsed'] ?? 0) as int,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'kills': kills,
'headshots': headshots,
'accuracy': accuracy,
'timesUsed': timesUsed,
};
}
}
这段是我在项目里最常见的写法:用 fromJson/toJson 把模型“接得上接口”。
num -> double这个转换我会强制做一次,避免后端给68这种整数导致类型问题- 默认值写在解析层,UI 就不用到处做
?? 0
extension WeaponStatComputed on WeaponStat {
double get kdPerUse => timesUsed == 0 ? 0 : kills / timesUsed;
double get headshotRate => kills == 0 ? 0 : headshots / kills;
}
把“派生字段”放在 extension 里,比在 UI 里临时算更稳:
- 避免重复计算逻辑散落(之后你要改规则只改一处)
- 避免除 0(真实用户数据里
timesUsed == 0很常见)
2. 数据来源:Repository(先 mock,后替换接口)
abstract class WeaponStatisticsRepository {
Future<List<WeaponStat>> fetchWeaponStats();
}
我会先把数据源抽象成接口。这样页面完全不关心数据来自哪里:
- 本地 mock
- 真实接口
- 本地缓存(文件/数据库)
class MockWeaponStatisticsRepository implements WeaponStatisticsRepository {
Future<List<WeaponStat>> fetchWeaponStats() async {
await Future<void>.delayed(const Duration(milliseconds: 450));
return const [
WeaponStat(name: 'M416', kills: 150, headshots: 45, accuracy: 68.5, timesUsed: 120),
WeaponStat(name: 'AKM', kills: 132, headshots: 36, accuracy: 61.2, timesUsed: 98),
WeaponStat(name: 'AWM', kills: 85, headshots: 72, accuracy: 92.3, timesUsed: 45),
];
}
}
这个 mock 我故意加了 delayed,原因很现实:
- 不加延迟你根本看不到 loading 态,UI 很容易写得“看起来能用但一上真接口就露馅”
- mock 数据里我加了
AKM,方便你后面做排序/筛选时能看到变化
3. 状态管理:一个简单的 ChangeNotifier
enum LoadStatus { idle, loading, success, empty, failure }
真实项目我一般会把“加载状态”显式化,而不是只用 bool isLoading。
- 后面要加“下拉刷新/分页”时,这个结构更好扩展
class WeaponStatisticsNotifier extends ChangeNotifier {
WeaponStatisticsNotifier(this._repo);
final WeaponStatisticsRepository _repo;
LoadStatus status = LoadStatus.idle;
String? errorMessage;
List<WeaponStat> items = const [];
Future<void> load() async {
status = LoadStatus.loading;
errorMessage = null;
notifyListeners();
try {
final data = await _repo.fetchWeaponStats();
items = data;
status = data.isEmpty ? LoadStatus.empty : LoadStatus.success;
} catch (e) {
status = LoadStatus.failure;
errorMessage = e.toString();
}
notifyListeners();
}
}
这段写法的关键点是:
- 两次
notifyListeners():一次切 loading,一次更新结果(页面体验会更顺滑) - 失败时把异常转成
errorMessage,UI 就能显示“可读的错误”而不是白屏
4. 页面骨架:先把状态切对
class WeaponStatisticsPage extends StatefulWidget {
const WeaponStatisticsPage({super.key});
State<WeaponStatisticsPage> createState() => _WeaponStatisticsPageState();
}
class _WeaponStatisticsPageState extends State<WeaponStatisticsPage> {
late final WeaponStatisticsNotifier notifier;
void initState() {
super.initState();
notifier = WeaponStatisticsNotifier(MockWeaponStatisticsRepository());
notifier.load();
}
void dispose() {
notifier.dispose();
super.dispose();
}
}
这里我先用最朴素的方式把 notifier 挂在页面上(不引入额外库)。项目里你也可以用 Provider/Riverpod,但思路是一样的:
- 页面创建时拉取一次数据
- 页面销毁时释放监听
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: notifier,
builder: (context, _) {
return Scaffold(
appBar: AppBar(
title: const Text('武器统计'),
backgroundColor: const Color(0xFF2D2D2D),
actions: [
IconButton(
onPressed: notifier.load,
icon: const Icon(Icons.refresh),
),
],
),
backgroundColor: const Color(0xFF1A1A1A),
body: _buildBody(),
);
},
);
}
AnimatedBuilder 这个用法挺适合“小页面自带状态”的场景:不用额外依赖,也能做到数据变化自动刷新 UI。
refresh按钮我建议保留,调试/联调接口时非常省事
Widget _buildBody() {
switch (notifier.status) {
case LoadStatus.loading:
return const Center(child: CircularProgressIndicator());
case LoadStatus.failure:
return _ErrorView(
message: notifier.errorMessage ?? '加载失败',
onRetry: notifier.load,
);
case LoadStatus.empty:
return const _EmptyView(text: '暂无武器数据');
case LoadStatus.success:
return _WeaponStatList(items: notifier.items);
case LoadStatus.idle:
return const SizedBox.shrink();
}
}
这个 switch 其实是“项目可维护性”的分水岭:
- 有些页面写到最后变成一堆
if/else,再加上刷新/重试就非常难读 - 把空态/错误态写出来,你会发现产品体验立刻像“真的在用的 App”
5. 列表与卡片:拆组件,控制单个 Widget 复杂度
class _WeaponStatList extends StatelessWidget {
const _WeaponStatList({required this.items});
final List<WeaponStat> items;
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) => _WeaponStatCard(stat: items[index]),
);
}
}
我习惯用 ListView.separated,因为间距是 UI 的“硬需求”,单独写 separator 比在 Card 上写 margin 更直观。
class _WeaponStatCard extends StatelessWidget {
const _WeaponStatCard({required this.stat});
final WeaponStat stat;
Widget build(BuildContext context) {
return Card(
color: const Color(0xFF2D2D2D),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stat.name,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_StatText(label: '击杀', value: stat.kills.toString()),
_StatText(label: '爆头', value: stat.headshots.toString()),
_StatText(label: '精准度', value: '${stat.accuracy.toStringAsFixed(1)}%'),
],
),
const SizedBox(height: 12),
_FooterText(stat: stat),
],
),
),
);
}
}
卡片里我只保留“结构”,细节交给更小的组件处理。这样你后面要改字体/颜色/布局,不会在一个大方法里迷路。
class _StatText extends StatelessWidget {
const _StatText({required this.label, required this.value});
final String label;
final String value;
Widget build(BuildContext context) {
return Column(
children: [
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 11)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold)),
],
);
}
}
这一段就是“纯 UI 原子组件”。放小一点的组件有两个实际收益:
- 父组件代码更短
- 你做单元测试/Golden Test(如果你要做)时更容易定位
class _FooterText extends StatelessWidget {
const _FooterText({required this.stat});
final WeaponStat stat;
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('使用次数: ${stat.timesUsed}', style: const TextStyle(color: Colors.white70, fontSize: 12)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFFF6B35).withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'平均 ${stat.kdPerUse.toStringAsFixed(1)} 杀/次',
style: const TextStyle(color: Color(0xFFFF6B35), fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
);
}
}
这里我用的是前面 extension 里的 kdPerUse,目的就是让 UI 不再做业务计算。
6. 错误态 / 空态(真实项目里别省略)
class _EmptyView extends StatelessWidget {
const _EmptyView({required this.text});
final String text;
Widget build(BuildContext context) {
return Center(
child: Text(text, style: const TextStyle(color: Colors.white70)),
);
}
}
空态别写得太花,核心是让用户知道“不是卡了,是确实没数据”。
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 12),
ElevatedButton(onPressed: onRetry, child: const Text('重试')),
],
),
),
);
}
}
错误态我一定会带一个“重试”按钮。联调接口时经常会遇到:
- 首次请求失败(网络/权限/服务抖动)
- 重试一次就好了
7. 一个小加分项:排序(更贴近“统计”)
enum SortType { byKillsDesc, byAccuracyDesc }
统计页如果永远按原顺序展示,用户很难快速找到“我最强的那把枪”。排序是低成本高收益。
extension WeaponSort on List<WeaponStat> {
List<WeaponStat> sortedBy(SortType type) {
final copy = [...this];
switch (type) {
case SortType.byKillsDesc:
copy.sort((a, b) => b.kills.compareTo(a.kills));
break;
case SortType.byAccuracyDesc:
copy.sort((a, b) => b.accuracy.compareTo(a.accuracy));
break;
}
return copy;
}
}
这里我返回一个新 list,而不是原地 sort 传进来的 items:
- 避免把 notifier 的原始数据顺序改掉(后面你加分页/合并数据时更安全)
小结
这一页做完后,你会发现它已经不像“Demo”,更像一个可以直接塞进项目的页面。
- 模型:字段 + 序列化 + 派生计算
- 数据层:Repository 抽象,mock 先跑通
- 页面:加载/空态/错误态齐全,并且拆组件保持可维护
- 可用性:加一个排序或筛选,统计页的价值立刻上来
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)