鸿蒙 ArkTS 实战解析:中医体质辨识应用的声明式 UI 架构与交互设计
引言:鸿蒙开发背景与 ArkTS 语言生态
鸿蒙操作系统(HarmonyOS)作为华为面向全场景多设备打造的分布式操作系统,其应用开发框架经历了从早期 Java UI 到方舟开发框架(ArkUI)的演进。ArkUI 框架的核心编程语言便是 ArkTS——一种在 TypeScript 基础上扩展而来的类型安全语言。ArkTS 保留了 TypeScript 的静态类型系统、接口定义、泛型等高级特性,同时为声明式 UI 范式做了深度定制,引入了诸如 @Component、@Entry、@State、@Builder、@Prop、@Link 等一系列装饰器,使开发者能够以声明式的方式描述界面结构与状态驱动关系。这种设计范式与 SwiftUI、Jetpack Compose 等现代声明式 UI 框架理念相通,但在语法细节和运行时机制上具有鸿蒙自身的特色。
声明式 UI 范式的核心思想在于"状态即视图"。开发者无需像命令式编程那样手动调用 findViewById、setText、setOnClickListener 等方法来操作界面,而是通过声明状态变量并绑定到 UI 描述中,由框架负责监听状态变化并自动触发界面重渲染。在 ArkTS 中,@State 装饰器标记的变量一旦被修改,框架会自动找到所有引用该变量的 UI 组件并执行局部刷新。这种数据驱动的方式极大地简化了状态管理与界面同步的复杂度,尤其在构建包含多 Tab、多弹窗、多列表交互的复杂应用时,优势尤为明显。本篇将以一个完整的中医体质辨识应用为例,逐段剖析 ArkTS 代码的组织方式、状态流转机制以及 ArkUI 组件体系的使用方法。
ArkUI 组件体系是鸿蒙应用开发的基石。它提供了丰富的内置组件,从基础容器(Column、Row、Stack、Flex)到交互组件(Text、Image、TextInput、Toggle、Progress),再到滚动容器(Scroll、List)和装饰能力(ForEach、if/else 条件渲染),形成了一套完整的前端开发工具链。容器组件负责布局组织,其中 Column 实现纵向排列、Row 实现横向排列、Stack 实现层叠堆放,三者构成了 ArkUI 布局的三大支柱。在布局属性层面,layoutWeight 用于弹性分配剩余空间、position 用于绝对定位、zIndex 用于控制层叠顺序、padding 与 margin 分别控制内边距与外边距,这些属性共同支撑了精密的界面布局控制。此外,ArkUI 还提供了 animateTo 动画 API,允许开发者以闭包形式定义动画终态,框架自动计算插值并驱动界面平滑过渡。理解这些组件与机制的协作方式,是深入鸿蒙原生开发的关键。
本应用以中医体质辨识为核心业务场景,融合了九种体质辨识、药膳推荐、穴位按摩打卡、经络时辰养生、调理计划管理、医案记录追溯、代茶饮定制等多维度功能。界面采用中式米白宣纸风格设计,底部以双排三列导航呈现六个功能入口,配合六个弹层覆盖交互场景,构成了一个结构完整、交互丰富的典型鸿蒙应用。接下来我们将从数据模型定义、状态管理、业务逻辑、UI 构建器、弹窗系统到根布局,逐层拆解这份 ArkTS 代码的技术实现细节。
一、数据模型层:interface 接口定义与类型安全
在 ArkTS 中,interface 关键字用于定义数据结构的契约,这与 TypeScript 中的接口概念一脉相承。接口本身不产生运行时对象,它只是一种编译期的类型约束,确保数据在传递过程中字段类型正确、字段名称完整。在本应用中,开发者首先定义了六个接口,分别对应六类核心业务实体。
1.1 体质数据接口
interface BodyT {
name: string
pct: number
desc: string
color: string
emoji: string
}

BodyT 接口定义了中医体质的五个核心属性:name 记录体质名称(如"平和质"、"气虚质"等)、pct 记录该体质在辨识结果中的占比百分比、desc 记录体质特征的简短描述、color 记录该体质对应的主题色值(使用十六进制颜色字符串)、emoji 记录表情符号图标。这五个字段共同描述了一条完整的体质数据,后续在体质 Tab 页的横条图中,每一条体质数据都会被渲染为一个带颜色条的列表项。
技术要点: ArkTS 的
interface与 TypeScript 的interface在语法上一致,但在编译期会被擦除,不产生运行时代码。它的核心价值在于类型检查——当你在代码中尝试给pct赋值为字符串时,编译器会立即报错。这种"编译期安全"是 ArkTS 相比纯 JavaScript 的核心优势之一,尤其在中大型应用中能显著减少因类型错误导致的运行时崩溃。
1.2 药膳数据接口
interface HerbT {
name: string
func: string
taste: string
suit: string
emoji: string
fav: boolean
}
HerbT 接口定义了药膳条目的数据结构,包含名称、功效、性味、适宜体质、图标表情和收藏标记六个字段。其中 fav 字段类型为 boolean,用于标记用户是否收藏了该药膳,这是典型的"用户偏好"数据,后续会绑定到 UI 上的爱心图标,点击切换收藏状态。
1.3 穴位数据接口
interface PointT {
name: string
meridian: string
effect: string
min: number
done: boolean
}

PointT 接口定义了穴位数据结构,meridian 字段记录穴位所属经络(如"足阳明胃经"、"任脉"等),min 字段记录建议按揉时长(分钟),done 字段标记今日是否已按揉打卡。这三个接口都体现了"业务实体 + 状态标记"的设计模式——将展示性数据与交互性状态封装在同一个数据结构中,在声明式 UI 中,当 done 或 fav 变化时,框架自动刷新引用该数据的 UI 区域。
1.4 调理计划、医案与代茶饮接口
interface PlanT {
title: string
freq: string
detail: string
on: boolean
}
interface CaseT {
date: string
type: string
result: string
doc: string
}
interface TeaT {
name: string
mat: string
effect: string
season: string
emoji: string
}

PlanT 接口定义调理计划,on 字段标记该计划是否处于启用状态,freq 记录执行频次(如"每日"、"每周 3 次"等)。CaseT 接口定义医案记录,doc 字段记录主治医师姓名,date 记录就诊日期。TeaT 接口定义代茶饮条目,mat 字段记录材料配比,season 记录适宜季节。
技术要点:
interface中每个字段都可以有不同的类型——string用于文本、number用于数值、boolean用于开关状态。ArkTS 严格要求字段类型与赋值类型匹配,这种严格性贯穿整个开发链路,从数据定义到 UI 绑定再到事件处理,每一步都受类型系统保护。这正是鸿蒙原生应用相比 Web 前端开发在工程质量上的核心提升。
二、静态数据常量:Array 泛型集合与初始数据
在定义了接口契约之后,代码紧接着声明了七个 const 常量,每个都是 Array<T> 泛型数组,为应用提供初始数据。
2.1 九种体质数据
const BODIES5: Array<BodyT> = [
{ name: '平和质', pct: 42, desc: '阴阳气血调和,精力充沛', color: '#B03A2E', emoji: '☯️' },
{ name: '气虚质', pct: 18, desc: '元气不足,易疲乏气短', color: '#C0392B', emoji: '🍂' },
{ name: '阳虚质', pct: 12, desc: '畏寒怕冷,手足不温', color: '#D35400', emoji: '🔥' },
{ name: '阴虚质', pct: 8, desc: '口燥咽干,手足心热', color: '#8E44AD', emoji: '💧' },
{ name: '痰湿质', pct: 10, desc: '形体肥胖,腹部松软', color: '#16A085', emoji: '🌫️' },
{ name: '湿热质', pct: 5, desc: '面垢油光,口苦口干', color: '#F39C12', emoji: '🌶️' },
{ name: '血瘀质', pct: 3, desc: '肤色晦暗,易生斑痕', color: '#7B241C', emoji: '🩸' },
{ name: '气郁质', pct: 1, desc: '情绪低沉,敏感多虑', color: '#2E86C1', emoji: '🌾' },
{ name: '特禀质', pct: 1, desc: '过敏体质,易鼻塞喷嚏', color: '#6C3483', emoji: '🌸' }
]
BODIES5 常量包含了中医九种体质的完整数据,类型标注为 Array<BodyT>,即"元素类型为 BodyT 的数组"。每个元素都是一个对象字面量,严格匹配 BodyT 接口的字段定义。九种体质的百分比总和约为 100,构成了一个饼图式的分布数据。每种体质分配了独立的颜色值,用于在 UI 中区分不同体质的横条颜色。这些数据后续会在体质 Tab 页通过 ForEach 渲染为九条横条图。
技术要点:
Array<BodyT>是 ArkTS 泛型语法的体现。泛型使得数组的元素类型被精确约束——如果尝试向BODIES5中推入一个缺少emoji字段的对象,编译器会立即报错。这种类型安全在大型数据集操作时尤为关键,能防止因字段缺失导致的 UI 渲染异常。同时,const关键字确保常量引用不可被重新赋值,但数组内部元素仍可被修改(因为const冻结的是引用绑定而非对象内容)。
2.2 药膳库数据
const HERBS5: Array<HerbT> = [
{ name: '山药茯苓粥', func: '健脾益气', taste: '甘淡平', suit: '气虚质', emoji: '🍚', fav: true },
{ name: '当归生姜羊肉汤', func: '温中散寒', taste: '甘温', suit: '阳虚质', emoji: '🍲', fav: false },
{ name: '银耳百合莲子羹', func: '滋阴润燥', taste: '甘微寒', suit: '阴虚质', emoji: '🍮', fav: true },
{ name: '薏米赤小豆汤', func: '利水渗湿', taste: '甘淡凉', suit: '痰湿质', emoji: '🥣', fav: false },
{ name: '绿豆苦瓜排骨汤', func: '清热祛湿', taste: '甘苦寒', suit: '湿热质', emoji: '🫕', fav: false },
{ name: '山楂桃仁露', func: '活血化瘀', taste: '酸甘', suit: '血瘀质', emoji: '🍶', fav: false },
{ name: '玫瑰花陈皮茶', func: '疏肝理气', taste: '甘微苦', suit: '气郁质', emoji: '🌹', fav: true },
{ name: '黄芪乌鸡汤', func: '补气固表', taste: '甘温', suit: '气虚质', emoji: '🍗', fav: false },
{ name: '枸杞菊花决明茶', func: '清肝明目', taste: '甘苦凉', suit: '阴虚质', emoji: '🍵', fav: false },
{ name: '芡实莲子猪肚汤', func: '健脾祛湿', taste: '甘涩平', suit: '痰湿质', emoji: '🍜', fav: false },
{ name: '秋梨膏', func: '润肺止咳', taste: '甘凉', suit: '阴虚质', emoji: '🍐', fav: false },
{ name: '八珍糕', func: '气血双补', taste: '甘平', suit: '平和质', emoji: '🍥', fav: false }
]

HERBS5 包含十二道药膳数据,每道药膳都标注了功效、性味、适宜体质。这里值得注意的设计是 fav 字段的初始值——部分药膳默认已收藏(fav: true),部分未收藏(fav: false)。这种"预置用户偏好"的做法在原型开发和功能演示中很常见,它让应用在首次打开时就呈现出有内容的状态,而不是空白列表。
2.3 穴位库与调理计划数据
const POINTS5: Array<PointT> = [
{ name: '足三里', meridian: '足阳明胃经', effect: '健脾和胃,扶正培元', min: 3, done: true },
{ name: '关元', meridian: '任脉', effect: '培元固本,温阳补气', min: 3, done: true },
{ name: '三阴交', meridian: '足太阴脾经', effect: '调补肝脾肾', min: 3, done: false },
{ name: '涌泉', meridian: '足少阴肾经', effect: '滋阴降火,安神助眠', min: 5, done: false },
{ name: '太冲', meridian: '足厥阴肝经', effect: '疏肝解郁,平肝潜阳', min: 3, done: true },
{ name: '中脘', meridian: '任脉', effect: '和胃健脾,消积化滞', min: 4, done: false },
{ name: '风池', meridian: '足少阳胆经', effect: '祛风散寒,清利头目', min: 3, done: false },
{ name: '合谷', meridian: '手阳明大肠经', effect: '疏风解表,通络止痛', min: 2, done: true },
{ name: '气海', meridian: '任脉', effect: '益气补虚,调理气机', min: 3, done: false },
{ name: '血海', meridian: '足太阴脾经', effect: '活血化瘀,调经统血', min: 3, done: false }
]
const PLANS5: Array<PlanT> = [
{ title: '卯时晨起梳头', freq: '每日', detail: '胆经循行,梳头 100 下升发阳气', on: true },
{ title: '辰时小米粥早餐', freq: '每日', detail: '胃经当令,温软养胃', on: true },
{ title: '午时小憩 15 分钟', freq: '每日', detail: '心经当令,养心护神', on: false },
{ title: '酉时揉按涌泉', freq: '每日', detail: '肾经当令,引火归元助眠', on: true },
{ title: '戌时散步 30 分钟', freq: '每周 5 次', detail: '心包经当令,舒缓情志', on: true },
{ title: '艾灸关元', freq: '每周 2 次', detail: '每次 15 分钟,温阳固本', on: false },
{ title: '八段锦练习', freq: '每周 3 次', detail: '完整两遍,调和气血', on: true },
{ title: '泡脚艾叶浴', freq: '每周 3 次', detail: '40℃ 温水 20 分钟,微微汗出', on: false }
]

