AtomGit Flutter 鸿蒙客户端: 周情绪柱状图

不依赖 fl_chart、syncfusion 等第三方图表库------用 Flutter 原生组件手写一个周情绪柱状图,零额外依赖,纯 Dart 实现,天然跨平台(包括鸿蒙)。

一、为什么不用第三方图表库?

E-Brufen 的图表需求很简单:7 根柱子 + 坐标标签 + 颜色映射 + 入场动画。引入一个 MB 级的图表库是过度依赖。而且大多数图表库底层依赖 dart:ui 的 Canvas 绑定和 Skia 引擎特性,在鸿蒙(HarmonyOS)平台上可能存在兼容性风险------轻则渲染异常,重则直接 Crash。

自己写的优势:

  • 零额外依赖,APK/HAP 体积不受影响
  • 完全控制样式、动画和交互行为
  • 天然跨平台(纯 Dart,不依赖平台通道)
  • 核心代码不到 80 行,可维护性极高
  • 适合鸿蒙等新兴平台的"安全第一"策略

二、数据模型:MoodType 枚举与 MoodEntry 实体

图表的数据源来自两个核心模型。理解它们的设计是理解图表聚合逻辑的前提。

MoodType 枚举 ------ 五种情绪类型的结构化定义:

dart 复制代码
enum MoodType {
  angry(1, '😡', '生气'),
  sad(2, '😢', '难过'),
  tired(3, '😴', '疲惫'),
  calm(4, '😐', '平静'),
  happy(5, '😊', '开心');

  final int value;    // 数值映射,用于图表 Y 轴计算
  final String emoji; // UI 展示
  final String label; // 中文标签
}

关键设计:每个情绪枚举自带一个 1-5 的 value 字段。这个字段是整个图表数值计算的基石------日均分、颜色映射、柱高比例全部依赖它。枚举值在语义上是定序数据(ordinal),但我们将其视为等距量表的近似值,这样求平均才在数学上有意义。

MoodEntry 实体 ------ 单条情绪记录,包含 idmoodTypenote(可选备注)、createdAtupdatedAt 时间戳。这里使用的是 DateTime 而非时间戳整数,因为 DateTime.weekday 属性可以直接获取星期几(Monday=1, Sunday=7),无需手动计算。

MoodStorage 持久层 ------ 基于 Hive CE(纯 Dart 键值存储)实现,不依赖 SQLite 原生库,对鸿蒙平台友好。提供 getByWeek(DateTime anyDay) 方法,按周一至周日切分数据,直接作为 MoodChartweekMoods 入参。数据流向清晰:MoodStorage.getByWeek()MoodChart(weekMoods: ...) → 内部聚合 → 渲染。

三、数据聚合管线:从原始记录到每日均值

这是图表组件的核心计算逻辑,分三个阶段执行:

dart 复制代码
// 阶段 1:初始化 7 天的空容器
final dayCounts = <int, List<int>>{
  for (var i = 0; i < 7; i++) i: [],
};

// 阶段 2:按星期几分桶(m.createdAt.weekday - 1 将 Mon=1 映射为 idx=0)
for (final m in weekMoods) {
  final dayIndex = m.createdAt.weekday - 1;
  dayCounts[dayIndex]?.add(m.moodType.value);
}

// 阶段 3:计算每日平均分
final dayAvgs = dayCounts.map((day, vals) {
  if (vals.isEmpty) return MapEntry(day, 0.0);
  return MapEntry(day, vals.reduce((a, b) => a + b) / vals.length);
});

// 确定 Y 轴上限
final maxVal = dayAvgs.values.fold(0.0, (a, b) => a > b ? a : b);
final chartMax = maxVal < 1 ? 5.0 : maxVal;

架构洞察 ------ 为什么用 Map<int, List> 而非直接计算?

分桶-聚合两步走的模式(split-apply-combine)比单次遍历直接求均值更清晰:第一步只管分组,第二步只管计算。这遵循了单一职责原则------当未来需求变化(比如需要同时展示中位数、最大值、最小值),只需要修改聚合阶段,分组逻辑完全不变。

