HarmonyOS 3D 拆解分析:单页多 Tab 架构的实现
前言
之前的 3D 拆解分析 App 有两个页面:Index(首页)和 AnalysisPage(分析工作台)。用户在首页选好场景后,通过 router.pushUrl 跳转到分析页面。分析完按返回键回到首页。
这个架构能跑,但有个问题:每次从分析页返回首页,页面会重新渲染,之前的场景选择状态会丢失。而且两个页面之间传参需要 router.pushUrl + router.getParams,数据流绕了一圈。
这次重构把首页和分析页合并成一个页面,用底部 Tab 导航切换。不再需要页面跳转,所有状态都在同一个组件里管理。
架构变化
旧架构:两个独立页面
Index.ets (首页)
→ router.pushUrl → AnalysisPage.ets (分析页)
→ router.back → Index.ets
新架构:单页多 Tab
Index.ets
├── Tab 0: HomeTab() — 首页内容
├── Tab 1: AnalyzeTab() — 分析工作台
└── Tab 2: HistoryTab() — 历史记录(占位)

所有 Tab 共享同一个组件实例,切换 Tab 只是改变 @State tab 的值,不会触发页面重建。
底部导航栏:SymbolGlyph + Tab 切换
导航栏布局
build() {
Stack({ alignContent: Alignment.Bottom }) {
Scroll() {
Column() {
if (this.tab === 0) { this.HomeTab() }
else if (this.tab === 1) { this.AnalyzeTab() }
else { this.HistoryTab() }
Blank().height(90)
}
}
Row() {
this.TabBtn(0, '首页')
this.TabBtn(1, '拆解')
this.TabBtn(2, '历史')
}
.width('100%').height(68).backgroundColor(CW)
.justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Center)
.shadow({ radius: 12, color: 'rgba(0,0,0,0.05)', offsetY: -2 })
}
}

用 Stack 让导航栏浮在内容区上面。Blank().height(90) 在内容区底部留白,防止最后一行内容被导航栏遮挡。
Tab 按钮组件
@Builder TabBtn(idx: number, label: string) {
Column({ space: 3 }) {
SymbolGlyph(this.tabIcon(idx, this.tab === idx))
.fontSize(22).fontColor([this.tab === idx ? A : '#C0BFC6'])
Text(label).fontSize(10).fontColor(this.tab === idx ? A : '#C0BFC6')
}
.onClick(() => { this.tab = idx })
}

