31
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Dependency Injection Framework for Swift - Swinjectを利用したシンプルな天気アプリの例 その2/2

Last updated at Posted at 2015-08-16

以下のブログ記事の翻訳です1
Dependency Injection Framework for Swift - Simple Weather App Example with Swinject Part 2/2


前回のブログ記事では、シンプルな天気アプリのモデル部分を実装し、dependency injection (依存性の注入) とSwinjectを利用して、密結合になった依存性を排除する方法について学びました。密結合だった部分を疎結合にすることにより、ユニットテストが書きやすくなることがわかりました。今回の記事ではアプリのUI部分を実装し、疎結合にしたコンポーネントをSwinjectでどのように接続していくか見ていきます。

このブログ記事で使用するソースコードはGitHubのリポジトリからダウンロードできます。

UIの基本構成

最初に、テーブルビューで天気情報を表示するための基本的なUI構成を作っていきます。UIのコンポーネントはストーリーボードからインスタンス化しますが、ストーリーボード自体のインスタンス化は手で書いて、UIStoryboardを継承したSwinjectStoryboardを使えるようにします。

Info.plistを開いて"Main storyboard file base name"キーを削除してください。もし生のキーを表示している状態であれば、"UIMainStoryboardFile"と表示されているかもしれません。

ViewController.swiftを削除してWeatherTableViewController.swiftを追加してください。その中身は空のWeatherTableViewControllerの定義があるだけとします。後でdependency injectionパターンを用いてこのクラスを実装します。

WeatherTableViewController.swift

import UIKit

class WeatherTableViewController: UITableViewController {
}

Main.storyboardを開いて、デフォルトで作成されているビューコントローラを削除してください。その後、オブジェクトライブラリから新しくナビゲーションコントローラをストーリーボードに追加してください。

そのナビゲーションコントローラを選択し、アトリビュートインスペクタで"Is Initial View Controller"をチェックしてください。

ナビゲーションコントローラのルートビューコントローラとなっているテーブルビューコントローラを選択し、そのカスタムクラスをWeatherTableViewControllerに設定してください。そのテーブルビューのプロトタイプセルを選択し、そのスタイルをRight Detailに変更し、Identifierを"Cell"に設定してください。テーブルビューコントローラのナビゲーションアイテムを選択し、そのタイトルを"Weather Now"に設定してください。

SwinjectSimpleExample Storyboard Screenshot

AppDelegate.swiftを修正し、イニシャルビューコントローラをストーリーボードから手動でインスタンス化するように書き換えます。ここで、UIStoryboardの代わりにSwinjectStoryboardを使用し、後でdependency injectionを追加できるようにしておきます。SwinjectStoryboardのインスタンス化にはイニシャライザを使わず、create関数を使用します2

AppDelegate.swift

import UIKit
import Swinject

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(
        application: UIApplication,
        didFinishLaunchingWithOptions
        launchOptions: [NSObject: AnyObject]?) -> Bool
    {
        let window = UIWindow(frame: UIScreen.mainScreen().bounds)
        window.backgroundColor = UIColor.whiteColor()
        window.makeKeyAndVisible()
        self.window = window

        let storyboard = SwinjectStoryboard.create(name: "Main", bundle: nil)
        window.rootViewController = storyboard.instantiateInitialViewController()

        return true
    }

    ...
}

これでアプリを実行する準備ができました。Command-Rを入力して実行しましょう。下の画像のような空のテーブルビューが表示されると思います。

SwinjectSimpleExampleEmptyScreenshot.png

ビューコントローラへのDependency Injection

それでは、空っぽだったテーブルビューコントローラを実装し、dependency injectionを追加しましょう。

WeatherTableViewControllerweatherFetcherプロパティを追加します。

WeatherTableViewController.swift

class WeatherTableViewController: UITableViewController {
    var weatherFetcher: WeatherFetcher?
}

AppDelegateを修正し、設定を済ませたContainerSwinjectStoryboardに渡してインスタンス化するようにしましょう。

