5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

【Swift】Realmを使って画像データの保存と表示を実装する

Last updated at Posted at 2023-07-13

はじめに

どうも、プログラミング未経験で学習中のはるさんです。
今回はRealmを使って画像データの保存と表示を実装するについて説明していきたいと思います。
私は個人アプリを開発する過程で画像データ保存について様々な記事やAIを駆使して取り組みました。
ところがなかなか上手くいかず、最終的にメンターさんの助言とiOSの画像保存の仕組みについて
解説されている方の記事を読んでようやく解決することができました。
初学者にとって画像データの保存と表示は苦戦する部類だと思います。
自身の復習もかねて今回は記事にした次第です。

記事の対象者

  • Swift学習を初めたばかりの方
  • Realmでの画像保存と表示に苦戦されている方

記事を執筆時点での筆者の環境

  • macOS Ventura 13.4.1
  • Xcode 14.3.1
  • Swift 5.8.1
  • iOS 16.4

1.画像データを保存する

1-1. そもそもUIImage型では保存することはできない

RealmにはString型やBool型、Int型などが保存できますが、UIImage型は
保存できません。じゃあどうすればいいのかというと、2つの方法があります。

  • Data型で保存する
  • アプリに画像を保存している場所をファイル名称としてString型で保存する

このうち後者の アプリに画像を保存している場所をファイル名称としてString型で保存する
と言う方法を以下では説明していきます。
これだけ言われると、「どうゆうこと?」となりますよね。
順を追って説明します。

1-2. アプリの保存領域

簡単に説明すると、アプリの中にはデータを保存するディレクトリ(フォルダー)が存在します。
正式にはドキュメントディレクトリと言います。
そのディレクトリの中に画像データを入れるのですが、この時データを識別できるように
名前をつけて保存します。
その時につけた名前を保存場所の名称としてRealmに保存すると言うのが大まかな流れです。

DocumentDirectoryImage.png

1-3. コードで処理を書き出してみる

ここまで来て混乱してくるので文章で処理順番を書き出してみます。

  1. 画像データの保存名称をユニークな名称になるようuuidStringを使用して作成する
  2. アプリ内のドキュメントディレクトリの場所(住所)を取得する
  3. ドキュメントディレクトリに画像データの保存場所(ファイル)を作成(画像データの名称と同じにする)
  4. 画像データであるUIImageをJPEGデータに変換する(生で保存すると容量を圧迫するので)
  5. 変換した画像データをファイルの保存場所に保存する
  6. 最後にデータの保存名称(保存場所)をString型で返却する
    上記をまとめたものが以下になります。これは実際に私のアプリで定義したメソッドです。
    引数のimageがUIImage?でオプショナル型になっているのは写真の添付を任意としている
    仕様によるものです。ここは用途に合わせて変更してください。
    /// 写真データを保存するためのファイル名を出力するメソッド
    /// - 保存するドキュメントディレクトリのURLを取得
    /// - ファイルを保存するURLを作成
    /// - 保存するデータをjpegに変換
    /// - ファイルURLにデータを保存
    /// - ファイル名を出力
    func setImage(image: UIImage?) -> String? {
        // 画像がnilだったらnilを返却して処理から抜ける
        guard let image = image else { return nil }
        // ファイル名をUUIDで生成し、拡張子を".jpeg"にする
        let fileName = UUID().uuidString + ".jpeg"
        // ドキュメントディレクトリのURLを取得
        let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        // ファイルのURLを作成
        let fileURL = documentsDirectoryURL.appendingPathComponent(fileName)
        // UIImageをJPEGデータに変換
        let data = image.jpegData(compressionQuality: 1.0)
        // JPEGデータをファイルに書き込み
        do {
            try data!.write(to: fileURL)
            print(fileName)
        } catch {
            print("💀エラー")
        }
        return fileName
    }

