本文基于 HarmonyOS Next API 24+,深入「车辆保养手册」应用的数据层。我们将以 MaintenanceItemPartItem 两个接口为切入点,讲透 ArkTS 静态数据建模、类型驱动渲染、派生数据计算、状态与渲染的耦合边界,以及把"40 条业务数据"组织成"驱动整个 UI 的数据源"的工程取舍。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

一、ArkTS 类型系统在工程中的定位

1.1 ArkTS 不是 TypeScript 的超集

很多从 Web 前端转来的开发者第一次写 ArkTS 会下意识把它当作"加了装饰器的 TypeScript",这是个危险的认知。ArkTS 是 HarmonyOS Next 的应用层语言,它在 TypeScript 的语法外观之下,是一套围绕 ArkUI 响应式 UI 模型重新设计的强约束语言

关键差异在三个方向:

  1. 不支持动态结构:ArkTS 禁用 anyunknown 在 struct 字段、禁用运行时改写对象形状(如 obj.newKey = ...)。本项目的两个接口 MaintenanceItemPartItem,所有字段类型都是 string / number 的原始组合,不存在可选字段或联合类型。
  2. 不支持 getter/setter:ArkTS 在 struct 内不支持 get xxx() 形式的计算属性。派生数据必须用方法表达——这是本应用用 getFilteredMaintenance() 而不是 get filteredMaintenance 的根本原因。
  3. 装饰器是一等公民@State / @Builder / @Component / @Entry 不是可选的语法糖,而是 ArkUI 响应式模型的执行核心。@State 变量的赋值会触发依赖它的 build() 片段重渲染,这是语言运行时强制的。

1.2 接口设计的工程哲学

本应用的两个核心接口:

interface MaintenanceItem {
  name: string
  category: string
  intervalKm: number
  intervalMonth: number
  note: string
}

interface PartItem {
  name: string
  category: string
  lifeYear: number
  lifeKm: number          // 单位:万公里(与 MaintenanceItem 的公里不同)
  replaceTip: string
}

这两个接口的形状直接决定了 UI 的渲染形状——每张卡片的字段映射到接口的一个字段。这种"接口字段 ↔ UI 元素"一一对应的建模方式,是声明式 UI 的精髓。

设计上有几个值得展开的取舍:

字段命名的业务一致性

MaintenanceItem.intervalKmPartItem.lifeKm 名字不同——前者是"间隔",后者是"寿命"。这反映业务语义差异:

  • 保养周期是周期性事件:每 5000 公里换一次机油,所以叫 interval(间隔)
  • 配件寿命是一次性事件:发电机用到 25 万公里就报废,所以叫 life(寿命)

UI 文案也跟着语义走:保养卡片显示"5000 公里 / 6 个月",配件卡片显示"15 年 / 60 万公里"。如果字段名都叫 km,UI 文案就要绕弯判断"这是间隔还是寿命",不如在数据层就把语义分开。

lifeKm 单位是万公里,不是公里

PartItem.lifeKm 的单位是万公里(数值 60 = 60 万公里),而 MaintenanceItem.intervalKm 的单位是公里(数值 5000 = 5000 公里)。这个单位差异在 UI 上被显式处理:

Text(`${item.intervalKm.toLocaleString()} 公里`)   // 保养:5000 公里
Text(`${item.lifeKm} 万公里`)                    // 配件:60 万公里

为什么不统一成公里?因为配件寿命的数值范围是 8-60(万公里),如果改成公里就是 80000-600000,数值跨度大且不直观——车主习惯说"发动机能用 60 万公里"而不是"发动机能用 600000 公里"。保养周期反过来,5000 公里是车主习惯表达。

这是一个"数据单位跟着用户语言走"的取舍。代价是 UI 渲染要显式处理单位差异,但收益是数据值与用户认知直接对齐,无需做转换。

category 用字符串而不是枚举

category: string 而不是 category: MaintenanceCategory 是个看似违反工程惯例的选择。理由有三:

  1. 跨接口共享:保养与配件的分类是同一组(发动机、底盘、电气、车身),用枚举要声明两个同义枚举或共享枚举,增加样板。
  2. 静态数据集:40 条数据都是编译期常量,分类值是写死的字符串,不存在"传入未知分类"的风险。
  3. 筛选逻辑简单item.category === this.activeCategory 的字符串相等比对,足够清晰。

