LoginSignup
25
23

More than 5 years have passed since last update.

Dependency Injection in MVVM Architecture with ReactiveCocoa Part 3: モデルの設計

Last updated at Posted at 2016-01-07

前回Part 2の翻訳から間が空いてしまいましたが、ReactiveCocoa v4.0 RC1がリリースされてAPIが安定し、英語記事の更新も落ち着いたので翻訳を再開します。Part 3は以下のブログ記事の翻訳です1

Dependency Injection in MVVM Architecture with ReactiveCocoa Part 3: Designing the Model


前回のブログ記事では、Model、View、ViewModelの各フレームワークからなるアプリを開発するために、Xcodeのプロジェクトを設定しました。今回のブログ記事では、MVVMアーキテクチャのModel部分を開発します。エンティティとサービスで構成されるModelを設計し、dependency injectionを行う方法について学びます。MVVMアーキテクチャにより、システムを疎結合にすることが特徴です。ReactiveCocoaを使用して、MVVMアーキテクチャのModel、View、ViewModelを疎結合にするために不可欠なイベントハンドリングを行います。また、JSONからSwiftの型へマッピングを行うHimotokiの使い方について学びます。

ソースコードはGitHubのリポジトリからダウンロードできます。

SwinjectMVVMExample ScreenRecord

Himotoki

HimotokiはSwiftで書かれたタイプセーフなJSONデコード専用ライブラリです。JSONからSwiftの型へのマッピングをDecodableプロトコルとして簡潔に記述できます。特に、イミュータブルな (let) プロパティへのマッピングをサポートしているところが特徴です。

使い方は簡単です。ここでは、{ "some_name": "Himotoki", "some_value": 1 }のようなJSONをSomeValue型にマッピングするとします。マッピングの記述のため、SomeValue型をDecodableプロトコルに準拠させます。

struct SomeValue {
    let name: String
    let value: Int
}

extension SomeValue: Decodable {
    static func decode(e: Extractor) throws -> Group {
        return try SomeValue(
            name: e <| "some_name",
            value: e <| "some_value"
        )
    }
}

decode関数の中で、SomeValueのイニシャライザの引数としてマッピングを表現しています。ここでは、some_nameSomeValuenameプロパティへ、some_valuevalueプロパティへマッピングしています。

JSONデータからマッピングしたインスタンスを取得するためには、AlamofireやNSJSONSerializationから返される
[String: AnyObject]型のJSONデータを引数としてdecode関数を呼びます。

func testSomeValue() {
    // JSON data returned from Alamofire or NSJSONSerialization.
    let json: [String: AnyObject] = ["some_name": "Himotoki", "some_value": 1]

    let v: SomeValue? = try? decode(json)
    XCTAssert(v != nil)
    XCTAssert(v?.name == "Himotoki")
    XCTAssert(v?.value == 1)
}

Himotokiを利用することにより、JSONデータを扱うためのコードを減らすことができます2。Himotokiはネストされたデータやオプショナルパラメータもサポートしています。詳しくはプロジェクトのページを参照してください。

Pixabay APIの仕様

Pixabay APIのドキュメントによると、Pixabayのサーバから返ってくるJSONデータは以下ようなフォーマットになっています。画像情報の配列をhits、見つかった画像の総数をtotal、APIで利用可能な画像の総数をtotalHitsとして取り出すことができます。

{
    "total": 12274,
    "totalHits": 240,
    "hits": [
        {
            "id": 11574,
            "pageURL": "https://pixabay.com/en/sonnenblumen-sonnenblumenfeld-flora-11574/",
            "type": "photo",
            "tags": "sunflower, sunflower field, flora",
            "previewURL": "https://pixabay.com/static/uploads/photo/2012/01/07/21/56/sunflower-11574_150.jpg",
            "previewWidth": 150,
            "previewHeight": 92,
            "webformatURL": "https://pixabay.com/get/3b4f5d71752e6ce9cbcf/1356479243/aca42219d23fd9fe0cc6f1cc_640.jpg",
            "webformatWidth": 640,
            "webformatHeight": 396,
            "imageWidth": 1280,
            "imageHeight": 792,
            "views": 10928,
            "downloads": 1649,
            "likes": 70,
            "user": "WikiImages"
        },
        {
            "id": "256",
            "pageURL": "https://pixabay.com/en/example-image-256/",
            "type": "photo",
            // ... etc.
        },
        //... 18 more hits for page number 1
    ]
}

