#95 制作我的第一款 iOS App: 干饭手册
踩坑是最好的老师
今年四月我完成了人生第一款 iOS App 的 1.0 版本,它的中文名称叫「干饭手册」,主要功能是记录自己会做的饭菜。除此之外还包括:
- AVIF 压缩,节省空间;
- AI 识别食材,记录食用次数,从而展示食材偏好;
- AI 美化图片;
- 通过 iCloud 同步、分享;
其实大部分都是很基础的功能,比如菜品的 CRUD、搜索、分组和折叠。但经验不足,光是完善这些基本交互就已经让我力竭了。从结果上看,我完成了从零开始的 iOS App 上架,向伴侣兑现了承诺,是成功的。但回望数月的开发过程,需求膨胀、未经管理,旧机型上性能表现不佳,又是失败的。
幕后
干饭手册看似简单,但开发过程中体验到了不少 iOS 基建设施。
- 数据库:SwiftData
- 同步:CloudKit 容器
- 分享:CKShare 共享 zone
- 登录:Sign in With Apple
- 接入了 associated domains,用 Cloudflare Worker 做一些简单的路由;
- 图片处理:Core Image
- 加了一些 metal shader
- 做了小组件
- 加了订阅和一次性付费
- 加了四种语言本地化
- 对了,还有 SwiftUI
iOS 26 带来了大量的系统级视觉改进,我愿称之为「设计 harness」:定下基础视觉后,直接在脑中构图,常用在一些表单页面;探索新排版时还是会用到 Figma。
本文将分为两部分介绍:第一部分是产品界面相关介绍,主要由我来撰写;第二部分是开发向的复盘记录,根据我和 agent 的聊天记录总结改写而成。
产品介绍
五六年前写过不少 App 的用后感,这次也轮到体验自己的 App。只不过设计基本都是我做的,所以会说得比较详细。
背景
任何产品的源头都是用户需求,干饭手册的需求源头是我的女朋友(现在是老婆)会做很多菜。由于会时不时开发新菜,菜做多了之后,有时会忘记自己会做什么,导致很多菜只吃过一次就被遗忘了。

同时我们也发现,在人类干饭流程中,当时很少有人真的去解决从「考虑吃啥」到「决定吃啥」这一步。最常见的形式是随机菜品,但实际上总会多次随机,效率很低。与其这样,不如直接看到自己所有会做的饭菜,再去挑选。
MVP 阶段,我用飞书的多维表格简单记录,她则是将菜品图保存在相册中。那个时候我就想做食材统计了。但我这边班味挺重的,看图也不方便,更不方便向别人分享。于是我们想做个小程序来记录,跨平台也方便。

当年还没有 Vibe Coding,所以只能委托别人来做。为此甚至注册了个体工商户。由于对方迟迟抽不出时间,这个项目基本只停留在设计阶段。2023 年失业的时候我想自学 SwiftUI 尝试一下,但是一个月后不小心找到工作了也只能作罢。
时间来到 2025 年 12 月,我开始有时间去实现这一承诺。
App 图标
最早的图标是老婆画的。她比较喜欢这种 MBE 风格。

后来开发 iOS App 时,使用 Icon Composer 重新绘制了深色、浅色两种主题图标。

区分深浅主题图标并不难,但 Icon Composer 有着 Apple 一贯的逆天交互,颇有当年 iTunes 的风采。本以为点击 ② 处各属性即可设置变体属性,但其实你要设置如图的 Dark 变体,需要先在 ① 处点击深色模式图标,再点击 ② 处的 Opacity 左侧加号,才能增加 Dark 变体属性。

Onboarding
请看 VCR。
首先我用 SF Symbol 制作了文字版 logo 的路径动画,之前在 X 分享过。因为本地化,我又如法炮制了其他三种语言的动画。
用 SF Symbol 制作路径动画好处在于,省去了第三方依赖,调用也很简单,用 isActive 控制即可。但 SF Symbol 本身导出的代码不带激活属性,踩了个小坑。应该是:绘制出现 true → false,绘制消失 false → true。

Image(LocalizedBrandAsset.logoTextAnimationName)
.renderingMode(.template)
.resizable()
.scaledToFit()
...
.symbolEffect(.drawOn.byLayer, isActive: isDrawActive)缺点是 Symbol Image 本身体积也不小,几 KB 的 SVG 会膨胀到几十、几百 KB。对一些非路径的形状,插值也不太友好。

