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

📅 2026/7/9 18:30:17
AtomGit Flutter 鸿蒙客户端: 周情绪柱状图
不依赖 fl_chart、syncfusion 等第三方图表库——用 Flutter 原生组件手写一个周情绪柱状图零额外依赖纯 Dart 实现天然跨平台包括鸿蒙。一、为什么不用第三方图表库E-Brufen 的图表需求很简单7 根柱子 坐标标签 颜色映射 入场动画。引入一个 MB 级的图表库是过度依赖。而且大多数图表库底层依赖dart:ui的 Canvas 绑定和 Skia 引擎特性在鸿蒙HarmonyOS平台上可能存在兼容性风险——轻则渲染异常重则直接 Crash。自己写的优势零额外依赖APK/HAP 体积不受影响完全控制样式、动画和交互行为天然跨平台纯 Dart不依赖平台通道核心代码不到 80 行可维护性极高适合鸿蒙等新兴平台的安全第一策略二、数据模型MoodType 枚举与 MoodEntry 实体图表的数据源来自两个核心模型。理解它们的设计是理解图表聚合逻辑的前提。MoodType 枚举—— 五种情绪类型的结构化定义enumMoodType{angry(1,,生气),sad(2,,难过),tired(3,,疲惫),calm(4,,平静),happy(5,,开心);finalint value;// 数值映射用于图表 Y 轴计算finalStringemoji;// UI 展示finalStringlabel;// 中文标签}关键设计每个情绪枚举自带一个 1-5 的value字段。这个字段是整个图表数值计算的基石——日均分、颜色映射、柱高比例全部依赖它。枚举值在语义上是定序数据ordinal但我们将其视为等距量表的近似值这样求平均才在数学上有意义。MoodEntry 实体—— 单条情绪记录包含id、moodType、note可选备注、createdAt和updatedAt时间戳。这里使用的是DateTime而非时间戳整数因为DateTime.weekday属性可以直接获取星期几Monday1, Sunday7无需手动计算。MoodStorage 持久层—— 基于 Hive CE纯 Dart 键值存储实现不依赖 SQLite 原生库对鸿蒙平台友好。提供getByWeek(DateTime anyDay)方法按周一至周日切分数据直接作为MoodChart的weekMoods入参。数据流向清晰MoodStorage.getByWeek()→MoodChart(weekMoods: ...)→ 内部聚合 → 渲染。三、数据聚合管线从原始记录到每日均值这是图表组件的核心计算逻辑分三个阶段执行// 阶段 1初始化 7 天的空容器finaldayCountsint,Listint{for(vari0;i7;i)i:[],};// 阶段 2按星期几分桶m.createdAt.weekday - 1 将 Mon1 映射为 idx0for(finalminweekMoods){finaldayIndexm.createdAt.weekday-1;dayCounts[dayIndex]?.add(m.moodType.value);}// 阶段 3计算每日平均分finaldayAvgsdayCounts.map((day,vals){if(vals.isEmpty)returnMapEntry(day,0.0);returnMapEntry(day,vals.reduce((a,b)ab)/vals.length);});// 确定 Y 轴上限finalmaxValdayAvgs.values.fold(0.0,(a,b)ab?a:b);finalchartMaxmaxVal1?5.0:maxVal;架构洞察 —— 为什么用 Mapint, List 而非直接计算分桶-聚合两步走的模式split-apply-combine比单次遍历直接求均值更清晰第一步只管分组第二步只管计算。这遵循了单一职责原则——当未来需求变化比如需要同时展示中位数、最大值、最小值只需要修改聚合阶段分组逻辑完全不变。maxVal 1 ? 5.0 : maxVal这个三元表达式是防御性设计的关键。当一周没有任何记录时maxVal为 0所有柱子的相对高度计算avg / chartMax * 120会退化为0 / 0 * 120即 NaN。将chartMax兜底为 5.0满分值确保空图表仍然有完整的坐标系结构——柱子高度为 0但星期标签和布局骨架完好无损。四、五级颜色梯度_colorForAvg 的设计哲学Color_colorForAvg(double avg){if(avg4.5)returnColors.green;// [4.5, 5.0] 很开心if(avg3.5)returnColors.lightGreen;// [3.5, 4.5) 较开心if(avg2.5)returnColors.amber;// [2.5, 3.5) 中性/一般if(avg1.5)returnColors.orange;// [1.5, 2.5) 较低落if(avg0)returnColors.red;// (0, 1.5) 很低落returnColors.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 布局模型精妙之处的部分SizedBox(height:160,// 固定图表总高约束柱子的最大可用空间child:Row(crossAxisAlignment:CrossAxisAlignment.end,// 核心所有子元素底部对齐children:[一,二,三,四,五,六,日].asMap().entries.map((e){finalavgdayAvgs[e.key]??0.0;finalbarHeightchartMax0?(avg/chartMax*120):0.0;returnExpanded(// 7 个 Expanded 均分 Row 的水平空间child:Padding(padding:constEdgeInsets.symmetric(horizontal:4),child:Column(mainAxisAlignment:MainAxisAlignment.end,// 列内元素底部对齐children:[Text(avg0?avg.toStringAsFixed(1):,...),// 数值标签constSizedBox(height:4),AnimatedContainer(// 柱子主体duration:constDuration(milliseconds:500),height:barHeight,decoration:BoxDecoration(color:_colorForAvg(avg),borderRadius:BorderRadius.circular(8),),),constSizedBox(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 * 120120是柱子的最大像素高度小于SizedBox的160为上下标签留出空间。相对比例而非绝对像素让图表在不同数据范围下保持一致的视觉密度。六、动画AnimatedContainer 的 500ms 生长效果AnimatedContainer(duration:constDuration(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内部使用AnimationControllerTween实现。7 个柱子的动画是独立且并行的——每个AnimatedContainer拥有自己的动画控制器。这意味着切换周数据时7 根柱子会同时以不同幅度生长或缩短形成自然的数据刷新动画。七、鼓励性反馈summaryText 静态方法staticStringsummaryText(ListMoodEntryweekMoods){if(weekMoods.isEmpty)return本周还没有记录哦来记录第一份心情吧 ;finalavgweekMoods.map((m)m.moodType.value).reduce((a,b)ab)/weekMoods.length;if(avg4.0)return这周心情很阳光继续保持 ;if(avg3.0)return这周心情平稳一切刚刚好 ;if(avg2.0)return这周有些低落给自己一个拥抱 ;return这周辛苦了好好休息一下吧 ;}这是情绪类应用温度感的直接体现——机器可以给出冰冷的数据但好的产品会在数据之上叠加一层人性化的解读。设计要点空数据时的引导“来记录第一份心情吧” 不指责用户而是温和地邀请正向优先即使是最低分段 2.0文案也是好好休息而非你太糟糕了——心理健康类应用的语言需要格外慎重emoji 的辅助作用每个分段的 emoji 提供非语言的视觉情绪线索静态方法summaryText标记为static表明它是无状态的纯函数——输入一周的数据输出一段文本。可以被任何页面调用不依赖组件实例。八、架构总览数据流与组件协作用户操作 (MoodPicker) │ ▼ MoodStorage.insert() ── Hive Box 持久化 ── notifyListeners() │ ▼ 页面层 ChangeNotifier 监听 │ ├── MoodStorage.getByWeek(today) → ListMoodEntry │ │ │ ├── MoodChart(weekMoods: ...) │ │ ├── 数据聚合 (dayCounts → dayAvgs) │ │ ├── 颜色映射 (_colorForAvg) │ │ ├── 柱状图渲染 (Row AnimatedContainer) │ │ └── 无副作用纯渲染 │ │ │ └── MoodChart.summaryText(weekMoods) │ └── 纯函数无副作用 │ ▼ UI 刷新柱状图 说明文字整个数据流是单向的用户操作 → 持久化 → 通知 → 重新查询 → 传递给组件 → 渲染。MoodChart作为StatelessWidget自身不持有状态完全由父组件传入的weekMoods决定渲染结果。这种设计使组件高度可测试——传入 mock 数据即可验证所有渲染分支。九、实用技巧与注意事项DateTime.weekday的语义陷阱Dart 的weekday返回 1-7Mon-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.3200ms与MoodChart的 500ms 柱子动画形成时间上的层次感——选择情绪快速反馈→ 图表更新平滑过渡两个动画的时长差异创造了明确的交互节奏。小结MoodChart 不到 80 行的核心代码证明了简单的移动端数据可视化不需要重型图表库。Flutter 的RowExpandedAnimatedContainer组合足以优雅地应对大多数柱状图需求。自写代码在可定制性、包体积、跨平台兼容性尤其是鸿蒙这样的新兴平台方面远超任何第三方库——对个人开发者和独立作品而言这是务实的工程选择。核心设计模式分桶聚合 → 比例计算 → 颜色映射 → 弹性布局 隐式动画这四个步骤构成了一个可复用的轻量图表架构模板稍作调整即可应用于折线图、散点图等场景。作者简介E-Brufen DevFlutter 鸿蒙开发者专注于跨平台移动应用开发与心理健康数字化项目地址AtomGit - E-Brufen。。