🪶

GitHub Copilot for Xcode Agentの精度の高さ

に公開

🧪Mapkit + Cloud Firestoreで検証

Pre-release 0.34.116から搭載されたAI Agentの制度がどれ程のものなのかを時間のあるときに実験しております。

記事の対象者

  1. xcodeの使い方を知っているiOS開発者
  2. Firebaseの知識がある
  3. Udemyや書籍で学習したことがある

https://github.com/github/CopilotForXcode/releases/tag/0.34.116

今までは、Cursor/Windsurf Editor/VSCode GitHub Copilot Agnetを使用しておりましたがiOSの開発をするなら、xcodeを使わないとIDEを使用して作業ができないデメリットがありました。

課題

  1. buildができない
  2. コードを記載している箇所でエラーが表示されない
  3. xcodeとAI Agentを搭載したText Editorを2つ起動することになる
  4. ブレークポイントを打ってデバッグするときはxcodeを使う
  5. logはxcodeでビルドしないと出力されない

この課題を解決するには、xcodeだけ使うしかない。そりゃ当然だ。Android OSのアプリを開発するときだって、Android Studio使いますし😇

Flutter/Expoは、VSCodeなど他のText Editorでコーディングとビルドができる。

どれほどのものか試してみよう

記事のタイトル通りMapkit + Cloud Firestoreで検証してみましょう。試してみたところOpenChatからAIに指示を出してコードを生成させて機能を作ってみたところ精度は高かった!

動画も作ってみた
https://youtu.be/ZD1-OjVVMeY

これはありがたい。Map系の機能を作るのは情報少ないので難しく😅

https://developer.apple.com/documentation/mapkit/
https://firebase.google.com/docs/firestore/quickstart?hl=ja#swift

公式の手順を参考に必要な設定を追加しましよう。

https://firebase.google.com/docs/ios/setup?hl=ja

https://github.com/firebase/firebase-ios-sdk

Cloud Firestoreには、shopコレクションIDを作成しておく。画像と同じ設計にすればOK!

Firebaseとの接続をする設定を追加する。

import SwiftUI
import FirebaseCore

class AppDelegate: NSObject, UIApplicationDelegate {
  func application(_ application: UIApplication,
                   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    FirebaseApp.configure()

    return true
  }
}

@main
struct AiAgentDemoApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

モジュールがないと怒られると思うので、ホワイトハウスを追加しましよう。

データを表示する設定を追加。データの取得が成功すればMap上にピンが表示されているはずです。最初に表示される位置は、渋谷を指定しています。

import SwiftUI
import MapKit
import FirebaseFirestore

// お店のデータモデル
struct Shop: Identifiable {
    var id: String
    var name: String
    var location: GeoPoint
    var createdAt: Date
    var updatedAt: Date
    
    // MapKitで使用するための座標変換メソッド
    var coordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
    }
    
    // デバッグ情報
    func debugDescription() -> String {
        return "Shop(id: \(id), name: \(name), location: (\(location.latitude), \(location.longitude)))"
    }
}

struct ContentView: View {
    // 渋谷駅の座標
    private let shibuyaCoordinate = CLLocationCoordinate2D(
        latitude: 35.658034,
        longitude: 139.701636
    )
    
    // 初期マップ領域(渋谷駅中心)
    @State private var region = MKCoordinateRegion(
        center: CLLocationCoordinate2D(
            latitude: 35.658034,
            longitude: 139.701636
        ),
        span: MKCoordinateSpan(
            latitudeDelta: 0.01,
            longitudeDelta: 0.01
        )
    )
    
    // 店舗データ
    @State private var shops: [Shop] = []
    
    // 選択された店舗
    @State private var selectedShop: Shop?
    
    // モーダル表示用フラグ
    @State private var showingModal = false
    
    // データ取得状態
    @State private var isLoading = false
    @State private var errorMessage: String? = nil
    
