不依赖 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 实体 ------ 单条情绪记录,包含 id、moodType、note(可选备注)、createdAt 和 updatedAt 时间戳。这里使用的是 DateTime 而非时间戳整数,因为 DateTime.weekday 属性可以直接获取星期几(Monday=1, Sunday=7),无需手动计算。
MoodStorage 持久层 ------ 基于 Hive CE(纯 Dart 键值存储)实现,不依赖 SQLite 原生库,对鸿蒙平台友好。提供 getByWeek(DateTime anyDay) 方法,按周一至周日切分数据,直接作为 MoodChart 的 weekMoods 入参。数据流向清晰: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.green、Colors.amber、Colors.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 次
└── ...
关键设计决策:
CrossAxisAlignment.end而非CrossAxisAlignment.stretch:柱子需要从底部"生长"而非填满。.end对齐方式让所有Column子元素收缩到其内容高度,然后底部对齐------柱高不同,但基线一致,这是柱状图的视觉基础。Expanded平分水平空间 :7 个Expanded子元素自动均分Row的宽度,无需手动计算屏幕宽度 / 7 - padding。这是 Flutter 弹性布局模型的核心优势------自适应不同屏幕宽度和横竖屏切换。MainAxisAlignment.end在 Column 内 :将数值标签、柱子和星期标签作为一个整体推到Column底部。结合外层的CrossAxisAlignment.end,效果是:无论柱高多少,星期标签始终在同一水平线上。- 柱高计算公式
avg / chartMax * 120:120是柱子的最大像素高度(小于SizedBox的160,为上下标签留出空间)。相对比例而非绝对像素让图表在不同数据范围下保持一致的视觉密度。
六、动画: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 中等时长动画的推荐值,给用户足够的"感知时间"又不至于不耐烦
注意 AnimatedContainer 对 color 和 borderRadius 的变化也会执行隐式动画。但在这个场景下,颜色和圆角在单次渲染周期内是固定的,动画主要体现在高度变化上------切换不同周时,柱子会从旧高度平滑过渡到新高度。
性能提示 :
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 数据即可验证所有渲染分支。
九、实用技巧与注意事项
DateTime.weekday的语义陷阱 :Dart 的weekday返回 1-7(Mon-Sun),减 1 后映射为 0-6。如果你的应用将周日视为一周的第一天,需要自行处理偏移。当前实现遵循中国习惯(周一至周日),与_mondayOf()的周一基准一致。AnimatedContainer的curve参数 :当前使用默认的Curves.linear。如果希望柱子有"弹性"效果,可以添加curve: Curves.easeOutBack------柱子会略微超过目标高度然后回弹,更有生命力。但注意不要过度使用,情绪类应用应避免过于花哨的动画。- 大数据量下的性能 :当前实现每次
build都重新执行完整的数据聚合管线(dayCounts→dayAvgs→chartMax)。对于一周几十条记录的场景完全没问题。如果数据量增长到数千条级别,考虑将聚合结果缓存或使用ListView.builder替代一次性渲染。 - 空状态的优雅降级 :组件在多个层面处理了空数据------
dayCounts初始化为空列表、dayAvgs空列表映射为 0.0、chartMax兜底为 5.0、_colorForAvg(0)返回灰色、柱高为 0 时AnimatedContainer隐藏柱子。这种多层防御确保了图表在任何数据状态下都不会崩溃。 - 与 MoodPicker 的配合 :
MoodPicker使用AnimatedScale实现选中态放大效果(scale 1.0 → 1.3,200ms),与MoodChart的 500ms 柱子动画形成时间上的层次感------选择情绪(快速反馈)→ 图表更新(平滑过渡),两个动画的时长差异创造了明确的交互节奏。
小结
MoodChart 不到 80 行的核心代码证明了:简单的移动端数据可视化不需要 重型图表库。Flutter 的 Row + Expanded + AnimatedContainer 组合足以优雅地应对大多数柱状图需求。自写代码在可定制性、包体积、跨平台兼容性(尤其是鸿蒙这样的新兴平台)方面远超任何第三方库------对个人开发者和独立作品而言,这是务实的工程选择。
核心设计模式:分桶聚合 → 比例计算 → 颜色映射 → 弹性布局 + 隐式动画,这四个步骤构成了一个可复用的轻量图表架构模板,稍作调整即可应用于折线图、散点图等场景。
作者简介 :E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen。。