HarmonyOS运动健康——二级页面的Builder复用与ArkTS组件模式
运动健康App从最初的首页仪表盘到现在已经扩展到12个页面。抛开各页面的具体业务逻辑,单独看这些二级页面(成就、身体数据、目标设置、设置),会发现它们在结构上有大量重复——同样的导航栏、同样的卡片容器、同样的列表分割线。这些重复不是巧合,而是 ArkTS @Builder 在实际项目中自然演化出来的复用模式。
完整效果


导航栏:最稳定的重复结构
四个新页面的导航栏代码几乎一字不差:
Row() {
Row() {
SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])
}.width(34).height(34).borderRadius(17)
.backgroundColor('rgba(0,0,0,0.03)')
.justifyContent(FlexAlign.Center)
.onClick(() => { router.back() })
Text('页面标题').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor(T1).margin({ left: 10 }).layoutWeight(1)
// 右侧操作区(各页面不同)
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })

差异只在标题文字和右侧区域。成就页放 4/8 计数,身体数据页放"编辑"按钮,目标页放"保存"按钮,设置页右侧留空。
如果把导航栏抽成通用 Builder,需要处理三个变量:标题文字、右侧内容、右侧的点击回调。但实际写下来发现,强行抽成一个"万能导航栏"反而增加了理解成本——右侧内容有时是 Text,有时是 Row,有时为空,参数类型不统一。
所以在当前阶段,每个页面各写一遍导航栏是更务实的选择。等后续页面稳定了,再考虑抽成组件也不迟。ArkTS 里的 @Builder 适合做"轻量复用"——复制代码的成本低于维护一个过度抽象的通用组件。
@Builder 的三种角色
回顾这四个页面的 Builder 用法,可以归纳出三种典型角色:
角色一:可复用的 UI 片段
这是 Builder 最常见的用途。BodyDataPage 里的 DataCard 是最典型的例子:
@Builder DataCard(label: string, value: string, unit: string, color: string) {
Row() {
Column() {
Text(label).fontSize(13).fontColor(T2)
Row() {
Text(value).fontSize(32).fontWeight(FontWeight.Bold).fontColor(color)
Text(' ' + unit).fontSize(14).fontColor(T3)
}.margin({ top: 4 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
}.width('100%').padding(18).backgroundColor('#FFFFFF')
.borderRadius(16).margin({ bottom: 10 })
}
六个指标卡片共用一个 Builder,调用时只传不同参数。这种模式的核心特征是:参数全是基本类型(string),没有回调函数,不涉及状态变更。Builder 在这里只是一个"带参数的模板",渲染完就结束了。
类似的角色在整个项目里出现了很多次:首页的 RingStat、训练页的 ExerciseRow、进度页的 DayColumn、设置页的 Row。它们的共同特点是:纯粹的展示,不改变任何状态。
角色二:带交互的列表项
GoalPage 的 GoalRow 比 DataCard 多了一个参数——一个 action 回调:
@Builder GoalRow(label: string, value: string, color: string, action: () => void) {
Row() {
Text(label).fontSize(15).fontColor(T1).layoutWeight(1)
Text(value).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(color).margin({ right: 10 })
}.width('100%').padding(16).onClick(action)
}
ProfilePage 的 Menu Builder 也是同样的模式——接收一个 action 回调,绑定到 onClick:
@Builder Menu(label: string, icon: string, action: () => void) {
Row() {
Text(icon).fontSize(18).margin({ right: 14 })
Text(label).fontSize(14).fontColor(T1).layoutWeight(1)
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(12).fontColor(['#DDD'])
}.width('100%').padding({ left: 18, right: 18, top: 14, bottom: 14 })
.onClick(action)
}
和第一种角色的关键区别在于:参数里包含 () => void 类型的回调。Builder 本身不执行业务逻辑,但它把"点击后做什么"的决策权交给了调用方。这让 Builder 从"纯模板"升级成了"可交互的组件片段"。
这种模式在 ArkTS 里比定义一个完整的 @Component 更轻量。@Component 有自己的生命周期和状态管理,适合独立的复杂组件;@Builder 更适合"只是长得一样,行为各不相同"的场景。
角色三:条件分支的容器
AchievementPage 里有一个不太起眼但很重要的用法——用 if/else 在 Builder 内部做条件渲染:
ForEach(this.badges, (b: Badge) => {
GridItem() {
Column() {
// ... 图标、名称、描述 ...
if (b.earned) {
Text('✓ 已获得').fontSize(9).fontColor(b.color)
.fontWeight(FontWeight.Bold)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(b.color + '15')
.borderRadius(6).margin({ top: 6 })
} else {
Row() {}.width(30).height(2).borderRadius(1)
.backgroundColor('#E0DCE8').margin({ top: 8 })
}
}
}
})
if/else 不是 Builder 的专属能力,但当它出现在 ForEach + GridItem 的组合里时,就变成了"数据驱动 UI 状态"的典型模式。b.earned 这个布尔值决定了渲染完全不同的两套 UI——一套是带颜色的"已获得"标签,一套是灰色小横条。
SettingsPage 的 Row Builder 里也有类似的条件渲染:
@Builder Row(label: string, sub: string) {
Row() {
Text(label).fontSize(15).fontColor(T1).layoutWeight(1)
if (sub.length > 0) {
Text(sub).fontSize(13).fontColor(T3)
}
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(12)
.fontColor(['#DDD']).margin({ left: 8 })
}
}
sub 参数为空字符串时,副标题不渲染。比用 Visibility.Hidden 更干净——不占空间,不参与布局计算。
ForEach + Grid:数据驱动的网格布局
AchievementPage 的徽章墙用了 Grid + ForEach 的组合。这个模式值得单独说一下,因为它和项目里其他页面用的 Row + ForEach(横向滚动)有所不同。
Grid() {
ForEach(this.badges, (b: Badge) => {
GridItem() {
// 徽章内容
}
})
}.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
.width('100%').padding({ left: 16, right: 16 })
columnsTemplate('1fr 1fr') 定义两列等宽布局。和 Row({ space: 10 }) 的区别在于:Row 是一行排不下就溢出或换行,需要外面套 Scroll 才能滚动;Grid 天然支持多行多列,行数由数据量自动决定。
项目里横向滚动的场景(首页推荐训练、训练页动作列表)用 Row + Scroll,纵向网格的场景(成就页徽章墙)用 Grid + ForEach。选哪个取决于内容的排列方向,而不是个人偏好。
状态管理:@State 的边界
四个新页面里,只有 GoalPage 用了 @State:
@State steps: number = 10000
@State calories: number = 500
@State minutes: number = 30
@State workouts: number = 3
其他三个页面(AchievementPage、BodyDataPage、SettingsPage)虽然也有数据(徽章列表、身体指标),但都用的是 @State + 初始值的模式——数据在组件内部定义,不涉及外部传入或跨页面共享。
// AchievementPage
@State badges: Badge[] = [
{ icon: '🔥', name: '连续7天', desc: '连续运动7天', earned: true, color: '#FF4757' },
// ...
]
这里有一个隐含的设计决策:这些数据目前是写死的 mock 数据。如果以后要接真实数据(从服务器或本地存储读取),@State 的初始值赋值方式需要改成异步加载。但在原型阶段,写死数据 + @State 是最快的路径——先让页面跑起来,再考虑数据来源。
整个运动健康 App 的数据层设计是分层的:WorkoutData.ets 管理训练相关的结构化数据,各页面各自管理自己的展示数据。社区页、饮食页、个人中心页都没有调用 WorkoutData,而是各自定义了本地数据。这种"页面自治"的方式在原型阶段减少了耦合,但也意味着以后接入真实数据时,每个页面都需要单独改造。
颜色常量:贯穿全 App 的视觉语言
四个新页面的顶部都定义了同一组颜色常量:
const A: string = '#FF4757' // 主色·运动红
const T1: string = '#1E1B2E' // 标题黑
const T2: string = '#888888' // 副文本
const T3: string = '#BBBBBB' // 辅助灰
A 是整个 App 的主色调——红色,代表运动和活力。所有需要"强调"的地方都用 A:导航栏返回按钮的 hover 态、成就页的"已获得"标签、目标页的步数颜色、身体数据页的体重数值。
T1/T2/T3 形成了三级文本颜色体系:T1 是标题和重要信息,T2 是次要说明,T3 是辅助性文字和占位符。这套体系从首页一直沿用到最底层的设置页,保证了视觉一致性。
每个页面都重新定义这些常量(而不是从全局导入),是一个有意的取舍。好处是每个页面都是自包含的,复制粘贴就能跑;坏处是如果以后要改颜色,需要改 12 个文件。在原型阶段,自包含的优先级更高。
SymbolGlyph:系统图标的正确打开方式
四个新页面都用了 SymbolGlyph($r('sys.symbol.chevron_left')) 和 SymbolGlyph($r('sys.symbol.chevron_right')) 作为导航图标。
和 emoji 图标(如菜单里的 📊、🏆)不同,SymbolGlyph 是 HarmonyOS 的系统符号字体,特点是:
- 矢量渲染:放大不失真,不同分辨率设备都能清晰显示
- 颜色可控:通过
fontColor参数设置,可以和文字颜色保持一致 - 体积小:不占用图片资源,直接从系统字体渲染
emoji 图标虽然方便,但有两个问题:不同设备的 emoji 样式不统一,而且无法设置颜色。所以导航栏这类需要精确控制颜色的地方用 SymbolGlyph,菜单项的装饰性图标用 emoji。两种方式各司其职。
从 Builder 到 Component 的临界点
这四个页面都是用 @Builder 实现的,没有用 @Component。但在项目里,@Component 同样大量存在——首页的 Index、训练页的 WorkoutPage、进度页的 ProgressPage 等。
选 @Builder 还是 @Component,核心标准是:这个 UI 单元是否需要独立的状态和生命周期。
@Builder 适合:
- 纯展示的 UI 片段(DataCard、Stat、Row)
- 带回调但没有自身状态的交互项(GoalRow、Menu)
- 条件渲染的分支(if/else 包裹的 UI 块)
@Component 适合:
- 有独立
@State的完整页面 - 需要
aboutToAppear/aboutToDisappear生命周期的场景 - 需要暴露给外部调用的独立组件
这四个新页面之所以全部用 @Builder 而不是 @Component,是因为它们都是"单文件页面"——一个 @Entry @Component struct 里面包含了所有逻辑,没有拆分成子组件。当页面的复杂度增加到需要拆分的时候,@Builder 就不够用了,需要升级为 @Component。
目前整个运动健康 App 里,只有 UserProfile 是一个独立的 @Component(被 CommunityPage 调用)。其他页面都是自包含的 @Entry 组件。这个架构在 12 个页面的规模下还能hold住,但如果继续扩展,可能需要引入更系统的组件拆分策略。
更多推荐



所有评论(0)