【健康菜谱助手|07】HarmonyOS ArkTS 营养页实战:用可读指标展示热量与营养结构
营养页很容易做成四条彩色进度条:热量、蛋白质、脂肪、碳水各算一个平均值,再把结果除以固定目标。这样的界面看起来完整,但只要追问三个问题,数据口径就可能站不住:页面里的“本周”从哪一天开始?同一道菜做了两次是否应该算两餐?600 kcal 与 25 g 蛋白质究竟是健康建议、产品参考线,还是某个用户的个性化目标?
本文基于“健康菜谱助手”1.0.5 的真实 HarmonyOS ArkTS 工程展开。NutritionPage.ets通过@StorageLink读取cookedRecords和favoriteRecords,关联RECIPE_LIST后计算四类营养均值,同时展示菜品热量排行和四条营养提示。代码入口清楚,视觉层次也已经形成,但“本周已做”的时间过滤、重复烹饪次数、空态语义、目标来源与计算边界仍需要补齐。
营养页的可信度不取决于进度条有多漂亮,而取决于数据集、统计窗口、份量口径和目标来源能否被明确解释并重复计算。
HEALTHY-RECIPE-ONE7-NUTRITION-20260730
本文将完成以下工作:
- 还原
NutritionPage、UserDataManager、RecipeModel和RECIPE_LIST的真实链路。 - 解释为什么当前“本周已做”实际统计的是历史去重集合。
- 设计带时间范围、样本数和目标来源的
NutritionViewData。 - 把平均值、进度比例、排序与空态变成可单测的纯函数。
- 处理无效记录、重复菜谱、未来时间、周起点和数值异常。
- 给出 ArkUI 渲染、性能、可访问性与测试验收方案。
一、真实页面目前展示了什么
NutritionPage由三个主体区域组成:
- “本周已做 / 收藏均值”两个页签。
- 热量、蛋白质、脂肪、碳水四条平均营养进度。
- 菜品热量排行与四条营养小贴士。
页面状态来自两个全局键:
@StorageLink('cookedRecords') cookedRecords: CookedRecord[] = [] @StorageLink('favoriteRecords') favoriteRecords: FavoriteRecord[] = [] @State selectedTab: number = 0CookedRecord只有recipeId和cookedAt,FavoriteRecord只有recipeId和createdAt。真正的热量与宏量营养素并不保存在记录中,而是通过recipeId回查静态RECIPE_LIST。这意味着营养页展示的是菜谱模型中的估算值,不是用户实际称重、实际份量或设备采集结果。
二、四项营养指标的现有计算方式
当前getAvgNutrients()先取得菜谱数组,再累加calories、protein、fat、carbs,最后除以菜谱数量:
let totalCal = 0 let totalPro = 0 let totalFat = 0 let totalCarb = 0 for (const recipe of recipes) { totalCal += recipe.calories totalPro += recipe.protein totalFat += recipe.fat totalCarb += recipe.carbs } const count = recipes.length const avgCalories = Math.round(totalCal / count) const avgProtein = Math.round(totalPro / count * 10) / 10热量取整数,三类宏量营养素保留一位小数。四个固定目标分别为 600 kcal、25 g、20 g、75 g。进度条宽度使用Math.min(value / target * 100, 100),超过目标后只显示满格。
这段计算对“一个数组的算术平均值”是正确的,问题在于传入数组是否真的代表页面标题所说的数据集合。
三、“本周已做”当前并没有周过滤
getCookedRecipes()只按recipeId关联菜谱:
static getCookedRecipes(records: CookedRecord[]): RecipeModel[] { const result: RecipeModel[] = [] for (const record of records) { const recipe = RECIPE_LIST.find(item => item.id === record.recipeId) if (recipe !== undefined) { result.push(recipe) } } return result }这里没有读取cookedAt,自然也没有判断记录是否落在当前周。只要用户曾经做过一道菜,它就可能长期出现在所谓“本周已做”的统计里。页面标题与计算口径不一致,是营养页最需要先修复的问题。
更隐蔽的一点在addCooked():新记录写入前会过滤掉相同recipeId。因此同一道菜本周做了三次,记录中通常只保留最新一次。当前均值不是“本周每餐平均”,而更接近“历史上做过且尚未清除的不同菜谱平均”。
四、先定义产品口径,再写计算代码
营养页可以有多种合理口径,但必须选定一个:
| 视图 | 样本单位 | 时间范围 | 重复处理 |
|---|---|---|---|
| 本周每餐平均 | 一次烹饪记录 | 本周一至当前时间 | 同菜多次分别计算 |
| 本周菜谱均值 | 不同菜谱 | 本周一至当前时间 | 同菜去重 |
| 收藏菜谱均值 | 当前收藏菜谱 | 无时间限制 | 按菜谱去重 |
如果产品确实要展示“本周每餐平均”,数据层就不能在写入时删除同菜历史。更稳妥的做法是让每次烹饪都生成独立事件 ID,统计时再根据业务需要聚合。收藏天然是集合,仍可保持按recipeId去重。
本文采用“本周每餐平均”作为已做页签口径,并保留“收藏菜谱均值”作为收藏页签口径。
五、把时间从字符串升级为可比较字段
当前日期格式类似2026-07-17 19:30。它便于阅读,却没有时区信息,也不适合跨地区或夏令时场景。新记录建议保存毫秒时间戳:
export interface CookedRecordV2 { id: string recipeId: number cookedAtMs: number }id保证每一次烹饪事件都可追踪;cookedAtMs直接用于范围比较。已有字符串记录可以在仓库迁移时解析,解析失败的记录要计入诊断信息,而不是默默参与或默默丢弃。
六、明确一周的起止边界
周统计最容易在周日、月末、跨年和零点附近出错。可以把“周一 00:00”封装为纯函数,并通过传入nowMs让测试不依赖系统当前时间:
export interface TimeRange { startMs: number endMs: number } export function currentWeekRange(nowMs: number): TimeRange { const now = new Date(nowMs) const day = now.getDay() const offsetToMonday = day === 0 ? -6 : 1 - day const start = new Date( now.getFullYear(), now.getMonth(), now.getDate() + offsetToMonday, 0, 0, 0, 0 ) return { startMs: start.getTime(), endMs: nowMs } }这里采用设备本地时区,并把endMs设为当前时刻。产品如果要求自然周完整区间或服务端统一时区,应在需求中明确,不能让页面自己猜。
七、过滤本周记录时要处理未来数据
本周过滤不是只判断大于起点,还要限制不超过当前时间:
export function filterRecordsInRange( records: CookedRecordV2[], range: TimeRange ): CookedRecordV2[] { return records.filter((record: CookedRecordV2) => Number.isFinite(record.cookedAtMs) && record.cookedAtMs >= range.startMs && record.cookedAtMs <= range.endMs ) }未来时间可能来自设备时间被修改、迁移错误或测试数据。如果不限制上界,它会污染当前周结果。过滤函数不修改原数组,便于重复调用和单元测试。
八、关联菜谱时保留无效记录诊断
当前getCookedRecipes()找不到recipeId时直接跳过。用户只看到样本变少,却无法知道发生了什么。更可复核的关联结果应该同时返回有效样本和缺失 ID:
export interface RecipeJoinResult { recipes: RecipeModel[] missingRecipeIds: number[] } export function joinRecipes( records: CookedRecordV2[], recipeMap: Map<number, RecipeModel> ): RecipeJoinResult { const recipes: RecipeModel[] = [] const missingRecipeIds: number[] = [] for (const record of records) { const recipe = recipeMap.get(record.recipeId) if (recipe === undefined) { missingRecipeIds.push(record.recipeId) } else { recipes.push(recipe) } } return { recipes, missingRecipeIds } }页面不一定要直接展示缺失 ID,但调试日志或诊断状态应能说明“3 条记录中有 1 条无法关联”。
九、用 Map 避免重复线性查找
RECIPE_LIST.find()每关联一条记录都要扫描数组。当前只有 60 道菜时影响不大,但模型增长后会变成O(records × recipes)。在服务初始化时建立Map,关联复杂度更稳定:
export function buildRecipeMap(recipes: RecipeModel[]): Map<number, RecipeModel> { const map = new Map<number, RecipeModel>() for (const recipe of recipes) { map.set(recipe.id, recipe) } return map }这也让测试可以注入三五条最小样本,不必依赖整份MockData。
十、定义营养计算的输入和输出
不要让 ArkUI 页面直接维护四个累加变量。先定义纯数据契约:
export interface NutritionValue { calories: number protein: number fat: number carbs: number } export interface NutritionAverage extends NutritionValue { sampleCount: number }sampleCount不是附属信息。均值 220 kcal 来自 1 道菜还是 20 餐,可信程度和用户理解完全不同,界面应展示样本数。
十一、用纯函数计算均值
export function averageNutrition(recipes: RecipeModel[]): NutritionAverage { if (recipes.length === 0) { return { calories: 0, protein: 0, fat: 0, carbs: 0, sampleCount: 0 } } let calories = 0 let protein = 0 let fat = 0 let carbs = 0 for (const recipe of recipes) { calories += safeNonNegative(recipe.calories) protein += safeNonNegative(recipe.protein) fat += safeNonNegative(recipe.fat) carbs += safeNonNegative(recipe.carbs) } const count = recipes.length return { calories: round(calories / count, 0), protein: round(protein / count, 1), fat: round(fat / count, 1), carbs: round(carbs / count, 1), sampleCount: count } }其中safeNonNegative()把非有限值和负数归零或标记为无效,具体策略取决于数据质量要求。技术上不能让NaN继续进入进度条宽度。
十二、统一数值清洗与舍入规则
export function safeNonNegative(value: number): number { return Number.isFinite(value) && value >= 0 ? value : 0 } export function round(value: number, digits: number): number { const factor = Math.pow(10, digits) return Math.round(value * factor) / factor }热量显示整数、克数显示一位小数,是界面格式,不应散落在每个字段的表达式里。统一函数能确保测试、列表和详情页采用同一规则。
十三、目标值必须带来源
当前目标 600、25、20、75 直接写在getAvgNutrients()中。用户可能把它理解为医疗或个性化建议。更合适的数据结构是:
export interface NutritionTarget { calories: number protein: number fat: number carbs: number sourceLabel: string scopeLabel: string } const DEFAULT_REFERENCE: NutritionTarget = { calories: 600, protein: 25, fat: 20, carbs: 75, sourceLabel: '应用内每餐参考线', scopeLabel: '仅用于菜谱比较,不构成个性化健康建议' }页面标题应写“每餐参考”,而不是笼统写“推荐”。如果未来引入年龄、性别、体重、目标或疾病信息,就涉及敏感健康数据、隐私说明与专业规则,不应只在前端偷偷换一组数字。
十四、进度比例要同时限制上下界
当前只用Math.min(..., 100)限制上界。完整函数应处理目标为零、负数和非有限值:
export function ratioPercent(value: number, target: number): number { if (!Number.isFinite(value) || !Number.isFinite(target) || target <= 0) { return 0 } const percent = value / target * 100 return Math.max(0, Math.min(percent, 100)) }更重要的是,满格并不等于“达标”或“更健康”。脂肪和热量超过参考线时继续使用普通颜色,会掩盖超出状态。可以单独返回below、near、over,再由 UI 选择文案和颜色。
十五、不要用颜色独自表达状态
营养项目前依赖橙、蓝、黄、绿区分。对于色觉差异用户,颜色不能承担唯一语义。每行应同时展示:
- 指标名称与单位。
- 当前均值和参考值。
- “约为参考线的 73%”或“高于参考线 12%”的文本。
- 可读的进度条或图标。
颜色用于辅助扫描,文字负责准确解释。正文文本与背景对比度应保持可读,进度轨道也不能淡到无法识别。
十六、空数据不是四个零
当前无样本时,营养卡仍展示四个0和“推荐每餐”,排行区才显示“暂无数据”。这会让用户误以为系统计算出零营养。更合理的空态是:
export interface NutritionEmptyState { title: string description: string actionLabel: string } const COOKED_EMPTY: NutritionEmptyState = { title: '本周还没有烹饪记录', description: '完成一道菜后,这里会按每次烹饪记录计算平均营养。', actionLabel: '去选菜谱' }收藏页签则使用“先收藏感兴趣的菜谱”。两个空态的原因和下一步不同,不能共用一句“暂无数据”。
十七、构造页面需要的 ViewData
页面不应反复调用getRecipes()、getAvgNutrients()和排序函数。一次生成完整视图数据:
export interface NutrientRowViewData { key: string label: string valueText: string referenceText: string percent: number stateText: string color: string } export interface NutritionViewData { title: string scopeText: string sampleCount: number rows: NutrientRowViewData[] ranking: RecipeModel[] missingRecordCount: number empty: boolean }这样 ArkUI 只渲染,不再承担统计业务。页签切换时重新计算一次,记录数组变化时再刷新一次。
十八、本周视图的完整计算链路
export function buildWeeklyCookedView( records: CookedRecordV2[], recipeMap: Map<number, RecipeModel>, nowMs: number, target: NutritionTarget ): NutritionViewData { const range = currentWeekRange(nowMs) const weeklyRecords = filterRecordsInRange(records, range) const joined = joinRecipes(weeklyRecords, recipeMap) const average = averageNutrition(joined.recipes) return buildNutritionViewData( '本周每餐平均', formatRange(range), joined.recipes, average, target, joined.missingRecipeIds.length ) }nowMs、recipeMap和target都由外部注入,测试可以固定输入并得到确定输出。
十九、收藏视图与已做视图不能共用错误口径
收藏均值不需要周过滤,但要先对recipeId去重,避免历史迁移产生重复收藏:
export function uniqueFavoriteIds(records: FavoriteRecord[]): number[] { const ids = new Set<number>() for (const record of records) { ids.add(record.recipeId) } return Array.from(ids) }收藏视图文案应说明“按当前收藏菜谱估算”,而不是暗示用户真的吃过这些菜。相同的四个营养数值,在两个页签里代表不同含义,scopeText必须跟随切换。
二十、热量排行必须使用副本排序
当前写法是:
this.getRecipes().sort((a, b) => b.calories - a.calories)现阶段getRecipes()返回新数组,所以不会直接改写RECIPE_LIST。但这个安全性依赖调用者知道实现细节。更稳妥的排序函数明确复制,并增加相同热量的稳定规则:
export function rankByCalories(recipes: RecipeModel[]): RecipeModel[] { return [...recipes].sort((left: RecipeModel, right: RecipeModel) => { const calorieDelta = right.calories - left.calories return calorieDelta !== 0 ? calorieDelta : left.id - right.id }) }固定次级排序可以避免数据相同时列表顺序在刷新后跳动。
二十一、排行要说明比较对象
“菜品热量排行”容易被理解为全部菜谱排行。实际上它只比较当前页签的数据集。标题可以改为“本周记录热量排行”或“收藏菜谱热量排行”,并显示样本数。若只展示前五名,还需写“共 N 道,展示前 5 道”。
热量排行也不代表健康排行,不能用冠军、优胜等价值判断。它只是按模型中的calories数值降序排列。
二十二、同一道菜多次烹饪如何统计
如果本周做了两次清蒸鲈鱼和一次红烧肉,“每餐平均”应包含三个样本。记录层必须保留三个事件,关联后得到三个RecipeModel引用是允许的。若产品要“不同菜谱平均”,再在统计层按recipeId去重。
把去重放在统计层,而不是写入层,能保留原始事实并支持更多分析。原始事件一旦被覆盖,后续无法恢复真实次数。
二十三、份量口径是营养数字的前提
RecipeModel目前只有热量和三类营养数字,没有servingCount、servingGram或“每 100 克”字段。页面只能把它们视为“每份菜谱模型值”。
建议补充:
export interface NutritionBasis { servingCount: number servingGram?: number source: string updatedAt: string }在没有份量信息前,界面文案应使用“菜谱估算值”,不要写成精确摄入量。技术实现不能替产品做医学结论。
二十四、营养小贴士需要内容边界
当前四条小贴士包含蔬菜摄入、蛋白质、饮水和控盐数字。它们属于健康信息,而不是普通 UI 文案。至少应做到:
- 标注参考来源与更新时间。
- 避免“适合所有人”的绝对表达。
- 对儿童、孕妇、慢性病患者等特殊人群提示遵循专业建议。
- 不根据收藏或浏览行为推断疾病与健康状态。
如果没有可靠来源,宁可改成烹饪方法提示,例如少油烹饪、查看食材份量,也不要制造权威感。
二十五、ArkUI 页面只负责状态选择与渲染
建议将页面拆成以下边界:
| 层 | 职责 |
|---|---|
NutritionRepository | 读取记录和菜谱,执行版本迁移 |
NutritionAnalyzer | 时间过滤、关联、均值、比例与排行 |
NutritionViewModel | 根据页签生成NutritionViewData |
NutritionPage | 页签、空态、列表和导航 |
依赖方向保持为Page -> ViewModel -> Analyzer/Repository。纯计算模块不依赖 ArkUI 和AppStorage,因此可以在普通测试中验证。
二十六、减少 build 阶段的重复计算
当前页面在构建树中多次调用getRecipes(),排行条件和排行数据又分别计算。数据量小时不明显,但状态变化会触发重建。可以在记录或页签变化时更新@State viewData:
@State private viewData: NutritionViewData = EMPTY_VIEW_DATA private refreshView(): void { this.viewData = this.selectedTab === 0 ? this.viewModel.buildWeeklyCooked(this.cookedRecords, Date.now()) : this.viewModel.buildFavorites(this.favoriteRecords) }页签点击后更新selectedTab并调用refreshView()。若记录在其他页面改变,需要通过明确的生命周期或状态监听刷新,不能只依赖偶然重建。
二十七、进度条组件需要稳定尺寸
营养条应有固定高度、明确轨道和受控宽度。动态文本再长,也不能挤压到数值区:
Row() { Text(row.label) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Blank() Text(row.valueText) .maxLines(1) } .width('100%') Stack({ alignContent: Alignment.Start }) { Row().width('100%').height(8) Row().width(`${row.percent}%`).height(8) } .width('100%') .height(8)平板和 2in1 可以限制内容最大宽度,避免进度条横跨过长;小屏上要确保参考文案换行后不遮挡下一项。
二十八、列表 key 与状态身份
营养行使用label作为 key,在当前四项固定且唯一时可行。排行使用rank_${recipe.id},同一菜谱若按每餐样本重复出现,就会产生重复 key。
如果排行按菜谱聚合,recipe.id足够;如果排行按烹饪事件展示,应使用CookedRecordV2.id。key 必须对应业务实体身份,不能使用会变化的排名或数组下标。
二十九、可访问性与可读反馈
营养行可以提供组合描述,例如“蛋白质,平均每餐 18.5 克,参考线 25 克,约 74%”。页签需要清晰的选中状态,不能只靠底部绿色线。返回按钮应使用标准图标和足够触控区域,并提供可识别描述。
正文最小字号、辅助文本颜色和白底对比度需要分别检查。Sizes.SMALL_FONT为 10,若用于关键解释,应提高到更易读的字号,而不是把重要口径藏在极小文字里。
三十、错误状态不能伪装成空态
UserDataManager.init()解析失败时会回退为空数组。对首页可能是安全降级,但营养页会把存储损坏表现成“没有记录”。建议仓库返回明确状态:
export type LoadState = 'loading' | 'ready' | 'empty' | 'error' export interface NutritionLoadResult { state: LoadState viewData?: NutritionViewData message?: string }用户数据确实为空时显示引导;读取失败时显示重试和错误说明。两者不能混为一谈。
三十一、测试矩阵
| 编号 | 输入 | 预期 |
|---|---|---|
| N01 | 周一 00:00 的记录 | 计入本周 |
| N02 | 上周日 23:59 的记录 | 不计入本周 |
| N03 | 当前时间之后的记录 | 被过滤并诊断 |
| N04 | 本周同菜两次 | 每餐口径计为 2 个样本 |
| N05 | 空记录 | 显示引导空态,不显示四个伪零值 |
| N06 | 无效recipeId | 有效样本继续计算,缺失数为 1 |
| N07 | 目标为 0 | 进度为 0,不出现NaN% |
| N08 | 值高于目标 | 宽度为 100%,文本显示超出 |
| N09 | 值为负数 | 清洗或标记异常,不产生负宽度 |
| N10 | 相同热量菜谱 | 按 ID 稳定排序 |
| N11 | 重复收藏记录 | 收藏均值按菜谱去重 |
| N12 | 页签快速切换 | 标题、样本、排行同步更新 |
| N13 | 存储解析失败 | 显示错误态而非空态 |
| N14 | 320vp 小屏长菜名 | 文本不遮挡热量和蛋白质 |
| N15 | 深色模式 | 正文、进度轨道和状态色可辨认 |
| N16 | 大字体 | 指标与参考文案可换行且操作可达 |
三十二、可复核样例
准备三条菜谱:
const recipes: RecipeModel[] = [ makeRecipe(1, 'A', 300, 20, 10, 40), makeRecipe(2, 'B', 600, 30, 20, 80), makeRecipe(3, 'C', 150, 10, 5, 20) ]如果本周三次烹饪分别对应 A、B、A,按每餐平均应为:
- 热量
(300 + 600 + 300) / 3 = 400 kcal。 - 蛋白质
(20 + 30 + 20) / 3 = 23.3 g。 - 脂肪
(10 + 20 + 10) / 3 = 13.3 g。 - 碳水
(40 + 80 + 40) / 3 = 53.3 g。
如果错误地按菜谱去重,A 只算一次,结果会变为 450 kcal。这个样例可以直接揭示记录层提前去重造成的口径偏差。
三十三、验收步骤
33.1 数据口径
- 清空测试数据,分别写入上周、本周和未来记录。
- 固定
nowMs,确认页面样本数只包含本周有效事件。 - 同一道菜写入两次,确认每餐平均会计算两次。
- 切换收藏页签,确认不受本周时间范围影响。
33.2 数值与排行
- 使用可手算的三条菜谱数据核对四项均值。
- 将某项设为参考线两倍,确认条宽不溢出且文本显示超出。
- 构造相同热量菜谱,连续刷新后顺序保持一致。
- 删除一条菜谱模型,确认缺失记录不会导致页面崩溃。
33.3 界面状态
- 验证 loading、empty、error、content 四种状态。
- 检查手机小屏、平板横屏和 2in1 窗口缩放。
- 检查大字体、长菜名、深浅色模式和页签焦点状态。
- 返回列表再进入,确认统计范围和样本数没有漂移。
三十四、常见故障定位
34.1 标题写本周,旧菜仍出现
检查cookedAt是否真的参与过滤,以及旧字符串是否迁移为时间戳。只调用getCookedRecipes()不会产生周语义。
34.2 同一道菜做两次仍只算一次
检查addCooked()是否按recipeId覆盖旧记录。若产品口径是每餐,必须保留独立事件。
34.3 进度条消失或宽度异常
打印value、target和percent,检查是否存在NaN、负数或目标为零。不要把未经清洗的表达式直接拼入宽度。
34.4 排行刷新时跳动
为相同热量增加稳定次级排序,并确认ForEachkey 使用稳定实体 ID。
34.5 空态显示四个零
不要调用“空营养数组”伪造统计结果。由sampleCount === 0进入独立空态分支。
三十五、落地顺序
- 先确认“本周每餐平均”与“收藏菜谱均值”的产品口径。
- 将烹饪记录升级为带唯一事件 ID 和毫秒时间戳的模型。
- 提取周范围、过滤、关联、均值、比例和排序纯函数。
- 增加
NutritionViewData,让页面只渲染一次计算结果。 - 补空态、错误态、样本数、参考来源和范围文案。
- 执行 N01–N16 测试与多设备可读性检查。
这个顺序先修正数据事实,再优化视觉表达。否则界面越精致,错误口径传播得越可信。
三十六、源码事实与建议实现的边界
营养指标涉及健康语义,尤其需要避免把固定展示值包装成已经验证的个性化建议。当前源码事实与本文建议如下:
| 维度 | 当前源码事实 | 本文建议,不代表已经实现 |
|---|---|---|
| 页签 | 只有“本周已做”和“收藏均值”两个页签 | 为不同口径生成独立 ViewData 与范围说明 |
| 数据来源 | 通过@StorageLink读取 cookedRecords、favoriteRecords,再关联 RECIPE_LIST | 增加无效关联诊断、加载态和错误态 |
| “本周” | getCookedRecipes()没有任何周起止过滤 | 使用可注入 nowMs 和明确 weekStart/weekEnd 过滤 |
| 烹饪次数 | addCooked()会先按 recipeId 移除旧记录,同一道菜只保留最近一条 | 如果口径是“每餐”,改为保存独立烹饪事件 |
| 均值 | 对关联后的菜谱计算热量、蛋白质、脂肪和碳水算术平均 | 显示样本数、范围、份量口径与异常数据处理 |
| 固定目标 | 四个 target 分别为 600 kcal、25 g、20 g、75 g | 把目标来源和适用范围作为显式元数据 |
| 进度宽度 | 使用Math.min(value / target * 100, 100),只限制上界 | 同时限制下界并处理 target<=0、NaN、Infinity |
| 空数据 | getEmptyNutrients()返回四个 value=0 的指标;排行显示“暂无数据” | 使用独立 empty 状态,不把“无样本”解释为摄入为零 |
| 排行 | 对getRecipes()返回的关联结果按 calories 降序排序,key 使用rank_${recipe.id} | 使用副本和稳定次级排序,明确比较对象 |
| 提示内容 | 四条营养小贴士直接写在页面代码中 | 内容来源、适用人群与免责声明需要单独治理 |
| 验证状态 | 本文完成源码级复核、配图检查和文章质量检查 | 文中的周过滤、事件模型和 N01–N16 测试未被宣称已构建或通过 |
所以,NutritionViewData、时间范围过滤、事件级烹饪记录、纯函数统计和错误状态都属于建议改造。当前 1.0.5 页面实际展示的仍是去重后的已做菜谱集合或收藏集合的菜谱级均值,不能把它直接解释为用户本周真实摄入,也不能把固定 target 当作医疗或个体化营养结论。
三十七、总结
健康菜谱助手的营养页已经具备页签、四项营养均值、进度条、热量排行和提示卡片,作为产品原型是完整的。工程化升级的重点不是再加一种图表,而是让每一个数字都能回答“统计了谁、统计多久、按什么份量、与什么目标比较”。
需要守住六条原则:
- “本周”必须由明确的时间范围计算,不能只写在标题里。
- 每餐统计要保留每次烹饪事件,去重应由分析口径决定。
- 均值必须展示样本数,空数据必须使用独立空态。
- 固定目标要标注来源与适用范围,不能伪装成个性化建议。
- 比例、排序和关联都应是可测试、不修改输入的纯函数。
- ArkUI 页面负责表达状态,数据与统计规则留在服务层。
当用户能看到“本周一至今天,共 5 餐,菜谱估算平均热量 420 kcal”,并能理解这个数字的来源与限制时,营养页才真正从彩色进度条升级为可信、可复核的产品功能。
参考资料
- HarmonyOS ArkUI 概述
- HarmonyOS ArkTS 概述
- HarmonyOS 数据管理概述
- HarmonyOS 深浅色适配
- 工程源码:
NutritionPage.ets、UserDataManager.ets、RecipeModel.ets、MockData.ets、ThemeConstants.ets