Onboarding 首页特意做成了一页,没有像以往那样要滑动很多次才可以登录。如果做成单屏内横滑,用户大概率不会去横滑,所以做成了默认纵向自动滚动、手动拖拽时暂停、2s 后恢复滚动的形式。点击后可查看 feature 详情。之所以能这么做,是因为每个 feature 其实没那么重要,用户只需要了解这是一个记录做饭的 App 就行了。
第二页是权限说明,展示 App 运行的所需权限。我很反感一打开 App 连续弹出索要权限窗口,尽可能交给用户去控制,或者渐进性地开启所需权限。
大葱的插图是使用 AI 生成的,主要用的是 Nano Banana Pro。生成后放到 Figma 矢量化,再调整边缘、比例和颜色。
因为 App 启用了 Universal Links,所以一打开 App 就一定会触发联网弹窗。我曾尝试拦截弹窗,在点击按钮后自动继续 OnBoarding 流程。但联网弹窗没有任何 API 可以识别是否点击过,自己做判断的话也容易失败(可能我和 LLM 的能力都不足),所以最后删掉了这个过程。

首页
首页一进来是空状态,目的只有一个:让用户上传菜品。

上传界面分为了四部分,从上到下为:
- 页面标题和页数指示器;
- 图像预览;
- 图像操作,添加分组、美化和删除;
- 撰写菜品标题和描述;

这里我加了一个标题提示:输入后 2s 会触发一个重名检查,提示用户是否已经上传这道菜。
左右滑动翻页时,输入焦点也会自动放在菜品标题输入框中。但是滑动过快的话则会失效。
这里还应该有一些改进,比如:
- 菜品描述作为那种「我可以不用但是不能没有」的低频组件,默认应该折叠描述输入框,有需要再展开输入;
- 我有想过,当添加多张图片时,额外加一步来批量添加分组,而不是一张张地点击添加分组;
- 键盘避让的流畅性应该还有优化空间;
首页是每排 3 枚图片的网格布局,因为老婆说要带上标题,所以起初没做成照片墙样式[1]。可以折叠分组节省纵向空间,也可以按关键词搜索与标题、描述、分组相关的菜品。
[1] 在后续版本中添加了无标题模式,减少了各种间距,变得更像照片墙了。

关于首页的折叠动画和缩略图也踩了不少坑,会在开发章节分享。

为了应对有些图片和底色接近(失去边缘分割感),通常会在图片边缘添加一条非常细微的描边,比如 4% 的黑色。这次尝试了 10% 的白色内描边,但是叠加了 plus lighter,有一种边缘高亮的效果。这样小图很多时,也不会因为灰色描边太多而显得界面死板。

另外也添加了长按触发的 context 菜单(上图左一)。
右上角是分组的显示和管理菜单(上图中间)。设置 .menuActionDismissBehavior(.disabled) 后,分组的排序、重命名和删除就都可以在一个 menu 中完成。右侧点击进入管理菜品模式,可以批量编辑分组和删除菜品。

这里的逻辑是:
- 添加:给菜品添加分组,代表只添加新分组,不修改旧分组信息,做并集处理;
- 移动:移动分组比较麻烦的是多对多的关系。如果要分开去管理分组的话就失去了批量选择的意义。所以这里简化为多对一关系,使用替换的逻辑,可以多选几个分组,然后替换为一个目标分组。如果目标分组不存在而无法替换,则直接添加分组。
- 删除:在已关联分组中,选择删除哪几个分组,再选择目标分组,然后做差集处理;
如你所见,添加 / 移动 / 删除这里我并没有使用系统的 segment control 样式,因为那种 picker 样式高度较小,字体也较小,强行放大型号的话字号却不会变……所以想自己写一个类似 App Store 排行榜顶部的切换控件,但是没写好样式。
27 系统新增了 .pickerStyle(.tabs) ,但是样式没变,也无法控制字号。只是 VoiceOver 可以读取为 tab 了。

编辑分组的窗口这里额外定义了 3 种 .presentationDetents,其中小尺寸窗口可以继续选中背后的图片。除了拖动窗口,点击右上角缩放图标也可以恢复窗口尺寸。
详情页
点击缩略图,经过 zoom navigation transition,来到详情页。

