[SwiftUI][Realm] SwiftUI と Realm で TODOアプリを作る

     
Realm で TODOアプリを作っていきます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.2 Beta
  • Xcode 13.2.1
  • iOS 15.2
  • Realm 10.21.0

TODOアプリアーキテクチャ

MVVM で作っていきます。Model として、TODOModel class を作ります。
このクラスが Realm を扱います。(View, ViewModel からは、Realmが直接は見えません)

ViewModel としては、その名前そのままの ViewModel という class を用意しました。
このクラスが、View と Model との仲介を行います。

View は、段階ごとに作っていきます。MainView が起動直後のメインビューとなり、TODO 項目をリスト表示。
DetailView が詳細確認や変更を行うビューになる予定です。

なお、MVVM+Router に拡張する予定で、Router として Router class を用意する予定です。
Router は、アプリの状態から次に表示する View を決定する役割を担います。

Xcode プロジェクトセットアップ

以降で、Realm を使用したアプリを作成していくために、Xcode プロジェクトを作成します。

プロジェクト作成

まずは、いつも通り(?) にプロジェクトを作成します。

Xcode を起動し、"File" - "New" - "Project..." を選択することで新規プロジェクト作成ウィザードが動き始めます。

以降では、以下の設定で作成したプロジェクトで実装していきます。

テンプレート選択では、"iOS App" を選択します。

次の画面では、以下のように選択します。

  • product name: RealmTODO
  • Interface: SwiftUI
  • Language: Swift
  • (no-check) Use CoreData
  • (no-check) Inlude Tests

適当なフォルダを指定して保存すれば プロジェクト作成は完了です。

SwiftPM設定

Realm を使うためには、外部ライブラリを使用する設定を行う必要があります。

Carthage, CocoaPods, SwiftPM(SwiftPackageManager) 等、さまざまな仕組みがありますが、Apple が推していることもあり SwiftPM を使用して設定していきます。

Swift Package の追加

外部パッケージとして Realm を 以下の設定でプロジェクトに追加します。

  • Package URL https://github.com/realm/realm-swift.git
  • Dependency Rule: Up to Next Major Version
  • minimum バージョン: 10.10.0
  • maximum バージョン: 空欄

こうすることで、プロジェクトから、外部パッケージである "Realm" を使用することができるようになります。

バージョン指定から、適切なバージョンが選択され取得されます。本記事執筆時点では、Realm 10.21.0 が取得されました。取得のタイミングで異なるバージョンが取得されるかもしれませんが、問題ないハズです。

Swift Package を使う : import

ここまでの設定をしたプロジェクトであれば、Realm を使用したいコードで、"import RealmSwift" と使用を宣言することで、Realm を使用することができます。

Realmの使い方

Realmへのアクセス

Realm での要素を考える前に、まずは、 Realm そのものへのアクセスを確認します。

以下のようなコードで Realm を取得し、その配下の情報にアクセスしていくことになります。


// (1) Realm を取得
let realm = try! Realm()
// (2) Realm からのデータ取得
let items = realm.objects(...)
// (3) Realm にデータ保存
let newItem = ...
try! realm.write {
    realm.add(newItem)
}

Realm での要素定義

定義方法

Realm に保存する要素は、以下のようにして定義する必要があります。

  • 保存するオブジェクトは Object (RealmSwiftObject) を継承した class として作成する
  • 保存対象にするプロパティは、@Persisted という property wrapper を指定する

例えば、TODOItem という要素で、UUID 型の id, String 型の title と detail を持つような要素は以下のような定義となります。


class TODOItem: Object {
    @Persisted var id: UUID
    @Persisted var title: String
    @Persisted var detail: String
}

すべてのプロパティを Realm に保存したいので 全てのプロパティに @Persisted を付与しています。
# @Persisted は、Realm 10.10.0 で導入された記法です。
# @Persisted を付与しないプロパティは、保存されません。

サポートされている型

Realm では、@Persisted 指定された以下の型について保存することができます。

  • Bool
  • Int, Int8, int16, Int32, Int64
  • Float, Double
  • String
  • Data
  • Date
  • Decimal128
  • UUID
  • ObjectId
  • List
  • MutableSet
  • Map (optional 不可)
  • AnyRealmValue (optional 不可)
  • User-defined Object (optional のみ可)

String や Int 等の primitive な型はサポートされていて、かつ optional として保持することもできますので、普通に使うときに困ることは少ないはずです。

特徴的なところとしては、Map, AnyRealmValue は、optional としては持つことができません。独自定義したオブジェクトは、optional としてしか持つことはできません。10進数を保持するための型は、Decimal128 として定義されています。なお、Decimal128 は、Realm で定義されている class です。Swift/Objective-C の Decimal とは異なります。 User-defined なオブジェクトは、optional としてのみ持つことができます。

TODOItem を定義する

TODOの要素としては、タイトルと詳細を String で持ち、UUID 型のid を持つ要素 TODOItemとして定義しました。


class TODOItem: Object, Identifiable {
    @Persisted(primaryKey: true) var id: UUID = UUID()
    @Persisted var title: String
    @Persisted var detail: String
}

先ほどの定義例と比較して追加した点は、以下の2点です。

  • Identifiable に準拠させることで、SwiftUI での処理をしやすくする
  • id に primaryKey 指定する

重複しない値を持つプロパティに、primaryKey を指定することで、オブジェクトを効率的に検索・更新することができるようになります。大規模にならないと体感しない気もしますが、指定しておきます。

Realm には、この TODOItem が複数保存されることになります。

ViewModel 等から直接 Realm とやり取りしないように、TODOItem を束ねるためのクラスも定義していきます。

TODOアプリで使うモデル

TODOItem を管理するクラス TODOModel を定義します。


class TODOModel {
    var config: Realm.Configuration
    
    init() {
        config = Realm.Configuration()
    }
    
    private var realm: Realm {
        return try! Realm(configuration: config)
    }
}

Realm は、この TODOModel で管理されます。この後に作成する ViewModel は、この Model を経由して、Realm にアクセスすることになります。

Realm でのコレクション

Realm から特定の型をすべて取得するには以下のようなコードになります


let realm = try! Realm()
let items = realm.objects(TODOItem.self)

このとき受け取る items は、Results<TODOItem> という型になっています。

この Results<TODOItem> という型は List<TODOItem> と同じようにフィルター等をかけることができますが、SwiftUI で直接扱うのには不向きでです。たとえば、SwiftUI の ForEach では直接扱ってはいけません。

なぜかというと SwiftUI では、List 等で表示に使用するコレクションは immutable である(変更されない)ことを想定しているのですが、Realm は、変更が発生した際には、変更以前に取得していたコレクション Results<TODOItem> も動的に変更します。

この機能は、Realm では、"Live" である言われます。 Realm で変更が行われると、そこから取得/参照しているオブジェクトも動的に変更が反映されるようになっています。

例えば、新しく TODOItem を追加すると (追加以前に取得していた)コレクション Results も要素が増えます。この機能は、データが更新されていても新規にフェッチすることなくアクセスでき、非常に便利なのですが、そのまま SwiftUIと組み合わせると、困ることになります。

明示的に、コレクションを immutable にするために、.freeze というメソッドが用意されています。

SwiftUI での List 等で使うためには、この freeze メソッドを使い、コレクションを immutable にする必要があります。

SwiftUIでのコレクション

SwiftUI 向けには新しく @ObservedResults というプロパティラッパー が用意されています。

以下のように定義することで使用できます。


    @ObservedResults(TODOItem.self) var items

上記のように定義するだけで、items = realm.objects(TODOItem.self) と定義していることになります。

さらにすでに freeze されていて immutable になっているので、そのまま SwiftUI で使うことができます。

なお、immutable のままでは変更できないので、変更が必要な時には、.thaw メソッドを使って、変更可能にする必要があります。

この記事シリーズでは、View は、Realm のデータを ViewModel 経由で取得するため、@ObservedResults は使用しません。

Realmからの読み出し

先のコレクションで説明しましたが、以下のコードで、Realm に保存されている TODOItem を取得することができます。


    @ObservedResults(TODOItem.self) var items

注意点としては、immutable なので、そのままでは変更できず、変更が必要であれば .thaw メソッドを使う必要があるという点です。

SwiftUI の ビュー向けに読みだす

すべての TODOItem の Title をリスト表示するようなビューは、@ObservedResults を使用すると 以下のようになります。


struct MainView: View {
    @ObservedResults(TODOItem.self) var items
    
    var body: some View {
        List {
            ForEach(items) { item in
                Text("\(item.title)")
            }
        }
    }
}

Modelから読み出す

便利な property wrapper が用意されているのですが、View から直接読んでしまうと、依存関係が複雑になってしまうので、あくまで ViewModel が Model から読み出し、View に渡すようにします。

Model 側には、以下のようなコードで保存されている TODOItem すべてを Results<TODOItem> として渡すメソッド(items)を用意します。


class TODOModel: ObservableObject{
    var config: Realm.Configuration
   
    init() {
        config = Realm.Configuration()
    }
    
    var realm: Realm {
        return try! Realm(configuration: config)
    }
    
    var items: Results {
        realm.objects(TODOItem.self)
    }
}

View で Results<TODOItem> を使用する際には、必要な時に .freeze してimmutable にすることにします。

Realmへの書き込み(追加、変更、削除)

Realm に対して、新規要素追加、既存要素のプロパティ変更、要素削除 の操作はいずれも ”書き込み”となります。
Realm に書き込みを行うときは、realm.write に与える closure 内での操作(transaction と呼ばれます)が必要となります。

以下のコードでは、Realm に 新しい TODOItem を書き込むことができます。


            let realm = ....
            let newItem: TODOItem = ....
            try! realm.write {
                realm.add(newItem)
            }

既存の TODOItem のプロパティを変更する時には、以下のようなコードになります。


            let realm = ....
            let itemToBeUpdated: TODOItem = ....
            let newTitle: String = ....
            try! realm.write {
                itemToBeUpdated.title = newTitle
            }

既存の要素を削除するには、以下のようなコードになります。


            let realm = ....
            let itemToBeRemoved: TODOItem = ...
            try! realm.write {
                realm.delete(itemToBeRemoved)
            }

アプリ実装

複数の機能を同時に追加するといきなり複雑になるので、少しづつ実装していきます。

まずは、以下の機能を実装します。

  • 保存されている要素数を表示
  • ボタンを押すと、要素を追加
  • 保存されている要素をリスト表示

Model : Realm を管理する

MVVM の構造を保ちたいので、実際に Realm に対して操作を行うのは、Model 内部からのみとします。

  • TODOModel
    • 既存要素をコレクションとして提供する
    • 新しい要素を Realm に追加する
  • TODOItem
    • Title, Detail を受け取り新しい TODOItem を生成する

TODOModel のコードは、以下。


class TODOModel: ObservableObject{
    var config: Realm.Configuration
    
    init() {
        config = Realm.Configuration()
    }
    
    var realm: Realm {
        return try! Realm(configuration: config)
    }
    // 保存されている TODOItem を Results<TODOItem> として返す
    var items: Results<TODOItem> {
        realm.objects(TODOItem.self)
    }
    // Title と Detail を受け取り、新規 TODOItem を作成・登録する
    func addTODOItem(_ title: String, detail: String) {
        let item = TODOItem()
        item.id = UUID()
        item.title = title
        item.detail = detail
        try! realm.write {
            realm.add(item)
        }
    }
}

TODOItem のコードは、以下。


class TODOItem: Object, Identifiable {
    @Persisted(primaryKey: true) var id: UUID = UUID()
    @Persisted var title: String
    @Persisted var detail: String
}

ViewModel: RealmとView をつなぐ

ViewModel は、Model と View をつなぐものです。

現時点では非常にシンプルになります。

以下の機能を実装します。(単に中継しているだけです)

  • 既存要素をコレクションとして(Model から受け取りViewに)提供する
  • (View からのリクエストで)新しい要素を作成する(よう Model に依頼する)

class ViewModel: ObservableObject {
    @Published var model: TODOModel = TODOModel()
    // Model から受け取る Results<TODOItem> を (View へ)渡す
    var todoItems: Results<TODOItem> {
        model.items
    }
    // View からのリクエストで、Title, Detail に指定値を持つように TODOItem 作成(を依頼する)
    func addTODOItem(_ title: String, detail: String = "") {
        // 変更することを明示する
        self.objectWillChange.send()
        // Model へ作成を依頼する
        model.addTODOItem(title, detail: detail)
    }
}

View: Realm のデータを表示する

Realm に保存されている TODOItem の数と そのタイトルのリスト表示を行うビューを作ります。

変更や削除はできませんが、まずは、 "Realm からデータを読み出せる" "Realm にデータを保存できる" ということがきちんとできることを確認できるような View を作成します。

当然ですが、初期状態ではデータ(TODOItem) が Realm 内に存在しません。

初期データを設定することも可能ですが、すこし手間ですので、読み込めていることを確認するために、データ要素数をどこかに表示するようにしてみます。

保存されている要素を取得する方法はすでに説明してました。

機能的には以下のようなビューになります。

  • 表示するもの
    • TODOItem の数
    • 個々の TODOItem の Title (リスト表示する)
  • 機能
    • 新規 TODOItem を作成するボタン

見てわかるように、非常にシンプルな機能のみを実装し、要素の編集や削除はまだできません。

複雑な機能は、単純な機能が動作することを確認できた上で追加で実装していきます。

# アプリを シミュレータから削除することで、データを全削除することはできます。

以下のような表示にします。

一番最初に起動した直後は、要素は表示されません。NavigationView のタイトル横に、要素数を表す # 0 が表示されていることで、Realm からデータ(がないということ)を読み込めていることが確認できます。

Launched(Empty)

TODOItem が存在する時には、そのタイトルを List 表示します。

右上の + ボタンを押すと、その時点での日時をタイトルにした TODOItem を作成しますので、リストに要素が表示されるようになります。

OneTODOItemAdded

MainView の実装は以下のようになります。


