はじめに
どうも、プログラミング未経験で学習中のはるさんです。
今回は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に保存すると言うのが大まかな流れです。
1-3. コードで処理を書き出してみる
ここまで来て混乱してくるので文章で処理順番を書き出してみます。
- 画像データの保存名称をユニークな名称になるよう
uuidString
を使用して作成する - アプリ内のドキュメントディレクトリの場所(住所)を取得する
- ドキュメントディレクトリに画像データの保存場所(ファイル)を作成(画像データの名称と同じにする)
- 画像データであるUIImageをJPEGデータに変換する(生で保存すると容量を圧迫するので)
- 変換した画像データをファイルの保存場所に保存する
- 最後にデータの保存名称(保存場所)を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
メソッドを使って、documentsDirectoryURL
にfileName
を
ファイル名として、その結果を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に変換することになります。
- アプリのドキュメントディレクトリの場所(住所)を取得
- Realmに保存した画像データのファイル名を使ってアクセスするためのURLを作成する
- 上記のURLを使って画像データを読み込む
- 読み込んだデータを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をダウンロードして、ポッドインストールをした上で確認してみてください。
この記事が初学者の方々の学習において、少しでもお役にたてれば幸いです。
参考記事