avatar

leviegu

2026年10月8日
从构图蒙版到原生相机:蒙版相机开发复盘
#iOS#SwiftUI#Vibe Coding#开发复盘#AI 撰写

最近决定把 蒙版相机 归档。这是一个用构图模板辅助取景的相机项目:用户选择模板,在实时画面上参考引导线调整主体位置,然后拍摄并保存到系统相册。蒙版本身只出现在预览里,不写入成片。

最初看起来,这只是“相机预览上叠一层线条”的小工具。但从 Expo / React Native 原型,到 SwiftUI 迁移,再到原生相机重写,真正花时间的部分逐渐变成了设备能力、画幅与方向、Live Photo、异步保存,以及一次次真机验证。

这篇文章想回顾几次重要的设计转向。项目最后没有继续推进,但这段经历让我更具体地理解了:一个功能能跑起来,和一个产品值得继续做,是两件需要分别验证的事。

先验证一层蒙版有没有用

第一版的目标很克制:内置几种构图模板,让用户在真实相机画面上参考黄色引导线完成拍摄。

选择模板 → 授权 → 参考蒙版取景 → 拍摄 → 保存到系统相册

当时用 Expo Camera 提供预览与拍摄,React Native Skia 绘制引导线和标注,Expo MediaLibrary 负责相册写入。没有账号、云同步或照片管理,也没有拍完后再进入一个结果页。照片保存后,用户留在相机里继续拍就可以了。

这套实现适合验证最初的想法。对于已经熟悉 React 的开发者来说,页面、模板列表和状态流都能比较快地搭起来,也不必一开始就进入原生相机的细节。

早期还确定了一条后来一直保留的边界:模板保存构图数据,渲染层负责把它画出来。

模板里的坐标使用 0...1 的归一化值,不绑定屏幕像素,也不保存 Skia 或 SwiftUI 的运行时对象。换一个画幅或换一种渲染方式时,至少不需要把所有模板重新写一遍。

这次迁移真正延续下来的,主要是产品假设、模板几何和验证记录。页面与相机实现后来变化很大,但这些数据没有跟着技术栈一起失去价值。

外拍暴露的第一个问题,是怎么选模板

9 月的一次外拍留下了 26 张成片,覆盖 11 个模板。道路和河道能形成纵深,建筑正面能形成对称,小主体也能在大面积风景里获得比较明确的位置。

这些结果给了项目继续探索的理由,但它们还不能证明“使用蒙版一定拍得更好”。那轮没有同场景的无蒙版对照,也没有记录完成构图的时间或拍摄者反馈。能确认的是,一些模板确实容易产生可辨认的构图意图。

更明显的问题反而发生在拍摄之前:新手站在现场,未必知道应该选哪个模板。

一个几何上合理的模板,放到不合适的场景里,仍然可能得到缺少主体的照片。例如,天空主导的模板可以帮助安排地景位置,却不能让原本没有层次的天空变得有趣;滨水场景即使分好了天空、岸上和水面,也还需要一个值得拍的主体。

这让我意识到,增加模板数量并不会自动降低拍照门槛。真实示例、适用条件和少量容易比较的候选,可能比再增加几组引导线更有帮助。

我们曾经考虑过用本地 AI 理解纯景画面,再推荐模板,也做过原生相机接入方向的探索。但这条路线后来停止了,AI 推荐没有成为最终实现。后来的产品范围收敛到两件事:先把 iOS 相机做可靠,再通过内容和选择路径帮助用户找到合适的模板。

从 Expo 转向 SwiftUI,重点逐渐回到相机

原型阶段可以把相机理解成一个组件:传入配置,拿到照片,再保存出去。但当目标变成接近原生相机的使用体验时,这个理解就太粗了。

倍率显示对应什么设备能力?快捷倍率和连续缩放是否使用同一范围?前后镜头切换后哪些状态应该保留?预览里看到的范围与照片是否一致?Live Photo 不可用时还能不能拍普通照片?这些问题都需要进入具体的相机实现。

项目先探索过原生相机层,随后转向 SwiftUI 原生 iOS 工程。UI 使用 SwiftUI,预览和采集使用 AVFoundation,照片写入使用 PhotoKit,图像与视频加工也主要使用 Apple 系统框架。