详情页包括具体菜品名称、描述、分组和添加日期。可以编辑、美化图片,或者手动添加相关食材。左右滑动可以查看当前分组的其他菜品。初次进入这个页面 +1 按钮会通过 TipKit 显示引导。点击后是一个 shader 动画(有音效),如下。
打开美化页[2]。这是一个常驻的深色背景页面,可以裁切、旋转、移除背景和 AI 美化图片。在按钮上方是一个图片切换的暂存区。比如你用 AI 生成了多张图片,都可以在这里查看、下载和删除。
[2] 说是美化页,其实是为了和「编辑页」区分,算是和 LLM 约定的一种消歧方式。

按钮都处于一个 GlassEffectContainer 中,所以切换时能看到液态玻璃带来的过渡效果。说起来这个裁剪功能还是 Gemini 3 Pro 写的,我挺满意,现在看也是《互联网珍贵 Gemini Coding 资料》了。
移除背景用了大家都在用的 VisionKit 的 VNGenerateForegroundInstanceMaskRequest。优点是本地处理、隐私友好、响应快,蒙版机制可以快速切换回原图;缺点是,画面复杂时识别不准确。
AI 美化可以使用会员默认模型(Seedream 4.5)或者自己的模型。也可以自定义 prompt。由于生成等待时间可能比较长,我又加了 shader 动画:
这是第二版动画了,第一版动画是粒子效果,在小红书或 X 可以看到。调试 shader 并非易事,就算不用 shader 用 progress view 也不是不行。但是一件事做久了,总会想放松一下,在 vibe 时的放松方式就是做一些遵循人性欲望的事,偶尔解放下自己。
分享
由于没有后端服务器,内容的分享仅限于图片分享和神奇的 iCloud 分享(CKShare)。
图片分享分为了整体分享和单张图片分享。整体分享有点像菜单,所以做成了菜单样式。如果有精力,(在控制高度的范围内)我还是想做成图文混排的,以及更多的自定义选项(比如只分享某个分组)。


两种分享都有一些简单的布局调整,像「卡片间距」调整是为一些透明背景图片准备的(防止上下间距过大)。菜单都可以随时收起和展开,方便预览。单张菜品的调整菜单上方则是类似美化页的图片版本切换。

你应该能看出来分享这里界面明显开始变形,因为 CKShare 这些东西无论开发还是测试都太麻烦了,每天精力都被极速消耗。总之在这里可以看到一些在线分享的统计信息、可以选择分享的分组以及查看分享者(解除分享权限)。
通过粘贴链接或扫描二维码来接收分享。

由于支持 Universal Links,所以直接扫码会出现 App icon。如果是在微信中打开,网页中会提示用默认浏览器打开,打开后也可以直接跳到 App,并弹出邀请。如果你和对方在通讯录互为联系人,那么用户名那里显示的是通讯录姓名……
设置
设置页入口在左上角头像处。在设计了传统的表单式设置界面后,我开始想能不能做得特别一点。此时已经和产品无关了,只是想做点设计。

我先设计了一个徽章,用于标识用户自己会做多少道菜。但我一直没找到合适的空间去放置,作罢。
接着又做了「抽卡」系统,如上左一图的右上方。每次打开 App 都会抽出一张不同的卡片。卡片主题定为了厨房果蔬,并加入水杯、牙线和我家两只小猫作为稀有卡片(彩蛋)。抽卡动画在这里查看。

当时也是为了寻找一些新的设计风格,所以每张卡片都是我按照实物图用矢量的风格描画下来的。我比较喜欢煮熟的鸡蛋这张,改了很多次去体现蛋黄的不规则颜色。木耳那张我也改了很多次,但因为颜色相近、挑战更大,最终效果也比较一般。果然这里面最菜的是我。

又做了一段时间需求后,我突然想到[3],设置页是不是也可以再随机一点?于是我大幅度修改了布局,把分享入口单独拿了出来,顶部作为画布,剩下的地方作为设置项。
[3] 「突然想到」大多是我做这类设计时的方法,零铺垫的 aha moment。

每次打开设置后是这种效果:
你会发现头像、昵称、加入天数、菜品计数、随机卡片互相重叠。我个人主观认为,重叠就重叠吧!毕竟用户不需要从这里获取什么重要信息。
其他一些细节比如:
- 这里其实只有菜品计数属于有用的新信息,所以它的图层层级一直在最上方;
- 为每个元素设置了边缘阈值,确保元素面积不会超出屏幕边缘 20% 左右,防止被边缘裁剪;
下方设置包括:
- 同步状态和设置:这里主要是同步信息和开关;
- 会员中心:会员开通和管理。说起来做产品时没太关注这个页面,结果送审时发现审核人员几乎只关注这个页面;
- AI 管理:开通会员后,可以手动添加自己的模型端点;
- 食材管理:在这里可以批量让 AI 根据图片生成食材,(根据食用次数)查看食材的记录;
- 食用记录:每点 +1 按钮都会按照时间戳记录在这里;
- 个人信息:改头像和昵称,以及注销账户入口(审核需要);
- 图标设置:更改 App 图标。我最喜欢百变怪那版,当时在玩 pokopia;

