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 模型 | 主模块类型为 entry |
| target SDK | 6.0.2(22) |
来自工程构建配置 |
| compatible SDK | 6.0.2(22) |
当前设备兼容基线 |
| 设备类型 | phone | module.json5 当前只声明手机 |
| 预览设计尺寸 | 316 x 390 |
人体拟合服务的坐标基准 |
| 素材渲染形式 | 符号和颜色占位 | 可平滑替换为 Media 资源或 PixelMap |

一、不要把贴纸只建模成图片路径
最小实现通常只有三个字段:
ts
interface Sticker {
id: string;
name: string;
resource: Resource;
}
这个模型无法回答以下问题:
- 贴纸应该放在人物前面还是后面?
- 应该跟随头、眼睛、肩膀还是手?
- 没识别到人时是否仍可添加?
- 同一素材能否重复添加?
- 默认缩放和默认层级是多少?
- 它适用于人像、宠物、物体还是背景?
- 用户切换识别目标后是否继续跟随?
因此,项目把一个装饰单品建模为完整领域对象:
ts
export interface DecorationItem {
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 描述"素材定义",用户真正添加到画布后还需要一个实例模型:
ts
export interface DecorationLayer {
id: string;
itemId: string;
name: string;
category: DecorationCategory;
x: number;
y: number;
scale: number;
rotation: number;
layer: number;
}
两者不能合并。素材定义是只读目录数据,图层实例保存用户对位置、角度、缩放和层级的修改。同一个气球素材可以创建多个 DecorationLayer,但它们仍引用同一个 itemId。
二、用联合类型封住非法分类
分类不是任意字符串,而是一个闭合集合:
ts
export type DecorationCategory =
'pack' | 'head' | 'face' | 'neck' | 'body' |
'hand' | 'mood' | 'text' | 'frame' | 'festival';
相比 category: string,联合类型有三项直接收益:
- 拼写错误会在编译期暴露。
switch分支更容易做穷尽检查。- 分类标签、筛选条件和素材数据共享同一语义。
页面分类本身也使用类型约束:
ts
export interface CategoryTab {
key: DecorationCategory;
label: string;
}
素材库返回十个分类:
ts
static categories(): 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 的具体字段,它只声明希望绑定的语义锚点:
ts
export type AnchorType =
'headTop' | 'eyes' | 'cheeks' | 'noseMouth' |
'neck' | 'shoulder' | 'bodyCenter' | 'hand' |
'around' | 'fullFrame';
这些值可以分为三类:
| 锚点类型 | 例子 | 数据来源 |
|---|---|---|
| 面部锚点 | headTop、eyes、cheeks、noseMouth |
面部元数据、眼鼻关键点或估算框 |
| 身体锚点 | neck、shoulder、bodyCenter、hand |
骨骼关键点、分割轮廓或人体框 |
| 场景锚点 | around、fullFrame |
用户位置或画布固定区域 |
猫耳定义只需要表达"跟随头顶":
ts
DecorationLibrary.item(
'cat_ears',
'猫耳',
'head',
'^ ^',
'#FF9AC8',
'headTop',
1.0,
18,
true
)
而 HumanFittingService 负责把 headTop 转换为预览坐标。这样识别算法可以从矩形框升级到关键点,素材目录不需要改动。
四、前景、后景和场景层不能只靠数字猜
项目显式定义三种放置语义:
ts
export type DecorationPlacement =
'behindBody' | 'frontBody' | 'scene';
defaultLayer 仍然有价值,但它只解决同一放置区域内的排序。placement 解决的是渲染阶段:
text
场景底图
-> behindBody 装饰
-> 人像或相机画面
-> frontBody 装饰
-> scene 氛围、文字和边框
-> 交互控制框
例如翅膀必须在身体后面:
ts
DecorationLibrary.item(
'angel_wings', '天使翅膀', 'body', 'wings',
'#F7FAFF', 'shoulder', 1.18, 10, true, 'behindBody'
)
边框属于整个场景:
ts
DecorationLibrary.item(
'polaroid_frame', '拍立得框', 'frame', 'frame',
'#FFFFFF', 'fullFrame', 1.0, 80, false, 'scene'
)
如果只依赖 10 和 80 这样的魔法数字,未来加入遮罩、抠图或多人层时很容易错层。语义分组加组内数字排序更稳定。
五、工厂函数统一默认策略
素材数量达到几十个后,直接书写完整对象既冗长又容易漏字段。项目使用私有工厂函数统一默认值:
ts
private static item(
id: string,
name: string,
category: DecorationCategory,
symbol: string,
color: string,
anchor: AnchorType,
defaultScale: number,
defaultLayer: number,
followTarget: boolean,
placement: DecorationPlacement = 'frontBody'
): DecorationItem {
return {
id,
name,
category,
symbol,
color,
anchor,
defaultScale,
defaultRotation: 0,
defaultLayer,
placement,
followTarget,
freeMove: true,
multiInstance:
category === 'mood' || category === 'text' || category === 'hand',
targetTypes: anchor === 'fullFrame' || anchor === 'around'
? ['portrait', 'pet', 'object', 'background']
: ['portrait']
};
}
这里把三条产品规则集中起来:
- 默认允许自由移动。
- 氛围、文字和手持素材允许多实例。
- 全画幅或环绕素材适配所有目标,其余素材默认只适配人像。
集中默认值的前提是规则稳定。如果后续某个手持素材只能出现一次,应给工厂增加可选策略参数,而不是在调用后修改对象。
六、把目标适配放进数据层
目标类型也是联合类型:
ts
export type TargetType =
'portrait' | 'pet' | 'object' | 'background';
UI 筛选可以直接使用 targetTypes:
ts
function availableFor(
items: DecorationItem[],
targetType: TargetType
): DecorationItem[] {
return items.filter((item: DecorationItem) => {
return item.targetTypes.includes(targetType);
});
}
这比在页面中硬编码"宠物模式隐藏眼镜、显示边框"更可维护。素材运营数据发生变化时,不需要修改 ArkUI 组件逻辑。
识别目标为空时也要有明确策略:
ts
function canAddWithoutTarget(item: DecorationItem): boolean {
return !item.followTarget ||
item.anchor === 'around' ||
item.anchor === 'fullFrame';
}
这样用户即使没有进入识别人像,也可以添加文字、边框和氛围元素。
七、单品与图层实例的转换
添加素材时,不要把目录对象直接塞进 @State 后修改。应创建独立实例:
ts
function createLayer(
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.defaultLayer + sequence
};
}
添加前先校验重复策略:
ts
function canCreateLayer(
item: DecorationItem,
layers: DecorationLayer[]
): boolean {
if (item.multiInstance) {
return true;
}
return !layers.some((layer: DecorationLayer) => {
return layer.itemId === item.id;
});
}
实例 ID 不能只使用 item.id,否则多实例素材会覆盖。生产环境可使用单调序列或 UUID;时间戳加序号适合本地单进程应用。
八、套装是引用集合,不是复制的单品列表
套装模型只保存 ID:
ts
export interface DecorationPack {
id: string;
name: string;
summary: string;
itemIds: string[];
}
例如生日派对:
ts
{
id: 'birthday',
name: '生日派对',
summary: '生日帽、气球、礼物盒、Happy',
itemIds: ['birthday_hat', 'balloon', 'gift_box', 'happy_text']
}
只保存 ID 有两个好处:单品名称、颜色或默认缩放更新后,套装自动获得新配置;持久化体积也更小。代价是必须校验引用完整性。
解析套装时应返回明确结果:
ts
interface PackResolution {
items: DecorationItem[];
missingItemIds: string[];
}
function resolvePack(
pack: DecorationPack,
items: DecorationItem[]
): PackResolution {
const itemMap: Map<string, DecorationItem> =
new Map(items.map((item: DecorationItem) => [item.id, item]));
const resolved: DecorationItem[] = [];
const missing: string[] = [];
pack.itemIds.forEach((itemId: string) => {
const item: DecorationItem | undefined = itemMap.get(itemId);
if (item) {
resolved.push(item);
} else {
missing.push(itemId);
}
});
return { items: resolved, missingItemIds: missing };
}
不要静默忽略缺失引用。开发版可以阻止启动或输出错误日志,正式版则跳过坏数据并让套装入口不可用。
九、启动期执行目录一致性校验
静态数据也会出错,至少需要检查以下规则:
| 规则 | 失败影响 |
|---|---|
| 单品 ID 唯一 | 按 ID 查找结果不确定 |
| 套装 ID 唯一 | 页面 key 冲突 |
| 套装引用全部存在 | 点击套装后缺少元素 |
| 分类存在对应 Tab | 单品无法被筛选到 |
| 默认缩放大于 0 | 图层不可见或镜像异常 |
| 默认层级在约定范围 | 遮挡关系不可控 |
| 颜色格式合法 | ArkUI 渲染失败或回退 |
| 跟随型素材不能使用场景锚点 | 语义互相冲突 |
可以实现一个纯函数校验器:
ts
function validateLibrary(
items: DecorationItem[],
packs: DecorationPack[],
tabs: CategoryTab[]
): string[] {
const errors: string[] = [];
const itemIds: Set<string> = new Set<string>();
const tabKeys: Set<DecorationCategory> =
new Set(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.defaultScale <= 0) {
errors.push(`非法缩放: ${item.id}`);
}
});
packs.forEach((pack: DecorationPack) => {
pack.itemIds.forEach((itemId: string) => {
if (!itemIds.has(itemId)) {
errors.push(`套装 ${pack.id} 引用了不存在的 ${itemId}`);
}
});
});
return errors;
}
校验器不依赖 UIAbility、相机和网络,可以放入本地单元测试。
十、套装批量添加要保证原子性
用户点击套装时,不能添加一半后才发现某个单品失败。正确流程是:
text
解析套装引用
-> 检查缺失 ID
-> 检查目标类型
-> 检查单实例冲突
-> 计算每个初始锚点
-> 一次性生成新图层数组
-> 赋值给页面状态
ArkUI 状态更新可以使用新数组:
ts
function appendPackLayers(
current: DecorationLayer[],
items: DecorationItem[]
): DecorationLayer[] {
const additions: DecorationLayer[] = [];
items.forEach((item: DecorationItem, index: number) => {
if (canCreateLayer(item, current.concat(additions))) {
additions.push(createLayer(item, index));
}
});
return current.concat(additions);
}
不要在循环中连续修改同一个 @State 数组。一次赋值更容易触发稳定刷新,也方便撤销操作保存一份前置快照。
十一、持久化只保存稳定字段
图层存档不应复制整个 DecorationItem。推荐保存:
ts
interface StoredDecorationLayer {
schemaVersion: number;
id: string;
itemId: string;
x: number;
y: number;
scale: number;
rotation: number;
layer: number;
}
恢复时按 itemId 合并最新目录定义。这样素材更名或换图不会破坏旧作品,而位置和缩放仍保留。若素材已下线,可以:
- 保留一个兼容别名映射。
- 使用占位素材并提示"原素材已下线"。
- 忽略该层,但记录迁移日志。
必须加入 schemaVersion,否则未来增加锚点偏移、透明度和翻转字段时,很难区分旧数据。
十二、ArkUI 列表渲染要使用稳定 key
分类列表以素材 ID 为 key:
ts
ForEach(items, (item: DecorationItem) => {
// build item card
}, (item: DecorationItem) => item.id)
画布图层以实例 ID 为 key:
ts
ForEach(layers, (layer: DecorationLayer) => {
// build movable layer
}, (layer: DecorationLayer) => layer.id)
如果画布仍使用 itemId,两个气球实例会被 ArkUI 当成同一个节点,出现复用错位、删除错误或手势状态串联。
排序时不要原地修改状态数组:
ts
function orderedLayers(layers: DecorationLayer[]): DecorationLayer[] {
return layers.slice().sort(
(left: DecorationLayer, right: DecorationLayer) =>
left.layer - right.layer
);
}
十三、测试目录数据而不是测试 UI 文案
适合自动化的测试包括:
ts
describe('DecorationLibrary', () => {
it('has unique item ids', () => {
const items = DecorationLibrary.items();
const ids = new Set(items.map(item => item.id));
expect(ids.size).assertEqual(items.length);
});
it('resolves every pack reference', () => {
const items = DecorationLibrary.items();
const errors = validateLibrary(
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、动态贴纸或云端素材包时也有稳定的扩展基础。