这不是一次把 React 页面翻译成 SwiftUI 就能结束的迁移。第一版原生相机完成了一轮基础能力后,我们又在 9 月下旬重写了相机,重新整理权限、会话、采集、处理与保存的边界。

回过头看,我更愿意把这次技术栈选择理解成对产品重点的调整。Expo 帮助我们较快地验证了构图蒙版;当主要工作变成相机控制和成片一致性时,直接面对系统能力更便于定位和验证问题。

代价也很明确:原先设想的跨平台覆盖收缩到了 iOS。归档时,仓库里的 Android 目录只有平台素材,没有可运行的应用工程。

相机重写:先分清每一段流程归谁

相机代码很容易膨胀。一个拍摄页面既要管理权限、启动会话和镜头切换,又要处理对焦、曝光、蒙版、快门反馈、图片加工和相册保存,最后任何小改动都可能影响整条链路。

重写时,我们逐渐把职责整理成了几层:

模块主要职责
页面与控件组合预览、蒙版和操作区域,管理页面交互与反馈
CameraController接收相机命令,管理可拍状态、生命周期和资源交接
CameraEngine在会话队列上配置设备、启停会话并发起采集
PhotoProcessor / LivePhotoProcessor加工已经取得的静态图和配对视频
CameraPhotoSaver接管处理、相册写入、资源清理与失败反馈

其中一个重要约束是,相机核心不认识模板。它不需要知道模板 ID、名称或几何元素,只接收本次拍摄需要的画幅和方向。蒙版覆盖层也不申请权限、不操作会话,更不参与成片加工。

后来又把图片与视频加工从 Camera 目录拆到 Photo。它们接收资源和输出要求,完成裁切、旋转、水印与编码;相机层继续负责采集、保存生命周期和 PhotoKit 写入。

真正值得拆开的,通常是输入、输出和生命周期已经不同的工作。仅仅把大文件分成几个文件,如果状态所有权仍然混在一起,问题也不会因此消失。

用户偏好不等于设备正在工作的模式

Live Photo 是一个很具体的例子。用户希望开启 Live,并不意味着当前设备、镜头、会话配置和麦克风权限一定允许它工作。

因此我们分开保存“用户想开启什么”和“现在实际启用了什么”。麦克风被拒绝或设备不支持时,保留 Live 偏好,降级成普通照片并说明原因;重新进入相机或切回支持的镜头时,再尝试恢复。

会话配置本身失败则仍然报告错误,不能把所有失败都包装成普通照片降级。这种区分让 UI 能表达真实状态,也避免因为一项可选能力不可用而阻断整个拍摄流程。

画幅正确,还不代表取景正确

相机开发里最容易被低估的是坐标与方向。

屏幕上有预览区域、蒙版坐标和点按位置;设备有自己的对焦坐标;照片有像素矩阵与方向标签;Live Photo 视频还有轨道变换和有效画面区域。它们不能直接当成同一套坐标使用。

一个看起来很小的“横拍”需求,就同时涉及蒙版如何旋转、最终照片如何旋转,以及静态照片与视频能否保持一致。真机还出现过左右横握时,系统设备方向都报告为竖屏的情况,因此最后结合 Core Motion 的重力信息判断握持方向。

我们后来明确了两条规则。

首先,预览与蒙版使用同一块布局区域,显示比例由选中的模板变体派生。模板尺寸切换只改变画幅和覆盖层,不因为换一层引导线就重建相机会话。

其次,在按下快门时冻结这次拍摄的画幅、模式和方向。后续用户切换模板或调整尺寸,不应该改变已经发起的拍摄与保存任务。

静态照片最终要得到比例与方向正确的像素,不能只修改 EXIF 标签来掩盖像素本身的错误。Live Photo 的封面与视频则需要消费同一份拍摄要求,同时保留配对标识、声音和时间元数据。

这里也必须承认验证边界:部分比例和横竖拍已经实测,但预览四边与成片内容在所有镜头、倍率和方向下的精确对应,没有完成完整验收。比例正确只是一个检查项,不能代替取景一致性。

快门反馈、继续拍摄和保存完成是三个时间点

开发中一个反复出现的反馈是:按下快门后等待太久。

最直接的办法是增加闪屏和触感,让用户知道按钮已经生效。但反馈只能改善感知,不能减少采集、加工或保存的实际耗时。