Modelの設計の概要

今回の設計では、Modelをエンティティとサービスで構成します。簡単に言うと、エンティティは対象となるシステムに存在する概念あるいはオブジェクトです3。サービスは状態のないオペレーションで、エンティティには収まらないものです。

ViewModelとModel、そしてModelと外部システムを疎結合にするため、それらのインターフェイスをプロトコルとして定義します。下の図でImageSearchingNetworkingがプロトコルです。ImageSearchNetworkはそれらの実装です。ViewModelはImageSearchingプロトコルを通してModelにアクセスし、そのプロトコルの実装であるImageSearchNetworkingプロトコルを通して外部のシステムにアクセスします。外部システムがJSONデータを持つイベントを発生させ、そのイベントがViewModelへと伝播していく時にImageSearchによってデータがResponseEntityImageEntityに変換されます。

Model Designg

エンティティ

この節では、Pixabayから返される画像とレスポンスを表すエンティティを定義していきます。ExampleModelターゲットに以下の内容のImageEntity.swiftを追加してください。Project Navigator上のExampleModelグループ (フォルダアイコン) を右クリックしてNew File...を選択し、Swift Fileを選択すれば、ExampleModelターゲットにファイルを追加できます。Xcodeがどのターゲットにファイルを追加するか聞いてきた時にはExampleModelのみチェックしておいてください。

ImageEntity.swift

import Himotoki

public struct ImageEntity {
    public let id: UInt64

    public let pageURL: String
    public let pageImageWidth: Int
    public let pageImageHeight: Int

    public let previewURL: String
    public let previewWidth: Int
    public let previewHeight: Int

    public let imageURL: String
    public let imageWidth: Int
    public let imageHeight: Int

    public let viewCount: Int64
    public let downloadCount: Int64
    public let likeCount: Int64
    public let tags: [String]
    public let username: String
}

// MARK: Decodable
extension ImageEntity: Decodable {
    public static func decode(e: Extractor) throws -> ImageEntity {
        let splitCSV: String -> [String] = { csv in
            csv.characters
                .split { $0 == "," }
                .map {
                    String($0).stringByTrimmingCharactersInSet(
                        NSCharacterSet.whitespaceCharacterSet())
                }
        }

        return try ImageEntity(
            id: e <| "id",

            pageURL: e <| "pageURL",
            pageImageWidth: e <| "imageWidth",
            pageImageHeight: e <| "imageHeight",

            previewURL: e <| "previewURL",
            previewWidth: e <| "previewWidth",
            previewHeight: e <| "previewHeight",

            imageURL: e <| "webformatURL",
            imageWidth: e <| "webformatWidth",
            imageHeight: e <| "webformatHeight",

            viewCount: e <| "views",
            downloadCount: e <| "downloads",
            likeCount: e <| "likes",
            tags: (try? e <| "tags").map(splitCSV) ?? [],
            username: e <| "user"
        )
    }
}

ImageEntityがイミュータブルなプロパティを持つstructとして定義してあることに注意してください。イミュータブルであることにより、エンティティを使用する際の安全性を確保できます4ExampleViewModelターゲットから参照できるよう、エンティティの型はpublicで定義してあります。

ImageEntityには、Swiftの命名慣習とアプリの構成に合わせ、JSONの要素と異なる名前にしたプロパティもあります。idviewCountdownloadCountlikeCountは、32-bitシステムでも大きな値を扱えるよう、UInt64Int64で定義してあります。JSONの要素tagsCSV形式の文字列となっていますが、ImageEntityでは(try? e <| "tags").map(splitCSV)により分割した文字列の配列になっています。?? []mapの返り値に適用することにより、JSONデータにtagsがなくてnilが返ってきた場合は代わりに空配列が使用されます。

ExampleModelターゲットに以下の内容のResponseEntity.swiftを追加してください。ここでは、配列をマッピングするために<||演算子を使用しています。JSONのtotal要素は無視しています。もし後で必要になったら追加すればよいでしょう。

ResponseEntity.swift

import Himotoki

public struct ResponseEntity {
    public let totalCount: Int64
    public let images: [ImageEntity]
}

// MARK: Decodable
extension ResponseEntity: Decodable {
    public static func decode(e: Extractor) throws -> ResponseEntity {
        return try ResponseEntity(
            totalCount: e <| "totalHits",
            images: e <|| "hits"
        )
    }
}

