当前位置: 首页 > news >正文

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{Today='today',Week='week'}privateasyncrefresh():Promise<void>{this.loading=true;try{constctx=getContext(this)ascommon.UIAbilityContext;constdates:string[]=this.range===RangeKey.Week?DateUtil.weekOf(DateUtil.today()):[DateUtil.today()];this.summary=awaitNutritionService.ensure().summarize(ctx,dates);}finally{this.loading=false;this.draw();}}

页面不知道计划文件怎样存储,也不关心食谱怎样查询。聚合责任放在NutritionService中。

四、Service 聚合两类数据

实现步骤:

  1. 查询日期范围内的计划;
  2. 读取全部食谱;
  3. 构建recipeId -> Recipe映射;
  4. 遍历计划并累加。
asyncsummarize(context:common.UIAbilityContext,dates:string[]):Promise<NutritionSummary>{constmealService=MealPlanService.ensure(context);constrecipeService=RecipeService.ensure(context);constentries:MealPlanEntry[]=awaitmealService.listByDateRange(dates);if(entries.length===0){returnemptyNutritionSummary();}constrecipes:Recipe[]=awaitrecipeService.list();constrecipeMap:Map<string,Recipe>=newMap<string,Recipe>();recipes.forEach((recipe:Recipe)=>{recipeMap.set(recipe.id,recipe);});constsum:NutritionSummary=emptyNutritionSummary();entries.forEach((entry:MealPlanEntry)=>{constrecipe:Recipe|undefined=recipeMap.get(entry.recipeId);if(recipe===undefined){return;}constvalue:RecipeNutrition=recipe.nutrition;sum.kcal+=value.kcal;sum.proteinG+=value.proteinG;sum.fatG+=value.fatG;sum.carbsG+=value.carbsG;sum.entryCount+=1;});returnsum;}

五、为什么先构建 Map

如果每条计划都调用recipes.find(),时间复杂度接近:

计划数 × 食谱数

构建 Map 后:

构建映射:食谱数 查询聚合:计划数

即使当前数据不大,映射也让关联关系表达得更清楚。这个模式同样适用于:

  • 收藏关联食谱;
  • 购物条目关联来源计划;
  • 库存关联食材目录;
  • 统计页关联多个业务实体。

六、缺失关联记录如何处理

计划可能引用已删除的食谱。当前实现跳过该条:

if(recipe===undefined){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)||value<0){return0;}returnvalue;}

累加时:

sum.kcal+=safeNumber(value.kcal);sum.proteinG+=safeNumber(value.proteinG);

营养值为负数通常没有业务意义。对于极端大值,可以增加上限校验并提示用户修正食谱。

八、今日与本周切换

范围切换是一个小型异步状态机:

privateasynconRangeChange(key:RangeKey):Promise<void>{if(this.range===key||this.loading){return;}this.range=key;awaitthis.refresh();}

加载期间禁用重复点击可以减少并发请求。虽然数据来自本地,但连续点击仍可能让较早请求后返回并覆盖新状态。

更完整的实现可以使用请求序号:

privaterequestId:number=0;privateasyncrefresh():Promise<void>{constid=++this.requestId;constresult=awaitthis.loadSummary();if(id!==this.requestId){return;}this.summary=result;}

九、Canvas 绘制环形图

页面创建绘图上下文:

privatesettings:RenderingContextSettings=newRenderingContextSettings(true);privatectx:CanvasRenderingContext2D=newCanvasRenderingContext2D(this.settings);

环形图由三段圆弧组成。先计算克数总和:

privategramTotal():number{returnthis.summary.proteinG+this.summary.fatG+this.summary.carbsG;}

然后将每个指标转换成比例:

constsegments:number[]=[this.summary.proteinG/total,this.summary.fatG/total,this.summary.carbsG/total];

十、完整绘制过程

