0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【開発日誌 Day6】依存ライブラリ4案を比べて、結局Apple純正だけが残った

0
Posted at

結論は最後に置く。先に決め方を書く。外部ライブラリを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.LoggerLog という型でラップするとか、URLSession の薄い拡張を1つ書くとか。外部依存は1〜2個に絞り、半分は自前で書く。

C案:vendor copyする。必要な機能だけ、ライブラリのソースから30行を抜き出して、自分のリポジトリに持ち込む。ライセンス表記を守り、由来コミットのSHAを // from: alamofire/sha:xxxxx の形でファイル先頭に残す。

D案:依存ゼロ。Apple純正フレームワークだけで全部書く。URLSessionCoreDataCryptoKitos.LoggerKeychain Services APICombineNetwork.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 依存ゼロ」「個人開発 ライブラリ選定」で検索して、この記事に辿り着いた人がいたら、結論より「軸の引き方」を持ち帰ってほしい。

関連記事


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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?