maxVal < 1 ? 5.0 : maxVal 这个三元表达式是防御性设计的关键。当一周没有任何记录时,maxVal 为 0,所有柱子的相对高度计算 avg / chartMax * 120 会退化为 0 / 0 * 120 即 NaN。将 chartMax 兜底为 5.0(满分值),确保空图表仍然有完整的坐标系结构------柱子高度为 0,但星期标签和布局骨架完好无损。

四、五级颜色梯度:_colorForAvg 的设计哲学

dart 复制代码
Color _colorForAvg(double avg) {
  if (avg >= 4.5) return Colors.green;        // [4.5, 5.0] 很开心
  if (avg >= 3.5) return Colors.lightGreen;   // [3.5, 4.5) 较开心
  if (avg >= 2.5) return Colors.amber;        // [2.5, 3.5) 中性/一般
  if (avg >= 1.5) return Colors.orange;       // [1.5, 2.5) 较低落
  if (avg > 0)   return Colors.red;           // (0, 1.5)   很低落
  return Colors.grey.shade200;                // == 0        无数据
}

五个色阶形成 red → orange → amber → lightGreen → green 的热力图渐变,与 MoodType 的 1-5 分值语义对应。这里有几个值得注意的细节:

  • 边界值向上包含>= 判断意味着 4.5 分属于"很开心"而非"较开心",这是"向上取整"策略,让用户在接近更好情绪时得到积极反馈------这是情绪类应用的"温度感"设计。
  • 0 值的特殊处理 :灰色(grey.shade200)明确区分"数据缺失"和"情绪低落"。红色柱子表示"今天心情不好",而灰色表示"今天还没记录"------两种状态对用户的暗示完全不同。
  • 使用 Material 预定义色Colors.greenColors.amberColors.orange 是 Material Design 的标准色板,在浅色/深色主题下都有良好的对比度,无需手动调色。

五、布局架构:Row + Expanded + CrossAxisAlignment.end

这是整个组件最体现 Flutter 布局模型精妙之处的部分:

dart 复制代码
SizedBox(
  height: 160,  // 固定图表总高,约束柱子的最大可用空间
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.end,  // 核心:所有子元素底部对齐
    children: ['一', '二', '三', '四', '五', '六', '日']
      .asMap().entries.map((e) {
        final avg = dayAvgs[e.key] ?? 0.0;
        final barHeight = chartMax > 0 ? (avg / chartMax * 120) : 0.0;

        return Expanded(  // 7 个 Expanded 均分 Row 的水平空间
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 4),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.end,  // 列内元素底部对齐
              children: [
                Text(avg > 0 ? avg.toStringAsFixed(1) : '', ...),  // 数值标签
                const SizedBox(height: 4),
                AnimatedContainer(  // 柱子主体
                  duration: const Duration(milliseconds: 500),
                  height: barHeight,
                  decoration: BoxDecoration(
                    color: _colorForAvg(avg),
                    borderRadius: BorderRadius.circular(8),
                  ),
                ),
                const SizedBox(height: 4),
                Text(e.value, ...),  // 星期标签(一二三四五六日)
              ],
            ),
          ),
        );
      }).toList(),
  ),
);

布局层级拆解:

复制代码
SizedBox(height: 160)           ← 固定图表总高度,防止无数据时塌陷
  └── Row(crossAxisAlignment.end) ← 7 根柱子底部对齐于同一条基线
        ├── Expanded → Column(MainAxisAlignment.end)
        │     ├── Text(avg)         ← 数值标签,无数据时为空字符串
        │     ├── AnimatedContainer ← 柱子主体,高度由日均分决定
        │     └── Text(星期标签)    ← 一二三四五六日
        ├── Expanded → Column(...)  ← 同上,重复 7 次
        └── ...

关键设计决策:

  1. CrossAxisAlignment.end 而非 CrossAxisAlignment.stretch :柱子需要从底部"生长"而非填满。.end 对齐方式让所有 Column 子元素收缩到其内容高度,然后底部对齐------柱高不同,但基线一致,这是柱状图的视觉基础。
  2. Expanded 平分水平空间 :7 个 Expanded 子元素自动均分 Row 的宽度,无需手动计算 屏幕宽度 / 7 - padding。这是 Flutter 弹性布局模型的核心优势------自适应不同屏幕宽度和横竖屏切换。
  3. MainAxisAlignment.end 在 Column 内 :将数值标签、柱子和星期标签作为一个整体推到 Column 底部。结合外层的 CrossAxisAlignment.end,效果是:无论柱高多少,星期标签始终在同一水平线上。
  4. 柱高计算公式 avg / chartMax * 120120 是柱子的最大像素高度(小于 SizedBox160,为上下标签留出空间)。相对比例而非绝对像素让图表在不同数据范围下保持一致的视觉密度。