URLはUniform Resource Locatorの略称です。URLは、ウェブ上のリソース(Webページ、画像、動画、ファイルなど)を一意に識別するためのアドレスのことです。今回のケースでいくとウェブ上ではなくあくまでデバイス上ですが、参照先をURLと呼ぶのはそのリソースの場所を特定するために使われるからです。

以下は補足です。

 let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]

ここではFileManagerクラスのdefaultプロパティを使って標準のファイルマネージャを取得し
urls(for:in:)メソッドを呼び出しています。指定された検索パスとドメインを基に、ファイルマネージャが管理するディレクトリのURLの配列を返します。このコードでは、documentDirectory検索パスとuserDomainMaskドメインを指定し、配列の最初の要素(インデックス0)を取得しています。
つまり、このコードはアプリのドキュメントディレクトリのURLを取得するためのものです。

  let fileURL = documentsDirectoryURL.appendingPathComponent(fileName)

appendingPathComponentメソッドを使って、documentsDirectoryURLfileName
ファイル名として、その結果をfileURLに代入しています。
つまりここでドキュメントディレクトリ内に保存ファイルを作成しているイメージです。

このFileManagerクラスについては以下の記事が非常に参考になりますので、
詳しく知りたい方は一度ご覧になってみてください。

2023/7/14追記
Twitterでご指摘頂きました。ドキュメントディレクトリにデータを保存している場合、
iCloudでバックアップされる仕組みになっているようです。
ファイル保存には下記ガイドラインがありこれに沿っていないとリジェクトされることもあるようです。
iOS Data Storage Guidelines
私の個人アプリではAppleから指摘はされませんでしたが、写真を大量に保存するような
アプリはリジェクトされる可能性があります。

詳しくはこちらの記事が参考になりました。

回避策としては以下の3行を追加して、iCloud にバックアップされない設定で保存すれば
良いみたいです。

    func setImage(image: UIImage?) -> String? {
        // 画像がnilだったらnilを返却して処理から抜ける
        guard let image = image else { return nil }
        // ファイル名をUUIDで生成し、拡張子を".jpeg"にする
        let fileName = UUID().uuidString + ".jpeg"
        // ドキュメントディレクトリのURLを取得
        let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        // ファイルのURLを作成
        var fileURL = documentsDirectoryURL.appendingPathComponent(fileName)
        // UIImageをJPEGデータに変換
        let data = image.jpegData(compressionQuality: 1.0)
        // 【追加】URLResourceValuesをインスタンス化
        var values = URLResourceValues()
        // 【追加】iCloudの自動バックアップから除外する
        values.isExcludedFromBackup = true
        do {
            // 【追加】iCloudの自動バックアップから除外する設定の登録
            try fileURL.setResourceValues(values)
            // JPEGデータをファイルに書き込み
            try data!.write(to: fileURL)
            print(fileName)
        } catch {
            print("💀エラー")
        }
        return fileName
    }

1-4. Realmでの保存処理

あとはsetImageメソッドで返却された保存場所をRealmにString型で保存すれば完成です。

import UIKit
import RealmSwift

class EditItemViewController: UIViewController {

    /// お使いデータ
    var errandData = ErrandDataModel()

    private func saveData() {
        // numberOfItemPickerViewで選択された値を取得
        let selectedNumberOfItem = numberOfItemArray[numberOfItemPickerView.selectedRow(inComponent: 0)]
        // numberOfItemPickerViewで選択された値を取得
        let selectedUnit = unitArray[unitPickerView.selectedRow(inComponent: 0)]
        // データベースに保存
        let realm = try! Realm()
        try! realm.write {
            errandData.nameOfItem = nameOfItemTextField.text!
            errandData.numberOfItem = selectedNumberOfItem
            errandData.unit = selectedUnit
            errandData.salesFloorRawValue = selectedSalesFloorRawValue!
            if supplementTextView.text == "" {
                errandData.supplement = nil
            } else {
                errandData.supplement = supplementTextView.text
            }
            // MARK: 画像データの保存
            // Relamに定義したsetImageメソッドにphotoPathImageViewのimageデータを渡して
            // 返却された保存場所の名称を保存する
            errandData.photoFileName = errandData.setImage(image: photoPathImageView.image)
            realm.add(errandData)
        }
    }

}

