PR

SwiftUI Documentsといふもの 〜 Mac系のファイル操作アプリ作成に便利だった

ADPの登録が完了してさっそくiCouldにファイル保存しようと思って調べているときに見つけたプロジェクトのテンプレートである「Document App」。作成してPreviewを見るも「なんのこっちゃ」とスルーしていたが、調べてみるとかなり使いやすいフレームワークっぽいので、その調査と備忘録になります。

Documents | Apple Developer Documentation
Enable people to open and manage documents.

Document Appで作成したテンプレアプリを調べる

アプリを起動すると「ファイル」アプリでよく見る画面が表示されて「あれ?起動するアプリ間違えたかな」と思ったけどDocment Appではこのファイル選択画面から起動するらしいです。MacのFinderWindowsのExplorer的な感じで利用できるインターフェースです。「データを読み書きすること」を目的としたアプリであれば、この機能を使うと簡単にiCouldにアクセスできます。もちろんADP登録とiCloudの設定が必要ですが。

DocumentGroup

DocumentGroup | Apple Developer Documentation
A scene that enables support for opening, creating, and saving documents.
Swift
import SwiftUI

@main
struct SampleDocumentApp: App {
    var fileProvider: FileProvider = FileProvider()
    
    var body: some Scene {
        DocumentGroup(newDocument: SampleDocumentDocument()) { file in
            let _ = print("\(String(describing: file.fileURL))")
            ContentView(document: file.$document)
                .environment(fileProvider)
        }
    }
}

上記のSampleDocumentDocument(サンプルアプリ作ったときにプロジェクト名をSampleDocumentにしたら後ろにDocumentが付いちゃった)がFileDocumentを継承していれば「Create Document」で新規作成時にファイルが作成されて閲覧・編集ができるような仕組みです。「fileURL」が参照可能ですが、「そこから書き込みするとやべぇ事になる」との事なので基本は「FileDocument」経由でアクセスするのが正解だそうです。ネットワーク経由で排他とか考えたくないですからね。printで調べたiCloud DriveのURLたちです。ちなみにfileProviderはデバッグ用に作ったクラスで、最後に出てきます。

iCloud系のURLはすべて頭に下記のURLが付きます。xxxxはユーザーIDでXXXXはデバイスのIDですかね。シュミレーターを利用しいてますが、シュミレーター側でもApple IDでログインしてiCloudを有効にする必要があります。

ANSI
file:///Users/xxxx/Library/Developer/CoreSimulator/Devices/XXXX/data/Library/Mobile%20Documents

iCould DriveのURL

ANSI
file:///Users/xxxx/Library/Developer/CoreSimulator/Devices/XXXX/data/Library/Mobile%20Documents /com~apple~CloudDocs/

一番悩んだのがiCloud DriveのURLをどのように取得するか。開けない事にはURLを知ることができないので、DocumentGroupは簡単に実装できるのでものすごい便利でした。このコンテナ名はネットではよく見るのですが、公式には見つけられなかった。探し方が悪いのかな。とりあえずiCloud Driveの直下がこのURLになります。

アプリフォルダのURL

ANSI
file:///Users/xxxx/Library/Developer/CoreSimulator/Devices/XXXX/data/Library/Mobile%20Documents/iCloud~org~m7e~sample~container/Documents/

XcodeでiCloudの機能を追加して、Servicesの「iCloud Documents」「CloudKit」にチェック入れてからContainersにCloudKit Consoleで追加したコンテナID(?)にチェックを入れると、このコンテナがデフォルトで出来上がるようです。ユーザーがアプリから見た場合のフォルダはiCloud Drive/アプリ名/です。

書類フォルダのURL

ANSI
file:///Users/xxxx/Library/Developer/CoreSimulator/Devices/XXXX/data/Library/Mobile%20Documents/com~apple~CloudDocs/Documents/

元々はこの書類フォルダにcsvファイルを投げ込むために色々と調べていました。いろんな「Documents」フォルダが存在しているので、ややこしいですね。

このiPhone内のURL

ANSI
file:///Users/xxxx/Library/Developer/CoreSimulator/Devices/XXXX/data/Containers/Shared/AppGroup/XXXXX/File%20Provider%20Storage/

ファイルアプリで表示したときに「このiPhone内」と表示されるURLです。Documents経由では簡単にアクセスできるけど、FileManager経由ではどうなんでしょう。

FileDocument

FileDocument | Apple Developer Documentation
A type that you use to serialize documents to and from file.
UTType | Apple Developer Documentation
A structure that represents a type of data to load, send, or receive.
Swift
import SwiftUI
import UniformTypeIdentifiers

extension UTType {
    static var exampleText: UTType {
        UTType(importedAs: "com.example.plain-text")
    }
    static var csv: UTType {
        UTType(importedAs: "org.m7e.plain-text")
    }
}

struct SampleDocumentDocument: FileDocument {
    var text: String

    init(text: String = "Hello, world!") {
        self.text = text
    }

    static var readableContentTypes: [UTType] { [.exampleText, .csv] }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8)
        else {
            throw CocoaError(.fileReadCorruptFile)
        }
        text = string
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = text.data(using: .utf8)!
        return .init(regularFileWithContents: data)
    }
}