ImageEntityをテストするため、ExampleModelTestsターゲットに以下の内容のDummy.swiftImageEntitySpec.swiftを追加してください。

Dummy.swift

let imageJSON: [String: AnyObject] = [
    "id": 12345,
    "pageURL": "https://somewhere.com/page/",
    "imageWidth": 2000,
    "imageHeight": 1000,
    "previewURL": "https://somewhere.com/preview.jpg",
    "previewWidth": 200,
    "previewHeight": 100,
    "webformatURL": "https://somewhere.com/image.jpg",
    "webformatWidth": 600,
    "webformatHeight": 300,
    "views": 54321,
    "downloads": 4321,
    "likes": 321,
    "tags": "a, b c, d ",
    "user": "Swinject"
]

ImageEntitySpec.swift

import Quick
import Nimble
import Himotoki
@testable import ExampleModel

class ImageEntitySpec: QuickSpec {
    override func spec() {
        it("parses JSON data to create a new instance.") {
            let image: ImageEntity? = try? decode(imageJSON)

            expect(image).notTo(beNil())
            expect(image?.id) == 12345
            expect(image?.pageURL) == "https://somewhere.com/page/"
            expect(image?.pageImageWidth) == 2000
            expect(image?.pageImageHeight) == 1000
            expect(image?.previewURL) == "https://somewhere.com/preview.jpg"
            expect(image?.previewWidth) == 200
            expect(image?.previewHeight) == 100
            expect(image?.imageURL) == "https://somewhere.com/image.jpg"
            expect(image?.imageWidth) == 600
            expect(image?.imageHeight) == 300
            expect(image?.viewCount) == 54321
            expect(image?.downloadCount) == 4321
            expect(image?.likeCount) == 321
            expect(image?.tags) == ["a", "b c", "d"]
            expect(image?.username) == "Swinject"
        }
        it("gets an empty array if tags element is nil.") {
            var missingJSON = imageJSON
            missingJSON["tags"] = nil
            let image: ImageEntity? = try? decode(missingJSON)

            expect(image).notTo(beNil())
            expect(image?.tags.isEmpty).to(beTrue())
        }
        it("throws an error if any of JSON elements except tags is missing.") {
            for key in imageJSON.keys where key != "tags" {
                var missingJSON = imageJSON
                missingJSON[key] = nil
                let image: ImageEntity? = try? decode(missingJSON)

                expect(image).to(beNil())
            }
        }
        it("ignores an extra JSON element.") {
            var extraJSON = imageJSON
            extraJSON["extraKey"] = "extra element"
            let image: ImageEntity? = try? decode(extraJSON)

            expect(image).notTo(beNil())
        }
    }
}

Dummy.swiftの中では、ダミーのJSONデータのインスタンスをimageJSONとして定義しています。AlamofireやNSJSONSerializationから返ってくるような[String: AnyObject]型のJSONデータをHimotokiは扱うため、imageJSONStringではなく[String: AnyObject]として定義しています。

it("parses JSON data to create a new instance.")では、ImageEntityのすべてのプロパティがJSONからマッピングされていることを確認しています。

it("gets an empty array if tags element is nil.")では、JSONデータにtags要素が含まれない場合に、tagsプロパティに空配列がセットされることを確認しています。

it("throws an error if any of JSON elements except tags is missing.")では、tags以外のJSON要素が欠けている場合にdecode関数がエラーを投げることを確認しています。不正な、あるいは壊れたJSONデータはtry?nilが返ってくるか見ることにより確認できます。

it("ignores an extra JSON element.")では、JSONデータに余分な要素があってもdecode関数はImageEntityのインスタンスを返すことを確認しています。余分な要素を無視することにより、将来のPixabay APIの変更に対してJSONデータのマッピングが安全になります。

それでは次に、ExampleModelTestsターゲットに以下の内容のResponseEntitySpec.swiftを追加してください。テストの内容は見たとおり簡単です。

ResponseEntitySpec.swift

import Quick
import Nimble
import Himotoki
@testable import ExampleModel

