在这里插入图片描述

衣橱里的衣服越来越多,但你真的了解自己的穿衣习惯吗?哪些衣服穿得最多,哪些买来就压箱底了,衣橱里什么颜色的衣服最多?统计分析功能就是用来回答这些问题的。

今天这篇文章,我来详细讲讲衣橱管家App里统计分析功能的实现。这个功能用到了图表库fl_chart来做数据可视化,涉及到饼图绑定、数据聚合、列表展示等多个技术点。

功能需求分析

统计分析功能需要展示以下几类数据:

第一,总览数据,包括衣物总数、搭配数量、衣物总价值。

第二,分类分布,用饼图展示各类衣物的占比。

第三,颜色分布,展示衣橱里各种颜色的衣物数量。

第四,穿着频率,列出最常穿和很少穿的衣物。

页面基础结构

先看StatisticsScreen的定义:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../providers/wardrobe_provider.dart';
import '../../models/clothing_item.dart';

class StatisticsScreen extends StatelessWidget {
  const StatisticsScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('衣物统计')),
      body: Consumer<WardrobeProvider>(
        builder: (context, provider, child) {
          return SingleChildScrollView(
            padding: EdgeInsets.all(16.w),
            child: Column(
              children: [
                _buildOverviewCard(provider),
                SizedBox(height: 16.h),
                _buildCategoryChart(provider),
                SizedBox(height: 16.h),
                _buildColorChart(provider),
                SizedBox(height: 16.h),
                _buildMostWornCard(provider),
                SizedBox(height: 16.h),
                _buildLeastWornCard(provider),
              ],
            ),
          );
        },
      ),
    );
  }
}

这里用StatelessWidget就够了,因为页面不需要维护局部状态,所有数据都从Provider获取。
fl_chart是Flutter里很流行的图表库,支持饼图、柱状图、折线图等多种图表类型。
Consumer包裹整个body,这样衣物数据变化时,所有统计数据都会自动更新。

总览卡片实现

总览卡片展示三个核心数据:

Widget _buildOverviewCard(WardrobeProvider provider) {
  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('总览', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
          SizedBox(height: 16.h),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildStatItem('衣物总数', '${provider.clothes.length}', Icons.checkroom, Colors.pink),
              _buildStatItem('搭配数量', '${provider.outfits.length}', Icons.style, Colors.blue),
              _buildStatItem('总价值', ${provider.getTotalValue().toStringAsFixed(0)}', Icons.attach_money, Colors.green),
            ],
          ),
        ],
      ),
    ),
  );
}

三个数据项横向排列,用Row和spaceAround实现等间距分布。
provider.clothes.length直接获取衣物列表的长度,就是衣物总数。
getTotalValue()是Provider里的方法,计算所有衣物价格的总和。
toStringAsFixed(0)把小数转成整数字符串,因为总价值不需要显示小数。

统计项的构建方法:

Widget _buildStatItem(String label, String value, IconData icon, Color color) {
  return Column(
    children: [
      Container(
        padding: EdgeInsets.all(12.w),
        decoration: BoxDecoration(color: color.withOpacity(0.1), shape: BoxShape.circle),
        child: Icon(icon, color: color, size: 24.sp),
      ),
      SizedBox(height: 8.h),
      Text(value, style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
      Text(label, style: TextStyle(fontSize: 12.sp, color: Colors.grey)),
    ],
  );
}

每个统计项由图标、数值、标签三部分组成,从上到下排列。
图标放在圆形背景里,背景色是图标颜色的浅色版本,看起来很协调。
数值用大字体粗体显示,标签用小字体灰色,主次分明。

分类分布饼图

用饼图展示各类衣物的占比:

Widget _buildCategoryChart(WardrobeProvider provider) {
  final stats = provider.getCategoryStats();
  final total = stats.values.fold(0, (a, b) => a + b);

  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('分类分布', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
          SizedBox(height: 16.h),
          SizedBox(
            height: 200.h,
            child: stats.isEmpty
                ? const Center(child: Text('暂无数据'))
                : PieChart(
                    PieChartData(
                      sections: _buildPieSections(stats, total),
                      centerSpaceRadius: 50.r,
                      sectionsSpace: 2,
                    ),
                  ),
          ),
          SizedBox(height: 16.h),
          _buildLegend(stats),
        ],
      ),
    ),
  );
}

getCategoryStats()返回一个Map,key是分类名称,value是该分类的衣物数量。
fold方法用来计算所有数量的总和,用于后面计算百分比。
PieChart是fl_chart提供的饼图组件,需要传入PieChartData配置。
centerSpaceRadius设置中间空心圆的半径,做成环形图比实心饼图更好看。
sectionsSpace设置各扇区之间的间隙,有间隙看起来更清晰。

饼图扇区的构建方法:

List<PieChartSectionData> _buildPieSections(Map<String, int> stats, int total) {
  final colors = [Colors.pink, Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal];
  int index = 0;
  return stats.entries.map((entry) {
    final color = colors[index % colors.length];
    index++;
    return PieChartSectionData(
      value: entry.value.toDouble(),
      title: '${(entry.value / total * 100).toStringAsFixed(0)}%',
      color: color,
      radius: 40.r,
      titleStyle: TextStyle(fontSize: 12.sp, color: Colors.white, fontWeight: FontWeight.bold),
    );
  }).toList();
}

预定义一组颜色,用取模运算循环使用,这样不管有多少分类都有颜色可用。
value是扇区的数值,决定扇区的大小。
title显示在扇区上,这里显示百分比。
radius是扇区的半径,也就是环的宽度。
titleStyle设置标题的样式,白色粗体在彩色背景上对比度好。

图例的构建:

Widget _buildLegend(Map<String, int> stats) {
  final colors = [Colors.pink, Colors.blue, Colors.green, Colors.orange, Colors.purple, Colors.teal];
  return Wrap(
    spacing: 16.w,
    runSpacing: 8.h,
    children: stats.entries.map((e) {
      final index = stats.keys.toList().indexOf(e.key);
      return Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(width: 12.w, height: 12.w, color: colors[index % colors.length]),
          SizedBox(width: 4.w),
          Text('${e.key}: ${e.value}件'),
        ],
      );
    }).toList(),
  );
}

图例用Wrap包裹,自动换行,适应不同屏幕宽度。
每个图例项由色块和文字组成,色块颜色和饼图扇区颜色对应。
mainAxisSize: MainAxisSize.min让Row只占用必要的宽度,不会撑满整行。

颜色分布展示

颜色分布用标签的形式展示:

Widget _buildColorChart(WardrobeProvider provider) {
  final stats = provider.getColorStats();

  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('颜色分布', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
          SizedBox(height: 16.h),
          Wrap(
            spacing: 8.w,
            runSpacing: 8.h,
            children: stats.entries.map((e) {
              return Container(
                padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
                decoration: BoxDecoration(
                  color: ClothingItem.getColorFromName(e.key).withOpacity(0.3),
                  borderRadius: BorderRadius.circular(16.r),
                  border: Border.all(color: ClothingItem.getColorFromName(e.key)),
                ),
                child: Text('${e.key} ${e.value}件', style: TextStyle(fontSize: 12.sp)),
              );
            }).toList(),
          ),
        ],
      ),
    ),
  );
}

getColorStats()返回颜色统计数据,key是颜色名称,value是数量。
每个颜色用一个圆角标签展示,背景色是该颜色的浅色版本,边框是该颜色。
ClothingItem.getColorFromName把颜色名称转换成Color对象。
这种展示方式比饼图更直观,用户一眼就能看出有哪些颜色。

最常穿着列表

展示穿着次数最多的衣物:

Widget _buildMostWornCard(WardrobeProvider provider) {
  final items = provider.getMostWorn();

  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              const Icon(Icons.trending_up, color: Colors.green),
              SizedBox(width: 8.w),
              Text('最常穿着', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
            ],
          ),
          SizedBox(height: 12.h),
          ...items.map((item) => ListTile(
                contentPadding: EdgeInsets.zero,
                leading: Container(
                  width: 40.w,
                  height: 40.w,
                  decoration: BoxDecoration(
                    color: ClothingItem.getColorFromName(item.color).withOpacity(0.3),
                    borderRadius: BorderRadius.circular(8.r),
                  ),
                  child: Icon(Icons.checkroom, color: ClothingItem.getColorFromName(item.color), size: 20.sp),
                ),
                title: Text(item.name),
                trailing: Text('${item.wearCount}次', style: const TextStyle(color: Colors.green, fontWeight: FontWeight.bold)),
              )),
        ],
      ),
    ),
  );
}

getMostWorn()返回穿着次数最多的几件衣物,已经按穿着次数降序排列。
标题前面加个上升趋势图标,表示这些是"热门"衣物。
用…展开操作符把map的结果展开成多个Widget。
trailing显示穿着次数,用绿色表示这是好的(穿得多说明喜欢)。

很少穿着列表

展示穿着次数最少的衣物:

Widget _buildLeastWornCard(WardrobeProvider provider) {
  final items = provider.getLeastWorn();

  return Card(
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              const Icon(Icons.trending_down, color: Colors.orange),
              SizedBox(width: 8.w),
              Text('很少穿着', style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold)),
            ],
          ),
          SizedBox(height: 8.h),
          Text('这些衣物可能需要更多关注', style: TextStyle(fontSize: 12.sp, color: Colors.grey)),
          SizedBox(height: 12.h),
          ...items.map((item) => ListTile(
                contentPadding: EdgeInsets.zero,
                leading: Container(
                  width: 40.w,
                  height: 40.w,
                  decoration: BoxDecoration(
                    color: ClothingItem.getColorFromName(item.color).withOpacity(0.3),
                    borderRadius: BorderRadius.circular(8.r),
                  ),
                  child: Icon(Icons.checkroom, color: ClothingItem.getColorFromName(item.color), size: 20.sp),
                ),
                title: Text(item.name),
                trailing: Text('${item.wearCount}次', style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold)),
              )),
        ],
      ),
    ),
  );
}