今回はsetImageメソッドをRealmのデータモデルを定義している
class ErrandDataModel: Objectに定義しているため呼び出しが
errandData.setImage(image: _)となっています。
定義場所はあまり深く考えずに定義したので、直接viewControllerに定義しても良いかもしれません。

import UIKit
import RealmSwift

/// お使いデータモデル
class ErrandDataModel: Object {

    /// データのID
    @objc dynamic var id:String = UUID().uuidString
    /// 商品の購入判定
    @objc dynamic var isCheckBox:Bool = false
    /// 商品名
    @objc dynamic var nameOfItem:String = ""
    /// 商品の必要数
    @objc dynamic var numberOfItem = "1"
    /// 商品の必要数に対する単位
    @objc dynamic var unit:String = "個"
    /// 売り場に対応するRawValue
    @objc dynamic var salesFloorRawValue:Int = 0
    /// 商品に対する補足文、nilを許容
    @objc dynamic var supplement:String? = nil
    /// 商品の写真データのファイル名、nilを許容
    @objc dynamic var photoFileName:String? = nil

    /// enum DefaultSalesFloorTypeをお使いデータに登録
    var defaultSalesFloor: DefaultSalesFloorType {
        get {
            return DefaultSalesFloorType(rawValue: salesFloorRawValue)!
        }
        set {
            salesFloorRawValue = newValue.intValue
        }
    }
    
    override static func primaryKey() -> String? {
        return "id"
    }

    /// 写真データを保存するためのファイル名を出力するメソッド
    /// - 保存するドキュメントディレクトリのURLを取得
    /// - ファイルを保存するURLを作成
    /// - 保存するデータをjpegに変換
    /// - ファイルURLにデータを保存
    /// - ファイル名を出力
    func setImage(image: UIImage?) -> String? {
        // 画像がnilだったらnilを返却して処理から抜ける
        guard let image = image else { return nil }
        // ファイル名をUUIDで生成し、拡張子を".jpeg"にする
        let fileName = UUID().uuidString + ".jpeg"
        // ドキュメントディレクトリのURLを取得
        let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        // ファイルのURLを作成
        let fileURL = documentsDirectoryURL.appendingPathComponent(fileName)
        // UIImageをJPEGデータに変換
        let data = image.jpegData(compressionQuality: 1.0)
        // JPEGデータをファイルに書き込み
        do {
            try data!.write(to: fileURL)
            print(fileName)
        } catch {
            print("💀エラー")
        }
        return fileName
    }

}

2. 保存した画像を表示する

2-1. 保存した画像をUIImageに変換する

次に保存した画像を表示する処理についてです。大まかな流れとしては保存したファイル名をもとに
ディレクトリにアクセスして画像を読み込み、UIImageに変換することになります。

  1. アプリのドキュメントディレクトリの場所(住所)を取得
  2. Realmに保存した画像データのファイル名を使ってアクセスするためのURLを作成する
  3. 上記のURLを使って画像データを読み込む
  4. 読み込んだデータをUIImageに変換して返却する
import UIKit
import RealmSwift

/// お使いデータモデル
class ErrandDataModel: Object {

    // 省略

    /// 商品の写真データのファイル名、nilを許容
    @objc dynamic var photoFileName:String? = nil

    // 省略

