【アプリ開発】CloudKitへのデータ追加・削除(Swift/SwiftUI)

App

この記事ではSwitt/SwiftUIのアプリからCloudKitへデータを作成・更新・削除する方法について記載します。
皆様のアプリ開発に役立てるように分かりやすく、実際のアプリの動き、コードから解説していきます。CloudKitについてや始め方を知りたい方は以下の記事をご参照ください。

実行画面

以下が今回説明に使用する処理画面になります。

動画でCloudKitを使用している注目ポイントを説明します。
– 「投稿」ボタンをを押下し、投稿内容を入力する画面に遷移
– タイトル、画像の挿入、記事を入力後、「投稿」ボタンを押下
– 投稿一覧画面に先ほどの投稿が出てくる
– 中身も先ほどの入力内容が反映されている

つまり、CloudKitに追加されたデータがユーザーの投稿一覧画面に出てくるため、ユーザー同士で投稿を共有することを可能にしている。

CloudKitでの準備

アプリからCloudKitへデータを追加していくために、データのレコードを入れていく箱を作成します。

・CloydKit Console画面から「CloudKit Database」を選択

・左側のペインから「Recode Type」を選択し、+ボタンを押下

・①レコードタイプの名前を入力(これがレコードを入れていく箱のイメージ)
・②Fieldsの+ボタンを押下し、赤枠のようにデータのタイプを用意する

・左ペインより「indexes」を選択し、その中のAdd Basic indexの+ボタンを押下し、INDEX TYPEを追加していく。
OUERYABLE:データベースの検索対象として指定する。今回のアプリではスレッド名で検索をかけ、スレッド名に応じた投稿を出すために使用。
SORTABLE:データベースのソート条件として指定する。今回のアプリでは投稿日といいね数でソートするために使用。

左ペインより「Security Rples」を選択し、その中の「_icloud」を押下し、下記画像の赤枠内のような設定にする。
※icloudにログインしているユーザーはデータベースのレコードへ「作成・閲覧・更新」ができる権限を与える。「_world」があり、全てのユーザーへの権限設定が可能だが、「閲覧」の権限しか与えることができない。

プログラム解説

ビュー画面

投稿一覧画面

CloudKitのあるデータを表示している投稿一覧画面のプログラムが以下になります。

import SwiftUI

struct ThreadView: View {

    //trueでスレッドの中身画面をモーダル表示
    @State var thread_state = false

    //CloudKitから取得した値を格納する変数
    @State var id = ""
    @State var title = ""
    @State var report = ""
    @State var post_name = ""
    @State var date = Date()
    @State var gt = false
    @State var selectedImaged:Data = Data.init()

    //trueで投稿画面を開く
    @State var isActive = false
    //ThreadinItem構造体で定義したデータ構造を配列で宣言
    @State var inItems = [ThreadinItem]()

    @Environment(\.presentationMode) var presentationMode
    
    @AppStorage("myname") var nameAppStorage = ""
    
    //前画面からスレッドの名前を引き継ぎ
    @Binding var threadname: String
    
    //ThreadinListViewModelクラスのインスタンスを指定(viewModel 内の状態が変更されるとビューが自動的に再描画)
    @StateObject var viewModel = ThreadinListViewModel()
    
