拍一张照片,App 自动把物品"拆开"成零件,用滑块控制爆炸力度。

前言

爆炸图(Exploded View)是工业设计和工程领域常用的一种展示方式——把产品的各个零件沿着装配轴拉开,让内部结构一目了然。通常这种图需要专业软件手动制作,但我一直在想,能不能用手机拍照后自动完成这个过程。

于是就有了这个「3D 拆解分析」App。拍一张物品照片,选择拆解精度(2×2 到 4×4),App 自动把图片切成网格,然后用 3D 爆炸视图展示各个"零件"。滑块控制爆炸力度,从 0%(原始图片)到 100%(完全展开)。

这篇文章讲的是首页和分析工作台两个页面的完整实现,包括数据流设计、3D 爆炸视图的渲染原理、以及 ArkTS 中的一些实战技巧。

项目结构

先看一下整体架构:

entry/src/main/ets/
├── pages/
│   ├── Index.ets          # 首页:选择精度、拍照入口
│   └── AnalysisPage.ets   # 分析工作台:爆炸视图、部件清单
├── components/
│   └── ExplodedView.ets   # 3D 爆炸视图组件
└── services/
    ├── AnalysisModel.ts    # 数据模型、部件信息
    └── SegmentationService.ts  # 图像切分、网格推荐

职责划分很清晰:

  • pages 只管 UI 布局和交互逻辑
  • components 是可复用的渲染组件
  • services 处理纯数据和算法,不依赖 UI

这种分层在 ArkTS 项目中很重要——services 里的函数可以独立测试,不需要启动 UI 环境。
完整效果
在这里插入图片描述

首页:从入口到分析工作台

页面结构

首页的 build() 方法定义了四个区域:

build() {
  Column() {
    this.HeaderSection()        // 标题栏
    this.HeroCard()             // 主卡片(拍照入口)
    this.GridPresetSection()    // 精度预设
    this.HowItWorksSection()   // 使用说明
    Blank()                     // 弹性留白
    this.Footer()               // 底部提示
  }
  .width('100%').height('100%').backgroundColor(BG)
}

在这里插入图片描述

每个区域用 @Builder 方法拆分,保持 build() 的可读性。这是 ArkUI 中组织复杂页面的标准做法——把大页面拆成多个 @Builder 方法,每个方法负责一个视觉区块。

Hero Card:主操作入口

@Builder HeroCard() {
  Column() {
    Stack({ alignContent: Alignment.Center }) {
      // 渐变背景
      Column() {}
        .width('100%').height(160).borderRadius(20)
        .linearGradient({
          angle: 135,
          colors: [[ACCENT, 0], ['#A78BFA', 0.5], ['#C4B5FD', 1]]
        })
      // 文字内容
      Column() {
        Text('📸').fontSize(40).margin({ bottom: 8 })
        Text('拍照分析物品').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text('支持手机、手表、相机、家电等').fontSize(12)
          .fontColor('rgba(255,255,255,0.7)').margin({ top: 4 })
      }
      .width('100%').height('100%')
      .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
    }
    .width('100%').borderRadius(20)
    .shadow({ radius: 16, color: ACCENT + '30', offsetY: 6 })
    .onClick(() => { this.openAnalysis(3, 3, '') })
  }
}

在这里插入图片描述

几个设计细节:

  • 渐变背景用 linearGradient 实现,角度 135° 从左上到右下,紫色渐变
  • Stack 让文字叠加在渐变背景上面
  • 阴影颜色 ACCENT + '30' 是半透明紫色,和背景色呼应
  • .onClick 跳转到分析页面,默认用 3×3 网格

预设网格:可视化选择拆解精度

首页最核心的交互是四个精度预设芯片:

@Builder PresetChip(label: string, grid: string, cols: number, rows: number) {
  Column() {
    // 迷你网格预览
    Column({ space: 2 }) {
      ForEach(this.getPresetGrid(cols, rows), (row: string[], _r: number) => {
        Row({ space: 2 }) {
          ForEach(row, (_cell: string, _c: number) => {
            Row() {}
              .width(8).height(8).borderRadius(2)
              .backgroundColor(ACCENT + '20')
          })
        }
      })
    }
    .margin({ bottom: 6 })
    Text(grid).fontSize(15).fontWeight(FontWeight.Bold).fontColor(TEXT_PRIMARY)
    Text(label).fontSize(10).fontColor(TEXT_SECONDARY)
  }
  .layoutWeight(1).padding({ top: 14, bottom: 14 })
  .backgroundColor(CARD_BG).borderRadius(14)
  .border({ width: 1, color: '#F0EEF4' })
  .onClick(() => { this.openAnalysis(cols, rows, '') })
}

