【开发日志 Day1】Captio太喜欢了,想再做一次“那个体验”
本页面由日语原文机器翻译而成。 阅读日语原文
Captio这个软件吗?
写好备忘录,按下发送按钮。 仅凭这一点,自己的邮箱就能收到备忘录。 启动的瞬间会显示键盘,写的话会发送,发送的话会消失。 没有任何多余的功能。 只是那样的应用程序。
但是“仅此而已”,非常舒服。
在通勤电车上想到的想法,会议中突然想到的任务,睡前闪过脑海的一句话——Captio立刻把这一切送到了邮箱里。 打开备忘录软件,选择文件夹,加上标题......完全没有麻烦。 那个“零摩擦”的体验,一旦知道就放不下。
那个Captio终止了服务。
尝试了几个替代软件,但都离那个体验还很远。 启动缓慢,UI有多余的要素,发送后留下备忘录——虽然可能看起来是微不足道的差异,但Captio创造的体验的本质,正是那些“琐碎的事情”的积累。
所以决定自己做。 再体验一次Captio。
这是“SimpleMemo”的开发日志Day1。 在这篇文章中,我们将分解Captio的体验,并记录在现代技术堆栈中如何再现它的所有技术判断。
1。 Captio的“什么”好——试着拆解体验
为了将Captio的体验语言化,试着分解为3的要素。
要素1:启动即输入(Instant Launch-to-Input)
Captio启动后,会立即显示键盘,可以输入文本。 没有在启动屏幕后显示主页画面,然后按下备忘录制作按钮......这样的步骤。 启动=输入。 这个“从启动到可以输入文本的时间”,我们称之为Time-to-Text。 Captio的那个,体感是0.5秒以下。
要素2:发送后立即消失(Instant Clear After Send)
按下发送按钮,画面会立即清除。“ 发送中......”没有时间盯着进度条等待。 写下的备忘录消失的瞬间,带着“啊,发的啊”的安心感,可以转移到下一个行动上。 这个“从按下发送按钮到UI通关的时间”,我们称之为Send-to-Reset。 目标是在感觉被等待之前让画面变空。
要素3:没有任何多余的东西(Nothing Unnecessary)
文件夹功能、标签功能、富文本、下拉菜单对应——Captio这些都没有。 只有文本输入栏和发送按钮。 这种洁度使认知负荷接近于零。“ 不需要考虑“应该做什么”的应用程序。 那是Captio。
这个3的要素,通过现代的iOS开发再现。 那就是“SimpleMemo”的任务。
2。 架构:为什么SwiftUI“不是”而是UIKit
在2026年的iOS开发中,选择UIKit而不是SwiftUI可能会显得异端。 但是,我们的首要目标很明确——Time-to-Text 500ms以下。
SwiftUI的`body`重新评估、`StateObject`的初始化、`@Environment`的解决——这些开销在普通的应用程序开发中是可以忽略的范围,但是实现“启动即输入”是不能忽略的。 1ms也想快速地聚焦在文本字段上。
在那里采用的是不使用Storyboard的UIKit直接构建。
在SceneDelegate中生成window,直接在rootViewController上设置ComposeViewController。 甚至跳过Storyboard的解析处理。
// SceneDelegate.swift
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = ComposeViewController()
window.makeKeyAndVisible()
self.window = window
}
然后用ComposeViewController的`viewDidAppear`,立即聚焦在文本视图上。
// ComposeViewController.swift
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
textView.becomeFirstResponder()
}
仅凭这个2个代码,从启动到键盘显示的路径是最短的。
目标是500ms以下。 之后通过画面录像测量的记录(2026年8月11日,iPhone 16e・warm启动)中,从点击图标到键盘显示完成是0.4秒。 在SwiftUI中很难细化地控制`@FocusState`和body的重新评价的时机,判断很难填充这条路径(两者不是在同一条件下测量比较的)。
UIKit可能是“旧的”。 但是“很快”。 在这个1点中,UIKit现在也是最好的选择。
3。 “发送后消失”一键实现的设计
为了再现Captio的“发送就会消失”的体验,有最重要的设计判断。 那就是将发送动画和UI清除与网络响应完全分离。
一般应用程序的发送流程是这样的:
- 按下发送按钮
- 显示加载指示器
- 等待服务器的响应
- 成功的话清除UI,失败的话显示错误
在这个流程中,网络的延迟直接变成了UI的延迟。 我们的流程是这样的:
- 按下发送按钮
- 瞬间清除UI(动画:0.25秒)
- 后台网络发送
- 成功→安静地从Outbox中删除/失败→在后台重试
// ComposeViewController.swift
private func performSendAnimation() {
UIView.animate(withDuration: 0.25,
delay: 0,
options: .curveEaseOut) {
self.textView.alpha = 0
self.subjectField.alpha = 0
} completion: { _ in
self.textView.text = ""
self.subjectField.text = ""
self.textView.alpha = 1
self.subjectField.alpha = 1
self.textView.becomeFirstResponder()
}
}
对于用户来说,按下发送按钮的瞬间,备忘录“消失”,进入可以写下下一个备忘录的状态。 网络的成败,在用户看不见的地方进行处理。
“但是,如果发送失败怎么办?”——回答这个疑问的是下一节的Outbox架构。
4。 防止溢出的Outbox架构
将发送UI与网络响应分开意味着可能会出现“UI被清除,但实际上邮件发送失败”的情况。 这是绝对不能容忍的。 用户认为“发送”的备忘录,实际上并没有发送——这对备忘录应用程序来说是致命的信任损失。
在那里采用的是Outbox模式。
信息的发送流程如下:
- 发送按钮点击:信息AES-GCM加密后保存在Outbox(本地存储)
- UI清除:performSendAnimation即时清除画面
- 后台发送:从Outbox中取出信息发送到Relay API
- 成功:从Outbox删除信息
- 失败:等待发送,稍后重新发送
- 离线:用NWPathMonitor监视连接,恢复时自动重新发送
重要的是,网络发送前保存在Outbox的顺序。 因此,即使应用程序突然崩溃,即使终端断电,Outbox上写的信息也会留下。
Outbox中保存的信息,通过使用CryptoKit的AES-GCM加密进行保护。 加密密钥保存在Keychain中,无法从应用程序的沙盒外访问。
// OutboxManager.swift
func enqueue(message: OutboxMessage) throws {
let key = try KeychainHelper.getOrCreateSymmetricKey()
let sealedBox = try AES.GCM.seal(
message.plainData,
using: key
)
let encrypted = EncryptedOutboxEntry(
id: message.id,
sealedData: sealedBox.combined!,
createdAt: Date(),
retryCount: 0
)
try persistenceStore.save(encrypted)
}
另外,因为全部由自己实现,所以对外部库的依赖为零。 CryptoKit、NWPathMonitor、URLSession——全部用Apple纯正的框架完成。 从第三方库的版本管理和安全审计中解放出来,对小规模团队来说是一个很大的好处。
5。 Relay API:邮箱的OAuth 不依赖配合的发送
Captio的发送机制没有正式公开。 开发方的终止通知提出的理由是,为了保持应用程序的稳定性和安全性,无法继续花费精力,所以发送方式没有被列为理由。 只是一般来说,依靠外部邮件API的发送,容易受到提供者要求变更和访问限制的影响。
我们选择了Cloudflare Workers + Resend API的构成。
Cloudflare Workers是边缘计算基础,在离全世界用户最近的数据中心执行代码。 来自日本的请求由日本的边缘处理,来自美国的请求由美国的边缘处理。 因此,与以往的无服务器(AWS Lambda等)相比,冷启动的问题几乎解决了。
邮件发送使用Resend API。 Resend是面向开发者的邮件发送API。
邮件认证流程
用户设置收件人邮箱地址时,使用6位数的验证码进行邮件认证。 这样可以防止向第三方的邮箱地址发送非法。
多层速率限制
为了防止非法使用,实施了多层的费率限制。
// Cloudflare Worker - Rate Limit Configuration
const RATE_LIMITS = {
devicePerMinute: 30, // 1デバイスあたり1分30回
devicePerDay: 200, // 1デバイスあたり1日200回
ipPerHour: 120, // 1IPあたり1時間120回
globalPerDay: 300 // サービス全体で1日300回(調整可能)
};
通过以设备单位、IP单位、全球单位的3层进行速率限制,可以阻止单一设备的暴走和僵尸网络攻击。
2026-09 追记:每个终端的上限和整体的1日的上限,在2026年6月被拆除。 现在的发送上限是每个IP地址的1小时的上限。
幂等性的保证
为了防止网络不稳定导致的重复发送,为每个信息分配UUID,在Relay API侧进行重复检查。 重新发送相同UUID的信息时,2第1次以后不发送邮件,返回成功回复。 因此,即使结合Outbox模式的重试机构,用户也不会重复接收相同的备忘录。
2026-10 追记:现在的发送用服务器,自己没有进行重复的确认。 将信息ID作为向Resend请求的幂等键(Idempotency-Key)传递,Resend记住相同的键24小时,对相同内容的请求返回与之前相同的响应。 因此,24小时内不会重复发送相同的备忘录(详情请见Relay API的设计)。
6。 今天做的事情:一点一点地消除小小的违和感
Day1的实施工作大部分都花在了重新审视星级评价对话框上。 App Store的评价是应用程序的生命线,但不能以损害用户体验的形式要求评价。
重新设计显示条件
将星级评价对话框的显示条件设置如下:
- 100通显示:仅对充分使用应用程序的用户显示
- 最低14天的间隔:距离上次显示14天以上
- 关闭后30天不显示:用户关闭时,30天不重新显示
星星图标的点击区域修正
Apple Human Interface Guidelines中,建议触摸目标的尺寸最低为44x44pt。 因为星星图标的点击区域不符合这个标准,所以修正了。
// StarRatingView.swift
private func createStarButton() -> UIButton {
let button = UIButton(type: .custom)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
])
return button
}
星星图标的扭曲修正
修正了UIStackView的`.fillEqually` distribution导致星星图标扭曲的问题。 将`contentMode`设置为`.scaleAspectFit`,将StackView的distribution改为`.equalSpacing`。
评价后的流程
- 5星:转入App Store的评论画面(`SKStoreReviewController`)
- 4星以下:安静地显示“谢谢”的吐司后结束。 不将负面评论引导到App Store。
7。 消除了History的显示状态不一致
在发送记录画面(History)中,发现了一个bug。 尽管已经发送完毕,但状态还是“发送中”不变的信息。
调查原因的结果,发现状态更新的逻辑有问题。 在以往的实现中,以信息的文本内容为关键来更新状态。 但是,如果多次发送相同内容的备忘录,基于文本的搜索无法确定正确的信息。
修正简单明了。 将基于文本的搜索改为基于ID的状态更新。 每个信息在Outbox保存时都分配了UUID,所以使用这个ID更新History的状态。
// HistoryManager.swift
// Before: テキストベースの検索(バグの原因)
// func updateStatus(forText text: String, status: SendStatus)
// After: IDベースのステータス更新
func updateStatus(forMessageId id: UUID, status: SendStatus) {
guard let index = entries.firstIndex(where: { $0.messageId == id }) else {
return
}
entries[index].status = status
persistEntries()
}
通过这个修正,无论发送多少次相同内容的备忘录,每个状态都会正确显示。
8。 对隐私的偏执的执着
备忘录软件会写私人信息。 密码、个人烦恼、商业想法——无法预测用户会写什么。 正因如此,隐私保护不是“充分”而是“过度”。
应用程序切换器的隐私覆盖
在iOS的应用程序切换器中,应用程序的画面会显示为缩略图。 为了不让应用程序切换器的画面看不到备忘录的内容,在应用程序转移到后台时显示隐私叠加。
2026-09 追记:在现在的应用程序中,这个保护没有生效(显示用的处理保留在源中,但没有被调用)。 显示中的备忘录可能会留在应用程序切换器的图像中。 详情请看“隐私设计”的报道。
短暂的URLSession
通常的URLSession将缓存、cookie、认证信息保存在磁盘上。 我们通过使用`URLSessionConfiguration.ephemeral`,使这些信息完全不留在磁盘上。 作为通信的缓存,为了不让备忘录的内容留在存储中。
日志的无害化
开发中的调试日志需要注意。“ 将发送的备忘录内容输出到日志中”这种看似无害的处理,可能会成为安全漏洞。 我们的政策很明确:
- 备忘录的内容,即使是DEBUG构建也不会记录在日志中
- 错误日志不使用`localizedDescription`,`type(of: error)`只记录
- 理由:因为`localizedDescription`可能包含用户输入
// NetworkManager.swift
// BAD: エラーの詳細をログに出力
// Logger.error("Send failed: \(error.localizedDescription)")
// GOOD: エラーの型のみをログに出力
Logger.error("Send failed: \(type(of: error))")
可能看起来偏执。 但是,为了让大家信任并使用备忘录软件,我认为这种程度的偏执是必要的。
9。 10语言对应:想传达给全世界的Captio用户
Captio以英语圈为中心,全世界都有用户。 为了向原用户传递“SimpleMemo”,从首次发布开始对应10语言。
对应语言:日语、英语、西班牙语、法语、德语、意大利语、葡萄牙语、韩语、中文(简体字)、阿拉伯语。
2026-09 追记:现在的App Store的记号,对应语言是日语、英语、西班牙语、法语、德语、葡萄牙语、俄语、中文(简体字)、中文(繁体字)、阿拉伯语的10语言。 上面列表中的意大利语和韩语,不包括在现在的记号中。
特别是阿拉伯语的RTL(Right-to-Left)对应是技术性的挑战。 不仅需要镜像文本的方向,还需要镜像整个UI布局。 通过始终如一地使用Auto Layout的`leading`/`trailing`约束,在RTL环境下也能实现自然的布局。
应用内语言切换
除了iOS 16以后的应用内语言切换之外,还实施了独特的语言切换机制。 语言切换时,通过`cross-dissolve`过渡替换ViewController,提供流畅的切换体验。
// LanguageManager.swift
func applyLanguageChange() {
guard let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.windows.first else { return }
let newVC = ComposeViewController()
newVC.view.frame = window.bounds
UIView.transition(with: window,
duration: 0.3,
options: .transitionCrossDissolve,
animations: {
window.rootViewController = newVC
})
}
10。 今天的学习:产品是“安心感”的设计
Day1结束后想到的是,这个产品的本质是“安心感”的设计。
总结一下技术性的数值:
- Time-to-Text:500ms以下(目标)
- Send-to-Reset:感觉不到等待的短暂(目标)
- 信息溢问题:用Outbox模式防止
- 加密:AES-GCM(CryptoKit)
- 外部库依赖:零
但是,这些数值都被归纳为“安心感”这个目标。
启动快→“随时都能马上写”的安心感。
发送后消失→“已处理”的安心感。
溢出对策→“未发送状态不会消失”安心感。
加密→“任何人都看不到”的安心感。
Captio之所以受到喜爱,并不是因为功能优秀。“ 因为彻底磨练了“可以放心使用”的体验。 我想继承那个。
11。 下次(Day2)预告
Day2计划致力于以下主题:
- 发送动画的精炼:从现在的0.25秒淡出,到更舒适的动画
- History状态更新的时机优化:实时更新 vs 批量更新的比较探讨
- 成功吐司真的有必要吗? :关于发送成功的反馈,探讨“不显示”的选项
以Captio的“一无所有”的洁度为目标,继续到Day2。
参考文献
- Apple Developer — UIKit Documentation — UIKit框架的官方参考。 用于SimpleMemo的UI构建
- Apple Developer — CryptoKit Documentation — 用于通过AES-GCM加密实现Outbox数据保护
- Cloudflare Workers Documentation — 作为邮件中继API的无服务器基础采用
- Chris Richardson — Pattern: Transactional outbox (microservices.io) — 发送的信息首先写在等待发送表(Outbox)上,其他处理发送的设计模式。 SimpleMemo在终端中使用了同样的想法
- RFC 5321 — Simple Mail Transfer Protocol (SMTP) — 邮件发送协议的标准规格