HarmonyOS趣味相机实战第14篇:装饰素材强类型建模、锚点语义与套装组合校验

HarmonyOS趣味相机实战第14篇:装饰素材强类型建模、锚点语义与套装组合校验

摘要

相机贴纸功能看起来像"选一张图片并叠到预览上",但素材数量增长后,真正难维护的不是图片资源,而是素材背后的业务语义:猫耳应该跟随头顶,眼镜应该跟随双眼,翅膀应该位于人体后方,边框只属于场景层,氛围贴纸允许重复添加,套装引用的素材不能失效。

本文基于 D:/APP/1quweixiangji 趣味相机工程,复盘 DecorationModels.etsDecorationLibrary.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,联合类型有三项直接收益:

  1. 拼写错误会在编译期暴露。
  2. switch 分支更容易做穷尽检查。
  3. 分类标签、筛选条件和素材数据共享同一语义。

页面分类本身也使用类型约束:

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 是页面入口分类,不是单品的自然类别。生产代码可以进一步拆成 DecorationItemCategoryDecorationTabKey,避免把套装误当作可以直接创建图层的单品。

三、锚点是素材与识别结果之间的协议

素材目录不应该知道人体识别 API 的具体字段,它只声明希望绑定的语义锚点:

ts 复制代码
export type AnchorType =
  'headTop' | 'eyes' | 'cheeks' | 'noseMouth' |
  'neck' | 'shoulder' | 'bodyCenter' | 'hand' |
  'around' | 'fullFrame';

这些值可以分为三类:

锚点类型 例子 数据来源
面部锚点 headTopeyescheeksnoseMouth 面部元数据、眼鼻关键点或估算框
身体锚点 neckshoulderbodyCenterhand 骨骼关键点、分割轮廓或人体框
场景锚点 aroundfullFrame 用户位置或画布固定区域

猫耳定义只需要表达"跟随头顶":

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'
)

如果只依赖 1080 这样的魔法数字,未来加入遮罩、抠图或多人层时很容易错层。语义分组加组内数字排序更稳定。

五、工厂函数统一默认策略

素材数量达到几十个后,直接书写完整对象既冗长又容易漏字段。项目使用私有工厂函数统一默认值:

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 合并最新目录定义。这样素材更名或换图不会破坏旧作品,而位置和缩放仍保留。若素材已下线,可以:

  1. 保留一个兼容别名映射。
  2. 使用占位素材并提示"原素材已下线"。
  3. 忽略该层,但记录迁移日志。

必须加入 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 均稳定且唯一。
  • 每个套装引用的单品都存在。
  • 每个素材都有明确锚点和放置层。
  • behindBodyfrontBodyscene 渲染顺序正确。
  • 多实例素材使用独立实例 ID。
  • 无识别目标时仍可添加场景素材。
  • 目标类型筛选不会误伤边框和文字。
  • 默认缩放、旋转和层级处于约定范围。
  • ArkUI 列表使用稳定 key。
  • 套装添加失败时不会留下半套图层。
  • 存档带 schemaVersion 并可迁移旧 ID。
  • 数据校验函数已纳入单元测试。
  • 正式包不会输出用户图像或素材选择隐私数据。

总结

可扩展的贴纸系统首先是数据建模问题,其次才是视觉渲染问题。DecorationItem 描述素材能力,DecorationLayer 保存用户实例,AnchorType 连接识别算法,DecorationPlacement 表达前后景语义,DecorationPack 通过稳定 ID 组合单品。

当这些规则进入类型、工厂函数和启动期校验后,新增素材不再需要到多个页面分支里补条件。素材目录、人体拟合、ArkUI 渲染和作品持久化之间形成清晰协议,后续接入真实 PNG、动态贴纸或云端素材包时也有稳定的扩展基础。

相关推荐
不羁的木木6 小时前
HarmonyOS技术精讲-Connectivity Kit:Wi-Fi进阶能力——热点、P2P与Aware
asp.net·harmonyos·p2p
Helen_cai7 小时前
HarmonyOS ArkTS 实战:实现一个校园宿舍管理与查寝应用
华为·harmonyos
listening7777 小时前
HarmonyOS ArkTS 实战:实现一个校园打印复印预约应用
华为·harmonyos
绝世番茄7 小时前
鸿蒙原生 ArkTS 布局之道:Grid 替代多层嵌套布局深度解析
华为·harmonyos·鸿蒙
●VON7 小时前
鸿蒙 PC Markdown 编辑器可靠性设计:未保存内容的崩溃恢复闭环
华为·中间件·编辑器·harmonyos·鸿蒙
listening7777 小时前
《从算法到落地:基于HarmonyOS API 23的端侧图像推理实战》
算法·华为·harmonyos
●VON8 小时前
鸿蒙 PC Markdown 编辑器导出架构:安全 HTML 与系统 PDF
华为·架构·编辑器·harmonyos·鸿蒙
不羁的木木8 小时前
HarmonyOS技术精讲-Connectivity Kit(短距通信服务):初识统一短距通信框架
华为·wpf·harmonyos
爸爸6198 小时前
UIAbility 生命周期在日记 App 中的实践
后端·华为·harmonyos·鸿蒙系统