    var body: some View {
        //isActiveがtrueであれば投稿画面に遷移する処理
        NavigationView {
            ZStack{
                NavigationLink(destination: ThreadPost(threadname: $threadname, inItems: $inItems), isActive: $isActive){
                    EmptyView()
                }
                VStack{
                 Text(threadname).bold().font(.system(size:24)).padding(10)
                    List{
                        //投稿を人気順で表示する場合
                        if gt {
                            //CloudKitに登録されているレコードの数だけループ処理
                            ForEach(viewModel.items_good, id: \.self){threadin in
                                Button(action: {
                                    id = threadin.id
                                    title = threadin.title
                                    report = threadin.report
                                    post_name = threadin.name
                                    date = threadin.timestamp
                                    if threadin.imagedata != nil {
                                        selectedImaged = threadin.imagedata
                                    }
                                    self.thread_state.toggle()
                                }){
                                    VStack {
                                        //投稿一覧を表示するためのビューを記載する。※今回は省略
                                    }.foregroundColor(Color.black)
                                }
                            }.listRowBackground(Color.white)
                          //投稿を新規順で表示する場合
                        } else {
                            ForEach(viewModel.items, id: \.self){threadin in
                                Button(action: {
                                    id = threadin.id
                                    title = threadin.title
                                    report = threadin.report
                                    post_name = threadin.name
                                    date = threadin.timestamp
                                    if threadin.imagedata != nil {
                                        selectedImaged = threadin.imagedata
                                    }
                                    self.thread_state.toggle()
                                }){
                                    VStack{
                                        
                                    }.foregroundColor(Color.black)
                                }
                            }.listRowBackground(Color.white)
                        }
                     //投稿の中身画面をモーダル表示するための処理
                    }.sheet(isPresented: $thread_state, onDismiss: {
                        selectedImaged = Data.init()
                        self.thread_state = false
                    }){
                        ThreadInside(id: $id, title: $title, date:$date,selectedImaged: $selectedImaged, report: $report, post_name: $post_name, threadname: $threadname,thread_state: $thread_state)
                    }
                    //viewModel.canUseCloudDatabaseはiCloudへのログイン状態
                    if viewModel.canUseCloudDatabase && nameAppStorage != "ゲスト"{
                        Button(action: {
                            //CloudKitから取得したデータを格納
                            inItems = viewModel.items
                            isActive = true
                        }){
                            Text("投稿").padding()
                        }
                    }
                }.navigationBarItems(leading: HStack{
                    Button(action: {
                        self.presentationMode.wrappedValue.dismiss()
                    }){
                        Text("<戻る").padding(.horizontal, 10)
                    }
                    //新規順か人気順かを切り替えるための処理
                    if gt {
                        Button(action: {
                            gt = false
                        }){
                            Text("新規順").font(.system(size:20)).padding(.leading, 35)
                        }
                        Text("人気順").font(.system(size:20)).padding(.horizontal, 15)
                    } else {
                        Text("新規順").font(.system(size:20)).padding(.leading, 35)
                        Button(action: {
                            gt = true
                        }){
                            Text("人気順").font(.system(size:20)).padding(.horizontal, 15)
                        }
                    }
                })
                //画面に遷移した際に実行される処理
                .task {
                    //ユーザーのicloudログイン状態
                    await viewModel.checkAccountStatus()
                    //CloudKitからデータを取得(fetch)してくる処理
                    await viewModel.fetchItems(threadname: threadname)
                }
                //isLoadingがtrueであればプレースホルダーを表示(ロード時に出るやつ)
                .redacted(reason: viewModel.isLoading ? .placeholder : [])
                //画面をロード(下にスクロール)した時の処理。再びCloudKitからデータを取得
                .refreshable {
                    await viewModel.fetchItems(threadname: threadname)
                }
            }
        }.navigationViewStyle(.stack).navigationBarBackButtonHidden(true)
    }
}

上記のプログラムはCloudKitの説明用に一部省略して記載しています。全てのプログラムの内容を確認したい場合は以下の記事をご参照ください。

投稿画面

CloudKitへデータを追加する投稿画面のプログラムが以下になります。

import SwiftUI
import FirebaseDatabase

struct ThreadPost: View {
    
    //投稿内容を格納する変数
    @State var title = ""
    @State var report = ""
    @State var selectedImaged:Data = Data.init()

    //同じ投稿があればアラート分を表示させるための変数
    @State var tex = ""
    //trueで画像の挿入処理をする
    @State var showphoto = false
    
    //ThreadinItemインスタンスを初期化
    @State var item = ThreadinItem(name: "", title: "", imagedata: Data.init(), report: "", threadname: "", timestamp: Date(), good: 0)
    
    @AppStorage("myname") var nameAppStorage = ""
    
    //前画面より情報を引き継ぐ
    @Binding var threadname: String
    //CloudKitより取得したレコードが格納されている
    @Binding var inItems: [ThreadinItem]
    
    @Environment(\.presentationMode) var presentationMode
    
    @StateObject var viewModel = ThreadinListViewModel()
    
