LoginSignup
22
21

More than 5 years have passed since last update.

Dependency Injection in MVVM Architecture with ReactiveCocoa Part 5: 非同期での画像の読み込み

Last updated at Posted at 2016-01-13

以下のブログ記事の翻訳です。

Dependency Injection in MVVM Architecture with ReactiveCocoa Part 5: Asynchronous Image Load


前回の記事までで、MVVMアーキテクチャに基いて、Pixabayのサーバから返された画像のメタデータを表示するところまで例題アプリを実装しました。今回のブログ記事では、非同期で画像を読み込む機能を追加します。非同期のイベントハンドリングのため、これまでと同様にReactiveCocoaを使用します。この開発を通して、ユニットテストとDependency InjectionをアップデートしながらMVVMアーキテクチャで機能を追加する方法について学びます。

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

Model

最初に、画像をリクエストする機能をModelに追加します。NetworkingプロトコルにrequestImageメソッドを追加してください。このメソッドは画像のURLを引数にとり、画像をイベントとして送るSignalProducerを返します。

Networking.swift

import ReactiveCocoa

public protocol Networking {
    // 省略

    func requestImage(url: String) -> SignalProducer<UIImage, NetworkError>
}

requestImageメソッドを実装するようにNetworkクラスを修正しましょう。このメソッドの中では、SignalProducerのイニシャライザに与えるトレーリングクロージャを使い、Alamofireからの非同期なレスポンスをReactiveCocoaSignalに変換しています。Alamofireからのレスポンスが成功して正常なデータを受け取った場合、.Next.Completedイベントをobserverに送出します。それ以外の場合は.Failedイベントを送出します。Alamofireのデフォルトではメインスレッドでレスポンスを返すため、Alamofireにシリアルキューを渡してバックグラウンドでレスポンスを返すようにしています。

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 func requestImage(url: String) -> SignalProducer<UIImage, NetworkError> {
        return SignalProducer { observer, disposable in
            let serializer = Alamofire.Request.dataResponseSerializer()
            Alamofire.request(.GET, url)
                .response(queue: self.queue, responseSerializer: serializer) {
                    response in
                    switch response.result {
                    case .Success(let data):
                        guard let image = UIImage(data: data) else {
                            observer.sendFailed(.IncorrectDataReturned)
                            return
                        }
                        observer.sendNext(image)
                        observer.sendCompleted()
                    case .Failure(let error):
                        observer.sendFailed(NetworkError(error: error))
                    }
            }
        }
    }
}

NetworkingプロトコルにrequestImageメソッドを追加したので、ImageSearchSpecで使っているスタブを更新します。そのユニットテストでは特にrequestImageメソッドを使っていないので、空のSignalProducerをスタブが返すようにしています。

ImageSearchSpec.swift

import Quick
import Nimble
import ReactiveCocoa
@testable import ExampleModel

class ImageSearchSpec: QuickSpec {
    // MARK: Stub
    class GoodStubNetwork: Networking {
        // 省略

        func requestImage(url: String) -> SignalProducer<UIImage, NetworkError> {
            return SignalProducer.empty
        }
    }

    class BadStubNetwork: Networking {
        // 省略

        func requestImage(url: String) -> SignalProducer<UIImage, NetworkError> {
            return SignalProducer.empty
        }
    }

    class ErrorStubNetwork: Networking {
        // 省略

        func requestImage(url: String) -> SignalProducer<UIImage, NetworkError> {
            return SignalProducer.empty
        }
    }

    // 省略
}

それでは、requestImageメソッドをチェックするためのユニットテストをNetworkSpecに追加しましょう。テストのための安定したサーバとしてhttpbin.orgを使います。

NetworkSpec.swift

