在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

鸿蒙 Next 宠物拍立得 App 开发实战:复古相纸 UI + 多选择器系统 + 宠物档案聚合 + 第二十款 App

作者:DULUO
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 10000 字


目录

  1. 引言
  2. 产品概念与数据模型
  3. 三 Tab 架构设计
  4. 复古拍立得卡片 UI
  5. 多选择器系统
  6. 相册 Grid 视图
  7. 宠物档案聚合
  8. 数据持久化
  9. 系列 20 款 App 终极回顾
  10. 编译错误全记录
  11. ArkUI 终极开发指南
  12. 结语

1. 引言

1.1 为什么要做宠物拍立得

宠物已经成为现代家庭中不可或缺的成员。根据《中国宠物行业白皮书》,2025 年中国城镇宠物数量超过 1.2 亿只,宠物主平均每月为宠物拍摄 15-20 张照片。然而,大多数宠物照片和普通照片混在手机相册里,缺乏专门的组织和展示方式。

“宠物拍立得"App 的核心理念是:用复古拍立得相纸的形式,记录和展示宠物的每一个可爱瞬间。它不是一个专业的摄影工具,而是一个充满趣味性的"电子相册”——给每张照片配上宠物的名字、品种、年龄、贴纸和一句话描述,然后以白色相纸边框的形式优雅呈现。

1.2 本 App 的技术特色

宠物拍立得在技术上有几个特点。

首先,它构建了拍立得卡片 UI——白色相纸边框 + 阴影 + Emoji 模拟照片,这种"相纸即卡片"的设计在 ArkUI 中通过 Column 嵌套 + shadow 实现,是一个可复用的 UI 组件模式。

其次,它实现了多选择器系统——6 个独立的选择器弹窗(Emoji / 品种 / 颜色 / 贴纸 / 分类),每个选择器使用不同的布局策略(Grid / List)。这是系列中选择器数量最多的一款 App。

此外,宠物档案聚合是数据层面的一个亮点——通过宠物名对所有照片进行分组统计,自动生成每只宠物的档案卡,包括照片数量、品种、毛色等信息。

1.3 第二十款 App——系列的里程碑

这是本系列的第二十款,也是最后一款 App。

App 数量:    20
代码总行数:  ~12,900 行
编译错误数:  ~180 个
博客总字数:  ~210,000 字
技术博客数:  20 篇

2. 产品概念与数据模型

2.1 功能需求

用户故事 1:我想给宠物拍一张拍立得风格的照片
用户故事 2:我想选择宠物的 Emoji 头像
用户故事 3:我想给照片添加贴纸(❤️⭐🎀等)
用户故事 4:我想在相册中浏览所有拍立得
用户故事 5:我想按宠物名字查看每只宠物的档案
用户故事 6:我想删除不满意的照片

功能清单:
├── F1: 拍立得创建(Emoji + 名字 + 品种 + 毛色 + 年龄)
├── F2: 贴纸系统(8 种贴纸,每张最多 3 个)
├── F3: 拍立得列表(白色相纸卡片 + 阴影)
├── F4: 相册 Grid 视图(2 列缩略图)
├── F5: 宠物档案(按名字分组 + 统计)
├── F6: 照片删除
├── F7: 6 种选择器弹窗
└── F8: 数据持久化

2.2 数据模型

interface PetPhoto {
  id: number;           // 唯一标识
  petEmoji: string;     // 宠物 Emoji 头像
  petName: string;      // 宠物名字
  petBreed: string;     // 品种
  petAge: string;       // 年龄
  petColor: string;     // 毛色
  date: number;         // 拍摄日期
  caption: string;      // 文字描述
  stickers: string[];   // 贴纸列表(最多 3 个)
}

stickers 是一个 string[] 数组,存储选中的 Emoji 贴纸。这个数组的长度被限制在 3 以内,防止贴纸过多遮盖照片内容。

2.3 预置数据

const PET_EMOJIS: string[]  = ['🐱', '🐶', '🐰', '🐹', '🐭', '🐸', '🐦', '🐤', '🐢', ...];
const PET_COLORS: string[]  = ['🤍 白色', '🖤 黑色', '🧡 橙色', '🤎 棕色', ...];
const PET_BREEDS: string[]  = ['🇨🇳 中华田园', '🇺🇸 金毛', '🇯🇵 柴犬', '🇬🇧 英短', ...];
const STICKERS: string[]    = ['❤️', '⭐', '🎀', '🌈', '🌸', '🍖', '🐟', '☀️'];

3. 三 Tab 架构设计

3.1 Tab 配置

