皆さん今朝はどんな夢をみましたか。今日は私の初夢を紹介します。
prepareForSegue
iOSアプリを開発していて、特にStoryboardを利用して遷移を作るとき、往々にしてそのコードは不完全になる。
ViewControllerの遷移をするときには、次に表示されるViewControllerに必要な情報を受け渡して初期化しなければならない。Storyboardを使った遷移は、すでにインスタンス化されたViewControllerオブジェクトに対して行うことになる。このためViewControllerのイニシャライザを利用できない。従ってUIViewController
のprepareForSegue(_:sender:)
メソッドをオーバーライドして、UIStoryboardSegue
オブジェクトのdestinationViewController: UIViewController
propertyから、次に表示される予定のViewControllerオブジェクトにアクセスして、必要と思われる情報を渡す。これが一般的な実装である。
destinationViewController
を初期化する
destinationViewController
propertyは単にUIViewController
型であるから、ここでは目的の型にキャストする必要があるだろう。そしてインスタンス化されたオブジェクトに対して、必ず必要なだけの情報を渡さなければならない。この際に型による保護は得られない。遷移元のViewControllerが遷移先のViewControllerについてよく知っている必要がある。例えば後から必要な情報が増えたときには、コンパイルは通るが実行時にエラーになる、といった状態が発生しやすい。あるいはsegueの接続が変わった場合に、もともと想定していた初期化が行われずに実行時エラーが起きることが頻繁にある。もちろんこれらの問題が気付かれぬままリリースされることはないだろうが、それでも型による保護がないというのは不便である。
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if let toViewController = segue.destinationViewController as? ToViewController { toViewController.entry = Entry(title: "Title") } else { fatalError() } }
prepareForSegue
の実装では、遷移先のViewControllerの型をキャストし、必要な情報を受け渡す
ViewControllerの初期化
そもそもViewControllerの初期化について考えておく必要がある。インスタンスは、本来であればイニシャライザで初期化されるべきであるが、Storyboardを使う以上それは叶わない(Nibであればイニシャライザを利用することもできる)。仮にイニシャライザが利用できる場合、そのシグネチャそのものが初期化に必要な情報を表すことになる。Storyboardによってインスタンス化されるViewControllerにおいても、どのように初期化されるべきか知っているのはそのViewController自身である方が好ましい。
ところで遷移を表すUIStoryboardSegue
は、3つの情報を持っている。var identifier: String? { get }
とvar sourceViewController: UIViewController { get }
とvar destinationViewController: UIViewController { get }
である。identifier
は一意にsegueを指す。同じsegueは必ず同じ型のsourceViewController
とdestinationViewController
を持つ。このことを利用して、この情報の組を型として表現することができるのではないか。
SegueInitiator
試しにこの遷移の情報を元にViewControllerを初期化する型を作ってみる。
protocol SegueInitiator: class { static var identifier: String? { get } func prepare(segue: UIStoryboardSegue) }
segueのidentifier
に対応して、prepare(_:)
メソッドを持つというSegueInitiator
型を定義した。
extension SegueInitiator { static func match(segue: UIStoryboardSegue) -> Bool { return identifier == segue.identifier } func performFrom(viewController: UIViewController) { if let identifier = Self.identifier { viewController.performSegueWithIdentifier(identifier, sender: self) } else { fatalError("can't perform nil identifier segue") } } }
ふたつのデフォルト実装も用意しておく。ひとつはUIStoryboardSegue
が対応するものかどうか。そしてもうひとつは、UIViewController
のperformSegueWithIdentifier(_:sender:)
を呼び出すことができるものである。
あとはこのprotocolに準拠するclassを定義すればよく、特にprepare(_:)
メソッドを遷移先のViewControllerに合わせて正しく実装すればよいはずである。しかし実際的にはsegueのsourceViewController
とdestinationViewController
のそれぞれの型を、より具体的なViewControllerの型に束縛したいはずだ。そこで以下のように具体的な型を持てるprotocolを作って元のprotocolを継承させる。
protocol TypedSegueInitiator: SegueInitiator { typealias Source: UIViewController typealias Destination: UIViewController func prepareWithSource(source: Source, destination: Destination) } extension TypedSegueInitiator { static func match(segue: UIStoryboardSegue) -> Bool { return identifier == segue.identifier && segue.sourceViewController is Source && segue.destinationViewController is Destination } func prepare(segue: UIStoryboardSegue) { if let source = segue.sourceViewController as? Source, let destination = segue.destinationViewController as? Destination { prepareWithSource(source, destination: destination) } else { fatalError("Given segue '\(segue.dynamicType) - Identifier: \(segue.identifier ?? ""), Source: \(segue.sourceViewController.dynamicType), Destination: \(segue.destinationViewController.dynamicType)' does not match this SegueInitiator '\(self.dynamicType) - Identifier: \(self.dynamicType.identifier), Source: \(Source.self), Destination: \(Destination.self)'") } } }
ここではtypealias
でSource
とDestination
のふたつのassociated typeを定義した。デフォルト実装でprepare(_:)
を実装し、型が合っているか検証した後に、新たなprepareWithSource(_:destination:)
メソッドを呼び出すことにする。型が合わない場合にはその時点でわかりやすいメッセージと共に実行時エラーを引き起こす。Storyboard上での設定と実装で定義が合わない場合にはもうどうしようもない。
これらを用いることで以下のように書ける。
struct Entry { let title: String } class FromViewController: UIViewController { @IBAction func executeToSegue(sender: AnyObject) { ToSegue(entry: Entry(title: "To Segue")).performFrom(self) } override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) { if let initiator = sender as? SegueInitiator { initiator.prepare(segue) } else { fatalError() } } } class ToViewController: UIViewController { @IBOutlet weak var titleLabel: UILabel! var entry: Entry! override func viewDidLoad() { super.viewDidLoad() titleLabel.text = entry.title } } class ToSegue: TypedSegueInitiator { let entry: Entry init(entry: Entry) { self.entry = entry } static let identifier: String? = "ToSegue" typealias Source = UIViewController typealias Destination = ToViewController func prepareWithSource(source: Source, destination: Destination) { destination.entry = entry } }
最後のToSegue
が、遷移先のViewControllerを実際に初期化する役割を持ったclassである。prepareWithSource(_:destination:)
メソッドの実装はごく単純であり、この段階においては型による安全性が保たれている。この初期化オブジェクトはイニシャライザで初期化に必要なインスタンスを受け取ることにしている。初期化オブジェクトのシグネチャが、初期化に必要な情報を表しているのである。
もちろんこのようにしても、ViewControllerを不適切なやり方で初期化しようとすることはできる。しかし原則的にSegueInitiator
を利用して遷移先の初期化を行うというルールを作り、ViewControllerのコードと同じところにSegueInitiatorのコードを置いておくことで、いくらかの助けにはなるだろう。
まとめ
実際に動作するコードをGitHub上に公開している。この例では、例えばUINavigationController
が間に挟まっているようなケースにも対応したNavigationSegueInitiator
というclassも用意してある。
本記事の内容は筆者が思いつくままに書いてみたという段階であり、実際に試されたことはない。この方法が現実のアプリケーションでうまく働くかは未知数である。
SwiftからUIKitをはじめとしたObjective-C時代のAPIを利用するときに感じるミスマッチは、現段階においては受け入れるしかないものと思われる。この遷移のインターフェースはiOSのUIKitにおいてもOS XのAppKitにおいてもそう変わらない。*1決して悪いとは思わないが、それでもやはりSwiftに期待される安全性からは程遠く感じられる。
次世代のiOSやOS Xではフレームワーク側が進歩して、このような問題が一挙に解決されるかもしれない。そういった期待を持ちつつも、いまのところは様々な工夫を試していくのがよいだろう。
- 作者: 荻原剛志
- 出版社/メーカー: SBクリエイティブ
- 発売日: 2015/12/25
- メディア: 単行本
- この商品を含むブログを見る