- 启动页设置:启动页会随机选择一张图片展示,类似下面的模板我一共做了 11 个;你可以在启动设置中,开启或关闭某个模板,不让它出现在启动页。
- 我觉得有趣的是,所有的启动页图片都是先在 Figma 中设计,然后使用 Core Image 滤镜组合实现。我之前在这篇推文分享过。


- 其他设置
- 外观主题设置;
- 首页排版设置:是否展示菜品名称;
- 更改语言;
- 是否展示启动页和启动动画;
- 保存格式设置为 AVIF、PNG或者每次询问;
- 设置不计数分组(比如有个外卖分组,想记录自己吃的外卖,但是不想计入自己会做的饭);
- 通知历史:用于下发多语言通知;
- 反馈或建议:简便的邮件反馈;

- 关于
- 常见问题,如上中图,同一页面,支持搜索。
- 为何制作这款 App:这类 App 很常见,为什么要做自己的;
- 隐私协议:审核上架必备;
- AI 使用披露:自己的产品都有责任公布 AI 使用状况;
- 致谢:站在谁的肩膀上;
- 关于我;

另外关于页还有很多隐藏入口:
- 点击 7 次右上角图标,会出现一个金戒指动画彩蛋。这是我向女朋友变「魔术」的道具——从屏幕中取出一枚金戒指;
- 点击 3 次版本号,会出现调试日志,只保留最近 400 行。主要是为了调试启动时序;
设置页同样花了很多精力。
小组件
为了尽可能体验 iOS 生态,我制作了两个小组件。一个是将你的菜品变为黑暗料理,一个是展示做了多少道菜。

黑暗料理的效果同样是使用了 Core Image 的像素化风格 CIFilter.pixellate() 和 CIFilter.hueBlendMode() 叠加色相。
结语
虽然前面提到我并没有用 Figma 设计全部界面,但设计上的探索少不了,这让我更专注、更享受设计。
这里的设计流程更像是「灵感驱动」,「用户体验」并非设计中心,而是作为底层基础。所以自己的项目大部分时候,脑中会先有个基础的界面,再在画布上探索额外的可能性。如果不行,才会回归基础的页面布局。
灵感的来源大多是瞬间的,所以只使用 Apple 的备忘录来管理 to-do。它启动速度快,功能也足够用。 但代价是:这种流程难以稳定复现,消耗大量时间和精力,放在公司产品的迭代中也不合适;设计过于跳脱,对其他设计同事可能也不太友好;灵感来源还不稳定。能将两者结合得很好,可能就是优秀甚至天才设计师吧。
对我这种普通人来说,灵感的来临可能便是最大的正反馈。
但也并非完全忽略用户体验。就像我始终倾向于实机验收 iOS App。在模拟器上点点,和拿到手上用拇指去按,差别很大;以最贴近用户的视角,能看出更多问题。