三个 Tab:
- 首页:
sys.symbol.house/sys.symbol.house_fill - 拆解:
sys.symbol.scope(瞄准镜图标,代表分析) - 历史:
sys.symbol.clock(时钟图标,代表时间线)
选中态用品牌紫 A,未选中用淡灰 #C0BFC6。
图标选择逻辑
private tabIcon(idx: number, active: boolean): Resource {
if (active) {
if (idx === 0) return $r('sys.symbol.house_fill')
if (idx === 1) return $r('sys.symbol.scope')
return $r('sys.symbol.clock')
}
if (idx === 0) return $r('sys.symbol.house')
if (idx === 1) return $r('sys.symbol.scope')
return $r('sys.symbol.clock')
}
注意:scope 和 clock 没有 _fill 版本(或系统不支持),所以选中和未选中用同一个图标,只通过颜色区分。house 有 _fill 版本,选中时用实心。
这种情况很常见——不是所有系统图标都有 _fill 变体。处理方式:没有 _fill 的图标就用颜色区分,有的就用填充+颜色双重区分。
状态管理:从跨页传参到组件内共享
旧方案:路由传参
// 首页
private push(cols, rows, uri): void {
router.pushUrl({ url: 'pages/AnalysisPage', params: { gridCols: cols, ... } })
}
// 分析页
aboutToAppear() {
const p = router.getParams() as Record<string, Object>
this.cols = p['gridCols'] as number
}
新方案:组件内状态
@State tab: number = 0
@State uri: string = ''
@State name: string = ''
@State cols: number = 3
@State rows: number = 3
// ... 更多状态
private startAnalysis(uri: string, c: number, r: number, n: string): void {
this.uri = uri
this.cols = c
this.rows = r
this.name = n
this.val = 0
this.on = false
this.list = false
this.rebuildAnalysis()
this.tab = 1 // 切换到分析 Tab
}
startAnalysis 方法做三件事:
- 设置分析数据(uri、cols、rows、name)
- 重置 UI 状态(val=0、on=false、list=false)
- 切换到分析 Tab
不需要路由跳转,不需要参数序列化/反序列化,所有数据都在同一个组件里。
rebuildAnalysis:从路由参数到直接调用
private rebuildAnalysis(): void {
const parts: PartInfo[] = segmentImage(this.cols, this.rows, 360, 360)
this.count = parts.length
this.pn = partsToNames(parts)
this.px = partsToGridX(parts)
this.py = partsToGridY(parts)
this.pd = partsToDepths(parts)
// ... 构建网格数据和部件卡片
}
旧版的 rebuild 在 aboutToAppear 中调用,依赖路由参数。新版的 rebuildAnalysis 在 startAnalysis 中调用,直接使用组件内的 @State 变量。
首页 Tab:场景选择的保留
HomeTab 保留了之前的所有功能——问候语、Hero Card、统计卡片、场景预设、三步流程。唯一的区别是点击"开始分析"时不再跳转页面:
// 旧版
.onClick(() => { this.push(this.cols(), this.rows(), '') })
// 新版
.onClick(() => {
this.startAnalysis('', this.colsH(), this.rowsH(),
recommendGrid(this.colsH(), this.rowsH()))
})
startAnalysis 接收四个参数:图片 URI、列数、行数、物品名称。首页没有拍照功能,所以 URI 传空字符串。
分析 Tab:从独立页面到 Tab 内容
AnalyzeTab 的布局和之前的 AnalysisPage 几乎一样——头部、工作区、滑块、网格选择、部件清单。区别在于:
- 不需要
aboutToAppear获取路由参数——数据已经在startAnalysis中设置好了 - 不需要
router.back()——切换回首页只需this.tab = 0 - 状态变量直接在组件内——不需要跨页面传递
工作区高度固定
.width('100%').height(320).margin({ left: 16, right: 16, bottom: 8 })
旧版的工作区用 .layoutWeight(1) 占满剩余空间。新版固定高度 320px——因为 Tab 切换时内容区高度不变,用固定高度更可控。
部件清单的紧凑化
// 旧版
.height(170).padding(14).borderRadius({ topLeft: 18, topRight: 18 })
// 新版
.height(140).padding(12).borderRadius({ topLeft: 16, topRight: 16 })
部件清单的高度、内边距、圆角都缩小了。在 Tab 布局中,垂直空间更宝贵——底部导航栏占了 68px,内容区需要更紧凑。
历史 Tab:占位设计
@Builder HistoryTab() {
Column() {
Text('分析历史').fontSize(20).fontWeight(FontWeight.Bold).fontColor(T1)
.width('100%').padding({ left: 18, top: 14 }).margin({ bottom: 12 })
Column() {
Text('📋').fontSize(48).opacity(0.2).margin({ bottom: 12 })
Text('暂无分析记录').fontSize(15).fontWeight(FontWeight.Medium).fontColor(T2)
Text('切换到首页选择场景开始拆解').fontSize(12).fontColor(T3).margin({ top: 4 })
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
}
空状态设计:
- 标题:分析历史
- 图标:📋(48px,20% 透明度)
- 主文案:暂无分析记录
- 副文案:切换到首页选择场景开始拆解
副文案引导用户去首页操作,而不是让用户猜测"这个页面是干什么的"。
保留 AnalysisPage 的原因
新版 Index 已经包含了分析功能,但 AnalysisPage.ets 没有删除。原因:
- 向后兼容:如果有其他页面通过
router.pushUrl跳转到 AnalysisPage,它仍然能工作 - 独立使用:如果后续需要把分析功能单独抽出来(比如做成一个可复用的组件),AnalysisPage 可以作为参考
- 渐进式迁移:先在 Index 中实现新架构,验证没问题后再删除旧页面
踩坑记录
1. Tab 切换时的状态保留
if (this.tab === 0) { this.HomeTab() }
else if (this.tab === 1) { this.AnalyzeTab() }
ArkUI 的 if/else 条件渲染会在条件变化时销毁旧组件、创建新组件。这意味着切换 Tab 时,每个 Tab 的内部状态会丢失。
如果需要保留 Tab 状态(比如分析页的滑块位置),有两个方案:
- 用
Visibility控制显示/隐藏(组件不销毁,只是不可见) - 把需要保留的状态提升到父组件(当前方案——
val、on等状态在 Index 层管理)
2. Blank().height 的高度计算
Blank().height(90)
这个高度需要 ≥ 导航栏高度(68px)+ 安全区域。设 90px 留了 22px 的余量。如果设太小,最后一行内容会被导航栏遮挡。
3. Scroll 的 edgeEffect 在 Tab 切换时
Scroll() { ... }
.edgeEffect(EdgeEffect.Spring)
Tab 切换时 Scroll 会重置滚动位置。如果需要记住每个 Tab 的滚动位置,需要在切换时保存和恢复 scrollOffset。
4. SymbolGlyph 的选中态图标
不是所有 sys.symbol.* 都有 _fill 版本。使用前最好在 DevEco Studio 的预览里确认。如果没有 _fill,就用颜色区分选中/未选中。
写在最后
单页多 Tab 架构的核心优势是"状态共享"——首页选的场景、分析页的滑块位置、部件清单的展开状态,都在同一个组件里,切换 Tab 不会丢失。
代价是代码量增加了——Index.ets 现在包含了三个 Tab 的所有代码,文件变长了。如果后续每个 Tab 的功能越来越复杂,可以考虑把每个 Tab 拆成独立组件,在 Index 中组合使用:
build() {
Stack({ alignContent: Alignment.Bottom }) {
Column() {
if (this.tab === 0) { HomeTab({ preset: $preset }) }
else if (this.tab === 1) { AnalyzeTab({ uri: $uri, ... }) }
else { HistoryTab() }
}
this.BottomNav()
}
}
这样每个 Tab 的代码独立维护,Index 只负责 Tab 切换和状态管理。
更多推荐

所有评论(0)