ランチログ iOS版 — 毎日のランチを写真・地図・コストで記録するiOSアプリ

個人開発している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標準フレームワークのみで構成しています。

技術スタック構成(lunch-log-ios) UI層(SwiftUI Views) MapView 地図・ピン GalleryView 写真一覧 WishlistView 行きたいリスト CalendarView 月間カレンダー StatsView 統計グラフ Settings 各種設定 ViewModel層(@Observable) EntryViewModel WishlistViewModel SettingsViewModel FriendListViewModel Repository層 LunchRepository WishlistRepository FriendRepository データ層 SwiftData LunchEntry / WishlistEntry / FriendEntry CloudKit(iCloud自動同期) 写真は @Attribute(.externalStorage) 方針: iOS版はすべてApple標準APIで構成。外部API利用料は一切発生しない
カテゴリAndroid版iOS版(確定)
UIJetpack Compose / Material3SwiftUI
DIHilt手動DI
DBRoomSwiftData
バックアップGoogle Drive APICloudKit(iCloud自動同期・無料)
地図Google Maps SDK(課金)MapKit(無料)
場所検索Google Places API(課金)MKLocalSearch / MKLocalSearchCompleter(無料)
写真選択ActivityResultContractsPhotosUI(PhotosPicker)
ヘルプ表示AlertDialogTipKit
広告Google AdMobGoogle AdMob(iOS SDK)+ ATT
課金Google Play BillingStoreKit 2
QRスキャンML Kit + CameraXAVFoundation + Vision framework

3. アーキテクチャ・データフロー

3-1. アーキテクチャ解説

MVVM(@Observable + SwiftUI)にRepositoryパターンを組み合わせています。ViewModelはRepository経由でのみデータへアクセスし、Repositoryの先にあるSwiftDataがCloudKitと自動同期する構成です。外部サーバーへのAPIリクエストは存在せず、端末内で完結する一方向データフローになっています。

データフロー(Apple標準APIのみで構成) Apple Frameworks MapKit PhotosUI / MKLocalSearch ViewModel @Observable @MainActor Repository CRUD処理 ID指定バッチ削除 SwiftData + CloudKit ローカルDB iCloud自動同期 写真はCKAsset @Query / 変更通知でUIへ自動反映 UI(View) SwiftUIが自動再描画 Map / Gallery / Calendar 等 外部APIサーバーを介さず、端末内で完結する一方向データフロー

3-2. 友達データ共有シーケンス

友達との記録共有は、外部APIサーバーを介さず「iCloud Driveファイル共有」で実現しています。当初はCKShareを使う設計を検討しましたが、SwiftDataがCKShareを公式サポートしていないことが判明したため、UIDocument経由でJSONファイルをiCloud Driveに書き出し、標準の共有シートで送る方式に変更しました。

友達データ共有シーケンス(iCloud Driveファイル共有) 外部APIサーバーは使わず、端末間のファイル共有のみで完結する 自分(User A) FriendShareService iCloud Drive 友達(User B) ① ランチ記録を保存 ② entries.json / wishlist.json + 写真サムネイルを生成 ③ 共有ファイルを書き出し ④ UIActivityViewController起動 ⑤「人を追加」で共有シートから友達へ送信 ⑥ DocumentPickerView で共有ファイルを選択 ⑦ reconcile()で SwiftDataへ差分反映 (追加・更新・削除) ポーリング型のため、CloudKit Pushのようなリアルタイム通知は行わない

3-3. DBスキーマ

写真は@Attribute(.externalStorage)を付けた独立モデル(LunchPhoto / WishlistPhoto)として切り出しています。こうすることでCloudKitがCKAssetとして写真を自動的に同期してくれます。

