CopyButton

2026年6月13日土曜日

古い iOS アプリを作り直さず近代化する:Realm 実機クラッシュ・OAuth 移行・StoreKit 2【2026年版】

Oyakodon for Mastodon のアプリアイコン(親子のマンモス) Oyakodon for Mastodon を App Store で見る

この記事は、放置していた自作 iOS アプリを Claude Code に近代化してもらった記録です。私はコードを1行も書いていないどころか、Mac にも Claude Code が動く PC にも一切触れていません。指示はすべてスマホから出しただけで、コード修正・調査・ビルド・TestFlight 配信・App Store 申請まで Claude Code が Mac にヘッドレス(SSH 経由)で実行しました。2017年に公開して以来ほぼ放置していた約9年もの(うち7年半は完全放置)の Mastodon ビューアの「何が壊れていて、何をどう直したか」を技術的にまとめます。

この記事でわかること

  • 放置した古い iOS アプリを、自分でコードを書かずに Claude Code へ近代化してもらう進め方
  • その作業分担(私が頼んだこと / Claude Code が私に求めたこと)
  • 結果として実現した技術的な更新(deprecated API 置き換え・OAuth 移行・Realm 実機クラッシュ修正など)

作業分担:私が頼んだこと、Claude Code が私に求めたこと

最初に私が Claude Code に頼んだのは、ひとことで言えば「7年半放置した Oyakodon を、作り直さずに直して、もう一度 App Store に出せる状態にしてほしい」でした。具体的な実装方針は対話しながら詰めましたが、エディタを開いてコードを書いたのは私ではなく Claude Code です。

Claude Code 側がやったこと(Mac にヘッドレスで):

  • Swift コードの修正そのもの(後述の Before / After はすべて Claude Code の変更)
  • クラッシュログ(.ips)を atos でシンボル解決して原因箇所を特定
  • curl で Mastodon API のレスポンスを叩いて挙動を切り分け
  • SSH 経由で Mac 上の xcodebuild archivealtool を回し、TestFlight へアップロード
  • 提出ビルドのバージョン番号(CFBundleVersion)の更新と、App Store Connect への審査提出(API 経由)。App Store の初期設定(アプリ枠・契約・基本情報)は旧アプリ時代に済んでいたので、私が App Store Connect に新規ログインする必要はなく、申請まで Claude Code が完結させた

私がやったこと(人間にしかできない部分だけ):

  • 「作り直さず近代化する」という方針を決め、スマホから指示を出すこと
  • TestFlight に上がったビルドを自分の iPhone で触り、挙動の異常をスクリーンショットで報告すること。後述の「起動直後に落ちる」「画面下端がズレる」はこの実機確認で見つかった。とくに WebView の位置ズレのように言葉で正確に説明しづらい不具合も、私はスクショを1枚送るだけで済み、それを読んで原因を切り分けたのは Claude Code だった

つまり私は Mac にも、Claude Code が動いている PC にも一度も触れていません。コードも見ていないし、App Store Connect にもログインしていません。私が物理的に関わったのは、手元の iPhone に届いた TestFlight ビルドを触って異常を伝えることだけ。Claude Code の役割は「調査・コード修正・ビルド・配信・申請」、私の役割は「方針決め」と「実機で触って異常を伝える目と手」という分担でした。

背景:何が「腐って」いたのか

題材は Oyakodon という個人開発の iOS アプリです。複数の Mastodon インスタンスを並べて管理できるビューアで、初リリースは2017年4月。つまりアプリ自体は約9年もので、2018年10月の更新を最後に約7年半は完全に放置していました。今回近代化したビルドは App Store(Oyakodon for Mastodon)で配信中です。久しぶりに開いたらビルドすら通らない状態で、原因は1つではありませんでした。

  • iOS SDK 側UILocalNotificationsetMinimumBackgroundFetchIntervalUIImageJPEGRepresentation といった deprecated(非推奨)API が積み重なり、value(forKey: "statusBar") のような private API はそもそもコンパイルを通さなくなっていた
  • Mastodon 側:Web UI の URL ルート変化(/web/timelines/home/home など)、API のバージョンアップ(v1 → v2)、ID の型変化
  • 依存ライブラリ:Realm(モバイル向けデータベース)のスキーマ変化、Firebase API 改訂、レビュー誘導ライブラリ Appirater のメンテ終了
  • 外部サービスの消滅:インスタンス一覧を返していた API(instances.mastodon.xyz)の廃止
  • 認証フロー:password grant(ユーザー名とパスワードを直接送る方式)が廃止され、Authorization Code flow への移行が事実上強制された

