まず始めに
本投稿ではUIViewControllerからUIViewを分離して、UIViewControllerをPresenterやViewModelのような位置づけにする考え方を紹介していきます。
昨今ではSwiftUIが実践導入されることが増えてきているため、UIKitの構成について考えることは少なくなっているとは思いますが、UIKitを利用した場合にこういう考え方もできるのだなという1例として読んでいただけますと幸いです。
以下のようなサンプルアプリを用いて紹介していきます。
- 検索画面で映画のタイトルを検索すると、一覧が表示できる
- 一覧から映画を選択すると、該当の映画の詳細情報が表示できる
動作 | 検索 | 詳細 |
---|---|---|
※サンプルコードはこちら
UIViewControllerからUIViewを分離するとは?
UIViewControllerからUIViewを分離すると記載していますが、UIViewControllerのviewを抽象化して必要な部分でのみUIViewの実体を利用するという形になります。
それでは通常のUIViewControllerでの使い方と合わせて解説していきます。
通常のUIViewControllerからのUIViewの見え方
上記のような画面を構成する際に、Storyboard
・xib
・コードレイアウト
を用いると以下のような実装になっておりことが多くはないでしょうか?
class SearchViewController: UIViewController {
@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var collectionView: UICollectionView!
override func viewDidLoad() {
super.viewDidLoad()
searchBar.delegate = self
collectionView.dataSource = self
}
}
UIViewController.view
上にaddSubviewされている、UISearchBar
とUICollectionView
にUIViewController
のプロパティとしてアクセスできるようになっているので、図解すると以下のようになっています。
そのため、UISearchBar
とUICollectionView
に容易にアクセスできるようになるため、ViewController上にレイアウトのコードや、ViewModel(またはPresenter)の繋ぎ込みのコードを実装することになると思います。
UIViewControllerからUIViewを抽象化した場合の見え方
UIViewController.viewはfunc loadView()をオーバライドし、UIViewではなく別のUIViewのサブクラスに差し替えることで、表示するViewを変更することができます。
UIViewController.viewに直接addSubviewしつつUIViewControllerのpropertyとして定義していたUIViewのサブクラスを、SearchView
を定義して移行します。
そしてSearchView
自体を抽象化してUIViewControllerに渡し、func loadView()
でSearchView
の実体としてUIView
をUIViewController.view
とすることで、UIViewControllerからほぼViewの実装を分離することができます。
iOS11からsafeAreaが追加されたことで、UIViewController.topLayoutGuide
を使わずにUIView単体でレイアウトを組みきれるようになったため、上記の構成であればUIViewController側にレイアウトコードが漏れてくることもありません。
final class SearchViewController: UIViewController {
private let viewContainer: AnyViewContainer<SearchInput, SearchOutput>
...
init(
viewContainer: AnyViewContainer<SearchInput, SearchOutput>,
...
) {
self.viewContainer = viewContainer
...
super.init(nibName: nil, bundle: nil)
}
override func loadView() {
// viewの差し替えをするだけで、以降UIViewの処理をこのUIViewControllerで実行することはない
view = viewContainer.view
}
override func viewDidLoad() {
super.viewDidLoad()
}
}
UIViewControllerをViewModelのような位置づけで実装する
UIViewControllerからUIViewを分離できたとしても、以後UIViewController上でUIViewにアクセスせずに処理することができるのかという疑問があると思います。
UIViewのサブクラスを以下のように抽象化することで、UIに関するロジックのみをUIViewControllerに実装することとなり、ViewModelのような位置づけで実装することができるようになります。
ViewContainer
ViewContainer
はUIViewのサブクラスを抽象化するために以下のような定義が存在します。
- Viewの更新処理を実行するためのinput
- Viewからのイベントを外部に伝達するためのoutput
- ViewControllerでViewを差し替えるためにUIViewとしてアクセスできるようにするためのview
protocol ViewContainer {
associatedtype Input
associatedtype Output
var input: Input { get }
var output: Output { get }
var view: UIView { get }
}
extension ViewContainer where Self: UIView {
var view: UIView { self }
}
上記をSearchViewに当てはめると以下のようなInputとOutputのprotocolになります。
public protocol SearchInput {
func setSnapshot(_ snapshot: NSDiffableDataSourceSnapshot<SearchSection, SearchItem>)
}
public protocol SearchOutput {
var searchText: AnyPublisher<String?, Never> { get }
var didScroll: AnyPublisher<(CGSize, CGSize, CGPoint), Never> { get }
var didSelectIndexPath: AnyPublisher<IndexPath, Never> { get }
}
一般的なViewModelの実装であればViewModelにInput・Outputの実装をすると思いますが、こちらの実装ではViewからのInput・OutputをViewControllerに抽象化して注入する形になるので、見え方が違っているだけでViewとPresenterの関係に似ている実装となります。
上記のInputとOutput及びViewContainerをSearchViewに適用すると以下のようになります。
extension SearchView: SearchInput {
func setSnapshot(_ snapshot: SearchSnapshot) {
dataSource.apply(snapshot, animatingDifferences: true, completion: nil)
}
}
extension SearchView: SearchOutput {
var didSelectIndexPath: AnyPublisher<IndexPath, Never> {
// UICollectionViewDelegateの処理を任意の方法でCombine化した処理
...
}
var didScroll: AnyPublisher<(CGSize, CGSize, CGPoint), Never> {
// UIScrollViewDelegateの処理を任意の方法でCombine化した処理
...
}
var searchText: AnyPublisher<String?, Never> {
// UISearchBarDelegateの処理を任意の方法でCombine化した処理
...
}
}
extension SearchView: ViewContainer {
var input: SearchInput { self }
var output: SearchOutput { self }
}
上記のような実装をすることで、SearchViewを抽象化したInput・Output・ViewContainerとして扱うことができますが、ViewContainerはassociatedTypeを持っているためExistentialとしては扱えないため、AnyViewContainer
として型消去をする必要があります。
struct AnyViewContainer<Input, Output>: ViewContainer {
let input: Input
let output: Output
let view: UIView
init<T: ViewContainer>(_ container: T) where T.Input == Input, T.Output == Output {
self.input = container.input
self.output = container.output
self.view = container.view
}
}
AnyViewContainer
を用いることで
let viewContainer = AnyViewContainer<SearchInput, SearchOutput>(SearchView())
のようにInputとOutputの型のみに着目した抽象化されたViewとして扱うことができるようになります。
AnyViewContainerを用いたViewControllerの実装
あとはViewModelを実装するように、ViewControllerでCombineなどを利用して処理を実装していく形になります。
以下はviewContainer.output
から検索を実行のイベントを受け取り、検索結果をsnapshotに変換をしてviewContainer.input
に渡す実装となります。
final class SearchViewController: UIViewController {
private let viewContainer: AnyViewContainer<SearchInput, SearchOutput>
private let model: SearchModel
private let mainScheduler: AnySchedulerOf<DispatchQueue>
private var cancellables = Set<AnyCancellable>()
@Published private var snapshot = SearchSnapshot()
@Published private var isBottom = false
init(
viewContainer: AnyViewContainer<SearchInput, SearchOutput>,
model: SearchModel,
mainScheduler: AnySchedulerOf<DispatchQueue>
) {
self.viewContainer = viewContainer
self.model = model
self.mainScheduler = mainScheduler
super.init(nibName: nil, bundle: nil)
}
public override func loadView() {
view = viewContainer.view
}
public override func viewDidLoad() {
super.viewDidLoad()
viewContainer.output.searchText
.flatMap { [model] query -> AnyPublisher<TMDBError, Never> in
guard let query = query else {
return Empty().eraseToAnyPublisher()
}
return model.search(query: query)
.flatMap { result -> AnyPublisher<TMDBError, Never> in
// エラーハンドリング
...
}
.eraseToAnyPublisher()
}
.receive(on: mainScheduler)
.sink { [weak self] error in
...
}
.store(in: &cancellables)
model.moviesPulisher
.flatMap { movies -> AnyPublisher<SearchSnapshot, Never> in
// snapshotへの変換処理
...
}
.assign(to: &$snapshot)
$snapshot
.receive(on: mainScheduler)
.sink { [weak self] in
self?.viewContainer.input.setSnapshot($0)
}
.store(in: &cancellables)
}
}
SearchView
に直接アクセスすることはなく、AnyViewContainer<SearchInput, SearchOutput
を介して伝達を行っています。
よって、UIViewControllerのユニットテストを書く際も、SearchViewに依存せずにモック化したInput・Outputから実施可能になります。
ViewContainerを用いたUIViewControllerのユニットテスト
ユニットテストを実施する際に、ViewControllerに対してモック化したViewContainerを渡す必要があるので、それぞれ必要な値を監視、置き換えできるように以下のような実装をします。
private final class ViewContainerMock: SearchInput, SearchOutput, ViewContainer {
var view: UIView { UIView() }
var input: SearchInput { self }
var output: SearchOutput { self }
let _searchText = PassthroughSubject<String?, Never>()
var searchText: AnyPublisher<String?, Never> { _searchText.eraseToAnyPublisher() }
let _setSnapshot = PassthroughSubject<SearchSnapshot, Never>()
func setSnapshot(_ snapshot: SearchSnapshot) { _setSnapshot.send(snapshot) }
}
上記で定義したViewContainerのモックをSearchViewControllerに注入して、ViewModelのテストを実装するようにしていきます。
final class ViewModelLikeTests: XCTestCase {
private var testTarget: SearchViewController!
private var viewContainer = ViewContainerMock()
private let model = SearchModelMock()
private let mainScheduer = AnySchedulerOf<DispatchQueue>.immediate
private var cancellabes = Set<AnyCancellable>()
override func setUpWithError() throws {
testTarget = SearchViewController(
viewContainer: viewContainer.eraseToAnyViewContainer(),
model: model,
mainScheduler: mainScheduer
)
testTarget.loadViewIfNeeded()
}
override func tearDownWithError() throws {
cancellabes.removeAll()
}
func testSnapshot_moviesPublisher_changed() {
let snapshot = CurrentValueSubject<SearchSnapshot?, Never>(nil)
viewContainer._setSnapshot
.sink(receiveValue: snapshot.send)
.store(in: &cancellabes)
let movies = [
Movie(
id: .init(rawValue: 1),
title: "test-title",
posterPath: nil
)
]
model.movies.send(movies)
let expected = [
SearchItem.movie(
MovieViewDataModel(
id: 1,
title: "test-title",
imageURL: nil
)
)
]
XCTAssertEqual(expected, snapshot.value?.itemIdentifiers(inSection: .movie))
}
}
UIViewControllerをPresenterのような位置づけで実装する
上記でViewModelのような位置づけの実装を紹介しましたが、同様にPresenterのような位置づけの実装もできます。
ViewContainer
Presenterのような位置づけの実装の場合は、ViewContainer
は以下のような定義をします。
- ViewControllerでViewを差し替えるためにUIViewとしてアクセスできるようにするためのview
public protocol ViewContainer: AnyObject {
var view: UIView { get }
}
extension ViewContainer where Self: UIView {
public var view: UIView { self }
}
そして、各Viewの単位で以下のような抽象化したViewとPresenterを定義します。
ViewControllerがPresenterの役割になるだけなので、protocolの定義は一般的なViewとPresenterを定義する場合と同様になります。
protocol DetailPresenterLike: AnyObject {
func didSelectIndexPath(_ indexPath: IndexPath)
}
protocol DetailViewLike: ViewContainer {
var presenterLike: DetailPresenterLike? { get set }
func setSnapshot(_ snapshot: NSDiffableDataSourceSnapshot<DetailSection, DetailItem>)
}
上記をDetailViewに適用すると以下のようになります。
final class DetailView: UIView {
weak var presenterLike: DetailPresenterLike?
...
}
extension DetailView: DetailViewLike {
func setSnapshot(_ snapshot: DetailSnapshot) {
dataSource.apply(snapshot, animatingDifferences: true, completion: nil)
}
}
extension DetailView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
presenterLike?.didSelectIndexPath(indexPath)
}
}
ViewContainerを用いたViewControllerの実装
あとはPresenterを実装するように、ViewControllerを実装していく形になります。
ViewModelとPresenterの違いは依存の方向とprotocolの定義の単位だけなので、呼び出し方などが変わる可能性はありますが内部実装はほぼ同様になります。
以下はMovie.IDに紐づく詳細情報を取得してをsnapshotに変換をしDetailViewLike
を介して反映を行い、描画されているItemがタップされるとDetailPresenterLike
のデリゲートメソッドからイベントを受け取って任意の処理を実行する実装となります。
final class DetailViewController: UIViewController {
private let viewContainer: DetailViewLike
private let model: DetailModel
private let movieID: Movie.ID
private let mainScheduler: AnySchedulerOf<DispatchQueue>
private let _didSelectIndexPath = PassthroughSubject<IndexPath, Never>()
private var cancellables = Set<AnyCancellable>()
@Published private var snapshot = DetailSnapshot()
public init(
movieID: Movie.ID,
viewContainer: DetailViewLike,
model: DetailModel,
mainScheduler: AnySchedulerOf<DispatchQueue>
) {
self.viewContainer = viewContainer
self.model = model
self.movieID = movieID
self.mainScheduler = mainScheduler
super.init(nibName: nil, bundle: nil)
}
public override func loadView() {
view = viewContainer.view
}
public override func viewDidLoad() {
super.viewDidLoad()
viewContainer.presenterLike = self
model.movieDetailPublisher
.map { movieDetail -> (DetailSnapshot, ThumbnailStatus?) in
// snapshotへの変換処理
...
}
.map { $0.0 }
.assign(to: &$snapshot)
model.loadMovieDetail(movieID: movieID)
.sink { _ in
// エラーハンドリング
...
}
.store(in: &cancellables)
$snapshot
.receive(on: mainScheduler)
.sink { [weak self] in
self?.viewContainer.setSnapshot($0)
}
.store(in: &cancellables)
_didSelectIndexPath
.throttle(for: 1, scheduler: mainScheduler, latest: false)
.flatMap { [weak self] indexPath -> AnyPublisher<DetailItem, Never> in
guard let me = self else {
return Empty().eraseToAnyPublisher()
}
let section = me.snapshot.sectionIdentifiers[indexPath.section]
let item = me.snapshot.itemIdentifiers(inSection: section)[indexPath.item]
return Just(item).eraseToAnyPublisher()
}
.sink { [weak self] item in
// 任意のアイテムが選択された場合の処理
}
.store(in: &cancellables)
}
}
extension DetailViewController: DetailPresenterLike {
public func didSelectIndexPath(_ indexPath: IndexPath) {
_didSelectIndexPath.send(indexPath)
}
}
DetailView
に直接アクセスすることはなく、DetailViewLike
を介して伝達を行っています。
よって、UIViewControllerのユニットテストを書く際も、DetailViewに依存せずにモック化したDetailViewLikeから実施可能になります。
ViewContainerを用いたUIViewControllerのユニットテスト
ユニットテストを実施する際に、ViewControllerに対してモック化したDetailViewLikeを渡す必要があるので、それぞれ必要な値を監視、置き換えできるように以下のような実装をします。
private final class DetailViewLikeMock: DetailViewLike {
weak var presenterLike: DetailPresenterLike?
var view: UIView { UIView() }
let _setSnapshot = PassthroghtSubject<DetailSnapshot, Never>()
func setSnapshot(_ snapshot: DetailSnapshot) {
_setSnapshot.send(snapshot)
}
}
上記で定義したDetailViewLikeのモックをDetalViewControllerに注入して、Presenterのテストを実装するようにしていきます。
final class PresenterLikeTests: XCTestCase {
private var testTarget: DetailViewController!
private let movieID = Movie.ID(rawValue: 100)
private let model = DetailModelMock()
private let viewContainer = DetailViewLikeMock()
private var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
testTarget = DetailViewController(
movieID: movieID,
viewContainer: viewContainer,
model: model,
mainScheduler: .immediate
)
testTarget.loadViewIfNeeded()
}
override func tearDownWithError() throws {
cancellables.removeAll()
}
func testSnapshot_movieDetailPublisher_changed() {
let snapshot = CurrentValueSubject<DetailSnapshot?, Never>(nil)
viewContainer._setSnapshot.params
.sink(receiveValue: snapshot.send)
.store(in: &cancellables)
let detail = MovieDetail(
id: movieID,
title: "title",
posterPath: nil,
backdropPath: nil,
overview: "overview",
releaseDate: "2000-01-01",
runtime: 100,
credits: nil,
recommendations: ListResponse(
page: 1,
results: [
Movie(
id: .init(rawValue: 200),
title: "title2",
posterPath: nil
)
],
totalResults: 1,
totalPages: 1
),
images: MovieImage(
backdrops: [
Image(filePath: .init(rawValue: URL(string: "https://sample.com/image/1")!))
],
posters: []
)
)
model.movieDetail.send(detail)
XCTAssertEqual(
[
DetailItem.thumbnail(.image(URL(string: "https://sample.com/image/1")!)),
DetailItem.summary(.init(title: "title", release: "2000-01-01")),
DetailItem.overview(.fixed("overview")),
DetailItem.movie(.init(id: 200, title: "title2", imageURL: nil))
],
snapshot.value?.itemIdentifiers
)
}
}
最後に
ここまでに解説してきたような構成が正しいというわけではなく、UIViewControlllerとUIViewをしっかり分離・抽象化すればViewModelやPresenterなどを用いらずに同様のことがUIViewControllerでも実現できるという内容でした。
UIViewControllerに既存のViewModelやPresenterのような位置づけをする紹介だったので、ViewModelやPresenterの実装に関連するような細かい解説はスキップしている形となっています。
より詳細な実装についてはサンプルがございますので、興味がありましたらご覧いただけますと幸いです。
https://github.com/marty-suzuki/ViewIsolatedViewController