buildBody() {
  if (this.activeTab === 0) this.buildCameraTab()     // 拍照
  else if (this.activeTab === 1) this.buildAlbumTab()  // 相册
  else this.buildPetTab()                               // 我的宠
}
Tab 图标 功能 用户场景
拍照 📸 展示拍立得列表 + 创建入口 “看看拍过的照片”
相册 🖼️ 2 列 Grid 缩略图 “快速浏览所有照片”
我的宠 🐾 宠物档案 + 统计 “看看每只宠物的数据”

3.2 两个 Tab 共享创建入口

照片创建按钮在"拍照"和"相册"两个 Tab 的顶部都可见(activeTab === 0 || activeTab === 1)。“我的宠” Tab 不展示创建按钮,因为该 Tab 的用途是查看统计而非创建新内容。


4. 复古拍立得卡片 UI

4.1 相纸卡片结构

拍立得卡片是 App 最核心的 UI 组件,由三层 Column 构成:

Column(外层,90% 宽度)
  └── Column(白色相纸)
       ├── Column(照片区,Emoji 居中,200px 高)
       │    └── Text(petEmoji, 72px)
       └── Column(底部标签区)
            ├── Text(宠物名, 16px 加粗)
            ├── Row(贴纸)
            ├── Text(描述, 可选)
            └── Text(品种 · 年龄, 11px)
  └── Text(日期, 底部)
@Builder
buildPolaroidCard(p: PetPhoto) {
  Column() {
    Column() {
      // 白色相框
      Column() {
        // 照片区
        Column() {
          Text(p.petEmoji).fontSize(72)
        }.width('100%').height(200).backgroundColor(C.frame)
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

        // 标签区
        Column() {
          Text(p.petName).fontSize(16).fontColor(C.text).fontWeight(FontWeight.Bold)
          Row() { ForEach(p.stickers, (s) => { Text(s) }) }.margin({ top: 2 })
          if (p.caption !== '') { Text(p.caption).fontSize(12) }
          Text(p.petBreed + ' · ' + p.petAge).fontSize(11).fontColor(C.textHint)
        }.width('100%').padding(12).alignItems(HorizontalAlign.Start)
      }.width('100%').backgroundColor(C.cardBg).borderRadius(4)
        .shadow({ radius: 8, color: '#00000015', offsetY: 4 })

      // 日期
      Text(this.formatDate(p.date)).fontSize(11).fontColor(C.textHint).margin({ top: 6 })
    }.width('90%').margin({ top: 12 })
  }.width('100%').alignItems(HorizontalAlign.Center)
}

4.2 视觉效果的关键参数

参数 效果
边框圆角 4px 模拟真实宝丽来相纸的直角(非圆角)
阴影 radius 8, offsetY 4 相纸浮在页面上的立体感
照片区背景 #F5F5F5(浅灰) 模拟老照片的泛白效果
Emoji 字号 72px 占据照片区的主要视觉
底部标签 白色背景 拍立得的标志性宽白边

4.3 空状态

当没有拍立得照片时,拍照 Tab 和相册 Tab 各自展示空状态引导:

📸                          🖼️
还没有拍立得照片            相册还是空的
点击右上角"拍一张"记录...   去拍一张拍立得照片吧

两个 Tab 的空状态文字不同,但视觉风格一致。


5. 多选择器系统

5.1 6 种选择器

本 App 共有 6 个选择器弹窗,是系列中数量最多的:

选择器 布局 选项数 列数 交互模式
Emoji Grid 12 6 列 单选
品种 List 10 1 列 单选 + ✓
毛色 List 6 1 列 单选 + ✓
贴纸 Wrap Row 8 自适应 多选(最多 3)
分类(主题筛选) List 4 1 列 单选 + ✓

5.2 Emoji 选择器(6 列 Grid)

Grid() {
  ForEach(PET_EMOJIS, (e: string, idx: number) => {
    GridItem() {
      Text(e).fontSize(28).padding(8)
        .backgroundColor(this.newEmoji === idx ? C.primary + '15' : 'transparent')
        .borderWidth(this.newEmoji === idx ? 1 : 0)
        .onClick(() => { this.newEmoji = idx; this.showEmojiPicker = false; })
    }
  }, (e: string) => e)
}.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr')

6 列的 Grid 布局让 12 个 Emoji 在 2 行内展示完毕,不需要滚动。

5.3 贴纸选择器(多选 + 限额)

贴纸选择器与其他选择器最大的不同是支持多选且有数量上限 3 个