privatedraw():void{constcenterX:number=90;constcenterY:number=90;constradius:number=70;constlineWidth:number=18;consttotal: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;}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(letindex=0;index<segments.length;index++){constspan: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 尚未准备好时调用可能无效。两处都保留更稳妥。

十三、深色模式监听

页面通过媒体查询监听系统模式:

privatedarkListener:mediaQuery.MediaQueryListener|null=null;asyncaboutToAppear(){constquery=mediaQuery.matchMediaSync('(dark-mode: true)');this.isDark=query.matches;this.darkListener=query;query.on('change',result=>{this.isDark=result.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{consttotal=this.gramTotal();if(total===0){return0;}returnMath.round((value/total)*100);}

三个四舍五入结果可能得到 99% 或 101%。如果产品要求总和严格等于 100%,可以用最大余数法:

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

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

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

环形图使用蛋白质、脂肪和碳水的克数比例,中心显示 kcal。不要把 kcal 与克数直接放在同一个占比计算中,因为单位不同。

也不要简单使用:

protein + fat + carbs = 总重量

食物还包含水分、纤维和其他成分。这里的环形图表达的是三大营养素内部比例,不是食物总重量构成。

十七、页面状态设计

营养页至少需要:

@Staterange:RangeKey=RangeKey.Today;@Statesummary:NutritionSummary=emptyNutritionSummary();@Stateloading:boolean=true;@StateloadError:string='';@StateisDark: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 表示人数吗?

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

http://www.gsyq.cn/news/1492174.html

相关文章:

  • 2026年PDF压缩教程:免费在线工具推荐与详细操作指南
  • Elastic Agent独立模式实战:手把手教你用Kibana生成配置文件,避开手动配置的坑
  • 别再为中文路径发愁了!用Overleaf在线搞定IEEE Transactions论文排版(附TPEL模板避坑点)
  • AzurLaneAutoScript:碧蓝航线全自动脚本终极指南,24小时智能挂机解放双手
  • 微信投票页面制作全攻略:零基础5分钟搞定(附免费工具实测) - 微信投票小程序
  • 遗传算法工程落地七处关键断点与实战避坑指南
  • 精选延吉6家正宗现压荞麦冷面,都是本地人认可、冰碴牛骨汤、现压现煮。 - 讲清楚了
  • C语言学生管理系统双版本:数组静态存储+链表动态管理,带完整交互菜单与文件读写
  • 私密文件共享工具怎么选?主流 4 大阵营对比与企业级避坑指南
  • 杰林码JLM音频SDK:含ARM/x86/RISC-V多架构库的C语言音频编解码工具包
  • AI入门三阶路径:从调用到构建的90天实操指南
  • GTA5线上小助手:免费开源工具,彻底改变你的洛圣都体验
  • 深度解析 PE瓶:核心特性、应用场景与优质生产厂家实践 - 速递信息
  • selenium自动化脚本基础语句
  • 2026 终极攻防变局:深度拆解 MITRE ATTCK ER8 企业安全评估路线图与微观技术实战
  • ROS2 编译与运行基本流程:colcon build、source 与 ros2 run 一文搞懂
  • 机器学习生产化:从Notebook到高可用AI系统的工程实践
  • ncmdump终极指南:快速免费解密网易云音乐NCM格式,实现跨平台音乐自由
  • 绵阳防水补漏哪家靠谱?2026 正规修缮公司排名实测 - 苏易修缮
  • 裸辞不是一时冲动!网工如何“有底气”地闪辞,并拿下薪资翻倍的Offer?
  • MuleSoft+LLM企业级AI编排:打破协议、事务与治理三重墙
  • 别再乱改配置文件了!Jenkins端口修改的正确姿势(systemd服务文件详解)
  • 证件照一键生成APP怎么选?2026年手机软件+小程序保姆级教程
  • 高红移耀变体PKS 2052−47的γ射线准周期振荡研究
  • 遗传算法实战:动态算子设计与混合编码优化指南
  • Chatbox 极简配置教程
  • Anthropic隐式状态层:LLM架构中正在归零的中间层
  • 公共卫生数据实战:从BMI清洗到因果推断的四层穿透分析
  • 别再只认升压芯片了!聊聊电荷泵驱动NMOS的那些‘坑’:效率、纹波与负载能力实测
  • 避开奸商套路!手把手教你用Thaiphoon Burner和CPU-Z,一眼看穿内存SPD信息有没有被篡改