以下のブログ記事の翻訳です。
Dependency Injection in MVVM Architecture with ReactiveCocoa Part 5: Asynchronous Image Load
前回の記事までで、MVVMアーキテクチャに基いて、Pixabayのサーバから返された画像のメタデータを表示するところまで例題アプリを実装しました。今回のブログ記事では、非同期で画像を読み込む機能を追加します。非同期のイベントハンドリングのため、これまでと同様にReactiveCocoaを使用します。この開発を通して、ユニットテストとDependency InjectionをアップデートしながらMVVMアーキテクチャで機能を追加する方法について学びます。
ソースコードはGitHubのリポジトリからダウンロードできます。
- SwinjectMVVMExample: 発展形を含むプロジェクト
- SwinjectMVVMExample_ForBlog: (Xcodeや外部フレームワークの更新を除き) ブログ記事に沿った説明のための簡略化したプロジェクト
Model
最初に、画像をリクエストする機能をModelに追加します。Networking
プロトコルにrequestImage
メソッドを追加してください。このメソッドは画像のURLを引数にとり、画像をイベントとして送るSignalProducer
を返します。
Networking.swift
import ReactiveCocoa
public protocol Networking {
// 省略
func requestImage(url: String) -> SignalProducer<UIImage, NetworkError>
}
requestImage
メソッドを実装するようにNetwork
クラスを修正しましょう。このメソッドの中では、SignalProducer
のイニシャライザに与えるトレーリングクロージャを使い、Alamofireからの非同期なレスポンスをReactiveCocoa
のSignal
に変換しています。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番目のテストでは、サーバから画像でないデータが返ってきた場合にNetwork
がNetworkError.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
に変換し、map
とflatMapError
でイベントとエラーの型を変換しています。
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
となっています。
後者の場合のSignalProducer
はconcat
で繋がれた2つの部分で構成されています。1つ目は、すぐにnil
を送出して終了するSignalProducer(value: nil)
です。最初にnil
を送出するのは、再利用されたセルのUIImageView
に表示されている古い画像を取り除くためです。2つ目は、Networking
に画像をリクエストするimageProducer
です。ここでは、テーブルビューの各セルでエラーメッセージを表示すべきではないので、flatMapError
を用いてエラーをnil
に変換して無視しています。ImageSearchTableViewCellModel
のインスタンスが破棄された時にSignalProducer
を停止するため、racutil_willDeallocProducer
を引数にしてtakeUntil
メソッドを使っています。NSObject
のエクステンションで定義したメソッドを使うため、ImageSearchTableViewCellModel
はNSObject
を継承しています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
を修正します。以下のようにNetworking
をImageSearchTableViewModel
に注入してください。
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())
}
}
}
}
}
StubNetwork
のrequestImage
メソッドは、先ほどのダミー画像を送出する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
にエクステンションを追加します2。ExampleView
グルーブに以下の内容のRACUtil.swift
を追加してください。このエクステンションの中で、ReactiveCocoaのObjective-C APIのrac_prepareForReuseSignal
をSwiftの型に変換しています。UITableViewCell
のprepareForReuse
が呼ばれたタイミングで、空のタプルのイベントが送出されるようになっています。
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
を入力してアプリを実行してみましょう。下のように、各イメージビューに画像が表示されていると思います。
では最後に、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
}
このテストでは、ImageSearchTableViewCell
のviewModel
プロパティがセットされた時に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のMutableProperty
をAnyProperty
プロパティでラップして、外部からは読み取り専用のプロパティを作る方法がありましたが、1人で開発するような小さなプロジェクトであればこれも少し煩雑かもしれません。
MVVMの他にもVIPERなどのアーキテクチャがありますが、開発規模に応じたアーキテクチャの選択と実装が必要でしょう。