对话中我觉得最有门槛的是设计 shader,一不懂计算机图形术语,二不懂数学公式,三不懂算法名词。这些形而上的概念,只能采用和现实事物类比的方式来表述,效率很低。所以和 AI 聊天不代表就不用学习新知识了,否则人类只会成为流程中的短板。
设计始终都在。
开发复盘
全程使用 Cursor 开发。项目前期使用 Sonnet 4.5 最多,偶尔 plan 模式会用 Opus 4.5,整体而言比较一般,而且太贵了。我从 $20 升级到 $60 档位依然每月都会额外花费几十美元。中后期主要使用 GPT-5.3-Codex,默认的 medium 思考强度,幸福感一下就上来了,额度多出数倍,效果又很不错。推出 Composer 1.5 和 Composer 2 后,一些杂活基本让 Composer 模型做了,用不完根本用不完。
我觉得 Cursor 的优势在于:
- 更便宜的月付价格可以体验到更多的前沿模型;
- Composer 模型很不错,即使是 fast 模式也依然有很多额度。现在的 Grok 4.5 更不错,兼具性能和速度;
- 对于中国大陆用户比较友好;
在这个 App 上,由于是第一次接触 SwiftUI、比较好奇,所以会对每个需求「微管理」,而不是放手让 agent loop 跑几小时。可能是我精力充沛,也可能是需求都很简单?
下面记录一些第一次使用 SwiftUI 的开发复盘,主要由 Agent 输出内容,希望这些弯路能反哺后续的相关项目(当然,也有可能模型的进化会抹平这些认知缺陷)。
对于专业开发人士,更多的可能只会博君一笑。
SwiftData + CloudKit 硬约束
按「普通 SwiftData」的心智建模:加 @Attribute(.unique)、关系写成非可选、复杂结构直接用原生类型或随意嵌套。结果一上 CloudKit,编译/运行约束就冒出一堆,被迫大改模型与业务层。
踩坑:
- CloudKit 模式下不能用
@Attribute(.unique)——业务查重、合并要自己做。 - 关系必须可选;到处都要写
eatLogs?.count ?? 0,空关系时页面不能崩。 - 复杂类型(标签数组、食材结构等)实践中常落到 JSON Data 字段;代价是失去原生查询能力,筛选/解析要在应用层做。
- 模型字段要有默认值或设为可选;迁移与旧数据兼容的成本被低估了。
复盘:
要把 CloudKit 约束当成模型设计的前置条件,不要「先本地跑通再开同步」。唯一性、关系、可查询字段从第一天起就按云端规则设计;JSON 字段要有明确的编解码边界与验收清单。
local / cloud 双容器与 stale ModelContext
启动用本地容器,登录后再切换成 CloudKit 容器——看起来合理。但 ViewModel / Service 在 App 生命周期里长期持有某个 ModelContext,切换后仍在往旧库写。
踩坑:
- 现象:toast「删除成功」,列表看起来变了,冷启动或换容器后数据还在;或「操作成功」但写进了未同步的本地库。
- 根因是上下文漂移(stale ModelContext),不只影响删除,凡是「写入链路」都中招:编辑、图片美化回写、食材批量更新、分组批量操作、同步通知触发时机等。
- 登录前本地新增 ≠ 自动上云;只有发生在云库上下文里的改动才会进私有库同步。
复盘:
- 任何长生命周期、持有
ModelContext的对象,都要提供并主动调用updateModelContext/ 同步鉴权上下文 一类的入口。 - 容器切换后要回归测试:冷启动有/无数据、登录后切换、批量删除、分享同步等场景。
- 成功提示必须绑定
save()真实结果,禁止「点了按钮就 toast 成功」。
私有库同步的「假进度」与卡顿误判
同步大量图片时 UI 卡,直觉是「iCloud 传太多了」→ 想优化传输队列,或弹全屏模态 + 真实百分比进度条 挡住用户操作。
踩坑:
- SwiftData + CloudKit 不暴露每张
externalStorage图片的上传队列与进度;「真实同步百分比」基本做不到。 - 可观测、可优化的往往是:本地图片处理、编码、密集
modelContext.save()、共享 Zone 上传。 - 日志里常见:每次
save都触发 export;15 张图 save 太密时,首页被拖死——主因是本地写入策略,不是「进度条没做」。 - 把「用模态遮住卡顿」当作主方案,会掩盖根因,也伤害可操作性。
复盘:
- 先分清「系统 iCloud 传输」与「本地可限流工作」。优先做:批量保存节流、后台编码、写入队列;进度条只用于你能掌控的本地任务。不要承诺系统同步的精确百分比。
多图 / AVIF / externalStorage 踩内存与主线程
菜品多图直接存在模型的 Data 数组上(配合 externalStorage),列表/封面随手取 .first;编码有时同步跑在主线程;美化页关闭时又同步压一遍 AVIF。
踩坑:
externalStorage的[Data]一碰.first就可能整包进内存——缩略图优化若仍触达大数组,等于没优化。- AVIF 同步编码 → 主线程卡住;「打开美化异步一次、关闭再同步一次」会把卡顿叠在交互结束瞬间。
- 批量加图(如 >10 张)+ 频繁 save + 首页全量
@Query+ 大图解码,体感是「首页卡死」。 - Widget / 启动路径若再在主线程解码缩放,会放大成「打开 App 就卡」。
复盘:
- 列表路径只碰缩略图字段或独立缓存;审计一切对
food_images_data的访问。 - 编码一律异步;关闭/保存路径禁止「再同步压一遍」。
- 批量导入要限流 save,并接受「大图进模型」与 CloudKit 同步的成本模型。
去背景图的「状态丢一半」
去背景靠文件名带 _nobg 之类语义判断;云端主要同步「当前显示的那张图」。重装/换设备后:只剩一版图,或「明明是去背景却被当成未去背景」。
踩坑:
- 文件名语义在重装、跨端回填时不稳定。
- 缺少持久化的三元组:原图 / 去背景图 / 当前展示哪一版。
- 任一写入路径(新增、编辑、美化回写)若只写显示图,就会永久丢掉另一版本。
- 历史上已丢原图的数据无法凭空恢复,只能在再次编辑保存后纳入新结构。
复盘:
- 多版本图片功能从第一天就按「可同步的完整 payload」设计;所有写路径统一写入,禁止降级成单版本。展示态读显式字段,不依赖文件名约定。
UserProfile 多记录冲突
CloudKit 不能 unique → 同一 userIdentifier 出现多条 UserProfile。各处 fetch(...).first:首页、设置、登录保存、分享资料写入,命中顺序不稳定。
踩坑:
- 现象:头像/昵称在「本地版」和「云端版」之间抖动;多次冷启动表现不一致。
- 分享侧写
SharedOwnerProfile/ParticipantProfile若也取first,参与者看到的资料同样抖。 - 「有时同步本地、有时同步线上」其实是选主策略随机,不是 iCloud 随机传错。
复盘:
- 统一 确定性选主(例如:有头像 > 非默认昵称 > 更新时间新 > 创建时间新),合并优选字段后删除重复记录。所有读取、可编辑、分享写入等路径都走同一套
resolve/preferredAPI,禁止散落的.first。
CKShare:以为能靠 SwiftData Sharing,实际走 Zone-based
初期容易带着「SwiftData + CloudKit Sharing 开箱即用」或「根记录 CKShare」的设想;实际遇到空分享、接收方 0 条数据、权限不对、链接失效等问题后,才整体改成 Zone-based CKShare,并拆出双 Zone。
踩坑:
- 只 share 空根记录 → 接收方看不到菜品;应 share 整个 Zone 内的
SharedFood等记录。 - 用
CKQuery查共享 Zone 时,Schema 未配 QUERYABLE 索引会稳定返回 0;更稳的是CKFetchRecordZoneChanges。 - Zone-based share 时
metadata.rootRecordID.zoneID可能为空,直接recordZone(for:)会zoneID can not be nil崩溃。 - 产品要「仅查看」时,要显式设
publicPermission等,系统分享 sheet 默认协作选项会踩坑。 - 仅菜品只读不够:接收者要改自己的头像昵称给邀请者看 → ShareZone(只读)+ ProfileZone(可写) 双 Zone,再加 Universal Link 打包双 URL。
- SwiftData 私有库 Zone 与自定义 Share Zone 不是一回事;两边数据模型与同步通知要分开想清楚。
复盘:
- 实时共享前先定产品:快照 vs 实时、只读 vs 可写资料。架构按 Zone-based + 明确权限设计;接收链路要防空 zoneID;查询优先变更拉取。这是整条产品线里绕路最长、文档与直觉落差最大的一块。
首页折叠动画:matchedGeometryEffect + 双视图
分组展开/收起做成两套 UI,用 matchedGeometryEffect 做「同一元素」过渡,期望系统 magically 补间。
踩坑:
- 典型后果:重影、跳变、收起态位置不对;修一个边距又引出 GeometryReader 高度预算问题。
- 双视图生命周期与 identity 稍有不一致,匹配就崩。
- 后续紧凑布局、「展示标题」开关等,还要和折叠态内边距、首行与网格间距常量一起算,动画方案选错会持续还债。
复盘:
- 折叠类交互优先 单数据源 + 单视图路径 驱动布局变化;
matchedGeometryEffect要用在真正同一视觉元素、identity 稳定的场景。复杂列表动画先做可预测的高度/opacity,再考虑匹配几何。
启动首屏决策与会员态时序
首帧直接用 @Query.isEmpty 决定空态/模板/列表;会员模板用 isProMember == false 当「非会员」;随机模板函数顺手写进 UserDefaults;数据一到就用默认 selectedTemplate 补 displayedFood。
踩坑:
- 首帧 Query 未回填 → 空态闪屏,有数据的用户也被闪一下。
membershipStatus初始.unknown,isProMember == false同时表示「未订阅」和「还没加载完」→ 冷启动把会员当非会员,默认模板被写回或误展示。- 本地 Debug 正常、TestFlight 复现:CloudKit 数据到达更晚/更乱序时,
foods.count变化先抢跑补了默认模板,后续正式选择时看到displayedFood != nil就跳过。 - 带副作用的
random(...)(写设置、空候选强制.default)会绕过用户「关掉默认模板」的配置。
复盘:
- 路由:先门控(启动态稳定)再落地;不要用首帧 emptiness 做最终结论。
- 会员:至少把三态拆开成
unknown/ 明确会员 / 明确非会员;冷启动可用轻量缓存,但 unknown 不做破坏性写入。 - 不要让「读配置 / 随机」函数隐式持久化。
- 若某字段(如
displayedFood)被当成「已完成选择」的信号,禁止其它刷新路径抢先赋值。 - 启动类 bug 必须用日志对齐 TestFlight 真实时序,不能只信模拟器。
StoreKit:本地 .storekit ≠ 审核 / TestFlight 环境
Xcode + 本地 ChiefMenu.storekit 购买流畅,以为订阅链路已通;审核员说看不到订阅选项,或优惠码提示「无法使用此促销优惠」;模拟器弹出「你已取消购买」被理解成「订阅被取消了」。
踩坑:
- TestFlight / App Review 走的是 App Store Connect 沙盒商品,不是工程里的
.storekit文件。 - 商品本地化未就绪、未随版本勾选提交、付费协议未生效等,都会导致审核设备拿不到 IAP。
.userCancelled是用户关掉购买 sheet,不是「会员已取消订阅」——文案若写成「你已取消购买」容易误导调试者。- 权益入口若只在部分页面 gating,美化 API 等仍可能被登录非会员调用(见第 13 条)。
复盘:
- 上架前用真机 Sandbox / TestFlight 做完整购买、恢复、重装恢复;把 ASC 商品状态与版本附件当发布门禁。错误文案区分「用户取消本次购买」与「订阅失效」。本地 StoreKit Configuration 只用于开发,不能当验收环境。
- 作为人类插一嘴:其实这里核心的问题是:商务那里我少填写了一张美区税单,导致绑定的银行卡审核一直没过,进而导致订阅那里状态一直不对。填写全部税单后就正常了……
Widget 拖累主 App 启动
小组件要真实菜品图 → App Group 写快照;菜品一变就 reloadTimelines;同步服务标 @MainActor,解码缩放默认在主线程。结果:打开 / 热启动卡顿,先被怀疑成「iCloud 同步」或「首页太重」。
踩坑:
- Widget Extension 与主 App 的 双
@main、重名源文件进两个 target →Multiple commands produce等编译坑。 - 启动路径上同步做 Widget 数据准备,会直接抢首帧。
- 调试时 Scheme 指到 Widget,会感觉「一打开就进小组件页」——和线上用户点图标行为不同,容易误判。
复盘:
- Widget 数据准备放后台、限频 reload;主线程只做必要调度。Extension 资源与源文件归属要干净。排查启动卡顿时把 Widget 同步单独打日志、可开关,避免和 CloudKit 问题搅在一起。
AI 批量写回与「假成功」提示
食材 AI 识别后批量写回多道菜;或美化完成后提示成功。UI 乐观更新,底层却是并发 + 多次 save + 可能已切换的 ModelContext。
踩坑:
- 部分菜品更新了、部分丢失;退出页后提示成功,图片又被旧数据覆盖。
- 分组/批量操作:选中态变了,落库没有。
- 本地写入失败仍
notifyFoodDidChange→ 分享 Zone 与私有库表现更乱。
复盘:
- 批量写回要串行或明确事务边界,返回真实成功/失败计数;成功提示只在
save成功后发。通知同步放在写入确认之后。AI 结果写模型前确认当前 context 仍是 active 容器。
会员 gating 漏入口
订阅页文案写了「解锁 AI 美化」等,列表入口也可能拦了,但美化页内部「点预览框 / 点生成」仍直接打 API——登录用户都能调用。
踩坑:
- 权益判断散落在文案、单个按钮、深层页面,漏一层就等于没门控。
isProMember与 StoreKit 异步状态耦合时,冷启动还可能短暂误判(与第 9 条叠加)。
复盘:
- Pro 能力列一张入口清单(识别、美化、云同步相关、分享、模板等),全部集中读
SubscriptionStore;在真正触发付费 API / 付费数据路径之前拦截并弹出会员页,而不是只藏入口。
本地化漏提取模式
以为「界面上的中文都会进 Localizable.xcstrings」。实际上大量文案先落成普通 String,再经 toast、ViewModel、FAQ 数据模型、枚举 title 传递,Xcode 不会稳定提取。
踩坑:
showToastMessage("请先选择菜品")一类直接/间接 StringFAQItem.question/answer、设置卡片title/description- 枚举
settingDisplayName、图标内容文案 - 看起来是
Text,数据却来自结构体字段
复盘:
- 新增用户可见文案时,优先
String(localized:)/LocalizedStringResource,不要先String再传来传去。对 toast、枚举显示名、内容型数据模型建立固定 checklist;需要搜索的字段要同时提供已本地化字符串版本(localizedStandardContains)。
iCloud 账号切换误报与清空
监听 CKAccountChanged 等,一变就提示「账号变了要不要清空」。冷启动第一次拿到状态也被当成「切换」;清空没有二次确认或文案含糊,存在误删风险。
踩坑:
- 需
hasCapturedInitialState之类门控,否则冷启动误报。 - 监听依赖常驻
CKContainer引用,否则通知可能根本不来。 - 切换 iCloud 账号时「本地数据会不会混进新账号」行为要产品化说明;清空必须 destructive + 二次确认。
- Sign in with Apple 退出与 iCloud 账号变更是不同事件,文案不能混用。
复盘:
- 账号变更 UX 要区分:登出 / 登入 / 切换;误报防护与容器常驻是功能正确性的一部分。清空本地是不可逆操作,流程上宁肯多一步确认。
结语
所以啊,这款 App 难的并不是产品设计、提需求,而是「本地模型 × 云同步 × 大图 × 会员时序 × 分享 Zone」叠在一起后的各种边界场景。说到底,弯路都出在「系统能力看起来自动,其实业务要自己兜底」的地方;兜住了,功能才算真正上线。
审核
实际的开发时间是从 11 月 16 日 到 4 月 16 日,正好有五个月。原本预计春节前就能完成,但实际还是低估了开发量。如果我能全职去做的话,应该会更快一点。但是我很穷,没钱,所以只能维持一份全职工作,利用业余时间一点点完成。
我原本以为国区上架最难的是备案,结果域名和服务器备案不到 1 周就一次通过了,随后公安备案用了 1 天也过审。App 备案也是不到 1 周、一次过审,公安备案貌似直接免流程了。我原以为 App 开发最麻烦的阶段已经过去了,没想到提交 ASC 审核才是噩梦的开始。
4 月 16 日开始提交审核,20 日进入 in review 状态,接下来 35 天毫无反应。邮件和加急申请都石沉大海。5 月 25 日我听从评论区建议,撤回了审核,然后重新提交。6 月 2 日进入 in review 状态,然后一周过去了又没有任何反馈。
6 月 9 号,WWDC26 第一天,我给 Tim Cook 写了封邮件。

