Gary BernhardtのBoundariesはお気に入りの講演のひとつです。Swiftにおけるファンクショナルプログラミングの講演を見ていれば聞いたことがあるでしょう。はじめは理論を理解できてもコンセプトが理解できませんでした。Swiftを書いていくうちに、ファンクショナルプログラミングだけでなくエンジニアリング全般に適用できる“Functional Core, Imperative Shell”のコンセプトを理解できるようになりました。
このtry! Swiftの講演では、いくつかの事例を紹介しながらBoundariesの理論によって、安全でよりよい、将来性のあるコードを書く話をします。
Functional & Imperative (0:00)
おはようございます、彩花です。Gary BernhardtのBoundariesの講演をご存知ですか?ファンクショナルプログラミングの講演でよく参照されます。
この講演ではFunctional CoreとImperative Shellのコンセプトが紹介されています。このコンセプトはまずコードの芯(コア)をファンクショナルにすることです。ファンクショナルにすることによって副作用がなくテストしやすいコードを書けます。しかしすべてをファンクショナルにすることはできません。たとえばUIKitを使うと副作用ばかりです。APIを使ったネットワークコードもそうです。
しかしその副作用だらけのコードはImperative Shellに取り出すことができます。イメージするとこのような感じです。ファンクショナルで堅実な芯です。アプリのアーキテクチャを全体的に見てみるとそれがいくつもあります。そしてそのひとつひとつにImperative Shellがあります。何週間か前、私のチームはWWDC 2014のAdvanced iOS Application Architecture and Patternsを見直しました。Andy Matuschakの私が好きなことばを引用します。
「自分の技術より感覚がずっと先に上達していることに気づいていないかもしれない。」
私もその立場にいました。Functional CoreとImperative Shellの理論は理解できたのですが、実際どのようにこのコンセプトを実用化するのかよくわかりませんでした。しかしそれでよかったのです。新しいアイデアを身につけるときはまず感覚を覚えてあとで直感的に使えると思ったときに使えばいいのです。今日はその話をさせていただきます。
実践的 Boundaries (4:00)
毎日少しずつSwiftを書いているうちにこのコンセプトはファンクショナルプログラミングだけではなくエンジニアリング全体的に適用できることがわかってきました。いくつかの事例を紹介しながらBoundariesについてお話しします。
ネットワークサンプル (4:36)
まず、不変な芯とネットワークのような表面の話です。Venmoアプリのニュースフィードは友だちのストーリーをたくさん見れます。好きなストーリーを選択してコメントなどを見れます。それぞれのストーリーはこのようなモデルになっています。
struct Story {
let ID: String
let title: String
let message: String
let sender: User
let recipient: User
let date: NSDate
// ...
}
StoryにはID、title、message、sender、recipient、dateなどがあります。
StoriesViewControllerクラスはstoryのリストを表示します。TableViewControllerでそれを表示するだけです。
class StoriesViewController: UIViewController {
let stories: [Story]
// ...
}
storyをタップすると初期化してStoryDetailViewControllerを表示します。
class StoryDetailViewController: UIViewController {
init(story: Story)
}
テーブルからどれかを選ぶとPushします。
class StoryDetailViewController: UIViewController {
private let titleView: StoryTitleView
private let senderView: AvatarView
private let recipientView:AvatarView
private let dateLabel: DateLabel
init(story: Story) {
titleView = StoryTitleView(story: story)
senderView = AvatarView(user: story.sender)
recipientView = AvatarView(user: story.recipient)
dateLabel = DateLabel(date: story.date)
}
}
titleView、senderView、recipientView、dateLabelがあります。storyを渡して初期化するとこれらのビューが生成され不変なletに設定されます。
すべてoptionalではなく不変でかなりファンクショナルな芯だと思います。
しかしこのあとすぐに新しい機能を追加しなければなりませんでした。url_scheme://stories/12345
のようなURLスキームに対応するプッシュ通知を実装する必要がありました。
StoryDetailViewControllerはこのようになっています。
class StoryDetailViewController: UIViewController {
init(story: Story)
}
ネットワークの問題 (7:10)
ここで問題がありました。さきほどのようにstoryを渡すイニシャライザを使うことはできません。プッシュ通知にstoryがないので初期化できないのです。storyIDしかありません。
そこでまずstoryIDを渡すイニシャライザを追加してみました。
class StoryDetailViewController: UIViewController {
init(story: Story)
init(storyID: String)
}
これでよさそうなので実装してみました。storyを渡すイニシャライザは同じままです。storyIDを渡すイニシャライザはどのように機能するでしょうか?
StoriesViewControllerとは違ってすべてのプロパティを初期化するstoryを持っていません。すべてオプショナルでないのでnilに置き換えれません。ではどうすればよいでしょうか?
viewDidLoad()
でstoryを読み込めそうです。しかし可変でオプショナルにする必要があります。初期化してすべてnilにしてからviewDidLoad()
でロードしてみます。
class StoryDetailViewController: UIViewController {
let storyID: String
private var titleView: StoryTitleView?
private var senderView: AvatarView?
private var recipientView: AvatarView?
private var dateLabel: DateLabel?
init(story: Story) { /* same as before */ }
init(storyID: String) {
self.storyID = storyID
titleView = nil
senderView = nil
recipientView = nil
dateLabel = nil
}
// viewDidLoadでAPIからロード?😰
}
これで動きますが、あまりよくないですね。
これまですべてオプショナルではなく不変でした。そのためこの方法しかありませんでした。今は2の4乗、16通りの方法があります。実際はすべて可変なので数え切れないほどの方法があります。
解決策 (8:31)
さらに考えてみましょう。ネットワークコードをStoryContainerViewController
という親のビューコントローラに取り出します。storyIDを渡して初期化できます。そしてviewDidLoadでstoryIDを保持すればAPIクライアントを使ってロードできます。
成功すればStoryDetailViewControllerを生成してChildViewControllerとして追加できます。エラーが発生すればエラーを表示します。
class StoryContainerViewController: UIViewController {
let storyID: String
init(storyID: String) {
self.storyID = storyID
}
override func viewDidLoad() {
client.showStory(ID: storyID) { result in
switch result {
case .Success(let story):
let viewController = StoryDetailViewController(story: story)
self.addChildViewController(viewController)
self.view.addSubview(viewController.view)
viewController.view.frame = view.bounds
viewController.didMoveToParentViewController(self)
case .Error(let error):
// エラー表示
}
}
}
}
url_scheme://stories/12345
StoryContainerViewController(storyID: "12345")
このようなURLを取得するときは、StoryContainerViewControllerを使えばちゃんと動きます。
プロトコル抽象化 (9:08)
これでstoryは処理できました。ほかのURLを処理する場合、たとえばURLからユーザのプロフィールを表示する場合はどうでしょうか?またはURLからメッセージを表示する場合もありますね。ジェネリックにしたらどうでしょうか?
新たにRemoteContentProviding
プロトコルを定義します。
protocol RemoteContentProviding {
associatedtype Content
func fetchContent(completion: Result<Content, Error> -> Void)
func viewControllerForContent(content: Result<Content, Error>) -> UIViewController
}
このプロトコルはassociatedtypeのContentを持っています。そして2つのfunctionを定義しています。ひとつはContentを取得します。APIリクエストにありそうですね。ふたつめはそのContentをViewControllerへ変換します。そしてジェネリクスのRemoteContentProvidingを持つRemoteContentContainerViewControllerを使います。
class RemoteContentContainerViewController<T: RemoteContentProviding>: UIViewController {
let provider: T
init(provider: T) {
self.remoteContentProvider = remoteContentProvider
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
provider.fetchContent { content in
let viewController = self.provider.viewControllerForContent(content)
self.addChildViewController(viewController)
self.view.addSubview(viewController.view)
viewController.view.frame = view.bounds
viewController.didMoveToParentViewController(self)
}
}
}
providerを使って初期化します。viewDidLoad()
でcontentを取得してviewControllerをChildViewControllerとして追加します。このようにすればうまく処理できます。
StoryProviderを見てみましょう���
struct StoryProvider: RemoteContentProviding {
let ID: String
func fetchContent(completion: Result<Story, Error> -> Void) {
client.showStory(ID: ID, completion: completion)
}
func viewControllerForContent(content: Result<Story, Error>) -> UIViewController {
switch content {
case .Success(let story): return StoryDetailViewController(story: story)
case .Error(_): return ErrorViewController(title: "Could not find story.")
}
}
}
fetchContentでclientを使ってAPIからstoryを取得して表示します。そしてviewControllerForContentでは、成功すればstoryを渡してStoryDetailViewControllerを返します。エラーならたとえばErrorViewControllerを使えばよいでしょう。
このようにURLスキームを処理するのであれば、IDを渡してStoryProviderを生成します。そしてRemoteContentContainerViewControllerにproviderを渡します。
url_scheme://stories/12345
let provider = StoryProvider(ID: "12345")
RemoteContentContainerViewController(provider: provider)
ほかも同様です。ユーザの場合はUserProvider、メッセージの場合はMessageProviderを使います。
状態を保持したネットワークのコードをContainerViewControllerに取り出すことで、DetailViewControllerが可変でオプショナルになる問題を防げます。
コーディネータ (12:00)
次は独立した芯と表面をつなげる話です。私は今サイドプロジェクトとしてオランダ語を勉強するアプリを開発しています。
新しいアプリを開発することによっていろいろな新しいアイデアを試す機会が増えました。そのひとつはコーディネータです。
昨年KhanlouのNSSpainの講演で初めて聞きました。コーディネータはビューコントローラはほかのビューコントローラを知らないという考え方に基づきます。
ではAppDelegateのコードから見ていきましょう。
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private lazy var applicationCoordinator: ApplicationCoordinator = {
return ApplicationCoordinator(window: self.window!)
}()
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
window = UIWindow(frame: UIScreen.mainScreen().bounds)
applicationCoordinator.start()
return true
}
}
AppDelegateが小さいことに驚きましたか?AppDelegateではwindowを渡してApplicationCoordinatorを初期化します。そしてdidFinishLaunchingWithOptions
でwindowを生成してApplicationCoordinatorのstartを呼ぶだけです。
ApplicationCoordinatorを見る前にCoordinatorプロトコルを見てみましょう。
protocol Coordinator {
func start()
}
コーディネータプロトコルを定義する方法はいくつかあります。私が定義したものは最小限必要なstartです。
コーディネータの実装を見てみましょう。
class ApplicationCoordinator: Coordinator {
let window: UIWindow
let rootViewController = UITabBarController()
let wordsNavigationController = UINavigationController()
let phrasesNavigationController = UINavigationController()
let wordsCoordinator: WordsCoordinator
let phrasesCoordinator: phrasesCoordinator
init(window: UIWindow) {
self.window = window
let viewControllers = [wordsNavigationController, phrasesNavigationController]
self.rootViewController.setViewController(viewControllers, animated: false)
self.wordsCoordinator = WordsCoordinator(presenter: wordsNavigationController)
self.phrasesCoordinator = PhrasesCoordinator(presenter: phrasesNavigationController)
}
func start() {
window.rootViewController = rootViewController
wordsCoordinator.start()
phrasesCoordinator.start()
window.makeKeyAndVisible()
}
}
ApplicationCoordinatorはこのようになっています。この例ではUITabBarControllerであるrootViewControllerがあります。そしてふたつのUINavigationController、WordsCoordinatorとPhrasesCoordinatorがあります。
次に、イニシャライザでrootViewControllerを生成します。これはふたつのNavigationControllerを持っています。それぞれのNavigationControllerでコーディネータを初期化します。
startではwindowのrootViewControllerを設定し、2つのコーディネータを動かします。WordsCoordinatorはこのようになっています。
class WordsCoordinator: Coordinator {
let presenter: UINavigationController
private let listViewController: ListViewController<Word>
private let dataSource: WordsDataSource
init(presenter: UINavigationController) {
self.presenter = presenter
self.dataSource = WordsDataSource()
self.listViewController = ListViewController<Word>()
self.listViewController.title = "Words"
self.listViewController.items = dataSource.words
self.listViewController.configureCell = { cell, item in
cell.item = item
}
self.listViewController.didSelectItem = { item in
presenter.pushViewController(WordViewController(word: item), animated: true)
}
}
func start() {
presenter.pushViewController(listViewController, animated: false)
}
}
WordsCoordinatorはNavigationControllerを使って初期化し、presenterを設定します。DataSourceを生成してロードします。またWordsを表示するListViewControllerを設定してtitleを設定します。またitemsをlistViewControllerに設定します。そしてconfigureCellでcellにitemを設定します。
このListViewControllerはobjc.ioのブログを参考にしています。ジェネリックなTableViewControllerやファンクショナルなViewControllerなどすばらしいブログを書いています。これらを実装するにはListViewControllerのdidSelectItem
でpresenterにViewControllerをpushさせます。
この理論はListViewControllerはそれ自身では何もpushしません。コーディネータに問合せます。
startでアニメーションなしでpresenterのpushViewControllerを実行します。ナビゲーションスタックにルートビューを設定するだけです。PhrasesCoordinatorも基本的には同じです。違いはwordsではなくphrasesを表示することだけです。
興味があればobjc.ioのこの記事をぜひ読んでみてください。
コーディネータについてはNSSpainでのSoroushの講演をぜひ見てください。本当にすばらしいものです。Objective-Cでの講演ですがとても参考になります。コーディネータを使うとアプリは木のような形になります。
検索機能を追加したければWordsCoordinatorにSearchCoordinatorを追加するだけです。ログインも簡単です。WordsCoordinatorやPhrasesCoordinatorの上にDictionaryCoordinatorを追加します。ApplicationCoordinatorがDictionaryCoordinatorとLoginCoordinatorを管理します。
サインアップも同じようにApplicationCoordinatorに追加できます。コーディネータを使えば簡単に新しい機能を追加できます。すばらしいですね。
将来性のあるコード (18:43)
抽象化はソフトウェアエンジニアがはじめに習う概念です。抽象化といえばインターフェースがきれいというイメージがありますね。しかしSwiftをたくさん書いているうちにインターフェースで抽象化するだけでは足りないことがわかってきました。
StoryDetailViewを使った最初の例ではstoryIDを渡すイニシャライザを追加するだけでは不十分でした。
さらに見てみるとインターフェースを抽象化するだけでは不十分です。まずアプリのどの部分がファンクショナルで不変、言い換えれば堅実なパターンに適しているのかを把握するのが大切です。
そして同じようにどの部分がネットワークコードのようにimperativeでステートフル、流動的なパターンに適しているかを把握するのが大切です。
流動的であることは必ずしも悪いことではありません。アプリは変化するものです。もっとも難しいのは堅実であることと流動的であることの境界を見つけることだと思います。そのバランスを見つけることでよりよく安全な将来性のあるコードを書けると思います。
今日は何か新しいことを習った、試してみようと思っていただければうれしいです。ありがとうございます。
Q&A (21:52)
Q: Venmoのように既存の大きなコードベースで実装するのは難しかったですか?
野中: コーディネータパターンは新しいコードベースでまず試したのですが、それでよいと感じたのでVenmoのコードベースで使うことにしました。
Q: 基本的なpushとpopのお話でしたが、スタック全体が切り替わるような場合はコーディネータパターンでどのように実装しますか?
野中: そのようなケースは実装したことがありません。コードを見てみたいですね。
Q: コーディネータパターンはVIPERでいうワイヤーフレームに同じようになっています。ストーリーボードやセグエとの関係はどうなりますか?
野中: 私はストーリーボードを使いません。ビューコントローラが全部お互いを知っていて切り離せないようになってしまうからです。
質問者: 遷移のコードを完全に独立させてセグエに取り付けるようなライブラリを作って、完全にビューコントローラからほかのコントローラへの参照をなくしました。
About the content
2017年3月のtry! Swift Tokyoの講演です。映像はRealmによって撮影・録音され、主催者の許可を得て公開しています。