HarmonyOS 3D 拆解分析:拍照自动生成爆炸图的完整实现
拍一张照片,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
}
注意 maxR 和 maxC 的限制——最多只渲染 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:图片显示尺寸
爆炸视图的渲染原理是:
- 把原始图片按网格切分成多个小块
- 每个小块根据
intensity值向外偏移 - 偏移方向由网格位置决定(左上角的块向左上偏移,右下角的块向右下偏移)
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 方法同时更新 intensity 和 isPlaying:
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 更新偏移量
数据流的关键节点:
- 首页 → 分析页:通过路由参数传递网格尺寸和图片路径
- 分析页内部:
@State变量驱动 UI 更新 - 分析页 → 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 变换(透视、旋转),让爆炸图更有立体感。另外,图像切分目前是等分网格,如果能根据图片内容智能切分(边缘检测、物体识别),效果会更真实。
更多推荐

所有评论(0)