struct MainView: View {
    // (1) ViewModel は、EnvironmentObject として渡します
    @EnvironmentObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            List {
                // (2) ViewModel が todoItems として ResultsResults<TODOItem> を返しますので、freeze して使います。
                ForEach(viewModel.todoItems.freeze()) { item in
                    // (3) TODOItem の title プロパティを Text として表示します
                    Text("\(item.title)")
                }
            }
            .navigationTitle("RealmTODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                // (4) NavigationBar の 左側に、現在の TODOItem の要素数を表示します
                ToolbarItem(placement: .navigationBarLeading) {
                    Text("#: \(viewModel.todoItems.count)")
                }
                // (5) NavigationBar の右側には、要素追加用の + ボタンを配置します
                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    Button(action: {
                        // 現在時刻を取得して、TODOItem のタイトル用文字列を作成
                        let dateFormatter = DateFormatter()
                        dateFormatter.dateStyle = .short
                        dateFormatter.timeStyle = .short
                        let itemName = dateFormatter.string(from: Date())
                        viewModel.addTODOItem(itemName)
                    }, label: {
                        Image(systemName: "plus")
                    })
                }
            }
        }
    }
}

ViewModel を EnvironmentObject として使用するために、App で以下のように ViewModel を EnvironmentObject として指定します。


@main
struct RealmTODOApp: App {
    @StateObject var viewModel = ViewModel()
    var body: some Scene {
        WindowGroup {
            MainView()
                .environmentObject(viewModel)
        }
    }
}

アーキテクチャノート

ここでは、View が、ボタン押下された時に 時間から 新しい TODOItem の Title 向け文字列を生成し、ViewModel に作成依頼をしています。

View は、作成依頼だけして、ViewModel がその時点での適切な Title を設定するべきという考えかたもあるかもしれません。ここでは、View がその時点での状態を View の詳細含め知っているということで、View が 新しい TODOItem の Title を生成して、ViewModel に渡すようにしています。

(当面) ViewModel で Title を生成しても大きな相違は発生しません。

まとめ

Realm を DB として TODOアプリの作成を開始しました。

ここまでに見たのは以下の機能です。

ここまでに実装した機能概要
  • Realm での要素読み出し
  • Realm から読み出したコレクションの SwiftUI での利用
  • Realm への要素書き込み

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

ここまでのコード

参考までにここまでに実装したコードを転記しておきます。

Model.swift

//
//  Model.swift
//
//  Created by : Tomoaki Yagishita on 2021/12/30
//  © 2021  SmallDeskSoftware
//

import Foundation
import RealmSwift

class TODOModel: ObservableObject{
    var config: Realm.Configuration
    
    init() {
        config = Realm.Configuration()
    }
    
    var realm: Realm {
        return try! Realm(configuration: config)
    }
    
    var items: Results {
        realm.objects(TODOItem.self)
    }
    func addTODOItem(_ title: String, detail: String) {
        let item = TODOItem()
        item.id = UUID()
        item.title = title
        item.detail = detail
        try! realm.write {
            realm.add(item)
        }
    }
}

class TODOItem: Object, Identifiable {
    @Persisted(primaryKey: true) var id: UUID = UUID()
    @Persisted var title: String
    @Persisted var detail: String
}

ViewModel.swift

//
//  ViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2021/12/30
//  © 2021  SmallDeskSoftware
//

import Foundation
import SwiftUI
import RealmSwift

class ViewModel: ObservableObject {
    @Published var model: TODOModel = TODOModel()
    
    var todoItems: Results {
        model.items
    }
    
    func addTODOItem(_ title: String, detail: String = "") {
        objectWillChange.send()
        model.addTODOItem(title, detail: detail)
    }
}

MainView.swift

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/01/10
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct MainView: View {
    @EnvironmentObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.todoItems.freeze()) { item in
                    Text("\(item.title)")
                }
            }
            .navigationTitle("RealmTODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Text("#: \(viewModel.todoItems.count)")
                }
                ToolbarItemGroup(placement: .navigationBarTrailing) {
                    Button(action: {
                        let dateFormatter = DateFormatter()
                        dateFormatter.dateStyle = .short
                        dateFormatter.timeStyle = .long
                        let itemName = dateFormatter.string(from: Date())
                        viewModel.addTODOItem(itemName)
                    }, label: {
                        Image(systemName: "plus")
                    })
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        MainView()
            .environmentObject(ViewModel())
    }
}

RealmTODOApp.swift

//
//  RealmTODOApp.swift
//
//  Created by : Tomoaki Yagishita on 2022/01/10
//  © 2022  SmallDeskSoftware
//

import SwiftUI

@main
struct RealmTODOApp: App {
    @StateObject var viewModel = ViewModel()
    var body: some Scene {
        WindowGroup {
            MainView()
                .environmentObject(viewModel)
        }
    }
}

コメントを残す

メールアドレスが公開されることはありません。