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



鸿蒙 Next 宠物拍立得 App 开发实战:复古相纸 UI + 多选择器系统 + 宠物档案聚合 + 第二十款 App
作者:DULUO
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 10000 字
目录
- 引言
- 产品概念与数据模型
- 三 Tab 架构设计
- 复古拍立得卡片 UI
- 多选择器系统
- 相册 Grid 视图
- 宠物档案聚合
- 数据持久化
- 系列 20 款 App 终极回顾
- 编译错误全记录
- ArkUI 终极开发指南
- 结语
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)
concat 比 push 更适合 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 三要三不要
要做的三件事:
- 所有类型用 interface 显式声明
- 逻辑全部放在普通方法中
- Builder 保持纯 UI 声明
不要做的三件事:
- @Builder 中不要用 let
- 不要用展开运算符克隆对象
- 不要用 Row.wrap(Row 不支持)
11.3 ArkUI 的终极评价
优势:
- 声明式 DSL 直观,UI 代码即结构
- @State 响应式机制简单可靠
- 编译时检查能提前发现 90% 的问题
- Preferences API 简洁
- 组件 API 持续改进中
不足:
- @Builder 语法约束严格
- 展开运算符不支持
- 错误恢复能力有限(级联错误)
- 部分 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 个 |
更多推荐




所有评论(0)