POINTS5 包含十个常用穴位的按摩数据,done 字段标记今日是否已完成按揉打卡。PLANS5 包含八条调理计划,涵盖了从晨起梳头到泡脚艾叶浴的全天调理安排。这两组数据中,done 和 on 字段的初始值混合了 true 和 false,让 UI 在首屏就呈现出"部分已完成"的真实状态。
2.4 医案记录与代茶饮数据
const CASES5: Array<CaseT> = [
{ date: '08-26', type: '舌象分析', result: '舌淡红苔薄白,齿痕轻', doc: '贺兰山' },
{ date: '08-26', type: '脉象记录', result: '脉细缓,重按无力', doc: '贺兰山' },
{ date: '08-12', type: '体质复测', result: '气虚质为主,兼痰湿', doc: '秦艽' },
{ date: '07-29', type: '艾灸调理', result: '关元 + 足三里,施灸 30 分钟', doc: '白术' },
{ date: '07-15', type: '耳穴压豆', result: '神门 + 内分泌 + 脾', doc: '秦艽' },
{ date: '06-30', type: '拔罐记录', result: '背俞穴走罐,出痧淡红', doc: '贺兰山' },
{ date: '06-17', type: '体质初测', result: '气虚质 62 分,平和质 41 分', doc: '秦艽' },
{ date: '06-03', type: '膏方开方', result: '补中益气膏 250g × 2 瓶', doc: '贺兰山' },
{ date: '05-20', type: '节气养生指导', result: '小满:健脾利湿为主', doc: '白术' },
{ date: '05-06', type: '情志调理', result: '五音疗法:角调式乐曲', doc: '白术' }
]
const TEAS5: Array<TeaT> = [
{ name: '陈皮普洱', mat: '陈皮 3g + 熟普 5g', effect: '理气化痰', season: '四季', emoji: '🍵' },
{ name: '桂圆红枣', mat: '桂圆 5 颗 + 红枣 3 枚', effect: '养血安神', season: '秋冬', emoji: '🫖' },
{ name: '薄荷甘草', mat: '薄荷 2g + 甘草 3g', effect: '疏风利咽', season: '春夏', emoji: '🌿' },
{ name: '菊花枸杞', mat: '杭白菊 5 朵 + 枸杞 10 粒', effect: '清肝明目', season: '夏', emoji: '🌼' },
{ name: '生姜红糖', mat: '生姜 3 片 + 红糖 15g', effect: '温中散寒', season: '冬', emoji: '🫚' },
{ name: '酸梅汤', mat: '乌梅 + 山楂 + 陈皮', effect: '生津消食', season: '夏', emoji: '🥤' }
]
const MERIDIANHOURS5: Array<string> = ['寅时 肺经', '卯时 大肠', '辰时 胃经', '巳时 脾经', '午时 心经', '未时 小肠', '申时 膀胱', '酉时 肾经', '戌时 心包', '亥时 三焦', '子时 胆经', '丑时 肝经']

