【断酒iOSアプリ制作】Realmでデータの永続化 (9日目)

断酒してから9日が経過しました!お酒のない生活にも少しずつ慣れ、空いた時間で新しいことに挑戦する余裕も生まれてきました。その一環として、アプリ開発をコツコツ進めております。健康志向が芽生えてきたこともあり、ジョギングを趣味として始めたのも最近の大きな変化です。普段は30分ほど、3km〜5kmを目安に走る程度ですが、ある日妙に調子が良く、40km近く走る暴挙に出た結果、膝を痛めるという大失態を犯しました。その後1ヶ月ほど苦しみましたが、ようやく膝も治り、再びジョギング生活が戻ってきました。

そんな中、新たな相棒としてランニングシューズを購入しました。つい先日まではAmazonで適当に買った3000円のシューズを履いていましたが、これが微妙に大きく、走りづらさを感じる原因だったようです。先日、 高尾山に登った帰り に新宿のL-Breathに立ち寄り、アシックスのシューズを購入することに。 驚いたのは、店舗に備わっている最新鋭のレーザースキャン。足のサイズを正確に計測し、自分に合った靴を即座に提案してくれました。結果、自分が想定していたよりも足のサイズが小さいことが発覚!一方で横幅が広いため、サイズ選びに苦戦していた理由がようやくわかりました。新しいシューズは「これだ!」と思える履き心地で、地面からの衝撃をしっかり吸収してくれそうです。これなら膝を痛める心配も少なそう。トレンドの厚底シューズの恩恵に、さっそく期待が高まります。

ここで改めて感じたのは、「適切なツール選び」の重要性です。シューズ一つでこれだけ快適さが変わるのなら、アプリ開発においても適切なツールを選ぶことで効率や完成度に大きな差が出るはずです。

さて、今回進めている断酒サポートアプリの開発でも、この「ツール選び」がポイントとなりました。iOSアプリ開発ではデータの永続化が欠かせませんが、どの方法を使うべきか迷うところです。候補として挙がるのは以下の通り:

  • UserDefaults:小規模な設定データの保存向け
  • Core Data:強力だが、学習コストが高い
  • SQLite:柔軟だが、低レベルな操作が必要
  • Realm:シンプルかつ直感的で高速

今回は、セットアップが容易で直感的に使える Realm を選びました。ここからは、このRealmを使ってアプリのデータを永続化する方法について紹介していきます。

Realmの概要

Realmはモバイル向けのデータベースで、以下の特徴があります:

  • 高速:SQLiteベースのデータベースより高速な読み書きが可能。
  • 簡単:Core Dataと比べてシンプルなAPI設計。
  • スキーマの自動管理:手動でデータベースのスキーマを管理する必要がない。
  • クロスプラットフォーム対応:iOS、Android、React Nativeなど複数のプラットフォームで使用可能。

プロジェクトにRealmを導入

CocoaPodsを使用したRealmのインストール

以下のようにPodfileを編集してRealmをインストールします。

swift
source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '13.0'

use_modular_headers!

target 'RealmSampleApp' do
  use_frameworks!
  
  pod 'RealmSwift'
end

その後、以下のコマンドを実行します:

swift
pod install

これでプロジェクトにRealmが導入されます。

UIの実装

以下のようなUIを準備します。

swift
class ViewController: UIViewController {

    // UIコンポーネント
    let datePicker: UIDatePicker = {
        let picker = UIDatePicker()
        picker.datePickerMode = .date
        picker.translatesAutoresizingMaskIntoConstraints = false
        return picker
    }()
    
    let textView: UITextView = {
        let textView = UITextView()
        textView.layer.borderColor = UIColor.lightGray.cgColor
        textView.layer.borderWidth = 1.0
        textView.layer.cornerRadius = 5.0
        textView.translatesAutoresizingMaskIntoConstraints = false
        return textView
    }()
    