DBスキーマ図(SwiftData Model) LunchEntry id: String storeName: String latitude / longitude: Double cost: Int? memo: String hashtags: [String] mealType: MealType rating: Int ownerTag: String createdAt: Date LunchPhoto imageData: Data(外部保存) thumbnailData: Data? index: Int(0=ホーム写真) entry: LunchEntry? 1:N WishlistEntry id: String storeName: String address: String latitude / longitude: Double memo: String hashtags: [String] ownerTag: String createdAt: Date WishlistPhoto imageData: Data(外部保存) thumbnailData: Data? index / entry: WishlistEntry? 1:N FriendEntry id: String ownerTag: String displayName: String colorHue: Double shareURL: String?(未使用) importBookmark: Data? lastSyncedAt: Date? createdAt: Date ownerTag が LunchEntry / WishlistEntry の ownerTag と対応 写真は @Attribute(.externalStorage) によりCloudKitがCKAssetとして自動同期

4. 画面構成・機能一覧

Map / Gallery / Wishlist / Calendar / Stats の5タブ構成で、各タブが独立したNavigationStackを持ちます。深い画面遷移は同じタブ内のスタックだけで処理する設計です。

画面遷移図(TabView × 各タブ独立NavigationStack) TabView(5タブ) Map Gallery Wishlist Calendar Stats NavigationStack MapEntrySheet MapEntryGroupSheet LocationPickerView NavigationStack EntryDetailView EntryView(編集) ZoomableImage NavigationStack WishlistEntryView 自分/友達 タブ MKLocalSearch検索 NavigationStack DayDetailView EntryDetailView EntryView(新規) NavigationStack AnnualReviewView Swift Charts TOP10店舗 SettingsView 通知・課金・データ管理 FriendListView QR表示・スキャン・共有 Map画面左上の設定・友達アイコンからSettings / FriendListへ遷移 深い遷移は同タブのNavigationStack内で完結する設計
タブ・機能内容
Mapピンと写真サムネイル表示。同一座標は件数バッジでグルーピング。友達ごとに色分け
Gallery月別 + ハッシュタグフィルター(使用頻度順)。ピンチズーム対応
WishlistMKLocalSearchで検索して追加。自分/友達タブ切り替え
CalendarカスタムLazyVGridで写真サムネイル表示。空日タップで新規記録
StatsSwift Chartsによる統計・年間ふりかえり(AnnualReviewView)
SNSシェアランチ記録を画像カード化しX/Instagram/LINEへ共有
月間予算アラート予算超過時に通知。同月2回目以降は抑制
評価・レーティング店舗に★1〜5を設定し統計に反映
Siriショートカット「ランチを記録して」で起動。今月合計を音声回答
友達リモート招待ディープリンクURLをLINE/メールで送信し自動で友達登録

5. 使い方ガイド

5-1. 初回セットアップ

  1. 初回起動時にATT(追跡許可)ダイアログが表示されます(AdMob要件のため起動時のみ表示)
  2. Mapタブを開くと位置情報の利用許可を求められます(記録時の緯度経度取得に使用)
  3. Settingsでリマインダー通知をONにすると通知許可を求められます
  4. QrScan画面を初めて開くとカメラ許可を求められます(友達追加のQRスキャン用)

5-2. 主要機能の使い方

  1. Map画面右下の+ボタンからランチ記録を追加。店名はMKLocalSearchの候補から選択するか、地図から手動で位置を選択できます
  2. 写真は最大5枚登録可能。長押し+ドラッグで並び替え、1枚目がホーム写真(★バッジ)になります
  3. Wishlist画面で気になる店を検索して登録し、訪問後にランチ記録へ「複製」して手早く記録できます
  4. Settings画面の「今すぐ共有を更新」で自分のランチ記録・行きたいリストをiCloud Drive経由で友達に共有できます
  5. 友達の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〜14xcodegenでプロジェクト生成、DEVLOG整備、仕様確定(iOS 18・MapKit・CloudKit・複数写真・友達機能)
基本機能実装2026-06-15〜18写真並び替え、StoreKit 2、マップUI刷新、店舗履歴候補、ハッシュタグ管理
友達共有方式の転換2026-06-20〜21CKShare非対応と判明しiCloud Driveファイル共有方式へ変更。マップ色分け・行きたいリスト友達タブ実装
TestFlight初回配布2026-06-26〜27Apple Developer Program組織アカウントでの申請準備、TestFlight 1.2配布
機能候補1〜9実装2026-07-01〜03SNSシェア・月間予算アラート・評価機能・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同期・友達データ共有・広告表示に使用)

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA