一、鸿蒙开发背景与 ArkTS 语言概述

华为 HarmonyOS(鸿蒙操作系统)自发布以来,始终以"万物互联"为核心愿景,致力于打通手机、平板、智能穿戴、车机、智慧屏等多种终端设备,为开发者提供统一的分布式开发框架。在 HarmonyOS 的应用开发体系中,ArkUI 框架是最核心的 UI 开发范式,它采用声明式编程模型,让开发者能够以简洁直观的方式描述界面结构,而不必像传统命令式编程那样一步步操作 DOM 节点。声明式 UI 的核心理念是"状态驱动视图"——开发者只需声明界面在不同状态下的样子,框架会自动管理状态变化到视图更新的整个流程,极大地降低了 UI 编程的复杂度。

ArkTS 是 HarmonyOS 应用开发的首选语言,它在 TypeScript 的基础上进行了扩展和优化,保留了 TypeScript 的类型系统优势,同时引入了装饰器语法来支持声明式 UI 的各种能力。与纯 TypeScript 不同,ArkTS 对类型检查更加严格,禁用了一些动态特性(如 any 类型的随意使用、运行时修改对象结构等),以确保编译期就能发现潜在问题,提升应用的运行效率和代码可靠性。ArkTS 中的装饰器是整个声明式 UI 体系的基石——@Entry 标记入口组件,@Component 声明自定义组件,@State 管理组件内部状态,@Builder 定义可复用的 UI 片段,@Prop@Link 处理父子组件间的数据传递,这些装饰器共同构成了一个完整的状态管理与 UI 组织体系。

在 ArkUI 的组件体系中,容器组件和基础组件是两大支柱。容器组件负责布局组织,例如 Column(纵向排列子元素)、Row(横向排列子元素)、Stack(层叠排列子元素)、Flex(弹性布局)、Scroll(滚动容器)等,它们决定了子组件的空间分布方式。基础组件则提供具体的可视元素,如 Text(文本)、Image(图片)、TextInput(输入框)、Toggle(开关)、Progress(进度条)、Button(按钮)等。容器组件和基础组件通过嵌套组合,可以构建出任意复杂的界面结构。每个组件都支持链式调用的属性修饰方法,如 .width().height().padding().margin().borderRadius().backgroundColor().fontSize().fontColor() 等,这些方法返回组件自身,从而支持流式 API 风格的编码体验。

HarmonyOS 的 ArkUI 框架还提供了丰富的动画能力。animateTo 是最常用的显式动画 API,它接受一个动画选项对象(包含 duration 持续时间、iterations 重复次数、playMode 播放模式、curve 动画曲线等参数)和一个闭包,在闭包内修改状态变量,框架会自动对状态变化产生过渡动画。当 iterations 设置为 -1 时表示无限循环,PlayMode.Alternate 表示反向播放,配合 Curve.EaseInOut 缓动曲线可以产生平滑的往复效果。这种"状态驱动动画"的设计使得开发者无需手动管理动画帧,只需关注状态变化本身,框架会自动处理插值计算和渲染刷新。此外,ArkUI 还支持组件转场动画(transition)、共享元素动画(sharedTransition)等更高级的动画能力,为打造流畅自然的用户体验提供了强有力的工具支撑。


二、数据模型设计:接口定义与类型安全

2.1 验光记录接口

在 ArkTS 中,interface 是定义数据结构的核心手段。与 TypeScript 的 interface 不同,ArkTS 的 interface 在编译期会被严格检查,所有字段必须明确类型,且不支持可选属性以外的高级特性。我们先来看验光记录的数据模型定义:

interface EyeT16 {
  id: number
  date: string
  lNaked: number
  rNaked: number
  lDio: number
  rDio: number
  note: string
}

在这里插入图片描述

这段代码定义了一个名为 EyeT16 的接口,用于描述验光记录的数据结构。接口中包含七个字段:id 是记录的唯一标识,类型为 numberdate 是验光日期,类型为 string,存储的是简化的日期格式如 '08-28'lNakedrNaked 分别代表左眼和右眼的裸眼视力值,以小数形式表示(如 0.6、0.8、1.0);lDiorDio 分别代表左眼和右眼的屈光度数,近视以负值表示(如 -2.75 代表近视 275 度);note 是备注信息,存储医生或验光师的临床备注。

技术要点:ArkTS 中的 interface 与 TypeScript 的区别

ArkTS 的 interface 在编译期会进行严格的类型推导和检查。与纯 TypeScript 不同,ArkTS 不允许在运行时随意添加或删除接口字段,对象字面量必须严格匹配接口定义的结构。这种设计虽然牺牲了一些灵活性,但换来了更高的运行效率和更早的错误发现能力。在 ArkTS 中,接口主要用于描述数据的"形状",而非实现面向对象的继承多态。

2.2 镜片信息接口

interface LensT16 {
  id: number
  name: string
  brand: string
  price: number
  feature: string
  heat: number
}

在这里插入图片描述

LensT16 接口描述了近视管理镜片的产品信息。id 为产品编号,name 是镜片的全称(如"离焦镜片 1.67 非球面"),brand 是品牌系列(如"依视路·星趣控"),price 是价格(以元为单位),feature 是技术特点描述,heat 是热度指数(一个 0-100 的数值,代表市场关注度)。这些字段覆盖了镜片展示所需的核心信息,为后续的列表渲染和详情弹框提供了数据基础。

这个接口的设计体现了数据建模的一个基本原则:字段应该精确反映业务需求,不多不少。heat 字段虽然看起来只是一个简单的数字,但在 UI 中却承担了"热度标签"的展示职责,让用户能够快速感知哪些镜片更受市场欢迎。在数据驱动 UI 的范式下,每一个 UI 上展示的信息都应该在数据模型中有对应的字段,而不是在渲染时临时计算或硬编码。

2.3 训练项目与青少年防控接口

interface TrainT16 {
  id: number
  name: string
  icon: string
  mins: number
  done: boolean
  effect: string
}

interface KidT16 {
  id: number
  name: string
  age: number
  dio: number
  hours: number
  outdoor: number
  tag: string
}

在这里插入图片描述

TrainT16 接口描述了视功能训练项目。icon 字段使用 Emoji 字符串作为图标,这是一种轻量化的图标方案,无需引入图片资源即可实现视觉区分。mins 是建议训练时长(分钟),done 是布尔值标记是否已完成,effect 是训练效果描述。done 字段特别重要,它直接驱动训练进度条的计算和列表项状态的切换,是典型的"状态驱动视图"场景。

KidT16 接口描述了青少年近视防控档案。dio 是等效球镜度数,hours 是日均近距离用眼时长,outdoor 是日均户外活动时长,tag 是防控状态标签。这三个数值字段(diohoursoutdoor)在 UI 中会根据阈值进行条件渲染——比如 hours >= 4 时显示警告色,outdoor >= 2 时显示正常色——这种基于数据阈值的条件渲染是业务 UI 开发中非常常见的模式。

2.4 订单接口

interface OrdT16 {
  id: string
  item: string
  status: string
  amount: number
  date: string
}

OrdT16 是最简洁的接口,仅包含五个字段。值得注意的是 id 字段类型为 string 而非 number,因为订单编号通常包含字母和数字的组合(如 'V20260828001'),不适合用数值类型存储。status 字段使用字符串而非枚举,在 UI 渲染时通过字符串比较来决定显示颜色和样式——这种做法虽然不如枚举类型安全,但在快速原型开发中更加灵活便捷。

技术要点:ArkTS 的类型系统

ArkTS 的类型系统在 TypeScript 基础上做了收紧和优化。它支持 numberstringboolean 等基本类型,支持 Array<T> 泛型数组,支持 interface 接口定义,但不支持 TypeScript 的 enum 枚举在运行时的某些行为。在 ArkTS 中,推荐使用 string 联合类型或常量来替代枚举,以获得更好的兼容性和编译效率。


三、静态数据源与常量定义

3.1 验光记录数据集

const EYES16: Array<EyeT16> = [
  { id: 1, date: '08-28', lNaked: 0.6, rNaked: 0.5, lDio: -2.75, rDio: -3.25, note: '疲劳感明显' },
  { id: 2, date: '07-12', lNaked: 0.6, rNaked: 0.6, lDio: -2.75, rDio: -3.00, note: '右眼加深 25 度' },
  { id: 3, date: '05-20', lNaked: 0.8, rNaked: 0.6, lDio: -2.50, rDio: -3.00, note: '视疲劳随访' },
  { id: 4, date: '03-08', lNaked: 0.8, rNaked: 0.8, lDio: -2.50, rDio: -2.75, note: '常规复查' },
  { id: 5, date: '01-15', lNaked: 1.0, rNaked: 0.8, lDio: -2.25, rDio: -2.75, note: '年初建档' },
  { id: 6, date: '06-30', lNaked: 0.8, rNaked: 0.8, lDio: -2.50, rDio: -3.00, note: 'OK 镜适配评估' }
]

在这里插入图片描述

这里使用 const 关键字声明了一个名为 EYES16 的常量数组,类型标注为 Array<EyeT16>,即元素类型为 EyeT16 接口的数组。数组中包含六条验光记录,按时间倒序排列(最新记录在最前面)。每条记录的字段值都严格符合 EyeT16 接口的类型定义。

值得注意的是数据中隐含的临床趋势:从 1 月份到 8 月份,右眼屈光度从 -2.75 加深到 -3.25,加深了 50 度;裸眼视力从 0.8 下降到 0.5。这些数据在 UI 中会通过柱状图直观展示,让用户一眼看出视力变化趋势。在真实应用中,这些数据应该来自后端 API 接口或本地数据库,但作为前端原型,使用静态常量数组来模拟数据源是常见且高效的做法。

技术要点:const 与 @State 的数据初始化策略

在 ArkTS 中,const 声明的常量数组是不可变引用——不能重新赋值,但数组内容理论上可以被修改。然而,作为全局静态数据源,我们不应该在运行时修改它。在组件内部,@State 状态变量会通过 .slice() 方法获取这些常量数组的副本,从而确保原始数据不被污染。.slice() 是 JavaScript/TypeScript 数组的原生方法,返回数组的浅拷贝,在 ArkTS 中同样适用。

3.2 镜片、训练与青少年数据集

const LENSES16: Array<LensT16> = [
  { id: 1, name: '离焦镜片 1.67 非球面', brand: '依视路·星趣控', price: 3980, feature: '近视管理 · HALT 高非球微透镜', heat: 96 },
  { id: 2, name: '多点近视离焦镜片', brand: '豪雅·新乐学', price: 3680, feature: 'DIMS 技术 · 临床延缓 60%', heat: 92 },
  { id: 3, name: '防蓝光 Office 镜片', brand: '蔡司·数码型', price: 2180, feature: '办公护眼 · 缓解睫状肌紧张', heat: 84 },
  { id: 4, name: '变色驾驶镜片', brand: '凯米·变色灰', price: 1480, feature: '室内外自动切换 · UV400', heat: 76 },
  { id: 5, name: '角膜塑形镜(OK 镜)', brand: '欧几里德·露晰得', price: 10800, feature: '夜戴日摘 · 延缓眼轴增长', heat: 88 },
  { id: 6, name: '日抛隐形 · 散光款', brand: '强生·欧舒适', price: 320, feature: '30 片装 · 高透氧硅水凝胶', heat: 80 }
]

const TRAINS16: Array<TrainT16> = [
  { id: 1, name: '反转拍训练(调节灵敏度)', icon: '⏱️', mins: 10, done: true, effect: '改善调节滞后' },
  { id: 2, name: '眼球追随运动', icon: '🎾', mins: 8, done: true, effect: '提升平滑追随能力' },
  { id: 3, name: '字母表远近交替', icon: '🔤', mins: 12, done: false, effect: '放松睫状肌' },
  { id: 4, name: '聚散球集合训练', icon: '🔴', mins: 10, done: false, effect: '改善集合不足' },
  { id: 5, name: '遮盖弱视刺激(左眼)', icon: '🩹', mins: 20, done: false, effect: '弱视矫正期' },
  { id: 6, name: '户外远眺 20-20-20', icon: '🌳', mins: 15, done: true, effect: '延缓近视进展' },
  { id: 7, name: '明暗交替眨眼操', icon: '💡', mins: 5, done: false, effect: '缓解干眼' },
  { id: 8, name: '视觉追踪扫视练习', icon: '🎯', mins: 10, done: false, effect: '提升阅读效率' }
]

const KIDS16: Array<KidT16> = [
  { id: 1, name: '糖糖', age: 9, dio: -1.50, hours: 3.2, outdoor: 1.5, tag: '离焦镜 · 控制良好' },
  { id: 2, name: '小树', age: 12, dio: -2.75, hours: 5.8, outdoor: 0.5, tag: '眼轴增速偏快' },
  { id: 3, name: '年糕', age: 7, dio: -0.75, hours: 2.1, outdoor: 2.2, tag: '远视储备不足' }
]

在这里插入图片描述

这三组数据分别定义了镜片产品列表、训练项目列表和青少年防控档案。镜片数据涵盖了从 320 元的日抛隐形到 10800 元的角膜塑形镜,价格跨度极大,为 UI 的价格展示和配色策略提供了丰富的数据基础。训练数据中 done 字段的 true/false 分布决定了训练进度条的百分比(3/8 = 37.5%),同时驱动列表项的样式状态切换。

