运动健康App从最初的首页仪表盘到现在已经扩展到12个页面。抛开各页面的具体业务逻辑单独看这些二级页面成就、身体数据、目标设置、设置会发现它们在结构上有大量重复——同样的导航栏、同样的卡片容器、同样的列表分割线。这些重复不是巧合而是 ArkTSBuilder在实际项目中自然演化出来的复用模式。完整效果导航栏最稳定的重复结构四个新页面的导航栏代码几乎一字不差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是最典型的例子BuilderDataCard(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回调BuilderGoalRow(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 的MenuBuilder 也是同样的模式——接收一个action回调绑定到onClickBuilderMenu(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.color15).borderRadius(6).margin({top:6})}else{Row(){}.width(30).height(2).borderRadius(1).backgroundColor(#E0DCE8).margin({top:8})}}}})if/else不是 Builder 的专属能力但当它出现在ForEachGridItem的组合里时就变成了数据驱动 UI 状态的典型模式。b.earned这个布尔值决定了渲染完全不同的两套 UI——一套是带颜色的已获得标签一套是灰色小横条。SettingsPage 的 Row Builder 里也有类似的条件渲染BuilderRow(label:string,sub:string){Row(){Text(label).fontSize(15).fontColor(T1).layoutWeight(1)if(sub.length0){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 的徽章墙用了GridForEach的组合。这个模式值得单独说一下因为它和项目里其他页面用的RowForEach横向滚动有所不同。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天然支持多行多列行数由数据量自动决定。项目里横向滚动的场景首页推荐训练、训练页动作列表用RowScroll纵向网格的场景成就页徽章墙用GridForEach。选哪个取决于内容的排列方向而不是个人偏好。状态管理State 的边界四个新页面里只有 GoalPage 用了StateStatesteps:number10000Statecalories:number500Stateminutes:number30Stateworkouts:number3其他三个页面AchievementPage、BodyDataPage、SettingsPage虽然也有数据徽章列表、身体指标但都用的是State 初始值的模式——数据在组件内部定义不涉及外部传入或跨页面共享。// AchievementPageStatebadges:Badge[][{icon:,name:连续7天,desc:连续运动7天,earned:true,color:#FF4757},// ...]这里有一个隐含的设计决策这些数据目前是写死的 mock 数据。如果以后要接真实数据从服务器或本地存储读取State的初始值赋值方式需要改成异步加载。但在原型阶段写死数据 State是最快的路径——先让页面跑起来再考虑数据来源。整个运动健康 App 的数据层设计是分层的WorkoutData.ets管理训练相关的结构化数据各页面各自管理自己的展示数据。社区页、饮食页、个人中心页都没有调用WorkoutData而是各自定义了本地数据。这种页面自治的方式在原型阶段减少了耦合但也意味着以后接入真实数据时每个页面都需要单独改造。颜色常量贯穿全 App 的视觉语言四个新页面的顶部都定义了同一组颜色常量constA:string#FF4757// 主色·运动红constT1:string#1E1B2E// 标题黑constT2:string#888888// 副文本constT3: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住但如果继续扩展可能需要引入更系统的组件拆分策略。