在这里插入图片描述

每个芯片上面有一个迷你网格预览——用小方块模拟网格切分效果。2×2 显示 4 个方块,3×3 显示 9 个方块,4×4 显示 16 个方块。

关键问题:为什么需要预计算网格?

ArkTS 的 ForEach 对数据源变化很敏感——如果每次渲染都重新生成网格数组,会导致 ForEach 重新创建所有子组件,产生闪烁。

解决方案是在 aboutToAppear 中预计算所有可能的网格:

aboutToAppear(): void {
  this.presetGrid2x2 = this.makePresetGrid(2, 2)
  this.presetGrid3x2 = this.makePresetGrid(3, 2)
  this.presetGrid3x3 = this.makePresetGrid(3, 3)
  this.presetGrid4x3 = this.makePresetGrid(4, 3)
  this.presetGrid4x4 = this.makePresetGrid(4, 4)
}

然后通过 getPresetGrid 方法根据参数返回对应的预计算数组:

private getPresetGrid(cols: number, rows: number): string[][] {
  if (cols === 2 && rows === 2) { return this.presetGrid2x2 }
  if (cols === 3 && rows === 2) { return this.presetGrid3x2 }
  if (cols === 3 && rows === 3) { return this.presetGrid3x3 }
  if (cols === 4 && rows === 3) { return this.presetGrid4x3 }
  return this.presetGrid4x4
}

这样 ForEach 的数据源始终是同一个引用,不会触发重新创建。

makePresetGrid:生成网格数据

private makePresetGrid(cols: number, rows: number): string[][] {
  const maxR: number = rows < 3 ? rows : 3
  const maxC: number = cols < 3 ? cols : 3
  const result: string[][] = []
  for (let r: number = 0; r < maxR; r++) {
    const cells: string[] = []
    for (let c: number = 0; c < maxC; c++) {
      cells.push('')
    }
    result.push(cells)
  }
  return result
}

注意 maxRmaxC 的限制——最多只渲染 3 行 3 列。因为芯片空间有限,4×4 的网格如果真的渲染 16 个小方块会太拥挤,所以预览时裁剪到 3×3。

跳转到分析页面

private openAnalysis(cols: number, rows: number, imgUri: string): void {
  const params: Record<string, Object> = {
    'imageUri': imgUri,
    'gridCols': cols,
    'gridRows': rows,
    'name': recommendGrid(cols, rows),
    'itemId': generateId()
  }
  router.pushUrl({ url: 'pages/AnalysisPage', params: params })
}

参数说明:

  • imageUri:照片的本地路径,拍照或选相册后获取
  • gridCols / gridRows:网格列数和行数
  • name:物品名称,由 recommendGrid 根据网格尺寸生成
  • itemId:唯一标识,由 generateId 生成

分析工作台:3D 爆炸视图

页面结构

分析页面的 build() 方法定义了五个区域:

build() {
  Column() {
    this.HeaderBar()           // 顶部标题栏
    Stack() { ... }            // 3D 爆炸视图区域
    this.IntensityControl()   // 爆炸力度滑块
    this.GridControl()        // 网格切换控件
    this.ActionRow()          // 操作按钮行
    this.PartListPanel()     // 部件清单面板
  }
}

状态管理:用字符串代替数组

分析页面的状态管理有一个值得注意的设计——部件数据用逗号分隔的字符串存储,而不是数组:

@State partNames: string = ''      // "部件1,部件2,部件3"
@State partGridX: string = ''      // "0,1,2"
@State partGridY: string = ''      // "0,0,0"
@State partDepths: string = ''     // "0.3,0.6,0.9"

为什么要这样做?

ArkTS 的 @State 装饰器对数组的更新检测有问题——直接修改数组元素不会触发 UI 刷新,必须创建新数组赋值。但字符串是不可变类型,修改后直接赋值就能触发刷新。

所以这里把部件数据拆成四个字符串,用逗号分隔。需要时通过工具函数解析:

const names: string[] = parseStringList(this.partNames)
const gx: number[] = parseIntList(this.partGridX)
const gy: number[] = parseIntList(this.partGridY)
const depths: number[] = parseFloatList(this.partDepths)

这种"序列化存储"的方式在 ArkTS 中很实用——当数据结构需要作为 @State 传递时,先序列化成字符串,使用时再反序列化。

ExplodedView 组件

