個人開発しているiOSアプリ「ランチログ iOS版」(Bundle ID: com.lunchlog.ios)の技術構成と開発の記録です。Android版として先に公開している「ランチログ」を、Swift / SwiftUIで一から移植したアプリで、写真・地図・コストを軸に毎日のランチを記録できます。
1. ランチログ iOS版 とは
1-1. 開発の背景・課題
ランチログはもともとAndroid版として公開していたアプリです。日々のランチを「どこで」「何を」「いくらで」食べたかを写真付きで記録し、地図・カレンダー・統計から振り返れるようにすることを目的としています。iOS版はこのAndroid版のコンセプトをそのまま踏襲しつつ、実装は流用せずSwift / SwiftUIで新規に組み直しました。
iOS版で最初に立てた方針は「すべてApple標準APIで構成し、外部APIの利用料を一切発生させない」ことです。Android版ではGoogle Maps SDKやGoogle Places APIなど課金対象のAPIを使っていましたが、iOS版はMapKit・SwiftData・CloudKitなど無料のApple製フレームワークだけで同等の機能を実現しています。
1-2. 既存アプリ・ツールとの比較
| 比較項目 | 汎用メモ・カレンダーアプリ | 家計簿アプリ | ランチログ iOS版 |
|---|---|---|---|
| 位置情報の自動記録 | なし | なし | MapKitで自動記録・地図表示 |
| 写真管理 | 弱い(添付程度) | なし | 複数枚対応・地図/カレンダーと連携 |
| 店舗別の振り返り | なし | 弱い(カテゴリ集計のみ) | 店舗TOP10・ハッシュタグ集計 |
| 友達との共有 | なし | なし | iCloud Driveファイル共有 |
| 費用 | 無料〜サブスク | 有料プランが多い | 基本無料(広告除去のみ課金) |
2. 技術スタック
方針どおり、UIからバックアップまで全レイヤーをApple標準フレームワークのみで構成しています。
| カテゴリ | Android版 | iOS版(確定) |
|---|---|---|
| UI | Jetpack Compose / Material3 | SwiftUI |
| DI | Hilt | 手動DI |
| DB | Room | SwiftData |
| バックアップ | Google Drive API | CloudKit(iCloud自動同期・無料) |
| 地図 | Google Maps SDK(課金) | MapKit(無料) |
| 場所検索 | Google Places API(課金) | MKLocalSearch / MKLocalSearchCompleter(無料) |
| 写真選択 | ActivityResultContracts | PhotosUI(PhotosPicker) |
| ヘルプ表示 | AlertDialog | TipKit |
| 広告 | Google AdMob | Google AdMob(iOS SDK)+ ATT |
| 課金 | Google Play Billing | StoreKit 2 |
| QRスキャン | ML Kit + CameraX | AVFoundation + Vision framework |
3. アーキテクチャ・データフロー
3-1. アーキテクチャ解説
MVVM(@Observable + SwiftUI)にRepositoryパターンを組み合わせています。ViewModelはRepository経由でのみデータへアクセスし、Repositoryの先にあるSwiftDataがCloudKitと自動同期する構成です。外部サーバーへのAPIリクエストは存在せず、端末内で完結する一方向データフローになっています。
3-2. 友達データ共有シーケンス
友達との記録共有は、外部APIサーバーを介さず「iCloud Driveファイル共有」で実現しています。当初はCKShareを使う設計を検討しましたが、SwiftDataがCKShareを公式サポートしていないことが判明したため、UIDocument経由でJSONファイルをiCloud Driveに書き出し、標準の共有シートで送る方式に変更しました。
3-3. DBスキーマ
写真は@Attribute(.externalStorage)を付けた独立モデル(LunchPhoto / WishlistPhoto)として切り出しています。こうすることでCloudKitがCKAssetとして写真を自動的に同期してくれます。
4. 画面構成・機能一覧
Map / Gallery / Wishlist / Calendar / Stats の5タブ構成で、各タブが独立したNavigationStackを持ちます。深い画面遷移は同じタブ内のスタックだけで処理する設計です。
| タブ・機能 | 内容 |
|---|---|
| Map | ピンと写真サムネイル表示。同一座標は件数バッジでグルーピング。友達ごとに色分け |
| Gallery | 月別 + ハッシュタグフィルター(使用頻度順)。ピンチズーム対応 |
| Wishlist | MKLocalSearchで検索して追加。自分/友達タブ切り替え |
| Calendar | カスタムLazyVGridで写真サムネイル表示。空日タップで新規記録 |
| Stats | Swift Chartsによる統計・年間ふりかえり(AnnualReviewView) |
| SNSシェア | ランチ記録を画像カード化しX/Instagram/LINEへ共有 |
| 月間予算アラート | 予算超過時に通知。同月2回目以降は抑制 |
| 評価・レーティング | 店舗に★1〜5を設定し統計に反映 |
| Siriショートカット | 「ランチを記録して」で起動。今月合計を音声回答 |
| 友達リモート招待 | ディープリンクURLをLINE/メールで送信し自動で友達登録 |
5. 使い方ガイド
5-1. 初回セットアップ
- 初回起動時にATT(追跡許可)ダイアログが表示されます(AdMob要件のため起動時のみ表示)
- Mapタブを開くと位置情報の利用許可を求められます(記録時の緯度経度取得に使用)
- Settingsでリマインダー通知をONにすると通知許可を求められます
- QrScan画面を初めて開くとカメラ許可を求められます(友達追加のQRスキャン用)
5-2. 主要機能の使い方
- Map画面右下の+ボタンからランチ記録を追加。店名はMKLocalSearchの候補から選択するか、地図から手動で位置を選択できます
- 写真は最大5枚登録可能。長押し+ドラッグで並び替え、1枚目がホーム写真(★バッジ)になります
- Wishlist画面で気になる店を検索して登録し、訪問後にランチ記録へ「複製」して手早く記録できます
- Settings画面の「今すぐ共有を更新」で自分のランチ記録・行きたいリストをiCloud Drive経由で友達に共有できます
- 友達のQRコードをスキャンするか、リモート招待のディープリンクを受け取ると自動で友達登録されます
6. 主な実装上のポイント
開発中に遭遇した代表的な課題と、その解決策をコードとあわせて紹介します。
① SwiftData削除時のクラッシュ修正
ギャラリー・行きたいリストから削除すると、CloudKit同期の通知タイミングのずれにより「遅延フォールトする属性への参照」でクラッシュする不具合がありました。インスタンス参照ではなく、ID指定のバッチ削除に変更して解消しています。
// Before: インスタンス参照による削除(CloudKit同期タイミングでクラッシュ)
context.delete(entry)
// After: ID指定のバッチ削除
let id = entry.id
try context.delete(model: LunchEntry.self, where: #Predicate { $0.id == id })
② シェアカード生成の初回空白バグ修正
SNSシェア用の画像カードをImageRendererで生成すると、SwiftDataのlazy loading環境ではnilが返り、初回タップ時にカードが空白になる問題がありました。UIHostingControllerを画面外に一時配置してレイアウトを確定させ、1秒待機してからdrawHierarchyで描画する方式に置き換えて解決しています。
let hostingController = UIHostingController(rootView: shareCardView)
window.addSubview(hostingController.view)
hostingController.view.frame = CGRect(x: -400, y: 0, width: 400, height: 400)
hostingController.view.layoutIfNeeded()
try await Task.sleep(nanoseconds: 1_000_000_000)
hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true)
③ ZIPインポート時のサムネイル欠落修正
ZIPファイルからインポートした写真はthumbnailDataがnilのまま保存され、マップピンやカレンダーセルに写真が表示されない不具合がありました。インポート処理内でimageDataから200pxのサムネイルを再生成するよう修正しています。
let photo = LunchPhoto(imageData: imageData, index: index)
photo.thumbnailData = compressImage(imageData, maxSide: 200)
entry.photos.append(photo)
④ xcodegenによるバージョン上書きの恒久修正
xcodegen generateを実行するたびにInfo.plistのバージョンがハードコード値(1.0/1)に戻ってしまう問題がありました。project.ymlにビルド変数を追加することで解消しています。
info:
properties:
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
7. 使用技術の解説
- SwiftData:Appleの新しいデータ永続化フレームワーク。Room(Android)のような役割で、
@Modelを付けたクラスがそのままDBのテーブルに対応します - CloudKit:iCloudを使ったバックエンドサービス。SwiftDataと組み合わせることで、コード追加なしにデバイス間でデータが自動同期されます
- MapKit / MKLocalSearch:Apple標準の地図・場所検索フレームワーク。Google Maps SDK/Places APIと同等の機能を無料で利用できます
- @Observable(Swift 5.9〜):SwiftUIの状態管理を簡潔にする新しいマクロ。ViewModelに付けるだけで変更検知の仕組みが自動生成されます
- StoreKit 2:Appleのアプリ内課金フレームワーク。async/awaitベースのAPIで購入・復元処理を実装できます
- PhotosUI(PhotosPicker):写真ライブラリへのアクセス許可(
NSPhotoLibraryUsageDescription)が不要な、システム提供の写真選択UIです
8. 開発プロセス
| フェーズ | 時期 | 内容 |
|---|---|---|
| 初期スキャフォールド | 2026-06-13〜14 | xcodegenでプロジェクト生成、DEVLOG整備、仕様確定(iOS 18・MapKit・CloudKit・複数写真・友達機能) |
| 基本機能実装 | 2026-06-15〜18 | 写真並び替え、StoreKit 2、マップUI刷新、店舗履歴候補、ハッシュタグ管理 |
| 友達共有方式の転換 | 2026-06-20〜21 | CKShare非対応と判明しiCloud Driveファイル共有方式へ変更。マップ色分け・行きたいリスト友達タブ実装 |
| TestFlight初回配布 | 2026-06-26〜27 | Apple Developer Program組織アカウントでの申請準備、TestFlight 1.2配布 |
| 機能候補1〜9実装 | 2026-07-01〜03 | SNSシェア・月間予算アラート・評価機能・Siriショートカット・年間ふりかえり・友達リモート招待など全実装 |
| バグ修正・安定化 | 2026-07-04 | カレンダー遷移バグ、ZIPインポートのサムネイル欠落、シェアカード空白バグなどを修正しTestFlight 1.4 (build 10)配布 |
9. 今後の改善候補
- WidgetKit / App Intents / CoreSpotlight(ホーム画面ウィジェット・Siri連携・Spotlight検索):v1.1で対応予定
- 友達データ共有の実機エンドツーエンド検証(別Apple ID 2台での確認が未実施)
- AdMob本番App ID / Ad Unit IDへの差し替え(App Store申請直前に対応予定)
10. 動作要件
- iOS 18.0以降(iPhone専用、iPad非対応)
- Apple ID / iCloudへのサインイン(バックアップ・友達共有機能を使う場合に推奨。iCloud未サインインでもローカル保存のみで動作可能)
- 位置情報・カメラ・通知の利用許可(各機能を使う場合のみ、ジャストインタイムで要求)
- ネットワーク接続(CloudKit同期・友達データ共有・広告表示に使用)
サラリーマンの相場道 
