NSTreeControllerで発生するremoveObjectIdenticalTo...というエラーの原因・解決
概要
下記のフォーラムにしか情報がなかったので、日本語情報のために投稿します。
自作モデルとNSTreeControllerをBindingして使っているときに-[xxxArray removeObjectIdenticalTo:]:unrecognized selector sent to instance xxx
というエラーに遭遇することがあります。
上記のエラーは「Swiftで提供されるArrayに対しては存在しないremoveObjectIdenticalTo:
というメッセージを送信してしまいましたよ」というエラーですが、このremoveObjectIdenticalTo:
というのはNSMutableArrayのメソッドです。
調査・検証の結果、NSTreeControllerのmove(_:to:)などの一部APIは内部の実装がNSMutableArrayを前提として実装されており、NSMutableArrayのメソッドにメッセージを送ることが分かりました。そのため、モデルオブジェクトで実装したSwift.Arrayをbindingで渡している場合はそれらのAPI使用時に存在しないメッセージを送ってしまってエラーを起こしてしまいます。(5年以上前から認知されている問題のようですが、修正される気配はありません)
ワークアラウンド
この問題のワークアラウンドとしていくつか方針があると考えているので、紹介します。
1. 通常のツリー構造のNodeオブジェクトとは別にCocoa用にNSNodeオブジェクトを実装する
メリット: NSTreeControllerとCocoa Bindingの恩恵を受けられる。Pure Swiftのモデルと、Cocoa用のモデルを分離できる
デメリット: 二重に実装が必要になってしまう
2. [1.の改善案] Cocoa対応可能な内部ストレージとしてNSMutableArrayを提供し、Swift.ArrayをSwift用のAPIとして提供
メリット: 実装の重複もなく、NSTreeControllerとCocoa Bindingの恩恵を受けられる
デメリット: 共通モデルにNSMutableArrayをプロパティとして実装するので、Pure Swiftにならない
3. Bindingを行わなわず、NSTreeControllerのinsertやremoveを利用してツリーを構築する
Navigating Hierarchical Data Using Outline and Split ViewsではSwift.Arrayをモデルで利用しているが、そのNodeオブジェクトをNSTreeControllerにバインドしていない。バインド対象がなければremoveObjectIdenticalTo:
を内部的に呼ばないのでエラーも起きようがない
メリット: NSMutableArrayを実装する必要がない. Swiftyなモデルを維持できる
デメリット: ツリーを自分で構築する実装を書く必要があるので面倒くさい. NSTreeControllerとCocoa Bindingの恩恵をあまり受けられない
4. NSTreeControllerの問題あるAPIを使わずに、removeとinsertなどで代用する
メリット: 実装の重複もなく、NSTreeControllerとCocoa Bindingの恩恵を受けられる. Swiftyなモデルを維持できる
デメリット: 完全な挙動の再現は難しい場合がある。例えばmove APIの場合は、NSTreeControllerが保持するNSTreeNodeの参照を保ったまま指定したindexPath位置に移動させるが、removeとinsertを使った場合は新しいNSTreeNodeとして扱われしまうので、複雑な実装を行いたい場合に困る事がある。
5. NSTreeController自体を使わずに、NSOutlineViewDataSourceでデータを提供する
メリット: NSTreeControllerに起因する問題を回避できる。NSOutlineViewが保持するデータがNSTreeNodeではなく、直接自分が提供したデータモデルになるので、取り回しやすくなる
デメリット: 他の画面で更新に適応するためのデータバインディングの処理を自分で書かないといけないことになったり、NSTreeControllerの恩恵が一切なくなり、実装がめんどくさい。
推奨ワークアラウンド
4の方法は難しい操作を提供しない場合は適しており、もっとも簡単に実装可能で良いです。
完全なコントロールを提供したい場合は2.の方針を推奨します。
以下、2.のサンプルコードを掲示します.
class Node: NSObject {
/*
本来はIDやコンテンツのプロパティがあると思いますが、その辺はご自身のユースケースに合わせて変わるので割愛しています。
*/
/// `NSMutableArray`を使った子ノード配列のプロパティ. Cocoa Bindingなどの`NSMutableArray`を内部実装で前提とした場合にのみ利用し、その他の場合は`children`を使うことを推奨する.
///
/// NSTreeControllerでは一部のAPIの内部実装でNSMutableArrayを前提としており、Swift.Arrayを渡してしまうと"ContiguousArrayStorage removeObjectIdenticalTo:]: unrecognized selector sent to instance xxx" という、Swift.Arrayに対してNSMutableArrayにしか存在しないメッセージを送ってしまうことに起因するエラーが発生する。
/// Cocoa BindingとNSTreeControllerの恩恵を最大限に受け取りつつ、上記のエラーを回避し、かつモデルオブジェクトをなるべくSwiftyに保つデザインを考慮した結果、内部ストレージとして`NSMutableArray`を使うという方針を採用した。
@objc dynamic var objcMutableChildren: NSMutableArray = .init()
/// 子ノードの配列. Cocoa Binding以外の利用のためのインターフェース
@objc dynamic var children: [Node] {
get {
return objcMutableChildren as? [Node] ?? []
}
set {
objcMutableChildren = NSMutableArray(array: newValue)
}
}
}
上記のようなモデルを実装した上で、objcMutableChildren
をBindingのkeypathとして指定します。なお、NSMutableArrayは参照型オブジェクトですので、オブジェクトの再割り当て以外では基本的にKVOが働きません(NSTreeControllerの都合で呼んでくれるものもあります)。「このアクションによって発生した変更を別ウインドウで同じオブジェクトをBindしている画面にも反映したい!でもうまくいかない!」というときは、NSKeyValueObservingのwillChangeValue(forKey:)とdidChangeValue(forKey:)を変更の前後で適切に手動コールすれば意図通りにKVO通知が飛ばせます。
総括
ここまでNSTreeControllerのBinding時に発生するエラーの原因と解決法(ワークアラウンド)を紹介しました。より良い設計方法等あればコメント歓迎致します。
Discussion