3D 爆炸视图是整个应用的核心组件:

ExplodedView({
  imageUri: this.imageUri,
  partNames: this.partNames,
  partGridX: this.partGridX,
  partGridY: this.partGridY,
  partDepths: this.partDepths,
  gridCols: this.gridCols,
  gridRows: this.gridRows,
  intensity: this.intensity,
  imgW: 360,
  imgH: 360
})
.width('90%').height('80%')

组件接收的参数:

  • imageUri:原始图片路径
  • partNames/partGridX/partGridY/partDepths:部件数据(字符串格式)
  • gridCols/gridRows:网格尺寸
  • intensity:爆炸力度(0~1)
  • imgW/imgH:图片显示尺寸

爆炸视图的渲染原理是:

  1. 把原始图片按网格切分成多个小块
  2. 每个小块根据 intensity 值向外偏移
  3. 偏移方向由网格位置决定(左上角的块向左上偏移,右下角的块向右下偏移)
  4. intensity = 0 时所有块在原始位置,intensity = 1 时所有块完全展开

爆炸力度控制

@Builder IntensityControl() {
  Column() {
    Row() {
      Text('爆炸力度')
        .fontSize(12).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY)
      Blank()
      Text(Math.round(this.intensity * 100) + '%')
        .fontSize(12).fontWeight(FontWeight.Bold).fontColor(ACCENT)
    }
    .width('100%').margin({ bottom: 6 })

    Slider({
      value: this.intensity * 100,
      min: 0, max: 100, step: 5,
      style: SliderStyle.OutSet
    })
    .blockColor(ACCENT)
    .trackColor('#F0ECF8')
    .selectedColor('#A78BFA')
    .trackThickness(6)
    .blockSize({ width: 24, height: 24 })
    .onChange((value: number) => { this.setIntensity(value / 100) })
    .width('100%')
  }
}

Slider 的值范围是 0~100(百分比),内部存储的 intensity 是 0~1(小数)。.onChange 回调中做了一次除以 100 的转换。

setIntensity 方法同时更新 intensityisPlaying

private setIntensity(val: number): void {
  this.intensity = val
  this.isPlaying = val > 0.05
}

当力度大于 5% 时自动标记为"播放中",这样顶部的播放按钮会切换成暂停图标。

网格切换控件

@Builder GridControl() {
  Row() {
    Text('网格').fontSize(12).fontColor(TEXT_SECONDARY).margin({ right: 10 })
    ForEach(['2×2', '3×2', '3×3', '4×3', '4×4'], (label: string) => {
      Text(label)
        .fontSize(11)
        .fontWeight(this.gridLabelMatch(label) ? FontWeight.Bold : FontWeight.Regular)
        .fontColor(this.gridLabelMatch(label) ? '#FFFFFF' : TEXT_SECONDARY)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .borderRadius(12)
        .backgroundColor(this.gridLabelMatch(label) ? ACCENT : '#F5F5F7')
        .margin({ right: 6 })
        .onClick(() => {
          const dims: number[] = this.parseGridLabel(label)
          this.changeGrid(dims[0], dims[1])
        })
    })
  }
}

选中态用紫色背景 + 白色文字,未选中用浅灰背景 + 灰色文字。gridLabelMatch 方法判断当前网格尺寸是否匹配。

切换网格时调用 changeGrid,它会重新计算部件数据:

private changeGrid(cols: number, rows: number): void {
  this.gridCols = cols
  this.gridRows = rows
  this.rebuildParts()
}

部件清单面板

@Builder PartListPanel() {
  Column() {
    Row() {
      Text('部件清单')
        .fontSize(14).fontWeight(FontWeight.Bold).fontColor(TEXT_PRIMARY)
      Blank()
      Text('✕')
        .fontSize(12).fontColor('#C0BFC6')
        .onClick(() => { this.showPartList = false })
    }
    .width('100%').margin({ bottom: 10 })

    Scroll() {
      Column({ space: 6 }) {
        ForEach(this.partCardData, (card: PartCardData) => {
          Row() {
            // 编号圆圈
            Column() {
              Text(card.index.toString())
                .fontSize(12).fontWeight(FontWeight.Bold).fontColor(ACCENT)
            }
            .width(28).height(28).borderRadius(14)
            .backgroundColor('#F4F1FC')
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)

            // 名称和位置
            Column() {
              Text(card.name)
                .fontSize(13).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY)
              Text('位置 (' + card.gx + ',' + card.gy + ') · 深度 ' + Math.round(card.depth * 100) + '%')
                .fontSize(10).fontColor(TEXT_SECONDARY)
            }
            .alignItems(HorizontalAlign.Start).margin({ left: 10 }).layoutWeight(1)

            // 深度条
            Row() {}
              .width(card.depth * 40).height(4).borderRadius(2)
              .backgroundColor(ACCENT + '60')
          }
          .width('100%').padding(10)
          .backgroundColor(CARD_BG).borderRadius(10)
          .border({ width: 1, color: '#F5F5F9' })
        })
      }
    }
    .scrollable(ScrollDirection.Vertical).scrollBar(BarState.Off)
    .height(180)
  }
}

