以下のブログ記事の翻訳です。
前回のブログ記事では、例題アプリのモデル部分を設計して実装しました。今回の記事では、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のインスタンスを生成します。 ↩