    let saveButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("保存", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    let loadButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("読み込み", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    let resultLabel: UILabel = {
        let label = UILabel()
        label.text = "日記がここに表示されます"
        label.numberOfLines = 0
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    

    let idTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "削除したいIDを入力"
        textField.borderStyle = .roundedRect
        textField.translatesAutoresizingMaskIntoConstraints = false
        return textField
    }()
    
    let deleteButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("削除", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupUI()
        
        saveButton.addTarget(self, action: #selector(saveDiary), for: .touchUpInside)
        loadButton.addTarget(self, action: #selector(loadDiary), for: .touchUpInside)
        deleteButton.addTarget(self, action: #selector(deleteDiary), for: .touchUpInside)

    }
    
    // UIのレイアウト設定
    func setupUI() {
        view.addSubview(datePicker)
        view.addSubview(textView)
        view.addSubview(saveButton)
        view.addSubview(loadButton)
        view.addSubview(resultLabel)
        view.addSubview(idTextField)
        view.addSubview(deleteButton)
        

        NSLayoutConstraint.activate([
            datePicker.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
            datePicker.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            
            textView.topAnchor.constraint(equalTo: datePicker.bottomAnchor, constant: 20),
            textView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            textView.widthAnchor.constraint(equalToConstant: 300),
            textView.heightAnchor.constraint(equalToConstant: 150),
            
            saveButton.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 20),
            saveButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: -70),
            
            loadButton.topAnchor.constraint(equalTo: textView.bottomAnchor, constant: 20),
            loadButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 70),
            
            resultLabel.topAnchor.constraint(equalTo: saveButton.bottomAnchor, constant: 30),
            resultLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            resultLabel.widthAnchor.constraint(equalToConstant: 300),
            
            idTextField.topAnchor.constraint(equalTo: resultLabel.bottomAnchor, constant: 20),
            idTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            idTextField.widthAnchor.constraint(equalToConstant: 300),
            
            deleteButton.topAnchor.constraint(equalTo: idTextField.bottomAnchor, constant: 10),
            deleteButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)

        ])
    }
    
    // 日記を保存する処理
    @objc func saveDiary() {
        let realm = try! Realm()
        let selectedDate = datePicker.date
        
        // プライマリキーがYYYYMMDD形式になるように設定
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyyMMdd" // 固定フォーマット
        let id = dateFormatter.string(from: selectedDate)
        print(id) // 例: 20241129
        
        // データ作成または更新
        let entry = DiaryEntry()
        entry.id = id
        entry.date = selectedDate
        entry.content = textView.text
        
        try! realm.write {
            realm.add(entry, update: .modified)
        }
        
        resultLabel.text = "日記を保存しました!"
        textView.text = ""
    }

    ...
}

UI説明

このアプリのUIは、日記を保存、読み込み、削除するためのシンプルな構成になっています。以下、それぞれの要素と役割について説明します:

日付選択(UIDatePicker)

日記の対象日を指定するために使用します。デフォルトでは今日の日付が選択されています。

テキスト入力エリア(UITextView)

日記の内容を入力するためのエリアです。入力後、「保存」ボタンを押すことでRealmに保存されます。

保存ボタン(UIButton)

入力した日記をRealmに保存します。同じ日付がすでに保存されている場合は上書きされます。

読み込みボタン(UIButton)

選択した日付の日記をRealmから読み込み、結果を表示します。

結果表示ラベル(UILabel)

読み込まれた日記や処理の結果を表示します。日記が存在しない場合は「指定の日付に日記はありません」と表示されます。

削除用ID入力フィールド(UITextField)

削除したい日記のIDを手動で入力するためのフィールドです。

削除ボタン(UIButton)

入力されたIDの日記をRealmから削除します。該当する日記がない場合はエラーメッセージを表示します。

Realmを使ったデータ操作の実装例

以下は、日記アプリを例にしたデータ操作の実装コードです。

モデル定義

まず、DiaryEntryモデルを作成します。

swift
import RealmSwift

class DiaryEntry: Object {
    @Persisted(primaryKey: true) var id: String // プライマリキー
    @Persisted var date: Date
    @Persisted var content: String
}

UIのセットアップと機能実装

以下のコードを参考に、ViewControllerにUIと機能を実装します。

日記の保存

swift
@objc func saveDiary() {
    let realm = try! Realm()
    let selectedDate = datePicker.date

    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyyMMdd"
    let id = dateFormatter.string(from: selectedDate)

    let entry = DiaryEntry()
    entry.id = id
    entry.date = selectedDate
    entry.content = textView.text

    try! realm.write {
        realm.add(entry, update: .modified)
    }

    resultLabel.text = "日記を保存しました!"
    textView.text = ""
}

日記の読み込み

swift
@objc func loadDiary() {
    let realm = try! Realm()
    let selectedDate = datePicker.date

    let startOfDay = Calendar.current.startOfDay(for: selectedDate)
    let endOfDay = Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!

    let predicate = NSPredicate(format: "date >= %@ AND date < %@", startOfDay as NSDate, endOfDay as NSDate)
    let entry = realm.objects(DiaryEntry.self).filter(predicate).first

    if let entry = entry {
        resultLabel.text = "日記: \(entry.content)"
        idTextField.text = entry.id
    } else {
        resultLabel.text = "指定の日付に日記はありません"
    }
}

日記の削除

swift
@objc func deleteDiary() {
    let realm = try! Realm()
    guard let id = idTextField.text, !id.isEmpty else {
        resultLabel.text = "IDを入力してください"
        return
    }

    if let entry = realm.object(ofType: DiaryEntry.self, forPrimaryKey: id) {
        try! realm.write {
            realm.delete(entry)
        }
        resultLabel.text = "ID: \(id) の日記を削除しました"
        idTextField.text = ""
    } else {
        resultLabel.text = "ID: \(id) の日記は存在しません"
    }
}

マイグレーション方法

アプリのリリース後にデータモデルを変更した場合、マイグレーションが必要です。以下はマイグレーションの設定例です。

swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let config = Realm.Configuration(
        schemaVersion: 2,
        migrationBlock: { migration, oldSchemaVersion in
            if oldSchemaVersion < 2 {
                // 必要なマイグレーション処理
            }
        }
    )

    Realm.Configuration.defaultConfiguration = config
    return true
}

コードを試してみる

この記事で使用したコードは以下のGitHubリポジトリで公開しています。ぜひご活用ください。 GitHub - aragig/ios_sample_dansyu: 断酒カレンダーアプリ制作で使う、部品サンプルです

今回のサンプルアプリは、RealmSampleAppターゲットをビルドすると再現できます。

まとめ

今回のアプリでは、Realmを使用したデータの永続化を通じて、以下の機能を実現しました:

  • 日記の保存:シンプルなフォームでデータの保存を実装。
  • 日記の読み込み:日付でのクエリを使用したデータの取得。
  • 日記の削除:プライマリキーを活用して特定データを削除。

これをベースに、グラフや統計機能を追加することで、さらに価値のあるアプリへと発展させることが可能です。

関連記事

最後までご覧いただきありがとうございます!

▼ 記事に関するご質問やお仕事のご相談は以下よりお願いいたします。
お問い合わせフォーム