FileDocumentを継承したオブジェクトを用意します。といってもプロジェクト作成時に「Document App」を選択すると勝手にテンプレートが作成されるので、それに調査用に「csv拡張子のファイル」を読み込めるように追加しています。ここでUTTypeを追加しないと「ファイルは見えるけど選択ができない」状態になります。

そのまま実装するとエラーが出るので「info.plist」にテンプレを参考にCSVの情報を追加して完了。ファイルを読み込む事ができます。

おまけ:FileManager経由でiCould Driveを参照する

Documentsの操作により、ファイルのパスが分かりました。パスが分かればどのようにアクセスするのか調べるのは可能なので、調べた事を記録しておきます。

iCloud Driveへのアクセス

調べててわかったことはADP登録前に調べたディレクトリ関連のIFは使わないという事。以前のイメージは「iCloud機能の有効化」→「FileManager.urls(for: in:)でiCloudのURLが追加取得可能」と勝手に想定していましたが、実際は「iCloudの有効化」→「FileManager.url(forUbiquityContainerIdentifier:)でiCloudのURL取得」でした。URL取得後は通常のファイルアプリと同様に操作可能なようです。

Swift
import Foundation

@Observable
class FileProvider {
    var fileName: String = "sample.txt"
    private var myiCloudContainerURL: URL?
    private var isiCouldAvalable: Bool = false
    private var myLocalContainerURL: URL?
    
    init?() {
        guard FileManager.default.ubiquityIdentityToken != nil else {
            return nil
        }
        DispatchQueue.global().async {
            let ubiquityContainerID = "com.apple.CloudDocs"
            if let url = FileManager.default.url(forUbiquityContainerIdentifier: ubiquityContainerID) {
                DispatchQueue.main.async {
                    print("container URL: \(url)")
                    self.myiCloudContainerURL = url
                    self.isiCouldAvalable = true
                    print("iCould is avaialble.")
                }
                return
            }
            print("Error to retrieve iCloud container URL!!!")
        }
        self.myLocalContainerURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
        let error = "Local URL error"
        print("Local URL \(self.myLocalContainerURL?.absoluteString ?? error)")
        
    }
    
    func save(content: String) {
        if isiCouldAvalable {
            let filePath = self.myiCloudContainerURL?.appendingPathComponent(self.fileName)
            do {
                try content.write(to: filePath!, atomically: true, encoding: .utf8)
                print("Success!! \(String(describing: filePath))")
            } catch {
                fatalError("Save iCloud FATAL ERROR!!! \(error)")
            }
        }
        let filePath = self.myLocalContainerURL?.appendingPathComponent("local.csv")
        do {
            try content.write(to: filePath!, atomically: true, encoding: .utf8)
            print("Success!! \(String(describing: filePath))")
        } catch {
            fatalError("Save Local FATAL ERROR!!! \(error)")
        }
    }
    
    func load(fileName: String) -> String {
        if isiCouldAvalable {
            let filePath = self.myiCloudContainerURL?.appendingPathComponent(fileName)
            guard let content = try? String(String(contentsOf: filePath!, encoding: .utf8)) else {
                fatalError("Read FATAL ERROR!!!")
            }
            return content
        }
        return "load Error!!"
    }
}

File Provider(本物)と「このiPhone内フォルダ」

File Provider | Apple Developer Documentation
An extension other apps use to access files and folders managed by your app and synced with a remote storage.

特に考えもなくデバッグ用のクラス「File Provider」を作成していましたが、「このiPhone内」のクラスにアクセスした際に「File Provider Storage」なる物が含まれたURLがあったので調べたら本物がいました(汗。少し調べてみましたが、今回はスルーする事にしました。

で、「このiPhone内」にアクセスする方法を調べました。FileProviderの説明の中に「ローカルで共有したい場合はFileProviderいらないよ」みたいな記述があって、その内容としては普通に「for: .documentDirectory , in: .userDomainMask」のAPIでURLを取得して使う事と「info.plist」に以下の2行を追加して「YES」で設定するだけでした。この設定をしないと自前のアプリからは参照できても、ファイルアプリから参照できないようです。

  • Application supports iTunes file sharing (UIFileSharingEnabled)
  • Supports opening documents in place (LSSupportsOpeningDocumentsInPlace)

実際は「このiPhone内」の下にアプリのフォルダーが作成されるのとURLはファイルアプリからアクセスしたのとは違いますが、ファイルアプリは「FileProvider」を利用してアクセスしているのでしょう。

ANSI
file:///Users/xxxx/Library/Developer/CoreSimulator/Devices/XXXX/data/Containers/Data/Application/YYYY/Documents/local.csv
Information Property List | Apple Developer Documentation
A resource containing key-value pairs that identify and configure a bundle.

info.plistの説明は検索するのが大変ですね。本家でフィルターかけて探しています。

雑感

これでiCloudを使ったファイルアクセスは可能となったので、早速アプリの実装にかかろうと思います。前回までのUIをやめて今回のSwfitUI Documentsを使って、ドキュメントのビューをテーブルビュー的なものにし、テーブルデータをjsonファイルでまとめてたのをSwiftDataを利用するように実装を変更しようと思います。結構やることあるな…ほぼ新規実装なのか(汗。今月中に広告付けてアプリをリリース手続き(審査があるらしいので)まで進めようと思っているのですが、おそらくギリギリですな。

コメント

タイトルとURLをコピーしました