つまり「SDK・サーバー・ライブラリ・外部サービス・認証」の5方向から同時に腐食していたわけです。1個直すと次が崩れる、という典型的な放置プロジェクトの状態でした。

方針:作り直さず in-place で近代化する

最初に決めたのは「全面書き直しをしない」ことでした。Oyakodon の差別化機能は、N 個のインスタンスへ同時投稿するマルチポストと、インスタンスをまたいだ boost(ブースト=リポスト)です。この核心部分は既存の Swift コードでちゃんと動いていました。動いているものを捨ててゼロから書き直すと、その「動く」を再現するコストのほうが高くつきます。

そこで、アプリの骨格はそのままに、表面の腐食(deprecated API・廃止された認証・古い型)だけを順番に剥がしていく in-place 近代化を選びました。作業は Phase 0〜8 に分割し、各 Phase は「ビルドが green(成功)になる」ことを区切りにしています。

Phase 別の対応一覧(すべて Claude Code が実施)

Phase内容規模感
0iOS 17 ビルド green / CocoaPods → SPM 移行Pods/ 配下 917 ファイル削除、pbxproj を自動変換
1WebView タイムライン正常化・ログイン保持・設定の外部ファイル化URL ルート表 / CSS / JS を外部ファイルに分離
2廃止された instance picker 撤去・ドメイン直接入力に変更instances.mastodon.xyz 廃止への対応
3Status / Notification / Attachment の ID を Int → String 化比較ヘルパー実装・ユニットテスト16件
4OAuth を Authorization Code フローへOAuth 用 WebView 画面を新設・URL スキーム登録
5/api/v2/media + /api/v2/search 対応・旧 Twitter 連携の死コード削除メディア処理完了待ちのポーリング実装
6BGTaskScheduler + UserNotifications へ古い background fetch 廃止対応、iOS 10 ガード全削除
7Share Extension の型システム移行MobileCoreServices → UniformTypeIdentifiers、139 行削除
8StoreKit 2 への IAP(アプリ内課金)移行250 行 → 79 行に縮小

規模としては最終的に 949 ファイル変更・2,717 行追加・225,731 行削除。削除行が極端に多いのは、Phase 0 で CocoaPods(依存管理ツール)をやめて、コミット済みだった大量のライブラリバイナリを SPM(Swift Package Manager、Apple 純正の依存管理)に置き換えたためです。

技術的に何が変わったか:Before / After

ここからは Claude Code が入れた変更のうち、特徴的なものを Before / After で見ていきます。

1. WebView のログインを、コードが毎起動で壊していた

放置前のコードは、起動のたびにキャッシュをクリアする処理で、ログイン状態を保持する領域(localStorage と IndexedDB)まで巻き込んで消していました。「キャッシュをきれいにする善意」のつもりが、結果として毎起動でログアウトさせていたわけです。

// BEFORE: 毎起動でキャッシュ+ログインデータを全消去
func removeCache() {
    URLCache.shared.removeAllCachedResponses()
    WKWebsiteDataStore.default().removeData(
        ofTypes: [
            WKWebsiteDataTypeDiskCache,
            WKWebsiteDataTypeOfflineWebApplicationCache,
            WKWebsiteDataTypeSessionStorage,
            WKWebsiteDataTypeLocalStorage,       // ← ログイン状態を保持する領域
            WKWebsiteDataTypeIndexedDBDatabases  // ← 同上
        ], modifiedSince: Date(timeIntervalSince1970: 0), completionHandler: {})
    // Library/Caches 以下も全削除...
}
// AFTER: URLCache だけクリア。WKWebsiteDataStore には触らない
func removeCache() {
    URLCache.shared.removeAllCachedResponses()
}

修正は「触らない」だけ。WKWebView(iOS の Web 表示部品)のセッションは WKWebsiteDataStore が握っているので、ここを消さなければログインは保持されます。