ForEach(STICKERS, (s: string) => {
  Text(s).fontSize(28).padding(8)
    .backgroundColor(this.newStickers.indexOf(s) >= 0 ? C.primary + '15' : 'transparent')
    .onClick(() => {
      if (this.newStickers.indexOf(s) < 0 && this.newStickers.length < 3) {
        this.newStickers = this.newStickers.concat([s]);
      }
    })
}, (s: string) => s)

concatpush 更适合 ArkTS 的不可变风格。选中态通过 indexOf 判断。

底部显示当前已选数量:

已选 2/3

5.4 弹窗创建弹窗

创建弹窗本身也包含多个输入区域:

📸 拍一张拍立得
😺 头像  [🐶]  ✏️ 换一个       → 打开 Emoji 选择器
📝 名字  [___________]         → TextInput
🐕 品种  [🇨🇳 中华田园]  ▼    → 打开品种选择器
🎨 颜色  [🤍 白色]  ▼         → 打开颜色选择器
🎂 年龄  [___________]         → TextInput
✨ 贴纸  [❤️⭐]  ➕ 添加      → 打开贴纸选择器
[描述文字区域...]               → TextArea
────────────────────────
📸 预览(实时)
       🐶
       小白
────────────────────────
[取消]              [📸 拍下]

整个弹窗在一个 Scroll 中,方便在键盘弹出时滚动查看。


6. 相册 Grid 视图

6.1 2 列 Grid

相册 Tab 使用 2 列 Grid 展示缩略图版的拍立得卡片:

Grid() {
  ForEach(this.photos, (p: PetPhoto) => {
    GridItem() {
      Column() {
        Text(p.petEmoji).fontSize(36)
        Text(p.petName).fontSize(12).fontColor(C.text).fontWeight(FontWeight.Bold)
      }.padding(6).backgroundColor(C.cardBg).borderRadius(4)
        .shadow({ radius: 4, color: '#00000010', offsetY: 2 })
    }
  }, (p: PetPhoto) => 'g' + p.id.toString())
}.columnsTemplate('1fr 1fr')

缩略图保留了拍立得卡片的两个核心元素:Emoji 和宠物名,去掉了详细标签。

6.2 列表 vs Grid

视图 Tab 布局 展示信息 适用场景
列表 拍照 单列 + 完整卡片 全部 浏览详情
Grid 相册 2 列 + 缩略图 Emoji + 名字 快速查找

两种视图互补,覆盖了"详情浏览"和"快速查找"两种场景。


7. 宠物档案聚合

7.1 按名字分组

“我的宠” Tab 将所有照片按宠物名字分组聚合:

getPetNames(): string[] {
  let names: string[] = [];
  for (let p of this.photos) {
    if (names.indexOf(p.petName) < 0) names.push(p.petName);
  }
  return names;
}

使用 indexOf 去重,保留首次出现的顺序。

7.2 宠物档案卡

每只宠物生成一张档案卡:

🐶 小白
🇨🇳 中华田园 · 🤍 白色
3 张拍立得

点击档案卡打开该宠物的第一张照片详情。

7.3 全局统计

档案列表下方的三列统计卡片:

📸 总照片    🐾 宠物数    🏆 最多拍
10 张        2 只         6 张

三个统计值来自三个方法:

getPetNameCount(): number  // 去重后的名字数
getPetPhotoCount(name): number  // 某只宠物的照片数
getMaxPetCount(): number    // 所有宠物中照片数的最大值

8. 数据持久化

8.1 存储完整数据

本 App 存储的是完整的 PetPhoto[] 数据,而非 ID 列表:

async saveData(): Promise<void> {
  await this.pref.put(STORAGE_KEY, JSON.stringify(this.photos));
  await this.pref.flush();
}

为什么存完整数据?因为每张拍立得是独立创建的,每次新增都需要写入完整列表。而收藏系统只需要存 ID 列表,是因为收藏状态只是一个布尔值,不需要完整数据。

8.2 数据加载

async loadData(): Promise<void> {
  let v = await this.pref.get(STORAGE_KEY, '');
  if (v !== '') {
    let d = JSON.parse(v as string) as PetPhoto[];
    if (d && d.length > 0) this.photos = d;
  }
}

加载时直接解析 JSON 并赋值给 this.photos,不需要额外的数据转换。


9. 系列 20 款 App 终极回顾

9.1 数据总览

