适用读者已经读完菜谱系列其他文章、想从布局复用角度理解项目结构的开发者。本文对比五个页面的布局结构提炼可复用的模式搞清楚哪些布局可以直接套用、哪些需要根据场景调整。完整效果一、五个页面的骨架结构五个页面的顶层布局可以归纳为两种骨架骨架一Column Scroll四个页面Column 页面容器 ├→ Row 导航栏 └→ Scroll 可滚动内容 └→ Column 垂直排列各区块 ├→ 区块 1 ├→ 区块 2 └→ Blank().height(20) 底部留白使用页面CategoryPage、FavoritesPage、ShoppingListPage、RecipeDetail导航栏固定在顶部不参与滚动Scroll 包裹所有内容区块。这是顶部导航 内容滚动的标准骨架——大多数移动端页面都用这种结构。为什么导航栏要放在 Scroll 外面如果放在 Scroll 里面用户往下滚动时导航栏会跟着滚走——返回按钮消失用户无法快速返回上一页。放在外面保证导航栏始终可见。骨架二Stack Scroll RowIndexStack({ alignContent: Alignment.Bottom }) 层叠容器 ├→ Scroll 可滚动内容 │ └→ Column 垂直排列 │ ├→ Header │ ├→ Search │ ├→ Categories │ ├→ Recipes │ └→ Blank().height(90) 底部导航栏预留 └→ Row 底部导航栏固定 └→ NavBtn × 3使用页面Index首页需要用 Stack 把底部导航栏固定在底部——Scroll 内容可以滚动导航栏始终可见。Blank().height(90)预留了导航栏的高度60px 上方留白防止最后一个菜谱卡片被导航栏遮挡。alignContent: Alignment.Bottom的关键作用。Stack 默认是Alignment.Center——两层都居中叠放。改成 Bottom 后导航栏Row贴底Scroll 内容在上方。如果忘了这个属性导航栏会跑到屏幕中间。两种骨架的选择标准场景骨架原因有底部导航栏Stack Scroll Row导航栏需要固定在底部无底部导航栏Column Scroll导航栏在顶部不需要层叠内容不需要滚动Column一屏能放下所有内容菜谱 App 只有首页有底部导航栏所以只有首页用骨架二。其他四个页面都是骨架一。二、导航栏的统一结构与四个变体四个非首页页面的导航栏基础结构完全一致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(title).fontSize(20).fontWeight(FontWeight.Bold).fontColor(T1).margin({left:10}).layoutWeight(1)// 右侧内容各页面不同}.width(100%).padding({left:16,right:16,top:12,bottom:8})返回按钮的嵌套结构拆解外层 Row 的作用——扩大点击区域。SymbolGlyph 本身只有图标那么大约 16×16用户很难精确点击。外层 Row 设定 34×34 的点击区域让触摸体验更好。justifyContent(FlexAlign.Center)让图标在 34×34 区域内居中。没有这行SymbolGlyph 会靠左对齐视觉上不在按钮中心。backgroundColor(rgba(0,0,0,0.03))给按钮一个极浅的底色。3% 黑色透明度几乎看不出颜色但给了用户一个这是可点击区域的视觉暗示。如果完全透明按钮会和背景融为一体。layoutWeight(1)让标题占据所有剩余空间。无论右侧有没有内容、内容多宽标题都不会被挤压。这和 CSS 的flex: 1效果一样。四个变体的右侧内容页面标题右侧内容实现方式CategoryPage动态分类名this.cat菜谱数量Text(this.recipes.length 道)FavoritesPage“我的收藏”菜谱数量Text(this.recipes.length 道)ShoppingListPage“采购清单”清空按钮Text(清空).onClick(...)RecipeDetail动态菜名this.recipe.name收藏按钮Text(this.fav ? ❤️ : ).onClick(...)踩坑记录最初 RecipeDetail 的导航栏标题写了固定字符串菜谱详情用户反馈应该显示具体菜名。改成this.recipe ? this.recipe.name : 后导航栏标题随菜谱动态变化。这个细节能让用户随时知道自己在看哪道菜。ShoppingListPage 的清空按钮放在导航栏而不是列表底部。原因是清空是全局操作放在导航栏比放在列表里更显眼。用户不需要滚动到列表底部就能找到清空按钮。fontColor(A)用红色文字暗示这是危险操作。三、菜谱卡片的两种实现菜谱卡片在三个页面重复出现但有细微差异首页卡片Row(){Text(r.icon).fontSize(36).width(64).height(64).borderRadius(16).backgroundColor(#F5F5FA).textAlign(TextAlign.Center)Column(){Text(r.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(T1)Text(r.desc).fontSize(11).fontColor(T3).maxLines(1).margin({top:2})Row(){Text(⏱ r.time).fontSize(10).fontColor(T2)Text( · r.difficulty).fontSize(10).fontColor(T2)Text( · r.servings).fontSize(10).fontColor(T2)}.margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)SymbolGlyph($r(sys.symbol.chevron_right)).fontSize(14).fontColor([#DDD])}.width(100%).padding(14).backgroundColor(CW).borderRadius(16).shadow({radius:4,color:rgba(0,0,0,0.03),offsetY:1})分类页/收藏页卡片Row(){Text(r.icon).fontSize(36).width(64).height(64).borderRadius(16).backgroundColor(#F5F5FA).textAlign(TextAlign.Center)Column(){Text(r.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(T1)Text(r.desc).fontSize(11).fontColor(T3).maxLines(1).margin({top:2})Text(⏱ r.time · r.difficulty).fontSize(10).fontColor(T2).margin({top:4})}.alignItems(HorizontalAlign.Start).margin({left:12}).layoutWeight(1)SymbolGlyph($r(sys.symbol.chevron_right)).fontSize(14).fontColor([#DDD])}.width(100%).padding(14).backgroundColor(#FFFFFF).borderRadius(16)两种实现的差异部分首页卡片分类页/收藏页卡片第三行信息三个独立 Text Text( · )分隔一个 Text 拼接字符串显示内容时间难度人数时间难度卡片阴影有shadow radius:4无第三行的两种写法对比首页用三个独立 Text每个 Text 一个属性中间用Text( · )分隔。分类页/收藏页用一个 Text 拼接所有属性。最终视觉效果一样但代码结构不同。三种写法各有优劣——三个独立 Text 更灵活可以单独控制每个属性的颜色拼接字符串更简洁一行搞定。首页卡片有阴影其他页面没有。首页是入口页面卡片阴影增加了层次感让内容和背景有分离。分类页和收藏页是列表页面卡片密集排列加阴影会让页面看起来太厚重。卡片的通用模板所有菜谱卡片都遵循同一个结构Row 卡片容器白色背景 圆角 padding 14 ├→ Text(icon) 左侧 emoji 图标64×64 固定区域 #F5F5FA 底色 ├→ Column layoutWeight(1) 中间信息区弹性宽度 │ ├→ Text(name) 菜名15px 粗体深色 │ ├→ Text(desc) 描述11px 浅灰 T3maxLines 1 │ └→ Text/Row(属性) 属性行10px 灰色 T2 └→ SymbolGlyph(chevron_right) 右侧箭头14px 浅灰 #DDD每个部分的设计理由左侧 64×64 emoji 底座固定尺寸保证所有卡片对齐无论 emoji 内容多大。#F5F5FA极浅灰背景让 emoji 有一个底座不会和白色卡片融为一体。中间layoutWeight(1)弹性宽度——无论菜名多长右侧箭头始终固定在最右。maxLines(1)菜谱描述可能很长限制一行保证所有卡片等高。右侧箭头告诉用户可点击查看详情。#DDD浅灰色不抢焦点只在用户注意时才看到。四、空状态的统一模式FavoritesPage 和 ShoppingListPage 都有空状态处理if(this.recipes.length0){Column(){Text(❤️).fontSize(64).opacity(0.2).margin({bottom:16})Text(还没有收藏).fontSize(16).fontColor(T2)Text(浏览菜谱时点击 ❤️ 即可收藏).fontSize(13).fontColor(T3).margin({top:4})}.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center)}空状态的三个要素要素作用样式设计理由大 emoji视觉锚点64px opacity(0.2)吸引注意力但淡化不抢文字焦点主提示告诉用户当前状态16px 灰色 T2直接说明没有数据副提示告诉用户怎么操作13px 浅灰 T3提供解决方案opacity(0.2)的设计意图。大 emoji 用 64px 是为了吸引注意力但 opacity(0.2) 让它变淡——用户看到淡化的 emoji 后自然会往下看文字。如果 emoji 不淡化用户的注意力会被 emoji 吸走忽略下方的操作提示。layoutWeight(1)justifyContent(FlexAlign.Center)的组合。layoutWeight(1)让空状态 Column 占据所有剩余空间justifyContent(FlexAlign.Center)让内容垂直居中。没有layoutWeight(1)Column 只会占内容高度居中效果不明显——内容会贴在导航栏下方。两个页面的空状态文案对比页面主提示副提示指向FavoritesPage还没有收藏浏览菜谱时点击 ❤️ 即可收藏指向菜谱详情页的收藏按钮ShoppingListPage清单是空的在菜谱详情页点击加入采购清单指向菜谱详情页的加入清单按钮两个副提示都指向了具体的操作入口——不是笼统地说去浏览菜谱而是告诉用户在哪个页面、点击哪个按钮。这种问题 具体解决方案的文案模式比暂无数据更有帮助。踩坑记录最初空状态没加layoutWeight(1)心形图标和文字贴在导航栏下方视觉上很拥挤。加了layoutWeight(1)后 Column 占据所有剩余空间配合居中实现垂直居中。五、列表渲染的三种模式模式一固定列表不需要滚动// ShoppingListPage 的采购清单Column({space:4}){ForEach(this.items,(item:string,idx:number){Row(){/* 列表项 */}})}采购清单项数通常较少5-10 个食材不需要滚动。Column({ space: 4 })自动处理间距代码最简洁。模式二可滚动列表// CategoryPage / FavoritesPageScroll(){Column({space:10}){ForEach(this.recipes,(r:Recipe){Row(){/* 菜谱卡片 */}})Blank().height(20)}.width(100%).padding({left:16,right:16})}菜谱列表可能超过一屏需要 Scroll 包裹。Blank().height(20)在列表底部留白防止最后一个卡片被屏幕边缘截断。Blank().height(20)而不是margin({ bottom: 20 })的原因。Blank 在 Column 里会占据固定高度的空间效果等同于底部留白。如果用 margin需要加在 ForEach 的最后一项上——但 ForEach 的最后一项是动态的收藏数量变化无法提前知道要加在哪一项。Blank 更简单。模式三分组列表// RecipeDetail 的六个区块Column(){Column(){/* Hero */}.padding(24).backgroundColor(#FFFFFF).borderRadius(18).margin({left:16,right:16,bottom:16})Column(){/* 食材 */}.padding(18).backgroundColor(#FFFFFF).borderRadius(18).margin({left:16,right:16,bottom:16})Column(){/* 步骤 */}.padding(18).backgroundColor(#FFFFFF).borderRadius(18).margin({left:16,right:16,bottom:16})// ...}详情页的六个区块是分组列表——每个区块是独立的白色容器区块之间有间距。这不是 ForEach 渲染的列表而是手动排列的区块。每个区块的样式独立调整Hero padding 24其他 padding 18比 ForEach 更灵活。三种模式的选择标准场景模式原因项数固定且少 10Column ForEach不需要滚动代码最简洁项数不确定或多Scroll Column ForEach超出一屏时可滚动区块结构各不相同手动排列 Column每个区块样式不同无法用 ForEach 统一六、条件渲染的三种用法用法一if-else 切换不同 UI 结构if(this.recipes.length0){// 空状态大 emoji 提示文字}else{// 列表Scroll ForEach}数组为空和不为空时UI 结构完全不同——空状态是居中的 emoji 文字列表是可滚动的卡片。这种两种完全不同的结构只能用 if-else不能用三元表达式三元只能切换属性不能切换结构。用法二if 控制区块显隐if(this.recipe){Scroll(){/* 所有内容 */}}数据加载成功后才渲染内容。this.recipe可能是 undefined传入的 id 无效时if (this.recipe)保证只在数据存在时才访问this.recipe.name等属性。为什么不用可选链this.recipe?.name虽然也能避免 undefined 错误但会让整个 build 方法变成一长串可选链调用可读性差。if (this.recipe)把数据是否加载成功的判断集中在一个地方代码更清晰。用法三三元表达式切换属性// 收藏按钮Text(this.fav?❤️:).fontSize(20)// 计时器按钮Text(this.timerOn?⏸ 停止:▶ 开始).backgroundColor(this.timerOn?#FF6B6B:A)// 底部导航栏Text(label菜谱?:(label收藏?❤️:))元素始终存在只改变文字内容、颜色、背景色。三元表达式适合这种同一个元素的两种状态切换。三种用法的选择标准场景用法原因两种完全不同的 UI 结构if-else空状态和列表结构不同数据是否存在if数据不存在时不渲染避免 undefined 报错文字/颜色/图标切换三元元素始终存在只改属性七、间距系统的完整规则页面级间距位置值出现次数规律页面左右 padding16px所有页面统一边距切换页面时宽度感一致区块之间 margin bottom16px详情页 5 处大区块之间用统一间距列表项之间 space10px3 个页面卡片之间用统一间距底部留白20px3 个页面防止最后一个元素被截断区块级间距位置值设计理由Hero 区块 padding24px视觉重心留白多一点其他区块 padding16-18px内容区标准留白标题行 margin bottom8-10px标题和内容之间的呼吸感列表项 padding12-14px卡片内边距同区块小元素间距2-4px紧密关联的元素用小间距间距选择的核心规律外大内小区块之间用大间距16px区块内元素之间用小间距2-10px。用户通过间距大小区分这是不同功能区和这是同一功能区内的元素。统一边距所有页面的左右 padding 统一为 16px。用户在切换页面时不会感到页面宽度变了。功能相关紧贴详情页的份量调整和计时器之间没有额外 margin——因为两个功能紧密关联都是烹饪过程中的辅助工具。功能独立的区块之间用 16px 间距拉开。八、底部导航栏的完整实现BuilderNavBtn(label:string,active:boolean){Column({space:2}){Text(label菜谱?:(label收藏?❤️:)).fontSize(18)Text(label).fontSize(9).fontColor(active?#FF6B6B:#C0BFC6)}.onClick((){if(label收藏)router.pushUrl({url:pages/FavoritesPage})elseif(label清单)router.pushUrl({url:pages/ShoppingListPage})})}三元表达式嵌套选择 emojilabel 菜谱 ? : (label 收藏 ? ❤️ : )根据标签名选择对应图标。这种写法比 if-else 链更紧凑但超过三个分支时可读性会下降。当前正好三个 tab三元嵌套刚好够用。“菜谱” tab 不跳转的设计onClick里只处理了收藏和清单的跳转——“菜谱” tab 没有跳转逻辑。因为 Index 本身就是菜谱页面重新 push 会导致页面重建搜索框内容丢失。踩坑记录最初菜谱 tab 也写了router.pushUrl({ url: pages/Index })点击后页面闪烁搜索框内容丢失。去掉跳转后问题解决。这是 Entry 页面的特性——重复 push 同一个 Entry 页面会重建实例。选中状态的视觉区分属性选中activetrue未选中activefalse文字颜色#FF6B6B主色红#C0BFC6浅灰emoji正常显示正常显示选中和未选中只通过文字颜色区分——emoji 不变。这种颜色区分比图标切换更轻量不需要准备两套图标资源。九、整体布局的嵌套层次Index骨架二 └→ Stack(Alignment.Bottom) ├→ Scroll │ └→ Column │ ├→ Row (Header) ← Stack 渐变圆 │ ├→ Row (Search) ← 圆角输入框 │ ├→ Row({ space: 8 }) ForEach ← 分类横向排列 │ ├→ Column({ space: 10 }) ForEach ← 菜谱卡片列表 │ └→ Blank(90) ← 导航栏预留 └→ Row Builder NavBtn × 3 ← 底部导航 CategoryPage / FavoritesPage骨架一 └→ Column ├→ Row (导航栏) └→ Scroll └→ Column ├→ ForEach (列表项) └→ Blank(20) ShoppingListPage骨架一无 Scroll └→ Column ├→ Row (导航栏) ├→ Row (输入框 添加按钮) └→ if (空状态) / Column ForElse (列表) RecipeDetail骨架一 └→ Column ├→ Row (导航栏) └→ Scroll └→ Column ├→ Column (Hero) ← padding 24 ├→ Column (食材) ← padding 18 加入清单按钮 ├→ Column (步骤) ← padding 18 编号时间线 ├→ Column (小贴士) ← padding 18 暖色背景 ├→ Column (份量调整) ← padding 16 ├→ Column (计时器) ← padding 16 └→ Blank(20)嵌套层次的规律最深 4 层Stack → Scroll → Column → Row最浅 2 层Column → Row。嵌套越深的页面结构越复杂Index 有 4 层嵌套越浅的页面越简单ShoppingListPage 有 2 层。每个 Column 容器就是一个功能区块。看到一个新的 Column 就知道是一个独立的功能区——可以独立调整 padding、背景色、圆角不影响其他区块。这种区块 Column的模式让代码结构清晰维护时能快速定位到目标区块。