class ResponseEntitySpec: QuickSpec {
    override func spec() {
        let json: [String: AnyObject] = [
            "totalHits": 123,
            "hits": [imageJSON, imageJSON]
        ]

        it("parses JSON data to create a new instance.") {
            let response: ResponseEntity? = try? decode(json)

            expect(response).notTo(beNil())
            expect(response?.totalCount) == 123
            expect(response?.images.count) == 2
        }
    }
}

Command-Uを入力してユニットテストを実行しましょう。パスしましたね。ここでは問題なくテストがパスしたと思いますが、実際の開発ではテストがパスするまでエンティティやテストの修正を繰り返すことになります。

ネットワークサービス

シンプルな天気アプリの例の時と同様に、Networkingに適合するNetwork型にAlamofireをカプセル化します。今回は、ReactiveCocoaを使ってネットワークのイベントをハンドリングします。ExampleModelターゲットに以下の内容のNetworking.swiftNetwork.swiftを追加してください。

Networking.swift

import ReactiveCocoa

public protocol Networking {
    func requestJSON(url: String, parameters: [String : AnyObject]?)
        -> SignalProducer<AnyObject, NetworkError>
}

Network.swift

import ReactiveCocoa
import Alamofire

public final class Network: Networking {
    private let queue = dispatch_queue_create(
        "SwinjectMMVMExample.ExampleModel.Network.Queue",
        DISPATCH_QUEUE_SERIAL)

    public init() { }

    public func requestJSON(url: String, parameters: [String : AnyObject]?)
        -> SignalProducer<AnyObject, NetworkError>
    {
        return SignalProducer { observer, disposable in
            let serializer = Alamofire.Request.JSONResponseSerializer()
            Alamofire.request(.GET, url, parameters: parameters)
                .response(queue: self.queue, responseSerializer: serializer) {
                    response in
                    switch response.result {
                    case .Success(let value):
                        observer.sendNext(value)
                        observer.sendCompleted()
                    case .Failure(let error):
                        observer.sendFailed(NetworkError(error: error))
                    }
                }
        }
    }
}

天気アプリの例では、ネットワークサービスはコールバックを用いてレスポンスを受け渡していました。今回は、requestJSONメソッドがSignalProducer<AnyObject, NetworkError>を返し、それによりオブザーバにイベントを伝えるようにしています。Alamofire (あるいはNSJSONSerialization) がJSONデータをAnyObject型 (実体は配列または辞書) として返すため、SignalProducerはジェネリック型としてAnyObjectをとっています。

requestJSONメソッドは、トレイリングクロージャを使ってSignalProducerのインスタンスを生成して返しています。そのクロージャの中では、sendNextsendCompletedsendFailedを呼び出して、AlamofireのレスポンスのイベントからReactiveCocoaのイベントに変換しています。SignalProducerインスタンスのstartメソッドが呼ばれるまでは、インスタンス生成時に渡されたクロージャは実際には実行されないことに注意してください。

デフォルトではAlamofireはレスポンスをメインスレッド (メインキュー) で返すため、ディスパッチキューをAlamofireに渡してバックグラウンドでレスポンスを返すようにしています。

sendFailedメソッドはNetworkErrorのインスタンスを引数にとります。ExampleModelターゲットに以下の内容のNetworkError.swiftを追加してください。Alamofireから送られてくるNSErrorをアプリ固有のエラー型に変換しています5

NetworkError.swift

import Foundation

public enum NetworkError: ErrorType {
    /// Unknown or not supported error.
    case Unknown

    /// Not connected to the internet.
    case NotConnectedToInternet

    /// International data roaming turned off.
    case InternationalRoamingOff

    /// Cannot reach the server.
    case NotReachedServer

    /// Connection is lost.
    case ConnectionLost

    /// Incorrect data returned from the server.
    case IncorrectDataReturned

