#95 制作我的第一款 iOS App: 干饭手册

踩坑是最好的老师

#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。只不过设计基本都是我做的,所以会说得比较详细。

背景

任何产品的源头都是用户需求,干饭手册的需求源头是我的女朋友(现在是老婆)会做很多菜。由于会时不时开发新菜,菜做多了之后,有时会忘记自己会做什么,导致很多菜只吃过一次就被遗忘了。

干饭旅程图,制作于 2022 年

同时我们也发现,在人类干饭流程中,当时很少有人真的去解决从「考虑吃啥」到「决定吃啥」这一步。最常见的形式是随机菜品,但实际上总会多次随机,效率很低。与其这样,不如直接看到自己所有会做的饭菜,再去挑选。

MVP 阶段,我用飞书的多维表格简单记录,她则是将菜品图保存在相册中。那个时候我就想做食材统计了。但我这边班味挺重的,看图也不方便,更不方便向别人分享。于是我们想做个小程序来记录,跨平台也方便。

飞书多维表格中记录会做的饭菜统计

当年还没有 Vibe Coding,所以只能委托别人来做。为此甚至注册了个体工商户。由于对方迟迟抽不出时间,这个项目基本只停留在设计阶段。2023 年失业的时候我想自学 SwiftUI 尝试一下,但是一个月后不小心找到工作了也只能作罢。

时间来到 2025 年 12 月,我开始有时间去实现这一承诺。

App 图标

最早的图标是老婆画的。她比较喜欢这种 MBE 风格。

干饭手册2022年logo

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

干饭手册2025年双主题logo

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

干饭手册 logo 在 Icon Composer 中的界面截图

Onboarding

请看 VCR。

0:00
/0:06

首先我用 SF Symbol 制作了文字版 logo 的路径动画,之前在 X 分享过。因为本地化,我又如法炮制了其他三种语言的动画。

0:00
/0:32

用 SF Symbol 制作路径动画好处在于,省去了第三方依赖,调用也很简单,用 isActive 控制即可。但 SF Symbol 本身导出的代码不带激活属性,踩了个小坑。应该是:绘制出现 true → false,绘制消失 false → true。

干饭手册文字版 logo 在 Assets.xcassets 的界面截图
    Image(LocalizedBrandAsset.logoTextAnimationName)
        .renderingMode(.template)
        .resizable()
        .scaledToFit()
        ...
        .symbolEffect(.drawOn.byLayer, isActive: isDrawActive)

缺点是 Symbol Image 本身体积也不小,几 KB 的 SVG 会膨胀到几十、几百 KB。对一些非路径的形状,插值也不太友好。

Onboarding 首页特意做成了一页,没有像以往那样要滑动很多次才可以登录。如果做成单屏内横滑,用户大概率不会去横滑,所以做成了默认纵向自动滚动、手动拖拽时暂停、2s 后恢复滚动的形式。点击后可查看 feature 详情。之所以能这么做,是因为每个 feature 其实没那么重要,用户只需要了解这是一个记录做饭的 App 就行了。

第二页是权限说明,展示 App 运行的所需权限。我很反感一打开 App 连续弹出索要权限窗口,尽可能交给用户去控制,或者渐进性地开启所需权限。

💡
@vanillaCitron 在 X 评论中提醒道,项目中相册读取和保存使用的 PhotosPicker 和 保存使用的 .addOnly 其实无需强制申请 .readWrite 相册权限,也就是说图中的「相册权限」其实可以去掉。会在之后更新中核实并移除。

大葱的插图是使用 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 动画(有音效),如下。

0:00
/0:04

打开美化页[2]。这是一个常驻的深色背景页面,可以裁切、旋转、移除背景和 AI 美化图片。在按钮上方是一个图片切换的暂存区。比如你用 AI 生成了多张图片,都可以在这里查看、下载和删除。

[2] 说是美化页,其实是为了和「编辑页」区分,算是和 LLM 约定的一种消歧方式。

美化页功能

按钮都处于一个 GlassEffectContainer 中,所以切换时能看到液态玻璃带来的过渡效果。说起来这个裁剪功能还是 Gemini 3 Pro 写的,我挺满意,现在看也是《互联网珍贵 Gemini Coding 资料》了。

移除背景用了大家都在用的 VisionKit 的 VNGenerateForegroundInstanceMaskRequest。优点是本地处理、隐私友好、响应快,蒙版机制可以快速切换回原图;缺点是,画面复杂时识别不准确。

AI 美化可以使用会员默认模型(Seedream 4.5)或者自己的模型。也可以自定义 prompt。由于生成等待时间可能比较长,我又加了 shader 动画:

0:00
/0:19

这是第二版动画了,第一版动画是粒子效果,在小红书X 可以看到。调试 shader 并非易事,就算不用 shader 用 progress view 也不是不行。但是一件事做久了,总会想放松一下,在 vibe 时的放松方式就是做一些遵循人性欲望的事,偶尔解放下自己。

分享

由于没有后端服务器,内容的分享仅限于图片分享和神奇的 iCloud 分享(CKShare)。

图片分享分为了整体分享和单张图片分享。整体分享有点像菜单,所以做成了菜单样式。如果有精力,(在控制高度的范围内)我还是想做成图文混排的,以及更多的自定义选项(比如只分享某个分组)。

分享所有菜品
分享单张菜品

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

在线分享管理

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

通过粘贴链接或扫描二维码来接收分享。

接收分享界面

由于支持 Universal Links,所以直接扫码会出现 App icon。如果是在微信中打开,网页中会提示用默认浏览器打开,打开后也可以直接跳到 App,并弹出邀请。如果你和对方在通讯录互为联系人,那么用户名那里显示的是通讯录姓名……

设置

设置页入口在左上角头像处。在设计了传统的表单式设置界面后,我开始想能不能做得特别一点。此时已经和产品无关了,只是想做点设计。

大蒜徽章

我先设计了一个徽章,用于标识用户自己会做多少道菜。但我一直没找到合适的空间去放置,作罢。

接着又做了「抽卡」系统,如上左一图的右上方。每次打开 App 都会抽出一张不同的卡片。卡片主题定为了厨房果蔬,并加入水杯、牙线和我家两只小猫作为稀有卡片(彩蛋)。抽卡动画在这里查看。

全部的蔬菜卡片}

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

部分蔬菜卡片的矢量图形

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

[3] 「突然想到」大多是我做这类设计时的方法,零铺垫的 aha moment。

新旧设置界面

每次打开设置后是这种效果:

0:00
/0:19

你会发现头像、昵称、加入天数、菜品计数、随机卡片互相重叠。我个人主观认为,重叠就重叠吧!毕竟用户不需要从这里获取什么重要信息。

其他一些细节比如:

  • 这里其实只有菜品计数属于有用的新信息,所以它的图层层级一直在最上方;
  • 为每个元素设置了边缘阈值,确保元素面积不会超出屏幕边缘 20% 左右,防止被边缘裁剪;

下方设置包括:

  • 同步状态和设置:这里主要是同步信息和开关;
  • 会员中心:会员开通和管理。说起来做产品时没太关注这个页面,结果送审时发现审核人员几乎只关注这个页面;
  • AI 管理:开通会员后,可以手动添加自己的模型端点;
  • 食材管理:在这里可以批量让 AI 根据图片生成食材,(根据食用次数)查看食材的记录;
  • 食用记录:每点 +1 按钮都会按照时间戳记录在这里;
  • 个人信息:改头像和昵称,以及注销账户入口(审核需要);
  • 图标设置:更改 App 图标。我最喜欢百变怪那版,当时在玩 pokopia
