ArkTS 自定义组件封装实战:动画水杯组件
前言
在 HarmonyOS 应用开发中,自定义组件是提高代码复用性和可维护性的重要手段。本文将通过一个实际案例------动画水杯组件,带你深入理解 ArkTS 声明式 UI 中的组件封装技术。
本文适合已经了解 ArkTS 基础语法的初学者阅读。通过学习本文,你将掌握:
- 如何使用
@Component装饰器创建自定义组件 @Prop装饰器的作用与使用场景- 如何实现组件的动画效果
- 组件样式设计与布局技巧
- 组件的复用与组合
- 声明式 UI 的响应式更新机制
- 组件性能优化的基本思路
什么是自定义组件
在开始编写代码之前,我们需要理解自定义组件的本质。在 ArkTS 中,组件是构建用户界面的基本单元。系统提供了许多内置组件,如 Text、Button、Column 等,但这些基础组件往往无法直接满足复杂的业务需求。
自定义组件允许我们将多个基础组件组合在一起,封装成一个具有特定功能的新组件。这样做的好处包括:
- 代码复用:同样的 UI 结构可以在多个页面中使用,避免重复编写
- 逻辑封装:将相关的状态和行为封装在一起,降低代码耦合度
- 易于维护:修改组件实现时,所有使用该组件的地方都会自动更新
- 提高可读性:通过组件名称就能理解其功能,代码更加语义化
例如,如果我们在多个页面都需要显示饮水进度,与其在每个页面重复编写相同的代码,不如封装成一个 WaterCup 组件,然后在需要的地方直接使用。
案例背景
我们要实现一个饮水管理应用中的水杯组件,它需要具备以下功能:
- 根据当前饮水量和目标量,动态显示水位高度
- 水位颜色随完成度变化(未达标时浅色,达标后深色)
- 显示当前饮水量和目标量的数值
- 水位变化时有平滑的动画效果
最终效果如下图所示:
无水:

有水:

完整代码
首先,让我们看一下完整的组件代码:
typescript
/**
* 饮水杯动画组件
*/
@Component
export struct WaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
cupWidth: number = 100;
cupHeight: number = 140;
private getWaterHeight(): number {
const ratio = Math.min(this.current / this.target, 1);
return ratio * (this.cupHeight - 20); // 留出杯口空间
}
private getWaterColor(): string {
const ratio = this.current / this.target;
if (ratio >= 1) return '#2196F3';
if (ratio >= 0.7) return '#42A5F5';
if (ratio >= 0.4) return '#64B5F6';
return '#90CAF9';
}
build() {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
// 杯子外框
Column()
.width(this.cupWidth)
.height(this.cupHeight)
.borderRadius({ bottomLeft: 16, bottomRight: 16 })
.border({
width: 3,
color: $r('app.color.water_cup_border')
})
.backgroundColor($r('app.color.water_cup_background'))
// 水
Column()
.width(this.cupWidth - 6)
.height(this.getWaterHeight())
.borderRadius({ bottomLeft: 13, bottomRight: 13 })
.backgroundColor(this.getWaterColor())
.animation({
duration: 300,
curve: Curve.EaseInOut
})
}
// 显示数值
Text(`${this.current}ml`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.water_blue'))
.margin({ top: 12 } as Padding)
Text(`目标: ${this.target}ml`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 4 } as Padding)
}
.alignItems(HorizontalAlign.Center)
}
}
逐步解析
1. 组件声明与导出
typescript
@Component
export struct WaterCup {
// 组件内容
}
知识点详解:
@Component 装饰器:
这是 ArkTS 中最重要的装饰器之一。当你在 struct 前面加上 @Component,就告诉编译器这是一个可复用的 UI 组件。编译器会为这个组件生成必要的代码,使其能够参与到 UI 的构建和更新流程中。
没有 @Component 装饰器的 struct 只是一个普通的数据结构,无法作为组件使用。
export 关键字:
export 使得这个组件可以被其他文件导入。如果不加 export,组件只能在当前文件内使用。
导入方式:
typescript
import { WaterCup } from '../components/WaterCup';
struct 关键字:
ArkTS 使用 struct(结构体)来定义组件,这与 TypeScript 中的 class 不同。struct 是值类型,在性能上比引用类型的 class 更高效。这是 ArkTS 针对 UI 渲染性能做的优化设计。
命名规范:
组件名称必须以大写字母开头,这是 ArkTS 的强制要求。推荐使用 PascalCase(帕斯卡命名法),如 WaterCup、UserProfile、NavigationBar 等。
2. 组件属性定义
typescript
@Prop current: number = 0;
@Prop target: number = 2000;
cupWidth: number = 100;
cupHeight: number = 140;
知识点详解:
@Prop 装饰器的作用:
@Prop 是用于父子组件通信的关键装饰器。它的工作机制如下:
- 单向数据流 :数据只能从父组件流向子组件,子组件不能直接修改
@Prop属性的值 - 响应式更新:当父组件中的数据变化时,会自动触发子组件重新渲染
- 必须有默认值:这是为了保证组件在没有传入参数时也能正常工作
为什么需要默认值?
考虑以下场景:
typescript
// 场景1:传入完整参数
WaterCup({ current: 1500, target: 2000 })
// 场景2:只传入部分参数
WaterCup({ current: 1500 }) // target 使用默认值 2000
// 场景3:不传入任何参数
WaterCup() // current 和 target 都使用默认值
默认值保证了组件的健壮性,即使父组件忘记传参,组件也能正常显示。
@Prop 的类型支持:
@Prop 支持多种数据类型:
- 基本类型:
number、string、boolean - 枚举类型:
enum - 对象类型:自定义的
interface或class
普通属性(无装饰器):
cupWidth 和 cupHeight 没有使用任何装饰器,它们是组件的内部配置。这些属性:
- 不参与响应式更新
- 不能从父组件传入
- 在组件生命周期内保持不变
何时使用普通属性?
当某个值满足以下条件时,使用普通属性:
- 不需要从外部传入
- 在组件内部是固定的
- 不会随时间变化
例如,水杯的宽高在这个组件中是固定的,所以使用普通属性。如果未来需要支持不同尺寸的水杯,可以将它们改为 @Prop。
使用示例:
typescript
// 在父组件中使用
@Entry
@Component
struct MyPage {
@State waterAmount: number = 1500;
build() {
Column() {
// 传入响应式数据
WaterCup({
current: this.waterAmount, // 绑定到 @State 变量
target: 2000
})
Button('喝水')
.onClick(() => {
this.waterAmount += 200; // 修改数据
// WaterCup 会自动更新显示
})
}
}
}
数据流向:
父组件 @State waterAmount
↓ (单向数据流)
子组件 @Prop current
↓ (触发重新渲染)
UI 更新(水位上升)
3. 计算水位高度
typescript
private getWaterHeight(): number {
const ratio = Math.min(this.current / this.target, 1);
return ratio * (this.cupHeight - 20); // 留出杯口空间
}
实现逻辑详解:
第一步:计算完成比例
typescript
const ratio = Math.min(this.current / this.target, 1);
这行代码包含两个关键操作:
-
除法运算 :
this.current / this.target计算当前饮水量占目标量的比例- 如果 current = 1000,target = 2000,则 ratio = 0.5(50%)
- 如果 current = 2000,target = 2000,则 ratio = 1.0(100%)
- 如果 current = 3000,target = 2000,则 ratio = 1.5(150%)
-
限制最大值 :
Math.min(ratio, 1)确保比例不超过 1- 这是为了防止用户喝水超标时,水溢出杯子
- 即使 current 大于 target,水位也只会显示到杯口
为什么要限制最大值?
从视觉角度考虑,水杯是一个有限的容器,水不可能溢出杯子。如果不限制,当用户喝水超过目标量时,水位高度会超过杯子高度,导致显示异常。
第二步:计算实际像素高度
typescript
return ratio * (this.cupHeight - 20);
这里有两个细节:
-
减去 20 像素:为杯口预留空间
- 杯子总高度是 140 像素
- 可用于显示水的高度是 120 像素(140 - 20)
- 这样即使水位达到 100%,也不会完全填满杯子,保持视觉美观
-
比例乘以可用高度:将 0-1 的比例转换为实际像素值
- ratio = 0.5 时,height = 0.5 × 120 = 60 像素
- ratio = 1.0 时,height = 1.0 × 120 = 120 像素
完整计算示例:
假设 cupHeight = 140,可用高度 = 120
场景1:刚开始喝水
- current = 0, target = 2000
- ratio = 0 / 2000 = 0
- height = 0 × 120 = 0 像素
- 显示:空杯子
场景2:喝了一半
- current = 1000, target = 2000
- ratio = 1000 / 2000 = 0.5
- height = 0.5 × 120 = 60 像素
- 显示:水位在杯子中间
场景3:达到目标
- current = 2000, target = 2000
- ratio = 2000 / 2000 = 1.0
- height = 1.0 × 120 = 120 像素
- 显示:水位接近杯口(留 20 像素空间)
场景4:超过目标
- current = 3000, target = 2000
- ratio = Math.min(3000 / 2000, 1) = Math.min(1.5, 1) = 1.0
- height = 1.0 × 120 = 120 像素
- 显示:水位仍然在杯口(不会溢出)
方法设计的优点:
- 封装性好:计算逻辑集中在一个方法中,便于理解和维护
- 可复用 :这个方法在
build()中被调用,每次组件更新时自动重新计算 - 易于测试:可以单独测试这个方法的计算逻辑是否正确
- 易于扩展:如果未来需要修改计算规则,只需修改这一个方法
性能考虑:
这个方法会在每次组件渲染时被调用。由于计算非常简单(只有一次除法和一次乘法),性能开销可以忽略不计。如果计算逻辑很复杂,可以考虑使用缓存优化。
4. 动态水位颜色
typescript
private getWaterColor(): string {
const ratio = this.current / this.target;
if (ratio >= 1) return '#2196F3'; // 深蓝(已达标)
if (ratio >= 0.7) return '#42A5F5'; // 中蓝(接近目标)
if (ratio >= 0.4) return '#64B5F6'; // 浅蓝(进行中)
return '#90CAF9'; // 很浅蓝(刚开始)
}
设计思路详解:
颜色选择的心理学依据:
颜色不仅仅是装饰,它能传递信息和情感。在这个组件中,我们使用蓝色系来表示水,这是因为:
- 符合认知:蓝色是水的自然颜色,用户一眼就能理解
- 情感关联:蓝色给人清爽、健康的感觉,符合饮水管理的主题
- 视觉层次:通过深浅变化,用户可以快速判断完成度
阶梯式颜色设计:
我们将完成度分为四个档次,每个档次对应一个颜色:
100% 及以上 → #2196F3 深蓝色 "太棒了,目标达成!"
70% - 99% → #42A5F5 中蓝色 "快要达标了,加油!"
40% - 69% → #64B5F6 浅蓝色 "进展不错,继续保持"
0% - 39% → #90CAF9 很浅蓝 "刚开始,还需努力"
为什么使用阶梯式而不是渐变?
- 性能考虑:阶梯式只需要 4 个颜色值,渐变需要计算大量中间色
- 视觉清晰:明确的颜色变化比细微的渐变更容易被注意到
- 心理激励:当颜色从浅变深时,用户会有明显的成就感
颜色值的选择:
这些颜色值来自 Material Design 的蓝色色板:
#2196F3:Blue 500(标准蓝)#42A5F5:Blue 400(稍浅)#64B5F6:Blue 300(更浅)#90CAF9:Blue 200(最浅)
使用标准色板的好处:
- 颜色经过专业设计,视觉和谐
- 符合现代 UI 设计规范
- 在不同设备上显示效果一致
判断逻辑的顺序:
typescript
if (ratio >= 1) return '#2196F3'; // 先判断最高档
if (ratio >= 0.7) return '#42A5F5'; // 再判断次高档
if (ratio >= 0.4) return '#64B5F6'; // 然后判断中档
return '#90CAF9'; // 最后返回最低档
这个顺序很重要。如果颠倒顺序,逻辑会出错:
typescript
// ❌ 错误示例
if (ratio >= 0.4) return '#64B5F6'; // 当 ratio = 1 时,这里就返回了
if (ratio >= 0.7) return '#42A5F5'; // 永远不会执行到这里
if (ratio >= 1) return '#2196F3'; // 永远不会执行到这里
扩展思考:
如果你想实现更细腻的颜色变化,可以使用线性插值:
typescript
private getWaterColorGradient(): string {
const ratio = Math.min(this.current / this.target, 1);
// 从浅蓝 (144, 202, 249) 到深蓝 (33, 150, 243)
const r = Math.round(144 - (144 - 33) * ratio);
const g = Math.round(202 - (202 - 150) * ratio);
const b = Math.round(249 - (249 - 243) * ratio);
return `rgb(${r}, ${g}, ${b})`;
}
但这会增加计算复杂度,对于大多数场景,阶梯式颜色已经足够。
颜色与动画的配合:
注意,颜色变化和高度变化是同步的。当用户添加饮水记录时:
this.current增加getWaterHeight()返回新高度getWaterColor()可能返回新颜色- 高度和颜色同时产生动画效果
这种同步变化增强了视觉反馈的连贯性。
5. 布局结构
组件的布局采用三层结构:
Column(垂直布局)
├── Stack(堆叠布局)
│ ├── Column(杯子外框)
│ └── Column(水)
├── Text(当前饮水量)
└── Text(目标量)
布局设计的思路:
第一层:Column 垂直布局
最外层使用 Column 将水杯和文字垂直排列。这是最常见的布局方式,适合从上到下展示内容。
typescript
Column() {
// 水杯部分
Stack() { ... }
// 文字部分
Text() { ... }
Text() { ... }
}
.alignItems(HorizontalAlign.Center) // 子元素水平居中
第二层:Stack 堆叠布局
Stack 是实现水杯效果的关键。它允许多个组件重叠显示,就像一摞纸叠在一起。
typescript
Stack({ alignContent: Alignment.Bottom }) {
// 底层:杯子外框
Column() { ... }
// 顶层:水
Column() { ... }
}
为什么使用 Stack?
如果不用 Stack,而是用 Column 或 Row,水和杯子会并排或上下排列,无法重叠。只有 Stack 能让水"填充"在杯子里面。
alignContent: Alignment.Bottom 的作用:
这个参数让 Stack 中的所有子组件从底部对齐。如果不设置对齐,将会默认居中,水会在中间,设置了底部对齐以后,水就会在底部。
底部对齐符合物理直觉:水总是从容器底部开始填充。
第三层:杯子和水的实现
杯子外框:
typescript
Column()
.width(this.cupWidth) // 宽度 100
.height(this.cupHeight) // 高度 140
.borderRadius({
bottomLeft: 16,
bottomRight: 16
}) // 只设置底部圆角
.border({
width: 3,
color: $r('app.color.water_cup_border')
}) // 3像素边框
.backgroundColor($r('app.color.water_cup_background'))
为什么只设置底部圆角?
真实的杯子通常是上宽下窄,底部有圆角。只设置底部圆角能更好地模拟杯子的形状。如果四个角都设置圆角,看起来更像一个胶囊,而不是杯子。
边框的作用:
3 像素的边框形成杯子的轮廓。如果没有边框,杯子和背景会融为一体,视觉效果不清晰。
水的实现:
typescript
Column()
.width(this.cupWidth - 6) // 比杯子窄 6 像素
.height(this.getWaterHeight()) // 动态高度
.borderRadius({
bottomLeft: 13,
bottomRight: 13
}) // 圆角比杯子小
.backgroundColor(this.getWaterColor()) // 动态颜色
.animation({
duration: 300,
curve: Curve.EaseInOut
})
为什么水的宽度要减 6 像素?
杯子的边框宽度是 3 像素,左右各 3 像素,总共 6 像素。如果水的宽度和杯子一样,水会覆盖边框,看起来像是溢出了。减去 6 像素后,水正好在边框内部。
杯子宽度 100:
├─ 3px 边框
├─ 94px 内部空间(水的宽度)
└─ 3px 边框
为什么水的圆角是 13 而不是 16?
杯子的圆角是 16,但这是外圆角。水在杯子内部,需要使用内圆角。内圆角 = 外圆角 - 边框宽度 = 16 - 3 = 13。
这样水的边缘才能完美贴合杯子的内壁。
视觉层次:
Z轴方向(从下到上):
1. 背景色(杯子内部的浅色)
2. 杯子边框(3px 的线条)
3. 水(蓝色,高度动态变化)
这种层次设计让水杯看起来有深度感。
响应式考虑:
虽然当前的宽高是固定的,但如果需要支持不同尺寸,可以这样改造:
typescript
@Prop cupWidth: number = 100;
@Prop cupHeight: number = 140;
// 使用时
WaterCup({
current: 1500,
target: 2000,
cupWidth: 120, // 自定义宽度
cupHeight: 160 // 自定义高度
})
所有相关的计算(如水的宽度、圆角大小)都会自动适配。
6. 动画效果
typescript
.animation({
duration: 300,
curve: Curve.EaseInOut
})
动画参数详解:
duration(持续时间):
duration: 300 表示动画持续 300 毫秒(0.3 秒)。这个时长的选择有讲究:
- 太短(< 200ms):动画过快,用户可能注意不到变化
- 太长(> 500ms):动画拖沓,影响操作流畅性
- 300ms:恰到好处,既能让用户感知到变化,又不会觉得慢
这个时长符合 Material Design 的动画规范,是经过大量用户测试验证的最佳值。
curve(缓动曲线):
Curve.EaseInOut 是一种缓动函数,它控制动画的速度变化:
速度
↑
│ ╱‾‾‾╲ ← EaseInOut:两端慢,中间快
│ ╱ ╲
│ ╱ ╲
│ ╱ ╲
└─────────────→ 时间
开始 结束
ArkTS 提供了多种缓动曲线:
-
Curve.Linear:匀速运动,速度恒定
- 适用场景:进度条、加载动画
- 缺点:看起来机械,不够自然
-
Curve.EaseIn:慢速开始,逐渐加速
- 适用场景:元素消失、收起动画
- 特点:给人一种"被吸走"的感觉
-
Curve.EaseOut:快速开始,逐渐减速
- 适用场景:元素出现、展开动画
- 特点:给人一种"弹出"的感觉
-
Curve.EaseInOut:慢速开始和结束,中间快速
- 适用场景:大多数过渡动画
- 特点:最自然,符合物理直觉
我们选择 EaseInOut 是因为水位上升是一个连续的过程,需要平滑自然的过渡。
触发机制详解:
动画的触发是自动的,不需要手动调用任何方法。工作流程如下:
1. 用户点击"喝水"按钮
↓
2. 父组件修改 @State waterAmount
↓
3. ArkTS 检测到状态变化
↓
4. 触发 WaterCup 组件重新渲染
↓
5. getWaterHeight() 返回新的高度值
↓
6. .height() 属性从旧值变为新值
↓
7. 由于设置了 .animation(),产生平滑过渡
↓
8. 用户看到水位平滑上升
关键点:动画作用于哪些属性?
.animation() 会对该组件的所有可动画属性生效,包括:
- 尺寸:
width、height - 位置:
position、offset - 透明度:
opacity - 颜色:
backgroundColor、fontColor - 变换:
rotate、scale、translate
在我们的水杯组件中,.animation() 设置在水的 Column 上,所以:
height变化会产生动画(水位上升/下降)backgroundColor变化也会产生动画(颜色渐变)
性能优化:
ArkTS 的动画是硬件加速的,运行在 GPU 上,不会阻塞主线程。但仍需注意:
- 避免同时动画过多元素:如果页面上有 100 个水杯同时动画,可能会卡顿
- 避免在动画中修改布局:动画应该只改变视觉属性,不要触发重新布局
- 使用合适的动画时长:过长的动画会占用更多资源
调试技巧:
如果动画不生效,检查以下几点:
.animation()是否设置在正确的组件上- 变化的属性是否支持动画
- 父组件传入的数据是否真的变化了
- 是否使用了
@State或@Prop装饰器
扩展:自定义动画曲线
如果内置的缓动曲线不满足需求,可以使用贝塞尔曲线自定义:
typescript
.animation({
duration: 300,
curve: Curve.cubicBezier(0.25, 0.1, 0.25, 1) // 自定义曲线
})
贝塞尔曲线的四个参数控制曲线形状,可以在 cubic-bezier.com 上可视化调试。
7. 文本显示
typescript
Text(`${this.current}ml`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.water_blue'))
.margin({ top: 12 } as Padding)
Text(`目标: ${this.target}ml`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 4 } as Padding)
知识点:
- 使用模板字符串
${变量}动态显示数值 - 链式调用设置样式属性
$r('app.color.xxx')引用资源文件中的颜色
组件使用示例
基础使用
typescript
@Entry
@Component
struct WaterPage {
@State currentWater: number = 1500;
@State targetWater: number = 2000;
build() {
Column() {
WaterCup({
current: this.currentWater,
target: this.targetWater
})
Button('喝水 +200ml')
.onClick(() => {
this.currentWater += 200;
})
}
}
}
代码解析:
@State 装饰器:
typescript
@State currentWater: number = 1500;
@State 是状态管理的核心装饰器。它的作用是:
- 标记这个变量是响应式的
- 当变量值改变时,自动触发 UI 更新
- 可以传递给子组件的
@Prop属性
数据绑定:
typescript
WaterCup({
current: this.currentWater, // 父组件的 @State
target: this.targetWater // 绑定到子组件的 @Prop
})
这里建立了父子组件之间的数据连接。当 this.currentWater 变化时,WaterCup 组件会自动接收到新值并重新渲染。
事件处理:
typescript
Button('喝水 +200ml')
.onClick(() => {
this.currentWater += 200; // 修改状态
})
点击按钮时,currentWater 增加 200。由于它是 @State 变量,这个改变会触发:
WaterPage组件重新渲染WaterCup组件接收到新的current值WaterCup重新计算水位高度和颜色- 产生平滑的动画效果
运行效果:
初始状态:

