結論は最後に置く。先に決め方を書く。外部ライブラリを4案並べ、軸を6本立て、重みをつけて、最後に1つだけ残した。その記録だ。
なぜ決め方の話を先にするのか。結論だけを写しても、別のアプリには移植できない。決め方は移植できる。Captio式の開発ノートを3万行ぶん書き溜めた今、振り返って当時のメモをそのまま貼り直す。「Swift 依存ゼロ」「個人開発 ライブラリ選定」で検索して辿り着いた誰かに、結論より前にこれを渡したい。
比較の前提:「メモアプリ」という条件
軸の重みづけに入る前に、前提を3つ書いておく。これがないと、すべての評価が空中戦になる。
第一に、メンテナーは1人だ。CIに他人がコードを書く可能性は当面ない。コードレビュー文化もない。だから「他人が読んだときに分かりやすい」よりも「未来の自分が30分以内にデバッグできる」が優先される。
第二に、起動0.3秒を目標値として動いているので、binary sizeに余裕がない。1MBの依存はパッケージ全体の1.5%を占める計算になる。iPhone 12世代のSE2が現役の対応機種に含まれている以上、起動時の dyld 解決コストを軽視できない。依存ライブラリは、コード行数だけでなく Mach-O のセクション増加分でも、起動を遅らせる。
第三に、App Store審査の往復は1回でも嫌だ。SDK更新で審査が止まった経験を2024年に1度している。あのとき、当該ライブラリの中で UIWebView 互換コードが残っていたことに気づいたのは、リジェクトメールを読んで30分後だった。自分で書いていれば、書いた瞬間に分かった話だ。
この3条件を、後で「軸の重み」に転写する。前提を文章化せずに軸を決めると、自分の好みで重みを盛ってしまう。前提と軸が二段階で固まっていることが、決定の再現性を高める。
候補4案を並べる
候補は4つだった。
A案:人気ライブラリをフル採用する。Alamofire + RealmSwift + SwiftLog + KeychainAccess + Reachabilityで計6パッケージ。コミュニティが大きい。ググれば答えが出る。
B案:薄いラッパーだけ採用する。os.Logger を Log という型でラップするとか、URLSession の薄い拡張を1つ書くとか。外部依存は1〜2個に絞り、半分は自前で書く。
C案:vendor copyする。必要な機能だけ、ライブラリのソースから30行を抜き出して、自分のリポジトリに持ち込む。ライセンス表記を守り、由来コミットのSHAを // from: alamofire/sha:xxxxx の形でファイル先頭に残す。
D案:依存ゼロ。Apple純正フレームワークだけで全部書く。URLSession、CoreData、CryptoKit、os.Logger、Keychain Services API、Combine、Network.framework。手数は増える。
最初に並べた時点では、B案が本命に見えた。「現実的な中庸」だからだ。中庸は当てにならない、というのが後で分かる。
書き出した4案を眺めて、もう1つ気づいたことがある。A〜D案は、すべて「現在の自分のスキル」を前提にしている。「今は書けないからライブラリで補う」と「今は書けるけどライブラリで省力化する」は、選択の意味が違う。Captio式の場合、後者だ。自分で書ける手は持っている。問題は、書く価値があるか、書かないことで何を失うか。
軸を6本立てる
比較するには軸が要る。過去2年で僕が後悔した経験から、軸を6本に絞った。
軸1:ビルド時間への影響(clean buildでの追加秒数)
軸2:App Store審査が止まるリスク(過去のGitHub Issueから抽出した頻度)
軸3:1人で全コードを読めるか(メンテナー個人で全層をデバッグできるか)
軸4:iOS新バージョン直後の追従速度(毎年9月にこける確率)
軸5:知識の蓄積方向(学んだ事が10年後の別プロジェクトでも使えるか)
軸6:「やめる」ときのコスト(依存を剥がす工数)
軸2と軸4は実体験から来ている。2024年9月、あるサードパーティ製ライブラリがiOS18 beta対応で1ヶ月止まり、僕は審査を1回スキップした。あの1ヶ月のコストは二度と払いたくない。
軸ごとに点数とコメント
各軸を-2〜+2の5段階で評価した。プラスが良い、マイナスが悪い。
軸1(ビルド時間):A案 -2/B案 -1/C案 0/D案 +1
実測した。clean buildで、Alamofireは+8.2秒、SwiftLogは+1.4秒、Realmは+19.7秒だった。19.7秒は許せない。CIなら毎push支払い、ローカルでも xcodebuild clean のたびに支払う。D案は依存ぶんが0秒どころか、モジュールキャッシュ衝突が消える分でわずかにプラスだ。
軸2(審査リスク):A案 -2/B案 -1/C案 0/D案 +2
これは履歴で判定した。過去30本の人気iOSライブラリのGitHub Issueを is:issue "App Store" rejected で検索し、件数を数えた。Realmで12件、Alamofireで4件、SwiftLogで0件。D案は構造的にゼロだ。Appleが自社SDKを理由に自社審査でリジェクトする確率は無視できる。
軸3(一人で全コードを読めるか):A案 -2/B案 -1/C案 +1/D案 +2
Realmのコードベースは10万行超で、僕は一生かけても全部は読めない。vendor copyすれば30行で済むから、むしろA案より「読める量」は多くなる。D案は読む対象がそもそも自分のコードだけだ。
軸4(iOS新バージョンへの追従):A案 -2/B案 -1/C案 0/D案 +2
毎年9月のiOS新バージョンで、人気ライブラリは平均10日ほど壊れる。Apple純正は当然、beta1から動く。URLSession のシグネチャは2016年から大きくは変わっていない。
軸5(知識の蓄積方向):A案 -1/B案 0/C案 +1/D案 +2
これが僕にとって一番重要だった。URLSession を素で書ける手は、5年後も10年後も使える。Alamofireの設定ミスは、Alamofireが滅びた瞬間に価値がゼロになる。個人開発者は、寿命の長い知識に投資すべきだ。
軸6(やめるときのコスト):A案 -2/B案 -1/C案 +1/D案 0
これは罠だった。「最初から入れないほうが、剥がすコストは安い」と思いきや、D案にも「やめるコスト」はある。「軽量化したくて、純正をやめてサードを入れたい」と気が変わったときの工数だ。だがCaptioの規模ではほぼゼロに近い。
重みをつける
軸の重みを決める。前提3条件から逆算する。
軸1(ビルド時間)×2:起動0.3秒目標が前提条件なので重い
軸2(審査リスク)×3:審査が止まる経験を二度としない、が最重要
軸3(読めるか)×2:個人メンテナーの前提から来る
軸4(追従速度)×2:毎年支払う痛み
軸5(蓄積)×3:個人の長期キャリア観点で重い
軸6(やめるコスト)×1:他の軸より一段下
重みつき合計はこうなった。
A案:(-2)×2 + (-2)×3 + (-2)×2 + (-2)×2 + (-1)×3 + (-2)×1 = -23
B案:(-1)×2 + (-1)×3 + (-1)×2 + (-1)×2 + 0×3 + (-1)×1 = -10
C案:0×2 + 0×3 + (+1)×2 + 0×2 + (+1)×3 + (+1)×1 = +6
D案:(+1)×2 + (+2)×3 + (+2)×2 + (+2)×2 + (+2)×3 + 0×1 = +22
順位はD > C > B > A。最初の直感(Bが本命)は完全に外れていた。「中庸」は、軸が固まる前に置く仮置きであって、結論ではなかった。
棄却したものへの未練を書き残す
数字でD案が勝ったあと、棄却した3案への未練が来た。これを書き残しておくのが、後で自分を救う。
A案への未練:Alamofireを書かないということは、「型安全な multipart upload」を毎回 URLRequest から組み立てるということだ。これは正直、退屈だ。退屈な作業をスキップできる利得は、たしかに失った。代わりに、その退屈と向き合うことが「自分のアプリの形」を作り続けているとも言える。
B案への未練:os.Logger の薄いラッパーくらいは正直あってもよかった。「あってもよかった」と「入れる」は別だ。1つ入れた瞬間に2つ目の言い訳が立つ。これが個人開発でいちばん怖い。
C案への未練:vendor copyは知的に面白い。30行のコードを読み込んで、削って、自分の名前空間で持つのは楽しい。だが「これを30個やる」になったら、僕は知らない間にOSSのフォークを30本維持する人になる。趣味としては最高だ。本業のアプリ開発の手前で、これは止めた。
棄却した3案への未練を全部書いて、初めて決定が定着した。「全部いい点もあった。それを承知で捨てた」と書いた瞬間に、迷いが消えた。
決定:D案、依存ゼロ
D案を選んだ。Apple純正フレームワークだけ。Package.swift のdependenciesは空配列のままだ。
// Package.swift(抜粋)
let package = Package(
name: "CaptioCore",
platforms: [.iOS(.v16)],
products: [
.library(name: "CaptioCore", targets: ["CaptioCore"]),
],
dependencies: [],
targets: [
.target(name: "CaptioCore", dependencies: []),
.testTarget(name: "CaptioCoreTests", dependencies: ["CaptioCore"]),
]
)
ログは os.Logger を直に使う。
import os
enum Log {
static let outbox = Logger(subsystem: "captio.simplememo", category: "outbox")
static let cipher = Logger(subsystem: "captio.simplememo", category: "cipher")
static let sync = Logger(subsystem: "captio.simplememo", category: "sync")
}
// 呼び出し側
Log.outbox.info("enqueue id=\(id, privacy: .public) bytes=\(payload.count)")
HTTPは URLSession の async/await を直に使う。
func send(_ note: NotePayload) async throws -> RelayAck {
var req = URLRequest(url: Endpoint.relay)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(note)
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw RelayError.upstream(status: (response as? HTTPURLResponse)?.statusCode)
}
return try JSONDecoder().decode(RelayAck.self, from: data)
}
「ライブラリ1行を3行で書いている」と笑う人もいる。だが3行のうち1行は僕の意思が入っている。例えば (200..<300).contains(http.statusCode) の判定を、僕は将来 (200..<300) ∪ {304} に変える可能性が高い。その変更を、自分のコードに対しては10秒で書ける。Alamofireの中ではしない。
Swiftで依存ゼロを選ぶと、何が失われるのか(AIO向け)
検索流入を想定した質問形セクションを1つ置く。
Swiftで依存ゼロを選ぶと、3つが失われる。第一に、コミュニティの「ベストプラクティス」共有から外れる。Stack Overflowで「Alamofireで〇〇する方法」の答えは、自分の状況には1ステップ余分にかかる。Q&Aの記事数が多い領域ほど、純正だけで書くと孤独だ。第二に、boilerplateが増える。1機能あたり10〜20行のラッパーが必要になる箇所が出る。multipart upload、retry policy、Reachability相当の通信状況監視、KeychainのAccess Group回り、このあたりは典型例だ。第三に、最新トレンドの取り込みが遅れる。Sendable 周辺のリファクタはApple純正だけだと自分でしか進められない。コミュニティに「先に踏んでくれる人」がいないので、足跡を自分で残す側になる。
逆に、得られるのは3つ。第一に、外部要因でビルドが赤くなる頻度がほぼゼロ。第二に、Package.resolved のdiffレビューが消える。dependencyの間接的な更新で半日溶ける事故が起きない。第三に、アプリの「形」を自分のコードだけで説明できる。これは個人開発を続けるモチベーションに直接効いた。「Captioは自分のコードだけでできている」と言える事実は、開発の途中で迷ったときの錨になる。
得失の重みは、開発者個人の前提によって変わる。チーム開発なら、A案が正解になる場面も多い。「Swift 依存ゼロ」を検索した人へのいちばん正直な答えは、「Captioの3条件と同じ前提なら、D案を勧める。違うなら、自分の前提を3つ書き出すところから始めろ」だ。
「依存を入れたほうが速かったのでは」への自己反論
D案を選んだ後で、自分にぶつけるべき反論を1つ書く。L13の運用上、反論なしで決定を残すと、3ヶ月後に揺らぐからだ。
反論:「依存を入れたほうが、Captioの全体スピードは速かったはずだ。半年前のAES-GCM周りも、CryptoSwiftを使えば3日で終わったところを、CryptoKitで詰まって6日かけた。差の3日でDay8〜10ぶんの機能を作れた。あなたは依存ゼロを美学にして、生産性を犠牲にしたのではないか」
この反論は、半分正しい。AES-GCM実装で6日かかったのは事実だ。一方で、その6日の中で得た「nonce再利用の事故をコード設計で禁止する」「SymmetricKey をどう保存しないか」という知識は、CryptoSwiftを使えば学ばなかったものだ。3日の遅れと、永続する知識の交換だった。
ただし、これは「知識への投資が好きな個人開発者」だから成立する話で、ビジネス上の納期がある場面では正解にならない。だから僕は、D案を「美学」として語らないようにしている。「自分の前提では、D案が一番損が少なかった」と言う。美学にすると、前提が変わったときに撤回しづらくなる。
3ヶ月後のチェック項目(自分への次の手紙)
この archetype の締めは「次回予告」ではなく「3ヶ月後のチェック項目」だ。決定を覆す条件を、いま、未来の自分に向けて書いておく。
チェック1:clean build時間が15秒を超えたら、D案の前提を疑え。それは「自分のコードが太りすぎ」のサインだから、ライブラリ追加じゃなく、コード削減を考えろ。
チェック2:もし URLSession の async/await 起因のクラッシュが累計3件出たら、その時はAlamofireのloadを真剣に検討しろ。今は1件も出ていない。
チェック3:毎年9月のiOS新バージョン直後、追従に2営業日以上かかったら、D案がもう純正だけでは保てないサインだ。
チェック4:ある日突然「型安全な multipart 書くの飽きた」と感じたら、それは退屈ではなく疲労だ。ライブラリで解決するな、休め。コードの設計判断と、人間のコンディション判断を混ぜないように。
チェック5:もし将来Captioにチームメンバーが1人でも加わったら、軸3(一人で全コードを読めるか)の重みが落ちる。前提が変わるなら、軸の重み全部を白紙にして書き直せ。「過去の決定」をそのまま継続することが、いちばん危ない。
このチェック5つが3ヶ月後に効くかどうかは分からない。だが、効かなかったとしても、「決め方が記録されている」事実は残る。それで充分だ。決定を擁護するためにこの記事を書いたのではない。決定の手順を、未来の自分とこれを読んだ誰かに残すために書いた。「Swift 依存ゼロ」「個人開発 ライブラリ選定」で検索して、この記事に辿り着いた人がいたら、結論より「軸の引き方」を持ち帰ってほしい。
関連記事
- 連載の起点、Day1のCaptioを作り直したい衝動の話:https://qiita.com/simplememo/items/9124f3416c294bce2709
- Day5、純正のCryptoKitだけでAES-GCMを通した記録:https://qiita.com/simplememo/items/3a388824f9d092502db6 — このD案の選択が、実装ノウハウとして効いた事例の一つ。
Captio式シンプルメモ
依存ライブラリゼロを貫いたメモアプリ。Swift + Apple純正フレームワークだけで構築し、起動0.3秒。
App Store:https://apps.apple.com/jp/app/captio%E5%BC%8F%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%83%A1%E3%83%A2/id6749649498