如果数据会从远端拉取、或支持用户自定义分类,就必须改成枚举或字符串字面量联合类型。但本应用的封闭数据集里,字符串字段是最省事的方案。

二、40 条数据的静态建模

2.1 模块级 const 而不是 struct 内部

const MAINTENANCE_DATA: MaintenanceItem[] = [
  { name: '机油及机滤', category: '发动机', intervalKm: 5000, intervalMonth: 6, note: '矿物机油5千公里,半合成7.5千,全合成1万公里' },
  ...
]

const PARTS_DATA: PartItem[] = [
  { name: '发动机总成', category: '发动机', lifeYear: 15, lifeKm: 60, replaceTip: '正常保养下可达整车寿命' },
  ...
]

三个数据集都声明在模块顶层(文件作用域),而不是 Index struct 内部。这个选择背后的工程逻辑:

  1. 数据是应用资产,不是组件状态:40 条保养规范是"知识库",不是"当前页面的状态"。把它们放进 struct 会混淆"资产"与"状态"的边界。
  2. 避免实例化重复创建:每次 Index 组件实例化,struct 内部的字段都会重新初始化。如果数据在 struct 内,每次都会创建一份 40 条数据的数组——浪费内存且毫无意义。
  3. 类型推断更稳定:模块顶层 const X: Type[] = [...] 让 TypeScript 类型推断一次性确定,struct 内字段则要受装饰器(@State 等)的推断顺序影响。

2.2 数据集设计的业务深度

这 40 条数据不是随手拼的占位数据,每条都对应一个真实的保养决策点:

保养项 数值 业务依据
机油及机滤 5000 公里 / 6 月 矿物机油基线,全合成可延至 1 万
燃油滤芯 30000 公里 / 24 月 内置式(油箱内)可延至 8 万
刹车油 40000 公里 / 24 月 关键是含水量超 3% 必换,不只是里程
变速箱油(AT) 60000 公里 / 36 月 CVT 4 万、双离合 6 万,差异写在 note
正时皮带 80000 公里 / 72 月 皮带车型必须按时换,链条车型免维护
蓄电池 80000 公里 / 48 月 启停 AGM 电池约 4-5 年,普通电池 3-4 年

每条数据的 note 字段不是占位文本,而是承载"什么时候该偏离基线"的判断标准——比如刹 deltaY车油的"含水量超 3% 必换"、刹车片的"厚度低于 3mm 必换"、轮胎的"胎纹低于 1.6mm 或侧壁鼓包必换"。这些数值阈值都来自行业通用规范,让数据集本身具备查阅价值。

2.3 数据集与 UI 卡片的字段映射

每条 MaintenanceItem 渲染成一张 MaintenanceCard,字段映射关系:

MaintenanceItem 字段 UI 元素 渲染处理
name 卡片左上标题 fontSize 15,Medium 字重
category 卡片左上副标签 CATEGORY_COLORS 着成色
intervalKm 卡片右上主数值 toLocaleString() 千位分隔
intervalMonth 卡片右上副数值 拼"或 X 个月"
note 卡片底部说明 二级字色
(category) 卡片左侧色条 4×40vp 色条,CATEGORY_COLORS

接口形状直接决定 UI 形状。声明式 UI 的"数据驱动"在这里体现得最直接——数据集改一个字段,UI 渲染就要跟着改;反过来,UI 想多显示一个东西,数据接口就要加一个字段。这种"接口 ↔ UI"的紧耦合不是缺点,而是声明式 UI 的核心红利:减少了中间转换层。

三、派生数据:从静态数据到渲染数据

3.1 过滤方法的设计

private getFilteredMaintenance(): MaintenanceItem[] {
  if (this.activeCategory === '全部') {
    return MAINTENANCE_DATA
  }
  return MAINTENANCE_DATA.filter((item: MaintenanceItem) => item.category === this.activeCategory)
}

这是"派生数据用方法"的标准范式。三个细节:

早返回避免无谓 filter

activeCategory === '全部' 时直接返回原数组引用,不执行 filter。这避免了一次无谓的数组复制——filter 总是返回新数组,而"全部"情况下渲染需要的就是原数组。