进一步拆开流程后,我们区分了三个时间点:

  1. 应用接受了这次快门,给出即时反馈。
  2. 系统已经允许下一次拍摄,应用也还有处理额度。
  3. 这张照片加工完成,并成功写入相册。

第二次拍摄不必等待上一张照片完全写入相册。采集资源交付后,保存任务可以独立继续,页面则根据系统 readiness 和应用负载决定什么时候开放快门。

但这里也不能无限并发。多张全尺寸照片同时展开,再叠加 Live Photo 视频导出,会带来内存和资源竞争。最终应用对采集、处理与保存合计设置了最多 3 张进行中照片的额度,而且这个额度跨页面共享,不能退出相机再进入就绕过限制。

处理后的静态图可以先生成一张小缩略图,让用户更早看到拍摄结果。它仍然只是临时预览,不能被当作保存成功;如果后续处理或写入失败,需要撤回对应预览并反馈错误。

相机页面的生命周期,也不再等同于已经交出的保存任务的生命周期。离开页面后,任务仍能在应用进程内继续处理,失败由根页面接收。不过,系统挂起或终止应用后的保存保证没有完成,这仍然是归档时保留的限制。

性能优化:先测量,再决定改哪里

早期我们很容易把“拍照慢”笼统归因于图片处理或写相册。加上分段日志后,才看到实际链路里存在很多不同的等待:系统交付静态图、Live 视频录制与收尾、图片解码和绘制、编码、视频导出,以及 PhotoKit 写入。

每次拍摄使用独立 ID 关联日志,时间从同一个单调时钟计算。这样才能知道一张照片究竟在哪个阶段花了时间,而不是把不同拍摄的回调拼到一起。

先减少重复生成图片

最初的静态图处理会先完成方向归一化和裁切,再生成另一张图片做最终旋转。对于全尺寸照片,两次绘制意味着额外的位图生成与数据处理。

后来把方向归一化、中心裁切和最终旋转合到同一次绘制里,水印也在最终输出坐标中加入,最后只做一次编码。目标是减少应用侧重复生成图片,而不是承诺底层系统只执行一个渲染 pass。

我们还实验过更窄的快速路径:当源像素已经满足输出要求、无需真正裁切或旋转时,复用编码资源;视频如果只需要改变显示方向,也尝试过仅调整轨道变换,避免重新编码。

这些实验确实得到过更短的处理时间,但后来正式版本要求照片与视频都加水印,像素必须重新加工,相关快速路径也随之移除。

这次取舍很有代表性:一个实验方案可以有效,却不一定符合最终产品要求。历史上测到的最快数字,也不能继续作为正式版本的性能描述。

视频水印要在同一份素材上比较

Live Photo 水印最初使用 Core Animation 合成。为了判断成本究竟来自转码还是水印,我们对同一份原始 MOV 做了三种导出:

实验路径视频导出中位数
不加水印,但强制重新导出651.6 ms
Core Animation 水印1114.8 ms
Core Image 水印661.8 ms

这组数据来自 6 张 4:3 样本,每张都对同一份素材运行三条路径,并轮换执行顺序。它不是冷启动测试,也不能代表所有设备、画幅或色彩条件,但至少支持了当时的判断:在这组素材上,Core Image 水印的导出耗时更接近基础转码成本。

这个过程并不是一次顺利替换。Core Image 首轮真机实验因输出尺寸校验失败,没有得到成功样本;本地合成素材通过,也没有提前暴露真实视频的编码尺寸与有效显示区域之间的差异。补充这些信息、修正有效画面处理,再检查方向、音轨与配对元数据后,才继续讨论速度。

后续正式保存样本的耗时也有所下降,Core Image 方案最终扩展到了全部支持的比例。但首拍额外等待仍然存在,多设备与全部输出组合的验收也没有完成。

有些调查最后没有得到修复

首张拍摄经常比后续拍摄更慢。一个自然猜测是静态图与视频同时加工造成资源竞争,所以我们实验过先处理图片、再处理视频。

结果是整张照片保存更慢,串行方案被撤回,恢复并行处理。

继续细分计时后,能看到首次视频导出到首次合成请求之间有额外等待,静态 renderer 在绘制闭包结束后也还有成本。但这些信息不足以证明根因,更不足以支持随意加入预热或缓存。