    var body: some View {
        ZStack {
            // マップ表示
            Map(coordinateRegion: $region, annotationItems: shops) { shop in
                MapAnnotation(coordinate: shop.coordinate) {
                    VStack {
                        // ラベル(店名)
                        Text(shop.name)
                            .font(.caption)
                            .padding(4)
                            .background(Color.white.opacity(0.8))
                            .cornerRadius(4)
                        
                        // ピン
                        Image(systemName: "mappin.circle.fill")
                            .font(.title)
                            .foregroundColor(.red)
                            .onTapGesture {
                                print("店舗が選択されました: \(shop.name), ID: \(shop.id)")
                                self.selectedShop = shop
                                self.showingModal = true
                            }
                    }
                }
            }
            .edgesIgnoringSafeArea(.all)
            
            // ローディング表示
            if isLoading {
                ProgressView("データを読み込み中...")
                    .padding()
                    .background(Color.white.opacity(0.8))
                    .cornerRadius(8)
            }
            
            // エラー表示
            if let errorMessage = errorMessage {
                VStack {
                    Text("エラー: \(errorMessage)")
                        .foregroundColor(.red)
                        .padding()
                    
                    Button("再試行") {
                        fetchShops()
                    }
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
                }
                .padding()
                .background(Color.white.opacity(0.9))
                .cornerRadius(10)
            }
            
            // デバッグ情報 - 店舗数
            VStack {
                Spacer()
                Text("読み込んだ店舗数: \(shops.count)")
                    .padding(8)
                    .background(Color.black.opacity(0.7))
                    .foregroundColor(.white)
                    .cornerRadius(8)
                    .padding()
            }
        }
        .onAppear {
            // アプリ起動時に店舗データを取得
            fetchShops()
        }
        .sheet(isPresented: $showingModal) {
            if let shop = selectedShop {
                ShopDetailView(shop: shop)
            } else {
                // 選択された店舗がない場合(通常はここには到達しないはず)
                Text("店舗データがありません")
                    .font(.title)
                    .padding()
            }
        }
        .onChange(of: showingModal) { isPresented in
            if isPresented {
                if let shop = selectedShop {
                    print("モーダルを表示: \(shop.debugDescription())")
                } else {
                    print("モーダル表示エラー: selectedShopがnil")
                }
            }
        }
    }
    
    // Firestoreから店舗データを取得
    private func fetchShops() {
        isLoading = true
        errorMessage = nil
        print("店舗データの取得を開始します...")
        
        let db = Firestore.firestore()
        db.collection("shop").getDocuments() { (querySnapshot, error) in
            // データ取得完了
            self.isLoading = false
            
            if let error = error {
                print("ドキュメント取得エラー: \(error)")
                self.errorMessage = "データ取得に失敗しました: \(error.localizedDescription)"
                return
            }
            
            guard let documents = querySnapshot?.documents, !documents.isEmpty else {
                print("ドキュメントが見つかりませんでした")
                self.errorMessage = "店舗データが見つかりませんでした"
                return
            }
            
            print("取得したドキュメント数: \(documents.count)")
            
            var loadedShops: [Shop] = []
            
            for document in documents {
                let data = document.data()
                print("ドキュメントID: \(document.documentID), データ: \(data)")
                
                guard let name = data["shop_name"] as? String else {
                    print("店舗名が見つかりません: \(document.documentID)")
                    continue
                }
                
                guard let location = data["location"] as? GeoPoint else {
                    print("位置情報が見つかりません: \(document.documentID)")
                    continue
                }
                
                guard let createdAt = data["created_at"] as? Timestamp else {
                    print("作成日時が見つかりません: \(document.documentID)")
                    continue
                }
                
                guard let updatedAt = data["updated_at"] as? Timestamp else {
                    print("更新日時が見つかりません: \(document.documentID)")
                    continue
                }
                
                let shop = Shop(
                    id: document.documentID,
                    name: name,
                    location: location,
                    createdAt: createdAt.dateValue(),
                    updatedAt: updatedAt.dateValue()
                )
                print("店舗オブジェクト作成: \(shop.name)")
                loadedShops.append(shop)
            }
            
            self.shops = loadedShops
            print("読み込んだ店舗数: \(self.shops.count)")
            
            // デバッグ: 読み込んだ店舗データの一覧表示
            for (index, shop) in self.shops.enumerated() {
                print("店舗[\(index)]: ID=\(shop.id), 名前=\(shop.name)")
            }
        }
    }
}