    internal init(error: NSError) {
        if error.domain == NSURLErrorDomain {
            switch error.code {
            case NSURLErrorUnknown:
                self = .Unknown
            case NSURLErrorCancelled:
                self = .Unknown // Cancellation is not used in this project.
            case NSURLErrorBadURL:
                self = .IncorrectDataReturned // Because it is caused by a bad URL returned in a JSON response from the server.
            case NSURLErrorTimedOut:
                self = .NotReachedServer
            case NSURLErrorUnsupportedURL:
                self = .IncorrectDataReturned
            case NSURLErrorCannotFindHost, NSURLErrorCannotConnectToHost:
                self = .NotReachedServer
            case NSURLErrorDataLengthExceedsMaximum:
                self = .IncorrectDataReturned
            case NSURLErrorNetworkConnectionLost:
                self = .ConnectionLost
            case NSURLErrorDNSLookupFailed:
                self = .NotReachedServer
            case NSURLErrorHTTPTooManyRedirects:
                self = .Unknown
            case NSURLErrorResourceUnavailable:
                self = .IncorrectDataReturned
            case NSURLErrorNotConnectedToInternet:
                self = .NotConnectedToInternet
            case NSURLErrorRedirectToNonExistentLocation, NSURLErrorBadServerResponse:
                self = .IncorrectDataReturned
            case NSURLErrorUserCancelledAuthentication, NSURLErrorUserAuthenticationRequired:
                self = .Unknown
            case NSURLErrorZeroByteResource, NSURLErrorCannotDecodeRawData, NSURLErrorCannotDecodeContentData:
                self = .IncorrectDataReturned
            case NSURLErrorCannotParseResponse:
                self = .IncorrectDataReturned
            case NSURLErrorInternationalRoamingOff:
                self = .InternationalRoamingOff
            case NSURLErrorCallIsActive, NSURLErrorDataNotAllowed, NSURLErrorRequestBodyStreamExhausted:
                self = .Unknown
            case NSURLErrorFileDoesNotExist, NSURLErrorFileIsDirectory:
                self = .IncorrectDataReturned
            case
            NSURLErrorNoPermissionsToReadFile,
            NSURLErrorSecureConnectionFailed,
            NSURLErrorServerCertificateHasBadDate,
            NSURLErrorServerCertificateUntrusted,
            NSURLErrorServerCertificateHasUnknownRoot,
            NSURLErrorServerCertificateNotYetValid,
            NSURLErrorClientCertificateRejected,
            NSURLErrorClientCertificateRequired,
            NSURLErrorCannotLoadFromNetwork,
            NSURLErrorCannotCreateFile,
            NSURLErrorCannotOpenFile,
            NSURLErrorCannotCloseFile,
            NSURLErrorCannotWriteToFile,
            NSURLErrorCannotRemoveFile,
            NSURLErrorCannotMoveFile,
            NSURLErrorDownloadDecodingFailedMidStream,
            NSURLErrorDownloadDecodingFailedToComplete:
                self = .Unknown
            default:
                self = .Unknown
            }
        }
        else {
            self = .Unknown
        }
    }
}

それではNetwork型のユニットテストを追加しましょう。ExampleModelTestsターゲットに以下の内容のNetworkSpec.swiftを追加してください。

NetworkSpec.swift

import Quick
import Nimble
@testable import ExampleModel

class NetworkSpec: QuickSpec {
    override func spec() {
        var network: Network!
        beforeEach {
            network = Network()
        }

        describe("JSON") {
            it("eventually gets JSON data as specified with parameters.") {
                var json: [String: AnyObject]? = nil
                let url = "https://httpbin.org/get"
                network.requestJSON(url, parameters: ["a": "b", "x": "y"])
                    .on(next: { json = $0 as? [String: AnyObject] })
                    .start()

                expect(json).toEventuallyNot(beNil(), timeout: 5)
                expect((json?["args"] as? [String: AnyObject])?["a"] as? String)
                    .toEventually(equal("b"), timeout: 5)
                expect((json?["args"] as? [String: AnyObject])?["x"] as? String)
                    .toEventually(equal("y"), timeout: 5)
            }
            it("eventually gets an error if the network has a problem.") {
                var error: NetworkError? = nil
                let url = "https://not.existing.server.comm/get"
                network.requestJSON(url, parameters: ["a": "b", "x": "y"])
                    .on(failed: { error = $0 })
                    .start()

                expect(error)
                    .toEventually(equal(NetworkError.NotReachedServer), timeout: 5)
            }
        }
    }
}

ここでは、ネットワークをテストするための簡単で安定したサーバとしてhttpbin.orgを使用しています。そのサーバはAlamofireのユニットテストでも使用されています。試しにブラウザで https://httpbin.org/get?a=b&x=y にアクセスしてみると、どのようにレスポンスが返ってくるかわかります。