2. Mastodon の ID は Int ではなく String で扱う

古いコードは Status(投稿)の ID を Int として保持し、NSString 経由で数値化していました。Mastodon の ID は時間とともに桁が増え続けるため、Int で扱うとオーバーフローの危険があります。素直に文字列のまま扱うのが正解でした。

// BEFORE: NSString 経由でパース(巨大 ID はオーバーフローの恐れ)
public class Status { public var id: Int! }

if let statusId = (item["id"] as? NSString)?.integerValue {
    status.id = statusId
}
let body = ["since_id": "\(sinceId)"]  // 毎回文字列に変換
// AFTER: JSON から String として直接取り出す
public class Status { public var id: String! }

if let statusId = item["id"] as? String {
    status.id = statusId
}
if !sinceId.isEmpty { body["since_id"] = sinceId }  // 空なら送らない

ID を String 化すると大小比較が文字列比較になってしまうので、「桁数が多いほうが新しい、桁が同じなら辞書順」という比較ヘルパーを実装し、ユニットテストを16件付けて担保しています。

3. OAuth:password grant から Authorization Code flow へ

Mastodon 側で password grant(ユーザー名とパスワードを直接トークンと交換する方式)が廃止されたため、ブラウザ経由で認可コードを受け取る Authorization Code flow へ移行が必要でした。

// BEFORE: password grant(廃止済み)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
                            username: String, password: String, ...) {
    let body = [
        "grant_type": "password",  // ← 廃止されたフロー
        "username":   username,
        "password":   password,
        ...
    ]
}
// AFTER: Authorization Code flow(WebView で得たコードのみ送る)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
                            code: String, ...) {
    let body = [
        "grant_type":   "authorization_code",
        "redirect_uri": "oyakodon://oauth",
        "code":         code,
        ...
    ]
}

設計の工夫:Apple 推奨の ASWebAuthenticationSession(OS が用意する認証専用ブラウザ)ではなく、アプリ既存の WKWebView の中で OAuth を完結させています。理由は UX です。ASWebAuthenticationSession はタイムライン表示に使っている WKWebView とは独立したブラウザセッションを使うため、「タイムライン表示用に1回 + OAuth 用にもう1回」と二重ログインを強いてしまいます。既存 WebView の navigation delegate(ページ遷移を監視するフック)で oyakodon://oauth?code=... への遷移を捕まえれば、同一セッションのまま1回のログインで済みます。

4. StoreKit 1 から StoreKit 2 へ(250行 → 79行)

IAP(In-App Purchase=アプリ内課金)は、Observer パターン+デリゲートで状態を手管理する StoreKit 1 の実装でした。StoreKit 2 の async/await ベースに置き換えると、状態フラグやエラーカウントの管理が丸ごと消えて、250行が79行になりました。

// BEFORE: Observer パターン、シングルトン、デリゲート(約250行)
class PurchaseManager: NSObject, SKPaymentTransactionObserver {
    var delegate: PurchaseManagerDelegate?
    func startWithProduct(_ product: SKProduct) {
        if SKPaymentQueue.canMakePayments() == false { ... }
        // フラグ管理、エラーカウント...
        SKPaymentQueue.default().add(payment)  // 結果は Observer へ委ねる
    }
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions: ...) {
        // switch で .purchased / .failed / .restored を振り分け
    }
}
// AFTER: async/await、@MainActor(約79行)
@MainActor final class IAPManager {
    static let shared = IAPManager()

    func purchase(productID: String) async throws -> Bool {
        let products = try await Product.products(for: [productID])
        guard let product = products.first else { return false }
        let result = try await product.purchase()
        switch result {
        case .success(let v):                 return await handleVerification(v)
        case .userCancelled, .pending:        return false
        @unknown default:                     return false
        }
    }

    func restore() async throws {
        try await AppStore.sync()
    }
}

他にも、バックグラウンド通知を古い performFetchWithCompletionHandler から BGTaskScheduler(iOS 13以降のバックグラウンドタスク API)へ、Share Extension を MobileCoreServices の C 定数から UniformTypeIdentifiersUTType へ移行しています。どれも「古い API を新しい API に1対1で置き換える」作業で、最低対応 OS を iOS 17 に引き上げたことで if #available(iOS 10.0, *) のような分岐を全削除できました。