六、动画:AnimatedContainer 的 500ms 生长效果

dart 复制代码
AnimatedContainer(
  duration: const Duration(milliseconds: 500),
  height: barHeight,
  decoration: BoxDecoration(
    color: color,
    borderRadius: BorderRadius.circular(8),
  ),
),

AnimatedContainer 是 Flutter 内置的隐式动画组件------当 height 属性变化时,自动在 duration 时长内执行线性插值过渡。这里选择 500ms 是基于 UX 考量:

  • 太短(< 200ms):用户难以察觉变化,柱子"闪现"而不"生长"
  • 太长(> 800ms):用户等待时间过长,感觉迟钝
  • 500ms:是 Material Design 中等时长动画的推荐值,给用户足够的"感知时间"又不至于不耐烦

注意 AnimatedContainercolorborderRadius 的变化也会执行隐式动画。但在这个场景下,颜色和圆角在单次渲染周期内是固定的,动画主要体现在高度变化上------切换不同周时,柱子会从旧高度平滑过渡到新高度。

性能提示AnimatedContainer 内部使用 AnimationController + Tween 实现。7 个柱子的动画是独立且并行的------每个 AnimatedContainer 拥有自己的动画控制器。这意味着切换周数据时,7 根柱子会同时以不同幅度"生长"或"缩短",形成自然的数据刷新动画。

七、鼓励性反馈:summaryText 静态方法

dart 复制代码
static String summaryText(List<MoodEntry> weekMoods) {
  if (weekMoods.isEmpty) return '本周还没有记录哦,来记录第一份心情吧 🌱';
  final avg = weekMoods
      .map((m) => m.moodType.value)
      .reduce((a, b) => a + b) / weekMoods.length;

  if (avg >= 4.0) return '这周心情很阳光!继续保持 🌞';
  if (avg >= 3.0) return '这周心情平稳,一切刚刚好 🍃';
  if (avg >= 2.0) return '这周有些低落,给自己一个拥抱 🫂';
  return '这周辛苦了,好好休息一下吧 💤';
}

这是情绪类应用"温度感"的直接体现------机器可以给出冰冷的数据,但好的产品会在数据之上叠加一层人性化的解读。设计要点:

  • 空数据时的引导:"来记录第一份心情吧" 不指责用户,而是温和地邀请
  • 正向优先:即使是最低分段(< 2.0),文案也是"好好休息"而非"你太糟糕了"------心理健康类应用的语言需要格外慎重
  • emoji 的辅助作用:每个分段的 emoji(🌱 🌞 🍃 🫂 💤)提供非语言的视觉情绪线索
  • 静态方法summaryText 标记为 static,表明它是无状态的纯函数------输入一周的数据,输出一段文本。可以被任何页面调用,不依赖组件实例。

八、架构总览:数据流与组件协作

复制代码
用户操作 (MoodPicker)
    │
    ▼
MoodStorage.insert() ── Hive Box 持久化 ── notifyListeners()
    │
    ▼
页面层 ChangeNotifier 监听
    │
    ├── MoodStorage.getByWeek(today) → List<MoodEntry>
    │       │
    │       ├── MoodChart(weekMoods: ...)
    │       │     ├── 数据聚合 (dayCounts → dayAvgs)
    │       │     ├── 颜色映射 (_colorForAvg)
    │       │     ├── 柱状图渲染 (Row + AnimatedContainer)
    │       │     └── 无副作用,纯渲染
    │       │
    │       └── MoodChart.summaryText(weekMoods)
    │             └── 纯函数,无副作用
    │
    ▼
UI 刷新(柱状图 + 说明文字)

