こんにちは、iOSエンジニアのtsuzuki817です。
今回は、「最新の生成AIをフル活用して、個人開発の限界をどこまで突破できるか?」というテーマで実験的なプロジェクトを行いました。
完成したのが、空想旅行カメラアプリ「Daydream Camera」です。
この記事では、AIエージェント(Gemini, Claude Code)とどのように役割分担をし、サーバーレスでありながら堅牢なセキュリティを持つアプリを構築したのか、その開発の裏側を公開します。
1. 作ったもの:Daydream Camera
「パスポートのいらない、0秒の海外旅行」
Daydream Cameraは、地図上の好きな場所にピンを刺すだけで、その場所の緯度経度・天候・時間帯をAIが解釈し、エモい風景写真を「現像」してくれるカメラアプリです。

- コンセプト: あえて「不便さ」を楽しむフィルムカメラ体験
- 技術: MapKit × 生成AI (Firebase AI / Gemini 3 Pro Image)
2. AIネイティブな開発体制
今回の開発では、私はコードを書く「作業者」ではなく、仕様を決定し品質を担保する「ディレクター」に徹しました。実作業は2つのAIエージェントが担当しています。
チームメンバー紹介
| 役割 | 担当AI | 主な業務 |
|---|---|---|
| PM / デザイナー / マーケター | Gemini (Google) | アイデアの壁打ち、ユニットエコノミクス(原価計算)、ロゴデザインのプロンプト作成、ASO対策 |
| 実装エンジニア | Claude Code (Anthropic) | Swift/SwiftUIの実装、StoreKit 2 / Firebase周りの構築 |
Geminiは曖昧なアイデアを具体的な仕様に落とし込む「右腕」として機能しました。特に、APIコストを考慮した価格設定のシミュレーションは非常に精度の高いものでした。
Claude Codeは私が作成した仕様書(CLAUDE.md)を読み込み、MVVMアーキテクチャに則ったクリーンなコードを自律的に書き上げました。
3. アーキテクチャ設計
ディレクトリ構成
DaydreamCamera/
├── DaydreamCameraApp.swift # エントリーポイント(Firebase/AppCheck初期化)
│
├── Models/
│ ├── PhotoEntity.swift # SwiftDataエンティティ
│ ├── DevelopingPhase.swift # 現像進捗の状態定義
│ └── WeatherType.swift # 天候タイプ
│
├── ViewModels/
│ └── CameraViewModel.swift # @Observable採用のViewModel
│
├── Views/
│ ├── MapFinderView.swift # 地図選択(NavigationStack管理)
│ ├── SituationInputView.swift # シチュエーション入力
│ ├── DevelopingView.swift # 現像アニメーション
│ ├── AlbumView.swift # アルバム
│ └── StoreView.swift # フィルム購入
│
├── Services/
│ ├── FirebaseAIClient.swift # Firebase AI クライアント
│ ├── FilmManager.swift # フィルム残数管理
│ ├── KeychainManager.swift # Keychain操作(iCloud同期対応)
│ ├── BanManager.swift # Remote Config連携BAN管理
│ ├── AppUserManager.swift # ユーザーID管理
│ └── ImageCompressor.swift # 参考画像圧縮
│
└── StoreKit/
└── StoreManager.swift # StoreKit 2 課金処理
採用したアーキテクチャパターン
MVVM + Protocol指向設計を採用し、以下の特徴を持たせました。
-
iOS 17+ の
@Observableマクロ-
ObservableObjectではなく@Observableを使用し、ボイラープレートを大幅に削減
-
-
SwiftData による永続化
- Core Dataではなく、iOS 17から導入されたSwiftDataを採用
-
@Modelマクロによるシンプルなエンティティ定義
-
Protocol指向のAPI設計
-
PhotoGenerationAPIプロトコルでAIクライアントを抽象化し、テスト容易性を確保
-
protocol PhotoGenerationAPI {
func generatePhoto(
latitude: Double,
longitude: Double,
heading: Double,
situation: SituationInput?
) async throws -> Data
}
4. 技術スタックとこだわり
個人開発のMVP(Minimum Viable Product)として、「運用コストは極限まで下げつつ、UXとセキュリティは妥協しない」構成を目指しました。
使用ライブラリ
| ライブラリ | バージョン | 用途 |
|---|---|---|
| Firebase iOS SDK | 12.6.0 | AI生成、Analytics、Remote Config、App Check |
| Google Mobile Ads SDK | 12.14.0 | AdMob広告 |
注目点として、外部ライブラリはFirebaseとAdMobのみに絞りました。Keychain操作もサードパーティ(KeychainAccess等)を使わず、Security.frameworkを直接利用することで依存関係を最小化しています。
MapKit vs Google Maps:コストとUXの両立
地図SDKにはApple標準のMapKitを採用しました。
Google Mapsのストリートビューは網羅性が高いですが、APIコストが従量課金で発生します。個人開発において、バズった瞬間に赤字になるリスクは避けるべきだと判断し、無料枠で使えるMapKitのLook Around機能を選定。
結果として、Look Around特有の「滑らかに移動するアニメーション」が、アプリのコンセプトである「白昼夢(Daydream)」の浮遊感と見事にマッチしました。
実装のポイント:非同期リクエストとキャンセル処理
地図をスクロールするたびにLook Aroundのプレビューを更新する必要がありますが、高速スクロール時に大量のAPIリクエストが発生することを防ぐため、協調的キャンセレーションを実装しました。
// CameraViewModel.swift
private var lookAroundTask: Task<Void, Never>?
func updateLookAround(for coordinate: CLLocationCoordinate2D) {
// 前のリクエストをキャンセル
lookAroundTask?.cancel()
lookAroundTask = Task {
isLoadingLookAround = true
do {
let request = MKLookAroundSceneRequest(coordinate: coordinate)
let scene = try await request.scene
// キャンセルされていなければ結果を反映
if !Task.isCancelled {
self.lookAroundScene = scene
}
} catch {
if !Task.isCancelled {
self.lookAroundScene = nil
}
}
if !Task.isCancelled {
isLoadingLookAround = false
}
}
}
View側では、onMapCameraChange の frequency: .onEnd オプションを使い、スクロール終了時のみAPIを呼び出すことでリクエスト数を最小化しています。
Map(position: $viewModel.cameraPosition)
.onMapCameraChange(frequency: .onEnd) { context in
viewModel.updateLookAround(for: context.camera.centerCoordinate)
}
Firebase AI (Gemini 3 Pro Image) による画像生成
画像生成にはFirebase AIを採用しました。Vertex AIをFirebase SDKから呼び出せるため、サーバーレスで生成AIを利用できます。
プロンプトエンジニアリング
位置情報から「その場所らしい」画像を生成するため、以下の要素をプロンプトに組み込んでいます。
private func generatePrompt(
latitude: Double,
longitude: Double,
heading: Double,
situationText: String?,
hasReferenceImage: Bool,
weather: WeatherType
) -> String {
let direction = headingToDirection(heading) // 0-360度 → 東西南北
var prompt = """
緯度と経度が\(latitude), \(longitude)の位置で
地元の現在の時間雰囲気とリアルタイムの天気に合った画像を作成してください。
背景はイラストではなくリアルな現実空間として描いてください。
カメラの向き: \(direction)。
"""
// 参照画像がある場合、その人物を合成
if hasReferenceImage {
prompt += """
添付した画像の主体となる人物・キャラクターのみを
その場所で観光しているように自然に馴染ませてください。
"""
}
// ユーザー入力のシチュエーション
if let situationText = situationText {
prompt += "\nシーンの説明: \(situationText)"
}
return prompt
}
ポイント:
-
heading(地図の向き)を「東西南北」に変換し、カメラアングルを指定 - 参照画像がある場合のみ「人物合成」の指示を追加
- 天候指定がある場合は「リアルタイムの天気」ではなく指定天候を使用
StoreKit 2 による消耗型アイテム課金
フィルム購入にはStoreKit 2のasync/await APIを採用しました。
@Observable @MainActor
final class StoreManager {
private var transactionListener: Task<Void, Error>?
init() {
// バックグラウンドでトランザクションを監視
transactionListener = listenForTransactions()
}
func purchase(_ product: Product, filmManager: FilmManager) async {
purchaseState = .purchasing
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
// 署名検証
let transaction = try checkVerified(verification)
// フィルムを付与
if let filmProduct = FilmProduct(rawValue: product.id) {
filmManager.addFilm(count: filmProduct.filmCount)
}
// トランザクション完了をAppStoreに通知
await transaction.finish()
purchaseState = .purchased
case .userCancelled:
purchaseState = .idle
case .pending:
// 保護者の承認待ち等
purchaseState = .idle
}
} catch {
purchaseState = .failed(error)
}
}
// 未完了トランザクションをバックグラウンドで監視
private func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
let transaction = try await self.checkVerified(result)
await transaction.finish()
}
}
}
}
StoreKit 2の利点:
-
async/awaitによるシンプルな非同期処理 -
Transaction.updatesで未完了トランザクションを自動検知 - サーバーサイドでのレシート検証が不要(オンデバイス検証)
5. サーバーレス × 堅牢なセキュリティ
バックエンドサーバーを持たず、FirebaseとiOS標準機能だけで「不正利用対策」と「BAN機能」を実装しました。
セキュリティアーキテクチャ全体像
┌─────────────────────────────────────────────────────────────┐
│ アプリ起動時 │
├─────────────────────────────────────────────────────────────┤
│ 1. AppUserManager: KeychainからUUIDを復元(or 新規生成) │
│ 2. BanManager: Remote Configから banned_user_ids をフェッチ │
│ 3. FilmManager: Keychainからフィルム残数を復元 │
│ └─ サニティチェック: 50枚超過 → 0枚にリセット │
│ 4. App Check: App Attestトークンを検証 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 不正検知時の対応 │
├─────────────────────────────────────────────────────────────┤
│ 1. AnalyticsManager: 異常イベントをログ送信 │
│ 2. 運営が Firebase Console で UUID を確認 │
│ 3. Remote Config に banned_user_ids を追加 │
│ 4. 次回アプリ起動時に BanOverlayView で機能停止 │
└─────────────────────────────────────────────────────────────┘
Keychainによるデータ永続化
フィルム残数やユーザーID(UUID)は、UserDefaults ではなくKeychainに暗号化して保存しています。
final class KeychainManager {
static let shared = KeychainManager()
private let service = "com.tsuzukit.DaydreamCamera"
func setData(_ data: Data, forKey key: String, synchronizable: Bool) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
// デバイスロック解除後にアクセス可能
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
// iCloud Keychain同期を有効化
if synchronizable {
query[kSecAttrSynchronizable as String] = kCFBooleanTrue
// 同期時はThisDeviceOnlyは使えないため変更
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
SecItemAdd(query as CFDictionary, nil)
}
}
なぜKeychainを使うのか?
| 保存方法 | アプリ削除後 | 暗号化 | iCloud同期 |
|---|---|---|---|
| UserDefaults | 削除される | なし | なし |
| Keychain | 保持される | あり | あり(オプション) |
アプリを削除・再インストールしても同じUUIDが復元されるため、BAN逃れを防止できます。
クライアントサイドでのサニティチェック
「フィルム所持数が50枚を超えている」などの異常値をアプリ起動時に検知し、自動的にリセットする自浄作用ロジックを実装しました。
@Observable
final class FilmManager {
private let maxAllowedFilms = 50 // 不正対策の上限
init() {
if let savedCount = KeychainManager.shared.getInt(forKey: "remainingFilms") {
// サニティチェック
if savedCount > maxAllowedFilms {
print("⚠️ Suspicious film count: \(savedCount). Resetting to 0.")
self.remainingFilm = 0
// 不正検知をAnalyticsにログ
AnalyticsManager.shared.logError(
type: "suspicious_film_count",
message: "Film count exceeded limit: \(savedCount)"
)
} else if savedCount < 0 {
self.remainingFilm = 0
} else {
self.remainingFilm = savedCount
}
}
}
func addFilm(count: Int, productId: String? = nil) {
let newCount = remainingFilm + count
// 上限を超える場合はキャップ
if newCount > maxAllowedFilms {
remainingFilm = maxAllowedFilms
} else {
remainingFilm = newCount
}
}
}
ポイント:
- 異常値検知時は即座にリセット + Analyticsログ送信
- 加算時も上限チェックを実施し、メモリ改ざんによる不正増殖を防止
Remote ConfigによるBANシステム
Firebase Remote Configを使い、サーバーデプロイなしで特定ユーザーをBANできる仕組みを構築しました。
@Observable
final class BanManager {
static let shared = BanManager()
private(set) var isBanned: Bool = false
@MainActor
func checkBanStatus() async {
do {
// Remote Configをフェッチ
try await remoteConfig.fetchAndActivate()
// banned_user_ids: カンマ区切りのUUIDリスト
let bannedIdsString = remoteConfig
.configValue(forKey: "banned_user_ids")
.stringValue ?? ""
let bannedIds = bannedIdsString
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
// 現在のユーザーIDがリストに含まれるか
let currentUserId = AppUserManager.shared.appUserId
isBanned = bannedIds.contains(currentUserId)
if isBanned {
AnalyticsManager.shared.logError(
type: "user_banned",
message: "User detected in ban list"
)
}
} catch {
isBanned = false // フェッチ失敗時はBANしない
}
}
}
運用フロー:
- Firebase Analyticsで
suspicious_film_countイベントを監視 - 異常なユーザーのUUIDを特定
- Firebase Console → Remote Config →
banned_user_idsにUUIDを追加 - 次回アプリ起動時に
BanOverlayViewで画面全体をブロック
Firebase App Check による不正アクセス防止
Firebase AIへの直接リクエスト(アプリを経由しないAPIコール)を防ぐため、App Check (App Attest) を有効化しています。
// DaydreamCameraApp.swift
@main
struct DaydreamCameraApp: App {
init() {
FirebaseApp.configure()
#if DEBUG
// デバッグビルドはDebug Providerを使用
let providerFactory = AppCheckDebugProviderFactory()
#else
// 本番はApp Attestを使用
let providerFactory = AppAttestProviderFactory()
#endif
AppCheck.setAppCheckProviderFactory(providerFactory)
}
}
6. UXと収益化のバランス
生成AIアプリはAPIコストがかかるため、マネタイズ設計が重要です。
「現像の待ち時間」を価値に変える
シャッターを押してから画像が出るまでの数秒間、あえて待たせることでフィルムカメラのようなワクワク感を演出しつつ、その間にAdMobバナーを表示して収益機会を作っています。
struct DevelopingView: View {
var body: some View {
VStack {
// 生成中のみ広告バナーを表示
if !isCompleted {
LargeBannerAdView()
.transition(.opacity)
}
// 暗室風のアニメーション
developingPhoto
// 6段階のプログレス表示
progressIndicator
}
}
}
カスタムアニメーション:暗室の液体エフェクト
「現像液に写真が浮かび上がる」体験をSwiftUIで再現するため、Animatable プロトコルを使ったカスタムシェイプを実装しました。
struct Wave: Shape, Animatable {
var amplitude: Double
var frequency: Double
var phase: Double
var animatableData: Double {
get { phase }
set { phase = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
let midHeight = rect.height / 2
path.move(to: CGPoint(x: 0, y: rect.height))
for x in stride(from: 0, through: rect.width, by: 1) {
let relativeX = x / rect.width
let y = midHeight + sin(relativeX * frequency * .pi * 2 + phase) * amplitude
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
path.closeSubpath()
return path
}
}
// 使用例
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
liquidOffset = .pi * 2
}
7. 開発を終えて
今回の「AIとペアプログラミングする」体験は、これまでの開発とは全く異なるスピード感がありました。
特に、「作りたい機能(What)」さえ明確であれば、「どう作るか(How)」はAIが高いレベルで提案・実装してくれる時代になったと痛感しています。
Claude Codeとの協業で効果的だったこと
-
CLAUDE.mdによる規約の事前定義
- SwiftUIの書き方、アーキテクチャルール、禁止事項を明文化
- 「NavigationLinkを使え、Buttonで画面遷移するな」などのルールが自動的に守られる
-
プロトコル設計を先にする
-
PhotoGenerationAPIのようなインターフェースを先に定義 - 実装の詳細はClaude Codeに任せる
-
-
コードレビューは人間が行う
- 生成されたコードを鵜呑みにせず、セキュリティ観点でチェック
- 特にKeychain周りのアクセシビリティ設定は入念に確認
Daydream CameraはApp Storeで公開中です。
ぜひ、あなたの手で「空想旅行」に出かけてみてください。
技術スタック一覧
| カテゴリ | 技術 |
|---|---|
| 言語 | Swift 6 |
| UI | SwiftUI (iOS 18+) |
| アーキテクチャ | MVVM + @Observable |
| データ永続化 | SwiftData, Keychain |
| 地図 | MapKit (Look Around) |
| AI画像生成 | Firebase AI (Gemini 3 Pro Image) |
| 課金 | StoreKit 2 |
| セキュリティ | Firebase App Check (App Attest) |
| 分析 | Firebase Analytics |
| リモート設定 | Firebase Remote Config |
| 広告 | Google Mobile Ads (AdMob) |