干饭手册可设置的图标}
  • 启动页设置:启动页会随机选择一张图片展示,类似下面的模板我一共做了 11 个;你可以在启动设置中,开启或关闭某个模板,不让它出现在启动页。
    • 我觉得有趣的是,所有的启动页图片都是先在 Figma 中设计,然后使用 Core Image 滤镜组合实现。我之前在这篇推文分享过。
干饭手册部分启动页模板
干饭手册中开关展示启动页模板
  • 其他设置
    • 外观主题设置;
    • 首页排版设置:是否展示菜品名称;
    • 更改语言;
    • 是否展示启动页和启动动画;
    • 保存格式设置为 AVIF、PNG或者每次询问;
    • 设置不计数分组(比如有个外卖分组,想记录自己吃的外卖,但是不想计入自己会做的饭);
  • 通知历史:用于下发多语言通知;
  • 反馈或建议:简便的邮件反馈;
反馈界面、常见问题和邮件截图
  • 关于
    • 常见问题,如上中图,同一页面,支持搜索。
    • 为何制作这款 App:这类 App 很常见,为什么要做自己的;
    • 隐私协议:审核上架必备;
    • AI 使用披露:自己的产品都有责任公布 AI 使用状况;
    • 致谢:站在谁的肩膀上;
    • 关于我;
AI 使用披露、致谢和社交主页界面

另外关于页还有很多隐藏入口:

  • 点击 7 次右上角图标,会出现一个金戒指动画彩蛋。这是我向女朋友变「魔术」的道具——从屏幕中取出一枚金戒指;
  • 点击 3 次版本号,会出现调试日志,只保留最近 400 行。主要是为了调试启动时序;
0:00
/0:17

设置页同样花了很多精力。

小组件

为了尽可能体验 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 / preferred API,禁止散落的 .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;数据一到就用默认 selectedTemplatedisplayedFood

踩坑

  • 首帧 Query 未回填 → 空态闪屏,有数据的用户也被闪一下。
  • membershipStatus 初始 .unknownisProMember == 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、重名源文件进两个 targetMultiple 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("请先选择菜品") 一类直接/间接 String
  • FAQItem.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 写了封邮件。

QQ 邮箱中给库克发送邮件截图

结果当晚我就收到了反馈。改了两次,也就是写邮件的 3 天后,App 上架了。

唉。

后话

自己做产品的好处是,关于产品的一切信息都在大脑这个高速带宽中传递,不用和其他人对齐、开会。很爽。

所以我在等审核的时候,又陆续开了其他坑,比如建立 Pokémon SVG Bench,迭代 Design Fragments 2.0 版本。一件接着一件,完全没有停下来写这篇复盘文章的节奏。后来我甚至想干脆不写了。因为 App 本身没什么特色值得宣传,我的执行又很失败。也写过梗概,但每次看完反而更没动力——就那些东西。

但最近看到了 Stephen Wolfram 悼念亡妻的文章,这是一篇真挚又深情的回忆录。共同生活的 36 年中,他记得很多细节。HN 的评论区有人摘出以前的一篇文章,Stephen 确实是一位喜欢记录的人。正因记录了历史中的点点滴滴,文字才会那么动人。

或许有时我把写作这件事看得太功利了,越来越把它作为一种宣传工具,而不是记录生活。今年的立秋,也就是撰写文章的今天,是我和老婆在一起的第五年,我觉得应该为她写一点什么。我突然间悟到,文章也是一种「存档」。前进的路上难免失败,但我们不会重回原点,正因有那些美好的记录在,我们得以重新站起走向未来。

干饭手册做的也是记录,一开始没往这方面想,现在看倒有些别样意义了。


如果你觉得文章对你有些帮助,可以请我的猫吃罐头 ↓

带有微信赞赏码和文字 A kind and compassionate act is often its own reward. 的横幅图像

订阅 Design Scenes

发布最新文章时,会以邮件通知你
[email protected]
订阅