前言

之前的 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')
}

注意:scopeclock 没有 _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 方法做三件事:

  1. 设置分析数据(uri、cols、rows、name)
  2. 重置 UI 状态(val=0、on=false、list=false)
  3. 切换到分析 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)
  // ... 构建网格数据和部件卡片
}

旧版的 rebuildaboutToAppear 中调用,依赖路由参数。新版的 rebuildAnalysisstartAnalysis 中调用,直接使用组件内的 @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 几乎一样——头部、工作区、滑块、网格选择、部件清单。区别在于:

  1. 不需要 aboutToAppear 获取路由参数——数据已经在 startAnalysis 中设置好了
  2. 不需要 router.back()——切换回首页只需 this.tab = 0
  3. 状态变量直接在组件内——不需要跨页面传递

工作区高度固定

.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 没有删除。原因:

  1. 向后兼容:如果有其他页面通过 router.pushUrl 跳转到 AnalysisPage,它仍然能工作
  2. 独立使用:如果后续需要把分析功能单独抽出来(比如做成一个可复用的组件),AnalysisPage 可以作为参考
  3. 渐进式迁移:先在 Index 中实现新架构,验证没问题后再删除旧页面

踩坑记录

1. Tab 切换时的状态保留

if (this.tab === 0) { this.HomeTab() }
else if (this.tab === 1) { this.AnalyzeTab() }

ArkUI 的 if/else 条件渲染会在条件变化时销毁旧组件、创建新组件。这意味着切换 Tab 时,每个 Tab 的内部状态会丢失。

如果需要保留 Tab 状态(比如分析页的滑块位置),有两个方案:

  • Visibility 控制显示/隐藏(组件不销毁,只是不可见)
  • 把需要保留的状态提升到父组件(当前方案——valon 等状态在 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 切换和状态管理。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