食材保鲜手册·ArkTS·鸿蒙:保鲜周期与常温保存的双数据集驱动渲染
本文基于 HarmonyOS Next API 24+ 与 ArkTS 2.x,深入「食材保鲜手册」应用的双数据集驱动渲染机制。我们将从 MAINTENANCE_DATA 与 PARTS_DATA 双数据集的接口设计、getFiltered 过滤逻辑、getMaxDays 与 getNonFreezableCount 概览统计、ForEach 与 @Builder 的数据绑定、卡片内字段渲染的字段语义化、不耐冷冻警示的条件分支渲染六个维度,讲透 ArkTS 数据驱动 UI 的核心机制,以及在保鲜周期与常温保存两个异构 Tab 间的渲染取舍。


一、双数据集的接口设计
1.1 MaintenanceItem 与 PartItem 的异构接口
「食材保鲜手册」的核心数据建模是两个异构接口:
interface MaintenanceItem {
name: string // 食材名
category: string // 分类(冷藏/冷冻/常温/蔬果)
fridgeDays: number // 冷藏天数
freezerDays: number // 冷冻天数(0 表示不耐冷冻)
note: string // 保鲜建议
}
interface PartItem {
name: string
category: string
pantryDays: number // 常温天数
dryDays: number // 干燥天数
replaceTip: string // 保存提示
}
两个接口的字段完全异构——MaintenanceItem 有 fridgeDays/freezerDays/note,PartItem 有 pantryDays/dryDays/replaceTip。这种异构是领域语义的直接体现——保鲜周期与常温保存是两种不同的保存策略,对应的食材属性也不同。
异构接口的设计取舍:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 异构接口(当前) | 字段语义贴合领域、类型独立 | 两个 ForEach 渲染两个 Builder |
| 同构接口(合并) | 一个 ForEach 通渲染 | 字段冗余(保鲜周期无 pantryDays) |
本应用选异构接口,理由是"字段语义贴合领域比代码统一更重要"。如果合并成:
interface FoodItem {
name: string
category: string
fridgeDays?: number // 可选
freezerDays?: number // 可选
pantryDays?: number // 可选
dryDays?: number // 可选
note?: string
replaceTip?: string
}
所有字段变可选,ArkTS 的可选字段访问要 item.fridgeDays ?? 0 或 item.fridgeDays!——类型断言负担重,且语义混乱(保鲜周期食材的 pantryDays 是什么?)。异构接口让每个 Tab 的数据类型独立清晰。
1.2 静态数据集的 const 定义
const MAINTENANCE_DATA: MaintenanceItem[] = [
{ name: '绿叶蔬菜(生菜、菠菜)', category: '冷藏', fridgeDays: 5, freezerDays: 180,
note: '清洗后沥干,密封冷藏可延长至一周' },
// ... 20 条
]
const PARTS_DATA: PartItem[] = [
{ name: '蜂蜜', category: '常温', pantryDays: 1095, dryDays: 1095,
replaceTip: '可长期常温保存,结晶属正常现象' },
// ... 20 条
]
数据集用 const + 数组字面量定义,类型标注 MaintenanceItem[] 和 PartItem[]。ArkTS 的 const 声明编译期常量,编译后是单例常量数据,没有运行时构造开销。
ArkTS 对数组字面量会做结构化类型校验——每条数据 { name: ..., category: ..., ... } 必须严格匹配接口字段名和类型。如果某条数据漏写一个字段、多写一个字段、字段类型不符(如 fridgeDays: '5' 字符串而非 number),编译期报错。
这种编译期校验是 ArkTS 相对 JavaScript 的核心增益——JS 里接口是鸭子类型,漏改字段运行时才暴露为 undefined,调试成本高;ArkTS 编译期就卡住,数据集正确性有保障。
1.3 数据集大小与内存占用
20 条 × 5 字段 = 100 个字段值的常量数据,每条数据估算:
name:约 10-20 字节(UTF-8 中文)category:约 6-12 字节- 4 个
number:每个 8 字节(双精度)= 32 字节 note/replaceTip:约 30-60 字节
单条估算 100-130 字节,20 条约 2-2.6 KB。两个数据集合计约 4-5 KB——对鸿蒙应用毫无内存压力,常驻内存零开销。
二、getFiltered 过滤逻辑
2.1 分类过滤的两种实现
private getFilteredMaintenance(): MaintenanceItem[] {
if (this.activeCategory === '全部') {
return MAINTENANCE_DATA
}
return MAINTENANCE_DATA.filter((item: MaintenanceItem) => item.category === this.activeCategory)
}
private getFilteredParts(): PartItem[] {
if (this.activeCategory === '全部') {
return PARTS_DATA
}
return PARTS_DATA.filter((item: PartItem) => item.category === this.activeCategory)
}
两个过滤方法的逻辑完全相同——分类等于选中分类则保留,"全部"则不过滤返回全集。差异只在数据集类型(MaintenanceItem[] vs PartItem[])。
'全部' 的特殊处理是"元选项"的工程惯用法——把"显示全部"作为分类筛选的第一项,与其他分类平级显示,但语义上是"清空筛选"的快捷操作。在过滤方法里 '全部' 直接返回原数组,不执行 filter——这是性能优化,避免"全选时还要遍历 20 条做无意义的过滤"。
2.2 filter 的回调签名
MAINTENANCE_DATA.filter((item: MaintenanceItem) => item.category === this.activeCategory)
Array.filter 的回调签名是 (item: T, index: number, array: T[]) => boolean,返回 true 则保留该元素。本应用只用了第一个参数 item,类型标注 MaintenanceItem 是强类型约束。
ArkTS 对回调参数的类型标注比 JavaScript 更严格——JS 里 item 是 any 类型,访问 item.category 运行时才知道是否存在;ArkTS 要求显式标注 item: MaintenanceItem,编译期校验 item.category 字段存在且类型是 string。
2.3 过滤的响应式链条
getFilteredMaintenance() 的返回值依赖 this.activeCategory,activeCategory 是 @State。变化链条:
- 用户点分类 chip →
this.activeCategory = cat @State activeCategory值变 → 鸿蒙响应式系统标记依赖该状态的 UI 需更新- 依赖
activeCategory的渲染点:分类筛选 chip 的选中态、概览统计的"当前筛选"显示、ForEach 的数据源 - ForEach 的数据源
this.getFilteredMaintenance()重算 → 返回新数组 - ForEach 检测数据源变化 → 差量渲染(移除消失的卡片、新增出现的卡片)
这个链条是 ArkUI 响应式系统的核心——开发者声明"UI 依赖什么状态",状态变化时系统自动算出"哪些 UI 需要更新",不需要手动调用命令式更新。
2.4 过滤的性能特性
filter 的复杂度是 O(n),n 是数组长度。本应用 n=20,过滤开销可忽略——20 次 string 比较约几微秒。
但 getFilteredMaintenance() 在 build() 里被多次调用:
- 顶部标题的项目计数 chip(
${this.getFilteredMaintenance().length}项) - 内容列表的 ForEach 数据源
- 概览统计的 getMaxDays 内部调用
每次调用都重新过滤——理论上 3 次调用 = 3 次 O(20) 过滤 = 60 次比较,仍可忽略。但更严谨的做法是用 @State 缓存过滤结果:
@State filteredMaintenance: MaintenanceItem[] = []
private updateFiltered() {
this.filteredMaintenance = this.activeCategory === '全部' ?
MAINTENANCE_DATA : MAINTENANCE_DATA.filter(...)
}
activeCategory 变化时调用 updateFiltered,build() 里访问 this.filteredMaintenance 而非每次重算。本应用没用这个优化——20 条数据的过滤开销不值得引入额外状态管理复杂度。这是"性能 vs �洁"的取舍,本应用选简洁。
三、getMaxDays 与 getNonFreezableCount 概览统计
3.1 getMaxDays 的双 Tab 分支
private getMaxDays(): number {
if (this.currentTab === 0) {
const list = this.getFilteredMaintenance()
const maxFridge = list.length > 0 ? Math.max(...list.map((i: MaintenanceItem) => i.fridgeDays)) : 0
const maxFreezer = list.length > 0 ? Math.max(...list.map((i: MaintenanceItem) => i.freezerDays)) : 0
return Math.max(maxFridge, maxFreezer)
} else {
const list = this.getFilteredParts()
const maxPantry = list.length > 0 ? Math.max(...list.map((i: PartItem) => i.pantryDays)) : 0
const maxDry = list.length > 0 ? Math.max(...list.map((i: PartItem) => i.dryDays)) : 0
return Math.max(maxPantry, maxDry)
}
}
getMaxDays 返回当前视图下最长保鲜/保存天数,用于概览统计栏的"最长保鲜"显示。它依赖两个 @State——currentTab(决定走哪个分支)和 activeCategory(通过 getFiltered 影响数据范围)。
Math.max(...list.map(...)) 的逻辑:
list.map((i) => i.fridgeDays)把数据列表转成天数数字数组Math.max(...arr)用展开运算符把数组元素作为参数传给 Math.max,返回最大值list.length > 0 ? Math.max(...) : 0防御空列表(如分类筛选无匹配数据时)
防御空列表的缘由是 Math.max() 无参返回 -Infinity,而非 0。如果分类筛选下无数据(如未来加新分类但数据集没对应分类),空列表导致 Math.max(...[]) 返回 -Infinity,UI 显示"最长保鲜 -Infinity 天"就荒谬了。防御后空列表返回 0,UI 显示"最长保鲜 0 天"——仍不算合理但至少不报错。
更严谨的做法是概览统计在空列表时显示"无数据"而非"0 天",但本应用当前所有分类都有数据,防御足下限。
3.2 getNonFreezableCount 的领域语义
private getNonFreezableCount(): number {
return MAINTENANCE_DATA.filter((i: MaintenanceItem) => i.freezerDays === 0).length
}
getNonFreezableCount 返回不耐冷冻的食材数量——freezerDays === 0 是不耐冷冻的领域信号。这个方法只在 Tab 0(保鲜周期)有意义,常温保存 Tab 没有"不耐冷冻"概念。
注意此方法过滤的数据源是 MAINTENANCE_DATA(全集)而非 getFilteredMaintenance()(过滤后)——所以概览统计的"不耐冷冻"显示的是全集的不耐冷冻数量,而非当前筛选下的数量。
这是个设计取舍:
| 方案 | 语义 | 用户预期 |
|---|---|---|
| 全集计数(当前) | “全数据集有多少不耐冷冻” | 用户切换分类时数字恒定,无反馈 |
| 过滤后计数 | “当前筛选下有多少不耐冷冻” | 用户切换分类时数字变化,反馈强 |
当前选全集计数的理由是"不耐冷冻是全局警示信息"——用户需要知道"全数据集有 N 项不耐冷冻",无论当前筛选什么。这就像"库存总预警数"应该显示全库存而非当前货架。
但代价是用户切到"蔬果"分类时仍看到全集的不耐冷冻数(实际蔬果可能没有不耐冷冻的),数字与当前视图脱节。这是"全局警示 vs 局部反馈"的取舍,本应用选全局警示。
3.3 概览统计栏的渲染
Row({ space: 10 }) {
Column() {
Text('最长保鲜')
.fontSize(10)
.fontColor($r('app.color.text_hint'))
Text(`${this.getMaxDays()} 天`)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.primary'))
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor($r('app.color.card_bg'))
.borderRadius(10)
.borderWidth(1)
.borderColor($r('app.color.card_border'))
.layoutWeight(1)
if (this.currentTab === 0) {
Column() {
Text('不耐冷冻')
.fontSize(10)
.fontColor($r('app.color.text_hint'))
Text(`${this.getNonFreezableCount()} 项`)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.tag_replace_text'))
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor($r('app.color.card_bg'))
.borderRadius(10)
.borderWidth(1)
.borderColor($r('app.color.card_border'))
.layoutWeight(1)
}
Column() {
Text('当前筛选')
.fontSize(10)
.fontColor($r('app.color.text_hint'))
Text(this.activeCategory)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor($r('app.color.card_bg'))
.borderRadius(10)
.borderWidth(1)
.borderColor($r('app.color.card_border'))
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 6 })
.backgroundColor($r('app.color.page_bg'))
概览统计栏用 Row({ space: 10 }) 横向排列三个统计卡片——最长保鲜、不耐冷冻、当前筛选。每个卡片用 Column 垂直堆叠"标签 + 数值",.layoutWeight(1) 等分宽度。
if (this.currentTab === 0) 控制是否渲染"不耐冷冻"卡片——Tab 1(常温保存)没有不耐冷冻概念,不渲染。这个 if 让 Tab 1 只有两个统计卡片(最长保鲜 + 当前筛选),Tab 0 有三个。layoutWeight(1) 在两种 Tab 下都等分,所以 Tab 1 的两个卡片各占 50%,Tab 0 的三个各占 33%——自适应布局。
3.4 统计栏的视觉编码
三个统计卡片的视觉编码:
| 卡片 | 数值字色 | 语义 |
|---|---|---|
| 最长保鲜 | primary(绿) |
正向——保鲜时长是好指标 |
| 不耐冷冻 | tag_replace_text(红) |
负向——不耐冷冻是警示 |
| 当前筛选 | text_primary(深) |
中性——只是状态显示 |
字色编码让用户一眼区分"正向/负向/中性"——绿色数字让人放松(保鲜时长足)、红色数字让人警觉(不耐冷冻要小心)、深色让人平视(当前筛选只是信息)。这是"颜色承载语义"的可视化设计。
四、ForEach 与 @Builder 的数据绑定
4.1 ForEach 的数据源绑定
if (this.currentTab === 0) {
ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => {
this.MaintenanceCard(item)
})
} else {
ForEach(this.getFilteredParts(), (item: PartItem) => {
this.PartCard(item)
})
}
ForEach 的数据源是 getFilteredMaintenance() 的返回值——一个 MaintenanceItem[] 数组。ForEach 遍历数组,对每个元素调用渲染回调 (item) => this.MaintenanceCard(item)。
item 的类型由数据源推断——MaintenanceItem[] 的元素是 MaintenanceItem,所以回调参数 item: MaintenanceItem。ArkTS 的类型系统会校验回调内对 item 的字段访问是否合法——访问 item.fridgeDays 合法(接口定义有)、访问 item.pantryDays 编译失败(接口定义无)。
这种强类型绑定是 ArkTS 对 ForEach 的保护——如果数据源类型变了(如 MaintenanceItem 改字段),所有 ForEach 内对旧字段的访问都会编译失败,迁移时不漏改。
4.2 @Builder 的参数化渲染
@Builder
MaintenanceCard(item: MaintenanceItem) {
Column() {
// ... 卡片布局,访问 item.name、item.category、item.fridgeDays 等
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(12)
.borderWidth(1)
.borderColor($r('app.color.card_border'))
}
@Builder 是 ArkUI 的 UI 复用单元——参数化渲染函数。MaintenanceCard(item: MaintenanceItem) 接收一个 MaintenanceItem 实例,渲染对应卡片。
ForEach 内 this.MaintenanceCard(item) 调用 Builder——每个数据项渲染一个独立卡片。20 条数据 = 20 次 Builder 调用 = 20 张卡片节点。
Builder 内访问 item.fridgeDays 等字段是强类型——ArkTS 编译期校验 item 是 MaintenanceItem 类型,访问的字段必须存在于接口定义。如果接口改字段名(如 fridgeDays → fridge_duration),Builder 内所有 item.fridgeDays 访问都会编译失败,迁移时不漏改。
4.3 @Builder vs 渲染函数的区别
ArkUI 的 @Builder 相对独立渲染函数有几个特殊行为:
- 必须在 struct 内——@Builder 是 struct 的方法,不能脱离 struct 独立定义
- 调用用 this.xxx——
this.MaintenanceCard(item)而非直接MaintenanceCard(item) - 共享 @State——Builder 内访问
this.xxx的 @State 直接响应,不需要 @Prop/@Link 传参 - UI 节点返回——Builder 内的 UI 节点(Column、Text 等)被直接渲染,不需要 return
这些特殊行为让 @Builder 既是"渲染函数"又是"struct 方法"——既参数化渲染又共享状态。这是 ArkUI 对 UI 复用的特定设计——介于"纯函数组件"和"struct 方法"之间。
4.4 数据绑定的响应式更新
ForEach 的数据源是 this.getFilteredMaintenance() 的返回值。当 activeCategory 变化时:
getFilteredMaintenance()重算 → 返回新数组- ForEach 检测数据源变化 → 差量渲染
- 差量渲染内部:对比新旧数组的元素差异,移除消失的卡片节点、新增出现的卡片节点、保留的卡片节点更新数据绑定
差量渲染的性能优势是"只更新差异部分"——分类从"全部"切到"冷藏"时,20 条数据中 5 条冷藏保留、15 条非冷藏移除,5 条冷藏的卡片节点不销毁不重建,只调整位置。
差量渲染依赖元素的稳定标识——ForEach 默认用数组索引作为标识,索引变化时元素被视为"新"。如果数据顺序变化(如排序),所有元素都会被当成"新"导致全重建。本应用数据集是静态 const,顺序固定,无此问题。
更严谨的做法是给 ForEach 显式 key:
ForEach(this.getFilteredMaintenance(), (item) => { ... }, (item) => item.name)
第三个参数是 key 生成器——用 item.name 作为元素稳定标识,数据顺序变化时 ForEach 能正确匹配旧节点。本应用数据集静态无排序变化,默认索引标识足用。
五、卡片内字段渲染的字段语义化
5.1 MaintenanceCard 的字段渲染
@Builder
MaintenanceCard(item: MaintenanceItem) {
Column() {
Row() {
Column()
.width(4)
.height(40)
.backgroundColor(CATEGORY_COLORS[item.category])
.borderRadius(2)
.margin({ right: 8 })
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(item.category)
.fontSize(11)
.fontColor(CATEGORY_COLORS[item.category])
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
Column() {
Text(`${item.fridgeDays} 天`)
.fontSize(13)
.fontColor($r('app.color.primary'))
.fontWeight(FontWeight.Medium)
if (item.freezerDays === 0) {
Text('不耐冷冻')
.fontSize(10)
.fontColor($r('app.color.tag_replace_text'))
.backgroundColor($r('app.color.tag_replace_bg'))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.margin({ top: 2 })
} else {
Text(`冷冻 ${item.freezerDays} 天`)
.fontSize(11)
.fontColor($r('app.color.text_hint'))
.margin({ top: 2 })
}
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
// 进度条行 + 底部备注行省略
}
// ...
}
卡片的字段渲染分四个层次:
- 色条(左侧 4×40vp):
backgroundColor(CATEGORY_COLORS[item.category])—— 分类色锚点 - 主信息(左段 Column):
item.name(食材名)+item.category(分类名)—— 标题区 - 数值信息(右段 Column):
${item.fridgeDays} 天(冷藏天数)+ 冷冻天数或不耐冷冻警示 —— 数值区 - 进度条(下方 Row):
getBarWidth(item.fridgeDays, 60)和getBarWidth(item.freezerDays, 365)—— 可视化区
字段渲染的语义化体现在——每个字段对应一个具体的视觉元素,字段值直接驱动 UI:
| 字段 | UI 体现 | 视觉位置 |
|---|---|---|
name |
主标题文字 | 左段 |
category |
副标签文字 + 色条颜色 | 左段 + 色条 |
fridgeDays |
主数值文字 + 冷藏进度条长度 | 右段 + 进度条 |
freezerDays |
副数值文字或不耐冷冻警示 + 冷冻进度条长度 | 右段 + 进度条 |
note |
备注文字 | 底部备注行 |
这种"字段 → UI 元素"的直接映射是数据驱动 UI 的核心——开发者读代码能立即看出每个字段对应什么 UI,调试时改字段值立即看到 UI 变化。
5.2 PartCard 的字段渲染差异
@Builder
PartCard(item: PartItem) {
Column() {
Row() {
Column()
.width(4)
.height(40)
.backgroundColor(CATEGORY_COLORS[item.category])
// ...
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(item.category)
.fontSize(11)
.fontColor(CATEGORY_COLORS[item.category])
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
Blank()
Column() {
Text(`${item.pantryDays} 天`)
.fontSize(13)
.fontColor($r('app.color.primary'))
.fontWeight(FontWeight.Medium)
Text(`干燥 ${item.dryDays} 天`)
.fontSize(11)
.fontColor($r('app.color.text_hint'))
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
// 进度条行 + 底部提示行省略
}
// ...
}
PartCard 与 MaintenanceCard 的字段渲染差异:
| 维度 | MaintenanceCard | PartCard |
|---|---|---|
| 主数值字段 | fridgeDays(冷藏天数) |
pantryDays(常温天数) |
| 副数值字段 | freezerDays(冷冻天数) |
dryDays(干燥天数) |
| 副数值特殊态 | freezerDays === 0 显示"不耐冷冻"警示 |
无特殊态(常温干燥天数都≥0) |
| 备注字段 | note(保鲜建议) |
replaceTip(保存提示) |
| 进度条 max 值 | 60(冷藏)/ 365(冷冻) | 1095(常温)/ 1095(干燥) |
| 弹性占位 | 无(色条+左段+右段挤左) | Blank(色条+左段+Blank+右段两端对齐) |
PartCard 的右段没有"不耐干燥"警示——常温干燥天数都是 ≥ 0 的正数,没有"0 表示不耐"的领域信号。所以 PartCard 的副数值渲染是恒定的 Text(干燥 ${item.dryDays} 天),无条件分支。
这个差异让两个 Builder 的复杂度不同——MaintenanceCard 有 if/else 分支(freezerDays 是否为 0),PartCard 无分支。PartCard 更简单但少了"不耐干燥"的领域警示——常温保存食材确实没有"不耐干燥"的概念(所有食材都能干燥保存,只是时长不同)。
5.3 进度条 max 值的领域依据
// MaintenanceCard 进度条
.width(this.getBarWidth(item.fridgeDays, 60)) // 冷藏 max=60
.width(this.getBarWidth(item.freezerDays, 365)) // 冷冻 max=365
// PartCard 进度条
.width(this.getBarWidth(item.pantryDays, 1095)) // 常温 max=1095
.width(this.getBarWidth(item.dryDays, 1095)) // 干燥 max=1095
进度条的 max 值选择依据领域语义:
| 进度条 | max 值 | 领域依据 |
|---|---|---|
| 冷藏 | 60 天 | 冷藏室食材最长约 60 天(剩菜 3、鲜奶 7、鸡蛋 35) |
| 冷冻 | 365 天 | 冷冻室食材最长约 1 年(西兰花焯水 365、肉类 180-270) |
| 常温 | 1095 天 | 常温食材最长约 3 年(蜂蜜、白糖、食盐理论上永久) |
| 干燥 | 1095 天 | 干燥环境延长保存,上限与常温相同 |
max 值影响进度条长度的视觉对比——同 30 天食材:
- 冷藏条 max=60:30/60 = 50%,半条
- 冷冻条 max=365:30/365 = 8%,短条
- 常温条 max=1095:30/1095 = 3%,极短条
视觉对比符合领域直觉——30 天在冷藏是"中等保鲜"(半条)、在冷冻是"短保鲜"(短条)、在常温是"极短保存"(极短条)。同数值在不同 max 下视觉权重不同,反映领域语义。
六、不耐冷冻警示的条件分支渲染
6.1 freezerDays === 0 的领域信号
if (item.freezerDays === 0) {
Text('不耐冷冻')
.fontSize(10)
.fontColor($r('app.color.tag_replace_text'))
.backgroundColor($r('app.color.tag_replace_bg'))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.margin({ top: 2 })
} else {
Text(`冷冻 ${item.freezerDays} 天`)
.fontSize(11)
.fontColor($r('app.color.text_hint'))
.margin({ top: 2 })
}
freezerDays === 0 是不耐冷冻的领域信号——0 不表示"冷冻 0 天"的字面意义,而表示"不建议冷冻"的领域语义。这个 0 值在 UI 层触发两个不同渲染分支:
| freezerDays 值 | 渲染分支 | UI 体现 |
|---|---|---|
| 0 | 不耐冷冻警示 | 红字红底胶囊标签 |
| > 0 | 冷冻天数文字 | 灰字"冷冻 N 天" |
这种"值驱动分支"是数据驱动 UI 的典型体现——同一个字段(freezerDays)的值(0 vs 非 0)决定 UI 的不同渲染,UI 不需要额外的"是否耐冷冻"布尔字段,0 值就承载了这个语义。
6.2 警示标签的视觉编码
不耐冷冻警示的视觉编码:
| 属性 | 值 | 语义 |
|---|---|---|
| 字色 | tag_replace_text(红 #C8423A) |
警示色 |
| 背景色 | tag_replace_bg(粉红 #F8E1E4) |
警示背景 |
| 字号 | 10vp | 小——警示但不抢注意力 |
| padding | 6×2 + 2×2 | 紧凑胶囊 |
| borderRadius | 4vp | 圆角胶囊 |
警示用"红字红底胶囊"是 UI 警示的惯用法——红色承载警示语义,胶囊形态让警示标签独立于普通文字。字号 10vp 比副数值 11vp 略小——警示存在但不抢主数值的注意力。
对比"不耐冷冻"警示与普通"冷冻 N 天"文字:
| 元素 | 字色 | 背景色 | 字号 | padding | borderRadius |
|---|---|---|---|---|---|
| 不耐冷冻警示 | 红 | 粉红 | 10 | 6+6,2+2 | 4 |
| 冷冻 N 天文字 | 灰 | 无 | 11 | 无 | 无 |
警示是"胶囊标签"形态(有背景有 padding 有圆角),普通文字是"裸文字"形态(无背景无 padding 无圆角)。形态差异让警示在视觉上独立突出,用户一眼能看出"这张卡片有警示"。
6.3 条件渲染的 ArkUI 支持
ArkUI 允许在 Builder 内用 if/else 控制渲染分支——这是声明式 UI 的核心特性。if 的条件变化时,整个分支重建或销毁:
freezerDays === 0:渲染警示分支,销毁文字分支freezerDays > 0:渲染文字分支,销毁警示分支
条件渲染的特点是"完全销毁/重建"——切到文字分支时,警示分支的所有节点销毁(释放内存);切到警示分支时,文字分支销毁。这种彻底的切换适合本应用——警示与文字是异构 UI,不存在"复用 DOM 节点"的场景。
对比"visible 控制"方案——警示和文字都渲染,用 .visibility(Visibility.None/Visible) 控制显隐:
Text('不耐冷冻')
.visibility(item.freezerDays === 0 ? Visibility.Visible : Visibility.None)
Text(`冷冻 ${item.freezerDays} 天`)
.visibility(item.freezerDays === 0 ? Visibility.None : Visibility.Visible)
visible 控制不销毁节点,只隐藏——两个节点都常驻内存,但只显示一个。对本应用这种"二选一"的简单分支,if/else 更省内存;visible 控制适合"频繁切换"的场景(避免反复销毁重建的开销)。
6.4 多处条件渲染的领域一致性
freezerDays === 0 在应用里触发三个独立的条件渲染:
- 卡片右段的警示标签:
if (item.freezerDays === 0)显示"不耐冷冻"胶囊 - 概览统计的不耐冷冻计数:
getNonFreezableCount用.filter((i) => i.freezerDays === 0).length统计数量 - 冷冻进度条的长度:
getBarWidth(0, 365)返回'0%',进度条前景层宽度 0 不显示
三个条件渲染都依赖 freezerDays === 0 的同一领域信号——领域逻辑一致。如果未来要改"不耐冷冻"的判定逻辑(如 freezerDays <= 7 也算"短冷冻"),三处都要同步改——这是"领域逻辑分散"的代价。
更严谨的做法是把判定逻辑封装成方法:
private isFreezable(item: MaintenanceItem): boolean {
return item.freezerDays > 0
}
三处都调用 isFreezable(item) 而非直接 item.freezerDays === 0。改逻辑时只改一处。本应用当前直接用 === 0 是"简单 vs 集中"的取舍——20 条数据下三处同步改成本可控,引入方法反而增加间接层。
七、双数据集驱动渲染的工程哲学
7.1 数据驱动 UI 的核心闭环
本应用的双数据集驱动渲染形成完整闭环:
@State activeCategory / currentTab (状态)
↓ 触发
getFilteredMaintenance / getFilteredParts (过滤)
↓ 返回新数组
ForEach (数据源变化)
↓ 差量渲染
MaintenanceCard / PartCard (Builder 调用)
↓ 字段访问
item.name / item.fridgeDays / ... (字段渲染)
↓ UI 节点
ArkUI 渲染引擎 (绘制)
这个闭环的核心是"状态 → 数据 → UI"的单向数据流——状态变化驱动数据变化,数据变化驱动 UI 变化,反向不存在(UI 不能直接改数据,除非通过事件回调改 @State)。这种单向数据流是 ArkUI 响应式系统的核心设计,让 UI 与数据始终保持同步。
7.2 Builder 的复用与异构
MaintenanceCard 和 PartCard 是两个独立 Builder,对应两个异构接口。这种"一个接口一个 Builder"的设计取舍:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 异构 Builder(当前) | 字段访问贴合接口、类型清晰 | 两个 Builder 重复骨架代码 |
| 同构 Builder(合并) | 骨架复用、代码少 | 字段访问要可选类型断言、语义混乱 |
本应用选异构 Builder——两个 Builder 的骨架(色条 + 标题 + 数值 + 进度条 + 备注)确实重复约 70% 代码,但剩余 30%(字段名、警示分支、进度条 max 值)的领域差异让合并 Builder 反而要加大量条件分支,可读性下降。
权衡的临界点是"骨架重复度 vs 字段差异度"——如果两个 Builder 的骨架重复 90%+ 且字段差异 10% 以下,合并 Builder 收益高;本应用骨架重复 70% 字段差异 30%,合并 Builder 收益不足,保留异构更清晰。
7.3 静态数据集 vs 动态数据源
本应用的数据集是 const 静态常量,编译期固定。这是"工具型应用"的典型形态——数据是预编译的领域知识,运行时不变化。
对比"动态数据源"应用(如网络请求的数据列表):
| 方案 | 数据来源 | 本应用使用 |
|---|---|---|
| 静态 const | 编译期常量 | MAINTENANCE_DATA、PARTS_DATA |
| 远程请求 | 网络 API | 未用 |
| 本地存储 | Preferences、文件 | 未用 |
静态 const 的优势是"零运行时开销 + 编译期类型校验"——数据集正确性在编译期就卡住,运行时无 IO 开销。劣势是"数据更新要重新编译发版"——如果保鲜常识有新研究要更新数据,必须改代码重新构建。
对于"保鲜常识"这种领域知识相对稳定的数据,静态 const 足够——保鲜常识不会天天变。但如果应用要支持"用户自定义食材保鲜数据",就要改成动态数据源(本地 Preferences 存用户数据),这是未来可扩展方向。
八、本篇小结
本章从双数据集接口设计讲到不耐冷冻警示的条件分支渲染,完整覆盖了「食材保鲜手册」ArkTS 数据驱动 UI 的工程实现。关键点归纳:
- 异构接口双数据集:MaintenanceItem vs PartItem 字段异构,贴合领域语义比代码统一更重要
- getFiltered 元选项处理:“全部” 直接返回原数组避免无意义遍历,O(n) 过滤开销可忽略
- 概览统计的双 Tab 分支:getMaxDays 按 currentTab 走两个分支,getNonFreezableCount 全集计数
- ForEach 差量渲染:数据源变化触发差量更新,移除消失卡片新增出现卡片保留卡片调整位置
- @Builder 参数化渲染:强类型校验字段访问,ForEach 内调用 20 次渲染 20 张卡片
- 字段 → UI 元素直接映射:name→标题、category→副标签+色条、fridgeDays→数值+进度条
- freezerDays === 0 领域信号:值驱动 UI 分支,警示胶囊 vs 普通文字两种渲染
- 进度条 max 值领域依据:冷藏 60、冷冻 365、常温/干燥 1095,视觉对比符合领域直觉
九、附录:本篇涉及的数据驱动 API
| API | 用途 | 本篇体现 |
|---|---|---|
interface |
接口定义 | MaintenanceItem、PartItem |
const |
编译期常量 | MAINTENANCE_DATA、PARTS_DATA |
Array.filter |
数组过滤 | getFiltered 系列 |
Array.map |
数组映射 | getMaxDays 内提取字段数组 |
Math.max(...arr) |
求最大值 | getMaxDays 内 |
@State |
响应式状态 | currentTab、activeCategory |
@Builder |
UI 复用单元 | MaintenanceCard、PartCard |
ForEach(arr, cb) |
数据驱动渲染 | 卡片列表 |
if (cond) |
条件渲染 | freezerDays === 0 警示分支 |
$r('app.color.xxx') |
资源引用 | 字色、背景色 |
CATEGORY_COLORS[key] |
映射查表 | 分类色 |
getBarWidth(value, max) |
进度条百分比 | 字段值 → 视觉长度 |
十、附录:本篇涉及的字段渲染映射表
10.1 MaintenanceItem 字段渲染
| 字段 | UI 体现 | 视觉位置 | 字段语义 |
|---|---|---|---|
name |
主标题文字 | 左段标题区 | 食材名 |
category |
副标签文字 + 色条颜色 | 左段标题区 + 色条 | 分类 |
fridgeDays |
主数值文字 + 冷藏进度条长度 | 右段数值区 + 进度条 | 冷藏天数 |
freezerDays |
副数值文字或不耐冷冻警示 + 冷冻进度条长度 | 右段数值区 + 进度条 | 冷冻天数(0=不耐冷冻) |
note |
备注文字 | 底部备注行 | 保鲜建议 |
10.2 PartItem 字段渲染
| 字段 | UI 体现 | 视觉位置 | 字段语义 |
|---|---|---|---|
name |
主标题文字 | 左段标题区 | 食材名 |
category |
副标签文字 + 色条颜色 | 左段标题区 + 色条 | 分类 |
pantryDays |
主数值文字 + 常温进度条长度 | 右段数值区 + 进度条 | 常温天数 |
dryDays |
副数值文字 + 干燥进度条长度 | 右段数值区 + 进度条 | 干燥天数 |
replaceTip |
提示文字 | 底部提示行 | 保存提示 |
至此完整覆盖了「食材保鲜手册」ArkTS 数据驱动 UI 的工程实现。下篇将从厨房暖色调资源体系与分类色锚点设计继续展开。
更多推荐




所有评论(0)