import Quick
import Nimble
@testable import ExampleModel

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

        // 省略

        describe("Image") {
            it("eventually gets an image.") {
                var image: UIImage?
                network.requestImage("https://httpbin.org/image/jpeg")
                    .on(next: { image = $0 })
                    .start()

                expect(image).toEventuallyNot(beNil(), timeout: 5)
            }
            it("eventually gets an error if incorrect data for an image is returned.") {
                var error: NetworkError?
                network.requestImage("https://httpbin.org/get")
                    .on(failed: { error = $0 })
                    .start()

                expect(error).toEventually(
                    equal(NetworkError.IncorrectDataReturned), timeout: 5)
            }
            it("eventually gets an error if the network has a problem.") {
                var error: NetworkError? = nil
                network.requestImage("https://not.existing.server.comm/image/jpeg")
                    .on(failed: { error = $0 })
                    .start()

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

1番目のテストでは、成功するケースとしてNetworkが非同期に画像を返すことを確認しています。2番目のテストでは、サーバから画像でないデータが返ってきた場合にNetworkNetworkError.IncorrectDataReturnedエラーを送出することを確認しています。3番目のテストでは、Alamofireからのエラーが対応するNetworkErrorに変換されてNetworkから送出されることを確認しています。

Command-Uを入力してテストを実行しましょう。

ViewModel

それでは、Modelから画像を受け取りViewのためにハンドリングするViewModelへと移りましょう。始めに、ExampleViewModelグループに以下の内容のRACUtil.swiftを追加してください。その際、ExampleViewModelターゲットにファイルを追加するよう注意してください。

RACUtil.swift

import Foundation
import ReactiveCocoa

internal extension NSObject {
    internal var racutil_willDeallocProducer: SignalProducer<(), NoError>  {
        return self.rac_willDeallocSignal()
            .toSignalProducer()
            .map { _ in }
            .flatMapError { _ in SignalProducer(value: ()) }
    }
}

NSObjectのエクステンションの中でrac_willDeallocSignalを変換し、オブジェクトが破棄されたタイミングで空のタプルを送出するSignalProducerを簡単に作れるようにしています。このようにエクステンションを追加したのは、ReactiveCocoaのSwift APIではObjective-C APIにあるrac_willDeallocSignalに相当するエクステンションがまだないためです。toSignalProducerによりObjective-CのSignalをSwiftのSignalProducerに変換し、mapflatMapErrorでイベントとエラーの型を変換しています。

ImageSearchTableViewCellModelingプロトコルとImageSearchTableViewCellModelクラスにgetPreviewImageメソッドを以下のように追加してください。

ImageSearchTableViewCellModeling.swift

import ReactiveCocoa

public protocol ImageSearchTableViewCellModeling {
    var id: UInt64 { get }
    var pageImageSizeText: String { get }
    var tagText: String { get }

    func getPreviewImage() -> SignalProducer<UIImage?, NoError>
}

ImageSearchTableViewCellModel.swift

import ReactiveCocoa
import ExampleModel

public final class ImageSearchTableViewCellModel
    : NSObject, ImageSearchTableViewCellModeling
{
    public let id: UInt64
    public let pageImageSizeText: String
    public let tagText: String

    private let network: Networking
    private let previewURL: String
    private var previewImage: UIImage?

    internal init(image: ImageEntity, network: Networking) {
        id = image.id
        pageImageSizeText = "\(image.pageImageWidth) x \(image.pageImageHeight)"
        tagText = image.tags.joinWithSeparator(", ")

        self.network = network
        previewURL = image.previewURL

        super.init()
    }

    public func getPreviewImage() -> SignalProducer<UIImage?, NoError> {
        if let previewImage = self.previewImage {
            return SignalProducer(value: previewImage).observeOn(UIScheduler())
        }
        else {
            let imageProducer = network.requestImage(previewURL)
                .takeUntil(self.racutil_willDeallocProducer)
                .on(next: { self.previewImage = $0 })
                .map { $0 as UIImage? }
                .flatMapError { _ in SignalProducer<UIImage?, NoError>(value: nil) }

            return SignalProducer(value: nil)
                .concat(imageProducer)
                .observeOn(UIScheduler())
        }
    }
}

getPreviewImageメソッドはUIImageを送出するSignalProducerのインスタンスを返します。画像のキャッシュがpreviewImageプロパティに存在したらキャッシュ画像を使うSignalProducerで、そうでなければNetworkingに画像をリクエストするSignalProducerとなっています。

後者の場合のSignalProducerconcatで繋がれた2つの部分で構成されています。1つ目は、すぐにnilを送出して終了するSignalProducer(value: nil)です。最初にnilを送出するのは、再利用されたセルのUIImageViewに表示されている古い画像を取り除くためです。2つ目は、Networkingに画像をリクエストするimageProducerです。ここでは、テーブルビューの各セルでエラーメッセージを表示すべきではないので、flatMapErrorを用いてエラーをnilに変換して無視しています。ImageSearchTableViewCellModelのインスタンスが破棄された時にSignalProducerを停止するため、racutil_willDeallocProducerを引数にしてtakeUntilメソッドを使っています。NSObjectのエクステンションで定義したメソッドを使うため、ImageSearchTableViewCellModelNSObjectを継承しています1

以下のようにNetworkingを渡すことができるようImageSearchTableViewModelを修正してください。Networkingのインスタンスを注入できるようにイニシャライザに引数を追加しています。startSearchメソッドの中で、NetworkingのインスタンスをImageSearchTableViewCellModelのイニシャライザに渡します。

ImageSearchTableViewModel.swift

import ReactiveCocoa
import ExampleModel

public final class ImageSearchTableViewModel: ImageSearchTableViewModeling {
    public var cellModels: AnyProperty<[ImageSearchTableViewCellModeling]> {
        return AnyProperty(_cellModels)
    }
    private let _cellModels = MutableProperty<[ImageSearchTableViewCellModeling]>([])
    private let imageSearch: ImageSearching
    private let network: Networking

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

    public func startSearch() {
        imageSearch.searchImages()
            .map { response in
                response.images.map {
                    ImageSearchTableViewCellModel(image: $0, network: self.network)
                        as ImageSearchTableViewCellModeling
                }
            }
            .observeOn(UIScheduler())
            .on(next: { cellModels in
                self._cellModels.value = cellModels
            })
            .start()
    }
}

最後に、Dependency Injectionを追加するためにAppDelegateを修正します。以下のようにNetworkingImageSearchTableViewModelに注入してください。

AppDelegate.swift

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?
    let container = Container() { container in
        // Models
        container.register(Networking.self) { _ in Network() }
        container.register(ImageSearching.self) { r in
            ImageSearch(network: r.resolve(Networking.self)!)
        }

        // View models
        container.register(ImageSearchTableViewModeling.self) { r in
            ImageSearchTableViewModel(
                imageSearch: r.resolve(ImageSearching.self)!,
                network: r.resolve(Networking.self)!)
        }

        // Views
        container.registerForStoryboard(ImageSearchTableViewController.self) { r, c in
            c.viewModel = r.resolve(ImageSearchTableViewModeling.self)!
        }
    }

    // 省略
}

それでは、ViewModelのアップデートに合わせてユニットテストを修正・追加しましょう。最初にImageSearchTableViewModelSpecを修正し、StubNetworkを追加します。そのインスタンスは、ImageSearchTableViewModelの修正したイニシャライザに渡すために使います。

ImageSearchTableViewModelSpec.swift

class ImageSearchTableViewModelSpec: QuickSpec {
    // MARK: Stub
    class StubImageSearch: ImageSearching {
        func searchImages() -> SignalProducer<ResponseEntity, NetworkError> {
            return SignalProducer { observer, disposable in
                observer.sendNext(dummyResponse)
                observer.sendCompleted()
            }
            .observeOn(QueueScheduler())
        }
    }

    class StubNetwork: Networking {
        func requestJSON(url: String, parameters: [String : AnyObject]?)
            -> SignalProducer<AnyObject, NetworkError>
        {
            return SignalProducer.empty
        }

        func requestImage(url: String) -> SignalProducer<UIImage, NetworkError> {
            return SignalProducer.empty
        }
    }

    // MARK: Spec
    override func spec() {
        var viewModel: ImageSearchTableViewModel!
        beforeEach {
            viewModel = ImageSearchTableViewModel(
                imageSearch: StubImageSearch(),
                network: StubNetwork())
        }

        // 省略
    }
}

後で使うため、DummyResponse.swiftにダミーの画像のインスタンスを追加してください。

DummyResponse.swift

let image1x1: UIImage = {
    UIGraphicsBeginImageContextWithOptions(CGSizeMake(1, 1), true, 0)
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return image
}()

以下のようにスタブとユニットテストをImageSearchTableViewCellModelSpecに追加してください。

ImageSearchTableViewCellModelSpec.swift

import Foundation
import Quick
import Nimble
import ReactiveCocoa
@testable import ExampleModel
@testable import ExampleViewModel

class ImageSearchTableViewCellModelSpec: QuickSpec {
    // MARK: Stubs
    class StubNetwork: Networking {
        func requestJSON(url: String, parameters: [String : AnyObject]?)
            -> SignalProducer<AnyObject, NetworkError>
        {
            return SignalProducer.empty
        }

        func requestImage(url: String) -> SignalProducer<UIImage, NetworkError> {
            return SignalProducer(value: image1x1).observeOn(QueueScheduler())
        }
    }

    class ErrorStubNetwork: Networking {
        func requestJSON(url: String, parameters: [String : AnyObject]?)
            -> SignalProducer<AnyObject, NetworkError>
        {
            return SignalProducer.empty
        }

        func requestImage(url: String) -> SignalProducer<UIImage, NetworkError> {
            return SignalProducer(error: .NotConnectedToInternet)
        }
    }

    // MARK: Spec
    override func spec() {
        var viewModel: ImageSearchTableViewCellModel!
        beforeEach {
            viewModel = ImageSearchTableViewCellModel(
                image: dummyResponse.images[0],
                network: StubNetwork())
        }

        describe("Constant values") {
            it("sets id.") {
                expect(viewModel.id).toEventually(equal(10000))
            }
            it("formats tag and page image size texts.") {
                expect(viewModel.pageImageSizeText)
                    .toEventually(equal("1000 x 2000"))
                expect(viewModel.tagText).toEventually(equal("a, b"))
            }
        }
        describe("Preview image") {
            it("returns nil at the first time.") {
                var image: UIImage? = image1x1
                viewModel.getPreviewImage()
                    .take(1)
                    .on(next: { image = $0 })
                    .start()

                expect(image).toEventually(beNil())
            }
            it("eventually returns an image.") {
                var image: UIImage? = nil
                viewModel.getPreviewImage()
                    .on(next: { image = $0 })
                    .start()

                expect(image).toEventuallyNot(beNil())
            }
            it("returns an image on the main thread.") {
                var onMainThread = false
                viewModel.getPreviewImage()
                    .skip(1) // Skips the first nil.
                    .on(next: { _ in onMainThread = NSThread.isMainThread() })
                    .start()

                expect(onMainThread).toEventually(beTrue())
            }
            context("with an image already downloaded") {
                it("immediately returns the image omitting the first nil.") {
                    var image: UIImage? = nil
                    viewModel.getPreviewImage().start(completed: {
                        viewModel.getPreviewImage()
                            .take(1)
                            .on(next: { image = $0 })
                            .start()
                    })

                    expect(image).toEventuallyNot(beNil())
                }
            }
            context("on error") {
                it("returns nil.") {
                    var image: UIImage? = image1x1
                    let viewModel = ImageSearchTableViewCellModel(
                        image: dummyResponse.images[0],
                        network: ErrorStubNetwork())
                    viewModel.getPreviewImage()
                        .skip(1) // Skips the first nil.
                        .on(next: { image = $0 })
                        .start()

                    expect(image).toEventually(beNil())
                }
            }
        }
    }
}

StubNetworkrequestImageメソッドは、先ほどのダミー画像を送出するSignalProducerを返します。ErrorStubNetworkの同メソッドは、エラーを送出するSignalProducerを返します。新しいユニットテストを追加する前に、specをリファクタリングしてあります。既存のテストをdescribe("Constant values")にグルーピングしました。

describe("Preview image")グルーピングには、getPreviewImageメソッドのための新しいユニットテストを5つ追加しました。1番目のテストでは、SignalProducerが最初のイベントとしてnilを送出することを確認しています。2番目のテストでは、成功時のイベントとして画像を送出することを確認しています。3番目のテストでは、画像のイベントをメインスレッドで送出することを確認しています。4番目のテストでは、キャッシュが存在する場合にはキャッシュした画像を即座に送出することを確認しています。このテストは特定の条件下での確認のため、contextでさらにグループ分けしてあります。5番目のテストでは、Networkingインスタンスからのエラーをnilに変換して送出することを確認しています。このテストもcontext`でグループ分けしてあります。

Command-Uを入力してユニットテストを実行しましょう。それでは、次のセクションに移り、Viewを実装していきます。

View

始めに、NSObjectの時と同様にUITableViewCellにエクステンションを追加します2ExampleViewグルーブに以下の内容のRACUtil.swiftを追加してください。このエクステンションの中で、ReactiveCocoaのObjective-C APIのrac_prepareForReuseSignalをSwiftの型に変換しています。UITableViewCellprepareForReuseが呼ばれたタイミングで、空のタプルのイベントが送出されるようになっています。

RACUtil.swift

import UIKit
import ReactiveCocoa

internal extension UITableViewCell {
    internal var racutil_prepareForReuseProducer: SignalProducer<(), NoError>  {
        return self.rac_prepareForReuseSignal
            .toSignalProducer()
            .map { _ in }
            .flatMapError { _ in SignalProducer(value: ()) }
    }
}

次に、ImageSearchTableViewCellを修正し、viewModelプロパティがセットされた時にイメージビューを更新する処理を入れます。過ぎ去ったRowのための画像で間違ってセルが更新されないよう、セルが他のRowのために再利用されるタイミングでgetPreviewImageのシグナルを停止するようにしてあります。

ImageSearchTableViewCell.swift

import UIKit
import ExampleViewModel
import ReactiveCocoa

internal final class ImageSearchTableViewCell: UITableViewCell {
    internal var viewModel: ImageSearchTableViewCellModeling? {
        didSet {
            tagLabel.text = viewModel?.tagText
            imageSizeLabel.text = viewModel?.pageImageSizeText

            if let viewModel = viewModel {
                viewModel.getPreviewImage()
                    .takeUntil(self.racutil_prepareForReuseProducer)
                    .on(next: { self.previewImageView.image = $0 })
                    .start()
            }
            else {
                previewImageView.image = nil
            }
        }
    }

    @IBOutlet weak var previewImageView: UIImageView!
    @IBOutlet weak var tagLabel: UILabel!
    @IBOutlet weak var imageSizeLabel: UILabel!
}

Command-Rを入力してアプリを実行してみましょう。下のように、各イメージビューに画像が表示されていると思います。

SwinjectMVVMExample Images Displayed in Table View Cells

では最後に、ExampleViewTestsグルーブに以下の内容のImageSearchTableViewCellSpec.swiftを追加してください。ExampleViewTestsターゲットにファイルを追加するように注意しましょう。

ImageSearchTableViewCellSpec.swift

import Quick
import Nimble
import ReactiveCocoa
import ExampleViewModel
@testable import ExampleView

class ImageSearchTableViewCellSpec: QuickSpec {
    class MockViewModel: ImageSearchTableViewCellModeling {
        let id: UInt64 = 0
        let pageImageSizeText = ""
        let tagText = ""

        var getPreviewImageStarted = false

        func getPreviewImage() -> SignalProducer<UIImage?, NoError> {
            return SignalProducer<UIImage?, NoError> { observer, _ in
                self.getPreviewImageStarted = true
                observer.sendCompleted()
            }
        }
    }

    override func spec() {
        it("starts getPreviewImage signal producer when its view model is set.") {
            let viewModel = MockViewModel()
            let view = createTableViewCell()

            expect(viewModel.getPreviewImageStarted) == false
            view.viewModel = viewModel
            expect(viewModel.getPreviewImageStarted) == true
        }
    }
}

private func createTableViewCell() -> ImageSearchTableViewCell {
    let bundle = NSBundle(forClass: ImageSearchTableViewCell.self)
    let storyboard = UIStoryboard(name: "Main", bundle: bundle)
    let tableViewController = storyboard
        .instantiateViewControllerWithIdentifier("ImageSearchTableViewController")
        as! ImageSearchTableViewController
    return tableViewController.tableView
        .dequeueReusableCellWithIdentifier("ImageSearchTableViewCell")
        as! ImageSearchTableViewCell
}

このテストでは、ImageSearchTableViewCellviewModelプロパティがセットされた時にgetPreviewImageが呼ばれることをImageSearchTableViewCellModelingのモックを使って確認しています。

Command-Uを押してテストを実行しましょう。パスしました!これで、ネットワークから非同期で画像を読み込んで表示するテーブルビューを実装し終えました。実装だけでなくユニットテストもあるので、動くソフトウェアを自信を持って開発し続けることができますね!

まとめ

今回のブログ記事では、画像をUIImageViewに非同期でロードする機能をMVVMアーキテクチャで実装しました。ユニットテストを更新するとともに、Model、ViewModel、Viewの各プロトコルと実装クラスに新しいメソッドを追加する手順を学びました。実装の際、ViewModelに対するDependency Injectionもアップデートしました。抽象化されたイベントの送出とハンドリングのためにReactiveCocoaをModel、ViewModel、Viewのすべてにおいて用いました。

ブログ記事のシリーズを通して以下のことを学びました。

  • Part 1: MVVMとReactiveCocoaのコンセプトと基本
  • Part 2: MVVMフレームワークのターゲットで構成されるXcodeプロジェクトの設定とCarthageによる外部フレームワークのインストール
  • Part 3: ネットワークなどの外部システムからアプリを疎結合にするためにプロトコルを使ったModelの設計
  • Part 4: ViewModelとViewの実装およびAppDelegateからのDependency Injection
  • Part 5: 新しい機能を追加するためのModel、ViewModel、Viewの修正とユニットテストの更新

これまで単に例題アプリを実装するだけでなく、MVVMの境界となるプロトコルのスタブやモックを用いたユニットテストも書いてきました3。MVVMアーキテクチャの中でプロトコル、実装、そしてユニットテストを追加するサイクルを維持することにより、プロジェクトを進めていく際の自信を高めることができます。ReactiveCocoaの抽象化されたイベントとSwinjectによるDependency InjectionでModel、View、ViewModelを疎結合にすることがそのサイクルの鍵となります4

これで本シリーズは終了しますが、GitHubのレポジトリにあるプロジェクトでは、画像の詳細ビューの表示、テーブルを下までスクロールした時に追加で画像データを読み込む機能、エラーハンドリング、ローカライゼーションなど、より進んだ開発を行っています。興味があればチェックしてみてください。GitHub上のSwinjectのプロジェクトにスターを付けていただけると励みになります。


以上でシリーズの翻訳は終了です。

記事の中では、MVVMのすべてのインターフェイスをプロトコルできっちり分けていましたが、あまりやり過ぎると開発のスピードを落としてしまうので、プロジェクトのサイズ (開発者の人数やアプリのサイズ) に合わせて適宜調整するほうがいいかもしれません。特に、UIテストを行っているなら、ViewからViewModelはプロトコルを介さずに実体を参照するようにすれば十分だと思います。ReactiveCocoaのMutablePropertyAnyPropertyプロパティでラップして、外部からは読み取り専用のプロパティを作る方法がありましたが、1人で開発するような小さなプロジェクトであればこれも少し煩雑かもしれません。

MVVMの他にもVIPERなどのアーキテクチャがありますが、開発規模に応じたアーキテクチャの選択と実装が必要でしょう。


  1. 訳注: ここでは簡便のためNSObjectを継承するようにしていますが、Swiftだけで同等の事ができるようにするほうがいいかもしれないです。 

  2. 訳注: UITableViewCellのエクステンションはありませんが、UIKitの一部のエクステンションがRexで実装されています。 

  3. このブログ記事では、いつも機能の実装をしたあとでユニットテストを書いていましたが、実際にはテストを先に書くか機能の実装と一緒に書いていくほうが良いプラクティスです。 

  4. 訳注: 少し言い過ぎかもしれません。もともと英語の文章だったので。 

22
21
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
22
21