【SwiftUI】CloudKitを使って簡単なTODOメモアプリを作ってみた

2022.08.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

データベースとしてCloudKitを使用したことがなかった為、簡単なTODOメモアプリを作りながら使い方を調べてみました。

環境

  • Xcode 13.3
  • iOS 15.5

作ったもの

cloudkit-with-todos-demo

こんな感じのTODOメモを作成しました。

CloudKitとは

アプリのデータをiCloudに保存し、すべてのデバイスと Web を最新の状態に保ちます。効率的な同期とシンプルな監視と管理を特長とする CloudKit を使用すると、アプリの構築と拡張がかつてないほど容易になります。プライベートデータをユーザーのiCloudアカウントに安全に保存して、ユーザーベースの拡大に合わせて無制限にスケールし、アプリのパブリック データ用に最大 1PBのストレージを取得します。

引用: Apple公式: CloudKit

アプリのデータをiCloudに保存ができ、無料で1PBまで利用が出来るとは素晴らしいですね。早速使っていきたいと思います。

プロジェクトにCloudKitを登録する

TARGETS > Signing & Capabilities を選択して左上の+ボタンを押します。

ダイアログが表示されるので、入力欄にCloudと入力。

Signing & Capabilities下部にiCloundの項目が追加されます。

今回はCloundKitを使用するのでチェックを入れて、Containersの+ボタンを押します。

新しくコンテナを追加する為のダイアログが表示されるので、任意のコンテナ名を入力します。コンテナ名はどのような値でも設定できますがiCloud.バンドルIDという命名が一般的のようです。

追加されるとナビゲータエリアに.entitlementsの拡張子のファイルが追加されます。追加が反映されない場合は、Container下部のリフレッシュボタンを押してみてください。

これでXcode側でのCloudKitを使う準備は整いました。

実装

今回はTODOリストアプリなので、CloudKitのデータベースに保存する為のToDoItemを定義します。

ToDoItem

import CloudKit

struct ToDoItem: Hashable {
    var id: String
    var title: String
    var registrationDate: Date

    enum ToDoItemRecordKeys: String {
        case type = "ToDoItem"
        case id
        case title
        case registrationDate
    }

    var record: CKRecord {
        let recordId = CKRecord.ID(recordName: id)
        let record = CKRecord(recordType: ToDoItemRecordKeys.type.rawValue,
                              recordID: recordId)
        record[ToDoItemRecordKeys.id.rawValue] = id
        record[ToDoItemRecordKeys.title.rawValue] = title
        record[ToDoItemRecordKeys.registrationDate.rawValue] = registrationDate
        return record
    }
}
  • id
    • レコード管理用のid
  • title
    • TODOのタイトル
  • registrationDate
    • TODOの登録日

また、レコード用のKeyとして使用する文字列をenumで定義しています。

record

CloudKitに保存する際に、CKRecord型を渡す必要がある為、変数recordを作成しています。recordにはそれぞれのKeyを使用してそれぞれの値を渡しています。

CKRecord作成時には、recordIdを渡さなくても自動で生成してくれるのですが、そのIDを管理するのが難しい為、独自でユニークなIDを作成して渡して、なおかつ値として保持してます。

init

// MARK: - Initializer
extension ToDoItem {

    init?(from record: CKRecord) {
        guard let id = record[ToDoItemRecordKeys.id.rawValue] as? String,
              let title = record[ToDoItemRecordKeys.title.rawValue] as? String,
              let registrationDate = record[ToDoItemRecordKeys.registrationDate.rawValue] as? Date
        else { return nil }
        self = .init(id: id, title: title, registrationDate: registrationDate)
    }

    init(title: String, registrationDate: Date) {
        let id = UUID().uuidString
        self = .init(id: id, title: title, registrationDate: registrationDate)
    }
}

レコードからToDoItemを生成する関数と、titleregistrationDateから生成する関数になります。後者では、idをUUID().uuidStringから取得して設定しています。

CloudService

import CloudKit

class CloudKitService {

    private let container = CKContainer.init(identifier: "iCloud.somethingYouWantToName")

    private var database: CKDatabase {
        return container.publicCloudDatabase
    }

    func accountStatus() async throws -> CKAccountStatus {
        return try await container.accountStatus()
    }