    /// 保存したファイル名を使って写真データを検索し、UIImageとして出力する
    /// - ドキュメントディレクトリのURLを取得
    /// - ファイルのURLを取得
    /// - ファイルからデータを読み込み、UIImageに変換して返却する
    func getImage() -> UIImage? {
         // photoFileNameがnilならnilを返却して抜ける
         guard let path = self.photoFileName else { return nil }
         // ドキュメントディレクトリのURLを取得
         let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
         // ファイルのURLを取得
         let fileURL = documentsDirectoryURL.appendingPathComponent(path)
         // ファイルからデータを読み込む
             do {
                let imageData = try Data(contentsOf: fileURL)
                // データをUIImageに変換して返却する
                return UIImage(data: imageData)
                } catch {
                print("💀エラー")
                return nil
             }
         }
}

以下は補足です。

guard let path = self.photoFileName else { return nil }

ここでRealmに保存してあるphotoFileNameをpathとして代入しています。
前述したとうり、今回のアプリでは画像データは任意のためnilの可能性もあるため
最初にguard文をしています。

let fileURL = documentsDirectoryURL.appendingPathComponent(path)

前回の保存処理でも使ったdocumentsDirectoryURL.appendingPathComponentにpathを代入して
画像データを指定しています。

2-2.実際に画像を表示する

import UIKit
import RealmSwift

/// F-買い物リスト編集
class EditShoppingListViewController: UIViewController {

    /// お使いデータ
    private var errandDataList: [ErrandDataModel] = []
    /// Realmから取得したErrandDataModelの結果セットを保持するプロパティ
    private var errandDataModel: Results<ErrandDataModel>?

    override func viewDidLoad() {
        super.viewDidLoad()
        //省略
        setErrandData()
    }

    /// 保存されたお使いデータをセットする
    private func setErrandData() {
        let realm = try! Realm()
        let result = realm.objects(ErrandDataModel.self)
        errandDataModel = realm.objects(ErrandDataModel.self)
        errandDataList = Array(result)
    }

    // 省略
}


extension EditShoppingListViewController: UITableViewDataSource, UITableViewDelegate {
    /// editShoppingListTableViewに表示するcell数を指定
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return errandDataList.count
    }

    /// editShoppingListTableViewに使用するcellの内容を指定
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let cell = editShoppingListTableView.dequeueReusableCell(
            withIdentifier: "ShoppingListTableViewCell", for: indexPath) as? ShoppingListTableViewCellController {
            // 編集モードの状態によってチェックボックスの表示を切り替える
            cell.checkBoxButton.isHidden = isEditingMode
            cell.delegate = self
            let errandDataModel: ErrandDataModel = errandDataList[indexPath.row]
            cell.setShoppingList(isCheckBox: errandDataModel.isCheckBox,
                                 nameOfItem: errandDataModel.nameOfItem,
                                 numberOfItem: errandDataModel.numberOfItem,
                                 unit: errandDataModel.unit,
                                 salesFloorRawValue: errandDataModel.salesFloorRawValue,
                                 supplement: errandDataModel.supplement,
                                 image: errandDataModel.getImage())
            return cell
        }
        return UITableViewCell()
    }
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
の中のcell.setShoppingListメソッドでテーブルビューのカスタムセルに表示するデータを
セットしています。その際に引数のimageはUIImageを渡すことになっています。
ここで先ほど定義したgetImage()メソッドをerrandDataModel.getImage()として呼び出し
画像データがあればUIImageをセットし、なければnilをセットする形にしています。

終わりに

いかがだったでしょうか。
画像データの扱いはなかなかに難しい部類です。
保存の仕組みと処理の順番を理解していないと実装は困難です。
今回なるべく噛み砕いて書いたつもりですが、書いている自分も混乱しました。
何回も処理を書いて覚えていきたいと思います。

今回のコードはGitHubに載せていますので、気になる方はご覧になってみてください。

なお、バージョンによってRealmの処理がなくなっているものがあります。
もしもご覧になる場合はバージョンごとにtag付けしていますので
v1.1.0をダウンロードして、ポッドインストールをした上で確認してみてください。

この記事が初学者の方々の学習において、少しでもお役にたてれば幸いです。

参考記事

5
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?