#Preview {
    ContentView()
}

ピンをタップするとお店の情報を表示するモーダルが表示されます。こちらはそのモーダルを表示するために使うstructです。

import SwiftUI
import MapKit
import FirebaseFirestore

struct ShopDetailView: View {
    let shop: Shop
    @Environment(\.presentationMode) var presentationMode
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 20) {
                    // お店のイメージ
                    HStack {
                        Spacer()
                        Image(systemName: "cart.fill")
                            .resizable()
                            .scaledToFit()
                            .frame(width: 120, height: 120)
                            .foregroundColor(.blue)
                        Spacer()
                    }
                    .padding()
                    
                    // お店の詳細情報
                    Group {
                        // お店の名前
                        Text(shop.name)
                            .font(.title)
                            .fontWeight(.bold)
                            .padding(.horizontal)
                        
                        // 位置情報
                        HStack {
                            Image(systemName: "mappin.circle")
                                .foregroundColor(.red)
                            Text("位置情報: \(String(format: "%.6f", shop.location.latitude)), \(String(format: "%.6f", shop.location.longitude))")
                                .font(.subheadline)
                        }
                        .padding(.horizontal)
                        
                        // 作成日時
                        HStack {
                            Image(systemName: "calendar")
                                .foregroundColor(.green)
                            Text("作成日時: \(formattedDate(shop.createdAt))")
                                .font(.subheadline)
                        }
                        .padding(.horizontal)
                        
                        // 更新日時
                        HStack {
                            Image(systemName: "clock")
                                .foregroundColor(.orange)
                            Text("更新日時: \(formattedDate(shop.updatedAt))")
                                .font(.subheadline)
                        }
                        .padding(.horizontal)
                        
                        Divider()
                            .padding(.vertical)
                        
                        // 地図表示
                        Text("地図")
                            .font(.headline)
                            .padding(.horizontal)
                        
                        Map(coordinateRegion: .constant(MKCoordinateRegion(
                            center: shop.coordinate,
                            span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005)
                        )), annotationItems: [shop]) { shop in
                            MapMarker(coordinate: shop.coordinate, tint: .red)
                        }
                        .frame(height: 200)
                        .cornerRadius(10)
                        .padding(.horizontal)
                    }
                    
                    Spacer()
                }
                .padding(.bottom, 20)
            }
            .navigationBarTitle("店舗詳細", displayMode: .inline)
            .navigationBarItems(trailing: Button(action: {
                presentationMode.wrappedValue.dismiss()
            }) {
                Text("閉じる")
            })
        }
    }
    
    // 日付をフォーマットする関数
    private func formattedDate(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .short
        formatter.locale = Locale(identifier: "ja_JP")
        return formatter.string(from: date)
    }
}

// プレビュー用のダミーデータ
#if DEBUG
struct ShopDetailView_Previews: PreviewProvider {
    static var previews: some View {
        let dummyShop = Shop(
            id: "dummy-id",
            name: "テスト店舗",
            location: GeoPoint(latitude: 35.658034, longitude: 139.701636),
            createdAt: Date(),
            updatedAt: Date()
        )
        return ShopDetailView(shop: dummyShop)
    }
}
#endif

最後に

iOSに関係した技術や知識があるのが前提で開発しているので、このような機能を開発できましたが、設定方法やデーベースの設計ができていれば、あまり技術への理解がない人間でも開発ができるかもしれないと思いました。

Cloud Firestoreの場合だとkey value storeなるNoSQLの知識が必要なので、iOSとは別の分野の知識ですが、こちらの設計の知識も必要とされます。

技術と知識があれば、AI Agentを活用して機能をすぐに開発できるので、新規開発のプロトタイプや保守しているソースコードのリファクタリングもすぐにやってくれそうな気がしました。ここに関しては長い期間試してみて検証が必要になりそうですね。

まだ個人開発したアプリで試せていない😅
必要な機能は揃っているからね。。。
アイコンのデザイン変えるぐらいしかやることないな。

Discussion