    func saveItem(_ item: ToDoItem) 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)
        }
    }

    func fetchItems() async throws -> [ToDoItem] {
        let query = CKQuery(recordType: ToDoItem.ToDoItemRecordKeys.type.rawValue,
                            predicate: NSPredicate(value: true))

        query.sortDescriptors = [.init(key: ToDoItem.ToDoItemRecordKeys.registrationDate.rawValue,
                                       ascending: true)]
        let result = try await database.records(matching: query)
        let records = result.matchResults.compactMap { try? $0.1.get() }
        return records.compactMap(ToDoItem.init)
    }

    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)
        }
    }
}

container

データの保存に使用するコンテナを指定します。identifierには最初にSigning & Capabilitiesで作成したコンテナの名前を入力します。

private let container = CKContainer.init(identifier: "iCloud.somethingYouWantToName")

database

データベースの種類は三通り選択出来、今回はpublicCloudDatabaseを選択しています。

private var database: CKDatabase {
    return container.publicCloudDatabase
}
Public database

Public databaseは全てのアプリユーザーがデータを読むことができるためiCloudの利用状況に関わらず共通のデータを使用したい場合に適しています。 iCloudにログインしているユーザーはPublic databaseに対し書き込みを行うことができますが、アプリ側で書き込むような機能を実装しなければアプリ開発者のみが(CloudKitのコンソールを通じて)書き込みすることができるためマスターデータを提供するようなことが可能です。

Private database

iCloudにログインしているユーザーが自身のプライベートな領域に読み書きをすることができます。Private databaseはiCloudにログインしているユーザーのみが読み書き可能でアプリ開発者含めた第三者はデータにアクセスすることができないためユーザーが安全にデータを格納するための領域を提供したい場合に適しています。

Shared database

Shared databaseはPrivate databaseと同じようにiCloudにログインしている場合にのみアクセスできユーザーのPrivate databaseのレコードをアプリケーションの他のユーザーと共有するために使用されます。他ユーザーへは読み込み、書き込み、その両方の権限を付与することができます。特定のユーザーのみ公開するようなことはできません。またデータの所有ユーザーが非公開化した場合は他ユーザーはアクセスできなくなります。

引用: CloudKitの概念を整理してみた

それぞれの特性に合ったデータベースを選択することが出来ます。

accountStatus

ユーザーのiCloundのアカウント状態を取得することが出来ます。もちろん、iCloundを利用できるようにしておかないとCloudKitを使用してデータを保存することが出来ません。

func accountStatus() async throws -> CKAccountStatus {
    return try await container.accountStatus()
}

saveItem

データベースにレコードを保存する関数です。ToDoItemで作成したrecordプロパティを使用して、データベースに保存しています。

func saveItem(_ item: ToDoItem) 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)
    }
}

fetchItems

データベースからレコードを取得して、そのレコードからToDoItemを生成しています。

func fetchItems() async throws -> [ToDoItem] {
    let query = CKQuery(recordType: ToDoItem.ToDoItemRecordKeys.type.rawValue,
                        predicate: NSPredicate(value: true))

    query.sortDescriptors = [.init(key: ToDoItem.ToDoItemRecordKeys.registrationDate.rawValue,
                                   ascending: true)]
    let result = try await database.records(matching: query)
    let records = result.matchResults.compactMap { try? $0.1.get() }
    return records.compactMap(ToDoItem.init)
}

queryToDoItemRecordKeys.typeと一致するものに絞って、登録日の昇順で選べるようにしています。

deleteItem

データベースからレコードを削除する関数です。レコードを削除するにはCKRecord.IDが必要になります。

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)
    }
}

ToDoListViewModel

import Foundation
import CloudKit

class ToDoListViewModel: ObservableObject {

    @Published var items = [ToDoItem]()
    @Published var showPopUpDialog = false
    @Published var isLoading = false
    @Published var canUseCloudDatabase = false

    private let service = CloudKitService()

    func saveItem(_ item: ToDoItem) async {
        await service.saveItem(item)
    }

    @MainActor
    func fetchItems() async {
        isLoading = true
        do {
            items = try await service.fetchItems()
            isLoading = false
        } catch {
            print("?Failure to fetch items:", items, error)
            isLoading = false
        }
    }

    @MainActor
    func deleteItem(with id: String) async {
        items.removeAll { $0.id == id }
        let recordId = CKRecord.ID(recordName: id)
        await service.deleteItem(with: recordId)
    }

    func checkAccountStatus() async {
        do {
            let status = try await service.accountStatus()
            canUseCloudDatabase = status == .available
        } catch {
            print("?Failure to fetch account status:", error)
            canUseCloudDatabase = false
        }
    }
}

