HarmonyOS 3D 拆解分析:AI 分析接入

HarmonyOS 3D 拆解分析:AI 分析接入

前言

之前的拆解流程是:拍照 → 网格切分 → 爆炸展示。部件名称是程序根据网格位置自动生成的("部件 1"“部件 2”),没有真正的语义信息。

这次迭代做了两件事:

  1. 接入 AI 分析服务——拍照后自动识别物品名称和部件名称
  2. 加入粒子场动效——工作区背景不再是纯色,有了氛围感

新增服务:AiAnalysisService

import 变化

import{ParticleField}from'../components/ParticleField'import{analyzeImage,AiAnalysisResult}from'../services/AiAnalysisService'

两个新组件:

  • ParticleField:粒子场背景组件
  • analyzeImage+AiAnalysisResult:AI 图像分析服务

AI 分析的状态管理

@StateaiLoading:boolean=false@StateaiResult:string=''

两个状态变量:

  • aiLoading:AI 是否在分析中
  • aiResult:分析结果的文字描述

AI 分析方法

privateasyncrunAiAnalysis():Promise<void>{this.aiLoading=true;this.aiResult=''constmockBase64:string='mock_image_data'constresult:AiAnalysisResult=awaitanalyzeImage(mockBase64)this.aiLoading=falseif(result.success&&result.parts.length>0){this.aiResult=result.objectName+' · '+result.parts.length+' 部件'constmaxParts:number=result.parts.length<this.pc.length?result.parts.length:this.pc.lengthfor(leti:number=0;i<maxParts;i++){this.pc[i].n=result.parts[i].name}// Force state refreshconstupdated:PCard[]=[]for(letj:number=0;j<this.pc.length;j++){updated.push(this.pc[j])}this.pc=updated}else{this.aiResult='AI 分析失败: '+result.errorMessage}}

核心逻辑:

  1. 用 mock 的 base64 图片调用analyzeImage
  2. 返回AiAnalysisResult,包含successobjectNamepartserrorMessage
  3. 成功时:用 AI 返回的部件名称替换程序生成的名称
  4. 失败时:显示错误信息

为什么需要"force state refresh"?

constupdated:PCard[]=[]for(letj:number=0;j<this.pc.length;j++){updated.push(this.pc[j])}this.pc=updated

直接修改this.pc[i].n不会触发 ArkUI 的状态更新——因为@State变量的引用没有变化。需要创建一个新数组赋值给this.pc,才能让 UI 刷新。

这是 ArkTS 状态管理的一个常见陷阱:修改数组元素不会触发重新渲染,必须替换整个数组。

AiAnalysisResult 的数据结构

interfaceAiAnalysisResult{success:booleanobjectName:string// "iPhone 15"parts:PartName[]// [{ name: "屏幕" }, { name: "电池" }, ...]errorMessage:string// 失败时的错误信息}interfacePartName{name:string}

AI 返回的信息:

  1. success:分析是否成功
  2. objectName:识别出的物品名称
  3. parts:部件名称列表
  4. errorMessage:失败原因

当前的 mock 实现:

analyzeImage传入的是'mock_image_data'——一个占位字符串。真正的实现需要:

  1. 将图片编码为 base64
  2. 调用 AI 视觉识别 API
  3. 解析返回结果

粒子场:ParticleField

// Workspace 背景Stack({alignContent:Alignment.Center}){Column(){}.width('100%').height('100%').borderRadius(18).linearGradient({angle:180,colors:[['#FCFBFE',0],['#F8F6FC',0.5],['#FCFBFE',1]]}).border({width:1.5,color:'#E0DAF0'}).shadow({radius:16,color:'rgba(108,60,224,0.06)',offsetY:4})// Native particle fieldParticleField({color:A})// ... ExplodedView or placeholder ...}

ParticleField放在背景层(渐变色卡片)和内容层(ExplodedView 或网格预览)之间。

为什么用粒子场?

  1. 氛围感:纯色背景太"死",粒子场给工作区一种"科技感"
  2. 深度暗示:粒子的运动暗示了 3D 空间的存在
  3. 视觉焦点:运动的粒子会吸引用户的注意力到工作区

粒子场的color参数传入主题色A(#8A5DFA),粒子颜色和整体紫色主题一致。

工作区的视觉重设计

旧版:纯白背景

Column(){}.width('100%').height('100%').borderRadius(18).backgroundColor('#FFFFFF').border({width:1,color:'#D8D2E8'}).shadow({radius:12,color:'rgba(0,0,0,0.06)',offsetY:3})

新版:渐变背景 + 粒子场

Column(){}.width('100%').height('100%').borderRadius(18).linearGradient({angle:180,colors:[['#FCFBFE',0],['#F8F6FC',0.5],['#FCFBFE',1]]}).border({width:1.5,color:'#E0DAF0'}).shadow({radius:16,color:'rgba(108,60,224,0.06)',offsetY:4})ParticleField({color:A})

变化:

  1. 纯白 → 微妙渐变(#FCFBFE → #F8F6FC → #FCFBFE),上下略浅,中间略深
  2. 边框从 1px 灰色 → 1.5px 淡紫色,和主题色呼应
  3. 阴影从黑色 → 紫色,更柔和
  4. 新增粒子场

空状态的数字展示

// 旧版Text('📸').fontSize(36).opacity(0.2).margin({bottom:12})Text(this.name).fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)Text(this.count+' 部件 · 点击 [▶ 爆炸] 查看').fontSize(12).fontColor(T3)// 新版Column(){Text(this.count.toString()).fontSize(44).fontWeight(FontWeight.Bold).linearGradient({angle:135,colors:[[A,0],['#C4B5FD',1]]})Text('部件就绪').fontSize(14).fontWeight(FontWeight.Medium).fontColor(T2).margin({top:4})Text('点击 ▶ 爆炸 开始拆解').fontSize(11).fontColor(T3).margin({top:6})}

变化:

  1. 📸 emoji → 大号渐变数字(44px,紫色渐变)
  2. 文字更简洁:“部件就绪” + “点击 ▶ 爆炸 开始拆解”

为什么用数字替代 emoji?

数字(比如"9")比 emoji 更直接地传达了"有多少部件"的信息。用户看到"9"就知道"这个物品会被切成 9 块",比看到一个 📸 更有用。

Header 的 Tag 组件

旧版:Dot

@BuilderDot(color:string,text:string){Row(){Row(){}.width(5).height(5).borderRadius(3).backgroundColor(color)Text(' '+text).fontSize(10).fontColor(T2)}}

新版:Tag

@BuilderTag(color:string,bg:string,text:string){Row(){Row(){}.width(5).height(5).borderRadius(3).backgroundColor(color)Text(' '+text).fontSize(10).fontWeight(FontWeight.Medium).fontColor(color)}.padding({left:7,right:7,top:3,bottom:3}).backgroundColor(bg).borderRadius(6)}

变化:

  1. 新增bg参数——背景色
  2. 文字颜色从 T2(灰色)→color(主题色),和圆点颜色一致
  3. 加了 padding 和背景色——从"裸文字"变成了"胶囊标签"

调用方式:

// 旧版this.Dot(A,this.count+' 部件')this.Dot(A3,this.cols+'×'+this.rows+' 网格')// 新版this.Tag(A,'#F4F1FC',this.count+'部件')this.Tag('#00B894','#E8F8F5',this.cols+'×'+this.rows)

Tag 更醒目——有了背景色和主题色文字,用户一眼就能看到"9 部件"“3×3”。

QuickBtn:快捷力度按钮

@BuilderQuickBtn(label:string,action:()=>void,active:boolean,color:string){Text(label).fontSize(11).fontWeight(FontWeight.Medium).fontColor(active?'#FFFFFF':T2).padding({left:14,right:14,top:5,bottom:5}).borderRadius(12).backgroundColor(active?color:'#F5F5F7').onClick(action)}

三个预设力度:0%、50%、100%。

Row({space:6}){this.QuickBtn('0%',()=>{this.setV(0)},this.val===0,A)this.QuickBtn('50%',()=>{this.setV(0.5)},this.val===0.5,'#FECA57')this.QuickBtn('100%',()=>{this.setV(1)},this.val===1,'#FF6B6B')Row(){}.width(1).height(18).backgroundColor('#E8E4F0').margin({left:4,right:4})ForEach(this.gps,...)}

为什么加 QuickBtn?

Slider 适合精细调节,但有时候用户只是想"看一下完全展开的效果"。QuickBtn 提供了一键设置:点"100%"直接爆炸到最大,点"0%"直接收起。

QuickBtn 和网格选择器放在同一行,用一个竖线分隔:

[0%] [50%] [100%] | [2×2] [3×2] [3×3] [4×3] [4×4]

左边是力度,右边是网格——一行搞定所有控制。

底部操作栏

Row(){// AI 分析按钮Row(){Text('🤖').fontSize(13).margin({right:2})Text(this.aiLoading?'分析中...':'AI 分析').fontSize(11).fontWeight(FontWeight.Medium).fontColor(this.aiLoading?T3:A)}.padding({left:12,right:12,top:6,bottom:6}).backgroundColor(this.aiLoading?'#F5F5F7':AL).borderRadius(12).onClick(()=>{if(!this.aiLoading){this.runAiAnalysis()}})// 部件清单按钮Row(){Text('☰').fontSize(12).fontColor(this.list?A:T3)Text(' 部件').fontSize(11).fontWeight(this.list?FontWeight.Medium:FontWeight.Regular).fontColor(this.list?A:T3)}.padding({left:12,right:12,top:6,bottom:6}).backgroundColor(this.list?AL:'#F5F5F7').borderRadius(12).margin({left:6}).onClick(()=>{this.list=!this.list})Blank()// 重置按钮Row(){Text('🔄 重置').fontSize(11).fontColor(T2)}.padding({left:12,right:12,top:6,bottom:6}).backgroundColor('#F5F5F7').borderRadius(12).onClick(()=>{this.setV(0);this.cg(3,3)})}

三个按钮:

  1. AI 分析:调用 AI 识别部件名称
  2. 部件清单:展开/收起部件列表
  3. 重置:力度归零 + 网格恢复 3×3

为什么重置按钮单独放?

重置是一个"破坏性"操作——会丢失当前的力度和网格设置。放在右边、用灰色文字,降低误触风险。

AI 结果指示器

if(this.aiResult.length>0){Row(){Row(){}.width(6).height(6).borderRadius(3).backgroundColor(this.aiLoading?'#FECA57':'#00B894')Text(' '+this.aiResult).fontSize(11).fontColor(T2)}.width('100%').padding({left:18,right:18,top:2,bottom:2})}

AI 分析完成后,在操作栏下方显示结果:

  • 黄色圆点 + “分析中…”(加载中)
  • 绿色圆点 + “iPhone 15 · 12 部件”(成功)
  • 绿色圆点 + “AI 分析失败: …”(失败)

踩坑记录

1. ArkTS 的状态刷新陷阱

// 直接修改数组元素——不会触发 UI 更新this.pc[i].n=result.parts[i].name// 必须创建新数组——才能触发 UI 更新constupdated:PCard[]=[]for(letj:number=0;j<this.pc.length;j++){updated.push(this.pc[j])}this.pc=updated

ArkTS 的@State是引用类型。修改数组内部属性不会改变引用,所以不会触发重新渲染。必须创建新数组替换整个引用。

2. QuickBtn 的 active 判断

this.QuickBtn('50%',()=>{this.setV(0.5)},this.val===0.5,'#FECA57')

this.val === 0.5是精确比较。但val是从 Slider 的v / 100计算的,可能存在浮点精度问题。比如用户拖到 50%,val可能是 0.4999999999 而不是 0.5。

建议用范围判断:

active:Math.abs(this.val-0.5)<0.01

3. ParticleField 的性能

粒子场是持续动画——即使用户不在拆解页,粒子也在跑。如果粒子数量多或动画复杂,可能影响电池续航。

建议:

  • 只在拆解页(tab=1)时启用粒子动画
  • 其他 Tab 时暂停动画

4. AI 分析的 mock 数据

constmockBase64:string='mock_image_data'constresult:AiAnalysisResult=awaitanalyzeImage(mockBase64)

当前传入的是假数据。真正的实现需要:

  1. this.uri读取图片
  2. 编码为 base64
  3. 调用 AI API
  4. 处理超时和错误

在真正接入 AI 之前,建议在AiAnalysisService里做好 mock 返回,方便前端调试。

写在最后

AI 分析的接入让拆解从"程序自动命名"升级到"智能识别命名"。用户拍一张 iPhone 的照片,AI 能告诉它"这是 iPhone 15,屏幕、电池、摄像头、主板……"——这比"部件 1、部件 2"有用得多。

粒子场的加入让工作区有了"呼吸感"。虽然只是视觉效果,但它让整个 App 从"工具"变成了"产品"——用户能感受到开发者在细节上花了心思。

当前的 AI 分析还是 mock 状态。后续接入真实的 AI 服务后,这个功能会成为 App 的核心卖点:拍照 → AI 识别 → 爆炸展示 → 分享给朋友。