HarmonyOS厨房助手实战第7篇:营养聚合、Canvas环形图与深色模式
HarmonyOS厨房助手实战第7篇:营养聚合、Canvas环形图与深色模式
摘要
本文继续实现 HarmonyOS 厨房助手的营养分析页面。数据来源不是手工填写的一张统计表,而是“用餐计划 + 食谱营养信息”的实时聚合结果。页面支持今日与本周切换,使用 ArkUI Canvas 绘制蛋白质、脂肪和碳水化合物环形图,并在深色模式变化时重新绘制。
文章重点覆盖:
- 如何连接 MealPlan 与 Recipe 两类数据;
- 如何用 Map 避免重复查找;
- 聚合口径怎样定义才不产生误导;
- Canvas 为什么需要显式重绘;
- 零数据、缺失食谱和非法营养值如何处理;
- 图表颜色怎样兼顾深色模式与可访问性。
一、先明确统计口径
营养图表最危险的问题不是代码错误,而是口径不清。厨房助手当前定义:
每条 MealPlanEntry 代表食谱的一人份
统计区间内的条目逐条累加对应食谱营养
因此一份食谱在同一天安排两次,会被统计两次。这个规则简单,但必须在产品中保持一致。
如果模型以后增加 servings,计算应调整为:
条目营养 = 食谱单人份营养 × 条目份数
如果食谱记录的是整道菜总营养,还需要除以食谱默认份数。先定义口径,再写聚合代码,才能避免“数字看起来正确,含义却错误”。
二、营养数据模型
食谱中保存四个基础指标:
export interface RecipeNutrition {
kcal: number;
proteinG: number;
fatG: number;
carbsG: number;
}
聚合结果额外包含参与统计的计划条数:
export interface NutritionSummary {
kcal: number;
proteinG: number;
fatG: number;
carbsG: number;
entryCount: number;
}
export function emptyNutritionSummary(): NutritionSummary {
return {
kcal: 0,
proteinG: 0,
fatG: 0,
carbsG: 0,
entryCount: 0
};
}
使用工厂函数生成空对象,比复用一个可变全局对象更安全。
三、从日期范围查询计划
页面只决定统计“今天”还是“本周”:
enum RangeKey {
Today = 'today',
Week = 'week'
}
private async refresh(): Promise<void> {
this.loading = true;
try {
const ctx =
getContext(this) as common.UIAbilityContext;
const dates: string[] =
this.range === RangeKey.Week
? DateUtil.weekOf(DateUtil.today())
: [DateUtil.today()];
this.summary = await NutritionService
.ensure()
.summarize(ctx, dates);
} finally {
this.loading = false;
this.draw();
}
}
页面不知道计划文件怎样存储,也不关心食谱怎样查询。聚合责任放在 NutritionService 中。
四、Service 聚合两类数据
实现步骤:
- 查询日期范围内的计划;
- 读取全部食谱;
- 构建
recipeId -> Recipe映射; - 遍历计划并累加。
async summarize(
context: common.UIAbilityContext,
dates: string[]
): Promise<NutritionSummary> {
const mealService = MealPlanService.ensure(context);
const recipeService = RecipeService.ensure(context);
const entries: MealPlanEntry[] =
await mealService.listByDateRange(dates);
if (entries.length === 0) {
return emptyNutritionSummary();
}
const recipes: Recipe[] = await recipeService.list();
const recipeMap: Map<string, Recipe> =
new Map<string, Recipe>();
recipes.forEach((recipe: Recipe) => {
recipeMap.set(recipe.id, recipe);
});
const sum: NutritionSummary = emptyNutritionSummary();
entries.forEach((entry: MealPlanEntry) => {
const recipe: Recipe | undefined =
recipeMap.get(entry.recipeId);
if (recipe === undefined) {
return;
}
const value: RecipeNutrition = recipe.nutrition;
sum.kcal += value.kcal;
sum.proteinG += value.proteinG;
sum.fatG += value.fatG;
sum.carbsG += value.carbsG;
sum.entryCount += 1;
});
return sum;
}
五、为什么先构建 Map
如果每条计划都调用 recipes.find(),时间复杂度接近:
计划数 × 食谱数
构建 Map 后:
构建映射:食谱数
查询聚合:计划数
即使当前数据不大,映射也让关联关系表达得更清楚。这个模式同样适用于:
- 收藏关联食谱;
- 购物条目关联来源计划;
- 库存关联食材目录;
- 统计页关联多个业务实体。
六、缺失关联记录如何处理
计划可能引用已删除的食谱。当前实现跳过该条:
if (recipe === undefined) {
return;
}
但产品还应决定是否展示提示。可扩展统计结果:
export interface NutritionSummary {
kcal: number;
proteinG: number;
fatG: number;
carbsG: number;
entryCount: number;
missingRecipeCount: number;
}
当 missingRecipeCount > 0 时,页面显示“有 2 条计划缺少食谱数据”。静默跳过虽然不会崩溃,但可能让用户误以为统计完整。
七、处理非法数值
JSON 可能来自旧版本或外部导入。聚合前应确保值可用:
function safeNumber(value: number): number {
if (!Number.isFinite(value) || value < 0) {
return 0;
}
return value;
}
累加时:
sum.kcal += safeNumber(value.kcal);
sum.proteinG += safeNumber(value.proteinG);
营养值为负数通常没有业务意义。对于极端大值,可以增加上限校验并提示用户修正食谱。
八、今日与本周切换
范围切换是一个小型异步状态机:
private async onRangeChange(key: RangeKey): Promise<void> {
if (this.range === key || this.loading) {
return;
}
this.range = key;
await this.refresh();
}
加载期间禁用重复点击可以减少并发请求。虽然数据来自本地,但连续点击仍可能让较早请求后返回并覆盖新状态。
更完整的实现可以使用请求序号:
private requestId: number = 0;
private async refresh(): Promise<void> {
const id = ++this.requestId;
const result = await this.loadSummary();
if (id !== this.requestId) {
return;
}
this.summary = result;
}
九、Canvas 绘制环形图
页面创建绘图上下文:
private settings: RenderingContextSettings =
new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.settings);
环形图由三段圆弧组成。先计算克数总和:
private gramTotal(): number {
return this.summary.proteinG
+ this.summary.fatG
+ this.summary.carbsG;
}
然后将每个指标转换成比例:
const segments: number[] = [
this.summary.proteinG / total,
this.summary.fatG / total,
this.summary.carbsG / total
];
十、完整绘制过程
private draw(): void {
const centerX: number = 90;
const centerY: number = 90;
const radius: number = 70;
const lineWidth: number = 18;
const total: number = this.gramTotal();
this.ctx.clearRect(0, 0, 180, 180);
if (total === 0) {
this.ctx.beginPath();
this.ctx.lineWidth = lineWidth;
this.ctx.strokeStyle = this.isDark
? '#2D2722'
: '#E5DDD0';
this.ctx.arc(
centerX,
centerY,
radius,
0,
Math.PI * 2
);
this.ctx.stroke();
return;
}
const segments: number[] = [
this.summary.proteinG / total,
this.summary.fatG / total,
this.summary.carbsG / total
];
const colors: string[] = [
this.proteinColor,
this.fatColor,
this.carbsColor
];
let start: number = -Math.PI / 2;
for (let index = 0; index < segments.length; index++) {
const span: number =
segments[index] * Math.PI * 2;
if (span <= 0) {
continue;
}
this.ctx.beginPath();
this.ctx.lineWidth = lineWidth;
this.ctx.strokeStyle = colors[index];
this.ctx.arc(
centerX,
centerY,
radius,
start,
start + span
);
this.ctx.stroke();
start += span;
}
}
从 -Math.PI / 2 开始,图表第一段位于正上方,更符合常见统计图习惯。
十一、为什么每段都 beginPath
如果省略 beginPath(),多个圆弧可能保留在同一路径中,后续 stroke() 会重复绘制之前的部分,导致颜色覆盖异常。
每个独立图形单元遵循:
beginPath
设置样式
构建路径
stroke 或 fill
Canvas 是立即模式绘图。状态变化不会自动重建之前的像素,必须主动清空并重画。
十二、Canvas 何时绘制
至少有三个触发点:
Canvas(this.ctx)
.width(180)
.height(180)
.onReady(() => {
this.draw();
})
数据加载完成:
finally {
this.loading = false;
this.draw();
}
主题变化:
listener.on('change', result => {
this.isDark = result.matches;
this.draw();
});
如果只在 onReady 绘制,异步数据回来后图表仍是空环;如果只在数据变化时绘制,Canvas 尚未准备好时调用可能无效。两处都保留更稳妥。
十三、深色模式监听
页面通过媒体查询监听系统模式:
private darkListener:
mediaQuery.MediaQueryListener | null = null;
async aboutToAppear() {
const query =
mediaQuery.matchMediaSync('(dark-mode: true)');
this.isDark = query.matches;
this.darkListener = query;
query.on('change', result => {
this.isDark = result.matches;
this.draw();
});
await this.refresh();
}
aboutToDisappear() {
if (this.darkListener !== null) {
this.darkListener.off('change');
}
}
生命周期结束时取消监听,避免页面销毁后仍收到回调。
普通 ArkUI 组件可通过资源目录自动切换颜色;Canvas 使用的是绘图上下文,需要拿到实际颜色并显式重绘,这是两者的重要区别。
十四、图表颜色与可访问性
浅色和深色背景需要不同的图表色:
const PROTEIN_LIGHT = '#15803D';
const FAT_LIGHT = '#B45309';
const CARBS_LIGHT = '#1D4ED8';
const PROTEIN_DARK = '#4ADE80';
const FAT_DARK = '#FBBF24';
const CARBS_DARK = '#60A5FA';
图例必须同时显示名称、克数和百分比,不能只依赖颜色:
蛋白质 62g 31%
脂肪 48g 24%
碳水 90g 45%
即使某些颜色难以分辨,文字仍能传达完整信息。
十五、百分比的舍入误差
简单计算:
private percent(value: number): number {
const total = this.gramTotal();
if (total === 0) {
return 0;
}
return Math.round((value / total) * 100);
}
三个四舍五入结果可能得到 99% 或 101%。如果产品要求总和严格等于 100%,可以用最大余数法:
- 先计算未舍入百分比;
- 对每项向下取整;
- 把剩余百分点依次分给小数部分最大的项目。
营养概览通常允许轻微舍入误差,但应在需求中明确。
十六、热量与三大营养素不是同一分母
环形图使用蛋白质、脂肪和碳水的克数比例,中心显示 kcal。不要把 kcal 与克数直接放在同一个占比计算中,因为单位不同。
也不要简单使用:
protein + fat + carbs = 总重量
食物还包含水分、纤维和其他成分。这里的环形图表达的是三大营养素内部比例,不是食物总重量构成。
十七、页面状态设计
营养页至少需要:
@State range: RangeKey = RangeKey.Today;
@State summary: NutritionSummary =
emptyNutritionSummary();
@State loading: boolean = true;
@State loadError: string = '';
@State isDark: boolean = false;
当前代码用空环表示没有计划。更友好的设计是同时显示说明:
今日暂无营养数据
请先在用餐计划中添加食谱,并为食谱补充营养信息
无数据、数据为零和加载失败需要区分:
- 没有计划:引导添加计划;
- 有计划但营养为零:引导补充食谱营养;
- 读取失败:展示重试。
十八、性能优化方向
当前聚合在每次范围切换时读取计划和食谱。小型离线应用足够使用。如果数据增加,可以考虑:
- Service 已有内存缓存;
- 按日期建立计划索引;
- Recipe 更新后只重算受影响日期;
- 缓存每日统计快照;
- 页面不可见时不重绘;
- Canvas 尺寸固定,避免布局抖动。
不要在没有性能数据时提前引入复杂缓存。先保证口径正确和刷新可靠。
十九、测试清单
- 今日无计划时返回全零;
- 一条计划正确累加一份营养;
- 同一食谱两条计划累加两次;
- 计划引用已删除食谱时不崩溃;
- 营养缺失字段归一化为零;
- 今日与本周切换结果不同;
- 总克数为零时绘制空环;
- 某一营养素为零时跳过该段;
- 深色模式切换后颜色立即更新;
- 离开页面后监听器被移除;
- 快速切换范围不会被旧请求覆盖;
- 图例文字与颜色对应一致。
二十、总结
营养分析页把多个独立能力串成一条完整链路:
日期范围
↓
用餐计划查询
↓
食谱映射
↓
营养聚合
↓
ArkUI 状态
↓
Canvas 绘制
↓
深色模式重绘
其中最重要的不是圆弧算法,而是统计口径、缺失关联处理和状态刷新。只有数据含义可靠,图表才真正有价值。
常见问题
1. 为什么不用第三方图表库?
当前只有一个固定环形图,Canvas 足够轻量。图表类型增多、需要坐标轴和交互时,再评估成熟图表库。
2. 为什么图表不直接使用主题资源对象?
Canvas 的 strokeStyle 需要可绘制颜色值,且像素不会随资源自动重建,因此主题变化时仍要显式重绘。
3. 营养数据应该保存计算结果吗?
当前数据量小,实时聚合更不易失效。数据量大或统计复杂时,可以缓存快照,但必须建立明确的失效机制。
4. entryCount 表示人数吗?
当前口径下每条计划是一人份,所以它近似表示份数。若增加份数字段,应该同时统计计划条数和总份数。
更多推荐



所有评论(0)