Release 的 Instruments trace 和 Debug 的分段日志也不能直接混在一起推导结论。CPU 热点能帮助定位工作发生在哪里,却未必解释另一个构建模式下的等待。

最后这项调查结束时,没有产出经过验证的首拍优化。把失败方案撤回,把不知道的部分留下来,比继续叠加一层看似合理的补丁更有价值。

AI 协作:让方案和验证一起留下来

这个项目继续沿用了 AI 深度参与编码的方式。和之前的全栈项目相比,原生相机开发让我更明显地感受到:AI 能很快写出一个实现,但它无法通过编译结果判断真机上的取景、触感、声音或播放效果。

我的工作也逐渐从描述一个功能,转向确认它的边界。例如,“拍得快一点”太宽泛,后来需要明确成:保持现有画质与成片要求,减少重复绘制,测量采集与保存各阶段,并确认快门何时可以再次接受请求。

当首次效果不满意时,继续让 AI 在现有代码上猜测和调参,很容易得到更多状态、更多补偿逻辑,却仍然没有解释问题。后来我们把协作规则收紧:先查系统推荐方式和成熟实现,明确适用条件,再做小范围、可撤回的实验。

快捷倍率切换就是一个例子。最终采用系统提供的 zoom ramp,而没有继续建立逐帧动画或额外的镜头补偿。系统能力能满足当前体验时,保留更小的实现也更容易验证。

另一件花时间但有价值的事,是整理文档。项目经历几次迁移后,旧文档里会同时出现 Expo、Skia、旧 SwiftUI 相机和新相机;如果都保留“当前”“已完成”的表述,下一轮开发就很容易混用它们。

后来我们分开维护实现清单、未完成事项、仍有效的设计,以及历史实验。旧方案注明过时范围,实验保留条件、数据、是否采用和剩余问题。这样 AI 再进入工程时,至少能分清哪些是代码事实,哪些只是曾经讨论过的计划。

构建通过、局部回归通过、真机样本正常和产品验收完成,需要分别记录。 这条规则不只约束 AI,也约束我自己对项目进度的判断。

最后为什么决定归档

归档时,蒙版相机已经具备模板到相机的导航、构图覆盖层、普通照片与带声音 Live Photo 的捕获和保存,以及比例裁切、方向处理、水印、缩放、对焦、曝光、闪光灯和前后摄切换等能力。搜索页也已经支持按名称与简介查找模板。

但它还不是完成交付的产品。首页分类与推荐仍有展示骨架,真实示例和模板选择体验没有完整落地,部分相机行为仍待真机验收,最新的相机 UI 设计也还停留在独立预览阶段。

在体验和对比同类产品后,我发现已经有产品较好地覆盖了蒙版相机想提供的核心拍摄辅助体验。继续沿着原来的方向投入,还缺少足够明确的差异和使用理由,因此决定停止推进。

这是基于这次使用与对比做出的产品判断。对我来说,功能还有多少没完成,并不是决定继续投入的唯一依据;更需要回答的是,用户为什么会选择这个产品。

我们没有为了延续项目,再强行追加 AI、社区或更多相机功能。代码、设计和实验记录保留下来,原有待办也被标记为停止推进。10 月 8 日,归档状态写入仓库。

回顾这段开发,最值得保留的经验有几条:

  1. 原型可以帮助验证想法,但有限的成功样本还需要对照,不能直接变成产品价值结论。
  2. 技术栈围绕核心能力选择;当重点进入设备与成像细节时,需要重新评估最初的便利性。
  3. 拆分代码前先确定状态、资源与生命周期归谁,特别是页面退出后仍要继续的工作。
  4. 性能优化需要清楚的测量边界、可比较的素材和成片正确性检查,也需要接受实验失败。
  5. 工程越接近能用,越应该回到用户选择它的理由,而不是只盯着还差哪些功能。

这次比最初预想走得更远,也留下了一些没有解决的问题。我仍然觉得这段探索有价值:相机能力调研、SwiftUI 实践、模板数据和真机验证经验都可以带到后续项目里。

只是下一次,我希望更早把技术验证、用户任务观察和同类产品对比放到一起。让原型尽快接触真实场景,也让继续投入的理由尽早接受检验。