[Swift] ファイルを抽象化した構造体を作る (移植版 from Zenn)

[Swift] ファイルを抽象化した構造体を作る (移植版 from Zenn)

1つにまとめてみると、まさかこんなに長い記事を書いてると自分でも思ってもみませんでした。 (大阪府・30代男性)
Clock Icon2021.03.18

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

この記事について

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/不可分操作

ドキュメントによると、atomicallytrueにすると、ファイルの書き込みは一時ファイルで行われ、終わったら元のファイルに置換するという挙動をするとのことです。 その場合、できあがったファイルの作成日時の情報はその実行された時間というふうに書き換わる挙動をします。

ここは作るアプリの性質にもよると思いますが、アトミック処理はたとえば同時多発的なプロセスからファイルアクセスが行われたときの整合性担保のための仕組みだと思いますし、個人の端末で個人だけが使うアプリにおいてはfalse指定でもあまり影響がないかなとも思います。この辺りは要件に合わせて使い分けてください。

使い方

let file = File.documentDirectory + "hoge" + "fuga" + "test.txt"
try? file.write(contents: "Hello World")

これで、ドキュメントディレクトリの下にhoge/fuga/というディレクトリが作られ、"Hello World"と書かれたテキストファイルが作成されると思います。

画像

画像の書き込みはJPEGPNGで書き込めるようにします。(他のフォーマットについては長くなると思うので別の機会があれば)

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構造体はディレクトリを抽象化する構造体でもあります。ディレクトリということは、その中身もあります。

ディレクトリの内容を確認するために用意されているのはFileManagercontentsOfDirectory(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 4] SwiftyJSONを使わずにシンプルにJSONをデータ構造化する

構造化されたデータを扱うためには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文字列に書き起こしてファイルに保存する」という挙動を実現できるようになりました。

デコードはメソッドを分けないの?

ここまでエンコードについてはjsonEncodewriteEncodedJsonというメソッドに分けました。繰り返しますが「ファイルデータを生成する」と「ファイルに書き込む」の責務に分けたかったからです。

しかし、デコードは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構造体は自分が使っていて便利だったのでご紹介したものです。 もちろんそれ以外の機能を付けてもいいと思いますし、使用しないものは削除していいと思います。 「いいかも」と思っていただいた方は取り入れてみてくださいませ。

この記事で紹介した構造体の全ソースコードは、以下の記事にすべて書いております。 コピペで使えると思うので、よければどうぞ

では、また。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.