这是个微优化(20 条数据的 filter 是微秒级),但更重要的意义是语义清晰:返回原数组让阅读者一眼看出"全部就是原数据",而 filter(item => true) 要绕个弯。

filter 的类型推断

MAINTENANCE_DATA.filter((item: MaintenanceItem) => ...) 显式声明 item 类型是 MaintenanceItem。ArkTS 在严格模式下要求箭头函数参数显式标注类型——这不同于 TypeScript 的"上下文推断优先"。在 ArkUI 的 ForEach / filter / map 等回调里,都建议显式写出参数类型,避免编译器推断失败。

返回类型显式

方法签名 : MaintenanceItem[] 显式标注返回类型,不依赖推断。这是工程代码的稳健做法——显式返回类型让方法成为"类型契约",调用方不需要看实现就能知道返回形状。

3.2 比例条宽度的实时计算

private getBarWidth(value: number, max: number): string {
  const percent = Math.min(value / max * 100, 100)
  return `${percent}%`
}

这个方法把"数值 + 满量程"转换成"百分比字符串"。三个工程细节:

返回字符串而不是 number

ArkUI 的 .width() 接受三种类型:

  • string:可以是 '100%'(百分比)、'16vp'(vp 单位)、'100px'(px 单位)
  • number:默认单位 vp,例如 .width(100) = 100vp
  • Resource:资源引用,如 $r('app.color.X')

本应用要的是百分比,必须返回字符串 '80%'。如果返回 number 80,会被当成 80vp,完全不是百分比的意思。

Math.min 封顶

Math.min(value / max * 100, 100) 确保百分比不超过 100。当 value > max(例如某项间隔 10 万公里、满量程 10 万),计算结果 = 100%,进度条满格。如果不封顶,进度条会超出容器宽度,触发 ArkUI 的边界处理(通常是截断),但显式封顶更安全。

满量程是硬编码常量

调用处:

.width(this.getBarWidth(item.intervalKm, 100000))   // 保养里程,满量程 10 万公里
.width(this.getBarWidth(item.intervalMonth, 72))     // 保养时间,满量程 72 月 = 6 年
.width(this.getBarWidth(item.lifeYear, 15))          // 配件年限,满量程 15 年
.width(this.getBarWidth(item.lifeKm, 60))            // 配件里程,满量程 60 万公里

四个满量程常量是按数据集最大值选定的"标尺":

  • 保养里程 100000 公里:正时皮带 8 万、刹 delta车盘 8 万、蓄电池 8 万,满量程取 10 万让最大值不顶格
  • 保养时间 72 月:正时皮带 72 月是数据集最大值,满量程就取 72
  • 配件年限 15 年:发动机总成 15 年是最大值,满量程取 15
  • 配件里程 60 万公里:发动机总成 60 万是最大值,满量程取 60

如果数据集会动态变化,满量程应该用方法计算 Math.max(...allValues)。但本应用静态数据集里直接硬编码更直观——阅读者一眼能看出"满量程是 10 万",而方法调用要绕一层。

3.3 派生数据在 build() 里的执行频率

派生方法在 build() 里被多次调用:

// 顶部计数 chip
Text(this.currentTab === 0 ?
  `${this.getFilteredMaintenance().length}` :
  `${this.getFilteredParts().length}`)

// 内容列表
ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => { ... })

每次 build() 重渲染(currentTabactiveCategory 变化触发),getFilteredMaintenance() 会被调用 2 次:一次算 .length、一次喂给 ForEach

这是不是性能问题?对 20 条数据来说,filter 一次的开销约 10-50 微秒,两次 = 20-100 微秒,远低于人眼能感知的 16 毫秒帧阈值。完全不需要做 memo 缓存。

如果数据集上千条、过滤逻辑复杂(多层 join),就该把过滤结果缓存为 @State,在 activeCategory 变化的回调里手动重算。但本应用的数据规模,重算本身就是最省事的方案。

四、状态与渲染的耦合边界

4.1 @State 的响应式机制

@State currentTab: number = 0
@State activeCategory: string = '全部'