TestFlight で実機に入れて初めて出たバグ3つ

ここが今回いちばん伝えたい部分です。ビルドが通り、シミュレータで完璧に動いても、TestFlight 配信ビルドを実機(iPhone)に入れた瞬間に落ちる・ズレるバグが3つ出ました。私が実機で触って異常を報告し、Claude Code が .ips クラッシュログを atos でシンボル解決して原因を特定する、という流れで潰しています。シミュレータだけ見ていたらリリース後にユーザーの端末で初めて発覚していたものばかりです。

(1) Realm が実機起動で即クラッシュ(埋め込み漏れ)

SPM で追加した RealmSwift は type: .dynamic、つまり常に動的フレームワーク(実行時に読み込まれる形式)です。ところが Phase 0 で pbxproj(Xcode のプロジェクト設定ファイル)を自動変換した際、フレームワークの「リンク」だけ設定して「埋め込み(Embed Frameworks フェーズ)」を追加していませんでした。

結果、シミュレータでは別経路で埋め込まれるため動くのに、実機では起動直後に dyld: Library not loaded: @rpath/RealmSwift.framework でクラッシュします。

判別の小ワザ:正常な IPA(iOS アプリのパッケージ)は RealmSwift.framework を内包して約12MB になります。これが2.5MB しかなければ埋め込み漏れです。アップロード前に IPA のサイズを見るだけで一発で分かります。

(2) Realm の Configuration を無視する罠

2つ目も Realm です。Realm.Configuration.defaultConfigurationschemaVersionmigrationBlock(スキーマ移行処理)を設定していたのに、起動時にクラッシュしました。

原因は、DB を開くときに Realm(fileURL:) を使っていたことです。この初期化方法は defaultConfiguration無視します。そのため既存 DB のスキーマバージョン(2)と、設定が反映されない新しい既定値(0)がミスマッチを起こしていました。

// NG: defaultConfiguration の schemaVersion / migrationBlock が無視される
let realm = try Realm(fileURL: dbURL)

// OK: Configuration を明示的に渡す
var config = Realm.Configuration.defaultConfiguration
config.fileURL = dbURL
let realm = try Realm(configuration: config)

(3) WebView 下端のズレ(3回直してやっと根治)

3つ目は、画面下端で WebView がツールバーに少しめり込む/隙間が空くズレです。これは2.7.2 build 2・2.7.3 build 1・2.7.3 build 2 と、計3回直しています(毎回 TestFlight に上げて実機で確認しました)。1〜2回目は対症療法で、根本原因は「3者がジオメトリ(座標・サイズ)を奪い合っていたこと」でした。

この「何 pt めり込んでいるか」を言葉で正確に伝えるのは難しいのですが、私がやったのは 実機の画面をスクショして送るだけ。ズレの量も方向も、Claude Code が画像から読み取って原因を絞り込んでいきました。テキストだけのやり取りなら何往復もかかったはずの問題が、画像1枚で前に進んだ場面です。実際に私が送った before / after の実機スクショが以下です。

修正前: WebView 下端がツールバーにめり込み、コンテンツが隠れている実機スクリーンショット Before: 下端がツールバーにめり込んでいる 修正後: WebView の4辺を Auto Layout でピン留めし、下端のズレが解消された実機スクリーンショット After: 4辺をピン留めしてズレが解消
私が実機で撮って Claude Code に送った before / after。この画像から Claude Code がズレ量を読み取った。
  • Storyboard の Auto LayouttranslatesAutoresizingMaskIntoConstraints = false のビューは、レイアウトのたびに制約値へ戻る
  • 手動フレーム計算:メニューバー表示/非表示の処理が、deprecated な statusBarFrame を使い、ボトムセーフエリア(ホームバー領域)34pt を無視していた
  • リサイズ処理:コンテナ高さをその瞬間にスナップショットしていたため、ツールバーの開閉アニメーション完了後の変化に追従できていなかった

この3者がフレームの値を毎回上書きし合うので、どこを直しても別の処理が巻き戻していたわけです。根治は「手動フレーム計算とリサイズ処理を捨て、WebView の4辺を Auto Layout でコンテナにピン留めする」こと。1回ピン留めすれば、以後はレイアウトが自動追従します。