整个数据流是单向的:用户操作 → 持久化 → 通知 → 重新查询 → 传递给组件 → 渲染。MoodChart 作为 StatelessWidget,自身不持有状态,完全由父组件传入的 weekMoods 决定渲染结果。这种设计使组件高度可测试------传入 mock 数据即可验证所有渲染分支。

九、实用技巧与注意事项

  1. DateTime.weekday 的语义陷阱 :Dart 的 weekday 返回 1-7(Mon-Sun),减 1 后映射为 0-6。如果你的应用将周日视为一周的第一天,需要自行处理偏移。当前实现遵循中国习惯(周一至周日),与 _mondayOf() 的周一基准一致。
  2. AnimatedContainer curve 参数 :当前使用默认的 Curves.linear。如果希望柱子有"弹性"效果,可以添加 curve: Curves.easeOutBack------柱子会略微超过目标高度然后回弹,更有生命力。但注意不要过度使用,情绪类应用应避免过于花哨的动画。
  3. 大数据量下的性能 :当前实现每次 build 都重新执行完整的数据聚合管线(dayCountsdayAvgschartMax)。对于一周几十条记录的场景完全没问题。如果数据量增长到数千条级别,考虑将聚合结果缓存或使用 ListView.builder 替代一次性渲染。
  4. 空状态的优雅降级 :组件在多个层面处理了空数据------dayCounts 初始化为空列表、dayAvgs 空列表映射为 0.0、chartMax 兜底为 5.0、_colorForAvg(0) 返回灰色、柱高为 0 时 AnimatedContainer 隐藏柱子。这种多层防御确保了图表在任何数据状态下都不会崩溃。
  5. 与 MoodPicker 的配合MoodPicker 使用 AnimatedScale 实现选中态放大效果(scale 1.0 → 1.3,200ms),与 MoodChart 的 500ms 柱子动画形成时间上的层次感------选择情绪(快速反馈)→ 图表更新(平滑过渡),两个动画的时长差异创造了明确的交互节奏。

小结

MoodChart 不到 80 行的核心代码证明了:简单的移动端数据可视化不需要 重型图表库。Flutter 的 Row + Expanded + AnimatedContainer 组合足以优雅地应对大多数柱状图需求。自写代码在可定制性、包体积、跨平台兼容性(尤其是鸿蒙这样的新兴平台)方面远超任何第三方库------对个人开发者和独立作品而言,这是务实的工程选择。

核心设计模式:分桶聚合 → 比例计算 → 颜色映射 → 弹性布局 + 隐式动画,这四个步骤构成了一个可复用的轻量图表架构模板,稍作调整即可应用于折线图、散点图等场景。


作者简介 :E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen。。

相关推荐
chh5632 小时前
C++--string
java·开发语言·网络·c++·学习
不才难以繁此生2 小时前
ArkUI 列表性能实战|中式美食菜谱列表如何拆组件、控重绘和稳住滑动体验
harmonyos·arkts·状态管理·foreach·arkui·列表性能·组件拆分
Ww.xh2 小时前
DevEco Studio虚拟机部署指南
华为·harmonyos
yuanlaile2 小时前
前端转 Flutter 踩坑实录:一套可落地 Flutter 鸿蒙 App 完整学习路线,建议收藏!
flutter·harmonyos·flutter开发鸿蒙·跨平台开发app·flutter深入学习
爸爸6192 小时前
08 ArkTS 类与静态方法设计:单例、DAO 与设计令牌
华为·harmonyos·鸿蒙系统
ZZZMMM.zip2 小时前
基于鸿蒙HarmonyOS NEXT开发AI天气助手应用:智能出行新体验与鸿蒙Flutter框架跨端实践
人工智能·flutter·华为·harmonyos·鸿蒙系统
心中有国也有家3 小时前
AtomGit Flutter 鸿蒙客户端:一键记录情绪的极致体验
学习·flutter·华为·harmonyos
ZZZMMM.zip3 小时前
营养方程式-营养餐单规划的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙系统
lauo3 小时前
从52起融资看教育科技新范式:PopLang引擎与Token节点经济如何重塑AI学习终端
人工智能·科技·学习
不才难以繁此生3 小时前
HarmonyOS RDB 实战|中式美食菜谱表设计:食材、步骤与购物清单怎么落库
flutter·harmonyos·rdb·中式美食·本地持久化