青少年数据中包含了三个典型场景:糖糖代表"控制良好"的案例(低度近视、中等用眼、适量户外);小树代表"眼轴增速偏快"的高风险案例(中度近视、超高用眼、极低户外);年糕代表"远视储备不足"的早期预警案例(低度近视、低用眼、充足户外)。这三种典型案例在 UI 中的条件渲染策略各不相同——高风险案例会以橙色警告色突出显示日均近距和日均户外的数值。

3.3 辅助常量与辅助函数

const ORDS16: Array<OrdT16> = [
  { id: 'V20260828001', item: '医学验光 + 眼轴测量', status: '已完成', amount: 120, date: '2026-08-28' },
  { id: 'V20260702002', item: '离焦镜片 1.67 · 星趣控', status: '制作中', amount: 3980, date: '2026-07-02' },
  { id: 'V20260610003', item: '视功能训练 10 次卡', status: '进行中', amount: 1580, date: '2026-06-10' },
  { id: 'V20260912004', item: '眼健康年检 · 含眼底照', status: '待就诊', amount: 260, date: '2026-09-12' }
]

const HOURSV16: Array<number> = [7, 5, 6, 8, 9, 6, 4]
const DAYS16: Array<string> = ['一', '二', '三', '四', '五', '六', '日']

function dioWord16(d: number): string {
  if (d <= -3.0) {
    return '中度近视'
  }
  if (d <= -1.0) {
    return '轻度近视'
  }
  return '临界'
}

ORDS16 定义了四条订单记录,覆盖了"已完成"、“制作中”、“进行中”、"待就诊"四种状态。这四种状态在 UI 中会通过条件表达式映射到不同的颜色——已完成用青色、进行中用紫色、制作中和待就诊用橙色,形成直观的状态视觉区分。

HOURSV16DAYS16 是两个辅助常量数组。HOURSV16 存储了一周七天的近距离用眼时长(小时),DAYS16 存储了星期一到星期日的中文简写。这两个数组在视力 tab 的"本周近距离用眼"图表中通过索引关联使用——ForEach 遍历 DAYS16 时同时获取索引 i,再用 HOURSV16[i] 取出对应的时长数据,这种"双数组索引联动"是处理关联数据的一种简洁模式。

dioWord16 是一个全局辅助函数,根据屈光度数值返回中文分类描述。当度数小于等于 -3.0D 时返回"中度近视",小于等于 -1.0D 时返回"轻度近视",否则返回"临界"。这个函数在视力 tab 的"屈光分类"展示中被调用,将数值化的屈光度转化为用户可理解的文字描述,是典型的"数据格式化"辅助函数。

技术要点:ArkTS 中的函数定义

ArkTS 支持顶层函数定义(在 struct 外部定义的函数),也支持组件内部方法。顶层函数通常用于纯逻辑处理,不依赖组件状态,可以在多个组件间复用。函数的参数和返回值都需要明确类型标注,ArkTS 不支持 TypeScript 的类型推断省略——即使在函数体中能推导出返回类型,也建议显式标注,以获得更好的编译期检查和代码可读性。


四、入口组件与状态管理

4.1 @Entry 与 @Component 装饰器

@Entry
@Component
struct Index {

在这里插入图片描述

@Entry@Component 是 ArkUI 声明式 UI 中最重要的两个装饰器。@Entry 装饰器标记当前组件为页面入口组件——在一个页面中只能有一个 @Entry 组件,它是整个页面的根节点,框架会自动将它注册到路由系统中。@Component 装饰器声明当前 struct 为自定义组件,自定义组件是 ArkUI 中可复用的 UI 单元。

struct 是 ArkTS 中定义组件的关键字,它与 TypeScript 中的 class 不同。struct 是值类型(在编译后会被优化为特定的数据结构),不支持继承,但可以通过组合和 @Builder 来实现代码复用。每个 @Component 修饰的 struct 必须实现 build() 方法,该方法返回该组件的 UI 结构。

技术要点:@Entry 与 @Component 的区别与关系

@Entry@Component 的超集——一个 @Entry 组件首先是一个 @Component 组件,然后才额外拥有入口页面的能力。@Entry 组件会被框架自动注册为路由目标,可以通过 router.push() 等方式进行页面跳转。而普通的 @Component 组件只能被其他组件引用,不能独立作为页面存在。在单页面应用中,通常只有一个 @Entry 组件,其余都是被它引用的 @Component 子组件或通过 @Builder 定义的 UI 片段。

4.2 @State 状态变量声明

  @State tab16: number = 0
  @State eyes16: Array<EyeT16> = EYES16.slice()
  @State lenses16: Array<LensT16> = LENSES16.slice()
  @State trains16: Array<TrainT16> = TRAINS16.slice()
  @State ords16: Array<OrdT16> = ORDS16.slice()
  @State selLens16: number = 0
  @State selEye16: number = 0
  @State showOpto16: boolean = false
  @State showLens16: boolean = false
  @State showTrain16: boolean = false
  @State showRemind16: boolean = false
  @State showDel16: boolean = false
  @State showBuy16: boolean = false
  @State oItem16: number = 0
  @State oSlot16: number = 0
  @State oDilate16: boolean = false
  @State tMins16: number = 10
  @State tTired16: number = 0
  @State rGap16: number = 0
  @State rNight16: boolean = true
  @State buyNum16: number = 1
  @State armed16: boolean = false
  @State eyeOp16: number = 1
  @State blink16: number = 1

在这里插入图片描述

这是整个组件的状态声明区域,共定义了 23 个 @State 状态变量。@State 是 ArkUI 状态管理中最基础的装饰器,它标记的变量会被框架追踪——当变量值发生变化时,框架会自动重新执行 build() 方法中依赖该变量的 UI 部分,实现"状态驱动视图更新"。

这些状态变量可以按功能分组理解:

导航与选中状态tab16 控制当前显示的 tab 页面(0-4 对应五个 tab);selLens16selEye16 分别记录当前选中的镜片索引和验光记录索引,用于详情弹框的数据定位。

弹框显隐控制showOpto16showLens16showTrain16showRemind16showDel16showBuy16 是六个布尔值,分别控制六个弹框/抽屉的显隐。这种"布尔值驱动弹框"是 ArkUI 中实现模态层的主流模式。

预约验光表单状态oItem16(检查项目选中索引)、oSlot16(时段选中索引)、oDilate16(是否需要散瞳)三个变量共同构成了预约验光抽屉的表单状态。

训练打卡状态tMins16(本次训练时长)和 tTired16(训练后感受索引)构成了训练打卡抽屉的表单状态。

用眼提醒状态rGap16(提醒间隔索引)和 rNight16(夜间护眼模式开关)构成了编辑用眼提醒抽屉的表单状态。

配镜下单状态buyNum16(镜片数量)控制配镜下单抽屉的购买数量。

删除确认状态armed16 是一个安全开关,只有当用户打开此开关后,删除按钮才会变为可点击状态,这是一种"二次确认"的防误删机制。

动画状态eyeOp16blink16 是两个动画驱动变量,它们的值会被 animateTo 动画不断修改,从而驱动 UI 上的透明度变化效果。

技术要点:@State 的响应式原理

@State 装饰器背后的机制是"观察者模式"。框架在 build() 执行时会记录每个状态变量被哪些 UI 组件读取,建立起"依赖关系图"。当状态变量被修改时,框架会查找依赖该变量的 UI 组件,只重新渲染这些组件,而不是整个页面。这种"细粒度更新"机制保证了状态变化时的高效渲染。需要注意的是,对于数组类型的 @State 变量,直接修改数组元素(如 this.eyes16[0].note = 'xxx')不会触发更新,必须替换整个数组引用(如 this.eyes16 = newArray)才能触发——这也是为什么代码中大量使用 .map().filter() 来生成新数组的原因。


五、生命周期与动画初始化

5.1 aboutToAppear 生命周期回调

  aboutToAppear() {
    this.getUIContext().animateTo({ duration: 1400, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.eyeOp16 = 0.3
    })
    this.getUIContext().animateTo({ duration: 700, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.blink16 = 0.1
    })
  }

在这里插入图片描述

aboutToAppear() 是 ArkUI 组件生命周期中的重要回调之一。它在组件创建后、build() 执行前被调用,此时组件的状态变量已完成初始化但尚未渲染到屏幕上。这个时机非常适合执行一些初始化逻辑,如数据请求、动画启动、事件订阅等。

这里启动了两个无限循环动画。第一个动画操作 eyeOp16 变量:初始值为 1,动画目标值为 0.3,持续时间为 1400 毫秒,播放模式为 PlayMode.Alternate(往返播放),缓动曲线为 Curve.EaseInOut(先慢后快再慢)。这意味着 eyeOp16 的值会在 1 到 0.3 之间往复变化,每 1400 毫秒完成一个单向过渡。这个变量被绑定到顶部标题栏中眼睛图标的 opacity 属性上,产生了"眨眼呼吸"般的透明度渐变效果。

第二个动画操作 blink16 变量:初始值为 1,动画目标值为 0.1,持续时间为 700 毫秒。blink16 的变化速度更快(700ms vs 1400ms),透明度变化范围更大(从 1 到 0.1),产生了一种快速的"闪烁"效果。

技术要点:animateTo 显式动画 API

animateTo 是 ArkUI 中最核心的显式动画 API。它的第一个参数是动画选项对象,包含以下关键属性:

  • duration:动画持续时间,单位毫秒
  • iterations:重复次数,-1 表示无限循环,1 表示只播放一次(默认)
  • playMode:播放模式,PlayMode.Normal(正向播放一次)、PlayMode.Alternate(往返播放)、PlayMode.Reverse(反向播放)
  • curve:缓动曲线,Curve.Linear(线性)、Curve.EaseIn(先慢后快)、Curve.EaseOut(先快后慢)、Curve.EaseInOut(两端慢中间快)等

第二个参数是一个闭包,在闭包内修改状态变量,框架会捕获这些变化并自动生成过渡动画。需要注意的是,闭包内的状态修改是"目标值"——动画会从当前值平滑过渡到目标值,然后根据 playMode 决定是否反向过渡回来。this.getUIContext() 获取当前 UI 上下文,通过上下文调用 animateTo 可以确保动画绑定到正确的组件实例。

组件创建

aboutToAppear 回调触发

启动动画1: eyeOp16 呼吸

启动动画2: blink16 闪烁

eyeOp16: 1.0 → 0.3 往返 1400ms

blink16: 1.0 → 0.1 往返 700ms

驱动顶部眼睛图标 opacity 渐变

build 方法执行

首次 UI 渲染

动画持续运行 无限循环

这个流程图展示了组件从创建到动画启动的完整过程。aboutToAppear 作为生命周期入口,同时启动两个独立的动画通道,两个动画各自独立运行、互不干扰,共同为界面顶部标题栏区域增添了灵动的视觉效果。由于 iterations 设为 -1,这两个动画会一直运行直到组件被销毁,无需开发者手动管理动画帧或停止逻辑。


六、顶部标题栏构建

6.1 @Builder 装饰器与 headerBar16