プロパティ

@Published var items = [ToDoItem]()
@Published var showPopUpDialog = false
@Published var isLoading = false
@Published var canUseCloudDatabase = false

private let service = CloudKitService()
  • items
    • TODOアイテム
  • showPopUpDialog
    • 新規TODO追加用のポップアップダイアログを表示するかのフラグ
  • isLoading
    • データベースからデータをロード中かどうかのフラグ
  • canUseCloudDatabase
    • アカウント情報からユーザーがデータベースを使用できるかのフラグ

また上記で記載したCloudServiceも宣言しています。

saveItem

引数で渡されたToDoItemをデータベースに保存します。

func saveItem(_ item: ToDoItem) async {
    await service.saveItem(item)
}

fetchItems

データベースからToDoItemを取得します。

@MainActor
func fetchItems() async {
    isLoading = true
    do {
        items = try await service.fetchItems()
        isLoading = false
    } catch {
        print("?Failure to fetch items:", items, error)
        isLoading = false
    }
}

取得を開始した際に、isLoadingのフラグをtrueに切り替え、終了時にfalseにしています。

deleteItem

引数として渡されたid: Stringと一致するToDoItemitems配列から削除して、データベースからも該当のものを削除をしています。

@MainActor
func deleteItem(with id: String) async {
    items.removeAll { $0.id == id }
    let recordId = CKRecord.ID(recordName: id)
    await service.deleteItem(with: recordId)
}

checkAccountStatus

ユーザーのアカウント状態を取得して、.availableならcanUseCloudDatabasetrueに切り替え、そうでない場合はfalseに切り替えています。

func checkAccountStatus() async {
    do {
        let status = try await service.accountStatus()
        canUseCloudDatabase = status == .available
    } catch {
        print("?Failure to fetch account status:", error)
        canUseCloudDatabase = false
    }
}

ToDoListView

TODOリストと右上のNavigationBarのボタンとしてTODOを追加する+ボタンを設置しています。

import SwiftUI

struct ToDoListView: View {

    @StateObject var viewModel = ToDoListViewModel()

    var body: some View {
        ZStack {
            // ? TODO List
            List {
                ForEach(viewModel.items, id: \.self) { item in
                    Text(item.title)
                        .swipeActions(edge: .trailing,
                                      allowsFullSwipe: true) {
                            Button(role: .destructive) {
                                Task.detached {
                                    await viewModel.deleteItem(with: item.id)
                                }
                            } label: {
                                Image(systemName: "trash")
                            }
                        }
                }
            }
            .redacted(reason: viewModel.isLoading ? .placeholder : [])
            .refreshable {
                await viewModel.fetchItems()
            }

            // ❗️ Pop-up Dialog
            if viewModel.showPopUpDialog {
                PopUpDialogView(isPresented: $viewModel.showPopUpDialog,
                                isEnabledToCloseByBackgroundTap: true) {
                    AddToDoView { item in
                        Task.detached {
                            await viewModel.saveItem(item)
                        }
                        viewModel.showPopUpDialog = false
                    }
                }
            }

            // ? Unavailable View
            if !viewModel.canUseCloudDatabase {
                UnavailableView()
            }
        }
        .navigationTitle("TODOs")
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Button {
                    viewModel.showPopUpDialog = true
                } label: {
                    Image(systemName: "plus")
                }
                .disabled(!viewModel.canUseCloudDatabase)
            }
        }
        .task {
            await viewModel.checkAccountStatus()
            await viewModel.fetchItems()
        }
    }
}

ToDoList

viewModel.itemsForEachTextとして表示しています。スワイプアクションを行うと、itemの削除が出来るようになっています。

List {
    ForEach(viewModel.items, id: \.self) { item in
        Text(item.title)
            .swipeActions(edge: .trailing,
                          allowsFullSwipe: true) {
                Button(role: .destructive) {
                    Task.detached {
                        await viewModel.deleteItem(with: item.id)
                    }
                } label: {
                    Image(systemName: "trash")
                }
            }
    }
}
.redacted(reason: viewModel.isLoading ? .placeholder : [])
.refreshable {
    await viewModel.fetchItems()
}

viewModel.isLoadingの値を見て、ロード中の場合は、.placeholderを表示させるようにしており、Listをプルダウンすると、viewModel.fetchItems()を実行します。

Pop-up Dialog