CASES5 包含十条医案记录,按时间倒序排列,覆盖舌象、脉象、体质测试、艾灸、耳穴、拔罐等多种中医诊疗方式。TEAS5 包含六种代茶饮配方。MERIDIANHOURS5 是一个字符串数组,记录了十二时辰与十二经络的对应关系——从寅时肺经到丑时肝经,这是中医子午流注理论的核心数据。
技术要点: 这里出现了两种数组声明方式——
Array<BodyT>(泛型数组)和Array<string>(基础类型数组)。MERIDIANHOURS5虽然是简单字符串数组,但它服务于经络时辰流注的可视化渲染,在后续的 Scroll 横向滚动容器中会逐条展示。这种将业务知识编码为静态数据的方式,使得 UI 渲染逻辑与领域数据彻底解耦,便于后续维护和扩展。
三、组件入口与状态管理:@Entry、@Component 与 @State
3.1 组件入口声明
@Entry
@Component
struct Index {
这段代码定义了应用的根组件。struct Index 声明了一个结构体,这是 ArkTS 中定义组件的方式——不同于 React 的函数组件或类组件,ArkTS 使用结构体(struct)来组织组件。@Component 装饰器将该结构体标记为"可复用的 UI 组件",使其内部可以声明 @State、@Prop、@Link 等状态变量以及 @Builder 方法。@Entry 装饰器进一步将该组件标记为"页面入口组件",即应用启动后渲染的第一个组件。一个 .ets 文件中只能有一个 @Entry 组件,它是整个页面树的根节点。
技术要点:
@Entry和@Component是两个不同层级的装饰器。@Component是组件级别——任何可复用的 UI 单元都需要它;@Entry是页面级别——只有页面根组件才需要它。如果你要抽取一个子组件在其他页面复用,只需@Component而不需要@Entry。这种分层设计使得组件既可以独立复用,又能在特定页面作为入口承载全局状态。
3.2 核心状态变量声明
@State selTab: number = 0
@State herbs: Array<HerbT> = HERBS5.slice()
@State points: Array<PointT> = POINTS5.slice()
@State plans: Array<PlanT> = PLANS5.slice()
@State cases: Array<CaseT> = CASES5.slice()
@State selBody: string = '气虚质'

@State 是 ArkTS 中最核心的状态装饰器。被它标记的变量会被框架纳入"观察列表",任何对这些变量的修改都会自动触发 UI 的局部刷新。这里声明了六个核心状态:selTab 记录当前选中的 Tab 索引(0-5),初始值为 0;herbs、points、plans、cases 分别是药膳、穴位、计划、医案的可变数据副本——通过调用 .slice() 方法从常量数组创建浅拷贝,确保状态数据与原始常量隔离,修改状态不会影响常量数据。selBody 记录当前选中的体质名称,初始为"气虚质"。
技术要点:
.slice()的使用是一个关键细节。如果直接将HERBS5赋值给@State herbs,那么herbs和HERBS5会指向同一个数组引用,修改herbs中的元素时虽然 UI 会刷新(因为@State监听到了赋值),但原始常量数据也会被篡改。通过.slice()创建副本,实现了状态数据与常量数据的隔离。这是声明式 UI 中"数据不可变性"原则的实践——常量数据只读,可变数据通过状态变量管理。
3.3 体质测试状态组
// 体质测试
@State showTest: boolean = false
@State qIdx: number = 0
@State ansA: number = 0
@State ansB: number = 0
// 药膳详情
@State showHerb: boolean = false
@State selHerb: HerbT = HERBS5[0]
// 穴位详情
@State showPoint: boolean = false
@State selPoint: PointT = POINTS5[0]
// 编辑计划
@State showEdit: boolean = false
@State editIdx: number = 0
@State editFreq: string = '每日'
@State editTimes: number = 1
@State editOn: boolean = true
// 删除记录
@State showDel: boolean = false
@State delCase: CaseT = CASES5[0]
@State delArmed: boolean = false
// 代茶饮定制
@State showTea: boolean = false
@State teaBase: string = '陈皮普洱'
@State teaSweet: string = '无糖'
@State teaHot: string = '热饮'
@State teaCnt: number = 7

这段代码集中声明了六个弹窗的控制状态。每一组弹窗都由若干个 @State 变量协同驱动:showTest/showHerb/showPoint/showEdit/showDel/showTea 这六个 boolean 变量分别控制六个弹窗的显示与隐藏;其余变量记录各自弹窗内的交互状态。例如体质测试弹窗中,qIdx 记录当前题目序号(0-5),ansA 和 ansB 分别记录两类得分维度的累积值。
技术要点: 弹窗的显示/隐藏完全由
@State boolean变量驱动,这是声明式 UI 中"条件渲染"的典型模式。当showTest从false变为true时,框架会自动在build()方法的Stack容器中插入弹窗组件;当变回false时,弹窗被移除。这种模式不需要开发者手动管理 DOM 节点的创建与销毁,框架负责全部的渲染调度。editFreq、editTimes、editOn等变量则是弹窗内的"表单状态",记录用户在弹窗中的选择,点击保存时这些状态被提交到全局数据中。
3.4 特效动画状态
// 特效
@State qiY: number = 0
@State yinOp: number = 0.4
qiY 和 yinOp 是两个用于动画效果的状态变量。qiY 控制太极图标的垂直位移(通过 .offset({ y: this.qiY }) 绑定),yinOp 控制阴标的透明度(通过 .opacity(this.yinOp) 绑定)。这两个变量的初始值分别为 0 和 0.4,在 aboutToAppear 生命周期中会被 animateTo 动画驱动,形成持续循环的呼吸动效。在声明式 UI 中,动画的本质就是"修改状态变量值"——状态变化后框架自动插值过渡,无需开发者手动编写动画帧逻辑。
四、生命周期与动画:aboutToAppear 与 animateTo
aboutToAppear(): void {
this.getUIContext().animateTo({ duration: 2800, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.qiY = -12
})
this.getUIContext().animateTo({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
this.yinOp = 1
})
}
aboutToAppear 是 ArkUI 组件生命周期的核心回调之一,它在组件创建后、build() 方法首次执行前被调用。这个时机非常适合做初始化工作——设置动画、发起网络请求、读取本地存储等。在本应用中,aboutToAppear 启动了两个循环动画。
animateTo 是 ArkUI 提供的显式动画 API。它接受两个参数:第一个是动画配置对象,第二个是闭包函数。闭包中只需修改 @State 变量的终值,框架会自动计算从当前值到终值的插值过程,并在 duration 指定的时间内平滑过渡。
第一个动画配置:duration: 2800(持续 2800 毫秒)、iterations: -1(无限循环)、playMode: PlayMode.Alternate(交替模式,即正向播放完毕后反向播放)、curve: Curve.EaseInOut(缓动曲线为先快后慢再快)。闭包中将 qiY 设为 -12,效果是太极图标从原位上移 12vp 再回到原位,循环往复,形成一种"漂浮"的视觉效果。
第二个动画配置:duration: 1600,同样无限循环交替播放。闭包中将 yinOp 设为 1,效果是阴标的透明度从 0.4 渐变到 1 再回到 0.4,形成"阴阳交替闪烁"的视觉效果,契合中医阴阳动态平衡的理念。
技术要点:
PlayMode.Alternate是动画播放模式枚举值之一。ArkUI 提供了三种播放模式:Normal(正向单次)、Reverse(反向单次)、Alternate(正反交替)。配合iterations: -1(无限次),就能实现永不停的往复动画。Curve.EaseInOut是缓动曲线,让动画在起始和结束阶段减速,中段加速,模拟自然运动节奏。getUIContext()方法返回当前 UI 上下文,是调用animateTo的前置条件——在aboutToAppear中调用时,UI 上下文已经就绪。
五、业务逻辑方法:数据计算与状态变更
5.1 统计计算方法
planRate5(): number {
let n: number = 0
this.plans.forEach((p: PlanT) => {
if (p.on) {
n += 1
}
})
return Math.round(n * 100 / this.plans.length)
}
pointDone5(): number {
let n: number = 0
this.points.forEach((p: PointT) => {
if (p.done) {
n += 1
}
})
return n
}
favHerb5(): number {
let n: number = 0
this.herbs.forEach((h: HerbT) => {
if (h.fav) {
n += 1
}
})
return n
}
这三个方法分别计算调理计划执行率、穴位按揉完成数和药膳收藏数。planRate5 通过遍历 plans 数组统计 on 为 true 的计划数量,再除以总数得到百分比,使用 Math.round 四舍五入。pointDone5 统计已按揉的穴位数量。favHerb5 统计已收藏的药膳数量。这三个方法不修改状态,只做纯计算,返回值直接嵌入 UI 的 Text 组件中展示。由于它们引用了 @State 变量(this.plans、this.points、this.herbs),当这些状态变化时,框架会重新调用方法并刷新对应 Text 的显示内容。
技术要点: 在声明式 UI 中,"计算属性"的概念是通过在
build()方法或@Builder方法中调用这些计算方法来实现的。每次@State变量被修改,框架重新执行引用了该变量的@Builder,计算方法随之被重新调用,返回最新结果。这与 Vue 的 computed 属性理念类似,但 ArkTS 中需要开发者自行保证计算方法不产生副作用——即只读不写。这里使用forEach遍历数组而非filter+length链式调用,是因为 ArkTS 对数组高阶函数的支持在某些版本下有性能差异,forEach+ 计数器的方式更直观高效。
5.2 收藏切换方法
toggleFav5(h: HerbT): void {
this.herbs = this.herbs.map((x: HerbT) => {
if (x === h) {
const nh: HerbT = { name: x.name, func: x.func, taste: x.taste, suit: x.suit, emoji: x.emoji, fav: !x.fav }
return nh
}
return x
})
if (this.selHerb === h) {
const nh: HerbT = { name: h.name, func: h.func, taste: h.taste, suit: h.suit, emoji: h.emoji, fav: !h.fav }
this.selHerb = nh
}
}
toggleFav5 方法实现了药膳收藏切换的核心逻辑。它使用了 Array.prototype.map 方法遍历 herbs 数组,对每个元素进行判断:如果该元素与传入的 h 是同一个引用(x === h),则创建一个新对象,复制原有字段但将 fav 取反(!x.fav);否则返回原对象不变。map 返回的新数组被重新赋值给 this.herbs,触发 @State 变更通知。此外,如果当前选中查看详情的药膳(this.selHerb)正是被切换收藏的药膳,也要同步更新 selHerb。
技术要点: 这里体现了"不可变更新"(Immutable Update)模式。ArkTS 的
@State对数组的状态监听基于引用变化——如果你直接修改数组元素的属性(如this.herbs[0].fav = true),框架可能无法感知到变化。正确做法是用map创建一个新数组引用并赋值,这样@State才能确保触发 UI 刷新。每个修改后的元素都创建新对象(const nh = { ... })而非修改原对象,这是声明式 UI 中数据流管理的重要原则。同时,通过x === h的引用比较来定位目标元素,这种模式在处理对象数组的状态变更时非常通用。
5.3 穴位打卡方法
doPoint5(p: PointT): void {
this.points = this.points.map((x: PointT) => {
if (x === p) {
const np: PointT = { name: x.name, meridian: x.meridian, effect: x.effect, min: x.min, done: !x.done }
return np
}
return x
})
}
doPoint5 的逻辑模式与 toggleFav5 完全一致——通过 map 创建新数组,对目标元素创建新对象并取反 done 字段,其余元素保持不变。当用户在穴位列表或穴位详情弹窗中点击"标记已按揉"时,调用此方法切换打卡状态。状态变更后,穴位列表中的打卡图标、进度环数据、统计文字都会自动刷新。
5.4 体质测试确认与计划编辑
confirmTest5(): void {
const score: number = this.ansA * 12 + this.ansB * 8
const newBody: string = score >= 40 ? '气虚质' : (score >= 24 ? '平和质' : '阴虚质')
const nc: CaseT = { date: '08-28', type: '体质测试', result: newBody + '倾向 ' + score.toString() + ' 分', doc: 'AI 辨识' }
this.cases = [nc].concat(this.cases)
this.selBody = newBody
this.showTest = false
this.qIdx = 0
this.ansA = 0
this.ansB = 0
}
confirmEdit5(): void {
const list: Array<PlanT> = this.plans.slice()
const np: PlanT = { title: list[this.editIdx].title, freq: this.editFreq + ' ' + this.editTimes.toString() + ' 次', detail: list[this.editIdx].detail, on: this.editOn }
list[this.editIdx] = np
this.plans = list
this.showEdit = false
}
confirmTest5 在体质测试完成后被调用。它根据答题累积的两个分数维度(ansA 和 ansB)计算总分:ansA 每分权重 12,ansB 每分权重 8。根据总分阈值判断体质倾向——40 分以上为气虚质,24-39 分为平和质,24 分以下为阴虚质。然后将结果封装为一条新的 CaseT 医案记录,通过 [nc].concat(this.cases) 将新记录插入到医案列表头部。同时更新当前选中体质(selBody)、关闭测试弹窗(showTest = false)、重置测试状态(qIdx、ansA、ansB 归零)。
confirmEdit5 在计划编辑弹窗中点击保存时调用。它先用 .slice() 创建 plans 数组的副本(保护原数组),然后在副本中替换指定索引位置的元素为新的 PlanT 对象(editIdx 指定位置),将编辑弹窗中选择的频次和次数组合为 freq 字符串。最后将修改后的副本赋值给 this.plans 并关闭弹窗。
技术要点:
confirmTest5中[nc].concat(this.cases)的写法等价于在数组头部插入新元素,但创建了一个全新的数组引用。这种写法比this.cases.unshift(nc)更安全——unshift修改原数组,可能不被@State正确监听。confirmEdit5中使用.slice()创建副本再修改,是"拷贝-修改-替换"(Copy-On-Write)模式的体现,在 ArkTS 中这是操作数组状态的标准范式。
5.5 删除与答题流转方法
doDel5(): void {
this.cases = this.cases.filter((x: CaseT) => x !== this.delCase)
this.showDel = false
}
nextQ5(a: number, b: number): void {
this.ansA += a
this.ansB += b
if (this.qIdx >= 5) {
this.confirmTest5()
} else {
this.qIdx += 1
}
}
doDel5 使用 Array.prototype.filter 方法过滤掉与 delCase 引用相等的元素,返回新数组赋值给 this.cases,实现删除医案记录的功能。nextQ5 是体质测试的答题流转方法——接收两个分数参数 a 和 b,累加到 ansA 和 ansB 中,然后判断是否已答完第六题(qIdx >= 5)。如果是,调用 confirmTest5 生成结果;否则将题目索引 qIdx 递增 1,进入下一题。每道题有四个选项,分别对应不同的 a 和 b 值组合,体现了一种简单的加权评分逻辑。
六、@Builder 公共组件:复用 UI 片段
6.1 Tab 按钮构建器
@Builder
tabBtn5(icon: string, label: string, idx: number) {
Column({ space: 3 }) {
Text(icon)
.fontSize(idx === this.selTab ? 20 : 18)
.opacity(idx === this.selTab ? 1 : 0.55)
Text(label)
.fontSize(11)
.fontColor(idx === this.selTab ? '#B03A2E' : '#8C8378')
.fontWeight(idx === this.selTab ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.selTab = idx
})
}
@Builder 装饰器将一个方法标记为"UI 构建器",使其能够像组件一样被调用。tabBtn5 接收三个参数:图标表情、标签文字、索引序号,构建一个 Tab 按钮。内部使用 Column 容器纵向排列图标和文字,通过三元运算符(idx === this.selTab ? ... : ...)根据当前选中 Tab 来切换样式——选中时图标更大、不透明、文字红色加粗;未选中时图标较小、半透明、文字灰色常规字重。
Column({ space: 3 }) 中的 space 参数设置子元素之间的纵向间距为 3vp。.layoutWeight(1) 将该按钮在父容器(Row)中的宽度设为弹性权重 1,使得一行中的多个 Tab 按钮等宽分布。.onClick 闭包将 selTab 设为当前按钮的索引,触发 Tab 切换。
技术要点:
@Builder是 ArkTS 中实现 UI 复用的核心机制。它类似于其他框架中的"渲染函数"或"模板片段",但有关键区别:@Builder方法不能有返回值,它通过声明式描述直接将 UI 结构注入到调用位置。@Builder方法可以接收参数,这使得它非常适合构建"参数化的重复 UI 单元"——如这里的 Tab 按钮,每次调用传入不同的图标、文字和索引,生成结构相同但内容不同的按钮。layoutWeight(1)是弹性布局的核心属性——当一行中有三个layoutWeight(1)的元素时,它们各占三分之一宽度,无论屏幕尺寸如何变化。
6.2 底部导航栏构建器
@Builder
tabBar5() {
Column() {
Divider()
.color('#E5DCC8')
Row() {
this.tabBtn5('☯️', '体质', 0)
this.tabBtn5('🍵', '药膳', 1)
this.tabBtn5('🧘', '经络', 2)
}
.width('100%')
Row() {
this.tabBtn5('📋', '医案', 3)
this.tabBtn5('📅', '调理', 4)
this.tabBtn5('🙋', '我的', 5)
}
.width('100%')
}
.width('100%')
.backgroundColor('#FBF7F0')
}
tabBar5 构建底部导航栏,采用双排三列布局。顶部是一条 Divider 分割线,颜色为浅米色。下方是两个 Row 容器,每个 Row 内调用三次 this.tabBtn5(...) 放置三个 Tab 按钮。六个 Tab 分别是体质、药膳、经络、医案、调理、我的。由于每个 tabBtn5 内部设置了 layoutWeight(1),三个按钮在一行内自动等宽分布。整个导航栏背景色为 #FBF7F0(米白色),与应用整体的中式宣纸风格一致。
技术要点:
Divider是 ArkUI 的内置分割线组件,默认为水平方向、全宽、1vp 厚度。通过.color()可自定义颜色。双排导航的设计在功能项较多时很实用——单排 6 个 Tab 会导致每个按钮过窄、文字溢出,而双排 3+3 布局保证了每个按钮有足够的触摸区域和文字显示空间。调用this.tabBtn5(...)时使用this.前缀,表明@Builder方法是组件实例方法,通过this访问。
6.3 头部信息栏构建器
@Builder
headerBar5() {
Column({ space: 12 }) {
Row() {
Column({ space: 2 }) {
Text('TCM BALANCE ☯️')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('上工治未病 · 辨体施养')
.fontSize(11)
.fontColor('#8C8378')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('阴')
.fontSize(14)
.fontColor('#FBF7F0')
.padding({ left: 9, right: 9, top: 5, bottom: 5 })
.backgroundColor('#2E86C1')
.borderRadius(12)
.opacity(this.yinOp)
Text('阳')
.fontSize(14)
.fontColor('#FBF7F0')
.padding({ left: 9, right: 9, top: 5, bottom: 5 })
.backgroundColor('#B03A2E')
.borderRadius(12)
.margin({ left: 6 })
}
.width('100%')
headerBar5 构建应用的头部信息区。第一行是一个 Row,左侧是应用标题"TCM BALANCE"和副标题"上工治未病",右侧是"阴"和"阳"两个标签。阴标使用蓝色背景(#2E86C1),透明度绑定到 this.yinOp 状态变量,在动画驱动下呼吸闪烁;阳标使用红色背景(#B03A2E),固定不透明。两个标签都使用 .borderRadius(12) 形成胶囊形圆角,并通过 .margin({ left: 6 }) 设置左侧间距。
.alignItems(HorizontalAlign.Start) 设置 Column 内子元素左对齐,使标题和副标题靠左排列。.layoutWeight(1) 让标题列占据 Row 中除阴阳标签外的全部剩余宽度,将阴阳标签挤到右侧。
Row({ space: 8 }) {
Text('🔎')
.fontSize(15)
Text('搜索体质 / 药膳 / 穴位 / 名医')
.fontSize(13)
.fontColor('#B3A897')
.layoutWeight(1)
Text('测体质')
.fontSize(11)
.fontColor('#FBF7F0')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#B03A2E')
.borderRadius(10)
.onClick(() => {
this.qIdx = 0
this.ansA = 0
this.ansB = 0
this.showTest = true
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(20)
第二行是搜索栏和"测体质"按钮。搜索区域使用灰色占位文字(#B3A897),右侧"测体质"按钮为红色胶囊形,点击后重置测试状态(qIdx、ansA、ansB 归零)并打开体质测试弹窗(showTest = true)。搜索栏整体使用白色背景和 20vp 圆角,形成卡片式的搜索框视觉。
Row({ space: 12 }) {
Column() {
Text('☯️')
.fontSize(30)
.offset({ y: this.qiY })
}
.padding(12)
.backgroundColor('#F3ECDD')
.borderRadius(16)
Column({ space: 4 }) {
Text('主体质:' + this.selBody)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('辨识于 08-12 · 秦艽医师确认')
.fontSize(11)
.fontColor('#8C8378')
Text('调理方向:补脾益气,佐以化湿')
.fontSize(11)
.fontColor('#B03A2E')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(13)
.backgroundColor('#FFFFFF')
.borderRadius(18)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 10 })
.backgroundColor('#FBF7F0')
}
第三行是体质概览卡片:左侧是太极图标,其 .offset({ y: this.qiY }) 绑定到动画状态 qiY,在 aboutToAppear 中启动的动画驱动下持续上下浮动。右侧显示主体质名称、辨识日期和医师、调理方向。整体卡片白色背景、18vp 圆角,配合米白色页面背景形成层次感。
技术要点:
.offset({ y: this.qiY })是位移属性,y为负值时元素上移。由于qiY是@State变量,animateTo闭包中修改qiY时,框架自动对.offset属性执行动画插值,太极图标便产生了平滑的上下浮动效果。这就是声明式动画的精髓——开发者只声明"动画终态",框架负责所有中间帧的计算与渲染。.borderRadius的圆角值越大越圆,当值达到元素尺寸一半时变为完整圆形。
6.4 区块标题构建器
@Builder
secTitle5(icon: string, title: string, extra: string) {
Row() {
Text(icon)
.fontSize(15)
.margin({ right: 6 })
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
.layoutWeight(1)
Text(extra)
.fontSize(12)
.fontColor('#B03A2E')
}
.width('100%')
.margin({ top: 18, bottom: 10 })
}
secTitle5 是一个通用的区块标题构建器,接收图标、标题文字、附加文字三个参数。结构为 Row 容器内横向排列:图标 + 标题(layoutWeight(1) 占满中间空间)+ 附加文字(右对齐,红色)。上下 margin 提供区块间的呼吸空间。这个构建器在六个 Tab 页面中被反复调用,为每个内容区块提供统一的标题样式——如"九种体质倾向"、“体质药膳库”、"今日穴位按摩"等。
七、Tab 0 体质辨识页:ForEach 列表渲染与横条图
@Builder
bodyTab() {
Column({ space: 0 }) {
this.secTitle5('📊', '九种体质倾向', '总分 100')
Column({ space: 9 }) {
ForEach(BODIES5, (b: BodyT) => {
Row({ space: 8 }) {
Text(b.emoji)
.fontSize(15)
Text(b.name)
.fontSize(12)
.fontColor('#3D3125')
.width(56)
Column({ space: 3 }) {
Row() {
Row()
.width(b.pct.toString() + '%')
.height(8)
.backgroundColor(b.color)
.borderRadius(4)
Row()
.layoutWeight(1)
.height(8)
.backgroundColor('#F3ECDD')
.borderRadius(4)
}
.width('100%')
Text(b.desc)
.fontSize(9)
.fontColor('#B3A897')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(b.pct.toString() + ' 分')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(b.color)
.width(42)
}
.width('100%')
.padding(10)
.backgroundColor(this.selBody === b.name ? '#F9F1E3' : '#FFFFFF')
.borderRadius(12)
.border({ width: this.selBody === b.name ? 1 : 0, color: '#B03A2E' })
.onClick(() => {
this.selBody = b.name
})
}, (b: BodyT) => b.name)
}
.width('100%')
bodyTab 构建体质辨识页的主体内容。核心是使用 ForEach 遍历 BODIES5 常量数组,为每种体质渲染一条横条记录。ForEach 的第一个参数是数据源数组,第二个参数是项渲染函数,第三个参数是键值生成函数((b: BodyT) => b.name,以体质名称作为唯一键)。
每条记录的布局结构为:Row 容器内横向排列表情图标、体质名称(固定宽度 56vp)、进度条区域和分数。进度条区域使用嵌套的 Row——内层第一个 Row 的宽度设为百分比字符串(b.pct.toString() + '%'),背景色为体质颜色,形成彩色进度条;第二个 Row 使用 layoutWeight(1) 填充剩余空间,背景为浅米色,形成"未满"部分。两条拼接在一起,视觉上就是一条完整的进度条。这是用纯 Row 组件模拟进度条的经典技巧。
技术要点:
ForEach是 ArkUI 中处理列表渲染的核心组件。它的第三个参数(键值生成函数)至关重要——框架通过键值判断哪些项需要新增、删除或更新,从而实现高效的局部 Diff 更新。如果键值不唯一或缺失,可能导致渲染异常或性能下降。这里使用b.name作为键,因为九种体质名称天然唯一。.border({ width: ..., color: ... })同时设置边框宽度和颜色,选中体质的记录边框为 1vp 红色,未选中的为 0vp(无边框),实现选中态高亮。
this.secTitle5('🍃', '五行养生要点', '对应五脏')
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🌲')
.fontSize(22)
Text('木 · 肝')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#16A085')
Text('舒达情志')
.fontSize(9)
.fontColor('#8C8378')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
Column({ space: 4 }) {
Text('🔥')
.fontSize(22)
Text('火 · 心')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#B03A2E')
Text('静养心神')
.fontSize(9)
.fontColor('#8C8378')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
Column({ space: 4 }) {
Text('🏔️')
.fontSize(22)
Text('土 · 脾')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#D35400')
Text('饮食有节')
.fontSize(9)
.fontColor('#8C8378')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
Column({ space: 4 }) {
Text('⚔️')
.fontSize(22)
Text('金 · 肺')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#7D6608')
Text('润燥益气')
.fontSize(9)
.fontColor('#8C8378')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
Column({ space: 4 }) {
Text('💧')
.fontSize(22)
Text('水 · 肾')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#2E86C1')
Text('藏精固本')
.fontSize(9)
.fontColor('#8C8378')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
}
.width('100%')
Text('🌸 处暑已过,白露将至:宜早睡早起,食酸润肺,减辛增甘')
.fontSize(11)
.fontColor('#8C8378')
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
体质页的第二部分是五行养生卡片,使用一个 Row 容器内嵌五个 Column,每个 Column 代表一行(木火土金水),包含表情、五行名称与对应脏腑、养生要点。五个 Column 都设置 layoutWeight(1),在 Row 中等宽分布。每张卡片使用不同的主题色——木为青绿、火为朱红、土为橙黄、金为暗金、水为蓝色,对应中医五色理论。最后是一条节气养生提示文字。
技术要点: 五等分布局是
layoutWeight的典型应用场景。五个Column各设layoutWeight(1),无论屏幕多宽,都自动等分。这比使用固定百分比宽度更灵活——如果将来从五个变为三个或六个卡片,只需增删Column即可,布局自动适配。.padding({ top: 10, bottom: 10 })只设置上下内边距,左右不设,使卡片内部文字垂直居中的同时卡片间靠Row({ space: 8 })的space参数控制间距。
八、Tab 1 药膳库页:列表卡片与收藏交互
@Builder
herbTab() {
Column({ space: 0 }) {
this.secTitle5('🍵', '体质药膳库', '收藏 ' + this.favHerb5().toString() + ' 道')
Row({ space: 8 }) {
ForEach(['全部', '气虚质', '阳虚质', '阴虚质', '痰湿质'], (f: string) => {
Text(f)
.fontSize(11)
.fontColor(this.selBody === f || (f === '全部' && false) ? '#FBF7F0' : '#8C8378')
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(this.selBody === f ? '#B03A2E' : '#F3ECDD')
.borderRadius(12)
.onClick(() => {
this.selBody = f
})
}, (f: string) => f)
}
.width('100%')
Column({ space: 10 }) {
ForEach(this.herbs, (h: HerbT) => {
Row({ space: 10 }) {
Text(h.emoji)
.fontSize(24)
.padding(8)
.backgroundColor('#F9F1E3')
.borderRadius(12)
Column({ space: 3 }) {
Text(h.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text(h.func + ' · ' + h.taste)
.fontSize(11)
.fontColor('#B03A2E')
Text('宜:' + h.suit)
.fontSize(10)
.fontColor('#8C8378')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(h.fav ? '❤️' : '🤍')
.fontSize(16)
.onClick(() => {
this.toggleFav5(h)
})
Text('详情')
.fontSize(11)
.fontColor('#B03A2E')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#F9F1E3')
.borderRadius(10)
.onClick(() => {
this.selHerb = h
this.showHerb = true
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
}, (h: HerbT) => h.name + h.fav.toString())
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
herbTab 构建药膳库页面。页面顶部是筛选标签栏,使用 ForEach 遍历体质名称数组,渲染一组可点击的筛选标签。选中态标签为红色背景白字,未选中为米色背景灰字。点击标签切换 selBody 状态,影响标签样式和后续列表筛选(虽然列表本身未做过滤,但标签状态联动已实现)。
药膳列表使用 ForEach 遍历 this.herbs 状态数组(而非常量 HERBS5),因为收藏状态需要动态变化。每条药膳记录是一个 Row 卡片:左侧表情图标(米色圆角背景)、中间药膳信息(名称、功效性味、适宜体质)、右侧收藏心和详情按钮。收藏心根据 h.fav 显示红心或空心,点击调用 toggleFav5(h) 切换收藏状态。详情按钮点击后设置 selHerb 并打开药膳详情弹窗。
技术要点:
ForEach的键值生成函数这里使用了h.name + h.fav.toString(),将名称和收藏状态组合为键。这是因为收藏状态变化时,键值也会变化,强制框架将该条目视为"新项"进行完整重新渲染,确保收藏图标即时更新。如果仅使用h.name作为键,当fav从 false 变为 true 时,键值不变,框架可能复用旧节点而不刷新图标。这种"键值含状态字段"的技巧在需要强制刷新列表项时很有用。
九、Tab 2 经络穴位页:Stack 层叠、Progress 环形进度与 Scroll 横向滚动
@Builder
meridianTab() {
Column({ space: 0 }) {
this.secTitle5('🧘', '今日穴位按摩', this.pointDone5().toString() + '/' + this.points.length.toString() + ' 已按')
Column({ space: 12 }) {
Row({ space: 14 }) {
Stack() {
Progress({ value: this.pointDone5() * 100 / this.points.length, total: 100 })
.width(82)
.height(82)
.style({ strokeWidth: 9 })
Column({ space: 1 }) {
Text('☯️')
.fontSize(14)
.offset({ y: this.qiY })
Text(this.pointDone5().toString() + '/' + this.points.length.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
}
}
Column({ space: 5 }) {
Text('经络调理进行中')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('每穴按揉 3 分钟,酸胀为度')
.fontSize(11)
.fontColor('#8C8378')
Text('今日已积累 14 分钟')
.fontSize(11)
.fontColor('#16A085')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(18)
meridianTab 经络页的核心亮点是 Stack 层叠布局配合 Progress 环形进度组件。Stack 容器将其子元素按层叠方式堆放——后声明的子元素覆盖在先声明的子元素之上。这里第一层是 Progress 环形进度条(82vp 见方),第二层是一个 Column,包含太极图标(同样绑定 qiY 动画浮动)和完成进度文字。两层叠放,形成"环形进度条中间显示数字和图标"的经典仪表盘效果。
Progress 组件通过 value 和 total 参数定义进度比例——value 为当前值,total 为总值,进度比例为 value/total。这里 value 为已完成穴位数乘以 100 除以总数,total 为 100,即百分比。.style({ strokeWidth: 9 }) 设置环形进度条的线宽为 9vp。
技术要点:
Stack是 ArkUI 三大布局容器之一,与Column(纵向)、Row(横向)并列。Stack的层叠特性使其非常适合构建"叠加视觉"——如进度环+中心文字、背景图+前景文字、地图+标记点等。默认情况下Stack的子元素居中对齐,可通过.alignContent()参数调整对齐方式。Progress组件支持三种类型:Linear(线性条)、Ring/Circular(环形)、ScaleRing(刻度环),通过type参数或ProgressType枚举指定。默认为环形。
this.secTitle5('⏳', '十二时辰经络流注', '顺时养生')
Scroll() {
Row({ space: 10 }) {
ForEach(MERIDIANHOURS5, (m: string, i: number) => {
Column({ space: 4 }) {
Text(m.split(' ')[0])
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(i === 8 ? '#FBF7F0' : '#3D3125')
Text(m.split(' ')[1])
.fontSize(10)
.fontColor(i === 8 ? '#FBF7F0' : '#8C8378')
}
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(i === 8 ? '#B03A2E' : '#FFFFFF')
.borderRadius(12)
}, (m: string) => m)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
Text('当前戌时 · 心包经当令,宜舒缓情志、准备入静')
.fontSize(11)
.fontColor('#B03A2E')
.margin({ top: 10 })
十二时辰经络流注使用 Scroll 横向滚动容器展示。Scroll 内嵌一个 Row,Row 中使用 ForEach 遍历 MERIDIANHOURS5 数组,为每个时辰渲染一个卡片。每个卡片通过 m.split(' ')[0] 和 m.split(' ')[1] 分别提取时辰名称(如"寅时")和经络名称(如"肺经")。当索引为 8(戌时,当前时辰)时,卡片高亮为红色背景白字,其余为白底深色字。
.scrollable(ScrollDirection.Horizontal) 将滚动方向设为水平,.scrollBar(BarState.Off) 隐藏滚动条,使横向滚动更干净。在 ForEach 的渲染函数中,第二个参数 i: number 是当前项的索引,这是 ForEach 的可选第二参数,常用于需要根据位置做差异化渲染的场景。
技术要点:
Scroll是 ArkUI 的滚动容器组件,可容纳超出视口的内容并支持手势滚动。ScrollDirection枚举提供Horizontal(水平)、Vertical(垂直)和Free(自由)三种方向。BarState枚举控制滚动条显示:On(常显)、Off(隐藏)、Auto(自动)。横向滚动列表是移动端常见的 UI 模式,特别适合展示时间轴、标签栏、分类选择等有限项的横向排列。
this.secTitle5('📍', '穴位库', '点击查看取穴')
Column({ space: 10 }) {
ForEach(this.points, (p: PointT) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(p.done ? '#B3A897' : '#3D3125')
Text(p.meridian + ' · ' + p.effect)
.fontSize(10)
.fontColor('#8C8378')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(p.done ? '已按 ✅' : '未按')
.fontSize(11)
.fontColor(p.done ? '#16A085' : '#B03A2E')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(p.done ? '#EAF6F1' : '#F9F1E3')
.borderRadius(10)
.onClick(() => {
this.doPoint5(p)
})
Text('取穴')
.fontSize(11)
.fontColor('#8C8378')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#F3ECDD')
.borderRadius(10)
.onClick(() => {
this.selPoint = p
this.showPoint = true
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
}, (p: PointT) => p.name + p.done.toString())
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
穴位库列表使用 ForEach 遍历 this.points 状态数组。每条穴位记录卡片包含:穴位名称(已按的变灰、未按的深色)、经络与功效(.maxLines(1) 限制单行、.textOverflow({ overflow: TextOverflow.Ellipsis }) 超出部分显示省略号)、打卡按钮(已按绿色、未按红色,点击调用 doPoint5(p) 切换状态)、取穴按钮(点击打开穴位详情弹窗)。
技术要点:
.maxLines(1)和.textOverflow({ overflow: TextOverflow.Ellipsis })是处理文本溢出的黄金组合。maxLines限制最大行数,textOverflow设置溢出时的处理方式——Ellipsis为省略号、Clip为直接裁剪、None为不处理。在列表项中限制文本行数可以防止过长描述撑破布局,保持列表项高度一致。键值函数p.name + p.done.toString()同样将状态纳入键值,确保打卡切换时列表项正确刷新。
十、Tab 3 医案页:时间轴布局与条件渲染
@Builder
caseTab() {
Column({ space: 0 }) {
this.secTitle5('📋', '调理医案', '共 ' + this.cases.length.toString() + ' 条')
Column() {
ForEach(this.cases, (c: CaseT, i: number) => {
Row({ space: 10 }) {
Column() {
Circle({ width: 9, height: 9 })
.fill('#B03A2E')
.opacity(this.yinOp)
if (i < this.cases.length - 1) {
Row()
.width(2)
.layoutWeight(1)
.backgroundColor('#E5DCC8')
}
}
.width(12)
.constraintSize({ minHeight: 60 })
Column({ space: 4 }) {
Row() {
Text(c.type)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
.layoutWeight(1)
Text(c.date)
.fontSize(10)
.fontColor('#B3A897')
}
.width('100%')
Text(c.result)
.fontSize(11)
.fontColor('#8C8378')
Row({ space: 8 }) {
Text('医师:' + c.doc)
.fontSize(10)
.fontColor('#B03A2E')
.layoutWeight(1)
Text('删除')
.fontSize(10)
.fontColor('#B03A2E')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#F9E9E7')
.borderRadius(8)
.onClick(() => {
this.delCase = c
this.delArmed = false
this.showDel = true
})
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.padding({ top: 2, bottom: 14 })
}
.width('100%')
.alignItems(VerticalAlign.Top)
}, (c: CaseT) => c.date + c.type + c.result)
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(18)
Text('📜 医案仅作调理参考,具体诊疗请至线下中医门诊')
.fontSize(11)
.fontColor('#B3A897')
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
caseTab 构建医案页面,采用时间轴(Timeline)布局。时间轴的核心结构是每条记录左侧的 Column:顶部是 Circle 圆点(绑定 yinOp 透明度动画,呈现呼吸闪烁效果),圆点下方是一根竖线(Row 宽 2vp,layoutWeight(1) 填充剩余高度,浅米色)。通过 if (i < this.cases.length - 1) 条件判断,最后一条记录不渲染竖线,形成完整的时间轴视觉。
右侧是医案内容:类型标题与日期横排、诊疗结果描述、医师名与删除按钮横排。删除按钮点击后设置 delCase 为当前记录、重置确认开关 delArmed 为 false、打开删除确认弹窗 showDel = true。.constraintSize({ minHeight: 60 }) 设置最小高度为 60vp,确保即使内容很短,时间轴的间距也保持均匀。
技术要点:
Circle是 ArkUI 的内置图形组件,通过width和height定义尺寸,.fill()设置填充颜色。if/else条件渲染是 ArkTS 在build()和@Builder方法中支持的控制流语法——条件为 true 时渲染对应 UI,为 false 时跳过。这里用条件渲染实现了"最后一条无竖线"的效果。.alignItems(VerticalAlign.Top)让Row内子元素顶部对齐,使时间轴圆点与医案标题在同一水平线上。constraintSize是约束尺寸属性,minHeight确保最小高度,防止内容过少时布局塌缩。
十一、Tab 4 调理计划页:Progress 进度环与 Toggle 开关
@Builder
planTab() {
Column({ space: 0 }) {
this.secTitle5('📅', '调理计划', '执行率 ' + this.planRate5().toString() + '%')
Column({ space: 12 }) {
Row({ space: 14 }) {
Stack() {
Progress({ value: this.planRate5(), total: 100 })
.width(80)
.height(80)
.style({ strokeWidth: 9 })
Column({ space: 1 }) {
Text('🍂')
.fontSize(13)
.offset({ y: this.qiY })
Text(this.planRate5().toString() + '%')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
}
}
Column({ space: 5 }) {
Text('气虚质四周调理方案 · 第 3 周')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('补脾为主线,兼化痰湿,起居有常')
.fontSize(11)
.fontColor('#8C8378')
Text('已完成 18/28 天打卡')
.fontSize(11)
.fontColor('#16A085')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(18)
planTab 调理计划页的顶部与经络页类似,使用 Stack + Progress 构建环形进度仪表盘。进度值绑定到 planRate5() 方法的返回值,显示计划执行率百分比。中心叠加落叶表情(绑定 qiY 动画)和百分比文字。右侧是方案描述信息。
this.secTitle5('🧾', '计划明细', '点击编辑')
Column({ space: 10 }) {
ForEach(this.plans, (p: PlanT, i: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(p.on ? '#3D3125' : '#B3A897')
Text(p.detail)
.fontSize(10)
.fontColor('#8C8378')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 180 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(p.freq)
.fontSize(10)
.fontColor('#B03A2E')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('#F9F1E3')
.borderRadius(8)
Text('编辑')
.fontSize(11)
.fontColor('#FBF7F0')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#B03A2E')
.borderRadius(10)
.onClick(() => {
this.editIdx = i
this.editFreq = '每日'
this.editTimes = 1
this.editOn = p.on
this.showEdit = true
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
}, (p: PlanT, i: number) => p.title + i.toString())
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
计划明细列表使用 ForEach 遍历 this.plans 状态数组,ForEach 的第二参数同时接收项数据和索引 i: number。每条计划卡片包含标题(启用深色、禁用灰色)、详情描述(单行省略,.constraintSize({ maxWidth: 180 }) 限制最大宽度防溢出)、频次标签、编辑按钮。编辑按钮点击时将索引 i 和默认编辑参数写入状态变量,打开编辑弹窗。
技术要点:
ForEach的项渲染函数签名为(item: T, index?: number) => void,第二个参数是可选的索引。当需要根据位置做差异化处理(如交替背景色、首尾特殊样式)时,索引非常有用。键值函数p.title + i.toString()将标题和索引组合为键——包含索引是为了处理多条同名计划的情况,确保每条记录的键值唯一。.constraintSize({ maxWidth: 180 })限制文本最大宽度,配合maxLines(1)和省略号,确保长描述不会撑破卡片布局。
十二、Tab 5 我的页:用户卡片与设置列表
@Builder
mineTab() {
Column({ space: 0 }) {
Column({ space: 8 }) {
Text('🧑⚕️')
.fontSize(42)
.padding(14)
.backgroundColor('#F9F1E3')
.borderRadius(30)
Text('李小天')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('气虚质调理第 68 天 · 平和质目标进度 62%')
.fontSize(12)
.fontColor('#B03A2E')
Row({ space: 8 }) {
Text('药膳收藏 ' + this.favHerb5().toString() + ' 道')
.fontSize(11)
.fontColor('#D35400')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#F9F1E3')
.borderRadius(10)
Text('打卡 46 天')
.fontSize(11)
.fontColor('#16A085')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#EAF6F1')
.borderRadius(10)
}
}
.width('100%')
.padding({ top: 22, bottom: 18 })
.backgroundColor('#FFFFFF')
.borderRadius(20)
mineTab 我的页面顶部是用户信息卡片:大头像(42vp 的表情图标,30vp 圆角的米色背景块)、姓名、调理进度描述、两个统计标签(药膳收藏数、打卡天数)。收藏数动态绑定到 favHerb5() 方法返回值,随收藏状态实时更新。
this.secTitle5('🫖', '我的代茶饮', '定制养生茶')
Column({ space: 10 }) {
ForEach(TEAS5, (t: TeaT) => {
Row({ space: 10 }) {
Text(t.emoji)
.fontSize(20)
Column({ space: 3 }) {
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text(t.mat + ' · ' + t.effect + ' · 宜' + t.season)
.fontSize(10)
.fontColor('#8C8378')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('定制')
.fontSize(11)
.fontColor('#FBF7F0')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor('#B03A2E')
.borderRadius(10)
.onClick(() => {
this.teaBase = t.name
this.showTea = true
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
}, (t: TeaT) => t.name)
}
.width('100%')
代茶饮列表使用 ForEach 遍历 TEAS5 常量数组,每条记录展示配方名、材料配比、功效、适宜季节,右侧"定制"按钮点击后设置 teaBase 为当前茶饮名并打开代茶饮定制弹窗。
this.secTitle5('⚙️', '设置', '')
Column({ space: 0 }) {
ForEach(['🔔 节气与时辰提醒', '☯️ 体质档案管理', '🍵 药膳收藏夹', '👨👩👧 家庭体质档案', '📅 调理计划通知', '📞 咨询执业中医师'], (s: string) => {
Row({ space: 8 }) {
Text(s)
.fontSize(13)
.fontColor('#3D3125')
.layoutWeight(1)
Text('›')
.fontSize(16)
.fontColor('#B3A897')
}
.width('100%')
.padding({ top: 13, bottom: 13 })
.backgroundColor('#FFFFFF')
}, (s: string) => s)
}
.width('100%')
.borderRadius(14)
.clip(true)
Text('TCM BALANCE · 中医体质辨识 v5.0.1')
.fontSize(10)
.fontColor('#B3A897')
.margin({ top: 18, bottom: 6 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
}
设置列表使用 ForEach 遍历字符串数组,渲染一组设置项。每项左侧是设置名称(layoutWeight(1) 占满空间),右侧是箭头符号"›"。整个设置列表容器使用 .borderRadius(14) 和 .clip(true)——clip(true) 是裁剪属性,使子元素的圆角不超出容器边界,形成顶部和底部行方角、整体容器圆角的分组列表效果。底部是应用版本号文字。
技术要点:
.clip(true)在 ArkUI 中用于裁剪溢出内容。当一个圆角容器内有多个子元素时,如果不裁剪,内部子元素的直角会超出容器的圆角边界。.clip(true)强制将超出部分裁剪掉,实现"外圆内方"的分组列表视觉。这是 iOS 风格设置列表的经典实现方式。ForEach也可以遍历普通字符串数组(Array<string>),不一定需要复杂对象——当数据本身就是一个简单值时,直接使用该值作为渲染内容即可。
十三、弹层系统:position 绝对定位与 zIndex 层级控制
13.1 弹层架构模式
本应用的六个弹层采用了统一的架构模式:每个弹层由两部分组成——遮罩层和内容层。遮罩层是一个全屏半透明背景,点击可关闭弹层;内容层是实际的弹窗卡片,通过 position 绝对定位放置在屏幕特定位置。两者通过 zIndex 控制层叠顺序,遮罩在下(zIndex: 999)、内容在上(zIndex: 1000)。
技术要点:
position是绝对定位属性,通过{ x, y }参数指定元素相对于父容器左上角的偏移。使用position后,元素脱离正常文档流,不再影响其他元素的布局。zIndex控制层叠顺序——数值越大越在上层。在Stack容器中,zIndex用于精确控制子元素的堆叠顺序。遮罩 999、内容 1000 的搭配确保弹层内容始终在遮罩之上,遮罩又在所有页面内容之上。这种"遮罩+内容+绝对定位+zIndex"的组合是 ArkTS 中实现自定义弹层的标准范式,比使用系统Dialog组件更灵活,可完全控制样式和位置。
13.2 体质测试弹层(居中大卡)
@Builder
testModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#663D3125')
.onClick(() => {
this.showTest = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 14 }) {
Text('☯️ 中医体质快速测试')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('第 ' + (this.qIdx + 1).toString() + ' / 6 题')
.fontSize(11)
.fontColor('#8C8378')
Progress({ value: (this.qIdx + 1) * 100 / 6, total: 100, type: ProgressType.Linear })
.width('100%')
.style({ strokeWidth: 8 })
体质测试弹层的内容卡片居中显示(通过 position({ x: '7%', y: '12%' }) 定位),宽度为屏幕的 86%。卡片内包含标题、题号进度文字、线性进度条(ProgressType.Linear),以及题目内容和四个选项按钮。
线性进度条通过 value: (this.qIdx + 1) * 100 / 6 计算当前题目的进度百分比——第一题为 16.7%,第六题为 100%。题号变化时进度条自动前进。
Column({ space: 10 }) {
if (this.qIdx === 0) {
Text('您容易感到疲乏、说话有气无力吗?')
.fontSize(13)
.fontColor('#3D3125')
.width('100%')
} else if (this.qIdx === 1) {
Text('您比别人容易怕冷,手脚发凉吗?')
.fontSize(13)
.fontColor('#3D3125')
.width('100%')
} else if (this.qIdx === 2) {
Text('您感到口干咽燥、总想喝水吗?')
.fontSize(13)
.fontColor('#3D3125')
.width('100%')
} else if (this.qIdx === 3) {
Text('您感到身体沉重不轻松、腹部肥满吗?')
.fontSize(13)
.fontColor('#3D3125')
.width('100%')
} else if (this.qIdx === 4) {
Text('您容易情绪低落、多愁善感吗?')
.fontSize(13)
.fontColor('#3D3125')
.width('100%')
} else {
Text('您容易对药物、食物、花粉过敏吗?')
.fontSize(13)
.fontColor('#3D3125')
.width('100%')
}
Row({ space: 8 }) {
Text('没有')
.fontSize(12)
.fontColor('#8C8378')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#F3ECDD')
.borderRadius(12)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.nextQ5(0, 0)
})
Text('偶尔')
.fontSize(12)
.fontColor('#D35400')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#F9F1E3')
.borderRadius(12)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.nextQ5(1, 0)
})
}
.width('100%')
Row({ space: 8 }) {
Text('经常')
.fontSize(12)
.fontColor('#B03A2E')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#F9E9E7')
.borderRadius(12)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.nextQ5(0, 2)
})
Text('总是')
.fontSize(12)
.fontColor('#FBF7F0')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#B03A2E')
.borderRadius(12)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.nextQ5(1, 3)
})
}
.width('100%')
}
.width('100%')
if (this.qIdx >= 5) {
Text('完成本题即可生成辨识结果')
.fontSize(11)
.fontColor('#16A085')
} else {
Text('作答后自动进入下一题')
.fontSize(11)
.fontColor('#B3A897')
}
}
.width('86%')
.padding(18)
.backgroundColor('#FBF7F0')
.borderRadius(22)
.position({ x: '7%', y: '12%' })
.zIndex(1000)
}
题目内容通过 if/else if/else 条件渲染链实现——根据 qIdx 的值显示对应的题目文本。六个题目分别对应气虚、阳虚、阴虚、痰湿、气郁、特禀六种体质倾向。四个选项"没有"“偶尔”“经常”"总是"分别传入不同的评分参数:(0,0)、(1,0)、(0,2)、(1,3),对应 nextQ5 的 a 和 b 参数。四个选项使用 layoutWeight(1) 等宽分布,.textAlign(TextAlign.Center) 使文字居中。
技术要点:
if/else if/else条件渲染链在 ArkTS 中是合法的 UI 描述语法。每条分支内声明的 UI 组件只在条件满足时被渲染。这与 JavaScript 的 if 语句在形式上一致,但在 ArkTS 中它运行在 UI 描述层而非逻辑层——框架会根据条件增删 UI 节点。TextAlign.Center是文本对齐枚举值,使Text内容在自身宽度内居中。配合layoutWeight(1),四个选项按钮等宽分布且文字居中,形成整齐的选项网格。(this.qIdx + 1) * 100 / 6的计算方式确保进度条在第一题时就显示约 16.7% 而非 0%,给用户"已开始"的心理暗示。
13.3 药膳详情弹层(底部抽屉)
@Builder
herbModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#663D3125')
.onClick(() => {
this.showHerb = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 14 }) {
Row()
.width(44)
.height(5)
.backgroundColor('#E5DCC8')
.borderRadius(3)
.margin({ top: 10 })
Row({ space: 12 }) {
Text(this.selHerb.emoji)
.fontSize(38)
Column({ space: 3 }) {
Text(this.selHerb.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text(this.selHerb.func + ' · ' + this.selHerb.taste)
.fontSize(11)
.fontColor('#B03A2E')
Text('宜:' + this.selHerb.suit + ' 人群')
.fontSize(11)
.fontColor('#8C8378')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(this.selHerb.fav ? '❤️' : '🤍')
.fontSize(20)
.onClick(() => {
this.toggleFav5(this.selHerb)
})
}
.width('100%')
Column({ space: 7 }) {
Text('做法')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
.width('100%')
Text('食材冷水浸泡 30 分钟,武火煮沸后转文火慢炖 40 分钟,空腹温服为佳。')
.fontSize(11)
.fontColor('#8C8378')
.lineHeight(17)
Text('宜忌')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
.width('100%')
.margin({ top: 4 })
Text('感冒发热期间停服;服用人参类方剂时忌萝卜与浓茶。')
.fontSize(11)
.fontColor('#8C8378')
.lineHeight(17)
}
.width('100%')
.padding(12)
.backgroundColor('#F9F1E3')
.borderRadius(14)
Row({ space: 10 }) {
Text('关闭')
.fontSize(13)
.fontColor('#8C8378')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
.backgroundColor('#F3ECDD')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showHerb = false
})
Text('加入一周食谱')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FBF7F0')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
.backgroundColor('#B03A2E')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showHerb = false
})
}
.width('100%')
.margin({ top: 2, bottom: 14 })
}
.width('100%')
.padding({ left: 18, right: 18 })
.backgroundColor('#FBF7F0')
.borderRadius({ topLeft: 24, topRight: 24 })
.position({ x: 0, y: '46%' })
.zIndex(1000)
}
药膳详情弹层采用底部抽屉样式——内容卡片通过 .position({ x: 0, y: '46%' }) 定位在屏幕下方 46% 处,顶部圆角(borderRadius({ topLeft: 24, topRight: 24 })),形成从底部滑出的抽屉视觉效果。卡片顶部是一个 44vp 宽、5vp 高的拖拽指示条(圆角矩形),这是移动端底部弹窗的标准设计语言。卡片内容展示选中药膳的详细信息:表情图标、名称、功效性味、适宜人群、收藏按钮、做法说明、宜忌提示,底部是"关闭"和"加入一周食谱"两个操作按钮。
技术要点:
.borderRadius({ topLeft: 24, topRight: 24 })是 ArkUI 的部分圆角语法——可以分别为四个角设置不同的圆角值。参数为topLeft、topRight、bottomLeft、bottomRight。底部抽屉通常只有顶部两角为圆角,底部为直角(因为紧贴屏幕底边)。.lineHeight(17)设置文本行高为 17vp,使多行描述文字有舒适的行间距。底部双按钮使用layoutWeight(1)等宽分布、textAlign(TextAlign.Center)文字居中,形成对称的操作栏。
13.4 穴位取穴弹层(底部抽屉)
@Builder
pointModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#663D3125')
.onClick(() => {
this.showPoint = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 14 }) {
Row()
.width(44)
.height(5)
.backgroundColor('#E5DCC8')
.borderRadius(3)
.margin({ top: 10 })
Row({ space: 12 }) {
Column() {
Text('🧘')
.fontSize(30)
.offset({ y: this.qiY })
}
.padding(12)
.backgroundColor('#F9F1E3')
.borderRadius(16)
Column({ space: 3 }) {
Text(this.selPoint.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text(this.selPoint.meridian)
.fontSize(11)
.fontColor('#B03A2E')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(this.selPoint.done ? '今日已按' : '今日未按')
.fontSize(10)
.fontColor(this.selPoint.done ? '#16A085' : '#8C8378')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(this.selPoint.done ? '#EAF6F1' : '#F3ECDD')
.borderRadius(10)
}
.width('100%')
Column({ space: 7 }) {
Text('功效')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
.width('100%')
Text(this.selPoint.effect)
.fontSize(11)
.fontColor('#8C8378')
Text('取穴方法')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
.width('100%')
.margin({ top: 4 })
Text('拇指指腹按压,以酸胀为度,顺时针揉按 ' + this.selPoint.min.toString() + ' 分钟,早晚各一次。')
.fontSize(11)
.fontColor('#8C8378')
.lineHeight(17)
}
.width('100%')
.padding(12)
.backgroundColor('#F9F1E3')
.borderRadius(14)
Row({ space: 10 }) {
Text('关闭')
.fontSize(13)
.fontColor('#8C8378')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
.backgroundColor('#F3ECDD')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showPoint = false
})
Text('标记已按揉')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FBF7F0')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
.backgroundColor('#16A085')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.doPoint5(this.selPoint)
this.showPoint = false
})
}
.width('100%')
.margin({ top: 2, bottom: 14 })
}
.width('100%')
.padding({ left: 18, right: 18 })
.backgroundColor('#FBF7F0')
.borderRadius({ topLeft: 24, topRight: 24 })
.position({ x: 0, y: '44%' })
.zIndex(1000)
}
穴位取穴弹层与药膳详情弹层结构一致,都是底部抽屉。内容展示选中穴位的名称、所属经络、打卡状态标签、功效说明和取穴方法(动态拼接 selPoint.min 分钟数)。底部"关闭"按钮关闭弹层,"标记已按揉"按钮调用 doPoint5(this.selPoint) 切换打卡状态并关闭弹层。穴位图标同样绑定 qiY 浮动动画。
13.5 编辑计划弹层(居中卡片)与 Toggle 组件
@Builder
editModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#663D3125')
.onClick(() => {
this.showEdit = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 13 }) {
Text('✏️ 编辑调理计划')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('「' + this.plans[this.editIdx].title + '」')
.fontSize(12)
.fontColor('#8C8378')
Column({ space: 8 }) {
Text('执行频次')
.fontSize(12)
.fontColor('#8C8378')
.width('100%')
Row({ space: 8 }) {
ForEach(['每日', '每周 2 次', '每周 3 次', '每周 5 次'], (f: string) => {
Text(f)
.fontSize(11)
.fontColor(this.editFreq === f ? '#FBF7F0' : '#8C8378')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(this.editFreq === f ? '#B03A2E' : '#F3ECDD')
.borderRadius(12)
.onClick(() => {
this.editFreq = f
})
}, (f: string) => f)
}
.width('100%')
}
.width('100%')
编辑计划弹层采用居中卡片样式(position({ x: '9%', y: '15%' }),宽度 82%)。卡片内容包含:标题、计划名称、执行频次选择器(ForEach 遍历四个频次选项,选中红色背景)、每日次数步进器(减号按钮 + 数字显示 + 加号按钮)、开启提醒开关(Toggle 组件)、取消和保存按钮。
Column({ space: 8 }) {
Text('每日次数:' + this.editTimes.toString() + ' 次')
.fontSize(12)
.fontColor('#8C8378')
.width('100%')
Row({ space: 14 }) {
Text('-')
.fontSize(16)
.fontColor('#B03A2E')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor('#F9E9E7')
.borderRadius(12)
.onClick(() => {
if (this.editTimes > 1) {
this.editTimes -= 1
}
})
Text(this.editTimes.toString() + ' 次')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('+')
.fontSize(16)
.fontColor('#16A085')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor('#EAF6F1')
.borderRadius(12)
.onClick(() => {
if (this.editTimes < 4) {
this.editTimes += 1
}
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
Row({ space: 8 }) {
Text('开启提醒')
.fontSize(13)
.fontColor('#3D3125')
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.editOn })
.onChange((v: boolean) => {
this.editOn = v
})
}
.width('100%')
Row({ space: 10 }) {
Text('取消')
.fontSize(13)
.fontColor('#8C8378')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
.backgroundColor('#F3ECDD')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showEdit = false
})
Text('保存')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FBF7F0')
.padding({ left: 14, right: 14, top: 9, bottom: 9 })
.backgroundColor('#B03A2E')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.confirmEdit5()
})
}
.width('100%')
.margin({ top: 2 })
}
.width('82%')
.padding(18)
.backgroundColor('#FBF7F0')
.borderRadius(22)
.position({ x: '9%', y: '15%' })
.zIndex(1000)
}
步进器通过两个 Text 按钮("-“和"+”)实现,分别绑定 editTimes 的递减和递增。递减有下限保护(if (this.editTimes > 1)),递增有上限保护(if (this.editTimes < 4)),防止值超出合理范围。.justifyContent(FlexAlign.Center) 使步进器内部三个元素水平居中排列。
Toggle 组件是 ArkUI 的开关控件,通过 type: ToggleType.Switch 指定为滑动开关样式,isOn 绑定到 editOn 状态变量。.onChange 回调在开关状态变化时被调用,参数 v 是新的布尔值,更新到 editOn 中。
技术要点:
Toggle是 ArkUI 的内置开关组件,支持两种类型:ToggleType.Switch(滑动开关)和ToggleType.Checkbox(复选框)和ToggleType.Button(按钮式)。isOn参数设置初始开关状态,.onChange回调监听状态变化。.justifyContent(FlexAlign.Center)是Row/Column的主轴对齐属性,FlexAlign枚举提供Start、Center、End、SpaceBetween、SpaceAround、SpaceEvenly等对齐方式。步进器的上下限保护是交互设计的基本原则——防止用户输入超出业务逻辑允许范围的值。
13.6 删除医案弹层(危险操作确认)
@Builder
delModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#663D3125')
.onClick(() => {
this.showDel = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 12 }) {
Text('⚠️')
.fontSize(28)
Text('删除医案记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#B03A2E')
Text('「' + this.delCase.date + ' ' + this.delCase.type + '」删除后不可恢复,请谨慎操作。')
.fontSize(12)
.fontColor('#8C8378')
.textAlign(TextAlign.Center)
.lineHeight(18)
Row({ space: 8 }) {
Text('我已知晓')
.fontSize(12)
.fontColor('#8C8378')
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.delArmed })
.onChange((v: boolean) => {
this.delArmed = v
})
}
.width('100%')
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
.backgroundColor('#F9E9E7')
.borderRadius(12)
Row({ space: 10 }) {
Text('再想想')
.fontSize(13)
.fontColor('#8C8378')
.padding({ left: 16, right: 16, top: 9, bottom: 9 })
.backgroundColor('#F3ECDD')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showDel = false
})
Text('确认删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FBF7F0')
.padding({ left: 16, right: 16, top: 9, bottom: 9 })
.backgroundColor(this.delArmed ? '#B03A2E' : '#D9A7A2')
.borderRadius(14)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
if (this.delArmed) {
this.doDel5()
}
})
}
.width('100%')
}
.width('74%')
.padding(18)
.backgroundColor('#FBF7F0')
.borderRadius(20)
.position({ x: '13%', y: '27%' })
.zIndex(1000)
}
删除医案弹层是一个窄幅居中卡片(宽度 74%),用于危险操作的二次确认。设计亮点是"我已知晓"开关——用户必须先打开 Toggle 开关(delArmed 变为 true),"确认删除"按钮的背景色才会从浅红(#D9A7A2,不可用态)变为深红(#B03A2E,可用态)。点击"确认删除"时检查 if (this.delArmed),只有开关已打开才执行 doDel5()。这种"二次确认+武装开关"的双重保险机制,在删除类不可逆操作中是标准的安全设计模式。
技术要点: 按钮的"可用/不可用"态通过条件背景色实现:
backgroundColor(this.delArmed ? '#B03A2E' : '#D9A7A2')。当delArmed为 false 时,按钮显示为浅红色,视觉上传达"不可点击"的信号;为 true 时变为深红色,传达"可执行"的信号。配合onClick中的if检查,形成了视觉和逻辑双重保护。这种模式比简单的AlertDialog确认框更细致,提供了更好的用户引导和安全保障。#663D3125是带透明度的颜色——前两位66是 Alpha 通道值(十六进制的 66 约等于十进制的 102,即 40% 不透明度),后六位是 RGB 颜色值。
13.7 代茶饮定制弹层(底部抽屉)
@Builder
teaModalOverlay() {
Column() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#663D3125')
.onClick(() => {
this.showTea = false
})
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
Column({ space: 14 }) {
Row()
.width(44)
.height(5)
.backgroundColor('#E5DCC8')
.borderRadius(3)
.margin({ top: 10 })
Text('🫖 定制「' + this.teaBase + '」')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Column({ space: 8 }) {
Text('甜度')
.fontSize(12)
.fontColor('#8C8378')
.width('100%')
Row({ space: 8 }) {
ForEach(['无糖', '微糖', '半糖', '罗汉果甜'], (s: string) => {
Text(s)
.fontSize(12)
.fontColor(this.teaSweet === s ? '#FBF7F0' : '#8C8378')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(this.teaSweet === s ? '#16A085' : '#F3ECDD')
.borderRadius(12)
.onClick(() => {
this.teaSweet = s
})
}, (s: string) => s)
}
.width('100%')
}
.width('100%')
Column({ space: 8 }) {
Text('饮用温度')
.fontSize(12)
.fontColor('#8C8378')
.width('100%')
Row({ space: 8 }) {
ForEach(['热饮', '温饮', '常温'], (s: string) => {
Text(s)
.fontSize(12)
.fontColor(this.teaHot === s ? '#FBF7F0' : '#8C8378')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.teaHot === s ? '#D35400' : '#F3ECDD')
.borderRadius(12)
.onClick(() => {
this.teaHot = s
})
}, (s: string) => s)
}
.width('100%')
}
.width('100%')
Column({ space: 8 }) {
Text('配送周期:' + this.teaCnt.toString() + ' 天量')
.fontSize(12)
.fontColor('#8C8378')
.width('100%')
Row({ space: 14 }) {
Text('-')
.fontSize(16)
.fontColor('#D35400')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor('#F9F1E3')
.borderRadius(12)
.onClick(() => {
if (this.teaCnt > 3) {
this.teaCnt -= 1
}
})
Text(this.teaCnt.toString() + ' 天')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#3D3125')
Text('+')
.fontSize(16)
.fontColor('#B03A2E')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor('#F9E9E7')
.borderRadius(12)
.onClick(() => {
if (this.teaCnt < 30) {
this.teaCnt += 1
}
})
Text('¥' + (this.teaCnt * 6).toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#B03A2E')
.margin({ left: 6 })
}
.width('100%')
}
.width('100%')
Row({ space: 10 }) {
Text('取消')
.fontSize(14)
.fontColor('#8C8378')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor('#F3ECDD')
.borderRadius(16)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showTea = false
})
Text('确认定制')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FBF7F0')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor('#B03A2E')
.borderRadius(16)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showTea = false
})
}
.width('100%')
.margin({ top: 2, bottom: 14 })
}
.width('100%')
.padding({ left: 18, right: 18 })
.backgroundColor('#FBF7F0')
.borderRadius({ topLeft: 24, topRight: 24 })
.position({ x: 0, y: '42%' })
.zIndex(1000)
}
代茶饮定制弹层是功能最丰富的底部抽屉,包含三组选择器和一组步进器:甜度选择(无糖/微糖/半糖/罗汉果甜,选中绿色)、饮用温度选择(热饮/温饮/常温,选中橙色)、配送周期步进器(3-30 天,下限 3、上限 30)和实时价格显示(teaCnt * 6 元)。三组选择器的选中态使用不同颜色——甜度绿色、温度橙色、步进器红色——通过颜色区分不同配置维度。价格随天数实时计算并显示,为用户提供了直观的金额反馈。
技术要点: 多个
ForEach在同一弹层中使用,每个遍历不同的字符串数组,生成不同维度的选择器。每个选择器的选中态颜色不同(绿、橙、红),这通过在各自的backgroundColor三元表达式中使用不同色值实现。步进器的价格计算this.teaCnt * 6嵌入在Text的字符串拼接中,由于teaCnt是@State变量,每次步进时价格自动更新。这种"计算值嵌入 UI"的模式在声明式框架中非常自然——无需手动刷新,状态变化自动触发重渲染。
十四、根布局 build():Stack 层叠与条件渲染弹层
build() {
Stack() {
Column() {
this.headerBar5()
Scroll() {
Column() {
if (this.selTab === 0) {
this.bodyTab()
} else if (this.selTab === 1) {
this.herbTab()
} else if (this.selTab === 2) {
this.meridianTab()
} else if (this.selTab === 3) {
this.caseTab()
} else if (this.selTab === 4) {
this.planTab()
} else {
this.mineTab()
}
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.width('100%')
.backgroundColor('#FBF7F0')
this.tabBar5()
}
.width('100%')
.height('100%')
if (this.showTest) {
this.testModalOverlay()
}
if (this.showHerb) {
this.herbModalOverlay()
}
if (this.showPoint) {
this.pointModalOverlay()
}
if (this.showEdit) {
this.editModalOverlay()
}
if (this.showDel) {
this.delModalOverlay()
}
if (this.showTea) {
this.teaModalOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor('#FBF7F0')
}
}
build() 是每个 @Component 组件必须实现的方法,它返回组件的 UI 描述。本应用的 build() 使用 Stack 作为根容器,内部包含两个层级:第一层是主界面(Column 内嵌头部栏、内容滚动区和底部导航栏),第二层是六个弹层的条件渲染。
主界面 Column 的结构为:headerBar5() 头部栏(固定不滚动)、Scroll 内容滚动区(layoutWeight(1) 占满中间空间)、tabBar5() 底部导航栏(固定不滚动)。Scroll 内部通过 if/else if/else 条件链,根据 selTab 的值渲染对应的 Tab 内容构建器。.scrollBar(BarState.Off) 隐藏滚动条,使内容区滚动更干净。
六个弹层在 Stack 的第二层级通过六个独立的 if 条件渲染——if (this.showTest)、if (this.showHerb) 等。每个弹层的显示/隐藏完全由对应的 @State boolean 变量驱动。由于这些弹层在 Stack 中,它们通过 position 绝对定位浮于主界面之上,zIndex 确保层级正确。当多个弹层同时打开时(虽然本应用的业务逻辑不会同时打开多个),后声明的弹层会覆盖在先声明的弹层之上。
技术要点:
Stack作为根容器是弹层系统的理想选择。Stack的层叠特性使得弹层可以自然地覆盖在主界面之上,无需额外的容器嵌套。Scroll+layoutWeight(1)的组合是经典的"头部固定+内容滚动+底部固定"布局模式——layoutWeight(1)让Scroll占据头部和底部之间的全部剩余空间。六个if条件渲染是独立而非互斥的(不是if/else if链),这意味着理论上可以同时渲染多个弹层。但在本应用的交互逻辑中,弹层是互斥打开的(打开一个弹层时不会同时打开另一个),这种设计既保证了灵活性(未来可以支持多弹层叠加)又避免了当前场景的冲突。
十五、应用架构流程图
15.1 整体架构与数据流
上图展示了应用从静态数据到界面渲染的完整数据流。静态常量通过 .slice() 拷贝为状态变量,状态变量驱动业务逻辑方法的计算,计算结果和状态变量共同传入 @Builder UI 构建器,最终汇聚到 build() 的 Stack 根容器中渲染输出。六个弹层通过独立的 show 布尔状态控制显示/隐藏,在 Stack 中条件渲染叠加于主界面之上。
15.2 用户交互与状态流转
上图展示了用户主要交互路径的状态流转。Tab 切换修改 selTab 触发条件渲染;体质测试通过 nextQ5 逐题流转,最终由 confirmTest5 计算结果并更新体质和医案;收藏通过 toggleFav5 重建 herbs 数组触发列表刷新;计划编辑通过 confirmEdit5 更新 plans 数组;删除通过 doDel5 过滤 cases 数组。每条路径都遵循"用户操作 → 状态变更 → UI 自动刷新"的声明式 UI 响应链路。
十六、核心概念对比表格
下表对本应用中涉及的各类数据结构、组件、状态变量、装饰器、布局属性等进行系统对比归纳:
| 类别 | 名称 | 类型/签名 | 作用说明 | 使用位置/场景 |
|---|---|---|---|---|
| 装饰器 | @Entry |
组件级 | 标记页面入口组件,应用启动后首个渲染 | struct Index |
| 装饰器 | @Component |
组件级 | 标记结构体为可复用 UI 组件 | struct Index |
| 装饰器 | @State |
状态级 | 声明可观察状态变量,变更触发 UI 刷新 | selTab、herbs、showTest 等 |
| 装饰器 | @Builder |
方法级 | 标记 UI 构建器方法,可参数化复用 | tabBtn5、headerBar5、bodyTab 等 |
| 接口 | BodyT |
interface | 定义体质数据契约(5 字段) | BODIES5 常量 |
| 接口 | HerbT |
interface | 定义药膳数据契约(6 字段含 fav) | HERBS5 常量、herbs 状态 |
| 接口 | PointT |
interface | 定义穴位数据契约(5 字段含 done) | POINTS5 常量、points 状态 |
| 接口 | PlanT |
interface | 定义调理计划契约(4 字段含 on) | PLANS5 常量、plans 状态 |
| 接口 | CaseT |
interface | 定义医案记录契约(4 字段含 doc) | CASES5 常量、cases 状态 |
| 接口 | TeaT |
interface | 定义代茶饮契约(5 字段含 season) | TEAS5 常量 |
| 容器组件 | Column |
内置 | 纵向排列子元素的容器 | 几乎所有布局 |
| 容器组件 | Row |
内置 | 横向排列子元素的容器 | 标题栏、列表项、按钮组 |
| 容器组件 | Stack |
内置 | 层叠堆放子元素的容器 | 进度环+文字、根布局+弹层 |
| 容器组件 | Scroll |
内置 | 可滚动内容容器 | Tab 内容区、时辰横滚 |
| 基础组件 | Text |
内置 | 文本显示 | 全局文字展示 |
| 基础组件 | Progress |
内置 | 进度条(环形/线性) | 穴位进度环、计划进度环、测试进度条 |
| 基础组件 | Toggle |
内置 | 开关组件 | 编辑计划提醒、删除武装确认 |
| 基础组件 | Divider |
内置 | 分割线 | 底部导航栏顶部 |
| 基础组件 | Circle |
内置 | 圆形图形 | 医案时间轴圆点 |
| 渲染控制 | ForEach |
内置 | 列表渲染,支持键值 Diff | 体质列表、药膳列表等所有列表 |
| 渲染控制 | if/else |
语法 | 条件渲染,增删 UI 节点 | Tab 切换、弹层显示、题目切换 |
| 布局属性 | layoutWeight |
通用属性 | 弹性分配剩余空间 | Tab 按钮等宽、卡片内容填充 |
| 布局属性 | position |
通用属性 | 绝对定位偏移 | 弹层遮罩与内容定位 |
| 布局属性 | zIndex |
通用属性 | 控制层叠顺序 | 弹层遮罩 999/内容 1000 |
| 布局属性 | padding |
通用属性 | 内边距 | 卡片内间距 |
| 布局属性 | margin |
通用属性 | 外边距 | 区块间距 |
| 布局属性 | borderRadius |
通用属性 | 圆角半径 | 卡片圆角、胶囊按钮 |
| 布局属性 | border |
通用属性 | 边框宽度和颜色 | 选中体质高亮边框 |
| 布局属性 | constraintSize |
通用属性 | 约束尺寸(min/max) | 时间轴最小高度、文本最大宽度 |
| 布局属性 | justifyContent |
通用属性 | 主轴对齐方式 | 步进器居中 |
| 布局属性 | alignItems |
通用属性 | 交叉轴对齐方式 | Column 左对齐 |
| 布局属性 | clip |
通用属性 | 裁剪溢出内容 | 设置列表外圆内方 |
| 样式属性 | fontSize |
通用属性 | 字体大小 | 全局文字 |
| 样式属性 | fontColor |
通用属性 | 字体颜色 | 全局文字 |
| 样式属性 | fontWeight |
通用属性 | 字体粗细 | 标题加粗 |
| 样式属性 | backgroundColor |
通用属性 | 背景颜色 | 卡片背景、按钮背景 |
| 样式属性 | opacity |
通用属性 | 不透明度 | 阴标呼吸、未选中态 |
| 样式属性 | offset |
通用属性 | 位移偏移 | 太极图标浮动动画 |
| 动画 API | animateTo |
实例方法 | 显式动画,闭包定义终态 | aboutToAppear 中启动循环动画 |
| 枚举 | PlayMode |
枚举 | 动画播放模式 | Alternate 交替循环 |
| 枚举 | Curve |
枚举 | 缓动曲线 | EaseInOut 缓入缓出 |
| 枚举 | ProgressType |
枚举 | 进度条类型 | Linear 线性、Ring 环形 |
| 枚举 | ToggleType |
枚举 | 开关类型 | Switch 滑动开关 |
| 枚举 | ScrollDirection |
枚举 | 滚动方向 | Horizontal 水平 |
| 枚举 | BarState |
枚举 | 滚动条状态 | Off 隐藏 |
| 枚举 | TextAlign |
枚举 | 文本对齐 | Center 居中 |
| 枚举 | TextOverflow |
枚举 | 文本溢出处理 | Ellipsis 省略号 |
| 枚举 | FontWeight |
枚举 | 字体粗细 | Bold 加粗、Normal 常规 |
| 枚举 | HorizontalAlign |
枚举 | 水平对齐 | Start 左对齐 |
| 枚举 | VerticalAlign |
枚举 | 垂直对齐 | Top 顶部对齐 |
| 枚举 | FlexAlign |
枚举 | 弹性对齐 | Center 居中 |
| 生命周期 | aboutToAppear |
回调 | 组件创建后首次渲染前 | 初始化动画 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:
}
}
.width('100%')
.height('100%')
.backgroundColor('#FBF7F0')
}
}
---

## 十七、总结
通过对这份中医体质辨识应用 ArkTS 代码的逐段剖析,我们可以清晰地看到鸿蒙声明式 UI 开发的完整技术图景。整个应用以 `@Entry @Component struct Index` 为根组件,通过数十个 `@State` 状态变量驱动六个 Tab 页面和六个弹层的交互逻辑,所有 UI 结构通过 `@Builder` 方法参数化复用,最终汇聚到 `build()` 方法的 `Stack` 根容器中统一渲染。这种架构使得"状态变更"成为驱动界面更新的唯一动力源——开发者只需修改状态变量值,框架自动完成 UI 的差异计算和局部刷新,无需任何手动 DOM 操作。
在数据层面,应用通过六个 `interface` 接口定义了严格的类型契约,确保药膳、穴位、计划、医案、代茶饮、体质六类业务实体在数据流转中字段完整、类型正确。七个 `const` 常量数组提供了初始数据源,通过 `.slice()` 拷贝为可变状态副本,实现了"常量只读、状态可变"的数据隔离原则。不可变更新模式贯穿所有状态变更操作——`map` 创建新数组、对象字面量创建新元素、`filter` 过滤删除项——每次状态变更都返回全新引用,确保 `@State` 监听器正确捕获变化并触发 UI 刷新。这种数据管理范式虽然相比直接修改属性稍显繁琐,但它从根本上消除了状态追踪的歧义,是声明式 UI 框架高效运作的基石。
在 UI 构建层面,`@Builder` 装饰器是实现组件复用的核心机制。`tabBtn5`、`secTitle5`、`headerBar5` 等公共构建器在多个页面中反复调用,避免了代码重复;六个 Tab 内容构建器(`bodyTab`、`herbTab`、`meridianTab`、`caseTab`、`planTab`、`mineTab`)各自封装一个功能页面的 UI 描述;六个弹层构建器通过统一的"遮罩+内容+position+zIndex"模式实现自定义弹窗。`ForEach` 列表渲染配合键值生成函数实现高效的 Diff 更新,`if/else` 条件渲染实现 Tab 切换和弹层显隐。`Column`、`Row`、`Stack` 三大容器组件配合 `layoutWeight`、`position`、`zIndex`、`borderRadius`、`clip` 等布局属性,构建出从简单列表到层叠弹窗、从横向滚动到时间轴布局的丰富界面结构。这种"容器+属性+组件"的组合方式赋予了开发者极强的布局控制力,同时也要求开发者对每个属性的作用范围和优先级有清晰认知。
在交互动画层面,`animateTo` 显式动画 API 配合 `@State` 变量实现了声明式动画效果。`aboutToAppear` 生命周期中启动了两个无限循环动画——太极图标的垂直浮动和阴标的透明度呼吸——它们分别通过 `.offset({ y: this.qiY })` 和 `.opacity(this.yinOp)` 绑定到 UI 属性。这种"状态驱动属性变化、框架自动插值过渡"的动画范式,使开发者无需关心动画帧率和插值算法,只需声明"从当前值到目标值"即可获得平滑的过渡效果。`PlayMode.Alternate` 交替模式让动画在正向和反向之间往复,模拟出"阴阳动态平衡"的中医理念,是技术与业务语义融合的典范。
在弹窗系统层面,六个弹层采用统一的架构模式但呈现三种不同的视觉形态:居中大卡(体质测试、编辑计划)、底部抽屉(药膳详情、穴位详情、代茶饮定制)、窄幅危险卡(删除确认)。这三种形态通过 `position` 的不同 `y` 值实现——居中卡片 `y` 值在 12%-27% 区间、底部抽屉 `y` 值在 42%-46% 区间、危险卡居中偏上。所有弹层都使用半透明遮罩(`#663D3125`,即 40% 不透明度的深褐色)覆盖背景,点击遮罩关闭弹层。删除弹层的"武装开关"二次确认机制通过 `Toggle` 组件和条件背景色实现,为不可逆操作提供了安全保障。这种弹层架构虽然完全基于 `position` + `zIndex` 手动实现,但它提供了比系统 `Dialog` 组件更高的自定义灵活度,是复杂交互场景下的优选方案。
从工程实践角度审视,这份代码展现了 ArkTS 在构建中大型应用时的几项核心能力。首先是类型安全——六个接口覆盖了全部业务实体,所有数组都有泛型约束,所有方法参数和返回值都有类型标注,编译器在每一步都提供类型保护。其次是状态管理——数十个 `@State` 变量按功能分组(Tab 状态、测试状态、编辑状态、弹窗状态、动画状态),每个弹窗拥有独立的状态组,互不干扰。再者是渲染控制——`ForEach` 的键值生成函数精心设计,将状态字段纳入键值(如 `h.name + h.fav.toString()`)确保状态变化时列表项正确刷新。最后是布局表达——从 `Column` 的 `space` 间距到 `Row` 的 `layoutWeight` 权重,从 `Stack` 的层叠到 `Scroll` 的滚动方向,每种布局属性都在恰当的场景发挥作用。这些能力共同构成了鸿蒙原生应用开发的技术底座。
综观全文,这份代码以中医体质辨识为业务载体,完整呈现了 ArkTS 声明式 UI 开发的方法论。从 `interface` 类型定义到 `const` 常量数据,从 `@State` 状态管理到 `@Builder` UI 复用,从 `animateTo` 动画到 `ForEach` 列表渲染,从 `Stack` 层叠布局到 `position`/`zIndex` 弹层系统,每一个技术点都在实际业务场景中得到了落地应用。九种体质横条图用 `Row` 拼接模拟进度条,十二时辰经络流注用 `Scroll` 横向滚动展示,医案时间轴用 `Circle` + 条件渲染竖线构建,删除确认用 `Toggle` 武装开关双重保障——这些实现细节充分说明,掌握 ArkTS 的关键不在于记忆多少 API,而在于理解"状态驱动 UI"的核心思维模式,并能将容器组件、布局属性、渲染控制、状态管理灵活组合,解决具体的业务交互需求。这正是鸿蒙原生开发的精髓所在。
更多推荐




所有评论(0)