结果当晚我就收到了反馈。改了两次,也就是写邮件的 3 天后,App 上架了。
唉。
后话
自己做产品的好处是,关于产品的一切信息都在大脑这个高速带宽中传递,不用和其他人对齐、开会。很爽。
所以我在等审核的时候,又陆续开了其他坑,比如建立 Pokémon SVG Bench,迭代 Design Fragments 2.0 版本。一件接着一件,完全没有停下来写这篇复盘文章的节奏。后来我甚至想干脆不写了。因为 App 本身没什么特色值得宣传,我的执行又很失败。也写过梗概,但每次看完反而更没动力——就那些东西。
但最近看到了 Stephen Wolfram 悼念亡妻的文章,这是一篇真挚又深情的回忆录。共同生活的 36 年中,他记得很多细节。HN 的评论区有人摘出以前的一篇文章,Stephen 确实是一位喜欢记录的人。正因记录了历史中的点点滴滴,文字才会那么动人。
或许有时我把写作这件事看得太功利了,越来越把它作为一种宣传工具,而不是记录生活。今年的立秋,也就是撰写文章的今天,是我和老婆在一起的第五年,我觉得应该为她写一点什么。我突然间悟到,文章也是一种「存档」。前进的路上难免失败,但我们不会重回原点,正因有那些美好的记录在,我们得以重新站起走向未来。
干饭手册做的也是记录,一开始没往这方面想,现在看倒有些别样意义了。
如果你觉得文章对你有些帮助,可以请我的猫吃罐头 ↓