AppDelegate.swift

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(
        application: UIApplication,
        didFinishLaunchingWithOptions
        launchOptions: [NSObject: AnyObject]?) -> Bool
    {
        let window = UIWindow(frame: UIScreen.mainScreen().bounds)
        window.backgroundColor = UIColor.whiteColor()
        window.makeKeyAndVisible()
        self.window = window

        let container = createContainer()
        let storyboard = SwinjectStoryboard.create(
            name: "Main",
            bundle: nil,
            container: container)
        window.rootViewController = storyboard.instantiateInitialViewController()

        return true
    }

    private func createContainer() -> Container {
        let container = Container()
        container.registerForStoryboard(WeatherTableViewController.self) { r, c in
            c.weatherFetcher = r.resolve(WeatherFetcher.self)
        }
        container.register(Networking.self) { _ in Network() }
        container.register(WeatherFetcher.self) { r in
            WeatherFetcher(networking: r.resolve(Networking.self)!)
        }
        return container
    }

    ...
}

createContainerメソッドの中で、最初にContainerのインスタンスを生成し、次にその設定をしています。ビューコントローラの依存関係の設定のため、registerForStoryboardを使用しています。ここでは、依存関係が解決されたWeatherFetcherのインスタンスでweatherFetcherプロパティがセットされるように設定しています。この方法はプロパティ注入 (property injection) と呼ばれます。前回のブログ記事で定義したNetworkingプロトコルは、Alamofireをカプセル化しているNetworkのインスタンスになるように設定しています。WeatherFetcherは、Networkingの該当するインスタンスをイニシャライズ時に受け取るように設定しています。これはイニシャライザ注入 (initializer injection) と呼ばれます。最後に、createContainerメソッドは設定を済ませたcontainerを返しています。

application:didFinishLaunchingWithOptions:メソッドの中で、上記のように設定したcontainerSwinjectStoryboardのイニシャライザに渡しています。以上です。シンプルな使い方ですね。Containerを設定して渡すだけでいいのです。

それでは次に、WeatherTableViewControllerの実装に取り掛かりましょう。

WeatherTableViewController.swift

class WeatherTableViewController: UITableViewController {
    var weatherFetcher: WeatherFetcher?

    private var cities = [City]() {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)

        weatherFetcher?.fetch {
            if let cities = $0 {
                self.cities = cities
            }
            else {
                // Show an error message.
            }
        }
    }

    // MARK: UITableViewDataSource
    override func tableView(
        tableView: UITableView,
        numberOfRowsInSection section: Int) -> Int
    {
        return cities.count
    }

    override func tableView(
        tableView: UITableView,
        cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell
    {
        let cell = tableView.dequeueReusableCellWithIdentifier(
            "Cell", forIndexPath: indexPath)
        let city = cities[indexPath.row]
        cell.textLabel?.text = city.name
        cell.detailTextLabel?.text = city.weather
        return cell
    }
}

最初に、citiesプロパティを追加し、Cityの空配列で初期化しています。そのプロパティをセットした時にテーブルビューが更新されるようにしてあります。

次に、viewWillAppearをオーバーライドし、fetchメソッドで天気情報の取得を開始するようにしています3fetchは引数としてクロージャをとり、天気情報を取得できた時にCityの配列を渡してそのクロージャを実行します。そのクロージャの中では、self.citiesがセットされ、その結果テーブルビューが更新されます。fetchが失敗した場合には、nilをクロージャに渡します。このブログ記事ではエラー処理は省略してありますが、GitHubのレポジトリにあるソースコードではエラーメッセージを表示するように実装してありますのでご確認ください。

最後に、tableView:numberOfRowsInSection:を実装して行数を返し、tableView:cellForRowAtIndexPath:を実装してセルに都市名と天気をセットするようにしています。

これでUIの実装が完了しました。アプリを実行してみましょう。現在の天気情報がテーブルビューに表示されましたね。

SwinjectSimpleExampleScreenshot.png

ビューコントローラのテスト

ここまででアプリが動作することを見ましたが、WeatherTableViewControllerのユニットテストを追加させてください。ビューが表示されるタイミングでビューコントローラが天気情報を取得し始めるか確認します。このテストでモックのコンセプトが分かります。

SwinjectSimpleExampleTestsに以下の内容のWeatherTableViewControllerSpec.swiftを追加してください。

WeatherTableViewControllerSpec.swift

import Quick
import Nimble
import Swinject
@testable import SwinjectSimpleExample

