NSDocument と async の関わりで中々に混乱させられたので、NSDocument の Swift Concurrency 周りのまとめ。
そもそも今の NSDocument はどうなっているのか
NSDocument はべらぼうに歴史のあるコンポーネントでなんせ Mac OS X 10.0 の登場当初からある最古参の構造なので、古臭いところもありながらなんやかや時代ごとのアップデートを受けて生き残っています。もちろん Swift Concurrency とも組み合わせられるわけですが、やはり元がピュア Swift でないことに起因する怪しさがあって、あとついでに Apple も今更 NSDocument じゃないと思っているのか割と説明がいい加減なので、具体的にどうすればいいのか悩まされる部分もそれなりです。
@MainActor/nonisolated
現時点(macOS 15 SDK)で NSDocument は @MainActor 指定されており、よってデフォルトの外部アクセスは await を挟む必要があります。が、実際には NSDocument のプロパティ/メソッドは多くが nonisolated 指定で、ちゃんと actor 的な動作をしているのは UI 系のアクションメソッドだけです。
これはなぜかと考えると、ドキュメントという頻繁なアクセスを伴う参照型をいちいち await していたらオーバーヘッドが大きいので、どうしても UI でメインスレッドに制約しなければならない機能以外は隔離不要ということではないかと思います。まあこれはそういうものということでいいんですが、実装に直接影響する箇所が一つあります。read() です。
NSDocument の基本的な動作として、なんらかの read 処理を override して、受け取った Data や URL を元に自身のプロパティを設定する必要があります。ここで read はいずれも nonisolated なので実質的に actor の外部からのアクセスとみなされ、自身の isolated な対象への set は await を必要とします。しかし read は async 関数ではないため実際には await することができません。
これの何が問題かというと NSDocument が持つ @MainActor 指定で、プロパティはデフォルトで MainActor 扱いになるため、nonisolated メソッドからの非await mutable アクセスが制約に引っかかることになります(Swift 5.5 では警告。6では違反)。
class MyDocument: NSDocument {
// MainActorなので nonisolated からの非 await アクセスができない
var myProperty: MyContent!
override nonisolated func read() {
let content = try MyContent(data: data)
// これはできない!(Swift 6より)
self.myProperty = content
}
}
おいおい、じゃあ read からデータをセットすることができないじゃないか!ということになるため、NSDocument サブクラスが自分用のプロパティを定義する際は必然的に nonisolated(unsafe) 指定しなければならないという結論になります。@MainActor とは一体。
class MyDocument: NSDocument {
nonisolated(unsafe) var myProperty: MyContent! // MainActor から除外
override nonisolated func read(from url: URL, ofType typeName: String) throws {
let content = try MyContent(data: data)
// nonisolated なプロパティは自由に叩ける
self.myProperty = content
}
}
Apple としては明示的に unsafe 指定させることで注意を促したいというメッセージなのかもしれませんが、だったらドキュメントで注意するなりサンプルを更新するなりして欲しいところではあります。
ちなみに別解として read 内でセットを MainActor.assumeIsolated で包むという手もあるんですが、これをやってしまうと canConcurrentlyReadDocuments で true を返した時にバックグラウンド読み込みがクラッシュするという問題があります。まあ上記の通りパフォーマンス的には nonisolated プロパティの方が正しいと思われますし、メインスレッド縛りは現代のデザインとしても良くないので素直に nonisolated で処理するのが妥当でしょう。
class MyDocument: NSDocument {
var myProperty: MyContent!
override nonisolated func read(from url: URL, ofType typeName: String) throws { {
let content = try MyContent(data: data)
// 通ることは通るが挙動は問題アリ
MainActor.assumeIsolated {
self.myProperty = content
}
}
}
並列読み込みはできるが read は async でない
ここも問題点です。 NSDocument は 10.6 という早い時期からバックグラウンド読み込みをサポートしており並列処理そのものはある程度考慮されています。しかし上記の通り read が async 関数になっているわけではなく、あくまで同期関数がバッグブランドで呼び出されるという構造になっています。ということは、長い読み込みのような理由で load が async になっているようなモデルクラスは read 内で await できないということになります。この辺りは UIDocment の open が完了ハンドラを取るため Swift 的に async で解釈可能になっているのと比べて NSDocument の古さを感じさせるところです。
class MyDocument: NSDocument {
override nonisolated func read(from url: URL, ofType typeName: String) throws {
await loadSlowData() // エラー。read は async でないので await できない
}
}
なお Task で包めば無理に await することはできますが、read を呼び出す側の方が非同期前提で処理を待っていないため正常に動作しません。 セマフォで Task を待たせるというのも今の Swift はエラーです。 よってこの方向は目がなく、どうにか他の async から読み込み処理を呼び出す必要があります。
class MyDocument: NSDocument {
override nonisolated func read(from url: URL, ofType typeName: String) throws {
Task {
await loadSlowData()
}
// 問題アリ
// Task の終了と関係なく read が終了するので read を抜けた時点で読み込みが未完了
}
}
どうせなら async 版 read を用意しておいてくれればいいのにとも思いますが、ないのは仕方ないので read で async を使いたい場合は NSDocumentController の側から手をいれる必要があります。
NSDocumentController のカスタマイズ
Document-Based アプリのオープンメカニズムは NSDocumentController で実装されています。一応このクラスはカスタマイズが考慮されていて、必要な挙動を適宜オーバーライドすると上位メカニズムの中でそれが使われるという構造になっています。古典的な動的 OOP まんまの設計なのでお世辞にも現代人に優しいとは言えませんが、ドキュメントはそこそこちゃんと書かれているためカスタマイズはそこまで難しくありません。
このなかでマスターの制御を行っているのが openDocument(withContentsOf:display:completionHandler:) です(reopenは除く)。定義自体は async 関数ではありませんが、幸い openDocument(withContentsOf url: URL, display displayDocument: Bool) async throws -> (NSDocument, Bool) としてオーバーライドすることもできるので、これをカスタマイズすれば read 過程に async を持ち込むことが可能です。
ドキュメントによれば openDocument は大雑把に次のような実装になっているはずです
class MyDocumentController: NSDocumentController {
//
// NSDocumentController.openDocument の模擬実装
//
override func openDocument(withContentsOf url: URL, display displayDocument: Bool) async throws -> (NSDocument, Bool) {
// 既存オープンチェック
// 仕様上は "being opend" なファイルも見なければならないらしいが、
// ここではちょっと省略
for doc in self.documents {
if doc.fileURL == url {
// すでにオープン済みだった場合は必要ならばウィンドウを表示
if displayDocument {
doc.showWindows()
}
return (doc, true) // 後者はすでにオープン済みかどうかなのでここでは true
}
}
// type (UTI)を決定
guard let type = try url.resourceValues(forKeys: [.contentTypeKey]).contentType else {
throw NSError(domain: MyErrorDomain, code: -1)
}
// ドキュメントを生成。init と read が実行される
// 注: 本当はドキュメントの canConcurrentlyReadDocuments が true の場合は
// ここだけバックグラウンドで実行されるはずだが、
// NSDocumentController が @MainActor 指定になっているため
// Swift Concurrency の範囲ではどのみちメインスレッドになってしまう。
let doc = try makeDocument(withContentsOf: url, ofType: type.identifier)
// 生成されたドキュメントを追加
self.addDocument(doc)
// display 指定が false ならばここで終了
if displayDocument == false {
return (doc, false)
}
// ウィンドウを生成して表示
doc.makeWindowControllers()
doc.showWindows()
return (doc, false)
}
}
makeDocument は type で決定されたドキュメントクラスを init して read を行います。よって、ドキュメントクラスに独自に async 版 read を付けてやって、代わりにそれを呼び出すような機構を付けてやれば非同期読み込みかが可能です。
まずキャストのために適当なプロトコルを用意します。
protocol AsyncReadableDocument: NSDocument {
nonisolated func readAsynchronous(from url: URL, ofType typeName: String) async throws
}
class MyDocumentController: NSDocumentController {
//
// NSDocumentController.openDocument の模擬実装
//
override func openDocument(withContentsOf url: URL, display displayDocument: Bool) async throws -> (NSDocument, Bool) {
// ... 省略
// type (UTI)を決定
guard let type = try url.resourceValues(forKeys: [.contentTypeKey]).contentType else {
throw NSError(domain: MyErrorDomain, code: -1)
}
// ドキュメントクラスを取得
guard let cls = self.documentClass(forType: type.identifier) as? AsyncReadableDocument.Type else {
throw NSError(domain: MyErrorDomain, code: -2)
}
// ドキュメントを初期化
// init(contentsOf:ofType:) を使うと read が呼ばれるので注意
// init() だと fileURL などは別に設定する必要があるので、
// read を呼ばないように init(contentsOf:ofType:) を override するのもよい
let doc = cls.init()
// 非同期読み込み
try await doc.readAsync(from: url, ofType: type.identifier)
/// 以下同じ
return (doc, false)
}
}
こうしてやれば AsyncReadableDocument に準拠した Document 側は readAsync で設定できます。
class MyDocument: NSDocument, AsyncReadableDocument {
nonisolated func readAsync(from url: URL, ofType typeName: String) async throws {
await loadSlowData() // 合法!
}
}
あとここでは省略しますがちゃんとやるなら reopen も同様に処理する必要があると思います。
最後にカスタムした NSDocumentController を利用するには以下のようにします。これで自動的に shared なドキュメントコントローラーが入れ替わります。
@main
class AppDelegate: NSObject, NSApplicationDelegate {
let dc = MyDocumentController()
}