前回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: 発展形を含むプロジェクト
- SwinjectMVVMExample_ForBlog: (Xcodeや外部フレームワークの更新を除き) ブログ記事に沿った説明のための簡略化したプロジェクト
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_name
をSomeValue
のname
プロパティへ、some_value
をvalue
プロパティへマッピングしています。
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と外部システムを疎結合にするため、それらのインターフェイスをプロトコルとして定義します。下の図でImageSearching
とNetworking
がプロトコルです。ImageSearch
とNetwork
はそれらの実装です。ViewModelはImageSearching
プロトコルを通してModelにアクセスし、そのプロトコルの実装であるImageSearch
はNetworking
プロトコルを通して外部のシステムにアクセスします。外部システムがJSONデータを持つイベントを発生させ、そのイベントがViewModelへと伝播していく時にImageSearch
によってデータがResponseEntity
やImageEntity
に変換されます。
エンティティ
この節では、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
として定義してあることに注意してください。イミュータブルであることにより、エンティティを使用する際の安全性を確保できます4。ExampleViewModel
ターゲットから参照できるよう、エンティティの型はpublic
で定義してあります。
ImageEntity
には、Swiftの命名慣習とアプリの構成に合わせ、JSONの要素と異なる名前にしたプロパティもあります。id
、viewCount
、downloadCount
、likeCount
は、32-bitシステムでも大きな値を扱えるよう、UInt64
やInt64
で定義してあります。JSONの要素tags
はCSV形式の文字列となっていますが、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.swift
とImageEntitySpec.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は扱うため、imageJSON
はString
ではなく[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.swift
とNetwork.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
のインスタンスを生成して返しています。そのクロージャの中では、sendNext
、sendCompleted
、sendFailed
を呼び出して、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
を追加してください。ResponseEntity
のSignalProducer
を返す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)
}
}
}
}
ImageSearch
はNetworking
に依存しており、その依存性はイニシャライザで注入されています (Initializer Injectionパターン)。
searchImages
メソッドは、network.requestJSON
から返されるSignalProducer<AnyObject, NetworkError>
をSignalProducer<ResponseEntity, NetworkError>
に変換しています。SignalProducer
の変換でattemptMap
を使っています。attemptMap
に渡したクロージャの中では、decode
を呼んでJSONデータをResponseEntity
インスタンスにマッピングしています。マッピングが成功した場合、ResponseEntity
インスタンスをResult(value: response)
で包んで返しています。失敗した場合6、Result(error: .IncorrectDataReturned)
としてエラーを返しています。もしある値をエラーでなく別の値に変換するだけであれば、SignalProducer
のmap
メソッドが利用できます。
(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部分を設計して実装していきます。
もし質問、提案、問題などがあれば気軽にコメントをどうぞ。
-
訳注: 英語版の著者本人による翻訳のため、翻訳に関わる著作権上の問題はありません。 ↩
-
関数型プログラミングに慣れていたら、Argoも便利なJSONパーサかもしれません。 ↩
-
DDD (ドメイン駆動設計)では、エンティティはドメインモデルの概念であり、アイデンティティにより定義されます。このブログ記事では、アイデンティティは関係なく、単に概念やオブジェクトを指す言葉としてエンティティを用います。 ↩
-
ErrorType
についてより深く学ぶために、"How to Implement the ErrorType Protocol"を読むことをお薦めします。 ↩ -
try?
は単にブログ記事を簡略化するために使用しています。do-try-catch
のほうがエラー情報に基づいたハンドリングに適しています。 ↩