HarmonyOS趣味相机实战第14篇装饰素材强类型建模、锚点语义与套装组合校验摘要相机贴纸功能看起来像“选一张图片并叠到预览上”但素材数量增长后真正难维护的不是图片资源而是素材背后的业务语义猫耳应该跟随头顶眼镜应该跟随双眼翅膀应该位于人体后方边框只属于场景层氛围贴纸允许重复添加套装引用的素材不能失效。本文基于D:/APP/1quweixiangji趣味相机工程复盘DecorationModels.ets与DecorationLibrary.ets的设计。我们把贴纸从零散 UI 常量升级为强类型领域模型再补齐套装引用校验、层级排序、目标过滤和兼容迁移。最终目标不是做一个更大的数组而是建立一条可以持续扩展的素材数据链路。工程背景与源码定位文件责任本文关注点entry/src/main/ets/model/DecorationModels.ets装饰、人物、照片和文档模型联合类型、实体边界、可选字段entry/src/main/ets/service/DecorationLibrary.ets分类、单品和套装目录工厂函数、默认值、引用关系entry/src/main/ets/service/HumanFittingService.ets根据目标计算贴纸位置与缩放AnchorType的消费方式entry/src/main/ets/service/CoreVisionHumanService.ets输出人体关键点和轮廓DetectedTarget数据来源entry/src/main/ets/pages/Index.ets页面工作台与拍摄状态素材选择、预览和拍照快照的接入位置entry/src/main/module.json5模块、Ability 与权限声明Stage 模型运行边界环境与版本边界项目当前值说明开发语言ArkTS使用严格类型建模应用模型Stage 模型主模块类型为entrytarget SDK6.0.2(22)来自工程构建配置compatible SDK6.0.2(22)当前设备兼容基线设备类型phonemodule.json5当前只声明手机预览设计尺寸316 x 390人体拟合服务的坐标基准素材渲染形式符号和颜色占位可平滑替换为 Media 资源或 PixelMap一、不要把贴纸只建模成图片路径最小实现通常只有三个字段interfaceSticker{id:string;name:string;resource:Resource;}这个模型无法回答以下问题贴纸应该放在人物前面还是后面应该跟随头、眼睛、肩膀还是手没识别到人时是否仍可添加同一素材能否重复添加默认缩放和默认层级是多少它适用于人像、宠物、物体还是背景用户切换识别目标后是否继续跟随因此项目把一个装饰单品建模为完整领域对象exportinterfaceDecorationItem{id:string;name:string;category:DecorationCategory;symbol:string;color:string;anchor:AnchorType;defaultScale:number;defaultRotation:number;defaultLayer:number;placement:DecorationPlacement;followTarget:boolean;freeMove:boolean;multiInstance:boolean;targetTypes:TargetType[];}DecorationItem描述“素材定义”用户真正添加到画布后还需要一个实例模型exportinterfaceDecorationLayer{id:string;itemId:string;name:string;category:DecorationCategory;x:number;y:number;scale:number;rotation:number;layer:number;}两者不能合并。素材定义是只读目录数据图层实例保存用户对位置、角度、缩放和层级的修改。同一个气球素材可以创建多个DecorationLayer但它们仍引用同一个itemId。二、用联合类型封住非法分类分类不是任意字符串而是一个闭合集合exporttypeDecorationCategorypack|head|face|neck|body|hand|mood|text|frame|festival;相比category: string联合类型有三项直接收益拼写错误会在编译期暴露。switch分支更容易做穷尽检查。分类标签、筛选条件和素材数据共享同一语义。页面分类本身也使用类型约束exportinterfaceCategoryTab{key:DecorationCategory;label:string;}素材库返回十个分类staticcategories():CategoryTab[]{return[{key:pack,label:套装},{key:head,label:头部},{key:face,label:脸部},{key:neck,label:颈肩},{key:body,label:身体},{key:hand,label:手持},{key:mood,label:氛围},{key:text,label:文字},{key:frame,label:边框},{key:festival,label:节日}];}需要注意pack是页面入口分类不是单品的自然类别。生产代码可以进一步拆成DecorationItemCategory与DecorationTabKey避免把套装误当作可以直接创建图层的单品。三、锚点是素材与识别结果之间的协议素材目录不应该知道人体识别 API 的具体字段它只声明希望绑定的语义锚点exporttypeAnchorTypeheadTop|eyes|cheeks|noseMouth|neck|shoulder|bodyCenter|hand|around|fullFrame;这些值可以分为三类锚点类型例子数据来源面部锚点headTop、eyes、cheeks、noseMouth面部元数据、眼鼻关键点或估算框身体锚点neck、shoulder、bodyCenter、hand骨骼关键点、分割轮廓或人体框场景锚点around、fullFrame用户位置或画布固定区域猫耳定义只需要表达“跟随头顶”DecorationLibrary.item(cat_ears,猫耳,head,^ ^,#FF9AC8,headTop,1.0,18,true)而HumanFittingService负责把headTop转换为预览坐标。这样识别算法可以从矩形框升级到关键点素材目录不需要改动。四、前景、后景和场景层不能只靠数字猜项目显式定义三种放置语义exporttypeDecorationPlacementbehindBody|frontBody|scene;defaultLayer仍然有价值但它只解决同一放置区域内的排序。placement解决的是渲染阶段场景底图 - behindBody 装饰 - 人像或相机画面 - frontBody 装饰 - scene 氛围、文字和边框 - 交互控制框例如翅膀必须在身体后面DecorationLibrary.item(angel_wings,天使翅膀,body,wings,#F7FAFF,shoulder,1.18,10,true,behindBody)边框属于整个场景DecorationLibrary.item(polaroid_frame,拍立得框,frame,frame,#FFFFFF,fullFrame,1.0,80,false,scene)如果只依赖10和80这样的魔法数字未来加入遮罩、抠图或多人层时很容易错层。语义分组加组内数字排序更稳定。五、工厂函数统一默认策略素材数量达到几十个后直接书写完整对象既冗长又容易漏字段。项目使用私有工厂函数统一默认值privatestaticitem(id:string,name:string,category:DecorationCategory,symbol:string,color:string,anchor:AnchorType,defaultScale:number,defaultLayer:number,followTarget:boolean,placement:DecorationPlacementfrontBody):DecorationItem{return{id,name,category,symbol,color,anchor,defaultScale,defaultRotation:0,defaultLayer,placement,followTarget,freeMove:true,multiInstance:categorymood||categorytext||categoryhand,targetTypes:anchorfullFrame||anchoraround?[portrait,pet,object,background]:[portrait]};}这里把三条产品规则集中起来默认允许自由移动。氛围、文字和手持素材允许多实例。全画幅或环绕素材适配所有目标其余素材默认只适配人像。集中默认值的前提是规则稳定。如果后续某个手持素材只能出现一次应给工厂增加可选策略参数而不是在调用后修改对象。六、把目标适配放进数据层目标类型也是联合类型exporttypeTargetTypeportrait|pet|object|background;UI 筛选可以直接使用targetTypesfunctionavailableFor(items:DecorationItem[],targetType:TargetType):DecorationItem[]{returnitems.filter((item:DecorationItem){returnitem.targetTypes.includes(targetType);});}这比在页面中硬编码“宠物模式隐藏眼镜、显示边框”更可维护。素材运营数据发生变化时不需要修改 ArkUI 组件逻辑。识别目标为空时也要有明确策略functioncanAddWithoutTarget(item:DecorationItem):boolean{return!item.followTarget||item.anchoraround||item.anchorfullFrame;}这样用户即使没有进入识别人像也可以添加文字、边框和氛围元素。七、单品与图层实例的转换添加素材时不要把目录对象直接塞进State后修改。应创建独立实例functioncreateLayer(item:DecorationItem,sequence:number):DecorationLayer{return{id:${item.id}_${Date.now()}_${sequence},itemId:item.id,name:item.name,category:item.category,x:126,y:148,scale:item.defaultScale,rotation:item.defaultRotation,layer:item.defaultLayersequence};}添加前先校验重复策略functioncanCreateLayer(item:DecorationItem,layers:DecorationLayer[]):boolean{if(item.multiInstance){returntrue;}return!layers.some((layer:DecorationLayer){returnlayer.itemIditem.id;});}实例 ID 不能只使用item.id否则多实例素材会覆盖。生产环境可使用单调序列或 UUID时间戳加序号适合本地单进程应用。八、套装是引用集合不是复制的单品列表套装模型只保存 IDexportinterfaceDecorationPack{id:string;name:string;summary:string;itemIds:string[];}例如生日派对{id:birthday,name:生日派对,summary:生日帽、气球、礼物盒、Happy,itemIds:[birthday_hat,balloon,gift_box,happy_text]}只保存 ID 有两个好处单品名称、颜色或默认缩放更新后套装自动获得新配置持久化体积也更小。代价是必须校验引用完整性。解析套装时应返回明确结果interfacePackResolution{items:DecorationItem[];missingItemIds:string[];}functionresolvePack(pack:DecorationPack,items:DecorationItem[]):PackResolution{constitemMap:Mapstring,DecorationItemnewMap(items.map((item:DecorationItem)[item.id,item]));constresolved:DecorationItem[][];constmissing:string[][];pack.itemIds.forEach((itemId:string){constitem:DecorationItem|undefineditemMap.get(itemId);if(item){resolved.push(item);}else{missing.push(itemId);}});return{items:resolved,missingItemIds:missing};}不要静默忽略缺失引用。开发版可以阻止启动或输出错误日志正式版则跳过坏数据并让套装入口不可用。九、启动期执行目录一致性校验静态数据也会出错至少需要检查以下规则规则失败影响单品 ID 唯一按 ID 查找结果不确定套装 ID 唯一页面 key 冲突套装引用全部存在点击套装后缺少元素分类存在对应 Tab单品无法被筛选到默认缩放大于 0图层不可见或镜像异常默认层级在约定范围遮挡关系不可控颜色格式合法ArkUI 渲染失败或回退跟随型素材不能使用场景锚点语义互相冲突可以实现一个纯函数校验器functionvalidateLibrary(items:DecorationItem[],packs:DecorationPack[],tabs:CategoryTab[]):string[]{consterrors:string[][];constitemIds:SetstringnewSetstring();consttabKeys:SetDecorationCategorynewSet(tabs.map((tab:CategoryTab)tab.key));items.forEach((item:DecorationItem){if(itemIds.has(item.id)){errors.push(重复素材ID:${item.id});}itemIds.add(item.id);if(!tabKeys.has(item.category)){errors.push(缺少分类页签:${item.category});}if(item.defaultScale0){errors.push(非法缩放:${item.id});}});packs.forEach((pack:DecorationPack){pack.itemIds.forEach((itemId:string){if(!itemIds.has(itemId)){errors.push(套装${pack.id}引用了不存在的${itemId});}});});returnerrors;}校验器不依赖 UIAbility、相机和网络可以放入本地单元测试。十、套装批量添加要保证原子性用户点击套装时不能添加一半后才发现某个单品失败。正确流程是解析套装引用 - 检查缺失 ID - 检查目标类型 - 检查单实例冲突 - 计算每个初始锚点 - 一次性生成新图层数组 - 赋值给页面状态ArkUI 状态更新可以使用新数组functionappendPackLayers(current:DecorationLayer[],items:DecorationItem[]):DecorationLayer[]{constadditions:DecorationLayer[][];items.forEach((item:DecorationItem,index:number){if(canCreateLayer(item,current.concat(additions))){additions.push(createLayer(item,index));}});returncurrent.concat(additions);}不要在循环中连续修改同一个State数组。一次赋值更容易触发稳定刷新也方便撤销操作保存一份前置快照。十一、持久化只保存稳定字段图层存档不应复制整个DecorationItem。推荐保存interfaceStoredDecorationLayer{schemaVersion:number;id:string;itemId:string;x:number;y:number;scale:number;rotation:number;layer:number;}恢复时按itemId合并最新目录定义。这样素材更名或换图不会破坏旧作品而位置和缩放仍保留。若素材已下线可以保留一个兼容别名映射。使用占位素材并提示“原素材已下线”。忽略该层但记录迁移日志。必须加入schemaVersion否则未来增加锚点偏移、透明度和翻转字段时很难区分旧数据。十二、ArkUI 列表渲染要使用稳定 key分类列表以素材 ID 为 keyForEach(items,(item:DecorationItem){// build item card},(item:DecorationItem)item.id)画布图层以实例 ID 为 keyForEach(layers,(layer:DecorationLayer){// build movable layer},(layer:DecorationLayer)layer.id)如果画布仍使用itemId两个气球实例会被 ArkUI 当成同一个节点出现复用错位、删除错误或手势状态串联。排序时不要原地修改状态数组functionorderedLayers(layers:DecorationLayer[]):DecorationLayer[]{returnlayers.slice().sort((left:DecorationLayer,right:DecorationLayer)left.layer-right.layer);}十三、测试目录数据而不是测试 UI 文案适合自动化的测试包括describe(DecorationLibrary,(){it(has unique item ids,(){constitemsDecorationLibrary.items();constidsnewSet(items.map(itemitem.id));expect(ids.size).assertEqual(items.length);});it(resolves every pack reference,(){constitemsDecorationLibrary.items();consterrorsvalidateLibrary(items,DecorationLibrary.packs(),DecorationLibrary.categories());expect(errors.length).assertEqual(0);});});还应覆盖这些边界同一个非多实例素材第二次添加被拒绝。氛围素材允许创建多个不同实例 ID。人像素材在pet目标下不可选。fullFrame素材在没有识别目标时仍可添加。套装中存在坏 ID 时不产生半套图层。排序函数不修改输入数组。十四、常见问题排查现象可能原因排查方式分类里看不到新增素材category没有对应 Tab校验categories()与items()点击套装少一个贴纸itemIds拼写错误运行引用完整性校验翅膀盖住人物placement使用了frontBody检查渲染阶段和放置语义两个气球互相覆盖状态图层 key 使用itemId改用实例id宠物模式出现人脸眼镜targetTypes默认值过宽按目标类型过滤目录贴纸添加后尺寸为零defaultScale非法启动期检查正数范围旧作品恢复后素材丢失目录 ID 被直接改名增加别名迁移表页面刷新不及时原地修改State数组创建新数组后一次赋值十五、上线前验收清单标题、分类和素材 ID 均稳定且唯一。每个套装引用的单品都存在。每个素材都有明确锚点和放置层。behindBody、frontBody、scene渲染顺序正确。多实例素材使用独立实例 ID。无识别目标时仍可添加场景素材。目标类型筛选不会误伤边框和文字。默认缩放、旋转和层级处于约定范围。ArkUI 列表使用稳定 key。套装添加失败时不会留下半套图层。存档带schemaVersion并可迁移旧 ID。数据校验函数已纳入单元测试。正式包不会输出用户图像或素材选择隐私数据。总结可扩展的贴纸系统首先是数据建模问题其次才是视觉渲染问题。DecorationItem描述素材能力DecorationLayer保存用户实例AnchorType连接识别算法DecorationPlacement表达前后景语义DecorationPack通过稳定 ID 组合单品。当这些规则进入类型、工厂函数和启动期校验后新增素材不再需要到多个页面分支里补条件。素材目录、人体拟合、ArkUI 渲染和作品持久化之间形成清晰协议后续接入真实 PNG、动态贴纸或云端素材包时也有稳定的扩展基础。