每个部件卡片显示三部分信息:

  • 编号圆圈(紫色背景)
  • 名称、网格位置、深度百分比
  • 深度条(宽度和深度值成正比)

partCardData 通过 buildPartCards 方法从字符串数据构建:

private buildPartCards(): PartCardData[] {
  const names: string[] = parseStringList(this.partNames)
  const gx: number[] = parseIntList(this.partGridX)
  const gy: number[] = parseIntList(this.partGridY)
  const depths: number[] = parseFloatList(this.partDepths)
  const result: PartCardData[] = []
  for (let i: number = 0; i < names.length; i++) {
    result.push({
      index: i + 1,
      name: names[i],
      gx: i < gx.length ? (gx[i] + 1) : 1,
      gy: i < gy.length ? (gy[i] + 1) : 1,
      depth: i < depths.length ? depths[i] : 0.5
    })
  }
  return result
}

注意 gx[i] + 1 — 网格坐标从 0 开始,但显示给用户时从 1 开始更自然。

数据流设计

整个应用的数据流是单向的:

首页选择精度
  → openAnalysis(cols, rows, imgUri)
  → router.pushUrl 传参
  → AnalysisPage 接收参数
  → rebuildParts() 生成部件数据
  → ExplodedView 渲染爆炸视图
  → Slider 控制 intensity
  → ExplodedView 根据 intensity 更新偏移量

数据流的关键节点:

  1. 首页 → 分析页:通过路由参数传递网格尺寸和图片路径
  2. 分析页内部@State 变量驱动 UI 更新
  3. 分析页 → ExplodedView:Props 传递部件数据和爆炸力度

没有反向数据流——ExplodedView 是纯展示组件,不修改父组件的状态。这种单向数据流让状态变化可预测,调试也更方便。

踩坑记录

1. ForEach 的数据源稳定性

// ❌ 每次渲染都创建新数组,ForEach 会重新创建所有子组件
ForEach(this.makePresetGrid(cols, rows), ...)

// ✅ 预计算,数据源引用稳定
ForEach(this.presetGrid3x3, ...)

ArkTS 的 ForEach 用引用相等性判断数据是否变化。如果每次渲染都创建新数组,即使内容相同,ForEach 也会认为数据变了,重新创建所有子组件。

2. @State 数组更新不触发渲染

// ❌ 直接修改数组元素,UI 不刷新
this.partNames.push('新部件')

// ✅ 用字符串存储,赋值触发刷新
this.partNames = this.partNames + ',新部件'

ArkTS 的 @State 对数组的检测是引用级别的——修改元素不会触发重新渲染。解决方案有两个:创建新数组赋值,或者用字符串存储。

3. Slider 的值映射

Slider({ value: this.intensity * 100 })  // 内部 0~1 → 显示 0~100
.onChange((value: number) => { this.setIntensity(value / 100) })  // 显示 0~100 → 内部 0~1

Slider 的值域和业务逻辑的值域不一致时,需要在初始化和回调中做双向转换。

4. SymbolGlyph 的图标选择

SymbolGlyph($r('sys.symbol.chevron_left'))  // 返回箭头

HarmonyOS 的系统图标命名有规律:sys.symbol.{图标名},实心版本加 _fill 后缀。选图标前最好在 DevEco Studio 的预览里确认一下有没有这个图标。

写在最后

这个项目的核心思路是"网格切分 + 偏移渲染"——把一张图片按网格切开,每块根据力度值向外偏移,视觉上形成爆炸效果。原理不复杂,但 ArkTS 的 @State 数组更新和 ForEach 数据源稳定性这两个坑值得留意。

目前的爆炸效果是 2D 偏移,后续可以考虑加入真正的 3D 变换(透视、旋转),让爆炸图更有立体感。另外,图像切分目前是等分网格,如果能根据图片内容智能切分(边缘检测、物体识别),效果会更真实。

Logo

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

更多推荐