いまさらだけどiOSのファイル操作まとめ（Swift）

![Xcode-12](https://img.shields.io/badge/Xcode-12-brightgreen) ![Swift-5.3](https://img.shields.io/badge/Swift-5.3-brightgreen) ![iOS-14](https://img.shields.io/badge/iOS-14-brightgreen)

## はじめに
いまさらだけど iOS のファイル操作についてまとめました。ストアに公開しない業務用アプリとかつくってるとわりとガイドラインとかわすれる。。。:sleepy:

## ファイル保存先
[File System Programming Guide](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html) をみるとファイルの保存先は下記のようになっている様子。目的に沿って適当なものを選択する。

* Documents/
  * 設定によって共有できるのでユーザーに見せたいファイルのみ保存する  
  （Realm のファイルはデフォルトでここに保存されるみたいです）
  * iCloud でバックアップされる
* Documents/Inbox
  * 他のアプリからファイルを受け取るときに使用するディレクトリ
  * iCloud でバックアップされる
  * 削除はできるが編集は不可
* Library/
  * ユーザーに見せたくないファイルを保存する
  * iCloud でバックアップされる
* Library/Caches
  * いわゆるキャッシュ
  * iCloud でバックアップされない
  * tmp/ よりも長期間保存されるが定期的にシステムにより削除される
* tmp/
  * 長期間保持する必要のない一時データを保存する
  * iCloud でバックアップされない
  * 使用後は、すみやかに削除すべき
  * 定期的にシステムにより削除される

## ガイドライン
ファイル保存には下記ガイドラインがありこれに沿っていないとリジェクトされることもあるようです。

[iOS Data Storage Guidelines](https://developer.apple.com/icloud/documentation/data-storage/index.html)

ざっくりいうと iCloud にバックアップされるから容量圧迫しないように保存ファイルには気を使え！ということのようです。

４項目ありざっくりは下記です。

1. Documents/ は iCloud にバックアップされるのでユーザーが作成したファイルか再作成できないファイルのみを保存しろ！
1. 再ダウンロードや再作成できるファイルは iCloud にバックアップされない Library/Caches に保存しろ！
1. 一時利用ファイルは iCloud にバックアップされないtmp/に保存し、不要になれば速やかに削除しろ！
1. 再作成できるがストレージが少ない場合でも削除されないようにするためにはバックアップしない属性をつけろ！

## パスの取得
それぞれのファイルパス取得方法です。

* ホームディレクトリ（Documents、Library などのディレクトリがあるとこ）  

    ```swift
    NSHomeDirectory()
    ```

* Documents/

    ```swift
    NSHomeDirectory() + "/Documents"

    // もしくはこっち
    FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! // URL型
    ```

* Library/

    ```swift
    NSHomeDirectory() + "/Library"

    // もしくはこっち
    FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! // URL型
    ```

* Library/Caches

    ```swift
    NSHomeDirectory() + "/Library/Caches"

    // もしくはこっち
    FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! // URL型
    ```

* tmp/

    ```swift
    NSHomeDirectory() + "/tmp"

    // もしくはこっち
    NSTemporaryDirectory() // ~/tmp/ のように末尾に/がつく
    ```

ちなみに [NSSearchPathForDirectoriesInDomains](https://developer.apple.com/documentation/foundation/1414224-nssearchpathfordirectoriesindoma) を使う方法もあります。

## ファイル操作
ファイルの保存、削除、移動などの処理をまとめてみました。

```swift
/// /Documents/hogeディレクトリ内の操作するやつ
struct HogeFileOperator {
    private let fileManager = FileManager.default
    private let rootDirectory = NSHomeDirectory() + "/Documents/hoge"

    init() {
        // ルートディレクトリを作成する
        createDirectory(atPath: "")
    }

    private func convertPath(_ path: String) -> String {
        if path.hasPrefix("/") {
            return rootDirectory + path
        }
        return rootDirectory + "/" + path
    }

    /// ディレクトリを作成する
    /// - Parameter path: 対象パス
    func createDirectory(atPath path: String) {
        if fileExists(atPath: path) {
            return
        }
        do {
           try fileManager.createDirectory(atPath: convertPath(path), withIntermediateDirectories: false, attributes: nil)
        } catch let error {
            print(error.localizedDescription)
        }
    }

    /// ファイルを作成する
    /// - Parameters:
    ///   - path: 保存先ファイルパス
    ///   - contents: コンテンツ
    func createFile(atPath path: String, contents: Data?) {
        // 同名ファイルがある場合は上書きされるので判定いるかも？
//        if fileExists(atPath: path) {
//            print("already exists file: \(NSString(string: path).lastPathComponent)")
//            return
//        }
        if !fileManager.createFile(atPath: convertPath(path), contents: contents, attributes: nil) {
            print("Create file error")
        }
    }

    /// ファイルがあるか確認する
    /// - Parameter path: 対象ファイルパス
    /// - Returns: ファイルがあるかどうか
    func fileExists(atPath path: String) -> Bool {
        return fileManager.fileExists(atPath: convertPath(path))
    }

    /// 対象パスがディレクトリか確認する
    /// - Parameter path: 対象パス
    /// - Returns:ディレクトリかどうか（存在しない場合もfalse）
    func isDirectory(atPath path: String) -> Bool {
        var isDirectory: ObjCBool = false
        fileManager.fileExists(atPath: convertPath(path), isDirectory: &isDirectory)
        return isDirectory.boolValue
    }

    /// ファイルを移動する
    /// - Parameters:
    ///   - srcPath: 移動元ファイルパス
    ///   - dstPath: 移動先ファイルパス
    func moveItem(atPath srcPath: String, toPath dstPath: String) {
        // 移動先に同名ファイルが存在する場合はエラー
        do {
           try fileManager.moveItem(atPath: convertPath(srcPath), toPath: convertPath(dstPath))
        } catch let error {
            print(error.localizedDescription)
        }
    }

    /// ファイルをコピーする
    /// - Parameters:
    ///   - srcPath: コピー元ファイルパス
    ///   - dstPath: コピー先ファイルパス
    func copyItem(atPath srcPath: String, toPath dstPath: String) {
        // コピー先に同名ファイルが存在する場合はエラー
        do {
           try fileManager.copyItem(atPath: convertPath(srcPath), toPath: convertPath(dstPath))
        } catch let error {
            print(error.localizedDescription)
        }
    }

    /// ファイルを削除する
    /// - Parameter path: 対象ファイルパス
    func removeItem(atPath path: String) {
        do {
           try fileManager.removeItem(atPath: convertPath(path))
        } catch let error {
            print(error.localizedDescription)
        }
    }

    /// ファイルをリネームする
    /// - Parameters:
    ///   - path: 対象ファイルパス
    ///   - newName: 変更後のファイル名
    func renameItem(atPath path: String, to newName: String) {
        let srcPath = path
        let dstPath = NSString(string: NSString(string: srcPath).deletingLastPathComponent).appendingPathComponent(newName)
        moveItem(atPath: srcPath, toPath: dstPath)
    }

    // ディレクトリ内のアイテムのパスを取得する
    /// - Parameter path: 対象ディレクトリパス
    /// - Returns:対象ディレクトリ内のアイテムのパス一覧
    func contentsOfDirectory(atPath path: String) -> [String] {
        do {
           return try fileManager.contentsOfDirectory(atPath: convertPath(path))
        } catch let error {
            print(error.localizedDescription)
            return []
        }
    }

    /// ディレクトリ内のアイテムのパスを再帰的に取得する
    /// - Parameter path: 対象ディレクトリパス
    /// - Returns:対象ディレクトリ内のアイテムのパス一覧
    func subpathsOfDirectory(atPath path: String) -> [String] {
        do {
           return try fileManager.subpathsOfDirectory(atPath: convertPath(path))
        } catch let error {
            print(error.localizedDescription)
            return []
        }
    }

    /// ファイル情報を取得する
    /// - Parameter path: 対象ファイルパス
    /// - Returns: 対象ファイルの情報（作成日など）
    func attributesOfItem(atPath path: String) -> [FileAttributeKey : Any] {
        do {
           return try fileManager.attributesOfItem(atPath: convertPath(path))
        } catch let error {
            print(error.localizedDescription)
            return [:]
        }
    }
}

// こんな感じで使う
let hoge = HogeFileOperator()
hoge.createDirectory(atPath: "fuga")
hoge.createDirectory(atPath: "fuga/foo")
print(hoge.isDirectory(atPath: "fuga")) // true
hoge.createFile(atPath: "fuga/piyo.txt", contents: "あいうえお".data(using: .utf8))
hoge.copyItem(atPath: "fuga/piyo.txt", toPath: "fuga/piyoコピー.txt")
hoge.copyItem(atPath: "fuga/piyo.txt", toPath: "fuga/piyoコピー2.txt")
hoge.moveItem(atPath: "fuga/piyo.txt", toPath: "fuga/foo/piyo.txt")
hoge.removeItem(atPath: "fuga/piyoコピー2.txt")
hoge.renameItem(atPath: "fuga/piyoコピー.txt", to: "コピーです.txt")
print(hoge.contentsOfDirectory(atPath: "")) // ["fuga"]
print(hoge.subpathsOfDirectory(atPath: "")) // ["fuga", "fuga/コピーです.txt", "fuga/foo", "fuga/foo/piyo.txt"]
let attributes = hoge.attributesOfItem(atPath: "fuga/コピーです.txt")
```

エラーはちゃんと呼び出し元でキャッチできるようにした方がいいのかも:innocent:

## バックアップ対象外
iCloud のバックアップ対象外にするには下記のように処理します。

```swift
// 対象のファイルパス
var targetFileURL: URL
var values = URLResourceValues()
values.isExcludedFromBackup = true
do {
    try targetFileURL.setResourceValues(values)
} catch let error {
    print(error.localizedDescription)
}
```

[isExcludedFromBackupKey](https://developer.apple.com/documentation/foundation/urlresourcekey/1408756-isexcludedfrombackupkey)を `true` にするみたいですがファイルの場合は操作するたびに設定してやる必要があるみたいです。

## ファイル共有
info.plist にキーを追加するとファイル App から Documents フォルダを参照できます。下記２パターンのどちらでもファイル App から Documents フォルダを参照できました。

* [UISupportsDocumentBrowser](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW37)
* [UIFileSharingEnabled](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW20) と [LSSupportsOpeningDocumentsInPlace](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/TP40009250-SW13)

２つの違いはいまいちわからない:worried:

こんな感じで参照できます。

<img width="300" alt="file" src="https://user-images.githubusercontent.com/34936885/106592607-144b2900-6593-11eb-9ef5-7ce6c75826a4.png">

## おわりに
これでファイル操作はだいたいできるはず:tada:

ほんとは Android のファイル操作について調べようと思ったけどそもそも iOS ってどうだったかなと思って書きました:relaxed:

## 参考
* [File System Programming Guide](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html)
* [iOS Data Storage Guidelines](https://developer.apple.com/icloud/documentation/data-storage/index.html)
* [UISupportsDocumentBrowser](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW37)
* [UIFileSharingEnabled](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW20)
* [LSSupportsOpeningDocumentsInPlace](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html#//apple_ref/doc/uid/TP40009250-SW13)
* [FileManager](https://developer.apple.com/documentation/foundation/filemanager)
* [isExcludedFromBackupKey](https://developer.apple.com/documentation/foundation/urlresourcekey/1408756-isexcludedfrombackupkey)