@State 是 ArkUI 的响应式装饰器。它的运行时机制:

  1. 赋值追踪:每次对 @State 变量赋值,ArkUI 运行时会记录"该变量变了"
  2. 依赖收集:在 build() 执行期间,所有读取该变量的表达式都会被登记为"依赖该状态"
  3. 重渲染触发:状态变化后,ArkUI 把所有依赖该状态的 build() 片段标记为"脏",下一帧重新执行

本应用的依赖关系:

  • currentTab 被 Tab 栏(高亮切换)、内容列表(if 分支)、顶部计数 chip 依赖
  • activeCategory 被分类筛选 chip 列(高亮切换)、内容列表(filter 结果)、顶部计数 chip 依赖

任何一个状态变化,这三处的渲染都会同步刷新。这是声明式 UI 的核心红利——开发者只管"状态怎么变","UI 怎么跟着变"由运行时自动完成。

4.2 状态变化的入口:onClick 回调

.onClick(() => {
  this.currentTab = index as number
})

.onClick(() => {
  this.activeCategory = cat
})

状态变化的唯一入口是用户交互的 onClick 回调。回调里直接对 @State 变量赋值,不需要调用 setState / notify 之类的显式通知 API——这是 ArkUI 与 React 的一个关键差异。

React 的 useState 返回 [value, setter],必须用 setter 才能触发重渲染;直接 value = X 不会触发。ArkUI 的 @State 是属性装饰器,直接赋值即触发,语法更接近原生直觉。

但代价是阅读时要留意——本应用里所有 this.currentTab = ... / this.activeCategory = ... 都是"会触发重渲染"的赋值,没有显式 setter 提示。在大型项目里,这要求团队约定"状态变量只在事件回调里改",避免在异步逻辑里无意改状态导致渲染时序错乱。

4.3 状态为什么不放 RxJS / Promise 里

本应用没有任何异步逻辑——所有数据是静态 const,所有交互是同步 onClick。如果数据从远端拉取,状态管理就要扩展:

@State maintenanceData: MaintenanceItem[] = []
@State isLoading: boolean = false

async function loadMaintenance() {
  this.isLoading = true
  try {
    const res = await fetch('https://api.example.com/maintenance')
    this.maintenanceData = await res.json()
  } finally {
    this.isLoading = false
  }
}

异步状态的关键约束:赋值必须在 ArkUI 的执行上下文里。ArkUI 不像 React 有 act() 包装,但同样要求状态变化在合适的时序触发——例如不能在 setTimeout 回调里直接改 @State(实际能跑,但时序行为在某些场景下不稳定)。

本应用不涉及异步,因此状态管理极简。这是工具型应用的一个红利——没有远端依赖、没有缓存失效、没有并发请求,状态层只有"用户点了什么"这一个变量。

五、类型驱动的渲染分支

5.1 MaintenanceCard 与 PartCard 的分野

两个 @Builder 的渲染形状相似但字段映射不同:

@Builder
MaintenanceCard(item: MaintenanceItem) {
  // ... 顶部:name + category + intervalKm + intervalMonth
  // ... 进度条:里程条(满量程 100000)+ 时间条(满量程 72)
  // ... 备注:item.note
}

@Builder
PartCard(item: PartItem) {
  // ... 顶部:name + category + lifeYear + lifeKm
  // ... 进度条:年限条(满量程 15)+ 里程条(满量程 60)
  // ... 提示:item.replaceTip
}

为什么不抽一个泛化的 BaseCard?两个理由:

  1. 字段名不同intervalKm vs lifeKmintervalMonth vs lifeYearnote vs replaceTip。如果抽公共 Builder,要传一堆参数(标题、分类、数值 1、数值 2、说明、满量程 1、满量程 2…),参数列表会膨胀到 8+ 个。
  2. 单位差异:保养里程单位是公里(要 toLocaleString 千位分隔),配件里程单位是万公里(直接拼"万公里")。这种单位差异硬塞进泛化 Builder 要传一堆格式化函数,得不偿失。

ArkUI 的 @Builder 不支持泛型(@Builder MaintenanceCard<T>(item: T) 不是有效语法),因此类型驱动的渲染分支只能用"两个独立 Builder"。这是 ArkUI 与 React泛化组件的一个差异——React 可以 <Card<T> item={item} />,ArkUI 必须具体类型具体 Builder。

5.2 build() 内 if 的类型收窄