点击按钮后:

水位会平滑上升。
多个水杯实例
typescript
Row() {
WaterCup({ current: 800, target: 2000 })
WaterCup({ current: 1500, target: 2000 })
WaterCup({ current: 2000, target: 2000 })
}
.justifyContent(FlexAlign.SpaceAround)
每个水杯是独立的实例,拥有自己的状态和显示效果。这展示了组件的复用性:同一个组件可以在页面上多次使用,互不干扰。
实际应用场景:
- 对比展示:显示今天、昨天、前天的饮水量
- 多人管理:家庭成员各自的饮水记录
- 目标对比:不同目标量下的完成情况
完整的饮水管理页面
typescript
@Entry
@Component
struct WaterManagementPage {
@State currentWater: number = 0;
@State targetWater: number = 2000;
@State records: WaterRecord[] = [];
addWater(amount: number): void {
this.currentWater += amount;
this.records.push({
id: Date.now(),
amount: amount,
time: this.getCurrentTime()
});
}
getCurrentTime(): string {
const now = new Date();
return `${now.getHours()}:${now.getMinutes()}`;
}
build() {
Column() {
// 标题
Text('今日饮水')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 20, bottom: 20 })
// 水杯展示
WaterCup({
current: this.currentWater,
target: this.targetWater
})
.margin({ top: 20, bottom: 20 })
// 快捷按钮
Row() {
QuickWaterButton({
amount: 100,
onTap: () => this.addWater(100)
})
QuickWaterButton({
amount: 200,
onTap: () => this.addWater(200)
})
QuickWaterButton({
amount: 500,
onTap: () => this.addWater(500)
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.margin({ top: 20, bottom: 20 })
// 今日记录
Text('今日记录')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.alignSelf(ItemAlign.Start)
.margin({ left: 20, top: 20, bottom: 10 })
List() {
ForEach(this.records, (record: WaterRecord) => {
ListItem() {
Row() {
Text(`${record.time}`)
.fontSize(14)
.fontColor('#666666')
Text(`+${record.amount}ml`)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#2196F3')
}
.width('90%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(15)
.backgroundColor('#F5F5F5')
.borderRadius(8)
.margin({ bottom: 10 })
}
})
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.padding(20)
}
}
interface WaterRecord {
id: number;
amount: number;
time: string;
}
功能说明:
-
状态管理:
currentWater:当前饮水总量targetWater:目标饮水量records:饮水记录数组
-
添加记录:
- 点击快捷按钮调用
addWater() - 更新总量并添加记录
- 记录包含时间戳和饮水量
- 点击快捷按钮调用
-
记录展示:
- 使用
List和ForEach显示所有记录 - 每条记录显示时间和饮水量
- 最新的记录在最上面
- 使用
用户体验优化:
- 即时反馈:点击按钮后,水位立即上升
- 视觉引导:颜色变化提示完成度
- 历史追溯:记录列表方便查看饮水习惯
- 操作便捷:快捷按钮减少输入步骤
每个水杯独立显示不同的数据,互不影响。
扩展:快捷按钮组件
在实际应用中,我们还需要快捷添加饮水量的按钮。下面是配套的按钮组件:
typescript
@Component
export struct QuickWaterButton {
amount: number = 200;
onTap: () => void = () => {};
build() {
Column() {
Text('💧')
.fontSize(24)
Text(`${this.amount}ml`)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.water_blue'))
.margin({ top: 4 } as Padding)
}
.width(70)
.height(70)
.justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.water_surface'))
.borderRadius(12)
.onClick(() => {
this.onTap();
})
}
}
使用示例:
typescript
Row() {
QuickWaterButton({
amount: 100,
onTap: () => { this.currentWater += 100; }
})
QuickWaterButton({
amount: 200,
onTap: () => { this.currentWater += 200; }
})
QuickWaterButton({
amount: 500,
onTap: () => { this.currentWater += 500; }
})
}
实现效果类似:

组件封装的最佳实践
1. 单一职责原则
每个组件只负责一个功能:
WaterCup只负责显示水杯状态QuickWaterButton只负责快捷操作- 数据管理由父组件负责
为什么要遵循单一职责?
假设我们把所有功能都塞进一个组件:
typescript
// ❌ 不好的设计:一个组件做太多事情
@Component
struct WaterManagement {
@State currentWater: number = 0;
@State records: WaterRecord[] = [];
build() {
Column() {
// 显示水杯
// 快捷按钮
// 记录列表
// 统计图表
// 设置面板
// ... 几百行代码
}
}
}
这样做的问题:
- 代码难以阅读和维护
- 无法复用其中的某个部分
- 修改一个功能可能影响其他功能
- 测试困难
正确的做法:拆分成多个小组件
typescript
// ✅ 好的设计:每个组件职责清晰
@Component
struct WaterManagementPage {
@State currentWater: number = 0;
@State records: WaterRecord[] = [];
build() {
Column() {
WaterCup({ current: this.currentWater, target: 2000 })
QuickButtonGroup({ onAdd: (amount) => this.addWater(amount) })
RecordList({ records: this.records })
StatisticsChart({ data: this.getWeeklyData() })
}
}
}
这样每个组件都可以独立开发、测试和复用。
2. 属性设计
使用 @Prop 的场景:
- 需要从父组件传入的数据
- 数据变化时需要更新 UI
使用普通属性的场景:
- 组件内部的常量配置
- 不需要响应式更新的数据
属性命名规范:
typescript
// ✅ 好的命名:清晰、语义化
@Prop current: number = 0; // 当前值
@Prop target: number = 2000; // 目标值
@Prop maxValue: number = 5000; // 最大值
@Prop showLabel: boolean = true; // 是否显示标签
// ❌ 不好的命名:含糊不清
@Prop val: number = 0; // val 是什么?
@Prop num: number = 2000; // num 代表什么?
@Prop flag: boolean = true; // flag 控制什么?
属性的默认值设计:
默认值应该是最常用的值,让大多数场景下不需要传参:
typescript
@Prop cupWidth: number = 100; // 默认宽度适合大多数场景
@Prop cupHeight: number = 140; // 默认高度比例协调
@Prop animationDuration: number = 300; // 默认动画时长适中
可选属性 vs 必需属性:
在 ArkTS 中,所有 @Prop 都必须有默认值,所以都是可选的。但在设计时要考虑:
typescript
// 核心属性:虽然有默认值,但通常需要传入
@Prop current: number = 0; // 几乎总是需要传入实际值
@Prop target: number = 2000; // 可能使用默认值
// 配置属性:大多数情况使用默认值
@Prop cupWidth: number = 100; // 很少需要修改
@Prop showPercentage: boolean = true; // 很少需要修改
3. 方法封装
将复杂的计算逻辑封装成私有方法:
getWaterHeight():计算水位高度getWaterColor():计算水位颜色
好处:
- 代码更清晰
- 便于测试和维护
- 可以在多处复用
方法的访问控制:
typescript
// private:只在组件内部使用
private getWaterHeight(): number { ... }
private getWaterColor(): string { ... }
// public:可以被外部调用(很少使用)
public reset(): void {
this.current = 0;
}
大多数情况下,组件的方法都应该是 private,因为组件应该通过属性来控制,而不是通过方法调用。
方法的命名规范:
typescript
// ✅ 好的命名:动词开头,表达意图
private getWaterHeight(): number { ... } // 获取高度
private calculateRatio(): number { ... } // 计算比例
private formatDisplayText(): string { ... } // 格式化文本
// ❌ 不好的命名:名词或含糊不清
private height(): number { ... } // 不清楚是获取还是设置
private water(): string { ... } // 不知道返回什么
private process(): void { ... } // 处理什么?
方法的职责划分:
每个方法应该只做一件事:
typescript
// ✅ 好的设计:职责单一
private getWaterHeight(): number {
const ratio = this.calculateRatio();
return ratio * (this.cupHeight - 20);
}
private calculateRatio(): number {
return Math.min(this.current / this.target, 1);
}
// ❌ 不好的设计:一个方法做太多事
private getWaterHeightAndColorAndText(): any {
const ratio = Math.min(this.current / this.target, 1);
const height = ratio * (this.cupHeight - 20);
const color = ratio >= 1 ? '#2196F3' : '#90CAF9';
const text = `${this.current}ml / ${this.target}ml`;
return { height, color, text };
}
4. 样式管理
推荐做法:
typescript
.backgroundColor($r('app.color.water_blue')) // 使用资源引用
不推荐:
typescript
.backgroundColor('#2196F3') // 硬编码颜色
使用资源文件的好处:
- 统一管理主题色
- 支持深色模式切换
- 便于批量修改
资源文件的组织:
在 resources/base/element/color.json 中定义颜色:
json
{
"color": [
{
"name": "water_blue",
"value": "#2196F3"
},
{
"name": "water_blue_light",
"value": "#90CAF9"
},
{
"name": "water_cup_border",
"value": "#BDBDBD"
},
{
"name": "water_cup_background",
"value": "#F5F5F5"
}
]
}
深色模式支持:
在 resources/dark/element/color.json 中定义深色版本:
json
{
"color": [
{
"name": "water_blue",
"value": "#64B5F6"
},
{
"name": "water_cup_border",
"value": "#424242"
},
{
"name": "water_cup_background",
"value": "#1E1E1E"
}
]
}
系统会根据当前主题自动选择对应的颜色值。
样式的复用:
如果多个组件使用相同的样式,可以定义样式函数:
typescript
// 在单独的文件中定义
export function cardStyle() {
return {
backgroundColor: $r('app.color.card_background'),
borderRadius: 12,
padding: 16,
shadow: {
radius: 8,
color: $r('app.color.shadow_color'),
offsetY: 2
}
};
}
// 在组件中使用
Column()
.backgroundColor(cardStyle().backgroundColor)
.borderRadius(cardStyle().borderRadius)
// ...
但要注意,ArkTS 不支持直接展开对象,需要逐个设置属性。
5. 性能优化
避免不必要的计算:
typescript
// ❌ 不好:每次渲染都创建新对象
build() {
Column()
.margin({ top: 12, bottom: 12, left: 16, right: 16 }) // 每次都创建新对象
}
// ✅ 好:使用常量
private readonly MARGIN = { top: 12, bottom: 12, left: 16, right: 16 };
build() {
Column()
.margin(this.MARGIN) // 复用同一个对象
}
避免在 build() 中进行复杂计算:
typescript
// ❌ 不好:在 build() 中计算
build() {
Column() {
Text(`${this.calculateComplexValue()}`) // 每次渲染都计算
}
}
// ✅ 好:使用计算属性或缓存
@State cachedValue: number = 0;
aboutToAppear() {
this.cachedValue = this.calculateComplexValue();
}
build() {
Column() {
Text(`${this.cachedValue}`) // 使用缓存值
}
}
合理使用动画:
动画虽然提升用户体验,但也消耗性能。对于不重要的动画,可以考虑禁用:
typescript
@Prop enableAnimation: boolean = true; // 允许外部控制是否启用动画
build() {
Column()
.height(this.getWaterHeight())
.animation(this.enableAnimation ? {
duration: 300,
curve: Curve.EaseInOut
} : undefined) // 根据配置决定是否启用动画
}
6. 组件的可测试性
设计易于测试的组件:
typescript
// ✅ 好的设计:计算逻辑独立,易于测试
private calculateRatio(): number {
return Math.min(this.current / this.target, 1);
}
// 可以单独测试这个方法
// 输入:current = 1000, target = 2000
// 期望输出:0.5
避免在组件中直接访问全局状态:
typescript
// ❌ 不好:依赖全局状态,难以测试
build() {
Text(`${globalStore.waterAmount}ml`) // 依赖全局变量
}
// ✅ 好:通过属性传入,易于测试
@Prop current: number = 0;
build() {
Text(`${this.current}ml`) // 只依赖属性
}
7. 文档和注释
为组件编写清晰的注释:
typescript
/**
* 饮水杯动画组件
*
* 功能:
* - 根据当前饮水量和目标量显示水位
* - 水位高度和颜色动态变化
* - 支持平滑动画效果
*
* 使用示例:
* ```
* WaterCup({ current: 1500, target: 2000 })
* ```
*
* @param current 当前饮水量(毫升)
* @param target 目标饮水量(毫升)
*/
@Component
export struct WaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
// ...
}
好的文档能帮助其他开发者(包括未来的你)快速理解组件的用途和用法。
常见问题与解决方案
Q1: 为什么水位不更新?
症状:
点击按钮后,水位没有变化,或者变化后没有动画效果。
可能的原因和解决方案:
原因1:父组件没有使用 @State
typescript
// ❌ 错误示例
struct WaterPage {
currentWater: number = 1500; // 没有 @State
build() {
Column() {
WaterCup({ current: this.currentWater })
Button('喝水')
.onClick(() => {
this.currentWater += 200; // 修改了,但不会触发更新
})
}
}
}
解决方案:
typescript
// ✅ 正确示例
struct WaterPage {
@State currentWater: number = 1500; // 添加 @State
build() {
Column() {
WaterCup({ current: this.currentWater })
Button('喝水')
.onClick(() => {
this.currentWater += 200; // 现在会触发更新
})
}
}
}
原因2:子组件没有使用 @Prop
typescript
// ❌ 错误示例
@Component
export struct WaterCup {
current: number = 0; // 没有 @Prop
// ...
}
解决方案:
typescript
// ✅ 正确示例
@Component
export struct WaterCup {
@Prop current: number = 0; // 添加 @Prop
// ...
}
原因3:直接修改了 @Prop 属性
typescript
// ❌ 错误示例
@Component
export struct WaterCup {
@Prop current: number = 0;
addWater() {
this.current += 200; // 不允许!@Prop 是只读的
}
}
@Prop 属性是单向数据流,子组件不能直接修改。如果需要修改,应该通过回调函数通知父组件:
typescript
// ✅ 正确示例
@Component
export struct WaterCup {
@Prop current: number = 0;
onAdd: (amount: number) => void = () => {};
build() {
Column() {
// 显示水杯
Button('喝水')
.onClick(() => {
this.onAdd(200); // 通知父组件
})
}
}
}
// 父组件
struct WaterPage {
@State currentWater: number = 0;
build() {
WaterCup({
current: this.currentWater,
onAdd: (amount) => {
this.currentWater += amount; // 在父组件中修改
}
})
}
}
Q2: 动画不流畅怎么办?
症状:
水位上升时有卡顿,或者动画不连贯。
可能的原因和解决方案:
原因1:动画设置在错误的组件上
typescript
// ❌ 错误示例
Stack() {
Column() // 杯子
Column() // 水
}
.animation({ // 动画设置在 Stack 上,不会生效
duration: 300,
curve: Curve.EaseInOut
})
解决方案:
typescript
// ✅ 正确示例
Stack() {
Column() // 杯子
Column() // 水
.height(this.getWaterHeight())
.animation({ // 动画设置在水的 Column 上
duration: 300,
curve: Curve.EaseInOut
})
}
原因2:频繁更新数据
如果在短时间内多次修改数据,动画可能来不及完成就被打断:
typescript
// ❌ 不好的做法
Button('快速喝水')
.onClick(() => {
for (let i = 0; i < 10; i++) {
this.currentWater += 20; // 连续修改 10 次
}
})
解决方案:
typescript
// ✅ 好的做法
Button('快速喝水')
.onClick(() => {
this.currentWater += 200; // 一次性修改
})
原因3:动画时长设置不当
typescript
// 太短,看不清动画
.animation({ duration: 50, curve: Curve.EaseInOut })
// 太长,感觉拖沓
.animation({ duration: 2000, curve: Curve.EaseInOut })
// ✅ 合适的时长
.animation({ duration: 300, curve: Curve.EaseInOut })
原因4:页面上动画元素过多
如果同时有很多组件在动画,可能导致性能问题:
typescript
// ❌ 可能卡顿
Row() {
ForEach(this.waterCups, (cup) => {
WaterCup({ current: cup.amount }) // 100 个水杯同时动画
})
}
解决方案:
- 减少同时动画的元素数量
- 使用虚拟滚动(LazyForEach)
- 考虑禁用不重要的动画
Q3: 如何自定义杯子尺寸?
方法 1:修改组件内部的默认值
直接修改 WaterCup.ets 文件:
typescript
@Component
export struct WaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
cupWidth: number = 120; // 改为 120
cupHeight: number = 160; // 改为 160
// ...
}
这种方法简单,但会影响所有使用该组件的地方。
方法 2:将尺寸改为 @Prop,从外部传入
修改组件定义:
typescript
@Component
export struct WaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
@Prop cupWidth: number = 100; // 改为 @Prop
@Prop cupHeight: number = 140; // 改为 @Prop
// ...
}
使用时可以自定义尺寸:
typescript
// 使用默认尺寸
WaterCup({ current: 1500, target: 2000 })
// 自定义尺寸
WaterCup({
current: 1500,
target: 2000,
cupWidth: 120,
cupHeight: 160
})
这种方法更灵活,推荐使用。
方法 3:创建不同尺寸的变体组件
typescript
// 小号水杯
@Component
export struct SmallWaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
build() {
WaterCup({
current: this.current,
target: this.target,
cupWidth: 80,
cupHeight: 110
})
}
}
// 大号水杯
@Component
export struct LargeWaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
build() {
WaterCup({
current: this.current,
target: this.target,
cupWidth: 140,
cupHeight: 180
})
}
}
这种方法适合有固定的几种尺寸规格的场景。
Q4: 如何添加点击事件?
需求:
点击水杯时显示详细信息或执行某个操作。
解决方案:
方法 1:在组件内部添加点击事件
修改 WaterCup 组件:
typescript
@Component
export struct WaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
onTap: () => void = () => {}; // 添加回调函数
build() {
Column() {
Stack() {
// 水杯内容
}
Text(`${this.current}ml`)
Text(`目标: ${this.target}ml`)
}
.onClick(() => {
this.onTap(); // 触发回调
})
}
}
使用时传入回调:
typescript
WaterCup({
current: 1500,
target: 2000,
onTap: () => {
console.log('水杯被点击了');
// 显示详情弹窗等
}
})
方法 2:在父组件中包装
typescript
Column() {
WaterCup({ current: 1500, target: 2000 })
}
.onClick(() => {
console.log('水杯被点击了');
})
这种方法不需要修改组件,但点击区域可能不够精确。
Q5: 如何支持横向水杯?
需求:
水杯横向显示,水从左向右填充。
解决方案:
创建一个新的 HorizontalWaterCup 组件:
typescript
@Component
export struct HorizontalWaterCup {
@Prop current: number = 0;
@Prop target: number = 2000;
cupWidth: number = 140;
cupHeight: number = 100;
private getWaterWidth(): number {
const ratio = Math.min(this.current / this.target, 1);
return ratio * (this.cupWidth - 20); // 留出右侧空间
}
private getWaterColor(): string {
const ratio = this.current / this.target;
if (ratio >= 1) return '#2196F3';
if (ratio >= 0.7) return '#42A5F5';
if (ratio >= 0.4) return '#64B5F6';
return '#90CAF9';
}
build() {
Column() {
Stack({ alignContent: Alignment.Start }) { // 改为左对齐
// 杯子外框
Row() // 改为 Row
.width(this.cupWidth)
.height(this.cupHeight)
.borderRadius({ topRight: 16, bottomRight: 16 }) // 右侧圆角
.border({ width: 3, color: $r('app.color.water_cup_border') })
.backgroundColor($r('app.color.water_cup_background'))
// 水
Row() // 改为 Row
.width(this.getWaterWidth()) // 宽度动态变化
.height(this.cupHeight - 6)
.borderRadius({ topRight: 13, bottomRight: 13 })
.backgroundColor(this.getWaterColor())
.animation({
duration: 300,
curve: Curve.EaseInOut
})
}
Text(`${this.current}ml`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.water_blue'))
.margin({ top: 12 })
}
.alignItems(HorizontalAlign.Center)
}
}
主要改动:
Stack的对齐方式改为Alignment.Start(左对齐)- 杯子和水从
Column改为Row - 圆角位置改为右侧
- 水的宽度动态变化,高度固定
Q6: 如何添加刻度线?
需求:
在杯子上显示刻度,标注不同的容量。
解决方案:
在 Stack 中添加刻度线层:
typescript
Stack({ alignContent: Alignment.Bottom }) {
// 杯子外框
Column() { ... }
// 水
Column() { ... }
// 刻度线(新增)
Column() {
ForEach([2000, 1500, 1000, 500], (value: number) => {
Row() {
Line()
.width(20)
.height(1)
.backgroundColor('#999999')
Text(`${value}ml`)
.fontSize(10)
.fontColor('#999999')
.margin({ left: 4 })
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ bottom: this.getScalePosition(value) })
})
}
.width(this.cupWidth)
.height(this.cupHeight)
.justifyContent(FlexAlign.End)
}
private getScalePosition(value: number): number {
const ratio = value / this.target;
return ratio * (this.cupHeight - 20);
}
这样就能在杯子上显示 500ml、1000ml、1500ml、2000ml 的刻度线。
总结
通过本文,我们学习了:
- 组件声明 :使用
@Component和export struct创建可复用组件 - 属性传递 :使用
@Prop接收父组件数据,实现响应式更新 - 动态计算:通过方法封装计算逻辑,保持代码清晰
- 布局技巧 :使用
Stack实现堆叠效果,模拟水杯水位 - 动画效果 :使用
.animation()实现平滑过渡 - 组件组合:多个小组件组合成完整功能
自定义组件是 ArkTS 开发的核心技能,掌握组件封装能够大幅提升开发效率和代码质量。建议你在实际项目中多加练习,尝试封装更多实用组件。
练习建议
- 修改颜色方案:尝试使用不同的颜色阶梯,比如从绿色到蓝色
- 添加波浪效果:研究如何在水面添加波浪动画
- 支持横向水杯:修改布局,实现横向显示的水杯
- 添加刻度线:在杯子上显示刻度,标注不同的容量
希望本文对你有所帮助!