「このアプリ、たしか 2018 年に入れた A/B テスト SDK がまだ Podfile に残っていますね」— Claude Code のセッションでそう指摘されたとき、思わず手が止まりました。アプリ自体は 2014 年から動かしていて、当時の自分はその SDK を真剣に評価して導入したはずです。でも、いつの間にか管理画面にもアクセスしなくなり、SDK のメジャーアップデートに追従するコストだけが毎リリース小さく積み上がっていました。
個人開発で 12 年もアプリを続けていると、こういう「死蔵 SDK」(dormant SDK)が必ず溜まります。広告ネットワーク、分析ツール、プッシュ通知、A/B テスト、クラッシュレポート、リモートコンフィグ — どれも導入時は理由がありました。けれど、その理由はもう失われていて、Podfile・Package.swift・build.gradle の中にだけ生き続けています。
私の壁紙アプリは 4 本あって、累計 5,000 万ダウンロードを超えています。1 本あたり数十個の SDK が入っており、4 本合計すると 100 を超える依存を管理することになります。これを手動で棚卸しするのは、もう現実的ではありません。そこで 4 週間ほど Claude Code に剥離パイプラインを組ませて回した結果、合計バイナリを 18.3% 削減できました。冷たいビルドも平均 31 秒短くなり、4 本の Privacy Manifest の更新も必要なくなりました。
この記事は、その 4 週間で確立した「死蔵 SDK 棚卸し → 安全な剥離 → 回帰検知」の運用設計を、実コードと数値付きで残しておくものです。同じように「12 年分の堆積」と戦っている個人開発者の方の役に立てば嬉しいです。
なぜ「使ってない SDK」を放置すると毎月コストが発生するのか
死蔵 SDK を放置することの直接的なコストは、見えにくい場所に分散しています。私が 4 週間の運用で観測した範囲では、次の 4 つに整理できました。
バイナリサイズ : 1 SDK あたり 200KB〜2.5MB。4 本のアプリ × 平均 5 個の死蔵 SDK で、合計 12〜60MB の無駄が発生していました
冷たいビルド時間 : 各 SDK の依存解決と初期化で、Xcode の冷たいビルドは 1 SDK あたり 3〜8 秒長くなります。Claude Code でビルド待ちが発生するたびに、私の集中も切れます
Privacy Manifest / Data Safety : Apple は 2024 年 5 月以降、依存 SDK の Privacy Manifest を再帰的に集計します。使ってもいない SDK のデータ収集申告が、Apple/Google のレビューを巻き込む形で残り続けます
セキュリティアラート : GitHub Dependabot や Snyk が「使ってもいない SDK の CVE」で毎月通知を上げてきます。本当に対応が必要な PR との優先度判別が鈍ります
3 番目は特に厄介で、2019 年に吉祥寺駅上空で見た光の輪をきっかけに視覚作品を作り始めてから、私は壁紙アプリのプライバシー周りを「作品としての誠実さ」と紐付けて考えるようになりました。死蔵 SDK のデータ収集申告を残しておくことは、その誠実さに対して小さく嘘をついている状態に近いと感じます。
「剥がせる SDK」と「剥がせない SDK」を分ける 3 軸スコア
剥離の難しさは、依存マニフェストには現れません。Podfile で pod 'XyzSDK' と書かれていても、実際のコードで一切呼ばれていない場合もあれば、初期化コードだけ残っていて API はほぼ使われていない場合もあります。逆に、ほんの 1 ファイルだけが import していて、しかもその 1 ファイルが Bridging Header 経由で呼ばれていて grep に引っかからない、ということもあります。
そこで、Claude Code に判定させる前に、機械的にスコアリングできる 3 軸を定義しておきました。
Reach Score(到達度) : そのモジュールが、現在のエントリポイントから呼び出されているかを抽象構文木で追跡したスコア。0〜100
Symbol Density(記号密度) : ソース全体に対する SDK の公開シンボル出現密度。grep ベースで雑に取れる
Bridge Risk(ブリッジリスク) : Objective-C Bridging Header、Kotlin/Java 相互呼び出し、Method Swizzling など、静的解析で見えない経路の有無
この 3 軸で、たとえば Reach=0, Density=0.0001, Bridge=Low の SDK は「ほぼ確実に剥がせる」、Reach=0, Density=0, Bridge=High の SDK は「危険、剥がすには動的検証が必要」というように、Claude に投入する前段で粒度を揃えておきます。これをやらずに「全部の SDK を Claude に判定させて」と依頼すると、Claude は丁寧にすべての SDK を「リスクあり」と評価してしまい、結果として何も剥がせません。
Step 1: マニフェスト横断スキャナ(iOS / Android 両対応)
最初のステップは、4 本のアプリすべてから依存マニフェストを集めて、SDK の登場頻度マップを作ることです。下のスクリプトは Podfile.lock / Package.resolved / build.gradle / settings.gradle を一括で読み、SDK 名・バージョン・登場プロジェクト数を集計します。
# tools/scan_dependencies.py
import json, re, subprocess
from pathlib import Path
from collections import defaultdict
PROJECT_ROOTS = [
Path( "~/repos/wallpaper-app-a" ).expanduser(),
Path( "~/repos/wallpaper-app-b" ).expanduser(),
Path( "~/repos/wallpaper-app-c" ).expanduser(),
Path( "~/repos/wallpaper-app-d" ).expanduser(),
]
def parse_podfile_lock (path: Path):
text = path.read_text( encoding = "utf-8" )
pods = []
for m in re.finditer( r " ^\s + - ([\w\d / \- ] + ) \( (\d[ ^ ) ] * ) \) " , text, re. MULTILINE ):
pods.append((m.group( 1 ).split( "/" )[ 0 ], m.group( 2 )))
return pods
def parse_spm_resolved (path: Path):
data = json.loads(path.read_text( encoding = "utf-8" ))
pins = data.get( "pins" , []) or data.get( "object" , {}).get( "pins" , [])
return [(p.get( "identity" ) or p.get( "package" , "" ), p.get( "state" , {}).get( "version" , "" )) for p in pins]
def parse_gradle (root: Path):
deps = []
for f in root.rglob( "build.gradle*" ):
for line in f.read_text( encoding = "utf-8" , errors = "ignore" ).splitlines():
m = re.search( r " ( implementation | api )\s * [ ' \" ]([\w \.\- : ] + ) : ([\w \.\- ] + )[ ' \" ] " , line)
if m:
deps.append(( f " { m.group( 2 ) } " , m.group( 3 )))
return deps
def scan (root: Path):
found = []
for lock in root.rglob( "Podfile.lock" ):
found.extend(parse_podfile_lock(lock))
for spm in root.rglob( "Package.resolved" ):
found.extend(parse_spm_resolved(spm))
found.extend(parse_gradle(root))
return found
def main ():
inventory = defaultdict( lambda : { "versions" : set (), "projects" : set ()})
for root in PROJECT_ROOTS :
for sdk, version in scan(root):
inventory[sdk][ "versions" ].add(version)
inventory[sdk][ "projects" ].add(root.name)
rows = []
for sdk, info in sorted (inventory.items()):
rows.append({
"sdk" : sdk,
"versions" : sorted (info[ "versions" ]),
"project_count" : len (info[ "projects" ]),
"projects" : sorted (info[ "projects" ]),
})
Path( "inventory.json" ).write_text(json.dumps(rows, indent = 2 , ensure_ascii = False ))
print ( f "detected { len (rows) } unique SDKs across { len ( PROJECT_ROOTS ) } apps" )
if __name__ == "__main__" :
main()
このスクリプトは、私の 4 本のアプリで合計 137 個のユニーク SDK を検出しました。手動で棚卸ししていた頃の認識(80 個くらい)よりずっと多くて、最初に流したときは少し落ち込みました。
Step 2: Reach Score — 抽象構文木で本当に呼ばれているかを追跡する
次に、各 SDK が現在のエントリポイントから到達可能かを判定します。iOS 側は SwiftSyntax、Android 側は Kotlin Compiler の PSI を使うのが王道ですが、個人開発でそこまで重い解析基盤を維持するのは現実的ではありません。
そこで、私は「Index Store」を流用することにしました。Xcode の DerivedData/Index には、ビルド時にコンパイラが吐いたシンボル参照グラフが入っています。これを index-import ベースのツールで読めば、AST を手で構築せずに Reach Score を出せます。
# tools/reach_score.sh — Xcode Index Store からシンボル到達グラフを抽出
set -euo pipefail
PROJECT_ROOT = " ${1 :? usage : reach_score . sh < project_root > } "
DERIVED = " $HOME /Library/Developer/Xcode/DerivedData"
INDEX_DIR = $( find " $DERIVED " -maxdepth 3 -type d -name "DataStore" \
-path "*$( basename " $PROJECT_ROOT ")*" | head -1 )
if [ -z " $INDEX_DIR " ]; then
echo "Index Store not found. Build the project once in Xcode first." >&2
exit 1
fi
# 各 SDK モジュールについて、参照しているソースファイル数を数える
python3 - << 'PY'
import os, json, subprocess
sdks = [r["sdk"] for r in json.load(open("inventory.json"))]
results = []
for sdk in sdks:
# 公開ヘッダ・モジュールを参照しているシンボル数を Index から雑に grep
out = subprocess.run(
["grep", "-rIl", "-e", f"import {sdk}", "-e", f"@import {sdk}",
os.environ.get("PROJECT_ROOT", ".")],
capture_output=True, text=True
)
refs = [l for l in out.stdout.splitlines() if l.endswith((".swift", ".m", ".mm", ".h"))]
results.append({"sdk": sdk, "import_files": len(refs), "files": refs[:5]})
json.dump(results, open("reach.json", "w"), indent=2, ensure_ascii=False)
PY
import_files == 0 のものは、まずは「Reach Score = 0」候補です。ただしここで終わりにせず、Step 3 の Bridge Risk を必ず通すこと。私自身、最初の週に Bridge Risk チェックを飛ばして剥がして、Objective-C カテゴリ経由で呼ばれていた旧 SDK を壊してしまった失敗があります。
Step 3: Bridge Risk — 静的解析で見えない経路を Claude に評価させる
Bridge Risk は、機械では判定が難しい部分です。Method Swizzling、Objective-C カテゴリ、KVC/KVO、Kotlin reified inline 関数経由、Reflection、JNI — このあたりは Claude Code に評価してもらうのが現実的でした。
ポイントは、Claude に投げる前に 候補を絞り込んでおく ことです。137 個全部を投げると、Claude のコンテキストが汚染されて精度が落ちます。先ほどの Step 2 で import_files == 0 だった 28 個に絞ったうえで、各 SDK ごとに「リポジトリ全体に対して swizzle・+ load・@objc dynamic・KVC・Reflection・JNI を含むファイルが SDK 名を間接参照していないか」を Claude に判定させます。
# tools/bridge_risk.sh
for sdk in $( jq -r '.[] | select(.import_files == 0) | .sdk' reach.json ); do
echo "===== $sdk ====="
rg -l --type swift -e "swizzle" -e "+ *load" -e "@objc dynamic" \
-g '!Pods/*' -g '!Carthage/*' | head -20 > /tmp/candidates.txt
claude -p "次の Swift ファイル群が、SDK ' $sdk ' の機能を Method Swizzling・KVC・Reflection 経由で間接的に呼び出している可能性があるかを評価してください。出力は JSON: {risk: 'Low'|'Medium'|'High', reason: '...'}" \
--allow-tool=read \
< /tmp/candidates.txt
done
Claude には、必ず JSON 形式での出力を強制 してください。自然言語で返させると、後段のパイプラインに渡すときに parse エラーが頻発します。私の運用では、判定が Medium 以上の SDK は人間がもう一度確認する、Low だけ自動 PR 化する、というラインを引いています。
4 週間の運用で、この 3 段階フィルタを通すと、当初 137 個のうち「剥離 PR を自動生成してよい」と判断された SDK は 14 個に絞り込まれました。判断のすべてを Claude に任せず、機械的フィルタで荒く絞ってから Claude に意味解釈をさせる、という分業が安定運用の鍵だと感じています。
Step 4: 剥離 PR 自動生成 — Claude Code の Plan Mode を使う
剥離 PR の自動生成では、Claude Code の Plan Mode を使うのが安全です。いきなり Edit ツールで Podfile を書き換えさせると、依存解決ツリーの破壊や、SDK が暗黙に追加していた Build Settings の取りこぼしが発生します。
私が使っている Plan Mode 用の System プロンプトはこんな形です。
あなたは iOS/Android の個人開発リポジトリで、不要になった SDK を1つだけ剥離する PR を準備します。
入力: SDK 名 / 検出された参照ファイル一覧 / マニフェストファイルのパス
出力: 計画 (Plan) として次を提示します:
1. 削除対象のマニフェスト行(Podfile, Package.swift, build.gradle)
2. 削除対象の Import 文一覧
3. 削除すると壊れる可能性のある Build Settings / Info.plist エントリ
4. 動作確認ステップ(具体的なシミュレータ動線)
5. ロールバック手順
実装は行わず、Plan のみを出します。人間が approve したあと、Apply モードに移行します。
このとき、必ず Build Settings と Info.plist の影響を Plan に含める ことが大切です。広告 SDK は SKAdNetworkIdentifier を Info.plist に追加してくる、分析 SDK は App Transport Security の例外を追加してくる — こうした副作用が、SDK 削除と一緒に消えるかどうかを Plan 段階で見えるようにしておきます。
Step 5: 回帰検知 — 剥離後 7 日の自動モニタリング
剥離 PR がマージされたあと、本当に何も壊していないかを確認するため、7 日間の自動モニタリングを走らせます。これは Cloudflare Workers の Cron Trigger で毎朝動かしていて、Crashlytics と AdMob のメトリクスを取得し、剥離前の 7 日間と比較します。
// monitor/post-decom-check.ts (抜粋)
export default {
async scheduled ( _event : ScheduledEvent , env : Env ) : Promise < void > {
const baseline = await env. KV . get < Metrics >( "baseline:wallpaper-a" , "json" );
const current = await fetchMetrics ({
crashlyticsAppId: env. CRASHLYTICS_APP_ID ,
admobApp: env. ADMOB_APP ,
days: 7 ,
});
const crashDelta = (current.crashFreeUsers - baseline ! .crashFreeUsers) * 100 ;
const ecpmDelta = (current.ecpm - baseline ! .ecpm) / baseline ! .ecpm * 100 ;
if (crashDelta < - 0.2 || ecpmDelta < - 8 ) {
await notifySlack ({
title: "post-decom regression suspected" ,
body: `crashFree Δ=${ crashDelta . toFixed ( 2 ) }%, eCPM Δ=${ ecpmDelta . toFixed ( 1 ) }%` ,
rollbackHint: "git revert <PR sha> && pod install && fastlane release" ,
});
}
} ,
} ;
しきい値は、私の運用では「Crash-Free Users が 0.2 ポイント以上悪化」「eCPM が 8% 以上悪化」を発火条件にしています。eCPM のしきい値が広告ネットワーク剥離時の許容範囲とぴったり合うように、3 週目に 5% → 8% に調整しました。広告系の SDK を剥離すると、短期的に eCPM が下がるのは自然なので、3〜5% のドリフトはノイズとして許容しています。
4 週間運用の数値
ここまでのパイプラインを 4 本のアプリで 4 週間運用した結果は、次の通りです。
検出 SDK 数: 137(4 本合計、重複除去後)
剥離候補に絞り込まれた SDK 数: 14
実際に剥離した SDK 数: 11(残り 3 個は Bridge Risk Medium で保留)
剥離した SDK の種別: 廃止された広告ネットワーク 3、旧 A/B テスト基盤 2、旧分析 SDK 4、旧プッシュ通知 1、旧クラッシュレポート 1
バイナリサイズ削減: 4 本平均で 18.3%(最大 24.1%、最小 9.8%)
冷たいビルド時間短縮: 平均 31 秒(M3 MacBook Pro / Xcode 16)
観測された回帰: 1 件(Crash-Free Users が 0.18 ポイント低下 → しきい値未満、3 日で自然回復)
数値以上に大きかったのは、Privacy Manifest の更新負担が消えたことでした。Apple の SDK 一覧から廃止された 3 種類の広告 SDK は、もはやアップデートも来ない代わりに、毎リリースで Privacy Manifest 互換性レポートに「未対応」として表示され続けます。剥がしたことで、4 本分の Privacy Manifest 監査がすっきりして、これは個人開発者の精神衛生上もとても大きい改善でした。
個人開発で続けるための運用上の推奨
最後に、4 週間運用して見えた「個人開発で続けるためのコツ」をいくつか共有します。
月 1 回、1 本だけ走らせる : 全アプリ同時に流すと検証コストが膨らみます。1 ヶ月に 1 本ずつ、剥離 PR を 1〜2 個に絞って流すペースが、私には合っていました
Bridge Risk Medium は剥がさない : 静的解析の限界は静的解析の限界として受け入れます。Medium 以上はロードマップに残しておき、関連機能のリファクタが入ったタイミングで一緒に剥がします
広告 SDK の剥離は eCPM のセグメント別に評価する : 国別・端末別の eCPM を見ずに全体平均だけで判断すると、特定地域のユーザー体験を悪化させていることに気づけません
Plan Mode の出力をそのまま PR description に貼る : レビューする自分のために、Plan の 5 項目(マニフェスト行 / Import / Build Settings / 動作確認 / ロールバック)を必ず PR description に残します。半年後の自分が読んで意味が分かる形に整えておくのが、12 年運用してきて学んだいちばん大切なことです
死蔵 SDK を剥がしていく作業は、派手な機能追加ではありません。けれど、12 年動かしているアプリの内側を、もう一度自分の頭で理解し直す機会になります。Claude Code が機械的な棚卸しを引き受けてくれるからこそ、私は「これは本当に剥がしていいのか」という意味の部分に集中できます。
同じように長く個人開発を続けている方の参考になれば幸いです。お読みいただきありがとうございました。