# App 行数 错误数 Type
1 🎵 白噪音 767 16 工具
2 ⏳ 时间胶囊 955 17 工具
3 🧊 冰箱剩菜 1320 22 工具
4 😅 尴尬粉碎机 953 1 工具
5 🛡️ 防骗训练 1038 12 教育
6 💡 碎片学习 851 12 教育
7 🐶 宠物日记 450 10 工具
8 🗑️ 情绪垃圾桶 390 4 工具
9 🧭 线下寻宝 447 11 社交
10 🗡️ 订阅刺客 478 11 工具
11 🎑 声音明信片 458 3 工具
12 🎲 家庭大富翁 537 8 游戏
13 📚 二手书漂流瓶 452 7 社交
14 🧹 废话过滤器 542 12 工具
15 🌱 绿植领养 530 1 社交
16 🌙 梦境解析 614 4 工具
17 🏕️ 断网挑战营 418 3 工具
18 👨‍🍳 语音菜谱 498 2 工具
19 🌙 睡前故事 668 1 教育
20 📸 宠物拍立得 582 2 工具

系列累计数据

  • 代码总行数:~13,000 行
  • 编译错误:~180 个
  • 博客总字数:~210,000 字
  • 平均每款 App:~650 行 / 9 个错误

9.2 错误类型终极统计

错误类型 数量 占比
@Builder 语法错误 58 32%
对象字面量无类型 15 8%
属性不存在 19 11%
展开运算符 9 5%
级联错误 25 14%
Text 组件限制 3 2%
BorderOptions 语法 2 1%
渲染层级问题 2 1%
@Builder 注解缺失 1 1%
内联对象作类型 1 1%
Row.wrap 不存在 1 1%
方法重复 1 1%
其他 43 24%

前 5 类错误占 70%,掌握了这 5 类就可以避免绝大部分编译问题。

9.3 App 类型分布

工具类:  13 款(65%)— 白噪音、时间胶囊、冰箱剩菜、尴尬粉碎机、
                 宠物日记、情绪垃圾桶、订阅刺客、声音明信片、
                 废话过滤器、梦境解析、断网挑战营、语音菜谱、宠物拍立得
教育类:  3 款(15%)— 防骗训练、碎片学习、睡前故事
社交类:  3 款(15%)— 线下寻宝、二手书漂流瓶、绿植领养
游戏类:  1 款(5%) — 家庭大富翁

工具类占 65%,是系列的主力。

9.4 错误数趋势曲线

22 → 17 → 16 → 1 → 12 → 12 → 10 → 4 → 11 → 11 → 3 → 8 → 7 → 12 → 1 → 4 → 3 → 2 → 1 → 2

从 22 降到 2,下降 91%。最后 10 款 App 的平均错误数为 4.2 个,前 10 款为 11.4 个——效率提升超过 60%。


10. 编译错误全记录

10.1 错误概览

本 App 出现 2 个编译错误

# 错误类型 位置 根因
1 方法不存在 buildPetTab buildPetGroupList 方法未定义(编辑遗漏)
2 @Builder 中 let buildPetGroupList ForEach 回调中的 let 声明

10.2 错误 1:方法不存在

现象Property 'buildPetGroupList' does not exist on type 'Index'

根因buildPetTab 中调用 this.buildPetGroupList(),但该方法被遗漏了。原因是在重构过程中,一次失败的 edit_file 操作没有正确添加该方法,导致调用存在但定义缺失。

10.3 错误 2:@Builder 中的 let

现象'this.buildPetGroupList()' does not meet UI component syntax — 且最终报错为 @Builder 中的语法错误。

根因buildPetGroupList 的 ForEach 回调中使用 let first = ...for 循环。即使方法名不是以 build 开头,但在 @Builder 内被调用时仍被当作 UI 构建上下文对待。

修复方式是将卡片逻辑提取为独立的 @Builder buildPetCard(name) + 3 个 getter 方法。

10.4 系列的关键教训回顾

App 教训
1-3 颜色对象需 interface,@Builder 不能用 let
4-6 模式复用可大幅降错,大段 Builder 分批重构
7-9 紧凑风格,ForEach key 用值本身,检查残留代码
10-12 setInterval 要清理,展开运算符替代
13-15 @Builder 注解不能缺,Text 组件限制,重构引入错误
16-18 内联对象不能作类型,已知错误重复犯,删除检查残留
19-20 方法重复,编辑遗漏

11. ArkUI 终极开发指南

11.1 七条铁律

经过 20 款 App 的验证,以下是 ArkUI 开发必须遵守的七条铁律:

铁律 1:Builder 不放逻辑
任何变量声明(let/const)、循环、条件分支(if/switch)都应该放在普通方法中,而不是 @Builder 中。这条规则占全部编译错误的 32%。

铁律 2:颜色声明接口

interface ColorScheme { ... }
const C: ColorScheme = { ... }

每款 App 都从复制这个模式开始,每次都有效。

铁律 3:数组修改用 concat