    var body: some View {
        NavigationView {
            VStack{
                //投稿情報を入力するビュー
                Form{
                    Section(header: Text("タイトル")){
                        TextField("15〜40文字",text:$title, axis: .vertical)
                    }
                    //画像を挿入する処理。「selectedImaged.count > 1000000」はCloudKitのデータ制限により、100000Byteのデータを格納できないため記載
                    if selectedImaged == Data.init() ||  selectedImaged.count > 1000000 {
                        Button(action: {
                            showphoto = true
                        }){
                            Text("画像挿入")
                        }.sheet(isPresented: $showphoto, onDismiss: {
                            showphoto = false
                        }){
                            ImagePicker(selectedImage: $selectedImaged, tex: $tex)
                        }
                    } else {
                        Button(action: {
                            showphoto = true
                        }){
                            Text("画像挿入済み")
                        }.sheet(isPresented: $showphoto, onDismiss: {
                            showphoto = false
                        }){
                            ImagePicker(selectedImage: $selectedImaged, tex: $tex)
                        }
                    }
                    Section(header: Text("記事")){
                        TextEditor(text: $report).frame(height: 400,alignment: .topLeading)
                    }
                }
                HStack{
                    Button(action: {
                        self.presentationMode.wrappedValue.dismiss()
                    }){
                        Text("やめる").padding()
                    }
                    //条件を満たしたら投稿ボタンを押せるようにする
                    if title == "" || report == "" || selectedImaged.count > 1000000{
                        Text("投稿").foregroundColor(Color.gray).padding()
                    }else {
                        Button(action: {
                            //CloudKitへデータを追加する関数を呼び出す
                            addPost()
                            self.presentationMode.wrappedValue.dismiss()
                        }){
                            Text("投稿").padding()
                        }
                    }
                }
                //アラート文、初期値は空っぽ
                Text(tex).font(.system(size: 13)).foregroundColor(.red)
            }
        }.navigationViewStyle(.stack).navigationBarBackButtonHidden(true)
    }
    
    func addPost() {
        var new_threadin = true
        //CloudKit内のデータに同じ記事内容があれば投稿できなくする。(同じ記事の連投を阻止する)
        for threadin in inItems {
            if threadin.title == title && threadin.name == nameAppStorage {
                tex = "以前と同じ投稿です。"
                new_threadin = false
            }
        }
        if new_threadin {
            //ThreadinItemインスタンスにデータを渡し、itemへ格納
            item = ThreadinItem(name: nameAppStorage, title: title, imagedata: selectedImaged, report: report, threadname: threadname, timestamp: Date(), good: 0)
            //非同期のタスクとする。つまり独立して実行するための記載
            Task.detached {
                //itemの情報をCloudKitに追加するための処理
                await viewModel.saveItem(item)
            }
        }
    }
}

上記のプログラムはCloudKitの説明用に一部省略して記載しています。全てのプログラムの内容を確認したい場合は以下の記事をご参照ください。

また、画像の挿入に関して詳しく記載した記事もあるため、どのような処理内容か理解したい方は以下の記事をご参照ください。

CloudKitへの接続・処理の定義

以下はCloudKitへの接続・データの定義・CloudKitへの処理に関するプログラムになります。

import CloudKit
import Foundation

//CloudKitで使用するためのデータモデルを定義
struct ThreadinItem: Hashable {
    var id: String
    var name: String
    var title: String
    var imagedata: Data
    var report: String
    var threadname: String
    var timestamp: Date
    var good: Int

    //CloudKit レコードのキーを定義した列挙型です。各ケースは、CKRecord内の対応するフィールドにマッピングします。
    enum ThreadinItemRecordKeys: String {
        case type = "ThreadinItem"
        case id
        case name
        case title
        case imagedata
        case report
        case threadname
        case timestamp
        case good
    }
    //CloudKitデータベースに追加する新しいレコードを作成する
    //CloudKitのCKRecordに変換するためのメソッドであり、それぞれのプロパティを適切なキーに紐付け、CKRecordを生成
    var record: CKRecord {
        //レコードを識別するための新しいレコードIDを生成
        let recordId = CKRecord.ID(recordName: id)
        //レコードのタイプと一意の識別しを指定
        let record = CKRecord(recordType: ThreadinItemRecordKeys.type.rawValue,
                              recordID: recordId)
        record[ThreadinItemRecordKeys.id.rawValue] = id
        record[ThreadinItemRecordKeys.name.rawValue] = name
        record[ThreadinItemRecordKeys.title.rawValue] = title
        record[ThreadinItemRecordKeys.imagedata.rawValue] = imagedata
        record[ThreadinItemRecordKeys.report.rawValue] = report
        record[ThreadinItemRecordKeys.threadname.rawValue] = threadname
        record[ThreadinItemRecordKeys.timestamp.rawValue] = timestamp
        record[ThreadinItemRecordKeys.good.rawValue] = good
        return record
    }
}

