前言
之前的拆解流程是:拍照 → 网格切分 → 爆炸展示。部件名称是程序根据网格位置自动生成的("部件 1"“部件 2”),没有真正的语义信息。
这次迭代做了两件事:
- 接入 AI 分析服务——拍照后自动识别物品名称和部件名称
- 加入粒子场动效——工作区背景不再是纯色,有了氛围感
新增服务: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}}核心逻辑:
- 用 mock 的 base64 图片调用
analyzeImage - 返回
AiAnalysisResult,包含success、objectName、parts、errorMessage - 成功时:用 AI 返回的部件名称替换程序生成的名称
- 失败时:显示错误信息
为什么需要"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 返回的信息:
success:分析是否成功objectName:识别出的物品名称parts:部件名称列表errorMessage:失败原因
当前的 mock 实现:
analyzeImage传入的是'mock_image_data'——一个占位字符串。真正的实现需要:
- 将图片编码为 base64
- 调用 AI 视觉识别 API
- 解析返回结果
粒子场: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 或网格预览)之间。
为什么用粒子场?
- 氛围感:纯色背景太"死",粒子场给工作区一种"科技感"
- 深度暗示:粒子的运动暗示了 3D 空间的存在
- 视觉焦点:运动的粒子会吸引用户的注意力到工作区
粒子场的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})变化:
- 纯白 → 微妙渐变(#FCFBFE → #F8F6FC → #FCFBFE),上下略浅,中间略深
- 边框从 1px 灰色 → 1.5px 淡紫色,和主题色呼应
- 阴影从黑色 → 紫色,更柔和
- 新增粒子场
空状态的数字展示
// 旧版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})}变化:
- 📸 emoji → 大号渐变数字(44px,紫色渐变)
- 文字更简洁:“部件就绪” + “点击 ▶ 爆炸 开始拆解”
为什么用数字替代 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)}变化:
- 新增
bg参数——背景色 - 文字颜色从 T2(灰色)→
color(主题色),和圆点颜色一致 - 加了 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)})}三个按钮:
- AI 分析:调用 AI 识别部件名称
- 部件清单:展开/收起部件列表
- 重置:力度归零 + 网格恢复 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=updatedArkTS 的@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.013. ParticleField 的性能
粒子场是持续动画——即使用户不在拆解页,粒子也在跑。如果粒子数量多或动画复杂,可能影响电池续航。
建议:
- 只在拆解页(tab=1)时启用粒子动画
- 其他 Tab 时暂停动画
4. AI 分析的 mock 数据
constmockBase64:string='mock_image_data'constresult:AiAnalysisResult=awaitanalyzeImage(mockBase64)当前传入的是假数据。真正的实现需要:
- 从
this.uri读取图片 - 编码为 base64
- 调用 AI API
- 处理超时和错误
在真正接入 AI 之前,建议在AiAnalysisService里做好 mock 返回,方便前端调试。
写在最后
AI 分析的接入让拆解从"程序自动命名"升级到"智能识别命名"。用户拍一张 iPhone 的照片,AI 能告诉它"这是 iPhone 15,屏幕、电池、摄像头、主板……"——这比"部件 1、部件 2"有用得多。
粒子场的加入让工作区有了"呼吸感"。虽然只是视觉效果,但它让整个 App 从"工具"变成了"产品"——用户能感受到开发者在细节上花了心思。
当前的 AI 分析还是 mock 状态。后续接入真实的 AI 服务后,这个功能会成为 App 的核心卖点:拍照 → AI 识别 → 爆炸展示 → 分享给朋友。