【開發日誌 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。

參考文獻