this.list = [newItem].concat(this.list);  // ✅
this.list.push(newItem);                  // ❌ 不触发 UI 更新

铁律 4:弹窗用 if 包裹在 Stack 中

Stack() {
  Column() { ... }   // 主内容
  if (this.showDialog) { this.buildDialog() }  // 弹窗在 Stack 层级
}

铁律 5:三 Tab 架构模板

buildTabBar() { Row { buildTabItem() × 3 } }
buildTabContent() { if/else if/else }
@State activeTab: number = 0;

铁律 6:数据模型先行
先写 interface,再写常量数据,最后写 UI 代码。这个顺序确保了数据结构的完整性。

铁律 7:定时器必须清理

aboutToDisappear(): void { this.stopTimer(); }
stopTimer(): void { clearInterval(this.timerId); this.timerId = -1; }

11.2 三要三不要

要做的三件事

  1. 所有类型用 interface 显式声明
  2. 逻辑全部放在普通方法中
  3. Builder 保持纯 UI 声明

不要做的三件事

  1. @Builder 中不要用 let
  2. 不要用展开运算符克隆对象
  3. 不要用 Row.wrap(Row 不支持)

11.3 ArkUI 的终极评价

优势

  1. 声明式 DSL 直观,UI 代码即结构
  2. @State 响应式机制简单可靠
  3. 编译时检查能提前发现 90% 的问题
  4. Preferences API 简洁
  5. 组件 API 持续改进中

不足

  1. @Builder 语法约束严格
  2. 展开运算符不支持
  3. 错误恢复能力有限(级联错误)
  4. 部分 API 与 Web 标准有差异

综合评价:对于中小型应用的快速开发,ArkUI 是一个值得投入的框架。学习曲线主要在于理解 ArkTS 的语法限制,而非框架本身的设计。

11.4 20 款 App 的模式复用资产

经过 20 款 App 的积累,以下是可以直接复用的模式资产:

模式 首次出现 复用次数 描述
ColorScheme + C App 1 20/20 颜色管理
三 Tab 架构 App 3 18/20 Tab 切换
Stack + if 弹窗 App 2 20/20 弹窗系统
concat 更新数组 App 3 18/20 @State 更新
ForEach + key App 3 20/20 列表渲染
Grid + columnsTemplate App 5 6/20 选择器
setInterval + cleanup App 11 3/20 定时器
ID 列表持久化 App 18 3/20 轻量存储

12. 结语

12.1 20 款 App 的开发历程

App1  🎵  白噪音          → 初识 ArkUI         22 错误
App2  ⏳  时间胶囊        → 数据持久化         17 错误
App3  🧊  冰箱剩菜        → Tab 架构           22 错误
App4  😅  尴尬粉碎机      → 模式复用            1 错误
App5  🛡️  防骗训练        → 适老化             12 错误
App6  💡  碎片学习        → 学习激励           12 错误
App7  🐶  宠物日记        → 紧凑风格           10 错误
App8  🗑️  情绪垃圾桶      → 情感交互            4 错误
App9  🧭  线下寻宝        → 社交互动           11 错误
App10 🗡️  订阅刺客        → 暗色主题           11 错误
App11 🎑  声音明信片      → 模拟录音            3 错误
App12 🎲  家庭大富翁      → 回合制游戏          8 错误
App13 📚  二手书漂流瓶    → 随机匹配            7 错误
App14 🧹  废话过滤器      → 自然语言检测       12 错误
App15 🌱  绿植领养        → 缘分匹配            1 错误
App16 🌙  梦境解析        → 潜意识探索          4 错误
App17 🏕️  断网挑战营      → 行为养成            3 错误
App18 👨‍🍳  语音菜谱        → 适老化设计          2 错误
App19 🌙  睡前故事        → 故事定制            1 错误
App20 📸  宠物拍立得      → 复古相纸 UI         2 错误

12.2 最后的话

20 款 App、20 篇博客、约 210,000 字、约 13,000 行代码、约 180 个编译错误。

从第一个白噪音 App 的 22 个错误,到最后一个宠物拍立得的 2 个错误——下降 91%。从第一个 Builder 中写 let 的错误,到建立完整的模式复用库——这就是学习的过程。

如果你读到了这里,说明你陪伴了 20 款 App 的诞生。现在,打开 DevEco Studio,去创造属于你自己的 App 吧。


附录 A:系列速查表

指标 数值
App 数量 20
博客总字数 ~210,000 字
代码总行数 ~13,000 行
编译错误 ~180 个
@Builder 方法 ~280 个
修复轮次 38 轮
代码行/App ~650 行
错误/App ~9 个
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