【開發日誌 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) — 郵件傳送協議的標準規格