//CloudKitのCKRecordからThreadinItem構造体のインスタンスを初期化するためのイニシャライザ拡張。また、デフォルトのイニシャライザも記載。
extension ThreadinItem {
    //CKRecordからThreadinItemインスタンスを初期化
    init?(from record: CKRecord) {
        guard let id = record[ThreadinItemRecordKeys.id.rawValue] as? String,
              let name = record[ThreadinItemRecordKeys.name.rawValue] as? String,
              let title = record[ThreadinItemRecordKeys.title.rawValue] as? String,
              let imagedata = record[ThreadinItemRecordKeys.imagedata.rawValue] as? Data,
              let report = record[ThreadinItemRecordKeys.report.rawValue] as? String,
              let threadname = record[ThreadinItemRecordKeys.threadname.rawValue] as? String,
              let timestamp = record[ThreadinItemRecordKeys.timestamp.rawValue] as? Date,
              let good = record[ThreadinItemRecordKeys.good.rawValue] as? Int
        else { return nil }
        self = .init(id: id, name: name, title: title, imagedata: imagedata, report: report ,threadname: threadname, timestamp: timestamp, good: good)
    }

    //ThreadinItem 構造体のプロパティを引数として、新しいインスタンスを作成
    init(name: String, title: String, imagedata: Data, report: String, threadname: String, timestamp: Date, good: Int) {
        let id = UUID().uuidString
        self = .init(id: id, name: name, title: title, imagedata: imagedata, report: report,threadname: threadname, timestamp: timestamp, good: good)
    }
}

//CloudKitに対する処理を記載
class CloudKitService {

    //コンテナーへの接続情報を取得
    private let container = CKContainer.init(identifier: "コンテナ名")
    //コンテナー内のパブリックデータベースの接続情報を取得
    private var database: CKDatabase {
        return container.publicCloudDatabase
    }
 
    //コンテナーの操作をするにあたりアカウントの状態を取得
    func accountStatus() async throws -> CKAccountStatus {
        return try await container.accountStatus()
    }
    
    //データを保存する処理。ThreadinItem構造体のrecodeメソッドで返ってくる情報を格納
    func saveItemin(_ item: ThreadinItem) async {
        do {
            try await database.save(item.record)
            print("🌟Success to save the record:", item.record)
        } catch {
            print("🔥Failure to save the record:", item.record, error)
        }
    }
    
    //CloudKitからデータを取得してくる処理。戻り値はレコードの一覧情報の配列
    func fetchItemsin(threadname: String) async throws -> ([ThreadinItem],[ThreadinItem]) {
        //スレッド名でフィルターをした投稿情報を検索
        let query = CKQuery(recordType: ThreadinItem.ThreadinItemRecordKeys.type.rawValue,
                            predicate: NSPredicate(format: "threadname == %@ ", threadname))

        //検索してきた情報を日付でソート
        query.sortDescriptors = [.init(key: ThreadinItem.ThreadinItemRecordKeys.timestamp.rawValue,
                                       ascending: false)]

        //検索条件で引っかかった値を取得
        let result = try await database.records(matching: query)
        //Result型からCKRecordに変換。matchResultsのタプル配列のvalue側がnilの値を省略(エラーでもnilを返す)。
        let records = result.matchResults.compactMap { try? $0.1.get() }
        
        //いいね数でソート
        query.sortDescriptors = [.init(key: ThreadinItem.ThreadinItemRecordKeys.good.rawValue,
                                       ascending: false)]
        let result_read = try await database.records(matching: query)
        let records_read = result_read.matchResults.compactMap { try? $0.1.get() }
        
        //日付順といいね数順のレコード一覧を取得。CKRecordをThreadinItem型に変換している。
        return (records.compactMap(ThreadinItem.init), records_read.compactMap(ThreadinItem.init))
    }

    //recordIDと合致するレコードを削除
    func deleteItem(with recordId: CKRecord.ID) async {
        do {
            try await database.deleteRecord(withID: recordId)
            print("🌟Success to delete item with record ID:", recordId)
        } catch {
            print("🔥Failure to delete item with record ID:", recordId)
        }
    }
    
