Combine을 사용한 VIPER 아키텍처 구현

개시하다


이른바 VIPER 구조


아래의 보도를 총결하였으니, 상세한 상황은 이쪽을 보십시오.
https://qiita.com/hicka04/items/09534b5daffec33b2bec
  • 각 구성 요소의 첫 글자를 따서 VIPER
  • View, Interactor, Presenter, Entity, Router
  • 단일 책임 원칙에 따라 적절히 분할
  • 각 구성 요소는protocol을 통해 교환
  • Mock 대신 쓰기 쉬운 테스트 코드
  • Combine 소개


    아래의 보도는 매우 참고할 만한 가치가 있으니, 상세한 상황은 이쪽을 보십시오.
    https://qiita.com/shiz/items/5efac86479db77a52ccc
  • 애플의 공식 함수형 재활동 프로그래밍 프레임워크
  • iOS 일대는 RxSwift, ReactiveSwift 등 OSS로 유명
  • 시간에 따라 다른 값의 처리를 선언할 수 있는 방법
  • Combine × VIPER 아키텍처


    생각한 일

  • Combine을 사용하지 않는 수속적인 VIPER 구조 이전
  • SwiftUI로 View 이동 용이
  • 쓰기 가능한 테스트 코드
  • 완성


    샘플 코드
  • Qita를 두드리는 API 일람표시
  • 자세히 마이그레이션 보기
  • Presenter


    Combine 도입 시 가장 큰 변화

    주안점

  • Presenter 준수ObservableObject
  • 공통 실현Presentation protocol을 실현ObservableObject
  • 에 부합
  • 뷰에서 Presenter로의 이벤트 알림PassthroughSubject 활용
  • enum에서 View에서 발생한 이벤트 목록을 정의하고 PassthroughSubject에서 재생
  • 유동적인 사건에 따라 인터랙터, 로터 등 곳곳에 처리를 의뢰
  • Presenter에 공개@Published된 속성
  • 화면 업데이트 프로세스는 다음 View 장에 나와 있습니다
  • .
  • 각 화면의 Preser용 protocol을 만들지 않음
  • 프레젠테이션을 만드는 프로토콜 모드도 시도했지만, 프로토콜에서 프레젠테이션에 공개하고 싶은 변수@Published를 공개하는 게 귀찮아서 포기했습니다.
  • View 테스트를 쓰지 않은 상태에서 프로토콜이 없어도 문제없다고 판단, 우선 쓰기 편의도
  • 자세한 내용은 아래 기사를 보십시오.
  • [iOS] Published attribute에서 부여한 속성은 protocol의 Requirements에 포함되지 않습니다.
  • 가능한 한 Presenter의 init에 성명하고 가능한 한 제작 방법과 속성을 표시하지 않는다
  • View→Presenter는protocol을 참조하지 않기 때문에 비privte 방법이나 속성을 만들면 밀접
  • 이 발생합니다

    샘플 코드


    ArticleSearchPresenter.swift
    ArticleSearchPresenter.swift
    import Foundation
    import Combine
    import CombineSchedulers
    
    enum ArticleSearchViewEvent {
        case viewDidLoad
        case refreshControlValueChanged
        case didSelect(article: ArticleModel)
    }
    
    final class ArticleSearchPresenter: Presentation {
        private var cancellables: Set<AnyCancellable> = []
        let viewEventSubject = PassthroughSubject<ArticleSearchViewEvent, Never>()
        
        @Published var articles: [ArticleModel] = []
        @Published var articleSearchError: ArticleSearchError?
        
        init<
            Router: ArticleSearchWireframe,
            ArticleSearchInteractor: ArticleSearchUsecase
        >(
            mainScheduler: AnySchedulerOf<DispatchQueue> = .main,
            router: Router,
            articleSearchInteractor: ArticleSearchInteractor
        ) {
            let searchKeywordSubject = PassthroughSubject<String, Never>()
            
            // 受け取ったイベントをもとに処理の依頼を各所に投げる
            viewEventSubject
                .sink { event in
                    switch event {
                    case .viewDidLoad, .refreshControlValueChanged:
                        searchKeywordSubject.send("Swift")
                        
                    case .didSelect(let article):
                        router.navigationSubject.send(.articleDetail(article))
                    }
                }.store(in: &cancellables)
            
            // キーワードが変更されたら記事を検索して結果を`@Published`のプロパティにセット
            searchKeywordSubject
                .flatMap { searchKeyword in
                    articleSearchInteractor
                        .execute(searchKeyword)
                        .convertToResultPublisher()
                }.receive(on: mainScheduler)
                .sink { [weak self] result in
                    switch result {
                    case .success(let articles):
                        self?.articles = articles
                        
                    case .failure(let error):
                        self?.articleSearchError = error
                    }
                }.store(in: &cancellables)
        }
    }
    

    View


    주안점

  • Presenter에 공개된 @Published 변수의 변경을 감시하고 화면 업데이트를 실시한다.
  • 상태 및 UI 동기화 용이
  • 동일한 Presenter를 사용하여 UIKit 및 SwiftUI만 화면
  • SwiftUI로 화면 단위로 전환 가능
  • 샘플 코드


    UIKit
    ArticleSearchViewController.swift
    ArticleSearchViewController.swift
    import UIKit
    import Combine
    import CombineCocoa
    
    class ArticleSearchViewController: UICollectionViewController {
        private let presenter: ArticleSearchPresenter
        
        // ...
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // ...
            
            // 記事の一覧が更新されたらリストを更新
            presenter.$articles
                .sink { [weak self] articles in
                    var snapshot = NSDiffableDataSourceSnapshot<Int, ArticleModel>()
                    snapshot.appendSections([0])
                    snapshot.appendItems(articles, toSection: 0)
                    self?.dataSource.apply(snapshot, animatingDifferences: true) {
                        self?.collectionView.refreshControl?.endRefreshing()
                    }
                }.store(in: &cancellables)
            
            // エラーが発生したらアラートを出す
            presenter.$articleSearchError
                .compactMap { $0 }
                .sink { [weak self] error in
                    let alert = UIAlertController(
                        title: error.errorDescription,
                        message: error.recoverySuggestion,
                        preferredStyle: .alert
                    )
                    alert.addAction(.init(title: "OK", style: .default, handler: nil))
                    self?.present(alert, animated: true) {
                        self?.collectionView.refreshControl?.endRefreshing()
                    }
                }.store(in: &cancellables)
            
            // Viewで発生したイベントをPresenterに通知
            presenter.viewEventSubject.send(.viewDidLoad)
        }
    }
    
    SwiftUI
    ArticleSearchView.swift
    ArticleSearchView.swift
    import SwiftUI
    
    struct ArticleSearchView: View {
        @ObservedObject var presenter: ArticleSearchPresenter
        
        var body: some View {
            ArticleListView(
                articles: presenter.articles, // 記事の一覧が更新されたらリストを更新
                onTapArticle: { article in
                    presenter.viewEventSubject.send(.didSelect(article: article))
                }
            )
            .alert(item: $presenter.articleSearchError) { error in // エラーが発生したらアラートを出す
                Alert(
                    title: .init(error.errorDescription),
                    message: error.recoverySuggestion.map { .init($0) },
                    dismissButton: nil
                )
            }.navigationBarTitle(Text("Articles"), displayMode: .large)
            .onAppear {
                // Viewで発生したイベントをPresenterに通知
                presenter.viewEventSubject.send(.viewDidLoad)
            }
        }
    }
    
    extension ArticleSearchView {
        struct ArticleListView: View {
            let articles: [ArticleModel]
            let onTapArticle: (ArticleModel) -> Void
            
            var body: some View {
                List(articles) { article in
                    HStack {
                        Text(article.title)
                        Spacer()
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                        onTapArticle(article)
                    }
                }.listStyle(.plain)
            }
        }
    }
    

    Router


    주안점

  • 화면 마이그레이션 요청 알림 활용 PassthroughSubject
  • 전환 목적지를 enum으로 정의하고 PassthroughSubject
  • 로 흐른다.

    샘플 코드


    ArticleSearchRouter.swift
    ArticleSearchRouter.swift
    import UIKit
    import SwiftUI
    import Combine
    
    enum ArticleSearchDestination: Equatable {
        case articleDetail(_ article: ArticleModel)
    }
    
    protocol ArticleSearchWireframe: Wireframe where Destination == ArticleSearchDestination {}
    
    final class ArticleSearchRouter: ArticleSearchWireframe {
        private var cancellables: Set<AnyCancellable> = []
        fileprivate weak var viewController: UIViewController?
        let navigationSubject = PassthroughSubject<ArticleSearchDestination, Never>()
        
        private init() {
            // 画面遷移
            navigationSubject
                .sink { destination in
                    switch destination {
                    case .articleDetail(let article):
                        let articleDetailView = ArticleDetailRouter.assembleModules(article: article)
                        self.viewController?.navigationController?.pushViewController(articleDetailView, animated: true)
                    }
                }.store(in: &cancellables)
        }
        
        // DI
        static func assembleModules() -> UIViewController {
            let router = ArticleSearchRouter()
            let qiitaDataStore = QiitaDataStore()
            let articleSearchInteractor = ArticleSearchInteractor(qiitaRepository: qiitaDataStore)
            let presenter = ArticleSearchPresenter(router: router, articleSearchInteractor: articleSearchInteractor)
            let view = ArticleSearchViewController(presenter: presenter)
            
            router.viewController = view
            
            return view
        }
    }
    

    Interactor


    주안점

  • Interactor에서 구현한 비즈니스 로직의 결과를 반환할 때 Publisher
  • 를 사용합니다.

    샘플 코드


    ArticleSearchInteractor.swift
    ArticleSearchInteractor.swift
    import Foundation
    import Combine
    
    struct ArticleSearchError: UsecaseError, Identifiable {
        private let error: QiitaRepositoryError
        
        var errorDescription: String {
            // ...
        }
        
        var recoverySuggestion: String? {
            // ...
        }
        
        var id: String {
            error.localizedDescription
        }
        
        init(error: QiitaRepositoryError) {
            self.error = error
        }
    }
    
    protocol ArticleSearchUsecase: Usecase
    where Input == String,
          Output == [ArticleModel],
          Failure == ArticleSearchError {}
    
    final class ArticleSearchInteractor {
        private let qiitaRepository: QiitaRepository
        
        init(qiitaRepository: QiitaRepository) {
            self.qiitaRepository = qiitaRepository
        }
    }
    
    extension ArticleSearchInteractor: ArticleSearchUsecase {
        func execute(_ input: String) -> AnyPublisher<[ArticleModel], ArticleSearchError> {
            qiitaRepository
                .searchArticles(keyword: input)
                .mapError { .init(error: $0) }
                .eraseToAnyPublisher()
        }
    }
    

    테스트 코드


  • combine-schedulers라는 프로그램 라이브러리를 사용하여 실현
  • iOSDC 2021의 "Scheduller로 Combine 코드를 사용한 테스트 방법과 그 구조를 조종했다"는 세션은 매우 참고 가치가 있다.

  • 슬라이드 쇼
  • PresenterTests


    ArticleSearchPresenterTests.swift
    ArticleSearchPresenterTests.swift
    @testable import ViperCombineSample
    import Quick
    import Nimble
    import Combine
    import CombineSchedulers
    import OrderedCollections
    
    final class ArticleSearchPresenterTests: QuickSpec {
        override func spec() {
            var cancellables: Set<AnyCancellable> = []
            var testScheduler: TestSchedulerOf<DispatchQueue>!
            
            var presenter: ArticleSearchPresenter!
            var router: MockArticleSearchRouter!
            var articleSearchInteractor: MockArticleSearchInteractor!
            
            var articlesOutputs: [[ArticleModel]] = []
            var articleSearchErrorOutputs: [ArticleSearchError?] = []
            var navigationOutputs: [ArticleSearchDestination] = []
            
            beforeEach {
                testScheduler = DispatchQueue.test
                
                router = .init()
                articleSearchInteractor = .init()
                presenter = .init(
                    mainScheduler: testScheduler.eraseToAnyScheduler(),
                    router: router,
                    articleSearchInteractor: articleSearchInteractor
                )
                
                presenter.$articles.sink { articlesOutputs.append($0) }.store(in: &cancellables)
                presenter.$articleSearchError.sink { articleSearchErrorOutputs.append($0) }.store(in: &cancellables)
                router.navigationSubject.sink { navigationOutputs.append($0) }.store(in: &cancellables)
            }
            
            afterEach {
                cancellables = []
                articlesOutputs = []
                articleSearchErrorOutputs = []
                navigationOutputs = []
            }
            
            describe("viewDidLoad") {
                beforeEach {
                    testScheduler.schedule {
                        presenter.viewEventSubject.send(.viewDidLoad)
                    }
                }
                
                context("articleSearchInteractorの返却値がエラーのとき") {
                    let error = ArticleSearchError(error: .connectionError(NSError(domain: "hoge", code: -1, userInfo: nil)))
                    
                    beforeEach {
                        testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
                            articleSearchInteractor.executeResult.send(completion: .failure(error))
                        }
                        
                        testScheduler.advance(by: 10)
                    }
                    
                    it("articleSearchErrorが更新される") {
                        expect(articleSearchErrorOutputs) == [
                            nil,
                            error
                        ]
                    }
                }
                
                context("articleSearchInteractorの返却値が成功のとき") {
                    let articles = [
                        ArticleModel(id: .init(rawValue: "article_id"), title: "article_title", body: "article_body")
                    ]
                    
                    beforeEach {
                        testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
                            articleSearchInteractor.executeResult.send(articles)
                        }
                        
                        testScheduler.advance(by: 10)
                    }
                    
                    it("articlesが更新される") {
                        expect(articlesOutputs) == [
                            [],
                            .init(articles)
                        ]
                    }
                }
            }
    
            // ...
        }
    }
    

    InteractorTests


    ArticleSearchInteractorTests.swift
    ArticleSearchInteractorTests.swift
    @testable import ViperCombineSample
    import Foundation
    import Quick
    import Nimble
    import Combine
    import CombineSchedulers
    
    final class ArticleSearchInteractorTests: QuickSpec {
        override func spec() {
            var cancellables: Set<AnyCancellable> = []
            var testScheduler: TestSchedulerOf<DispatchQueue>!
            var executeOutputs: [Result<ArticleSearchInteractor.Output, ArticleSearchInteractor.Failure>] = []
            
            var interactor: ArticleSearchInteractor!
            var qiitaDataStore: MockQiitaDataStore!
            
            let input: ArticleSearchInteractor.Input = "Swift"
            let error = NSError(domain: "hoge", code: -1, userInfo: nil)
            
            beforeEach {
                testScheduler = DispatchQueue.test
                
                qiitaDataStore = .init()
                interactor = .init(qiitaRepository: qiitaDataStore)
            }
            
            afterEach {
                cancellables = []
                executeOutputs = []
            }
            
            describe("execute") {
                beforeEach {
                    testScheduler.schedule {
                        interactor.execute(input)
                            .convertToResultPublisher()
                            .sink { executeOutputs.append($0) }
                            .store(in: &cancellables)
                    }
                }
    
                context("dataStoreの返却値がconnectionErrorのとき") {
                    let connectionError: QiitaRepositoryError = .connectionError(error)
                    
                    beforeEach {
                        testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
                            qiitaDataStore.searchArticlesResult.send(completion: .failure(connectionError))
                        }
                        
                        testScheduler.advance(by: 10)
                    }
                    
                    it("エラーが返却される") {
                        expect(executeOutputs) == [
                            .failure(.init(error: connectionError))
                        ]
                    }
                }
    
                // ...
                
                context("dataStoreの返却値が成功のとき") {
                    let response = [
                        ArticleModel(id: .init(rawValue: "article_id"), title: "article_title", body: "article_body")
                    ]
                    
                    beforeEach {
                        testScheduler.schedule(after: testScheduler.now.advanced(by: 10)) {
                            qiitaDataStore.searchArticlesResult.send(response)
                        }
                        
                        testScheduler.advance(by: 10)
                    }
                    
                    it("[ArticleModel]が返却される") {
                        expect(executeOutputs) == [
                            .success(response)
                        ]
                    }
                }
            }
        }
    }
    

    향후의 전망


    전체 Swift UI화


    TBD

    생각

  • 화면 이동 방법
  • SwiftUI의 화면 마이그레이션 사용NavigationLink으로 인해 Router는 현재처럼 View를 참조하여 외부에서 화면 마이그레이션을 수행하기 어렵습니다
  • .
  • 현재 Router는Presenter가 참조하지만 View는 직접 참조하는 방법을 연구하고 있습니다.
  • 로터에'이동 목적지의 enum을 수신하고 각 케이스에 대응하는 View로 돌아가는 방법'을 실시하면 수신된 View에 따라 생성할 수 있지 않습니까?

  • 여기 기사.Presenter에서 생성NavigationLink하는 방법

  • UI 구성 요소NavigationLink를 사용하여Presenter에게 데이터 변경 사항을 알릴 때Presenter에게 이벤트 알림 방법
  • ex@Binding
  • 이번에 제작TextField했는데 프레젠테이션에 이벤트 알림을 일체화시켰는데 여기도 정리가 되나요?
  • Swift Conceurrency와 공존/교환 등


    TBD

    생각

  • Combine으로 어디를 쓰고, Conceurrency로 어디를 쓰고, 모두 바꾸는 등의 선을 긋는다
  • View Bind 데이터가 가장 좋은 곳은 Combine?(SwiftUI로 전환 고려)
  • 이외에도 Conceurrency는...
  • 을 쓸 수 있습니다.
  • 컴바인 조종사에게서 async 방법을 호출할 수 있을까...?Task에서만 불러낼 수 있다면 전부 다 털어낼 수밖에...?

  • 여기 기사. 참조가 될 수 있음
  • 퓨처를 사용하기 때문에 AsyncSequence처럼 결과를 여러 번 재생하는 방법은 대응할 수 없습니다. 63?조사 필요
  • 총결산

  • Combine을 사용하지 않은 코드에서 쉽게 이동할 수 있고 SwiftUI로 쉽게 이동할 수 있음을 고려하여 실시하였다
  • 선언적으로 작성할 수 있으므로 상태 및 UI 동기화 용이
  • init와viewDidLoad의 코드 양이 많아졌기 때문에 도를 이용하여 일시적인 범위를 만드는 등 가독성을 높이는 연구가 필요할 수 있다
  • combin-scheduulers를 이용하여 테스트 코드
  • 를 실현할 수 있다

    좋은 웹페이지 즐겨찾기