最初のテストでは、リクエストで指定したようにレスポンスのJSONに"a"および"x"要素があり、それぞれ値が"b"および"y"となっていることを確認しています。json変数を使用して、サーバからのレスポンスを非同期に受け取っています。requestJSONから返されるSignalProducerにオブザーバ (つまりイベントハンドラ) を追加するため、json変数にレスポンスをセットするクロージャを引数としてonメソッドを呼んでいます。その後、startメソッドによりSignalProducerのシグナルを開始しています。レスポンスはtoEventuallyメソッドにより非同期でチェックされています。

2番目のテストでは、エラーの場合にNetworkErrorが発生することを確認しています。擬似的にエラーを発生させるため、存在しないURLをrequestJSONに渡しています。

ユニットテストを実行し、次の節に移りましょう。

画像検索サービス

この節では、Pixabay APIを利用して画像を検索するサービスを定義していきます。ここがExampleModelの中心です。

最初に、ExampleModelターゲットに以下の内容のPixabay.swiftを追加してください。APIのURLとパラメータを定義しています。apiKeyにはPixabayから取得したご自身のAPIキーを指定してください。

Pixabay.swift

internal struct Pixabay {
    internal static let apiURL = "https://pixabay.com/api/"

    internal static var requestParameters: [String: AnyObject] {
        return [
            "key": Config.apiKey,
            "image_type": "photo",
            "safesearch": true,
            "per_page": 50,
        ]
    }
}

extension Pixabay {
    private struct Config {
        private static let apiKey = "" // Fill with your own API key.
    }
}

次に、ExampleModelターゲットに以下の内容のImageSearching.swiftを追加してください。ResponseEntitySignalProducerを返すsearchImagesメソッドを持つプロトコルを定義しています。

ImageSearching.swift

import ReactiveCocoa

public protocol ImageSearching {
    func searchImages() -> SignalProducer<ResponseEntity, NetworkError>
}

最後に、ExampleModelターゲットに以下の内容のImageSearch.swiftを追加してください。

ImageSearch.swift

import ReactiveCocoa
import Result
import Himotoki

public final class ImageSearch: ImageSearching {
    private let network: Networking

    public init(network: Networking) {
        self.network = network
    }

    public func searchImages() -> SignalProducer<ResponseEntity, NetworkError> {
        let url = Pixabay.apiURL
        let parameters = Pixabay.requestParameters
        return network.requestJSON(url, parameters: parameters)
            .attemptMap { json in
                if let response = (try? decode(json)) as ResponseEntity? {
                    return Result(value: response)
                }
                else {
                    return Result(error: .IncorrectDataReturned)
                }
        }
    }
}

ImageSearchNetworkingに依存しており、その依存性はイニシャライザで注入されています (Initializer Injectionパターン)。

searchImagesメソッドは、network.requestJSONから返されるSignalProducer<AnyObject, NetworkError>SignalProducer<ResponseEntity, NetworkError>に変換しています。SignalProducerの変換でattemptMapを使っています。attemptMapに渡したクロージャの中では、decodeを呼んでJSONデータをResponseEntityインスタンスにマッピングしています。マッピングが成功した場合、ResponseEntityインスタンスをResult(value: response)で包んで返しています。失敗した場合6Result(error: .IncorrectDataReturned)としてエラーを返しています。もしある値をエラーでなく別の値に変換するだけであれば、SignalProducermapメソッドが利用できます。

(try? decode(json)) as ResponseEntity?のキャストは見慣れないかもしれませんが、decode関数がResponseEntity型を扱うようSwiftコンパイラの型推論を補助しているものです。もしキャストが(try? decode(json)) as? ResponseEntityであれば、ソースコードをコンパイルすることができません。

それではユニットテストを書きましょう。ExampleModelTestsターゲットに以下の内容のImageSearchSpec.swiftを追加してください。

ImageSearchSpec.swift

import Quick
import Nimble
import ReactiveCocoa
@testable import ExampleModel

class ImageSearchSpec: QuickSpec {
    // MARK: Stub
    class GoodStubNetwork: Networking {
        func requestJSON(url: String, parameters: [String : AnyObject]?)
            -> SignalProducer<AnyObject, NetworkError>
        {
            var imageJSON0 = imageJSON
            imageJSON0["id"] = 0
            var imageJSON1 = imageJSON
            imageJSON1["id"] = 1
            let json: [String: AnyObject] = [
                "totalHits": 123,
                "hits": [imageJSON0, imageJSON1]
            ]

            return SignalProducer { observer, disposable in
                observer.sendNext(json)
                observer.sendCompleted()
            }.observeOn(QueueScheduler())
        }
    }

