LoginSignup
42
41

More than 1 year has passed since last update.

今更ながらUIViewControllerからUIViewを分離したModel-View-ViewControllerという考え方

Last updated at Posted at 2021-10-03

まず始めに

本投稿ではUIViewControllerからUIViewを分離して、UIViewControllerをPresenterやViewModelのような位置づけにする考え方を紹介していきます。
昨今ではSwiftUIが実践導入されることが増えてきているため、UIKitの構成について考えることは少なくなっているとは思いますが、UIKitを利用した場合にこういう考え方もできるのだなという1例として読んでいただけますと幸いです。

以下のようなサンプルアプリを用いて紹介していきます。

  • 検索画面で映画のタイトルを検索すると、一覧が表示できる
  • 一覧から映画を選択すると、該当の映画の詳細情報が表示できる
動作 検索 詳細
sample.gif

※サンプルコードはこちら

UIViewControllerからUIViewを分離するとは?

UIViewControllerからUIViewを分離すると記載していますが、UIViewControllerのviewを抽象化して必要な部分でのみUIViewの実体を利用するという形になります。
それでは通常のUIViewControllerでの使い方と合わせて解説していきます。

通常のUIViewControllerからのUIViewの見え方

上記のような画面を構成する際に、Storyboardxibコードレイアウトを用いると以下のような実装になっておりことが多くはないでしょうか?

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されている、UISearchBarUICollectionViewUIViewControllerのプロパティとしてアクセスできるようになっているので、図解すると以下のようになっています。

名称未設定.001.jpeg

そのため、UISearchBarUICollectionViewに容易にアクセスできるようになるため、ViewController上にレイアウトのコードや、ViewModel(またはPresenter)の繋ぎ込みのコードを実装することになると思います。

UIViewControllerからUIViewを抽象化した場合の見え方

UIViewController.viewはfunc loadView()をオーバライドし、UIViewではなく別のUIViewのサブクラスに差し替えることで、表示するViewを変更することができます。
UIViewController.viewに直接addSubviewしつつUIViewControllerのpropertyとして定義していたUIViewのサブクラスを、SearchViewを定義して移行します。
そしてSearchView自体を抽象化してUIViewControllerに渡し、func loadView()SearchViewの実体としてUIViewUIViewController.viewとすることで、UIViewControllerからほぼViewの実装を分離することができます。

名称未設定.002.jpeg

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

42
41
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
42
41