    //特定レコード内の情報を更新するための処理
    func UpdateRecodein(id:String, nums:Int) async {
        let recordID = CKRecord.ID(recordName: id)
        
        // レコードの取得
        database.fetch(withRecordID: recordID) { record, error in
            guard let record = record else {
                print("Error fetching record: \(error!)")
                return
            }
            // フィールドの更新
            record["good"] = nums
            
            //CKModifyRecordsOperationを使用して、レコードを更新
            let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
            //保存方法(変更されたフィールドのみ保存)
            operation.savePolicy = .changedKeys
            //レコードの更新が完了したときに実行されるクロージャを設定
            operation.modifyRecordsCompletionBlock = { savedRecords, deletedRecordIDs, error in
                guard error == nil else {
                    print("Error updating record: \(error!)")
                    return
                }
                // 更新が成功した場合の処理
                print("Record updated successfully")
            }
            self.database.add(operation)
        }
    }
}

ビューからの処理に対応する

以下はビューからの呼び出しに対して、適切な形でCloudKitへの処理を呼び出すためのプログラムになります。実行順序は「ビュー→ビューからの処理に対応→CloudKitの処理」になります。

import Foundation
import CloudKit
import FirebaseDatabase

class ThreadinListViewModel: ObservableObject {

    //ThreadinItemのインスタンス
    @Published var items = [ThreadinItem]()
    @Published var items_good = [ThreadinItem]()
    
    //trueでロード中であることを表示
    @Published var isLoading = false
    //ユーザーのアカウント状態を格納
    @Published var canUseCloudDatabase = false
    
    //Firebaseを利用する
    let ref = Database.database().reference()
    //CloudKitServiceのインスタンス
    private let service = CloudKitService()

    //CloudKitにデータを保存する処理を呼び出す。保存されるデータはitemの情報
    func saveItem(_ item: ThreadinItem) async {
        await service.saveItemin(item)
    }
    
    @MainActor
    func fetchItems(threadname: String) async {
        //ここがtrueの間はロード中
        isLoading = true
        //CloudKitからデータを取得する処理を呼び出す。呼び出された値を変数を分けて格納。
        do {
            let rerurn_result = try await service.fetchItemsin(threadname: threadname)
            //ビューから処理によってどちらかが呼ばれる
            items = rerurn_result.0
            items_good = rerurn_result.1
            isLoading = false
        } catch {
            print("🔥Failure to fetch items:", items, error)
            isLoading = false
        }
    }

    @MainActor
    func deleteItem(with id: String) async {
        //itemsのidとビューから取得したIDと一致したものを削除
        items.removeAll { $0.id == id }
        //CloudKitのレコードを削除する処理を呼び出す
        let recordId = CKRecord.ID(recordName: id)
        await service.deleteItem(with: recordId)
    }

    func checkAccountStatus() async {
        do {
            let status = try await service.accountStatus()
            //メインスレッド上で非同期に処理を実行するためのものであり、UIの更新や描画など、メインスレッドでの処理が必要な場合に使用。
            DispatchQueue.main.async {
                self.canUseCloudDatabase = status == .available
            }
        } catch {
            print("🔥Failure to fetch account status:", error)
            canUseCloudDatabase = false
        }
    }
    
    //CloudKit上の特定レコードの情報を更新する処理
    func updatedb_good(threadId: String, tf: Bool, threadname: String) async {
        do {
            var nums = 0
            //Firebaseより特定の投稿の情報を取得
            let snapshot = try await ref.child("threadin/\(threadname)/" + threadId).getData()
            //値の情報(いいね数)を取得
            let num = snapshot.value as? Int ?? 0
            //いいねを押された場合
            if tf {
                nums = num + 1
            }else{
            //いいねが外された場合
                nums = num - 1
            }
            //Firebaseのデータを更新
            try await ref.child("threadin/\(threadname)/" + threadId).setValue(nums)
            //CloudKitのデータを更新する処理を呼び出す。
            await service.UpdateRecodein(id:threadId,nums:nums)
            } catch {
                print(error.localizedDescription)
            }
    }
    
}

CloudKitの解説をしやすいようにコードを一部省略しています。前コードを見たい場合は以下の記事をご参照ください。

以上がCloudKitを使用した処理のソースコードになります。
処理が複雑になっているため理解しにくいと思います。完璧に理解出来ずともサンプリとして利用いただき、トライアンドエラーで少しずつ理解していくのがお勧めです。

アプリ”NowShare”をダウンロード
情報共有アプリ"NowShare"
今からみんなでナレッジ共有! 本当に欲しい情報をキャッチ&リリース 教えてあげたい情報を知りたい人にすぐに共有。 ためになった情報にはいいねを押して盛り上げよう!
Appエンジニア
ナスジニアをフォローする
🍆ナスジニアのブログ(iPhoneアプリ開発)

コメント

タイトルとURLをコピーしました