if (this.currentTab === 0) {
  ForEach(this.getFilteredMaintenance(), (item: MaintenanceItem) => {
    this.MaintenanceCard(item)
  })
} else {
  ForEach(this.getFilteredParts(), (item: PartItem) => {
    this.PartCard(item)
  })
}

if 分支里 item 的类型是确定的——MaintenanceItemPartItem,不需要联合类型。这归功于数据源方法的返回类型已经显式标注 : MaintenanceItem[] / : PartItem[]

如果数据源是联合类型 MaintenanceItem | PartItem,就要在 Builder 里收窄:

ForEach(this.getMixedData(), (item: MaintenanceItem | PartItem) => {
  if ('intervalKm' in item) {
    this.MaintenanceCard(item)
  } else {
    this.PartCard(item)
  }
})

但本应用 Tab 切换是互斥的——同一时刻只渲染一种类型,因此数据源方法已经分野,不需要联合类型。这是"状态分支 → 数据分支 → 渲染分支"三段对齐的标准做法。

六、分类色映射的 Record 实现

6.1 Record<string, string> 的选用

const CATEGORY_COLORS: Record<string, string> = {
  '发动机': '#1F6FEB',
  '底盘': '#FF8C1A',
  '电气': '#16A34A',
  '车身': '#9333EA'
}

Record<string, string> 是 TypeScript / ArkTS 的索引签名类型,等价于 { [key: string]: string }。选用 Record 而不是 enuminterface 的理由:

  1. 查询是 O(1)CATEGORY_COLORS[item.category] 直接哈希查表,比 enum + switch 简洁
  2. 数据形状就是字典:4 个键值对,没有方法、没有副作用,Record 是最贴合的形状
  3. 与业务字符串对接item.categorystring,用 Record 可以直接索引;如果用 enum,要先做 enumValueFromString 转换

6.2 颜色值是硬编码 hex 而不是 $r

注意这里颜色用的是字符串 '#1F6FEB',而不是 $r('app.color.primary')。这是个有意的取舍:

  • $r 资源引用支持运行时切换深色模式、支持资源文件 override
  • 硬编码 hex 不支持深色模式,但阅读时一眼能看出颜色

分类色是"装饰性色标"——它只用于左侧色条和分类文字,不参与"明暗模式适配"的核心色板。如果应用要做深色模式,分类色应该都进资源文件;但本应用目前只设计了浅色模式,分类色直接硬编码更直观。

更好的工程化做法是:把分类色也写成资源,让资源系统统一管理。但本应用选择了"核心色走资源、分类色走硬编码"的折中——核心色(背景、文字、边框)走资源因为它们是"会被深色模式切换的";分类色硬编码因为它们是"业务标识色,深浅模式都用同样的色"。

6.3 4 个分类的色相分布

4 个分类的色相选择:

分类 hex HSL 大致 视觉
发动机 #1F6FEB 主色调,最常见类别
底盘 #FF8C1A 暖色,与蓝形成对比
电气 #16A34A 绿 中性色,机械感
车身 #9333EA 区别于以上三种

4 个色相覆盖蓝、橙、绿、紫,色相轮上分布均匀,相互区分度高。这是数据可视化"分类色板"的基本要求——同屏出现的多个分类要色相差大,不能只用饱和度区分(色弱用户无法区分)。

七、数据集的健壮性约束

7.1 静态数据的"封闭性"红利

本应用的数据集是编译期常量,40 条数据写死在源码里。这带来一个工程红利:不需要任何运行时校验

  • 不需要验证 intervalKm > 0:写源码时就保证
  • 不需要验证 category 是 4 个值之一:写源码时就保证
  • 不需要验证 name 非空:写源码时就保证

这种"封闭数据集"的健壮性靠编译期保证,不靠运行时检查。ArkTS 的接口类型已经能在编译期拦截大多数形状错误(例如忘写 note 字段、把 intervalKm 写成字符串),剩下的业务约束(数值范围、字符串值域)靠代码评审保证。

7.2 如果数据从远端拉取

如果改成远端拉取,就要加运行时校验:

function validateMaintenanceItem(raw: unknown): MaintenanceItem {
  if (typeof raw !== 'object' || raw === null) {
    throw new Error('Invalid item shape')
  }
  const obj = raw as Record<string, unknown>
  if (typeof obj.name !== 'string') throw new Error('name must be string')
  if (typeof obj.intervalKm !== 'number' || obj.intervalKm <= 0) {
    throw new Error('intervalKm must be positive number')
  }
  // ... 其他字段
  return obj as MaintenanceItem
}

但本应用是封闭工具,数据来自"业内通用规范 + 编译者审定",没有远端污染风险。因此跳过所有运行时校验,直接信任编译期常量。

这是工程中"边界校验"原则的体现——只在数据进入系统的边界(远端入口、用户输入、文件读取)做校验,内部流转的数据只要类型对就信任。本应用的所有数据都在边界内(源码常量),因此零校验代码。

7.3 接口字段的"必须性"

两个接口的字段都是必填,没有可选字段(?)或默认值。这反映数据本质——每条保养项都有名字、分类、间隔、说明,没有"可能没有"的字段。

如果接口设计成 note?: string,UI 渲染就要处理 note 可能为空的场景:

if (item.note) {
  Text('备注')
  Text(item.note)
}

但本应用的数据集里每条都有 note,把它设计成可选字段是过度设计。声明式 UI 的接口应该反映"数据的真实形状",而不是"理论上可能的最宽形状"。

八、本篇小结

本章从 ArkTS 类型系统切入,完整覆盖了数据建模到 UI 驱动的全流程。关键点归纳:

  1. 接口形状决定 UI 形状:声明式 UI 的"数据驱动"不是抽象口号,而是字段 ↔ 元素的一一对应。每改一个字段就要想 UI 要不要跟着改。
  2. 派生数据用方法:ArkTS 不支持 struct getter,派生数据(过滤、计算)用方法表达。方法在 build() 里被重复调用,对小数据集是可接受的。
  3. 状态最小化:只有用户交互的产物才做 @State,编译期常量用模块级 const,派生数据用方法。三类数据各归各位。
  4. 类型驱动渲染分支:两种数据类型对应两个独立 Builder,不追求泛化。if 分支让类型在分支内自然收窄,不需要联合类型。
  5. 封闭数据集零运行时校验:数据来自源码常量,靠编译期类型保证健壮性。运行时校验只在数据进入边界做。

九、附录:本篇涉及的关键 ArkTS / TypeScript 差异

特性 ArkTS TypeScript 本篇体现
struct 内 getter 不支持 支持 派生数据用方法 getFilteredMaintenance()
@State 装饰器 必需 不存在 状态变量声明
箭头函数参数类型 必须显式 可省略推断 filter((item: MaintenanceItem) => ...)
@Builder 泛型 不支持 不适用 类型驱动用两个独立 Builder
any 在 struct 字段 禁用 允许 所有字段都是具体类型
运行时改对象形状 禁用 允许 数据集是 const,从不改形状

十、附录:本篇涉及的数据接口完整定义

interface MaintenanceItem {
  name: string
  category: string
  intervalKm: number        // 单位:公里
  intervalMonth: number    // 单位:月
  note: string
}

interface PartItem {
  name: string
  category: string
  lifeYear: number          // 单位:年
  lifeKm: number            // 单位:万公里(与 MaintenanceItem.intervalKm 不同)
  replaceTip: string
}

十一、附录:本篇涉及的数据集统计

保养周期数据集(20 条)

分类 数量 代表项
发动机 9 机油、空气滤芯、燃油滤芯、火花塞、节气门、防冻液、正时皮带
底盘 7 刹 δ车油、变速箱油、差速器油、分动箱油、刹车片、刹车盘、轮胎
电气 2 蓄电池、发电机皮带
车身 4 空调滤芯、雨刮片、玻璃水

配件年限数据集(20 条)

分类 数量 代表项
发动机 8 发动机总成、水箱、三元催化、氧传感器、喷嘴、正时链条、排气管
底盘 5 减震器、悬挂球头、转向助力泵、半轴万向节、刹车分泵
电气 6 发电机、起动机、蓄电池、冷凝器、压缩机、ECU、点火线圈
车身 1 车门铰链

至此完整覆盖了"数据建模到 UI 驱动"的全流程。下一篇将深入 ArkUI 声明式 UI 的渲染机制,从 build() 执行模型讲到 @Builder 复用的边界。

Logo

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

更多推荐