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 聚合两类数据

实现步骤:

  1. 查询日期范围内的计划;
  2. 读取全部食谱;
  3. 构建 recipeId -> Recipe 映射;
  4. 遍历计划并累加。
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%,可以用最大余数法:

  1. 先计算未舍入百分比;
  2. 对每项向下取整;
  3. 把剩余百分点依次分给小数部分最大的项目。

营养概览通常允许轻微舍入误差,但应在需求中明确。

十六、热量与三大营养素不是同一分母

环形图使用蛋白质、脂肪和碳水的克数比例,中心显示 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 尺寸固定,避免布局抖动。

不要在没有性能数据时提前引入复杂缓存。先保证口径正确和刷新可靠。

十九、测试清单

  1. 今日无计划时返回全零;
  2. 一条计划正确累加一份营养;
  3. 同一食谱两条计划累加两次;
  4. 计划引用已删除食谱时不崩溃;
  5. 营养缺失字段归一化为零;
  6. 今日与本周切换结果不同;
  7. 总克数为零时绘制空环;
  8. 某一营养素为零时跳过该段;
  9. 深色模式切换后颜色立即更新;
  10. 离开页面后监听器被移除;
  11. 快速切换范围不会被旧请求覆盖;
  12. 图例文字与颜色对应一致。

二十、总结

营养分析页把多个独立能力串成一条完整链路:

日期范围
  ↓
用餐计划查询
  ↓
食谱映射
  ↓
营养聚合
  ↓
ArkUI 状态
  ↓
Canvas 绘制
  ↓
深色模式重绘

其中最重要的不是圆弧算法,而是统计口径、缺失关联处理和状态刷新。只有数据含义可靠,图表才真正有价值。

常见问题

1. 为什么不用第三方图表库?

当前只有一个固定环形图,Canvas 足够轻量。图表类型增多、需要坐标轴和交互时,再评估成熟图表库。

2. 为什么图表不直接使用主题资源对象?

Canvas 的 strokeStyle 需要可绘制颜色值,且像素不会随资源自动重建,因此主题变化时仍要显式重绘。

3. 营养数据应该保存计算结果吗?

当前数据量小,实时聚合更不易失效。数据量大或统计复杂时,可以缓存快照,但必须建立明确的失效机制。

4. entryCount 表示人数吗?

当前口径下每条计划是一人份,所以它近似表示份数。若增加份数字段,应该同时统计计划条数和总份数。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