ポップアップで表示されるダイアログの詳細は割愛させていただきます。詳細はこちらの記事を見ていただければと思います。

【SwiftUI】ポップアップで表示されるダイアログを作ってみた

ポップアップで表示されるダイアログで中身のViewとしてAddToDoViewを埋め込んでいます。

AddToDoViewで決定ボタンを押すことでviewModel.saveItem(item)を実行するようにしています。

if viewModel.showPopUpDialog {
    PopUpDialogView(isPresented: $viewModel.showPopUpDialog,
                    isEnabledToCloseByBackgroundTap: true) {
        AddToDoView { item in
            Task.detached {
                await viewModel.saveItem(item)
            }
            viewModel.showPopUpDialog = false
        }
    }
}
AddToDoView
import SwiftUI

struct AddToDoView: View {

    @State private var title = ""

    let addAction: (_ toDoItem: ToDoItem) -> Void

    var body: some View {
        VStack(spacing: 32) {
            Text("New Task")
            TextField("TiTle", text: $title)
                .padding()
                .background(Color(uiColor: .systemGray5))

            Button {
                let toDoTitle = title == "" ? "No title" : title
                addAction(ToDoItem(title: toDoTitle, registrationDate: Date()))
            } label: {
                Text("決定")
                    .foregroundColor(.white)
                    .padding(.vertical)
                    .padding(.horizontal, 32)
                    .background(.tint)
                    .cornerRadius(24)
            }
        }
        .padding()
    }
}

UnavailableView

Cloudのデータベースが利用できない場合に表示するViewです。

// ? Unavailable View
if !viewModel.canUseCloudDatabase {
    UnavailableView()
}
struct UnavailableView: View {
    var body: some View {
        ZStack {
            Rectangle()
                .fill(.black.opacity(0.7))
                .ignoresSafeArea()

            Text("Sorry!\nYou can't use iCloud Database")
                .fontWeight(.black)
                .foregroundColor(.white)
        }
    }
}

その他

.navigationTitle("TODOs")
.toolbar {
    ToolbarItem(placement: .navigationBarTrailing) {
        Button {
            viewModel.showPopUpDialog = true
        } label: {
            Image(systemName: "plus")
        }
        .disabled(!viewModel.canUseCloudDatabase)
    }
}
.task {
    await viewModel.checkAccountStatus()
    await viewModel.fetchItems()
}
toolbar

右上のNavigationBarのボタンを押すと、PopUpDialogViewを表示しています。また、ユーザーがデータベースを利用出来ない場合は.disableを適用しています。

task

iOS 15から使用できるメソッドで、Viewが表示される前に非同期処理を追加出来ます。今回はユーザーのアカウントチェックとItemをデータベースから取得する処理を実行しています。

CloudKit コンソールで確認する

CloudKitコンソールを表示させるには、Signing & CapabilitiesのiCloudの下部のCloudKit Consoleボタンを押すと表示されます。

CloudKit Databaseを押します。

データベースが表示されたら、今回使用する(又は、している)コンテナ名と一致しているか確認します。複数コンテナがある場合はここで切り替えが可能です。

Recordの項目を選択し、今回確認したいデータベースになっているかを確認し、レコードタイプも今回確認したいレコードタイプに切り替えます。すでのアプリ等で保存済みのレコードタイプは選択肢として表示されます。

Query Recordボタンを押すことでクエリを実行出来ます。

ただレコードタイプを追加して、一番初めはQuery Recordボタンを押すと、Field recordName is not marked queryableというエラーが表示されました。

このエラーを解決するためには、queryablerecordNameという値を追加する必要がある為、追加します。

Scheme > Indexes を開き、該当のレコードタイプを選択します。

開かれたページの下部にあるAdd Basic Indexをタップします。

選択項目が表示されるのでrecordNameを選択します。

recordNameを選択すると、自動でQueryableも入力されます。これでSave Changesを押すと完了です。

Record画面に戻って、Query Recordボタンを押すと無事にクエリ出来ました。

おわりに

前提としてユーザーがiCloudを利用できるようにしておかないといけないですが、それさえ乗り越えればとても便利なデータベースなのでは無いかなと感じました。 また、TODOを追加した後に、データベースに反映されるのにややタイムラグがあるように感じました。追加した後にリストに変更を反映させる場合には、少し時間を開けてレコードをフェッチするやItems配列に値を追加する等の対応は必要そうです。

CoreDataとの連携も出来るそうなので、機会があれば今度はそちらも試してみたいと思います!

参考