getLeastWorn()返回穿着次数最少的几件衣物。
标题用下降趋势图标,表示这些衣物"冷门"。
加了一行提示文字,告诉用户这些衣物可能需要关注,是不是该穿穿了或者考虑处理掉。
穿着次数用橙色显示,表示这是需要注意的(穿得少可能是浪费)。

Provider里的统计方法

WardrobeProvider里需要实现几个统计方法:

// 获取衣物总价值
double getTotalValue() {
  return clothes.fold(0.0, (sum, item) => sum + item.price);
}

// 获取分类统计
Map<String, int> getCategoryStats() {
  final stats = <String, int>{};
  for (final item in clothes) {
    stats[item.category] = (stats[item.category] ?? 0) + 1;
  }
  return stats;
}

// 获取颜色统计
Map<String, int> getColorStats() {
  final stats = <String, int>{};
  for (final item in clothes) {
    stats[item.color] = (stats[item.color] ?? 0) + 1;
  }
  return stats;
}

fold方法遍历列表并累加,初始值是0.0,每次把item.price加到sum上。
分类统计和颜色统计逻辑类似,都是遍历衣物列表,按某个属性分组计数。
用??运算符处理Map里不存在的key,不存在时默认为0。

穿着频率统计:

// 获取最常穿的衣物
List<ClothingItem> getMostWorn({int limit = 5}) {
  final sorted = List<ClothingItem>.from(clothes)
    ..sort((a, b) => b.wearCount.compareTo(a.wearCount));
  return sorted.take(limit).toList();
}

// 获取最少穿的衣物
List<ClothingItem> getLeastWorn({int limit = 5}) {
  final sorted = List<ClothingItem>.from(clothes)
    ..sort((a, b) => a.wearCount.compareTo(b.wearCount));
  return sorted.take(limit).toList();
}

先复制一份列表,避免修改原列表的顺序。
sort方法按穿着次数排序,最常穿的是降序,最少穿的是升序。
take(limit)取前几个,默认取5个。
…是级联操作符,可以在同一个对象上连续调用多个方法。

fl_chart的使用技巧

fl_chart是一个功能强大的图表库,使用时有几个注意点:

// pubspec.yaml里添加依赖
dependencies:
  fl_chart: ^0.66.0

// 饼图的基本用法
PieChart(
  PieChartData(
    sections: [...],           // 扇区数据
    centerSpaceRadius: 50.r,   // 中心空白半径
    sectionsSpace: 2,          // 扇区间隙
    startDegreeOffset: -90,    // 起始角度偏移
  ),
)

fl_chart支持饼图、柱状图、折线图、雷达图等多种图表。
PieChartData的sections是一个PieChartSectionData列表,每个元素代表一个扇区。
startDegreeOffset可以调整饼图的起始角度,-90表示从12点钟方向开始。

数据可视化的设计原则

做统计分析页面,有几个设计原则值得注意:

第一,数据要一目了然,用户不需要思考就能理解。

第二,颜色要有意义,比如绿色表示好,橙色表示需要注意。

第三,图表要配图例,不然用户不知道每个颜色代表什么。

第四,空状态要处理,没有数据时显示"暂无数据"而不是空白。

// 空状态处理
child: stats.isEmpty
    ? const Center(child: Text('暂无数据'))
    : PieChart(...),

判断数据是否为空,为空时显示提示文字,不为空时显示图表。
这样用户知道不是页面出错了,而是确实没有数据。

性能优化考虑

统计分析涉及到数据聚合计算,如果衣物数量很多,可能会有性能问题:

// 可以考虑缓存统计结果
Map<String, int>? _categoryStatsCache;

Map<String, int> getCategoryStats() {
  if (_categoryStatsCache != null) {
    return _categoryStatsCache!;
  }
  // 计算统计数据...
  _categoryStatsCache = stats;
  return stats;
}

// 数据变化时清除缓存
void addClothing(ClothingItem item) {
  clothes.add(item);
  _categoryStatsCache = null;  // 清除缓存
  notifyListeners();
}

缓存统计结果,避免每次build都重新计算。
数据变化时清除缓存,下次获取时重新计算。
对于衣橱管家这种数据量不大的应用,其实不缓存也没问题。

总结

统计分析功能的实现涉及到数据聚合、图表展示、列表渲染等多个方面。关键点在于:

用fl_chart库实现饼图,展示分类分布。

用颜色标签展示颜色分布,比图表更直观。

用列表展示穿着频率,帮助用户了解自己的穿衣习惯。

在OpenHarmony平台上,fl_chart库完全兼容,这套统计分析功能可以直接使用。通过数据可视化,用户能更好地了解自己的衣橱,做出更理性的购买决策。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