  @Builder
  headerBar16() {
    Column() {
      Row() {
        Text('👁️ 眼视光中心')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        Text('VISION LAB')
          .fontSize(10)
          .fontColor('#8BB4BC')
          .letterSpacing(2)
          .margin({ left: 6 })
        Text('👁️')
          .fontSize(17)
          .opacity(this.eyeOp16)
          .margin({ left: 6 })
        Column() {
          Text('眼轴 24.6mm')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        .borderRadius(13)
        .backgroundColor('#0E7490')
        .margin({ left: 10 })
        Text('🔔')
          .fontSize(19)
          .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

@Builder 是 ArkUI 中用于定义可复用 UI 片段的装饰器。被 @Builder 修饰的方法不接收参数(或接收参数用于定制化),返回一个 UI 结构。与 @Component 不同,@Builder 方法定义的 UI 片段不会创建独立的组件实例,而是直接内联到调用处,因此不能拥有自己的状态(@State),但可以访问所属组件的状态变量。

headerBar16() 方法构建了应用顶部的标题栏区域。整体使用 Column 容器进行纵向布局,内部第一行使用 Row 容器横向排列五个元素:标题文字"眼视光中心"、副标题"VISION LAB"、动画眼睛图标、眼轴数据胶囊、通知铃铛图标。

Column 是 ArkUI 中最基础的纵向布局容器组件,它将子元素按垂直方向从上到下依次排列。通过 .justifyContent() 可以控制子元素在主轴(纵轴)方向的对齐方式,通过 .alignItems() 可以控制子元素在交叉轴(横轴)方向的对齐方式。Row 则是横向布局容器,将子元素从左到右排列,对齐控制逻辑与 Column 类似但轴向互换。

注意第三个元素 Text('👁️') 绑定了 .opacity(this.eyeOp16)——这个 eyeOp16 状态变量在 aboutToAppear 中被 animateTo 不断修改,因此这个眼睛图标的透明度会持续产生呼吸般的渐变效果。这就是前面动画初始化的直接应用:状态变量作为桥梁,连接动画引擎和 UI 渲染。

技术要点:Column 与 Row 的对齐属性

  • Column 的主轴是纵向,交叉轴是横向。.alignItems(HorizontalAlign.Center) 控制子元素水平居中,.justifyContent(FlexAlign.Center) 控制子元素垂直分布。
  • Row 的主轴是横向,交叉轴是纵向。.alignItems(VerticalAlign.Center) 控制子元素垂直居中,.justifyContent(FlexAlign.Center) 控制子元素水平分布。
  • FlexAlign 枚举值包括:Start(起始对齐)、Center(居中)、End(末尾对齐)、SpaceBetween(两端对齐,首尾无间距)、SpaceAround(等间距,首尾有半间距)、SpaceEvenly(等间距,首尾有全间距)。

6.2 搜索栏与横向滚动公告

      Row() {
        Text('🔍 搜验光单 / 镜片 / 视功能训练')
          .fontSize(13)
          .fontColor('#8BB4BC')
        Text('📋')
          .fontSize(16)
          .margin({ left: 10 })
      }
      .width('100%')
      .height(40)
      .borderRadius(20)
      .backgroundColor('#FFFFFF')
      .padding({ left: 14, right: 12 })
      .alignItems(VerticalAlign.Center)
      .margin({ top: 12 })

      Scroll() {
        Row({ space: 8 }) {
          ForEach(['离焦镜 8 折', '开学季验光免费', '训练卡买 10 送 2'], (t: string) => {
            Text('🎁 ' + t)
              .fontSize(11)
              .fontColor('#7C3AED')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor('#F3EEFB')
          }, (t: string) => t)
        }
        .padding({ top: 2, bottom: 2 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ top: 10 })
      .height(30)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 8 })
    .backgroundColor('#EFF9FA')
  }

搜索栏使用 Row 容器实现,设置了 40 的固定高度和 20 的圆角半径,背景为白色,内部左对齐放置搜索图标和提示文字。这是一个纯展示型的搜索栏——没有绑定 TextInput 输入框,仅作为视觉引导元素。

下方的公告区域使用了 Scroll 滚动容器组件,并设置 .scrollable(ScrollDirection.Horizontal) 使其支持横向滚动。Scroll 是 ArkUI 中的滚动容器,可以包裹超出自身尺寸的内容,支持纵向和横向两种滚动方向。.scrollBar(BarState.Off) 隐藏了滚动条,使界面更加简洁。

Row({ space: 8 }) 中的 space 参数设置了子元素之间的间距为 8。ForEach 遍历三个促销文案字符串数组,为每个文案生成一个带紫色文字和浅紫色背景的圆角标签。ForEach 的第三个参数是键值生成器函数 (t: string) => t,它为每个元素生成唯一键,用于框架的 diff 算法——当列表数据变化时,框架通过键值判断哪些项需要新增、删除或更新,从而实现高效的列表渲染。

技术要点:ForEach 列表渲染

ForEach 是 ArkUI 中用于列表渲染的核心组件。它接收三个参数:

  1. 数据源:一个数组
  2. 项目生成器:一个箭头函数,接收数组元素(和可选的索引),返回 UI 组件
  3. 键值生成器:一个箭头函数,接收数组元素(和可选的索引),返回唯一字符串键

键值生成器是 ForEach 高效运作的关键。当数据源发生变化时,框架通过比对新旧键值列表来确定最小更新集——新增的键对应新创建的组件,消失的键对应被移除的组件,保持不变的键对应被复用(可能更新)的组件。因此,键值必须唯一且稳定,不应该使用数组索引作为键值(因为索引会随数组变化而偏移),而应该使用元素自身的唯一标识(如 id 字段或字符串内容)。


七、视力 Tab 页面详解

7.1 视力概览卡片与 Progress 环形进度

  @Builder
  tabVision16() {
    Column() {
      Row() {
        Column() {
          Stack() {
            Progress({ value: 55, total: 100 })
              .style({ strokeWidth: 9 })
              .color('#0E7490')
              .backgroundColor('#D6EEF2')
              .width(88)
              .height(88)
            Column() {
              Text('0.5')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#0E7490')
              Text('右眼裸视')
                .fontSize(9)
                .fontColor('#8BB4BC')
                .margin({ top: 2 })
            }
            .justifyContent(FlexAlign.Center)
          }
          .width(88)
          .height(88)
        }
        .justifyContent(FlexAlign.Center)

tabVision16() 是视力 tab 的主构建方法。第一个卡片使用 Row 横向布局,左侧是一个 88x88 的环形进度展示区域。

这里使用了 Stack 层叠布局容器——Stack 将子元素按顺序堆叠在一起,后放入的子元素覆盖在先放入的子元素之上。在这个场景中,Progress 环形进度条作为底层,Column(包含"0.5"和"右眼裸视"文字)作为上层叠在进度条中心,形成了"环形进度条 + 中心文字"的经典 UI 模式。

Progress 是 ArkUI 的进度组件,通过 Progress({ value: 55, total: 100 }) 创建——value 是当前值,total 是总值,百分比等于 value/total*100。默认类型为环形(ProgressType.Ring),通过 .style({ strokeWidth: 9 }) 设置环形线条宽度为 9。.color() 设置进度条前景色(深青色),.backgroundColor() 设置进度条背景色(浅青色),两者形成对比,直观显示进度比例。

技术要点:Stack 层叠布局

Stack 是 ArkUI 的层叠布局容器,子元素默认居中堆叠。可以通过 .alignContent() 修改默认对齐方式(如 Alignment.TopAlignment.BottomAlignment.Start 等)。Stack 在以下场景中特别有用:

  • 进度条叠加中心文字
  • 图片叠加角标或徽章
  • 背景层叠加前景内容
  • 弹框蒙层叠加弹框内容

Stack 中子元素的层级由声明顺序决定——先声明的在底层,后声明的在顶层。如果需要更精细的层级控制,可以使用 .zIndex() 属性显式指定层级数值,数值越大层级越高。

7.2 验光数据展示与条形对比图

        Column() {
          Row() {
            Text('综合验光')
              .fontSize(11)
              .fontColor('#8BB4BC')
            Text('R -3.25 / L -2.75')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0F3B44')
              .margin({ left: 8 })
          }
          .width('100%')
          Row() {
            Text('屈光分类')
              .fontSize(11)
              .fontColor('#8BB4BC')
            Text(dioWord16(-3.25))
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#7C3AED')
              .margin({ left: 8 })
          }
          .width('100%')
          .margin({ top: 6 })
          Row() {
            Text('双眼视力')
              .fontSize(11)
              .fontColor('#8BB4BC')
            Row() {
              Text('L')
                .fontSize(10)
                .fontColor('#0E7490')
              Row() {
                Row()
                  .width('60%')
                  .height(8)
                  .borderRadius(4)
                  .backgroundColor('#0E7490')
              }
              .width(52)
              .height(8)
              .borderRadius(4)
              .backgroundColor('#D6EEF2')
              .margin({ left: 4 })
              Text('R')
                .fontSize(10)
                .fontColor('#7C3AED')
                .margin({ left: 8 })
              Row() {
                Row()
                  .width('50%')
                  .height(8)
                  .borderRadius(4)
                  .backgroundColor('#7C3AED')
              }
              .width(52)
              .height(8)
              .borderRadius(4)
              .backgroundColor('#F3EEFB')
              .margin({ left: 4 })
            }
            .alignItems(VerticalAlign.Center)
            .margin({ left: 8 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 6 })

右侧区域使用 Column 纵向排列三行信息。第一行"综合验光"展示右眼 -3.25 和左眼 -2.75 的屈光度数值。第二行"屈光分类"调用了 dioWord16(-3.25) 函数,传入右眼度数 -3.25,函数返回"中度近视"(因为 -3.25 <= -3.0),以紫色文字展示。

第三行"双眼视力"实现了一个简易的双色条形对比图。左眼(L)使用青色系:外层 Row 作为容器轨道(宽 52、高 8、浅青色背景),内层 Row 作为进度填充(宽 60%、深青色背景),60% 对应 0.6 的裸眼视力值。右眼(R)使用紫色系:同样的结构,但填充宽度为 50%,对应 0.5 的裸眼视力值。这种"轨道 + 填充"的双层 Row 嵌套是纯代码绘制进度条的常见技巧——无需引入图片或 Canvas 绘图,仅用基础容器组件即可实现。

技术要点:纯组件实现的进度条

在 ArkUI 中,简单的进度条不一定要使用 Progress 组件。通过"外层容器作为轨道 + 内层容器作为填充"的嵌套 Row 结构,可以完全自定义进度条的外观:

  • 外层 Row:设置固定的 widthheightborderRadiusbackgroundColor(作为轨道背景色)
  • 内层 Row:设置百分比 width(如 '60%')、相同的 heightborderRadius、不同的 backgroundColor(作为填充色)

这种方式的优势在于完全可控的样式定制——可以自由调整圆角、颜色、高度,甚至添加渐变或动画。劣势是不如 Progress 组件那样支持内置的动画过渡,需要手动实现。

7.3 预约验光按钮与 onClick 事件

          Text('预约验光')
            .fontSize(12)
            .fontColor('#FFFFFF')
            .padding({ left: 18, right: 18, top: 7, bottom: 7 })
            .borderRadius(15)
            .backgroundColor('#0E7490')
            .margin({ top: 10 })
            .onClick(() => {
              this.oItem16 = 0
              this.oSlot16 = 0
              this.oDilate16 = false
              this.showOpto16 = true
            })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 16 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 12 })

"预约验光"按钮使用 Text 组件实现,通过 paddingborderRadiusbackgroundColor 的组合营造出按钮的外观效果。.onClick() 事件处理器在点击时执行四个状态变量的赋值操作:重置检查项目选择(oItem16 = 0)、重置时段选择(oSlot16 = 0)、关闭散瞳开关(oDilate16 = false),然后打开预约验光抽屉(showOpto16 = true)。

这种"先重置表单状态,再打开弹框"的模式确保了每次打开弹框时表单都处于初始状态,避免上次操作的残留数据影响当前操作。由于 @State 变量的修改会自动触发 UI 更新,当 showOpto16 变为 true 时,build() 方法中对应的条件渲染分支会立即生效,弹框组件被渲染到屏幕上。

技术要点:layoutWeight 权重分配

.layoutWeight() 是 ArkUI 弹性布局中的核心属性。它接受一个数值参数,表示当前组件在父容器中占据的"权重份额"。当一个 RowColumn 中有多个子元素设置了 layoutWeight,框架会先为没有设置 layoutWeight 的子元素分配空间,剩余空间再按 layoutWeight 的比例分配给有权重的子元素。

例如,在一个 Row 中有两个子元素,第一个没有 layoutWeight(固定宽度 88),第二个设置了 .layoutWeight(1),那么第二个子元素会占据剩余的全部宽度。如果有两个子元素都设置了 .layoutWeight(1),它们会平分剩余宽度。这种机制非常适合"固定 + 弹性"的混合布局场景。

7.4 屈光度柱状图

      Column() {
        Text('📉 屈光度变化(负值为近视度数)')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        Row({ space: 12 }) {
          ForEach(this.eyes16, (e: EyeT16) => {
            Column() {
              Row() {
                Column() {
                  Row()
                    .width(7)
                    .height(-e.rDio * 9)
                    .borderRadius(3)
                    .backgroundColor('#7C3AED')
                  Row()
                    .width(7)
                    .height(-e.lDio * 9)
                    .borderRadius(3)
                    .backgroundColor('#0E7490')
                    .margin({ left: 4 })
                }
                .height(30)
              }
              .height(30)
              .alignItems(VerticalAlign.Bottom)
              Text(e.date.slice(3))
                .fontSize(8)
                .fontColor('#8BB4BC')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
            .onClick(() => {
              this.selEye16 = e.id - 1
            })
          }, (e: EyeT16) => e.id.toString() + e.rDio.toString())
        }
        .alignItems(VerticalAlign.Bottom)
        .height(56)
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .margin({ top: 10 })
        Row() {
          Text('紫:右眼 · 青:左眼 · 半年加深 50 度,建议增加户外时间')
            .fontSize(10)
            .fontColor('#8BB4BC')
            .layoutWeight(1)
        }
        .width('100%')
        .margin({ top: 8 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 12 })

屈光度柱状图是整个视力 tab 中最具技术亮点的部分。这个图表完全用 ArkUI 基础组件手工"绘制"而成,没有使用任何图表库。

实现原理是:ForEach 遍历 eyes16 验光记录数组,为每条记录生成一组柱状条。每组包含两根柱子——紫色代表右眼屈光度,青色代表左眼屈光度。柱子的高度通过 .height(-e.rDio * 9) 计算得出:由于近视度数是负值(如 -3.25),取负后变为正值(3.25),再乘以 9 得到 29.25 的像素高度。这样度数越深(绝对值越大),柱子越高,直观反映了近视程度的视觉化对比。

外层 Row 使用 .alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐,形成"从底部向上生长"的柱状图效果。内层 Column 的高度固定为 30,限制了柱子的最大高度。柱子下方使用 e.date.slice(3) 截取日期字符串的第 4 个字符开始的部分(如 '08-28' 截取为 '28'),作为 X 轴标签。

ForEach 的键值生成器是 (e: EyeT16) => e.id.toString() + e.rDio.toString(),将 idrDio 拼接为唯一键。这样设计的原因是——当验光记录被删除时,id 会变化,键值也随之变化,确保框架能正确识别列表变化。

技术要点:纯组件手绘图表的思路

在 ArkUI 中,柱状图、折线图等常见图表完全可以使用基础组件手工构建,核心思路是:

  1. 柱状图:用 RowColumn 作为柱子,高度/宽度绑定到数据值
  2. 折线图:用多个定位的 Circle 节点 + LinePolyline 连接
  3. 饼图:用 Progress 环形组件叠加,或使用 Canvas 绘制
  4. 堆叠条:用嵌套 Row,内层多色 Row 按比例分配宽度

纯组件图表的优势是无需引入第三方库,样式完全可控,且天然支持响应式更新。劣势是复杂图表(如带有动画、缩放、tooltip 交互的图表)实现难度较大,此时应考虑使用 Canvas 或第三方图表组件。

7.5 本周用眼时长堆叠条

      Column() {
        Text('📱 本周近距离用眼(小时/天,建议 <4h)')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        ForEach(DAYS16, (d: string, i: number) => {
          Row() {
            Text('周' + d)
              .fontSize(11)
              .fontColor('#8BB4BC')
              .width(40)
            Row() {
              Row()
                .width((HOURSV16[i] * 100 / 10).toString() + '%')
                .height(12)
                .borderRadius(6)
                .backgroundColor(HOURSV16[i] >= 6 ? '#C2410C' : (HOURSV16[i] >= 4 ? '#D97706' : '#0E7490'))
            }
            .layoutWeight(1)
            .height(12)
            .borderRadius(6)
            .backgroundColor('#D6EEF2')
            Text(HOURSV16[i].toString() + 'h')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(HOURSV16[i] >= 6 ? '#C2410C' : '#0F3B44')
              .width(32)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 8 })
        }, (d: string, i: number) => d + i.toString())
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 12 })
    }
    .width('100%')
  }

这个"本周近距离用眼"图表使用了 ForEach 遍历 DAYS16 常量数组(星期一到星期日),同时获取索引 i,用 HOURSV16[i] 取出对应日期的用眼时长数据。这就是前面提到的"双数组索引联动"模式——DAYS16 提供标签,HOURSV16 提供数据值。

每行包含三部分:左侧"周X"标签(固定宽 40),中间进度条(layoutWeight 弹性占满剩余空间),右侧时长数值(固定宽 32,右对齐)。进度条的填充宽度计算为 (HOURSV16[i] * 100 / 10).toString() + '%'——将时长值乘以 10 得到百分比(如 7 小时对应 70%),再拼接百分号转为字符串。最大值 10 小时对应 100% 填充。

填充颜色使用三层嵌套三元运算符实现条件配色:HOURSV16[i] >= 6 ? '#C2410C' : (HOURSV16[i] >= 4 ? '#D97706' : '#0E7490')——超过 6 小时用深橙色(严重超标),4-6 小时用琥珀色(轻度超标),4 小时以下用青色(正常)。这种基于数据阈值的动态配色是数据可视化的核心技巧,让用户通过颜色即可判断数据的健康程度。

ForEach 遍历 DAYS16

获取索引 i

HOURSV16i 取用眼时长

时长 >= 6?

深橙色 严重超标

时长 >= 4?

琥珀色 轻度超标

青色 正常

渲染进度条行

是否遍历完毕?

完成七天用眼图表渲染


八、镜片 Tab 页面详解

8.1 镜片列表卡片

  @Builder
  tabLens16() {
    Column() {
      Row() {
        Text('👓 近视管理镜片')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        Text('点击查看详情')
          .fontSize(11)
          .fontColor('#8BB4BC')
          .margin({ left: 8 })
      }
      .width('100%')
      .margin({ bottom: 10 })

      ForEach(this.lenses16, (l: LensT16) => {
        Column() {
          Row() {
            Column() {
              Text('👓')
                .fontSize(26)
                .margin({ top: 10 })
              Text('热度 ' + l.heat.toString())
                .fontSize(10)
                .fontColor('#FFFFFF')
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor('#00000026')
                .margin({ top: 6 })
            }
            .width(84)
            .height(92)
            .borderRadius(14)
            .backgroundColor(l.id % 3 === 1 ? '#0E7490' : (l.id % 3 === 2 ? '#7C3AED' : '#D97706'))
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)

            Column() {
              Text(l.brand)
                .fontSize(10)
                .fontColor('#8BB4BC')
              Text(l.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#0F3B44')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .margin({ top: 2 })
              Text(l.feature)
                .fontSize(11)
                .fontColor('#5B7A82')
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .margin({ top: 3 })
              Row() {
                Text('¥' + l.price.toString())
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#C2410C')
                Text('详情 >')
                  .fontSize(11)
                  .fontColor('#0E7490')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
                  .onClick(() => {
                    this.selLens16 = l.id - 1
                    this.buyNum16 = 1
                    this.showLens16 = true
                  })
              }
              .width('100%')
              .alignItems(VerticalAlign.Bottom)
              .margin({ top: 6 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 12 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
        }
        .width('100%')
        .padding(12)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .margin({ bottom: 12 })
      }, (l: LensT16) => l.id.toString())
    }
    .width('100%')
  }

镜片 tab 使用 ForEach 遍历 lenses16 数组,为每个镜片生成一张产品卡片。每张卡片采用左右结构:左侧是一个 84x92 的彩色图标区域,右侧是产品信息区域。

左侧图标区域的背景色使用 l.id % 3 取模运算实现三色循环——id 取模为 1 时青色,取模为 2 时紫色,取模为 0 时琥珀色。这种"取模配色"技巧可以在不增加额外数据字段的情况下实现视觉差异化,让列表项在颜色上产生节奏感。

右侧信息区域展示了品牌、名称、特点和价格四个层次的信息。Text(l.name) 使用了 .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })——前者限制最多显示 1 行,后者指定溢出时用省略号(...)截断。Text(l.feature) 则允许最多 2 行,同样用省略号截断。这两个属性配合使用,可以有效处理不同长度的文本内容,防止过长文字破坏布局。

技术要点:文本溢出处理

在 ArkUI 中,文本溢出处理是 UI 适配的重要环节。核心属性包括:

  • .maxLines(n):限制文本最多显示 n 行,超出部分被截断
  • .textOverflow({ overflow: TextOverflow.Ellipsis }):指定截断方式为省略号
  • .textOverflow({ overflow: TextOverflow.Clip }):指定截断方式为直接裁剪

在列表项中使用文本溢出处理尤为重要——因为数据内容长度不可控(产品名称可能很长也可能很短),如果不做溢出处理,长文本会导致布局错乱。通常的做法是:标题限制 1 行,描述允许 2-3 行,确保关键信息优先展示。

“详情 >” 链接的 .onClick() 事件设置了三个状态变量:selLens16 记录当前选中的镜片索引(l.id - 1,因为数组从 0 开始而 id 从 1 开始),buyNum16 重置购买数量为 1,showLens16 设为 true 打开镜片详情弹框。这三个赋值操作共同完成了"打开详情弹框并初始化弹框状态"的任务。


九、训练 Tab 页面详解

9.1 训练进度线性条

  @Builder
  tabTrain16() {
    Column() {
      Row() {
        Text('🎯 视功能训练')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        Text('今日 3/8')
          .fontSize(11)
          .fontColor('#0E7490')
          .margin({ left: 8 })
        Text('打卡')
          .fontSize(11)
          .fontColor('#FFFFFF')
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .borderRadius(12)
          .backgroundColor('#0E7490')
          .margin({ left: 10 })
          .onClick(() => {
            this.tMins16 = 10
            this.tTired16 = 0
            this.showTrain16 = true
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 10 })

      Column() {
        Text('训练进度')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        Row() {
          Progress({ value: this.trains16.filter((t: TrainT16) => t.done).length, total: this.trains16.length, type: ProgressType.Linear })
            .style({ strokeWidth: 10 })
            .color('#0E7490')
            .backgroundColor('#D6EEF2')
            .layoutWeight(1)
          Text(Math.round(this.trains16.filter((t: TrainT16) => t.done).length * 100 / this.trains16.length).toString() + '%')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0E7490')
            .margin({ left: 10 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 12 })

训练进度区域使用了 Progress 组件的线性模式(ProgressType.Linear),与之前视力 tab 中的环形模式不同。value 参数通过 this.trains16.filter((t: TrainT16) => t.done).length 动态计算——使用数组的 .filter() 方法筛选出所有 donetrue 的训练项,再取 .length 得到已完成数量。total 参数使用 this.trains16.length 获取总训练项目数。

进度条右侧的文字百分比通过 Math.round(... * 100 / ...) 计算并四舍五入,展示如"38%"的百分比数值。这里的计算逻辑与 Progress 组件的 value/total 比例完全一致,确保文字和进度条保持同步。

由于 trains16@State 变量,当用户在列表中切换某项训练的完成状态时,filter 计算的结果会变化,Progressvalue 和百分比文字都会自动更新——这就是声明式 UI 的响应式优势,开发者只需修改数据,UI 自动同步。

9.2 训练列表项与状态切换

      ForEach(this.trains16, (t: TrainT16) => {
        Row() {
          Text(t.icon)
            .fontSize(22)
          Column() {
            Text(t.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(t.done ? '#8BB4BC' : '#0F3B44')
            Row() {
              Text(t.mins.toString() + ' 分钟 · ' + t.effect)
                .fontSize(10)
                .fontColor('#8BB4BC')
            }
            .margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 10 })

          Text(t.done ? '✓ 完成' : '待训练')
            .fontSize(11)
            .fontColor(t.done ? '#0E7490' : '#D97706')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .borderRadius(11)
            .backgroundColor(t.done ? '#D6EEF2' : '#FDF3DC')
            .onClick(() => {
              this.trains16 = this.trains16.map((x: TrainT16) => {
                if (x.id === t.id) {
                  return { id: x.id, name: x.name, icon: x.icon, mins: x.mins, done: !x.done, effect: x.effect }
                }
                return x
              })
            })
        }
        .width('100%')
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .alignItems(VerticalAlign.Center)
        .margin({ bottom: 10 })
      }, (t: TrainT16) => t.id.toString() + t.done.toString())
    }
    .width('100%')
  }

每个训练列表项使用 Row 横向布局:左侧 Emoji 图标,中间训练名称和效果(layoutWeight 弹性占满),右侧状态标签。

状态标签的文字、颜色和背景色都通过条件表达式 t.done ? ... : ... 根据完成状态动态决定。已完成时显示"✓ 完成"(青色文字 + 浅青色背景),未完成时显示"待训练"(琥珀色文字 + 浅琥珀色背景)。列表项标题的颜色也随状态变化——已完成时用灰色(表示淡化),未完成时用深色(表示强调)。

状态切换的核心逻辑在 .onClick() 事件中。这里使用了数组的 .map() 方法生成新数组:遍历 trains16 中的每一项,当 x.id === t.id(找到被点击的项)时,返回一个新对象,将 done 字段取反(!x.done),其余字段保持不变。最终将整个新数组赋值给 this.trains16

技术要点:@State 数组的不可变更新模式

在 ArkUI 中,@State 修饰的数组变量必须通过"整体替换"来触发 UI 更新。直接修改数组元素(如 this.trains16[0].done = true)不会触发响应式更新,因为框架监测的是数组引用的变化,而非数组内容的深层变化。

正确的做法是使用 .map().filter().slice() 等数组方法生成新数组,然后将新数组赋值给状态变量。这种"不可变更新"(Immutable Update)模式虽然看起来有些繁琐,但保证了状态变化的可追踪性和可预测性——每次状态变化都是一个全新的数组引用,框架可以精确地检测到变化并触发更新。

ForEach 的键值生成器是 (t: TrainT16) => t.id.toString() + t.done.toString()——将 iddone 拼接为键值。这里特意将 done 纳入键值,是因为当训练状态切换时,done 值变化会导致键值变化,框架会将该项视为"新项"进行重新渲染,从而更新状态标签的样式。如果键值只包含 id,框架可能复用旧组件而不更新样式。


十、青少年 Tab 页面详解

10.1 青少年防控档案卡片

  @Builder
  tabKid16() {
    Column() {
      Row() {
        Text('👨‍👩‍👧 青少年近视防控')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        Text('3 位家庭成员')
          .fontSize(11)
          .fontColor('#8BB4BC')
          .margin({ left: 8 })
      }
      .width('100%')
      .margin({ bottom: 10 })

      ForEach(KIDS16, (k: KidT16) => {
        Column() {
          Row() {
            Column() {
              Text('🧒')
                .fontSize(26)
            }
            .width(52)
            .height(52)
            .borderRadius(26)
            .backgroundColor('#D6EEF2')
            .justifyContent(FlexAlign.Center)

            Column() {
              Row() {
                Text(k.name)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#0F3B44')
                Text(k.age.toString() + ' 岁')
                  .fontSize(11)
                  .fontColor('#8BB4BC')
                  .margin({ left: 8 })
              }
              .alignItems(VerticalAlign.Center)
              Text(k.tag)
                .fontSize(11)
                .fontColor('#7C3AED')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding({ left: 12 })

            Text('建档 >')
              .fontSize(11)
              .fontColor('#0E7490')
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)

青少年 tab 使用 ForEach 遍历 KIDS16 常量数组,为每个孩子生成一张防控档案卡片。卡片顶部是一个 Row,左侧是 52x52 的圆形头像区域(borderRadius(26) 实现圆形),中间是姓名、年龄和状态标签,右侧是"建档 >"链接。

borderRadius(26) 配合 width(52)height(52) 实现了正圆效果——当圆角半径等于宽高的一半时,正方形变为正圆。这是 ArkUI 中创建圆形容器的标准做法。

10.2 三指标数据栏与 layoutWeight 三等分

          Row() {
            Column() {
              Text(k.dio.toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#C2410C')
              Text('等效球镜D')
                .fontSize(9)
                .fontColor('#8BB4BC')
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            Column() {
              Text(k.hours.toString() + 'h')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(k.hours >= 4 ? '#D97706' : '#0E7490')
              Text('日均近距')
                .fontSize(9)
                .fontColor('#8BB4BC')
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            Column() {
              Text(k.outdoor.toString() + 'h')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(k.outdoor >= 2 ? '#0E7490' : '#D97706')
              Text('日均户外')
                .fontSize(9)
                .fontColor('#8BB4BC')
                .margin({ top: 2 })
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ top: 10, bottom: 4 })

三指标数据栏是 layoutWeight 三等分布局的经典案例。外层 Row 中包含三个 Column,每个都设置了 .layoutWeight(1),因此三个 Column 平分 Row 的全部宽度,各占三分之一。每个 Column 内部纵向排列一个大号数值和一个小号标签。

第一个指标"等效球镜D"的数值颜色固定为橙色(#C2410C),因为近视度数本身就是需要关注的指标,统一用警示色强调。第二个指标"日均近距"使用条件配色 k.hours >= 4 ? '#D97706' : '#0E7490'——超过 4 小时用琥珀色警告,否则用青色表示正常。第三个指标"日均户外"的配色逻辑正好相反 k.outdoor >= 2 ? '#0E7490' : '#D97706'——超过 2 小时用青色表示达标,否则用琥珀色提醒不足。

这种"阈值条件配色"在健康数据展示中极为常见。不同指标的"好"与"坏"方向可能不同——用眼时长越低越好,户外时长越高越好——因此条件表达式的分支顺序需要根据指标的语义灵活调整。

技术要点:layoutWeight 实现等分布局

当多个子元素都设置相同的 layoutWeight 值时,它们会等分父容器的可用空间。这是实现"N 等分布局"最简洁的方式。与之相对的是百分比布局(.width('33.33%')),但百分比布局在嵌套场景中容易产生舍入误差,而 layoutWeight 由框架精确计算,不会产生像素偏差。在需要精确等分的场景中,优先使用 layoutWeight

10.3 眼轴监测时间轴

      Column() {
        Text('🗓️ 眼轴监测时间轴')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        ForEach(['2026-01 24.4mm', '2026-04 24.5mm', '2026-08 24.6mm'], (r: string) => {
          Row() {
            Circle({ width: 8, height: 8 })
              .fill('#0E7490')
            Column()
              .width(2)
              .height(22)
              .backgroundColor('#D6EEF2')
            Text(r)
              .fontSize(11)
              .fontColor('#5B7A82')
              .margin({ left: 10 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 4 })
        }, (r: string) => r)
        Text('年增速 0.2mm,处于生理增速上限,继续每 3 个月复查。')
          .fontSize(10)
          .fontColor('#8BB4BC')
          .margin({ top: 8 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 12 })
    }
    .width('100%')
  }

眼轴时间轴使用 ForEach 遍历三个字符串元素,每个元素代表一次眼轴测量记录。每行由三部分组成:一个 8x8 的 Circle 圆形节点(fill 方法设置填充色),一根 2x22 的竖线连接线(用 Column 组件模拟),以及时间轴文字内容。

Circle 是 ArkUI 的基础图形组件,通过 Circle({ width: 8, height: 8 }) 创建并设置尺寸,.fill() 方法设置填充颜色。这里用圆形节点表示时间轴上的每次测量事件,用竖线(实际上是窄高的 Column)将节点串联起来,形成视觉上的时间轴效果。底部附有临床建议文字,告知用户年增速 0.2mm 的临床意义。

技术要点:ArkUI 基础图形组件

ArkUI 提供了 Circle(圆形)、Ellipse(椭圆)、Line(直线)、Polyline(折线)、Polygon(多边形)、Path(自定义路径)、Rect(矩形)等基础图形组件。这些组件通过 .fill() 设置填充色、.stroke() 设置描边色、.strokeWidth() 设置描边宽度。基础图形组件通常用于装饰性元素(如时间轴节点、分隔线、图标等),也可以用于简单的自定义图表绘制。对于复杂的图形绘制需求,应使用 Canvas 组件配合 2D 绘图 API。


十一、我的 Tab 页面详解

11.1 个人信息卡片

  @Builder
  tabMine16() {
    Column() {
      Row() {
        Column() {
          Text('🧑‍🏫')
            .fontSize(30)
        }
        .width(58)
        .height(58)
        .borderRadius(29)
        .backgroundColor('#D6EEF2')
        .justifyContent(FlexAlign.Center)

        Column() {
          Text('江远')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0F3B44')
          Text('视光档案建立 588 天 · 家有 3 位防控成员')
            .fontSize(11)
            .fontColor('#8BB4BC')
            .margin({ top: 4 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 12 })

        Text('病历本 >')
          .fontSize(12)
          .fontColor('#0E7490')
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 12 })

“我的” tab 的个人卡片使用 Row 横向布局,左侧是 58x58 的圆形头像区域,中间是姓名和档案信息,右侧是"病历本"链接。结构与青少年 tab 的档案卡片类似,但尺寸更大,信息更丰富。

11.2 验光记录列表与删除入口

      Column() {
        Text('📋 验光记录(左滑条目可删除)')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        ForEach(this.eyes16, (e: EyeT16) => {
          Row() {
            Text(e.date)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0E7490')
              .width(48)
            Column() {
              Text('R ' + e.rDio.toString() + ' / L ' + e.lDio.toString() + 'D')
                .fontSize(11)
                .fontColor('#0F3B44')
              Text('裸视 R' + e.rNaked.toString() + ' L' + e.lNaked.toString() + ' · ' + e.note)
                .fontSize(10)
                .fontColor('#8BB4BC')
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            Text('🗑️')
              .fontSize(14)
              .onClick(() => {
                this.selEye16 = e.id - 1
                this.armed16 = false
                this.showDel16 = true
              })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .padding({ top: 8, bottom: 8 })
        }, (e: EyeT16) => e.id.toString())
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 12 })

验光记录列表遍历 eyes16 状态数组(而非常量 EYES16),因为这里需要支持删除操作——删除会修改数组内容,必须使用可变的 @State 变量。每行记录包含日期标签、验光数据详情和删除图标。

删除图标的 .onClick() 事件执行了三个操作:selEye16 = e.id - 1 记录被删除项的索引,armed16 = false 重置安全开关为关闭状态,showDel16 = true 打开删除确认弹框。这种"记录目标 + 重置确认 + 打开弹框"的三步模式是危险操作的标准流程,确保用户不会误删数据。

11.3 订单列表

      Column() {
        Text('🧾 我的订单')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        ForEach(this.ords16, (o: OrdT16) => {
          Row() {
            Column() {
              Text(o.item)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#0F3B44')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(o.id + ' · ' + o.date)
                .fontSize(10)
                .fontColor('#8BB4BC')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Column() {
              Text(o.status)
                .fontSize(10)
                .fontColor(o.status === '已完成' ? '#0E7490' : (o.status === '进行中' ? '#7C3AED' : '#D97706'))
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .borderRadius(8)
                .backgroundColor('#D6EEF2')
              Text('¥' + o.amount.toString())
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#0F3B44')
                .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .padding({ top: 10, bottom: 10 })
        }, (o: OrdT16) => o.id + o.status)
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ bottom: 12 })
    }
    .width('100%')
  }

订单列表遍历 ords16 状态数组,每行展示订单项目名称、订单编号、日期、状态标签和金额。状态标签的颜色使用三层嵌套三元运算符实现"三态配色":已完成用青色、进行中用紫色、其余状态(制作中、待就诊)用琥珀色。这种多状态配色在订单管理等业务场景中非常常见。


十二、弹框与抽屉系统详解

12.1 预约验光抽屉 —— optoOverlay16

  @Builder
  optoOverlay16() {
    Column() {
      Column() {
        Row()
          .width(44)
          .height(5)
          .borderRadius(3)
          .backgroundColor('#D6EEF2')
          .margin({ top: 10 })

        Text('📅 预约验光')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
          .margin({ top: 12 })

        Text('检查项目')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5B7A82')
          .margin({ top: 14 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(['综合验光', '眼轴测量', '视功能检查', '眼底照', '角膜地形图'], (a: string, i: number) => {
            Text(a)
              .fontSize(12)
              .fontColor(this.oItem16 === i ? '#FFFFFF' : '#5B7A82')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(12)
              .backgroundColor(this.oItem16 === i ? '#0E7490' : '#EFF9FA')
              .margin({ right: 8, bottom: 8 })
              .onClick(() => {
                this.oItem16 = i
              })
          }, (a: string, i: number) => a + i.toString())
        }
        .width('100%')
        .margin({ top: 8 })

预约验光抽屉是一个从底部弹出的面板。整体结构是嵌套的 Column:外层 Column 作为全屏蒙层(半透明背景),内层 Column 作为白色面板内容。面板顶部有一个 44x5 的圆角拖拽指示条(视觉上暗示可滑动关闭),然后是标题和表单内容。

检查项目选择区域使用了 Flex({ wrap: FlexWrap.Wrap }) 弹性布局容器。Flex 是 ArkUI 中比 RowColumn 更灵活的布局容器,支持子元素自动换行。FlexWrap.Wrap 表示当子元素在一行排不下时自动换到下一行。这在标签选择器场景中特别有用——标签数量和文字长度不固定,需要自动排列。

每个标签项的样式通过 this.oItem16 === i ? ... : ... 条件表达式实现"选中/未选中"两态切换:选中时白字青底,未选中时灰字浅青底。点击时将索引 i 赋值给 this.oItem16,由于是 @State 变量,赋值后所有标签的样式会自动更新——之前选中的标签恢复未选中样式,新选中的标签变为选中样式。

技术要点:Flex 弹性布局

Flex 是 ArkUI 中功能最全面的布局容器,它在 RowColumn 的基础上增加了换行能力。关键属性包括:

  • wrap: FlexWrap.Wrap:允许子元素换行
  • direction: FlexDirection.Row(默认):主轴方向为水平
  • justifyContent:主轴对齐方式
  • alignItems:交叉轴对齐方式

RowColumn 实际上是 Flex 的特化版本——Row 等价于 Flex({ direction: FlexDirection.Row })Column 等价于 Flex({ direction: FlexDirection.Column })。当需要换行能力时,必须使用 Flex 而非 Row/Column

12.2 时段选择与 Toggle 散瞳开关

        Text('时段')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5B7A82')
          .margin({ top: 14 })
        Row({ space: 8 }) {
          ForEach(['周六上午', '周六下午', '周三晚间', '周日上午'], (s: string, i: number) => {
            Text(s)
              .fontSize(12)
              .fontColor(this.oSlot16 === i ? '#FFFFFF' : '#5B7A82')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(12)
              .backgroundColor(this.oSlot16 === i ? '#7C3AED' : '#F3EEFB')
              .onClick(() => {
                this.oSlot16 = i
              })
          }, (s: string, i: number) => s + i.toString())
        }
        .width('100%')
        .margin({ top: 8 })

        Row() {
          Text('需要散瞳(约 40 分钟)')
            .fontSize(12)
            .fontColor('#5B7A82')
            .layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.oDilate16 })
            .selectedColor('#0E7490')
            .onChange((on: boolean) => {
              this.oDilate16 = on
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 14 })

时段选择区域使用 Row 横向排列四个时段标签,选中态用紫色(#7C3AED),与检查项目选中态的青色形成区分——让用户能直观感知"项目选择"和"时段选择"是两个独立的选项组。

散瞳选项使用了 Toggle 开关组件。Toggle({ type: ToggleType.Switch, isOn: this.oDilate16 }) 创建一个滑动开关样式(ToggleType.Switch)的开关,初始状态绑定到 oDilate16 状态变量。.selectedColor('#0E7490') 设置开关打开时的颜色为青色。.onChange((on: boolean) => { this.oDilate16 = on }) 监听开关状态变化,将新状态同步到状态变量——这就是双向绑定的实现:状态变量驱动开关初始显示,开关交互反向更新状态变量。

技术要点:Toggle 开关组件

Toggle 是 ArkUI 中的开关组件,支持两种类型:

  • ToggleType.Switch:滑动开关样式,类似 iOS 的 UISwitch
  • ToggleType.Checkbox:复选框样式

核心属性和方法:

  • isOn:初始开关状态(布尔值)
  • .selectedColor():开关打开时的颜色
  • .onChange((isOn: boolean) => {}):状态变化回调
  • .onClick():点击事件(与 onChange 类似但参数不同)

Toggle 在表单中常用于布尔值的选择场景,如"是否同意条款"、"是否开启通知"等。与 Text 按钮不同,Toggle 提供了原生的开关交互体验,用户期望通过滑动而非点击来切换状态。

12.3 抽屉的蒙层与关闭机制

        Row({ space: 10 }) {
          Text('取消')
            .fontSize(13)
            .fontColor('#5B7A82')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(19)
            .backgroundColor('#D6EEF2')
            .onClick(() => {
              this.showOpto16 = false
            })
          Text('确认预约')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(19)
            .backgroundColor('#0E7490')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 16, bottom: 18 })
      }
      .width('100%')
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#FFFFFF')
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#99053040')
    .justifyContent(FlexAlign.End)
    .zIndex(999)
    .onClick(() => {
      this.showOpto16 = false
    })
  }

抽屉底部的操作按钮区使用 Row 横向排列"取消"和"确认预约"两个按钮,各占 layoutWeight(1) 即一半宽度。"取消"按钮点击后设置 showOpto16 = false 关闭抽屉。

蒙层机制是这个抽屉实现的关键。外层 Column 设置了:

  • .width('100%').height('100%'):占据整个屏幕
  • .backgroundColor('#99053040'):半透明深色背景(#99 是十六进制透明度前缀,表示约 60% 不透明度)
  • .justifyContent(FlexAlign.End):子元素(白色面板)在底部对齐
  • .zIndex(999):层级设为 999,确保覆盖在页面内容之上
  • .onClick(() => { this.showOpto16 = false }):点击蒙层区域关闭抽屉

内层白色面板的 .onClick(() => {}) 是一个空函数——这是"事件冒泡阻断"技巧。当用户点击白色面板内部时,点击事件被空函数"消费"掉,不会冒泡到外层蒙层的 onClick,从而避免点击面板内容时误关闭抽屉。如果省略这个空函数,点击面板内任何位置都会触发蒙层的关闭逻辑。

技术要点:zIndex 与事件冒泡

.zIndex(n) 控制组件在 Stack 容器或同层级中的堆叠层级,数值越大越在上层。在弹框场景中,蒙层和弹框内容通常设置较高的 zIndex(如 999),确保覆盖在页面内容之上。

事件冒泡是 ArkUI 事件系统的核心机制——当子组件的 onClick 被触发后,事件会沿组件树向上传播,依次触发父组件的 onClick。如果需要在子组件中阻止冒泡,可以在子组件上绑定一个空的 onClick(() => {}) 来"消费"事件。这种模式在"点击弹框外部关闭弹框"的场景中极为常用。

12.4 镜片详情居中弹框 —— lensOverlay16

  @Builder
  lensOverlay16() {
    Column() {
      Column() {
        Text('👓 ' + LENSES16[this.selLens16].brand)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
        Text(LENSES16[this.selLens16].name)
          .fontSize(12)
          .fontColor('#5B7A82')
          .margin({ top: 6 })

        Row() {
          Column() {
            Text(LENSES16[this.selLens16].heat.toString())
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#7C3AED')
            Text('热度指数')
              .fontSize(10)
              .fontColor('#8BB4BC')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          Column() {
            Text('60%')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0E7490')
            Text('近视延缓率')
              .fontSize(10)
              .fontColor('#8BB4BC')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          Column() {
            Text('12-18月')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#D97706')
            Text('更换周期')
              .fontSize(10)
              .fontColor('#8BB4BC')
              .margin({ top: 2 })
          }
          .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#EFF9FA')
        .margin({ top: 14 })

        Text(LENSES16[this.selLens16].feature + '。适配前需进行完整医学验光与视功能评估,建议配合每日 2 小时以上户外活动与 20-20-20 用眼法则,效果更佳。')
          .fontSize(11)
          .fontColor('#5B7A82')
          .lineHeight(18)
          .margin({ top: 14 })

镜片详情弹框与预约验光抽屉不同——它使用居中弹出的方式(justifyContent(FlexAlign.Center)),而非从底部滑出。弹框内容根据 selLens16 状态变量从 LENSES16 常量数组中取出对应镜片的详情数据。

三指标数据栏(热度指数、近视延缓率、更换周期)使用 layoutWeight 三等分布局,与青少年 tab 的三指标栏结构相同。但这里的数据来源不同——热度指数来自镜片数据的 heat 字段,近视延缓率和更换周期是固定值(所有镜片统一为 60% 和 12-18 月),实际应用中这些数据应该从后端接口获取。

详情描述文字使用 .lineHeight(18) 设置行高为 18,比默认行高更宽松,提升了多行文本的阅读舒适度。行高设置在长文本展示中是提升可读性的重要手段——过紧的行距会让文字显得拥挤,过松则破坏段落整体感。

12.5 镜片详情弹框的价格区与配镜入口

        Row() {
          Text('¥' + LENSES16[this.selLens16].price.toString())
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#C2410C')
          Text('开学季立减 300')
            .fontSize(10)
            .fontColor('#C2410C')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(9)
            .backgroundColor('#FEEBDD')
            .margin({ left: 8 })
          Text('去配镜')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 20, right: 20, top: 9, bottom: 9 })
            .borderRadius(19)
            .backgroundColor('#0E7490')
            .layoutWeight(1)
            .textAlign(TextAlign.End)
            .onClick(() => {
              this.showLens16 = false
              this.buyNum16 = 1
              this.showBuy16 = true
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 16 })
      }
      .width('88%')
      .padding(18)
      .borderRadius(20)
      .backgroundColor('#FFFFFF')
      .constraintSize({ maxHeight: '78%' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#99053040')
    .justifyContent(FlexAlign.Center)
    .zIndex(999)
    .onClick(() => {
      this.showLens16 = false
    })
  }

"去配镜"按钮的 .onClick() 执行了一个"弹框切换"操作序列:先关闭镜片详情弹框(showLens16 = false),重置购买数量(buyNum16 = 1),再打开配镜下单抽屉(showBuy16 = true)。这种"关闭当前弹框 + 打开下一个弹框"的链式操作在多步骤业务流程中很常见——用户从详情页进入下单页,形成连续的操作路径。

内层面板设置了 .constraintSize({ maxHeight: '78%' })——constraintSize 是 ArkUI 中的尺寸约束属性,可以设置 maxWidthmaxHeightminWidthminHeight 四个约束。这里限制面板最大高度为屏幕的 78%,防止内容过多时超出屏幕范围。宽度设为 88%,在屏幕中居中显示,两侧留出蒙层区域。

12.6 训练打卡抽屉 —— trainOverlay16

  @Builder
  trainOverlay16() {
    Column() {
      Column() {
        Row()
          .width(44)
          .height(5)
          .borderRadius(3)
          .backgroundColor('#D6EEF2')
          .margin({ top: 10 })

        Text('🎯 训练打卡')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
          .margin({ top: 12 })

        Row() {
          Text('本次时长')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#5B7A82')
          Row() {
            Text('-')
              .fontSize(15)
              .fontColor('#5B7A82')
              .padding(6)
              .onClick(() => {
                if (this.tMins16 > 5) {
                  this.tMins16 = this.tMins16 - 5
                }
              })
            Text(this.tMins16.toString() + ' 分钟')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0E7490')
              .padding({ left: 10, right: 10 })
            Text('+')
              .fontSize(15)
              .fontColor('#D97706')
              .padding(6)
              .onClick(() => {
                if (this.tMins16 < 40) {
                  this.tMins16 = this.tMins16 + 5
                }
              })
          }
          .borderRadius(10)
          .backgroundColor('#EFF9FA')
          .margin({ left: 12 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 16 })

训练打卡抽屉实现了一个"步进器"控件——通过"-"和"+"按钮调整训练时长,每次增减 5 分钟,范围限制在 5-40 分钟之间。步进器使用三个 Text 组件横向排列:左减号、中间数值、右加号,外层 Row 提供浅色背景和圆角,形成一体化的控件外观。

"-"按钮的 onClick 中有条件判断 if (this.tMins16 > 5)——只有当前值大于 5 时才允许减少,防止数值过低。"+"按钮同理,if (this.tMins16 < 40) 确保不超过 40 分钟上限。这种"边界检查"是数值输入控件的基本安全措施。

技术要点:步进器控件的实现思路

ArkUI 没有内置的 Stepper 步进器组件,但可以通过 Row + Text(或 Button)轻松实现:

  1. 外层 Row 提供容器背景和圆角
  2. 左侧"-"文本绑定减少逻辑
  3. 中间数值文本绑定状态变量
  4. 右侧"+"文本绑定增加逻辑
  5. 在增减逻辑中加入边界检查

这种自定义步进器的优势在于样式完全可控,可以自由调整颜色、间距、字体大小等。如果需要更丰富的交互(如长按连续增减),可以在 Text 上绑定 gesture 手势识别器来实现。

12.7 训练感受选择与动态提示

        Text('训练后感受')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5B7A82')
          .margin({ top: 16 })
        Row({ space: 8 }) {
          ForEach(['轻松', '刚好', '酸胀', '吃力'], (f: string, i: number) => {
            Text(f)
              .fontSize(12)
              .fontColor(this.tTired16 === i ? '#FFFFFF' : '#5B7A82')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(12)
              .backgroundColor(this.tTired16 === i ? '#7C3AED' : '#F3EEFB')
              .onClick(() => {
                this.tTired16 = i
              })
          }, (f: string, i: number) => f + i.toString())
        }
        .width('100%')
        .margin({ top: 8 })

        Text(this.tTired16 >= 2 ? '酸胀感明显时建议降低难度,由视训师重新评估处方。' : '节奏合适,继续保持每日训练。')
          .fontSize(10)
          .fontColor(this.tTired16 >= 2 ? '#C2410C' : '#0E7490')
          .margin({ top: 12 })

训练感受选择器使用 ForEach 渲染四个感受标签(轻松、刚好、酸胀、吃力),与检查项目和时段选择的实现模式一致。但这里多了一个"动态提示文字"——当 tTired16 >= 2(即选择了"酸胀"或"吃力")时,提示文字变为橙色警告语"酸胀感明显时建议降低难度…“;否则显示青色鼓励语"节奏合适,继续保持每日训练”。

这种"根据选择动态切换提示内容"的模式在表单交互中非常有用——它让界面能够根据用户的输入给出即时反馈,引导用户做出正确的操作。提示文字的内容和颜色都通过条件表达式动态决定,内容从鼓励变为警告,颜色从青色变为橙色,形成了双重视觉反馈。

12.8 编辑用眼提醒抽屉 —— remindOverlay16

  @Builder
  remindOverlay16() {
    Column() {
      Column() {
        Row()
          .width(44)
          .height(5)
          .borderRadius(3)
          .backgroundColor('#D6EEF2')
          .margin({ top: 10 })

        Text('🔔 编辑用眼提醒')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
          .margin({ top: 12 })

        Text('提醒间隔')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5B7A82')
          .margin({ top: 14 })
        Row({ space: 8 }) {
          ForEach(['20 分钟', '30 分钟', '45 分钟', '60 分钟'], (g: string, i: number) => {
            Text(g)
              .fontSize(12)
              .fontColor(this.rGap16 === i ? '#FFFFFF' : '#5B7A82')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .borderRadius(12)
              .backgroundColor(this.rGap16 === i ? '#0E7490' : '#EFF9FA')
              .onClick(() => {
                this.rGap16 = i
              })
          }, (g: string, i: number) => g + i.toString())
        }
        .width('100%')
        .margin({ top: 8 })

        Row() {
          Text('🌙 夜间自动开启护眼模式')
            .fontSize(12)
            .fontColor('#5B7A82')
            .layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.rNight16 })
            .selectedColor('#7C3AED')
            .onChange((on: boolean) => {
              this.rNight16 = on
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 16 })

        Text('开启后,到点会提醒孩子抬头远眺 20 秒,并联动记录户外时长。')
          .fontSize(10)
          .fontColor('#8BB4BC')
          .margin({ top: 12 })

编辑用眼提醒抽屉的结构与预约验光抽屉类似——拖拽指示条、标题、标签选择器、Toggle 开关。提醒间隔标签选中态使用青色,夜间护眼模式开关的 selectedColor 使用紫色,两个控件的色彩区分让用户能感知它们控制的是不同的设置项。

底部有一行说明文字"开启后,到点会提醒孩子抬头远眺 20 秒…"——这种功能说明文字在设置类表单中很重要,帮助用户理解开关打开后的具体效果,减少操作困惑。

12.9 删除确认弹框 —— delOverlay16 的安全机制

  @Builder
  delOverlay16() {
    Column() {
      Column() {
        Text('⚠️')
          .fontSize(30)
          .margin({ top: 16 })
        Text('删除这条验光记录?')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0F3B44')
          .margin({ top: 10 })
        Text(this.eyes16.length > this.selEye16 ? this.eyes16[this.selEye16].date + ' · R ' + this.eyes16[this.selEye16].rDio.toString() + ' / L ' + this.eyes16[this.selEye16].lDio.toString() + 'D' : '')
          .fontSize(12)
          .fontColor('#8BB4BC')
          .margin({ top: 6 })
        Text('删除后屈光发展趋势曲线将缺失该点,医生对比时少一次参考,无法恢复。')
          .fontSize(11)
          .fontColor('#C2410C')
          .lineHeight(17)
          .margin({ top: 8 })
          .padding({ left: 14, right: 14 })

删除确认弹框是一个居中弹出的窄型危险操作确认卡。弹框顶部是警告图标,然后是标题、目标记录摘要和警告说明。目标记录摘要通过条件表达式 this.eyes16.length > this.selEye16 ? ... : '' 安全访问数组元素——先检查索引是否越界,再访问数据,避免数组越界异常。这种防御性编程在访问可能无效索引的数组时非常重要。

警告说明文字使用橙色(#C2410C),明确告知用户"删除后无法恢复"以及删除对临床数据的影响——这种后果说明比简单的"确定删除吗?"更能引起用户重视,减少误删概率。

12.10 armed16 安全开关与删除执行

        Row() {
          Text('我确认删除')
            .fontSize(11)
            .fontColor('#5B7A82')
            .layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.armed16 })
            .selectedColor('#C2410C')
            .selectedColor('#C2410C')
            .onChange((on: boolean) => {
              this.armed16 = on
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ left: 16, right: 16, top: 12 })

        Row({ space: 10 }) {
          Text('再想想')
            .fontSize(13)
            .fontColor('#5B7A82')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(18)
            .backgroundColor('#D6EEF2')
            .onClick(() => {
              this.showDel16 = false
            })
          Text(this.armed16 ? '删除' : '请先打开开关')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(18)
            .backgroundColor(this.armed16 ? '#C2410C' : '#B8CDD3')
            .onClick(() => {
              if (this.armed16 && this.selEye16 < this.eyes16.length) {
                this.eyes16 = this.eyes16.filter((x: EyeT16, i: number) => i !== this.selEye16)
                this.showDel16 = false
              }
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 16 })
      }
      .width('74%')
      .borderRadius(18)
      .backgroundColor('#FFFFFF')
      .border({ width: 1.5, color: '#F5D2C0' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#993F2008')
    .justifyContent(FlexAlign.Center)
    .zIndex(999)
    .onClick(() => {
      this.showDel16 = false
    })
  }

删除弹框的安全机制由 armed16 状态变量实现。弹框中有一个"我确认删除"的 Toggle 开关,只有当用户打开此开关(armed16true)时,删除按钮才会变为可用状态(橙色背景 + "删除"文字),否则显示为灰色不可用状态(灰色背景 + "请先打开开关"文字)。

删除按钮的 .onClick() 中有两层条件检查:if (this.armed16 && this.selEye16 < this.eyes16.length)——首先检查安全开关是否打开,然后检查选中索引是否有效。两个条件都满足时才执行删除操作:this.eyes16 = this.eyes16.filter((x: EyeT16, i: number) => i !== this.selEye16) 使用 .filter() 方法过滤掉索引为 selEye16 的元素,生成新数组并赋值给状态变量。

弹框面板设置了 .border({ width: 1.5, color: '#F5D2C0' })——1.5 像素宽的浅橙色边框,与危险操作的语义色彩呼应。蒙层背景色为 #993F2008——比其他弹框的蒙层颜色更深更暖,营造更严肃的视觉氛围。

技术要点:防误删的安全设计模式

在涉及不可逆操作(如删除数据)时,应采用多重安全措施:

  1. 确认弹框:在执行前弹出确认对话框,而非直接执行
  2. 后果说明:明确告知用户操作的不可逆性和潜在影响
  3. 安全开关:要求用户主动打开开关才能激活操作按钮
  4. 按钮状态联动:安全开关关闭时,操作按钮显示为不可用状态(灰色 + 提示文字)
  5. 双重条件检查:在事件处理逻辑中再次验证安全条件,防止 UI 状态与逻辑不同步

这种层层递进的安全设计虽然增加了操作步骤,但能有效防止用户误操作导致的数据丢失。

12.11 配镜下单抽屉 —— buyOverlay16

  @Builder
  buyOverlay16() {
    Column() {
      Column() {
        Row()
          .width(44)
          .height(5)
          .borderRadius(3)
          .backgroundColor('#D6EEF2')
          .margin({ top: 10 })

        Row() {
          Text('🛒 配镜下单')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#0F3B44')
          Text(LENSES16[this.selLens16].name)
            .fontSize(11)
            .fontColor('#0E7490')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .layoutWeight(1)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ left: 16, right: 16, top: 12 })

        Row() {
          Text('镜片数量(双眼)')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#5B7A82')
          Row() {
            Text('-')
              .fontSize(16)
              .fontColor('#5B7A82')
              .padding(8)
              .onClick(() => {
                if (this.buyNum16 > 1) {
                  this.buyNum16 = this.buyNum16 - 1
                }
              })
            Text(this.buyNum16.toString())
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0F3B44')
              .padding({ left: 12, right: 12 })
            Text('+')
              .fontSize(16)
              .fontColor('#D97706')
              .padding(8)
              .onClick(() => {
                if (this.buyNum16 < 3) {
                  this.buyNum16 = this.buyNum16 + 1
                }
              })
          }
          .borderRadius(12)
          .backgroundColor('#EFF9FA')
          .margin({ left: 12 })
          Text('一副起配 · 含镜架验配')
            .fontSize(10)
            .fontColor('#8BB4BC')
            .margin({ left: 10 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ left: 16, right: 16 })
        .margin({ top: 18 })

配镜下单抽屉的镜片数量步进器与训练打卡抽屉的时长步进器结构一致,但边界不同——最少 1 副(buyNum16 > 1),最多 3 副(buyNum16 < 3)。步进器右侧有"一副起配 · 含镜架验配"的说明文字,帮助用户理解计价规则。

12.12 价格汇总与订单提交

        Column() {
          Row() {
            Text('镜片小计')
              .fontSize(12)
              .fontColor('#8BB4BC')
            Text('¥' + (LENSES16[this.selLens16].price * this.buyNum16).toString())
              .fontSize(12)
              .fontColor('#0F3B44')
              .layoutWeight(1)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          Row() {
            Text('开学季立减')
              .fontSize(12)
              .fontColor('#8BB4BC')
            Text('-¥' + (300 * this.buyNum16).toString())
              .fontSize(12)
              .fontColor('#0E7490')
              .layoutWeight(1)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 6 })
          Row() {
            Text('合计')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0F3B44')
            Text('¥' + (LENSES16[this.selLens16].price * this.buyNum16 - 300 * this.buyNum16).toString())
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#C2410C')
              .layoutWeight(1)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .borderRadius(14)
        .backgroundColor('#EFF9FA')
        .margin({ top: 18, left: 16, right: 16 })

        Row({ space: 10 }) {
          Text('再看看')
            .fontSize(13)
            .fontColor('#5B7A82')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(19)
            .backgroundColor('#D6EEF2')
            .onClick(() => {
              this.showBuy16 = false
            })
          Text('提交订单')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(19)
            .backgroundColor('#0E7490')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 18, bottom: 18 })
      }
      .width('100%')
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#FFFFFF')
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#99053040')
    .justifyContent(FlexAlign.End)
    .zIndex(999)
    .onClick(() => {
      this.showBuy16 = false
    })
  }

价格汇总区域展示了三行:镜片小计(单价乘以数量)、开学季立减(每副减 300,乘以数量)、合计(小计减立减)。所有金额都通过表达式动态计算,当用户通过步进器调整数量时,三行金额会自动更新——这是声明式 UI 的响应式优势在价格计算场景中的体现。

合计金额使用 18 号字体和橙色,是整个抽屉中视觉层级最高的文字,引导用户关注最终价格。"提交订单"按钮使用青色背景,与"再看看"取消按钮形成主次对比。

技术要点:borderRadius 的方向性圆角

.borderRadius({ topLeft: 22, topRight: 22 }) 可以分别设置四个角的圆角半径。在底部抽屉场景中,只需顶部两角为圆角(topLefttopRight),底部两角为直角,使面板顶部呈圆弧状与屏幕顶部衔接。这种方向性圆角是 ArkUI 精细化样式控制的体现,支持 topLefttopRightbottomLeftbottomRight 四个方向的独立设置。


十三、底部 Tab 导航栏

13.1 tabBar16 的五标签实现

  @Builder
  tabBar16() {
    Row() {
      ForEach([['👁️', '视力'], ['👓', '镜片'], ['🎯', '训练'], ['👨‍👩‍👧', '青少年'], ['👤', '我的']], (t: string[], i: number) => {
        Column() {
          Text(t[0])
            .fontSize(20)
            .opacity(this.tab16 === i ? 1 : 0.45)
            .scale(this.tab16 === i ? { x: 1.12, y: 1.12 } : { x: 1, y: 1 })
          Text(t[1])
            .fontSize(10)
            .fontColor(this.tab16 === i ? '#0E7490' : '#8BB4BC')
            .fontWeight(this.tab16 === i ? FontWeight.Bold : FontWeight.Normal)
            .margin({ top: 3 })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 })
        .onClick(() => {
          this.tab16 = i
        })
      }, (t: string[], i: number) => t[1] + i.toString())
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }

底部 tab 栏使用 Row 横向排列五个标签项,每个标签通过 layoutWeight(1) 等分宽度。每个标签项是一个 Column,包含 Emoji 图标和中文文字标签。

标签的选中/未选中状态通过三层视觉差异来表现:

  1. 透明度:选中时图标完全不透明(opacity: 1),未选中时半透明(opacity: 0.45
  2. 缩放:选中时图标放大 1.12 倍(scale: { x: 1.12, y: 1.12 }),未选中时原始大小
  3. 颜色与字重:选中时文字青色加粗,未选中时灰色常规

点击标签时设置 this.tab16 = i,由于 tab16@State 变量,赋值后所有标签的选中状态会自动更新——之前选中的标签恢复未选中样式,新选中的标签变为选中样式。

技术要点:scale 缩放属性

.scale({ x: 1.12, y: 1.12 }) 用于对组件进行二维缩放。x 是水平缩放比例,y 是垂直缩放比例,1.0 表示原始大小。在 tab 栏中,选中标签轻微放大(1.12 倍)可以增强视觉层次,让用户更清楚地感知当前选中项。scaleopacityrotate(旋转)、translate(平移)同属于 ArkUI 的几何变换属性,可以在不改变布局的前提下对组件进行视觉变换。


十四、build 方法:整体页面组装

14.1 Stack 层叠布局与条件渲染

  build() {
    Stack() {
      Column() {
        this.headerBar16()

        Scroll() {
          Column() {
            if (this.tab16 === 0) {
              this.tabVision16()
            } else if (this.tab16 === 1) {
              this.tabLens16()
            } else if (this.tab16 === 2) {
              this.tabTrain16()
            } else if (this.tab16 === 3) {
              this.tabKid16()
            } else {
              this.tabMine16()
            }
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 10, bottom: 20 })
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .width('100%')
        .backgroundColor('#EFF9FA')
        .edgeEffect(EdgeEffect.Spring)

        this.tabBar16()
      }
      .width('100%')
      .height('100%')

      if (this.showOpto16) {
        this.optoOverlay16()
      }
      if (this.showLens16) {
        this.lensOverlay16()
      }
      if (this.showTrain16) {
        this.trainOverlay16()
      }
      if (this.showRemind16) {
        this.remindOverlay16()
      }
      if (this.showDel16) {
        this.delOverlay16()
      }
      if (this.showBuy16) {
        this.buyOverlay16()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#EFF9FA')
  }
}

build() 方法是整个组件的 UI 组装入口,使用 Stack 作为根容器。Stack 中包含两层内容:

第一层(底层):主页面结构,由 Column 纵向排列三部分——顶部标题栏(headerBar16())、中间滚动内容区(Scroll)、底部 tab 栏(tabBar16())。

第二层(上层):六个弹框组件,通过 if 条件渲染控制显隐。每个弹框对应一个 @State 布尔变量——当变量为 true 时,对应的 @Builder 方法被调用,弹框组件被渲染到 Stack 的上层;当变量为 false 时,if 分件不执行,弹框不渲染。

中间内容区使用 Scroll 纵向滚动容器包裹,设置 .scrollable(ScrollDirection.Vertical) 支持纵向滚动,.scrollBar(BarState.Off) 隐藏滚动条,.edgeEffect(EdgeEffect.Spring) 设置滚动到边缘时的弹簧回弹效果。.layoutWeight(1) 让滚动区域占据标题栏和 tab 栏之间的全部剩余空间。

滚动区域内部使用 if-else if-else 条件分支根据 tab16 的值决定渲染哪个 tab 页面。当 tab16 为 0 时渲染视力 tab,为 1 时渲染镜片 tab,依此类推。这种条件渲染确保同一时刻只有当前 tab 的内容被渲染到 DOM 中,其他 tab 的内容不存在于组件树中——这比"全部渲染但隐藏不可见"的方式更加高效,节省了内存和渲染开销。

技术要点:Stack 在弹框管理中的核心作用

Stack 是实现弹框层叠管理的关键容器。在 Stack 中,后声明的子元素覆盖在先声明的子元素之上。弹框组件声明在主页面内容之后,因此自然覆盖在页面上层。配合 if 条件渲染,实现了弹框的按需显示和隐藏。

这种"Stack + if 条件渲染 + @State 布尔控制"的三位一体模式是 ArkUI 中实现弹框/抽屉/蒙层的标准方案。它的优势在于:

  • 弹框逻辑与页面逻辑在同一组件中,共享状态,无需跨组件通信
  • 条件渲染确保弹框不显示时不占用资源
  • zIndex 控制确保弹框始终在最上层
  • 点击蒙层关闭只需设置对应的布尔值为 false

技术要点:edgeEffect 滚动边缘效果

.edgeEffect() 设置滚动到边缘时的视觉效果,支持三种模式:

  • EdgeEffect.None:无效果,滚动到边缘直接停止
  • EdgeEffect.Spring:弹簧回弹效果,模拟物理弹簧的过度滚动
  • EdgeEffect.Fade:渐隐效果,边缘内容渐变消失

EdgeEffect.Spring 是最常用的模式,提供了自然的触觉反馈,让用户感知到滚动已到达边界。这种细节体验是移动端 UI 设计中不可忽视的一环。

0

1

2

3

4

true

false

true

false

true

false

build 方法执行

Stack 根容器

第一层: 主页面 Column

headerBar16 顶部标题栏

Scroll 滚动内容区

tab16 当前值?

tabVision16 视力页

tabLens16 镜片页

tabTrain16 训练页

tabKid16 青少年页

tabMine16 我的页

tabBar16 底部导航栏

第二层: 弹框条件渲染

showOpto16?

optoOverlay16 预约验光抽屉

不渲染

showLens16?

lensOverlay16 镜片详情弹框

不渲染

其他弹框布尔值...

渲染对应弹框

不渲染


十五、技术对比总表

下面将通过一个综合对比表格,系统梳理本应用中涉及的各类数据结构、状态变量、装饰器、组件和技术模式,帮助读者建立完整的知识图谱。

序号 类别 名称 类型/签名 作用描述 使用场景 响应式特性
1 接口 EyeT16 interface 验光记录数据结构 视力 tab 图表、我的 tab 记录列表 不适用(纯数据类型)
2 接口 LensT16 interface 镜片产品数据结构 镜片 tab 列表、详情弹框 不适用(纯数据类型)
3 接口 TrainT16 interface 训练项目数据结构 训练 tab 列表与进度条 不适用(纯数据类型)
4 接口 KidT16 interface 青少年防控档案结构 青少年 tab 档案卡片 不适用(纯数据类型)
5 接口 OrdT16 interface 订单数据结构 我的 tab 订单列表 不适用(纯数据类型)
6 装饰器 @Entry struct 级 标记页面入口组件 Index 组件声明 框架自动注册路由
7 装饰器 @Component struct 级 声明自定义组件 Index 组件声明 框架管理组件生命周期
8 装饰器 @State 属性级 组件内部响应式状态 23 个状态变量 值变化触发 UI 更新
9 装饰器 @Builder 方法级 定义可复用 UI 片段 14 个 Builder 方法 无独立状态,访问宿主状态
10 状态变量 tab16 number 当前 tab 页面索引 tab 切换与条件渲染 变化触发 tab 内容切换
11 状态变量 eyes16 Array 验光记录数组 视力图表、记录列表、删除 整体替换触发更新
12 状态变量 trains16 Array 训练项目数组 训练列表、进度计算 map/filter 替换触发更新
13 状态变量 showOpto16 boolean 预约验光抽屉显隐 弹框条件渲染 true/false 控制渲染
14 状态变量 showDel16 boolean 删除确认弹框显隐 弹框条件渲染 true/false 控制渲染
15 状态变量 armed16 boolean 删除安全开关 防误删二次确认 控制删除按钮可用性
16 状态变量 eyeOp16 number 眨眼动画透明度 顶部图标呼吸效果 animateTo 驱动连续变化
17 状态变量 blink16 number 闪烁动画透明度 顶部图标闪烁效果 animateTo 驱动连续变化
18 容器组件 Column 布局容器 纵向排列子元素 几乎所有布局结构 不适用(布局组件)
19 容器组件 Row 布局容器 横向排列子元素 标题栏、列表项、按钮组 不适用(布局组件)
20 容器组件 Stack 布局容器 层叠排列子元素 进度环+文字、弹框层叠 不适用(布局组件)
21 容器组件 Flex 布局容器 弹性布局,支持换行 标签选择器自动换行 不适用(布局组件)
22 容器组件 Scroll 滚动容器 内容滚动区域 横向公告、纵向页面内容 不适用(布局组件)
23 基础组件 Text 显示组件 文本展示 标题、标签、数值、按钮 不适用(展示组件)
24 基础组件 Progress 进度组件 环形/线性进度展示 视力环形进度、训练线性进度 value 变化自动更新
25 基础组件 Toggle 交互组件 开关切换 散瞳选项、护眼模式、删除确认 onChange 回调更新状态
26 基础组件 Circle 图形组件 圆形绘制 时间轴节点 不适用(静态图形)
27 布局属性 layoutWeight 弹性权重 分配剩余空间 三等分数据栏、按钮等分 不适用(布局属性)
28 布局属性 zIndex 层级控制 设置堆叠层级 弹框蒙层覆盖(999) 不适用(布局属性)
29 视觉属性 opacity 透明度 控制组件不透明度 动画呼吸效果、tab 选中态 绑定状态变量响应更新
30 视觉属性 scale 缩放变换 二维缩放组件 tab 选中图标放大 绑定状态变量响应更新
31 交互方法 onClick 事件绑定 点击事件处理 按钮点击、标签选择、弹框关闭 在回调中修改状态触发更新
32 动画 API animateTo 显式动画 状态驱动过渡动画 顶部图标呼吸与闪烁 iterations:-1 无限循环
33 列表渲染 ForEach 列表组件 遍历数组渲染 UI 镜片列表、训练列表、记录列表 键值驱动 diff 更新
34 条件渲染 if-else 控制流 条件渲染 UI 分支 tab 页面切换、弹框显隐 条件变化触发增删组件
35 生命周期 aboutToAppear 生命周期回调 组件创建后初始化 启动动画、数据初始化 在 build 前执行一次

十六、总结与反思

16.1 声明式 UI 范式的核心价值

通过对这个眼视光中心应用的逐行解析,我们可以深刻体会到声明式 UI 范式带来的开发效率提升。在传统的命令式 UI 编程中,开发者需要手动操作 DOM 节点——创建元素、设置属性、添加事件监听器、在数据变化时找到对应节点并更新——这些繁琐的操作在声明式 UI 中全部被消除了。开发者只需用 @State 声明状态变量,在 build() 中描述状态与视图的映射关系,框架会自动处理"状态变化到视图更新"的全部流程。这种"数据驱动视图"的思想不仅减少了代码量,更重要的是降低了状态管理的复杂度——开发者可以专注于业务逻辑本身,而不必关心 UI 同步的细节。

在本应用中,这一优势体现在多个方面:tab 切换只需修改 tab16 一个变量,五个页面的渲染自动切换;训练进度条只需修改 trains16 数组中的 done 字段,进度百分比和线性条自动更新;弹框显隐只需修改对应的布尔变量,弹框组件自动增删。如果用命令式编程实现同样的功能,每次状态变化都需要手动查找和更新多个 DOM 节点,代码量和出错概率都会大幅增加。

16.2 @Builder 与组件化复用策略

本应用大量使用 @Builder 装饰器来组织 UI 代码,共定义了 14 个 @Builder 方法。@Builder 的定位介于"内联代码"和"独立组件"之间——它比直接在 build() 中写所有代码更加模块化,但又不像 @Component 那样创建独立的组件实例和状态域。这种"轻量级复用"的定位使其非常适合组织同一页面内的 UI 片段。

从代码组织角度看,@Builder 方法将一个 1700 行的大组件拆分成了逻辑清晰的模块:标题栏、五个 tab 页面、六个弹框、一个 tab 栏,每个模块独立维护,互不干扰。这种拆分方式使得代码的可读性和可维护性大幅提升——要修改某个弹框的样式,只需找到对应的 @Builder 方法即可,不必在一大段代码中搜索。同时,由于 @Builder 方法可以访问宿主组件的 @State 变量,状态管理仍然集中在组件内部,不存在跨组件通信的问题。

需要注意的是,@Builder 方法虽然实现了代码复用,但并没有实现真正的组件隔离——它们共享宿主组件的全部状态,任何一个 @Builder 方法都可以读写任何状态变量。在更复杂的应用中,如果需要真正的组件封装和独立状态管理,应该使用 @Component 自定义组件配合 @Prop@Link 等装饰器来实现父子组件间的数据传递。

16.3 弹框系统的设计模式

本应用实现了六个弹框/抽屉,涵盖了三种常见的弹框类型:底部抽屉(预约验光、训练打卡、用眼提醒、配镜下单)、居中大卡(镜片详情)、居中窄卡(删除确认)。这些弹框虽然类型不同,但都遵循统一的设计模式:Stack 层叠布局作为容器,@State 布尔变量控制显隐,if 条件渲染决定是否挂载,zIndex(999) 确保层级覆盖,蒙层 onClick 实现点击外部关闭,内层空 onClick 阻止事件冒泡。

这种统一的模式使得六个弹框的实现高度一致,代码风格统一,维护成本低。如果要新增一个弹框,只需按照相同模式定义一个 @State 布尔变量、一个 @Builder 方法、在 build()Stack 中添加一个 if 条件分支即可。这种可扩展性是良好架构设计的重要标志。

从用户体验角度看,不同类型的弹框选择了不同的弹出位置和尺寸——底部抽屉适合表单输入(手指自然位于屏幕底部),居中大卡适合信息展示(视觉焦点居中),窄型确认卡适合危险操作确认(聚焦注意力)。这种根据交互目的选择弹框形式的策略,体现了移动端 UI 设计的成熟考量。

16.4 纯组件手绘图表的技术选型

本应用中的所有图表(屈光度柱状图、用眼时长条形图、双眼视力对比条、训练进度条、眼轴时间轴)都完全使用 ArkUI 基础组件手工构建,没有引入任何第三方图表库。这种技术选型在快速原型开发阶段是合理且高效的——基础组件足够灵活,可以实现绝大多数常见图表类型,同时省去了引入和配置第三方库的成本。

柱状图通过 Row 的高度绑定数据值实现,条形图通过内外嵌套 Row 的宽度百分比实现,进度条通过 Progress 组件的 value 参数实现,时间轴通过 Circle 节点加竖线 Column 实现。这些实现方式虽然不如专业图表库那样支持丰富的交互(如 tooltip、缩放、动画过渡等),但对于数据展示型应用来说已经完全足够。更重要的是,纯组件图表天然支持响应式更新——当数据变化时,图表自动刷新,无需手动调用重绘方法。

在实际工程中,如果图表需求变得更加复杂(如需要动画过渡、手势缩放、数据联动高亮等),可以考虑使用 ArkUI 的 Canvas 组件配合 2D 绘图 API 进行更底层的绘制,或者引入第三方图表组件库。技术选型应根据项目复杂度和团队技术栈灵活决定,没有放之四海而皆准的最优方案。

16.5 状态管理的层次与边界

本应用的状态管理完全依赖 @State 装饰器,所有状态变量都定义在唯一的 @Entry 组件内部。这种"集中式状态管理"在中小型应用中是简洁高效的——所有状态一目了然,不存在跨组件通信的复杂性。23 个状态变量覆盖了导航控制、弹框显隐、表单数据、动画驱动、选中索引等全部状态需求,每个变量的用途清晰明确。

然而,当应用规模增长、组件层级加深时,纯 @State 方案会暴露出局限性。如果某个深层子组件需要访问顶层状态,中间层级的组件必须充当"传声筒"逐层传递,导致代码冗余。在这种场景下,ArkUI 提供了 @Provide@Consume 装饰器来实现跨层级的状态共享——顶层组件用 @Provide 声明共享状态,任意层级的后代组件用 @Consume 获取该状态,无需逐层传递。此外,@Observed@ObjectLink 可以实现嵌套对象的深度响应式观察。本应用由于是单页面应用,组件层级较浅,@State 已经足够胜任,但随着功能扩展,适时引入更高级的状态管理装饰器是必要的架构演进方向。

另一个值得注意的状态管理细节是"不可变更新"模式的普遍应用。本应用中所有数组类型 @State 变量的修改都通过 .map().filter().slice() 等方法生成新数组再赋值,而不是直接修改原数组元素。这种模式虽然代码略显冗长,但保证了状态变化的可追踪性——每次状态更新都是一个全新的引用,框架可以精确检测到变化并触发最小范围的 UI 更新。这种纪律性的状态管理习惯在团队协作中尤为重要,它使得状态变化路径清晰可循,降低了调试和维护的难度。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

.width('100%')
.height('100%')
.backgroundColor('#EFF9FA')

}
}


---
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/f39f604b09b744dfb293648cf2387653.png#pic_center)

### 16.6 总结:

本应用在 `aboutToAppear` 中启动了两个无限循环动画,为顶部标题栏的眼睛图标添加了呼吸和闪烁效果。虽然这些动画只是视觉装饰,不影响功能逻辑,但它们显著提升了应用的"活力感"和"品质感"。在移动应用市场竞争激烈的当下,功能完备只是基础,交互细节和视觉品质往往是决定用户留存的关键因素。

`animateTo` API 的设计哲学是"状态驱动动画"——开发者在闭包中修改状态变量的目标值,框架自动计算从当前值到目标值的过渡帧。配合 `iterations: -1`(无限循环)和 `PlayMode.Alternate`(往返播放),可以轻松实现持续运行的呼吸、闪烁、脉冲等动画效果。这种设计让开发者无需关心动画帧的绘制细节,只需关注状态变化的语义,大大降低了动画开发的门槛。

除了显式动画外,本应用还在 tab 栏中使用了 `opacity` 和 `scale` 属性来实现选中态的视觉过渡。虽然这些属性变化没有包裹在 `animateTo` 中,但 ArkUI 框架对部分属性提供了隐式动画支持——当属性值变化时,框架会自动添加平滑过渡效果。这种隐式动画进一步简化了开发者的工作,让界面交互的自然度提升成为"默认行为"而非"额外工作"。

综合来看,这个眼视光中心应用虽然是一个功能原型,但它在架构设计、状态管理、UI 组织、交互体验等方面都体现了 ArkUI 声明式 UI 范式的核心思想和最佳实践。通过对这份代码的深入解析,我们不仅理解了具体的技术实现细节,更重要的是建立了一套系统化的 ArkUI 开发知识体系——从数据建模到状态管理,从布局组织到动画驱动,从组件复用到弹框架构,每一个环节都有清晰的设计原则和实现模式可供参考。这些知识和模式可以直接迁移到其他 HarmonyOS 应用开发项目中,为高效、高质量的鸿蒙应用开发提供坚实的方法论基础。
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