    class BadStubNetwork: Networking {
        func requestJSON(url: String, parameters: [String : AnyObject]?)
            -> SignalProducer<AnyObject, NetworkError>
        {
            let json = [String: AnyObject]()

            return SignalProducer { observer, disposable in
                observer.sendNext(json)
                observer.sendCompleted()
            }.observeOn(QueueScheduler())
        }
    }

    class ErrorStubNetwork: Networking {
        func requestJSON(url: String, parameters: [String : AnyObject]?)
            -> SignalProducer<AnyObject, NetworkError>
        {
            return SignalProducer { observer, disposable in
                observer.sendFailed(.NotConnectedToInternet)
            }.observeOn(QueueScheduler())
        }
    }

    // MARK: - Spec
    override func spec() {
        it("returns images if the network works correctly.") {
            var response: ResponseEntity? = nil
            let search = ImageSearch(network: GoodStubNetwork())
            search.searchImages()
                .on(next: { response = $0 })
                .start()

            expect(response).toEventuallyNot(beNil())
            expect(response?.totalCount).toEventually(equal(123))
            expect(response?.images.count).toEventually(equal(2))
            expect(response?.images[0].id).toEventually(equal(0))
            expect(response?.images[1].id).toEventually(equal(1))
        }
        it("sends an error if the network returns incorrect data.") {
            var error: NetworkError? = nil
            let search = ImageSearch(network: BadStubNetwork())
            search.searchImages()
                .on(failed: { error = $0 })
                .start()

            expect(error).toEventually(equal(NetworkError.IncorrectDataReturned))
        }
        it("passes the error sent by the network.") {
            var error: NetworkError? = nil
            let search = ImageSearch(network: ErrorStubNetwork())
            search.searchImages()
                .on(failed: { error = $0 })
                .start()

            expect(error).toEventually(equal(NetworkError.NotConnectedToInternet))
        }
    }
}

先頭で3つのスタブを定義しています。GoodStubNetworkは正しいJSONデータを出力するSignalProducerを返します。BadStubNetworkは、空の辞書の形で不正なJSONデータを出力するSignalProducerを返します。ErrorStubNetworkは、JSONデータの代わりにエラーを出力するSignalProducerを返します。.observeOn(QueueScheduler())を使うことにより、すべてのSignalProducerにおいてイベントをバックグラウンドで出力し、非同期なネットワークのレスポンスを模擬しています。

spec()の中では、3つのユニットテスト (あるいはスペック) を定義しています。1つ目は、attemptMapが正しくJSONデータをResponseEntityに変換していることをチェックしています。2つ目は、attemptMapがJSONデータをエラーに変換するケースをチェックしています。3つ目は、ErrorStubNetworkから出力されるエラーがImageSearchを通して渡ってくることをチェックしています。

Command-Uを入力してテストを実行しましょう。パスしましたね!これで、MVVMアーキテクチャのModelの中心的な部分を実装し終えました。

まとめ

例題アプリのModel部分をMVVMアーキテクチャで開発することを通して、ModelをViewModelおよび外部システムから疎結合にする設計方法を学びました。プロトコルを使用することにより、実装に対する直接的な依存を排除できました。疎結合にしたコンポーネント間のイベントハンドリングのためにRectiveCocoaを利用したました。また、Himotokiを使うと簡単にJSONデータをエンティティにマッピングできることを見ました。次回のブログ記事では、ViewとViewModel部分を設計して実装していきます。

もし質問、提案、問題などがあれば気軽にコメントをどうぞ。


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

  2. 関数型プログラミングに慣れていたら、Argoも便利なJSONパーサかもしれません。 

  3. DDD (ドメイン駆動設計)では、エンティティはドメインモデルの概念であり、アイデンティティにより定義されます。このブログ記事では、アイデンティティは関係なく、単に概念やオブジェクトを指す言葉としてエンティティを用います。 

  4. イミュータブルあるいはミュータブルであることのより詳しい説明はこのページを参照してください。 

  5. ErrorTypeについてより深く学ぶために、"How to Implement the ErrorType Protocol"を読むことをお薦めします。 

  6. try?は単にブログ記事を簡略化するために使用しています。do-try-catchのほうがエラー情報に基づいたハンドリングに適しています。 

25
23
0

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
25
23