class WeatherTableViewControllerSpec: QuickSpec {
    class MockNetwork: Networking {
        var requestCount = 0

        func request(response: NSData? -> ()) {
            requestCount++
        }
    }

    override func spec() {
        var container: Container!
        beforeEach {
            container = Container()
            container.register(Networking.self) { _ in MockNetwork() }
                .inObjectScope(.Container)
            container.register(WeatherFetcher.self) { r in
                WeatherFetcher(networking: r.resolve(Networking.self)!)
            }
            container.register(WeatherTableViewController.self) { r in
                let controller = WeatherTableViewController()
                controller.weatherFetcher = r.resolve(WeatherFetcher.self)
                return controller
            }
        }

        it("starts fetching weather information when the view is about appearing.") {
            let network = container.resolve(Networking.self) as! MockNetwork
            let controller = container.resolve(WeatherTableViewController.self)!

            expect(network.requestCount) == 0
            controller.viewWillAppear(true)
            expect(network.requestCount).toEventually(equal(1))
        }
    }
}

最初に、NetworkingのモックとしてMockNetworkを定義しています。requestメソッドを実装していますが、レスポンスは返していません。その代わり、requestCountという名前のカウンタをインクリメントしています。モックは、あるインスタンスのメソッドやプロパティが正しく呼ばれているか確認するために使われます。スタブのようにダミーのデータを返すこともできますが、メソッドやプロパティの呼び出しをチェックできるところがスタブと異なります。

specについては、まず先にitの中を見てみましょう。MockNetworkWeatherTableViewControllerのインスタンスを設定済みのcontainerから取得しています。NetworkingMockNetworkに結び付けられていることをプログラマの私達は知っているので、containerから返ってきたインスタンスをMockNetworkにキャストしています。次に、ビューコントローラのviewWillAppearが呼ばれた後で、モックのrequestメソッドが1回だけ呼び出されることをrequestCountカウンタによってチェックしています。WeatherTableViewControllerNetworkingのインスタンスを直接保持しているわけではありませんが、モックのメソッド呼び出しを確認することにより、関連するインスタンスが正しく接続されていることが確認できます。

それでは、containerの設定部分に戻りましょう。1番目に、NetworkingMockNetworkに決定されるよう設定し、containerの中でそのインスタンスが共有されるように設定しています。このようにオブジェクトスコープを設定することにより、カウンタをチェックするためのMockNetworkインスタンスが、WeatherTableViewControllerによって間接的に保持されているインスタンスと同一になることを保証できます。2番目に、WeatherFetcherの依存性をイニシャライザ注入するように設定しています。3番目に、WeatherTableViewControllerの依存性をプロパティ注入するように設定しています。

それではユニットテストを実行してみましょう。パスしましたね。たとえばの話ですが、この天気アプリの開発を続け、機能をどんどん追加していくと考えてみてください。このユニットテストがあれば、UIとモデルの接続を誤って壊してしまう心配がなくなります。

まとめ

シンプルな天気アプリのUI部分を実装し、dependency injectionのコンテナを使ってどのようにコンポーネントを結びつけていくか学びました。SwinjectStoryboardがあると、ストーリーボードで定義されたビューコントローラに依存性を簡単に注入できることがわかりました。最後に、モックを利用して、依存関係の末端にあるインスタンスのメソッド呼び出しをチェックすることにより、意図したとおりに各コンポーネントが接続されていると確認できることがわかりました。

次回の記事では、MVVMアーキテクチャを用いてより大きな例題アプリを開発します。現在人気でエレガントなSwiftのリアクティブプログラミングフレームワークであるReactiveCococaを使用します。もちろん、dependency injectionとSwinjectを活用してMVVMの各コンポーネントを疎結合なまま結びつけていきます。

  1. 訳注: 英語版の著者本人による翻訳のため、翻訳に関わる著作権上の問題はありません。

  2. SwinjectStoryboardのインスタンス化は少し癖があります。これは、UIStoryboardが通常の指定イニシャライザを持っておらず、子クラスがオーバーライドできないためです。

  3. この例題のアプリでは、viewWillAppearの中でしかfetchを実行していないですが、実際のアプリでは、天気情報を更新するためのボタンなどがあるといいでしょう。

31
32
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?