【开发日志 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清除与网络响应完全分离。

一般应用程序的发送流程是这样的:

  1. 按下发送按钮
  2. 显示加载指示器
  3. 等待服务器的响应
  4. 成功的话清除UI,失败的话显示错误

在这个流程中,网络的延迟直接变成了UI的延迟。 我们的流程是这样的:

  1. 按下发送按钮
  2. 瞬间清除UI(动画:0.25秒)
  3. 后台网络发送
  4. 成功→安静地从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模式。

信息的发送流程如下:

  1. 发送按钮点击:信息AES-GCM加密后保存在Outbox(本地存储)
  2. UI清除:performSendAnimation即时清除画面
  3. 后台发送:从Outbox中取出信息发送到Relay API
  4. 成功:从Outbox删除信息
  5. 失败:等待发送,稍后重新发送
  6. 离线:用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。

参考文献