[Swift] ファイルを抽象化した構造体を作る (移植版 from Zenn)
この記事について
CX事業本部の中安です。まいどです。
自分の前回のブログでは、クラスメソッドの新たな仲間となったZenn
で1ヶ月記事を書き続けたことを綴りました。
今記事は、その中で書いていた「ファイルを抽象化した構造体を作る」という記事の焼き直し、移植修正版となります。
Zenn
では全部で8回に記事を分割していたので、一つの記事にまとめてみることにしたというわけです。
ちなみに、元記事の初回はこちらになります。
8記事のまとめなので長くなりますが、どなたかのお役に立てばと思います。
今記事で作りたいもの
今回はSwiftでは少し冗長になりがちなファイルの操作を簡単にするために "ファイルを抽象化した構造体" を作っていきます。
iOSアプリ開発では、永続的データやキャッシュのためにテキストや画像や動画等のファイルを取り扱うことも多いと思います。
標準ではファイル操作のためのFileManager
というクラスが用意されていて、実装者はシングルトンオブジェクトである FileManager.default
を参照し、そのメソッドを使用していきます。
基本的には操作する(または参照する)ファイルの絶対パスを引数にして処理を行わせますが、ここが冗長になってしまうソースコードをたびたび見ることがあります。 パス文字列とファイル操作自体が分かれていることは責務の分離としては間違いではないとは思いますが、これらの実装をもっとシンプルに書けるようにして開発を楽にしていきたいものです。
そこで今回用意するのが File
という構造体です。
File構造体の基本定義
File
構造体は、ファイルの絶対パスである path
を定数に持つシンプルな構造体にします。
import Foundation struct File { let path: String }
定数を1つ持つだけの構造体ですから、インスタンスを作るときも
let file = File(path: "path/to")
このようにシンプルです。
この構造体に様々な機能を拡張していきます。
特定のパスをすぐ参照できるようにする
iOSアプリではファイルの置かれる場所はある程度限定されます。 そこでそのディレクトリパスは定数で定義しておくといいと思います。
extension File { static let documentDirectoryPath = NSSearchPathForDirectoriesInDomains( .documentDirectory, .userDomainMask, true ).first! static let libraryDirectoryPath = NSSearchPathForDirectoriesInDomains( .libraryDirectory, .userDomainMask, true ).first! static let temporaryDirectoryPath = NSTemporaryDirectory() static let mainBundlePath = Bundle.main.bundlePath }
とりあえず、よく使うであろうディレクトリのパスを4つ定数定義しました。
詳しくはこちらを参照
https://developer.apple.com/icloud/documentation/data-storage/index.html
パス文字列が定義できるとFile構造体を作ることができます。File構造体自体もこのように定数定義しておくと何かと便利です。 (※Fileという名前ですが、ディレクトリの抽象型にもなるというわけです)
extension File { static let documentDirectoryPath = NSSearchPathForDirectoriesInDomains( .documentDirectory, .userDomainMask, true ).first! static let libraryDirectoryPath = NSSearchPathForDirectoriesInDomains( .libraryDirectory, .userDomainMask, true ).first! static let temporaryDirectoryPath = NSTemporaryDirectory() static let mainBundlePath = Bundle.main.bundlePath static let documentDirectory = File(path: documentDirectoryPath) static let libraryDirectory = File(path: libraryDirectoryPath) static let temporaryDirectory = File(path: temporaryDirectoryPath) static let mainBundle = File(path: mainBundlePath) }
使い方
let dir = File.documentDirectory // または let dir: File = .documentDirectory
これでドキュメントディレクトリへの参照が簡単かつ直感的にできるようになりました。
パスの連結を簡単にする
File構造体の中身はパス文字列だけです。このパス文字列にディレクトリ名やファイル名を足していくことになります。 ここではその方法を簡単にするため以下のように "+"演算子で行えるようにしておきます。
extension File { func append(pathComponent: String) -> File { return File(path: (path as NSString).appendingPathComponent(pathComponent)) } static func + (lhs: File, rhs: String) -> File { return lhs.append(pathComponent: rhs) } }
構造体なので、元の構造体のパス文字列を変更するのではなく、パス文字列を変更した新しい構造体を返していることがミソです。
使い方
let file = File.documentDirectory + "hoge" + "test.txt"
これで (ドキュメントディレクトリ)/hoge/test.txt
というパス文字列を持ったFile構造体を簡単に作れます。
同じファイルかどうかを判定できるようにする
同じパス文字列を持つFile構造体は"同じファイルである"といえると思うので、Equatable
に準拠させてやりましょう。
Equatable
に準拠すると、比較がしやすい上に、配列操作などでも強力な機能を持つことができます。
extension File: Equatable { static func == (lhs: File, rhs: File) -> Bool { return lhs.path == rhs.path } }
ファイルの存在確認
ファイルの存在確認は本来はこんな感じで取得できます。
FileManager.default.fileExists(atPath: "ファイルのパス")
まあ、これだとちょっと長いので、こんな感じで計算プロパティを作ってラップしておきます。
extension File { var exists: Bool { return FileManager.default.fileExists(atPath: path) } }
使い方
let file = File.documentDirectory + "text.txt" if file.exists { // .... }
といったように書くことができます。シンプルでしょ。
親ディレクトリを取得できるようにする
現在指定されているパスの親ディレクトリを取得できるようにしておきます。
extension File { var parentDirectoryPath: String { if path == "/" { return "" } return (path as NSString).deletingLastPathComponent } var parentDirectory: File { return File(path: parentDirectoryPath) } }
※パスがルートディレクトリ "/"
だと deletingLastPathComponent
のときににおかしな挙動をしてしまうので、こういう書き方になっています。
使い方
こういった書き方で同じディレクトリに置かれる兄弟ファイルを定義できるようになります。
let file1 = File.documentDirectory + "texts" + "001.txt" let file2 = file1.parentDirectory + "002.txt" // 同じディレクトリを指定できる // (ドキュメントディレクトリ)/texts/001.txt // (ドキュメントディレクトリ)/texts/002.txt
ディレクトリを作る仕組み
File構造体はパス文字列を持っているだけの存在です。 実際にディレクトリを指定していてもファイルシステム上はそのディレクトリが存在しているわけではありません。 なので、実際にディレクトリを作る仕組みをラップしていきます。
extension File { func makeDirectory() throws { if !exists { try FileManager.default.createDirectory( atPath: path, withIntermediateDirectories: true, attributes: nil ) } } }
先程つくったexists
も活躍しますね。
intermediateDirectories
は、中間のディレクトリを作ってくれるか、もしくは存在しないとエラーにするかに分岐します。やりたいことによりますが、true
固定でいいかなと思います。
https://developer.apple.com/documentation/foundation/filemanager/1407884-createdirectory
ここで初めてthrows
が出てきますが、これから先はファイル操作の実行系メソッドは throws
を宣言して、例外スローの可能性を実装者に示しておくとよいと思います。(try?
などで吸収しないほうがよいです)
使い方
let dir = File.documentDirectory + "users" + "user1" try? dir.makeDirectory()
これで (ドキュメントディレクトリ)/users/user1/
というディレクトリが実際に作られるはずです。
URLとデータを取得できるようにする
ファイルの操作はパス文字列を使うパターンの他に、ファイルURLで扱うパターンや、データに変換して扱うパターンがあります。主に書き込みなどを行うときですね。 その時のために容易に取得できるようにしておきましょう。
extension File { var url: URL { return URL(fileURLWithPath: path) } var data: Data? { return try? Data(contentsOf: url) } }
テキストや画像を取得できるようにする
データが取得できるようになると、そこから実際のファイルの内容を取得することができるはずです。
テキスト
テキストファイルからテキスト内容を取得するのはこんな感じです。
(※text
というメソッド名でもいいかも)
extension File { func contents(encoding: String.Encoding) -> String? { guard let data = self.data else { return nil } return String(data: data, encoding: encoding) } var contents: String? { return contents(encoding: .utf8) } }
エンコーディングはUTF8を使うことも多いでしょうから「文字列エンコーディングを指定できるプロパティ」と「UTF8固定の計算プロパティ」の2種類を用意しています。
画像
画像ファイルはUIImage
として簡単に取り出せるようにしておきます。
// UIImageを扱うのでimportをFoundationからUIKitに変えます import UIKit extension File { var image: UIImage? { guard let data = self.data else { return nil } return UIImage(data: data) } }
使い方
ファイルの内容を取得するにはこんな感じ
// テキスト let textFile = File.documentDirectory + "text.txt" let contents = textFile.contents! // 画像 let imageFile = File.documentDirectory + "image.png" let image = imageFile.image!
シンプルに取得できるようになったと思います。
万が一に画像ファイルでないファイルを指定したりするとnil
が返るようにしていますので、安全に使うことができるかなと思います。
テキストや画像を書き込めるようにする
逆にファイルに書き込めるようにもしていきましょう。前述で作った処理がここで生きてくることになります。
テキスト
extension File { func write(contents: String, encoding: String.Encoding = .utf8) throws { try parentDirectory.makeDirectory() try contents.write(to: url, atomically: false, encoding: encoding) } }
読み込むときと同じようにエンコーディングはUTF8をデフォルトにしておき、必要に応じて変えられるようにしておきます。
また、ファイルの親ディレクトリが存在していない可能性もあるので、書き込む前に作っておくことも忘れないようにしておきましょう。
実際に書き込む際には String型の機能を使うことになります。
https://developer.apple.com/documentation/foundation/nsstring/1407654-write
第2引数のatomically
は、アトミックかどうかの指定になります。
「アトミックかどうか」は不可分操作(原子操作)とも言われますが、端的に言うとファイルが途中で別プロセスに干渉されないようにするトランザクションの話になると思います。
https://ja.wikipedia.org/wiki/不可分操作
ドキュメントによると、atomically
をtrue
にすると、ファイルの書き込みは一時ファイルで行われ、終わったら元のファイルに置換するという挙動をするとのことです。
その場合、できあがったファイルの作成日時の情報はその実行された時間というふうに書き換わる挙動をします。
ここは作るアプリの性質にもよると思いますが、アトミック処理はたとえば同時多発的なプロセスからファイルアクセスが行われたときの整合性担保のための仕組みだと思いますし、個人の端末で個人だけが使うアプリにおいてはfalse
指定でもあまり影響がないかなとも思います。この辺りは要件に合わせて使い分けてください。
使い方
let file = File.documentDirectory + "hoge" + "fuga" + "test.txt" try? file.write(contents: "Hello World")
これで、ドキュメントディレクトリの下にhoge/fuga/
というディレクトリが作られ、"Hello World"
と書かれたテキストファイルが作成されると思います。
画像
画像の書き込みはJPEG
とPNG
で書き込めるようにします。(他のフォーマットについては長くなると思うので別の機会があれば)
extension File { // JPEGで書き込む func write(imageAsJpeg image: UIImage, quality: CGFloat = 0.9) throws { guard let data = image.jpegData(compressionQuality: quality) else { return } try parentDirectory.makeDirectory() try data.write(to: url) } // PNGで書き込む func write(imageAsPng image: UIImage) throws { guard let data = image.pngData() else { return } try parentDirectory.makeDirectory() try data.write(to: url) } }
テキストファイルと同様に先にディレクトリを作っておくことがミソです。
UIImageには画像をData型に各フォーマット用に変換する機能があるので素直にそれを使って書き出します。
JPEGについては圧縮率の指定が必要ですが、下記の記事を参考にして0.9
がデフォルト値として適当かなと思いました。
※ちなみに、jpegData()
やimage.pngData()
からnil
が返ってくるときも例外を吐いたほうがメンテナンス性は高まるかもしれません。今回はスキップしています。
使い方
// JPEG let image = UIImage(named: "sample.jpg")! let file = File.documentDirectory + "hoge" + "fuga" + "test.jpg" try? file.write(imageAsJpeg: image) // PNG let image = UIImage(named: "sample.png")! let file = File.documentDirectory + "hoge" + "fuga" + "test.png" try? file.write(imageAsPng: image)
ゴリゴリと画像の書き込み処理を書くよりシンプルになったと思います。
ファイル名を取得する
File構造体では絶対パスの文字列だけが保持されています。 しかし、ここからファイル名を取りたいという気持ちになると思います。
パス文字列から名前に関する情報はNSString
の機能を使うことで取ることができます。
これをFile構造体でラップしてやります。
ここに4種類の名前を取れるようにしました。
extension File { var name: String { return (path as NSString).lastPathComponent } var `extension`: String { let ext = (name as NSString).pathExtension return ext.isEmpty ? "" : ".\(ext)" } var extensionWithoutDot: String { let ext = (name as NSString).pathExtension return ext.isEmpty ? "" : "\(ext)" } var nameWithoutExtension: String { return (name as NSString).deletingPathExtension } }
name
純粋なファイル名です。
extension
ファイル名から拡張子だけを返します。このとき拡張子はドットが付いた状態になります。
「拡張子」を意味するextension
という英単語は予約語なのでバッククォートで囲ってやります。
extensionWithoutDot
extension
はドット付きの拡張子でしたが、こちらはそれを取り除いています。
nameWithoutExtension
ファイル名から拡張子部分を取り除いた名前です。
使い方
let file = File.documentDirectory + "hoge" + "fuga" + "sample.txt" print(file.name) print(file.extension) print(file.extensionWithoutDot) print(file.nameWithoutExtension) // 結果 // sample.txt // .txt // txt // sample
ファイルかどうか、ディレクトリかどうか
プログラマブルにファイル操作をしていると、今扱っているFile構造体がファイルを指しているのか、ディレクトリを指しているのかの判定が必要になってくるシーンもあるでしょう。
その判定のためのプロパティを追加していきたいと思います。
ファイルかどうか
extension File { var isFile: Bool { var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) { return !isDirectory.boolValue } return false } }
以前にもファイル存在確認のためにFileManager.default.fileExists()
は行いましたが、それに加えてディレクトリかどうかの判定を引数に参照渡しすることができます。
この仕組みを使うことで「そのパスは存在する」かつ「それはディレクトリではない」つまり「ファイルである」という判定をすることができますね。
では、ドキュメントディレクトリが空っぽの状態で以下のように実装してみます。
let file = File.documentDirectory + "hoge" + "fuga" + "sample.txt" print(file.isFile) try? file.write(contents: "Hello") print(file.isFile)
// 結果 // false // true
書き込みが行われるとファイルとして存在していることになるのでtrue
が返ります。
ディレクトリかどうか
さて、ファイルかどうかを判定できるのですから、それがfalse
だったらディレクトリだろうというのは間違った判定になります。「そのパスは存在する」かつ「それはディレクトリである」つまり「ディレクトリである」という判定にしておかなくてはなりません。
では、
extension File { var isDirectory: Bool { return exists && !isFile } }
というふうに定義しても良いかなと思うのですが、isFile
に依存しない作りにしたかったので
extension File { var isDirectory: Bool { var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) { return isDirectory.boolValue } return false } }
ちょっと冗長ですが、isFile
と同じように実装をしました。こちらはお好みで選択ください。
同じようにドキュメントディレクトリが空っぽの状態で以下のように実装してみます。
let file = File.documentDirectory + "hoge" + "fuga" + "sample.txt" print(file.parentDirectory.isDirectory) try? file.write(contents: "Hello") print(file.parentDirectory.isDirectory) print(file.isDirectory)
// 結果 // false // true // false
書き込みが行われると同時にディレクトリも作られるのでtrue
が返ります。
変数file
自体は書き込まれたファイルを指すのでもちろんfalse
になります。
ファイルの属性値を取得する
ファイルというものは作成されたときから様々なメタデータが付いています。 ここではそれらを「ファイルの属性値」と呼び、取得するための実装をしていきます。
iOSではファイルの属性といっても2つの情報が存在するようです。
attributesOfItem
attributesOfItem(atPath:) Returns the attributes of the item at a given path.
与えられたパスにあるアイテムの属性を返します。
https://developer.apple.com/documentation/foundation/filemanager/1410452-attributesofitem
attributesOfFileSystem
attributesOfFileSystem(forPath:) Returns a dictionary that describes the attributes of the mounted file system on which a given path resides.
与えられたパスが存在するマウントされたファイルシステムの属性を説明する辞書を返します。
https://developer.apple.com/documentation/foundation/filemanager/1411896-attributesoffilesystem
この2つの違いは別の記事に委ねるとして、いずれも[FileAttributeKey : Any]
型の辞書で返されてきます。これらをマージしてファイルの属性情報として使っていきたいと思います。
extension File { var attributes: [FileAttributeKey : Any] { let attr1 = (try? FileManager.default.attributesOfFileSystem(forPath: path)) ?? [:] let attr2 = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] return [attr1, attr2].reduce(into: [FileAttributeKey : Any](), { ret, attr in ret.merge(attr) { $1 } }) } }
ここで返されたファイルのメタデータを実際に覗いてみます。とりあえずどんなキーが辞書に入っているのでしょうか。
let file = File.documentDirectory + "hoge" + "fuga" + "sample.txt" try? file.write(contents: "Hello") file.attributes.forEach { kv in print(kv.key.rawValue) } // 結果 // NSFileExtensionHidden // NSFileReferenceCount // NSFilePosixPermissions // NSFileCreationDate // NSFileExtendedAttributes // NSFileSystemFreeNodes // NSFileSize // NSFileSystemSize // NSFileType // NSFileSystemNodes // NSFileModificationDate // NSFileGroupOwnerAccountID // NSFileSystemFileNumber // NSFileGroupOwnerAccountName // NSFileSystemNumber // NSFileOwnerAccountID // NSFileSystemFreeSize
色々と情報が取れてそうですね。
これらの情報から必要そうなものを抜き取りたいところですが、FileAttributeKey
を外から直接指定して取得するよりも、アプリ上必要な情報だけを取れるように隠蔽化しておいたほうがキレイかと思います。
ファイルの作成日時を取得する
まず、外から属性全体が見れないように隠蔽していきます。 その上でファイルの作成日時を返すプロパティを作ってやり、使用者に余計なことを考えさせないようにしてしまいます。
extension File { // 隠蔽化のためprivateに変更 private var attributes: [FileAttributeKey : Any] { let attr1 = (try? FileManager.default.attributesOfFileSystem(forPath: path)) ?? [:] let attr2 = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] return [attr1, attr2].reduce(into: [FileAttributeKey : Any](), { ret, attr in ret.merge(attr) { $1 } }) } // 作成日時 var creationDate: Date? { return attributes[.creationDate] as? Date } }
これでファイルの作成日時をDate型で取得することができます。
let file = File.documentDirectory + "sample.txt" print(file.creationDate!) // 結果:例 // 2021-01-01 12:00:00 +0000
こうしておいたほうが、属性値の辞書からの型キャストのことも考えずに直感的に使用できると思います。 他の属性についても同じようなノリで作っていくといいかと思います。
ファイルの更新日時を取得する
作成日時と同じことなのでソースコードだけ記しておきます。
extension File { // 隠蔽化のためprivateに変更 private var attributes: [FileAttributeKey : Any] { let attr1 = (try? FileManager.default.attributesOfFileSystem(forPath: path)) ?? [:] let attr2 = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] return [attr1, attr2].reduce(into: [FileAttributeKey : Any](), { ret, attr in ret.merge(attr) { $1 } }) } // 作成日時 var creationDate: Date? { return attributes[.creationDate] as? Date } // 更新日時 var modificationDate: Date? { return attributes[.modificationDate] as? Date } }
ファイルサイズを取得する
ファイルサイズはFileAttributeKey.size
で取得することができますが、ドキュメントによると返される値はunsigned long long
だとされています。swiftで扱う場合はUInt64
になるので型には注意しましょう。
https://developer.apple.com/documentation/foundation/fileattributekey/1416548-size
extension File { // 隠蔽化のためprivateに変更 private var attributes: [FileAttributeKey : Any] { let attr1 = (try? FileManager.default.attributesOfFileSystem(forPath: path)) ?? [:] let attr2 = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] return [attr1, attr2].reduce(into: [FileAttributeKey : Any](), { ret, attr in ret.merge(attr) { $1 } }) } // 作成日時 var creationDate: Date? { return attributes[.creationDate] as? Date } // 更新日時 var modificationDate: Date? { return attributes[.modificationDate] as? Date } // ファイルサイズ var size: UInt64 { return attributes[.size] as? UInt64 ?? 0 } }
使い方
let file = File.documentDirectory + "sample.txt" try? file.write(contents: "Hello") print(file.size) // 結果 // 5
"Hello"しか書かれていないファイルなので、サイズは5バイトだけですね。
ディレクトリ内の内容を高階メソッドで処理できるようにする
File構造体はディレクトリを抽象化する構造体でもあります。ディレクトリということは、その中身もあります。
ディレクトリの内容を確認するために用意されているのはFileManager
のcontentsOfDirectory(atPath:)
です。しかし、このメソッドは指定したパスが存在しなかったり、ファイルだった場合には例外を投げます。
過去の経験から、この例外に対して例外処理を都度書くよりはシンプルに空配列が返ったほうが使い勝手がよかったです。
まず、プライベートメソッドとして疑似map関数のような高階メソッドを用意します。
extension File { private func filesMap<T>(_ transform: (String) throws -> (T)) rethrows -> [T] { guard let fileNames = try? FileManager.default.contentsOfDirectory(atPath: path) else { return [] } return try fileNames.map { try transform($0) } } }
メソッドのシグネチャが若干ややこしいですが、map
同様に関数ブロックの戻り値がそのまま配列の型として認識され返却されるようにしています。
関数ブロックにはディレクトリ内のファイル名(またはディレクトリ名)が渡されるので、それをよしなに使う側が加工して型を決めた上で返却するだけでいいというわけです。
ちなみに最後の行は
return fileNames.sorted().map { try transform($0) }
にしてやると、Finderで並ぶ順のようになります。
では、これを用いて便利なパブリックプロパティを作っていきましょう。
ディレクトリ内のファイルをすべて取得する
ディレクトリの中身をFile構造体の配列として返すことで、続くその後の処理も扱いやすいようになります。
extension File { var files: [File] { return filesMap { self + $0 } } private func filesMap<T>(_ transform: (String) throws -> (T)) rethrows -> [T] { guard let fileNames = try? FileManager.default.contentsOfDirectory(atPath: path) else { return [] } return try fileNames.map { try transform($0) } } }
前述のfilesMap
の使いやすさがこれで分かると思います。
これでディレクトリの中がすべてFile構造体で取得できるようになります。
ディレクトリ内のファイルパス、ファイル名をすべて取得する
それ以外にfilesMap
を使って、ファイルパスやファイル名を返すプロパティもこんな感じで作れます。
var filePaths: [String] { return filesMap { (self + $0).path } } var fileNames: [String] { return filesMap { $0 } }
工夫次第ではディレクトリ内のファイル容量取得なども作れそうですね。
ファイル構造体をデバッグ時に見やすくする
さて、ディレクトリ内のファイルがすべて取れるようになったところで、違う話です。
アプリのローカルディレクトリ内でファイルを操作するようになると、今現在どんなファイルが置かれているかを確認したくなります。
しかし、File構造体をそのままprint
すると、フルパスがコンソールに吐き出されてしまい、少し見づらいものになってしまいます。
そこで、デバッグ時にそれが見やすくなるようにCustomStringConvertible
に準拠してコンソールに吐き出される文字列をカスタマイズしてしまいましょう。
extension File: CustomStringConvertible { var description: String { let type = isDirectory ? "Dir" : "File" return "<\(type) \(name)>" } }
これはあくまで一例なので、自分の見やすいようにしてください。
ファイル操作
今回はファイルを操作する処理を書いていきます。 ファイルを操作するということは基本的には throws をつけることになります。操作しようとするときにキチンと例外処理は考慮したいところですからね。
ファイル削除
extension File { func delete() throws { try FileManager.default.removeItem(atPath: path) } }
単純にFileManager
の削除処理をラップしたものです。
使い方
このように非常にシンプルかつ明快です。
let file = File(path: "path/to/") try? file.delete()
ディレクトリ内ファイル全削除
extension File { func deleteAllChildren() throws { try files.forEach { file in try file.delete() } } }
前回までに作ったfiles
を使用し、ディレクトリ内のファイルのすべての削除を試みます。(メソッド名がちょっと物騒ですね・・)
ファイルのコピー
extension File { func copy(to destination: File, force: Bool = true) throws { if force && destination.exists { try destination.delete() } try FileManager.default.copyItem(atPath: path, toPath: destination.path) } }
こちらもFileManager
のラップですが、単純にコピーだけをするとコピー先が存在していた場合にエラーが吐かれてしまいます。
使用者がそのエラーを享受するかどうかを選択できるように「強制コピーするかどうか」の引数を渡せるようにしています。 強制コピーする場合はコピー先ファイルを削除してからコピーすることで、先述のエラーを回避するというわけです。
ファイルの移動
extension File { func move(to destination: File, force: Bool = true) throws { if force && destination.exists { try destination.delete() } try FileManager.default.moveItem(atPath: path, toPath: destination.path) } }
コピーとほぼ同様です。移動ですので、元々のパスのファイルはなくなる動きをします。
ファイルのリネーム
extension File { func rename(to name: String, force: Bool = true) throws -> File { let destination = File(path: parentDirectoryPath) + name try move(to: destination, force: force) return destination } }
移動の応用編としてリネームのメソッドも定義しました。リネームとはつまり「同ディレクトリへのファイル移動」として処理をするわけです。
Codable
話の前提としてCodable
についてですが、およそ3年半くらい前に登場したあたりで一度ブログにまとめております。
構造化されたデータを扱うためにはSwiftではもはや必須な機能ですね。
Codable
は色々なデータフォーマットで使用できますが、今回は一番よく使われるであろうJSON形式での話に絞ります。
ファイルからオブジェクトを生成する
ファイルの内容がJSONであるとして、その内容からCodable(正しくはDecodable)のオブジェクトが生成できると便利です。早速作ってみましょう。
extension File { func jsonDecoded<T>(_ type: T.Type) throws -> T? where T : Decodable { guard let data = self.data else { return nil } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(type, from: data) } }
メソッドの3行目 decoder.keyDecodingStrategy = .convertFromSnakeCase
は、JSONキーがスネークケースである前提で書いているものなので、たとえばキャメルケース前提である場合は外してください。
また、メソッドの1行目はdata
が取れない場合はnil
を返すようにしていますが、そもそもdata
が取れないこと自体は異常ケースだと思うので、例外を吐くようにしてもいいかもしれません。(今回はそうしませんでしたが・・・)
使い方
以下のようにUser
というCodable
な構造体が定義されているとして
struct User: Codable { let name: String let age: Int let email: String }
ファイルからUser
の配列をこのように取得することができます。
let file = File.mainBundle + "data.json" let users = try? file.jsonDecoded([User].self)
このように「ファイルからオブジェクトを生成する」という処理を2行ほどで書けるようになるのです。
オブジェクトからファイルデータを生成する
では、逆にCodable(正しくはencodable)のオブジェクトをファイルに落とし込む処理も作りましょう。こうすることで先程作った処理と併せて、JSONファイルを介して双方向にやりとりができるようになるはずです。
ここでは2つのステップでオブジェクト=>JSONファイル
という流れを作っていきます。まずは「オブジェクトからファイルデータを生成する」までを1メソッドとしてまとめます。
「ファイルデータを生成する」と「ファイルに書き込む」は責務を分けておきたいからです。
extension File { func jsonEncode<T>(_ value: T) throws -> Data where T : Encodable { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.keyEncodingStrategy = .convertToSnakeCase return try encoder.encode(value) } }
メソッドの2〜3行目はデコード時と同様に、アプリで扱おうとするJSONの構造によって変わるところだと思いますので、よしなに変更してください。上記の通りに使用すると人の目に見やすいようなスネークケースのJSONデータになります。
オブジェクトからファイルに書き込む
前項で書いたとおり「ファイルデータを生成する」と「ファイルに書き込む」はメソッドを分けて定義します。
extension File { func jsonEncode<T>(_ value: T) throws -> Data where T : Encodable { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.keyEncodingStrategy = .convertToSnakeCase return try encoder.encode(value) } func writeEncodedJson<T>(_ value: T, encoding: String.Encoding = .utf8) throws where T : Encodable { let encoded = try jsonEncode(value) let jsonString = String(data: encoded, encoding: encoding) ?? "" try parentDirectory.makeDirectory() try jsonString.write(to: url, atomically: false, encoding: encoding) } }
使い方
let user = User(name: "佐藤さん", age: 21, email: "sato@hoge.com") let file = File.mainBundle + "data.json" try? file.writeEncodedJson([user])
ここもシンプルな実装で「構造化されたデータをJSON文字列に書き起こしてファイルに保存する」という挙動を実現できるようになりました。
デコードはメソッドを分けないの?
ここまでエンコードについてはjsonEncode
とwriteEncodedJson
というメソッドに分けました。繰り返しますが「ファイルデータを生成する」と「ファイルに書き込む」の責務に分けたかったからです。
しかし、デコードはjsonDecoded
の1つしか定義していません。
これは既に作っていたdata
プロパティが「ファイルを読み込む」責務を果たしているからです。わざわざloadDecodedJson
のようなメソッドを作る必要はありません。今まで作っていた処理で「ファイルを読み込む」と「オブジェクトを生成する」の責務の切り分けが充分できているというわけです。
まとめ
[iOSアプリ開発] ファイルを抽象化した構造体を作る(1)
- File構造体を作る
- 特定のディレクトリをすぐ参照できる
- パス文字列を
+
演算子で連結できる Equatable
に準拠する
[iOSアプリ開発] ファイルを抽象化した構造体を作る(2)
- ファイル(またはディレクトリ)の存在確認ができるようになった
- 現在のパスの親ディレクトリを取得できるようになった
- 実際にディレクトリを作る機能をつけた
[iOSアプリ開発] ファイルを抽象化した構造体を作る(3)
- ファイルURLを簡単に取得できるようにした
- ファイルの中身をData型で簡単に取得できるようにした
- テキストファイルの内容を取得できるようにした
- 画像ファイルの画像をUIImageで取得できるようにした
- テキストファイルに書き込めるようにした
- 画像ファイルにUIImageの内容を書き出しできるようにした
[iOSアプリ開発] ファイルを抽象化した構造体を作る(4)
- ファイル名と拡張子、またはその組み合わせを取得できるようにしました。
- そのFile構造体が「実在するファイルなのか」を取得できるようにしました。
- そのFile構造体が「実在するディレクトリなのか」を取得できるようにしました。
[iOSアプリ開発] ファイルを抽象化した構造体を作る(5)
- ファイルの属性値(メタデータ)を取得できるようにする。
- 属性値取得は隠蔽して、作成日時・更新日時・サイズを取得できるようする。
[iOSアプリ開発] ファイルを抽象化した構造体を作る(6)
- ディレクトリ内のファイル(またはディレクトリ)をすべて取得できるようにした
- 高階メソッドにより実装がシンプルにでき、ループ回数も節約できる
- すべて取得したときのデバッグの見やすさのために
CustomStringConvertible
に準拠した
[iOSアプリ開発] ファイルを抽象化した構造体を作る(7)
- ファイルの削除、コピー、移動をできるようにした
- 移動を応用することでリネームをできるようにした
[iOSアプリ開発] ファイルを抽象化した構造体を作る(8)
- 構造化されたデータは
Codable
を使用して扱う - CodableなデータをJSONファイルを介して取得・保存できるようにした
- データ生成とファイル処理の責務は切り分けた
最後に
今回作成したFile構造体
は自分が使っていて便利だったのでご紹介したものです。
もちろんそれ以外の機能を付けてもいいと思いますし、使用しないものは削除していいと思います。
「いいかも」と思っていただいた方は取り入れてみてくださいませ。
この記事で紹介した構造体の全ソースコードは、以下の記事にすべて書いております。 コピペで使えると思うので、よければどうぞ
では、また。