【SwiftUI】CloudKitを使って簡単なTODOメモアプリを作ってみた
データベースとしてCloudKitを使用したことがなかった為、簡単なTODOメモアプリを作りながら使い方を調べてみました。
環境
- Xcode 13.3
- iOS 15.5
作ったもの
こんな感じのTODOメモを作成しました。
CloudKitとは
アプリのデータをiCloudに保存し、すべてのデバイスと Web を最新の状態に保ちます。効率的な同期とシンプルな監視と管理を特長とする CloudKit を使用すると、アプリの構築と拡張がかつてないほど容易になります。プライベートデータをユーザーのiCloudアカウントに安全に保存して、ユーザーベースの拡大に合わせて無制限にスケールし、アプリのパブリック データ用に最大 1PBのストレージを取得します。
アプリのデータを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
を生成する関数と、title
とregistrationDate
から生成する関数になります。後者では、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のレコードをアプリケーションの他のユーザーと共有するために使用されます。他ユーザーへは読み込み、書き込み、その両方の権限を付与することができます。特定のユーザーのみ公開するようなことはできません。またデータの所有ユーザーが非公開化した場合は他ユーザーはアクセスできなくなります。
それぞれの特性に合ったデータベースを選択することが出来ます。
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) }
query
でToDoItemRecordKeys.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
と一致するToDoItem
をitems
配列から削除して、データベースからも該当のものを削除をしています。
@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
ならcanUseCloudDatabase
をtrue
に切り替え、そうでない場合は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.items
をForEach
でText
として表示しています。スワイプアクションを行うと、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
ポップアップで表示されるダイアログの詳細は割愛させていただきます。詳細はこちらの記事を見ていただければと思います。
ポップアップで表示されるダイアログで中身の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
というエラーが表示されました。
このエラーを解決するためには、queryable
のrecordName
という値を追加する必要がある為、追加します。
Scheme > Indexes を開き、該当のレコードタイプを選択します。
開かれたページの下部にあるAdd Basic Indexをタップします。
選択項目が表示されるのでrecordName
を選択します。
recordName
を選択すると、自動でQueryable
も入力されます。これでSave Changesを押すと完了です。
Record画面に戻って、Query Recordボタンを押すと無事にクエリ出来ました。
おわりに
前提としてユーザーがiCloudを利用できるようにしておかないといけないですが、それさえ乗り越えればとても便利なデータベースなのでは無いかなと感じました。 また、TODOを追加した後に、データベースに反映されるのにややタイムラグがあるように感じました。追加した後にリストに変更を反映させる場合には、少し時間を開けてレコードをフェッチするやItems配列に値を追加する等の対応は必要そうです。
CoreDataとの連携も出来るそうなので、機会があれば今度はそちらも試してみたいと思います!