// AFTER: 4辺を Auto Layout でピン留め → 以後は自動追従
let webView = self.webViewController.webView
webView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    webView.topAnchor.constraint(equalTo: webContainerView.topAnchor),
    webView.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor),
    webView.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
    webView.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
])
// 手動の resizeWebView() は削除

設計判断のメモ

  • 作り直さない:差別化機能(マルチポスト・クロスインスタンス boost)が既存コードで動いていたので、それを維持して表面だけ直すほうが確実だった。
  • CocoaPods → SPM:直接のきっかけはビルド環境の制約だったが、コミット済みだった50MB超のバイナリを一掃できる副次効果が大きかった。
  • 設定ファイルの外部化:Mastodon の Web UI ルートは今後も変わり続ける。URL パス表・CSS・JS を JSON やファイルに切り出しておくと、次に変わってもアプリ本体のビルドなしで追従しやすい。将来 Android 版を書くときの仕様共有も狙っている。
  • App Store 審査リスク:WebView でサイトを表示するだけのアプリは、ガイドライン 4.2(minimum functionality=最低限の機能しかない)で reject されることがある。マルチポスト・OAuth・通知・課金といったネイティブ層が、その防御ラインになっている。

結果として 2.7.2 はリリース済み、2.7.3 を App Store 審査に提出できる状態まで持っていけました。実作業期間は約1週間です。

よくある質問

Q. コードは自分で書いたんですか?

A. いいえ、1行も書いていませんし、コードを見てすらいません。私は「作り直さず近代化する」という方針を決めてスマホから指示しただけで、コード修正・調査・ビルド・TestFlight 配信・App Store 申請まで Claude Code が Mac にヘッドレス(SSH 経由)で実行しました。Mac にも Claude Code が動く PC にも触れていません。私が物理的にやったのは、手元の iPhone に届いた TestFlight ビルドを触って異常を報告することだけ。App Store Connect への申請も Claude Code が API 経由で行っています(App Store の初期設定は旧アプリ時代に完了済みだったため、新規ログインは不要でした)。

Q. シミュレータで動くのに実機で落ちるのはなぜ?

A. 動的フレームワークの埋め込み(Embed Frameworks)有無、コード署名、実機固有のセーフエリアなど、シミュレータが肩代わりしてくれる部分が実機では露出するためです。今回の Realm クラッシュはまさにこれで、リリース前に必ず TestFlight などで実機に配信して起動確認すべきです。

Q. 古いアプリは作り直したほうが速くないですか?

A. 差別化機能がまだ動いているなら、作り直しはおすすめしません。「動く」を再現するコストが高いからです。逆に、コア機能自体が時代遅れ・破綻している場合は書き直しのほうが速いこともあります。判断材料は「壊れているのは中身か、表面か」です。

Q. WebView 内で OAuth を完結させるのは安全ですか?

A. セキュリティ的には ASWebAuthenticationSession のほうが望ましいです。今回は二重ログインを避ける UX 優先で WKWebView 内に閉じましたが、自分が管理する認可サーバーでないアプリでは Apple 推奨方式を検討してください。

※本記事のコードは執筆時点(2026年6月)の Xcode / iOS SDK / 各ライブラリで実際に動かしたものを簡略化して載せています。バージョンが変わるとそのままでは動かない場合があります。うまくいかないときは コメント欄で教えてください。なお Bundle ID など環境固有の値はプレースホルダーに置き換えています。

まとめ

約9年もの(うち7年半は放置)の iOS アプリでも、壊れているのが「表面」なら作り直さずに直せます。今回は私自身はコードを書かず・見ず、Mac にも触れず、方針出しと自分の iPhone での実機確認に徹し、コード修正からビルド・TestFlight 配信・App Store 申請までを Claude Code に任せました。山場は、ビルドが通っても実機でだけ落ちる Realm まわりと WebView のジオメトリ。シミュレータを信じすぎず、TestFlight で実機に配信して確かめるのが近道でした。

この記事が役に立ったら X(Twitter)でシェアしてもらえると喜びます。

関連記事

参考

※この記事は Claude Code を使った執筆フローを試しています。

0 件のコメント:

コメントを投稿