HarmonyOS厨房助手实战第7篇营养聚合、Canvas环形图与深色模式摘要本文继续实现 HarmonyOS 厨房助手的营养分析页面。数据来源不是手工填写的一张统计表而是“用餐计划 食谱营养信息”的实时聚合结果。页面支持今日与本周切换使用 ArkUI Canvas 绘制蛋白质、脂肪和碳水化合物环形图并在深色模式变化时重新绘制。文章重点覆盖如何连接 MealPlan 与 Recipe 两类数据如何用 Map 避免重复查找聚合口径怎样定义才不产生误导Canvas 为什么需要显式重绘零数据、缺失食谱和非法营养值如何处理图表颜色怎样兼顾深色模式与可访问性。一、先明确统计口径营养图表最危险的问题不是代码错误而是口径不清。厨房助手当前定义每条 MealPlanEntry 代表食谱的一人份 统计区间内的条目逐条累加对应食谱营养因此一份食谱在同一天安排两次会被统计两次。这个规则简单但必须在产品中保持一致。如果模型以后增加servings计算应调整为条目营养 食谱单人份营养 × 条目份数如果食谱记录的是整道菜总营养还需要除以食谱默认份数。先定义口径再写聚合代码才能避免“数字看起来正确含义却错误”。二、营养数据模型食谱中保存四个基础指标exportinterfaceRecipeNutrition{kcal:number;proteinG:number;fatG:number;carbsG:number;}聚合结果额外包含参与统计的计划条数exportinterfaceNutritionSummary{kcal:number;proteinG:number;fatG:number;carbsG:number;entryCount:number;}exportfunctionemptyNutritionSummary():NutritionSummary{return{kcal:0,proteinG:0,fatG:0,carbsG:0,entryCount:0};}使用工厂函数生成空对象比复用一个可变全局对象更安全。三、从日期范围查询计划页面只决定统计“今天”还是“本周”enumRangeKey{Todaytoday,Weekweek}privateasyncrefresh():Promisevoid{this.loadingtrue;try{constctxgetContext(this)ascommon.UIAbilityContext;constdates:string[]this.rangeRangeKey.Week?DateUtil.weekOf(DateUtil.today()):[DateUtil.today()];this.summaryawaitNutritionService.ensure().summarize(ctx,dates);}finally{this.loadingfalse;this.draw();}}页面不知道计划文件怎样存储也不关心食谱怎样查询。聚合责任放在NutritionService中。四、Service 聚合两类数据实现步骤查询日期范围内的计划读取全部食谱构建recipeId - Recipe映射遍历计划并累加。asyncsummarize(context:common.UIAbilityContext,dates:string[]):PromiseNutritionSummary{constmealServiceMealPlanService.ensure(context);constrecipeServiceRecipeService.ensure(context);constentries:MealPlanEntry[]awaitmealService.listByDateRange(dates);if(entries.length0){returnemptyNutritionSummary();}constrecipes:Recipe[]awaitrecipeService.list();constrecipeMap:Mapstring,RecipenewMapstring,Recipe();recipes.forEach((recipe:Recipe){recipeMap.set(recipe.id,recipe);});constsum:NutritionSummaryemptyNutritionSummary();entries.forEach((entry:MealPlanEntry){constrecipe:Recipe|undefinedrecipeMap.get(entry.recipeId);if(recipeundefined){return;}constvalue:RecipeNutritionrecipe.nutrition;sum.kcalvalue.kcal;sum.proteinGvalue.proteinG;sum.fatGvalue.fatG;sum.carbsGvalue.carbsG;sum.entryCount1;});returnsum;}五、为什么先构建 Map如果每条计划都调用recipes.find()时间复杂度接近计划数 × 食谱数构建 Map 后构建映射食谱数 查询聚合计划数即使当前数据不大映射也让关联关系表达得更清楚。这个模式同样适用于收藏关联食谱购物条目关联来源计划库存关联食材目录统计页关联多个业务实体。六、缺失关联记录如何处理计划可能引用已删除的食谱。当前实现跳过该条if(recipeundefined){return;}但产品还应决定是否展示提示。可扩展统计结果exportinterfaceNutritionSummary{kcal:number;proteinG:number;fatG:number;carbsG:number;entryCount:number;missingRecipeCount:number;}当missingRecipeCount 0时页面显示“有 2 条计划缺少食谱数据”。静默跳过虽然不会崩溃但可能让用户误以为统计完整。七、处理非法数值JSON 可能来自旧版本或外部导入。聚合前应确保值可用functionsafeNumber(value:number):number{if(!Number.isFinite(value)||value0){return0;}returnvalue;}累加时sum.kcalsafeNumber(value.kcal);sum.proteinGsafeNumber(value.proteinG);营养值为负数通常没有业务意义。对于极端大值可以增加上限校验并提示用户修正食谱。八、今日与本周切换范围切换是一个小型异步状态机privateasynconRangeChange(key:RangeKey):Promisevoid{if(this.rangekey||this.loading){return;}this.rangekey;awaitthis.refresh();}加载期间禁用重复点击可以减少并发请求。虽然数据来自本地但连续点击仍可能让较早请求后返回并覆盖新状态。更完整的实现可以使用请求序号privaterequestId:number0;privateasyncrefresh():Promisevoid{constidthis.requestId;constresultawaitthis.loadSummary();if(id!this.requestId){return;}this.summaryresult;}九、Canvas 绘制环形图页面创建绘图上下文privatesettings:RenderingContextSettingsnewRenderingContextSettings(true);privatectx:CanvasRenderingContext2DnewCanvasRenderingContext2D(this.settings);环形图由三段圆弧组成。先计算克数总和privategramTotal():number{returnthis.summary.proteinGthis.summary.fatGthis.summary.carbsG;}然后将每个指标转换成比例constsegments:number[][this.summary.proteinG/total,this.summary.fatG/total,this.summary.carbsG/total];十、完整绘制过程privatedraw():void{constcenterX:number90;constcenterY:number90;constradius:number70;constlineWidth:number18;consttotal:numberthis.gramTotal();this.ctx.clearRect(0,0,180,180);if(total0){this.ctx.beginPath();this.ctx.lineWidthlineWidth;this.ctx.strokeStylethis.isDark?#2D2722:#E5DDD0;this.ctx.arc(centerX,centerY,radius,0,Math.PI*2);this.ctx.stroke();return;}constsegments:number[][this.summary.proteinG/total,this.summary.fatG/total,this.summary.carbsG/total];constcolors:string[][this.proteinColor,this.fatColor,this.carbsColor];letstart:number-Math.PI/2;for(letindex0;indexsegments.length;index){constspan:numbersegments[index]*Math.PI*2;if(span0){continue;}this.ctx.beginPath();this.ctx.lineWidthlineWidth;this.ctx.strokeStylecolors[index];this.ctx.arc(centerX,centerY,radius,start,startspan);this.ctx.stroke();startspan;}}从-Math.PI / 2开始图表第一段位于正上方更符合常见统计图习惯。十一、为什么每段都 beginPath如果省略beginPath()多个圆弧可能保留在同一路径中后续stroke()会重复绘制之前的部分导致颜色覆盖异常。每个独立图形单元遵循beginPath 设置样式 构建路径 stroke 或 fillCanvas 是立即模式绘图。状态变化不会自动重建之前的像素必须主动清空并重画。十二、Canvas 何时绘制至少有三个触发点Canvas(this.ctx).width(180).height(180).onReady((){this.draw();})数据加载完成finally{this.loadingfalse;this.draw();}主题变化listener.on(change,result{this.isDarkresult.matches;this.draw();});如果只在onReady绘制异步数据回来后图表仍是空环如果只在数据变化时绘制Canvas 尚未准备好时调用可能无效。两处都保留更稳妥。十三、深色模式监听页面通过媒体查询监听系统模式privatedarkListener:mediaQuery.MediaQueryListener|nullnull;asyncaboutToAppear(){constquerymediaQuery.matchMediaSync((dark-mode: true));this.isDarkquery.matches;this.darkListenerquery;query.on(change,result{this.isDarkresult.matches;this.draw();});awaitthis.refresh();}aboutToDisappear(){if(this.darkListener!null){this.darkListener.off(change);}}生命周期结束时取消监听避免页面销毁后仍收到回调。普通 ArkUI 组件可通过资源目录自动切换颜色Canvas 使用的是绘图上下文需要拿到实际颜色并显式重绘这是两者的重要区别。十四、图表颜色与可访问性浅色和深色背景需要不同的图表色constPROTEIN_LIGHT#15803D;constFAT_LIGHT#B45309;constCARBS_LIGHT#1D4ED8;constPROTEIN_DARK#4ADE80;constFAT_DARK#FBBF24;constCARBS_DARK#60A5FA;图例必须同时显示名称、克数和百分比不能只依赖颜色蛋白质 62g 31% 脂肪 48g 24% 碳水 90g 45%即使某些颜色难以分辨文字仍能传达完整信息。十五、百分比的舍入误差简单计算privatepercent(value:number):number{consttotalthis.gramTotal();if(total0){return0;}returnMath.round((value/total)*100);}三个四舍五入结果可能得到 99% 或 101%。如果产品要求总和严格等于 100%可以用最大余数法先计算未舍入百分比对每项向下取整把剩余百分点依次分给小数部分最大的项目。营养概览通常允许轻微舍入误差但应在需求中明确。十六、热量与三大营养素不是同一分母环形图使用蛋白质、脂肪和碳水的克数比例中心显示 kcal。不要把 kcal 与克数直接放在同一个占比计算中因为单位不同。也不要简单使用protein fat carbs 总重量食物还包含水分、纤维和其他成分。这里的环形图表达的是三大营养素内部比例不是食物总重量构成。十七、页面状态设计营养页至少需要Staterange:RangeKeyRangeKey.Today;Statesummary:NutritionSummaryemptyNutritionSummary();Stateloading:booleantrue;StateloadError:string;StateisDark:booleanfalse;当前代码用空环表示没有计划。更友好的设计是同时显示说明今日暂无营养数据 请先在用餐计划中添加食谱并为食谱补充营养信息无数据、数据为零和加载失败需要区分没有计划引导添加计划有计划但营养为零引导补充食谱营养读取失败展示重试。十八、性能优化方向当前聚合在每次范围切换时读取计划和食谱。小型离线应用足够使用。如果数据增加可以考虑Service 已有内存缓存按日期建立计划索引Recipe 更新后只重算受影响日期缓存每日统计快照页面不可见时不重绘Canvas 尺寸固定避免布局抖动。不要在没有性能数据时提前引入复杂缓存。先保证口径正确和刷新可靠。十九、测试清单今日无计划时返回全零一条计划正确累加一份营养同一食谱两条计划累加两次计划引用已删除食谱时不崩溃营养缺失字段归一化为零今日与本周切换结果不同总克数为零时绘制空环某一营养素为零时跳过该段深色模式切换后颜色立即更新离开页面后监听器被移除快速切换范围不会被旧请求覆盖图例文字与颜色对应一致。二十、总结营养分析页把多个独立能力串成一条完整链路日期范围 ↓ 用餐计划查询 ↓ 食谱映射 ↓ 营养聚合 ↓ ArkUI 状态 ↓ Canvas 绘制 ↓ 深色模式重绘其中最重要的不是圆弧算法而是统计口径、缺失关联处理和状态刷新。只有数据含义可靠图表才真正有价值。常见问题1. 为什么不用第三方图表库当前只有一个固定环形图Canvas 足够轻量。图表类型增多、需要坐标轴和交互时再评估成熟图表库。2. 为什么图表不直接使用主题资源对象Canvas 的strokeStyle需要可绘制颜色值且像素不会随资源自动重建因此主题变化时仍要显式重绘。3. 营养数据应该保存计算结果吗当前数据量小实时聚合更不易失效。数据量大或统计复杂时可以缓存快照但必须建立明确的失效机制。4. entryCount 表示人数吗当前口径下每条计划是一人份所以它近似表示份数。若增加份数字段应该同时统计计划条数和总份数。
HarmonyOS厨房助手实战第7篇:营养聚合、Canvas环形图与深色模式
HarmonyOS厨房助手实战第7篇营养聚合、Canvas环形图与深色模式摘要本文继续实现 HarmonyOS 厨房助手的营养分析页面。数据来源不是手工填写的一张统计表而是“用餐计划 食谱营养信息”的实时聚合结果。页面支持今日与本周切换使用 ArkUI Canvas 绘制蛋白质、脂肪和碳水化合物环形图并在深色模式变化时重新绘制。文章重点覆盖如何连接 MealPlan 与 Recipe 两类数据如何用 Map 避免重复查找聚合口径怎样定义才不产生误导Canvas 为什么需要显式重绘零数据、缺失食谱和非法营养值如何处理图表颜色怎样兼顾深色模式与可访问性。一、先明确统计口径营养图表最危险的问题不是代码错误而是口径不清。厨房助手当前定义每条 MealPlanEntry 代表食谱的一人份 统计区间内的条目逐条累加对应食谱营养因此一份食谱在同一天安排两次会被统计两次。这个规则简单但必须在产品中保持一致。如果模型以后增加servings计算应调整为条目营养 食谱单人份营养 × 条目份数如果食谱记录的是整道菜总营养还需要除以食谱默认份数。先定义口径再写聚合代码才能避免“数字看起来正确含义却错误”。二、营养数据模型食谱中保存四个基础指标exportinterfaceRecipeNutrition{kcal:number;proteinG:number;fatG:number;carbsG:number;}聚合结果额外包含参与统计的计划条数exportinterfaceNutritionSummary{kcal:number;proteinG:number;fatG:number;carbsG:number;entryCount:number;}exportfunctionemptyNutritionSummary():NutritionSummary{return{kcal:0,proteinG:0,fatG:0,carbsG:0,entryCount:0};}使用工厂函数生成空对象比复用一个可变全局对象更安全。三、从日期范围查询计划页面只决定统计“今天”还是“本周”enumRangeKey{Todaytoday,Weekweek}privateasyncrefresh():Promisevoid{this.loadingtrue;try{constctxgetContext(this)ascommon.UIAbilityContext;constdates:string[]this.rangeRangeKey.Week?DateUtil.weekOf(DateUtil.today()):[DateUtil.today()];this.summaryawaitNutritionService.ensure().summarize(ctx,dates);}finally{this.loadingfalse;this.draw();}}页面不知道计划文件怎样存储也不关心食谱怎样查询。聚合责任放在NutritionService中。四、Service 聚合两类数据实现步骤查询日期范围内的计划读取全部食谱构建recipeId - Recipe映射遍历计划并累加。asyncsummarize(context:common.UIAbilityContext,dates:string[]):PromiseNutritionSummary{constmealServiceMealPlanService.ensure(context);constrecipeServiceRecipeService.ensure(context);constentries:MealPlanEntry[]awaitmealService.listByDateRange(dates);if(entries.length0){returnemptyNutritionSummary();}constrecipes:Recipe[]awaitrecipeService.list();constrecipeMap:Mapstring,RecipenewMapstring,Recipe();recipes.forEach((recipe:Recipe){recipeMap.set(recipe.id,recipe);});constsum:NutritionSummaryemptyNutritionSummary();entries.forEach((entry:MealPlanEntry){constrecipe:Recipe|undefinedrecipeMap.get(entry.recipeId);if(recipeundefined){return;}constvalue:RecipeNutritionrecipe.nutrition;sum.kcalvalue.kcal;sum.proteinGvalue.proteinG;sum.fatGvalue.fatG;sum.carbsGvalue.carbsG;sum.entryCount1;});returnsum;}五、为什么先构建 Map如果每条计划都调用recipes.find()时间复杂度接近计划数 × 食谱数构建 Map 后构建映射食谱数 查询聚合计划数即使当前数据不大映射也让关联关系表达得更清楚。这个模式同样适用于收藏关联食谱购物条目关联来源计划库存关联食材目录统计页关联多个业务实体。六、缺失关联记录如何处理计划可能引用已删除的食谱。当前实现跳过该条if(recipeundefined){return;}但产品还应决定是否展示提示。可扩展统计结果exportinterfaceNutritionSummary{kcal:number;proteinG:number;fatG:number;carbsG:number;entryCount:number;missingRecipeCount:number;}当missingRecipeCount 0时页面显示“有 2 条计划缺少食谱数据”。静默跳过虽然不会崩溃但可能让用户误以为统计完整。七、处理非法数值JSON 可能来自旧版本或外部导入。聚合前应确保值可用functionsafeNumber(value:number):number{if(!Number.isFinite(value)||value0){return0;}returnvalue;}累加时sum.kcalsafeNumber(value.kcal);sum.proteinGsafeNumber(value.proteinG);营养值为负数通常没有业务意义。对于极端大值可以增加上限校验并提示用户修正食谱。八、今日与本周切换范围切换是一个小型异步状态机privateasynconRangeChange(key:RangeKey):Promisevoid{if(this.rangekey||this.loading){return;}this.rangekey;awaitthis.refresh();}加载期间禁用重复点击可以减少并发请求。虽然数据来自本地但连续点击仍可能让较早请求后返回并覆盖新状态。更完整的实现可以使用请求序号privaterequestId:number0;privateasyncrefresh():Promisevoid{constidthis.requestId;constresultawaitthis.loadSummary();if(id!this.requestId){return;}this.summaryresult;}九、Canvas 绘制环形图页面创建绘图上下文privatesettings:RenderingContextSettingsnewRenderingContextSettings(true);privatectx:CanvasRenderingContext2DnewCanvasRenderingContext2D(this.settings);环形图由三段圆弧组成。先计算克数总和privategramTotal():number{returnthis.summary.proteinGthis.summary.fatGthis.summary.carbsG;}然后将每个指标转换成比例constsegments:number[][this.summary.proteinG/total,this.summary.fatG/total,this.summary.carbsG/total];十、完整绘制过程privatedraw():void{constcenterX:number90;constcenterY:number90;constradius:number70;constlineWidth:number18;consttotal:numberthis.gramTotal();this.ctx.clearRect(0,0,180,180);if(total0){this.ctx.beginPath();this.ctx.lineWidthlineWidth;this.ctx.strokeStylethis.isDark?#2D2722:#E5DDD0;this.ctx.arc(centerX,centerY,radius,0,Math.PI*2);this.ctx.stroke();return;}constsegments:number[][this.summary.proteinG/total,this.summary.fatG/total,this.summary.carbsG/total];constcolors:string[][this.proteinColor,this.fatColor,this.carbsColor];letstart:number-Math.PI/2;for(letindex0;indexsegments.length;index){constspan:numbersegments[index]*Math.PI*2;if(span0){continue;}this.ctx.beginPath();this.ctx.lineWidthlineWidth;this.ctx.strokeStylecolors[index];this.ctx.arc(centerX,centerY,radius,start,startspan);this.ctx.stroke();startspan;}}从-Math.PI / 2开始图表第一段位于正上方更符合常见统计图习惯。十一、为什么每段都 beginPath如果省略beginPath()多个圆弧可能保留在同一路径中后续stroke()会重复绘制之前的部分导致颜色覆盖异常。每个独立图形单元遵循beginPath 设置样式 构建路径 stroke 或 fillCanvas 是立即模式绘图。状态变化不会自动重建之前的像素必须主动清空并重画。十二、Canvas 何时绘制至少有三个触发点Canvas(this.ctx).width(180).height(180).onReady((){this.draw();})数据加载完成finally{this.loadingfalse;this.draw();}主题变化listener.on(change,result{this.isDarkresult.matches;this.draw();});如果只在onReady绘制异步数据回来后图表仍是空环如果只在数据变化时绘制Canvas 尚未准备好时调用可能无效。两处都保留更稳妥。十三、深色模式监听页面通过媒体查询监听系统模式privatedarkListener:mediaQuery.MediaQueryListener|nullnull;asyncaboutToAppear(){constquerymediaQuery.matchMediaSync((dark-mode: true));this.isDarkquery.matches;this.darkListenerquery;query.on(change,result{this.isDarkresult.matches;this.draw();});awaitthis.refresh();}aboutToDisappear(){if(this.darkListener!null){this.darkListener.off(change);}}生命周期结束时取消监听避免页面销毁后仍收到回调。普通 ArkUI 组件可通过资源目录自动切换颜色Canvas 使用的是绘图上下文需要拿到实际颜色并显式重绘这是两者的重要区别。十四、图表颜色与可访问性浅色和深色背景需要不同的图表色constPROTEIN_LIGHT#15803D;constFAT_LIGHT#B45309;constCARBS_LIGHT#1D4ED8;constPROTEIN_DARK#4ADE80;constFAT_DARK#FBBF24;constCARBS_DARK#60A5FA;图例必须同时显示名称、克数和百分比不能只依赖颜色蛋白质 62g 31% 脂肪 48g 24% 碳水 90g 45%即使某些颜色难以分辨文字仍能传达完整信息。十五、百分比的舍入误差简单计算privatepercent(value:number):number{consttotalthis.gramTotal();if(total0){return0;}returnMath.round((value/total)*100);}三个四舍五入结果可能得到 99% 或 101%。如果产品要求总和严格等于 100%可以用最大余数法先计算未舍入百分比对每项向下取整把剩余百分点依次分给小数部分最大的项目。营养概览通常允许轻微舍入误差但应在需求中明确。十六、热量与三大营养素不是同一分母环形图使用蛋白质、脂肪和碳水的克数比例中心显示 kcal。不要把 kcal 与克数直接放在同一个占比计算中因为单位不同。也不要简单使用protein fat carbs 总重量食物还包含水分、纤维和其他成分。这里的环形图表达的是三大营养素内部比例不是食物总重量构成。十七、页面状态设计营养页至少需要Staterange:RangeKeyRangeKey.Today;Statesummary:NutritionSummaryemptyNutritionSummary();Stateloading:booleantrue;StateloadError:string;StateisDark:booleanfalse;当前代码用空环表示没有计划。更友好的设计是同时显示说明今日暂无营养数据 请先在用餐计划中添加食谱并为食谱补充营养信息无数据、数据为零和加载失败需要区分没有计划引导添加计划有计划但营养为零引导补充食谱营养读取失败展示重试。十八、性能优化方向当前聚合在每次范围切换时读取计划和食谱。小型离线应用足够使用。如果数据增加可以考虑Service 已有内存缓存按日期建立计划索引Recipe 更新后只重算受影响日期缓存每日统计快照页面不可见时不重绘Canvas 尺寸固定避免布局抖动。不要在没有性能数据时提前引入复杂缓存。先保证口径正确和刷新可靠。十九、测试清单今日无计划时返回全零一条计划正确累加一份营养同一食谱两条计划累加两次计划引用已删除食谱时不崩溃营养缺失字段归一化为零今日与本周切换结果不同总克数为零时绘制空环某一营养素为零时跳过该段深色模式切换后颜色立即更新离开页面后监听器被移除快速切换范围不会被旧请求覆盖图例文字与颜色对应一致。二十、总结营养分析页把多个独立能力串成一条完整链路日期范围 ↓ 用餐计划查询 ↓ 食谱映射 ↓ 营养聚合 ↓ ArkUI 状态 ↓ Canvas 绘制 ↓ 深色模式重绘其中最重要的不是圆弧算法而是统计口径、缺失关联处理和状态刷新。只有数据含义可靠图表才真正有价值。常见问题1. 为什么不用第三方图表库当前只有一个固定环形图Canvas 足够轻量。图表类型增多、需要坐标轴和交互时再评估成熟图表库。2. 为什么图表不直接使用主题资源对象Canvas 的strokeStyle需要可绘制颜色值且像素不会随资源自动重建因此主题变化时仍要显式重绘。3. 营养数据应该保存计算结果吗当前数据量小实时聚合更不易失效。数据量大或统计复杂时可以缓存快照但必须建立明确的失效机制。4. entryCount 表示人数吗当前口径下每条计划是一人份所以它近似表示份数。若增加份数字段应该同时统计计划条数和总份数。