在这里插入图片描述

武器统计能帮助玩家了解自己最常用的武器:击杀、爆头、命中率、使用频率这些数据一旦可视化,就很容易看出“我到底更擅长步枪还是狙”。

这一篇我不做“把一坨假数据塞进 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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