以下のブログ記事の翻訳です。
前回のブログ記事では、例題アプリのモデル部分を設計して実装しました。今回の記事では、ViewとViewModelのパートへと移ります。最初に、ViewとViewModelの空の実装をプロジェクトに追加し、アプリを実行できるようにします。その後、ユニットテストとともに実際の実装を追加します。その開発の中で、ReactiveCocoaのオブザーバブルなプロパティであるAnyProperty
型とMutableProperty
型の使い方を学べます。
ソースコードはGitHubのリポジトリからダウンロードできます。
- SwinjectMVVMExample: 発展形を含むプロジェクト
- SwinjectMVVMExample_ForBlog: (Xcodeや外部フレームワークの更新を除き) ブログ記事に沿った説明のための簡略化したプロジェクト
ViewとViewModelの設計の概要
ViewとViewModelを疎結合にするため、そのインターフェイスを下の図のようにプロトコルで定義します。ImageSearchTableViewModeling
とImageSearchTableViewCellModeling
はプロトコルで、ImageSearchTableViewModel
とImageSearchTableViewCellModel
はそれらのプロトコルに適合した実装です。ImageSearchTableViewModel
はイベントにラップされた画像のエンティティをModel層から受け取り、さらにImageSearchTableViewController
へとイベントで伝えるためにそのエンティティをImageSearchTableViewCellModel
に変換します。
ViewとViewModelの空の実装
最初に、SwinjectによるDependency Injectionを利用したViewとViewModelの空の実装を追加します。Agileプラクティスで言われるように、まず中身はないが動くソフトウェアを作り、ひとつずつ機能を追加していきます。
空のViewを追加
始めに、不要なファイルと設定を削除します。SwinjectMVVMExample
グループのViewController.swift
とMain.storyboard
を削除してください。SwinjectMVVMExample
グループのInfo.plist
を開き、Main storyboard file base name
を削除してください。(生のキー/値を表示していればUIMainStoryboardFile
と表示されているかもしれません。)
次に、ExampleView
グループに以下の内容のImageSearchTableViewController.swift
を追加してください。その際、ExampleView
ターゲットにファイルを追加することに注意してください。そのためには、ExampleView
グループを右クリックしてNew File...
を選択し、iOS > Source > Swift File
と順に選択し、ファイル名を入力する画面でExampleView
ターゲットにチェックを入れておきます。
ImageSearchTableViewController.swift
import UIKit
public final class ImageSearchTableViewController: UITableViewController {
}
同様に、ExampleView
グループに以下の内容のImageSearchTableViewCell.swift
を追加してください。
ImageSearchTableViewCell.swift
import UIKit
internal final class ImageSearchTableViewCell: UITableViewCell {
}
ここで、ImageSearchTableViewController
はpublic
なのに対し、ImageSearchTableViewCell
はinternal
であることに注意してください。そのセルにアクセスするのはテーブルビューコントローラのみで、ExampleView
フレームワークを使用する側には見せる必要がないためです。
ExampleView
グループにMain.storyboard
という名前で新しいストーリーボードを追加してください。そのストーリーボードを開き、Object Libraryからナビゲーションコントローラを追加します。そのナビゲーションコントローラを選択し、Attribute Inspectorの"Is Initial View Controller"にチェックを入れます。
ナビゲーションコントローラのルートビューコントローラとなっているテーブルビューコントローラを選択し、カスタムクラスとストーリーボードIDをImageSearchTableViewController
にします。そのテーブルビューコントローラのプロトタイプセルを選択し、カスタムクラスとセル識別子をImageSearchTableViewCell
にします。テーブルビューコントローラのナビゲーションアイテムを選択し、そのタイトルを"Pixabay Images"にします。
SwinjectMVVMExample
グループのAppDelegate.swift
を修正し、手動でストーリーボードからイニシャルビューコントローラをインスタンス化するようにします。後でDependency Injectionを追加するため、UIStoryboard
の代わりにSwinjectStoryboard
を使用します。SwinjectStoryboard
のインスタンス化はイニシャライザではなくcreate
関数で行います1。SwinjectStoryboard
に与えるバンドルはメインバンドルではなく、NSBundle.init(forClass:)
で取得したExampleView
ターゲットのバンドルにします。
AppDelegate.swift
import UIKit
import Swinject
import ExampleView
@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 bundle = NSBundle(forClass: ImageSearchTableViewController.self)
let storyboard = SwinjectStoryboard.create(name: "Main", bundle: bundle)
window.rootViewController = storyboard.instantiateInitialViewController()
return true
}
// 以下略...
}
これでアプリを実行する準備ができました。Command-R
を押して実行してみましょう。下の画像のような空のテーブルビューが表示されると思います。
空のViewModelを追加
ExampleViewModel
グループに以下の内容のImageSearchTableViewModeling.swift
とImageSearchTableViewModel.swift
を追加してください。ファイルを保存するときにExampleViewModel
ターゲットに追加するよう注意してください。ImageSearchTableViewModel
が依存しているImageSearching
はイニシャライザを通して注入します。これはイニシャライザインジェクションパターンと呼ばれるものです。
ImageSearchTableViewModeling.swift
public protocol ImageSearchTableViewModeling {
}
ImageSearchTableViewModel.swift
import ExampleModel
public final class ImageSearchTableViewModel: ImageSearchTableViewModeling {
private let imageSearch: ImageSearching
public init(imageSearch: ImageSearching) {
self.imageSearch = imageSearch
}
}
ExampleViewModel
グループに以下の内容のImageSearchTableViewCellModeling.swift
とImageSearchTableViewCellModel.swift
を追加してください。
ImageSearchTableViewCellModeling.swift
public protocol ImageSearchTableViewCellModeling {
}
ImageSearchTableViewCellModel.swift
public final class ImageSearchTableViewCellModel: ImageSearchTableViewCellModeling {
}
ImageSearchTableViewController
とImageSearchTableViewCell
が持つViewModelへの依存性をプロパティインジェクションパターンの形で定義します。
ImageSearchTableViewController.swift
import UIKit
import ExampleViewModel
public final class ImageSearchTableViewController: UITableViewController {
public var viewModel: ImageSearchTableViewModeling?
}
ImageSearchTableViewCell.swift
import UIKit
import ExampleViewModel
internal final class ImageSearchTableViewCell: UITableViewCell {
internal var viewModel: ImageSearchTableViewCellModeling?
}
Dependency Injectionを適用
AppDelegate
でDependency Injectionを行うため、依存関係を登録するcontainer
プロパティを以下のように追加します。ここでは、Container
のコンビニエンスイニシャライザを使用し、クロージャの中でcontainer
インスタンスの設定をしています。container
プロパティはSwinjectStoryboard
をインスタンス化するときに渡します。
AppDelegate.swift
import UIKit
import Swinject
import ExampleModel
import ExampleViewModel
import ExampleView
@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)!)
}
// Views
container.registerForStoryboard(ImageSearchTableViewController.self) {
r, c in
c.viewModel = r.resolve(ImageSearchTableViewModeling.self)!
}
}
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 bundle = NSBundle(forClass: ImageSearchTableViewController.self)
let storyboard = SwinjectStoryboard.create(
name: "Main",
bundle: bundle,
container: container)
window.rootViewController = storyboard.instantiateInitialViewController()
return true
}
// 以下略...
}
必要な型がすべてcontainer
に登録してあることを確認するため、ユニットテストを追加しましょう。テストを追加する前に、SwinjectMVVMExampleTests
グループのSwinjectMVVMExampleTests.swift
は不要なので削除しておきます。その後、同グループに以下の内容でAppDelegateSpec.swift
を追加してください。そのユニットテストでは、登録してある型をcontainer
から取得できることを.notTo(beNil())
を使って確認しています。
AppDelegateSpec.swift
import Quick
import Nimble
import Swinject
import ExampleModel
import ExampleViewModel
import ExampleView
@testable import SwinjectMVVMExample
class AppDelegateSpec: QuickSpec {
override func spec() {
var container: Container!
beforeEach {
container = AppDelegate().container
}
describe("Container") {
it("resolves every service type.") {
// Models
expect(container.resolve(Networking.self)).notTo(beNil())
expect(container.resolve(ImageSearching.self)).notTo(beNil())
// ViewModels
expect(container.resolve(ImageSearchTableViewModeling.self))
.notTo(beNil())
}
it("injects view models to views.") {
let bundle = NSBundle(forClass: ImageSearchTableViewController.self)
let storyboard = SwinjectStoryboard.create(
name: "Main",
bundle: bundle,
container: container)
let imageSearchTableViewController = storyboard
.instantiateViewControllerWithIdentifier("ImageSearchTableViewController")
as! ImageSearchTableViewController
expect(imageSearchTableViewController.viewModel).notTo(beNil())
}
}
}
}
Command-U
を入力してユニットテストを実行してみましょう。パスしましたね。依存性が注入された状態で、ViewとViewModelの空実装ができました。
ViewとViewModelの実際の実装
このセクションでは、テーブルビューに画像のメタデータ (タグやピクセルサイズ) を表示するための実際の実装をViewとViewModelに追加していきます。テーブルビューセルにUIImageView
を追加しますが、このブログ記事ではメタデータを表示するためのラベルだけ実装することとします。イメージビューは次のブログ記事で使用します。
ViewModelの実装
最初に、テーブルのデータソースを実装しましょう。MVVMアーキテクチャでは、データソースはViewModelで実装します。cellModels
プロパティとstartSearch
メソッドをImageSearchTableViewModeling
プロトコルに追加してください。
ImageSearchTableViewModeling.swift
import ReactiveCocoa
public protocol ImageSearchTableViewModeling {
var cellModels: AnyProperty<[ImageSearchTableViewCellModeling]> { get }
func startSearch()
}
cellModels
プロパティはAnyProperty
型として定義し、オブザーブできるようにします。AnyProperty
型が持つSignalProducer
型のproducer
プロパティにオブザーバを追加することができます。
ImageSearchTableViewModel
を修正し、プロトコルに追加したプロパティとメソッドを実装しましょう。
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
public init(imageSearch: ImageSearching) {
self.imageSearch = imageSearch
}
public func startSearch() {
imageSearch.searchImages()
.map { response in
response.images.map {
ImageSearchTableViewCellModel(image: $0)
as ImageSearchTableViewCellModeling
}
}
.observeOn(UIScheduler())
.on(next: { cellModels in
self._cellModels.value = cellModels
})
.start()
}
}
cellModels
プロパティはMutableProperty
型の_cellModels
プロパティをラップしています。これは、MutableProperty
型の_cellModels
プロパティが外部から変更されることを防ぐためであり、AnyProperty
でラップすることにより読み取り専用のオブザーバブルなプロパティを実現できます。
startSearch
メソッドにより、imageSearch.searchImages
で返されるSignalProducer
を開始します。next
イベントの副作用の中で、imageSearch
が返すresponse
からマッピングしたImageSearchTableViewCellModeling
の配列を_cellModels
の値にセットします。その副作用は.observeOn(UIScheduler())
によりメインスレッドで実行されていることに注意してください。ViewModelからViewへのイベントがメインスレッドで実行されることをViewModelで保証すべきだからです。
ReactiveCocoaにはメインスレッドで実行するためのスケジューラが2種類あります。ひとつはQueueScheduler.mainQueueScheduler
で、メインスレッドでイベントが実行されるよう常に非同期なスケジューリングが行われます。もうひとつは今回使用したUIScheduler
で、すでにメインスレッドの中にいればそのまま同期で実行し、そうでなければ非同期でメインスレッドにスケジューリングします。
次に、id
、pageImageSizeText
、tagText
プロパティをImageSearchTableViewCellModeling
プロトコルに追加してください。id
はデバッグ用に追加します。
ImageSearchTableViewCellModeling.swift
public protocol ImageSearchTableViewCellModeling {
var id: UInt64 { get }
var pageImageSizeText: String { get }
var tagText: String { get }
}
さらに、ImageSearchTableViewCellModel
を修正して上記プロトコルを実装し、プロパティがinit
の中で初期化されるようにします (init
はImageSearchTableViewModel
が呼びます)。ImageSearchTableViewCellModel
が実装するのはビューのロジックで、ModelのデータがどのようにViewに表示されるか定義します。
ImageSearchTableViewCellModel.swift
import ExampleModel
public final class ImageSearchTableViewCellModel: ImageSearchTableViewCellModeling {
public let id: UInt64
public let pageImageSizeText: String
public let tagText: String
internal init(image: ImageEntity) {
id = image.id
pageImageSizeText = "\(image.pageImageWidth) x \(image.pageImageHeight)"
tagText = image.tags.joinWithSeparator(", ")
}
}
ViewModelの実装のユニットテスト
ViewModelで実装したことを確認するためのユニットテストを追加しましょう。最初に、ExampleViewModelTests.swift
が不要なのでExampleViewModelTests
グループから削除しましょう。次に、そのグループに以下の内容のDummyResponse.swift
とImageSearchTableViewModelSpec.swift
を追加してください。
DummyResponse.swift
@testable import ExampleModel
@testable import ExampleViewModel
let dummyResponse: ResponseEntity = {
let image0 = ImageEntity(
id: 10000,
pageURL: "https://somewhere.com/page0/",
pageImageWidth: 1000,
pageImageHeight: 2000,
previewURL: "https://somewhere.com/preview0.jpg",
previewWidth: 250,
previewHeight: 500,
imageURL: "https://somewhere.com/image0.jpg",
imageWidth: 100,
imageHeight: 200,
viewCount: 99,
downloadCount: 98,
likeCount: 97,
tags: ["a", "b"],
username: "User0")
let image1 = ImageEntity(
id: 10001,
pageURL: "https://somewhere.com/page1/",
pageImageWidth: 1500,
pageImageHeight: 3000,
previewURL: "https://somewhere.com/preview1.jpg",
previewWidth: 350,
previewHeight: 700,
imageURL: "https://somewhere.com/image1.jpg",
imageWidth: 150,
imageHeight: 300,
viewCount: 123456789,
downloadCount: 12345678,
likeCount: 1234567,
tags: ["x", "y"],
username: "User1")
return ResponseEntity(totalCount: 123, images: [image0, image1])
}()
ImageSearchTableViewModelSpec.swift
import Quick
import Nimble
import ReactiveCocoa
@testable import ExampleModel
@testable import ExampleViewModel
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())
}
}
// MARK: Spec
override func spec() {
var viewModel: ImageSearchTableViewModel!
beforeEach {
viewModel = ImageSearchTableViewModel(imageSearch: StubImageSearch())
}
it("eventually sets cellModels property after the search.") {
var cellModels: [ImageSearchTableViewCellModeling]? = nil
viewModel.cellModels.producer
.on(next: { cellModels = $0 })
.start()
viewModel.startSearch()
expect(cellModels).toEventuallyNot(beNil())
expect(cellModels?.count).toEventually(equal(2))
expect(cellModels?[0].id).toEventually(equal(10000))
expect(cellModels?[1].id).toEventually(equal(10001))
}
it("sets cellModels property on the main thread.") {
var onMainThread = false
viewModel.cellModels.producer
.on(next: { _ in onMainThread = NSThread.isMainThread() })
.start()
viewModel.startSearch()
expect(onMainThread).toEventually(beTrue())
}
}
}
ImageSearchTableViewModelSpec
で最初に定義しているスタブは、dummyResponse
をイベントとして送ります。1つ目のテストでは、dummyResponse
から変換された配列がcellModels
プロパティに非同期にセットされることを確認しています。2つ目のテストでは、イベントがメインスレッドで送られてくることを確認しています。
次に、ExampleViewModelTests
グループにImageSearchTableViewCellSpec.swift
を追加してください。ここでは単純に、値がid
プロパティにセットされること、また変換された値がpageImageSizeText
プロパティとtagText
プロパティにセットされることを確認しています。
ImageSearchTableViewCellModelSpec.swift
import Quick
import Nimble
@testable import ExampleModel
@testable import ExampleViewModel
class ImageSearchTableViewCellModelSpec: QuickSpec {
override func spec() {
it("sets id.") {
let viewModel = ImageSearchTableViewCellModel(image: dummyResponse.images[0])
expect(viewModel.id).toEventually(equal(10000))
}
it("formats tag and page image size texts.") {
let viewModel = ImageSearchTableViewCellModel(image: dummyResponse.images[0])
expect(viewModel.pageImageSizeText).toEventually(equal("1000 x 2000"))
expect(viewModel.tagText).toEventually(equal("a, b"))
}
}
}
Command-U
を入力してユニットテストを実行してください。パスしましたね。それでは次のセクションに移りましょう。
Viewの実装
このセクションではViewを実装していきます。Main.storyboard
を開き、プロトタイプセルにUIImageView
を1つ、UILabel
を2つ追加し、好きなようにレイアウトしてください。もしセルの高さを変更したければ、(UITableViewController
ではなく) UITableView
のrowHeight
プロパティをストーリーボードのSize Inspectorで変更してください。
それから、ImageSearchTableViewCell
にアウトレットを追加し、ストーリーボードの各アイテムと接続してください。
ImageSearchTableViewCell.swift
import UIKit
import ExampleViewModel
internal final class ImageSearchTableViewCell: UITableViewCell {
internal var viewModel: ImageSearchTableViewCellModeling? {
didSet {
tagLabel.text = viewModel?.tagText
imageSizeLabel.text = viewModel?.pageImageSizeText
}
}
@IBOutlet weak var previewImageView: UIImageView!
@IBOutlet weak var tagLabel: UILabel!
@IBOutlet weak var imageSizeLabel: UILabel!
}
インスタンスがセットされたタイミングでラベルのテキストを更新するため、viewModel
プロパティにdidSet
オブザーバを定義していることに注意してください。画像については、次のブログ記事でイメージビューにセットする方法を説明します。
ImageSearchTableViewController
に以下のように実装を追加します。
ImageSearchTableViewController.swift
import UIKit
import ExampleViewModel
public final class ImageSearchTableViewController: UITableViewController {
private var autoSearchStarted = false
public var viewModel: ImageSearchTableViewModeling? {
didSet {
if let viewModel = viewModel {
viewModel.cellModels.producer
.on(next: { _ in self.tableView.reloadData() })
.start()
}
}
}
public override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
if !autoSearchStarted {
autoSearchStarted = true
viewModel?.startSearch()
}
}
}
// MARK: UITableViewDataSource
extension ImageSearchTableViewController {
public override func tableView(
tableView: UITableView,
numberOfRowsInSection section: Int) -> Int
{
if let viewModel = viewModel {
return viewModel.cellModels.value.count
}
return 0
}
public override func tableView(
tableView: UITableView,
cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell
{
let cell = tableView.dequeueReusableCellWithIdentifier(
"ImageSearchTableViewCell",
forIndexPath: indexPath) as! ImageSearchTableViewCell
if let viewModel = viewModel {
cell.viewModel = viewModel.cellModels.value[indexPath.row]
}
else {
cell.viewModel = nil
}
return cell
}
}
最初に、ここでもdidSet
がviewModel
プロパティに定義してあり、viewModel.cellModels
プロパティにオブザーバを追加する処理をしています。そのオブザーバにより、viewModel.cellModels
が更新された時にテーブルビューが再読み込みされるようにしてあります。
次に、viewWillAppear
をオーバーライドし、ビューが初めて表示された時だけstartSearch
を実行するようにしています。viewWillAppear
はメインスレッドでのみ呼ばれるので、ロックなしでautoSearchStarted
フラグを使用しています。
最後に、単にViewModelを使用するようにUITableViewDataSource
プロトコルを実装しています。
アプリを実行する準備ができました。Command-R
を入力し、以下の画像のようにアプリが表示されるか確認しましょう。イメージビューはまだ実装していませんが、ラベルは画像のメタデータについて表示しています。
Unit Tests for View Implementation
ViewModelのstartSearch
がビューの表示時に1回だけ呼ばれることを確認するため、ExampleViewTests
グループに以下のユニットテストを追加しましょう。モックを使用して何回メソッドが呼ばれたかカウントしています。
ImageSearchTableViewControllerSpec.swift
import Quick
import Nimble
import ReactiveCocoa
import ExampleViewModel
@testable import ExampleView
class ImageSearchTableViewControllerSpec: QuickSpec {
// MARK: Mock
class MockViewModel: ImageSearchTableViewModeling {
let cellModels = AnyProperty(
MutableProperty<[ImageSearchTableViewCellModeling]>([]))
var startSearchCallCount = 0
func startSearch() {
startSearchCallCount++
}
}
// MARK: Spec
override func spec() {
it("starts searching images when the view is about to appear at the first time.") {
let viewModel = MockViewModel()
let storyboard = UIStoryboard(
name: "Main",
bundle: NSBundle(forClass: ImageSearchTableViewController.self))
let viewController = storyboard.instantiateViewControllerWithIdentifier(
"ImageSearchTableViewController")
as! ImageSearchTableViewController
viewController.viewModel = viewModel
expect(viewModel.startSearchCallCount) == 0
viewController.viewWillAppear(true)
expect(viewModel.startSearchCallCount) == 1
viewController.viewWillAppear(true)
expect(viewModel.startSearchCallCount) == 1
}
}
}
Command-U
を押してテストを実行しましょう。パスしましたね!
まとめ
例題アプリのViewとViewModel部分を実装しました。最初に空の実装のViewとViewModelをプロジェクトに追加し、Agileプラクティスが言うように動くソフトウェアを作るところから始めました。その後、実際の実装を追加しました。Model、View、ViewModelの依存性をアプリケーションから注入する方法について見ました。container
プロパティをAppDelegate
に追加することにより、Dependency Injectionに漏れなどがないかテストすることができました。ReactiveCocoaがサポートするプロパティの型について学びました。MutableProperty
は値が変更可能でオブザーバブルなプロパティでした。AnyProperty
はMutableProperty
の読み取り専用のビューとして使いました。次回のブログ記事では、非同期で画像をロードする機能を実装します。
もし質問、提案、問題などがあれば気軽にコメントをどうぞ。
-
UIStoryboard
が普通の指定イニシャライザを持たず、子クラスでイニシャライザをオーバーライドできないため、SwinjectStoryboard
のインスタンス化は少し癖があります。この問題のワークアラウンドとして、イニシャライザでなくcreate
関数を使ってSwinjectStoryboard
のインスタンスを生成します。 ↩