Combine을 사용한 VIPER 아키텍처 구현
개시하다
이른바 VIPER 구조
아래의 보도를 총결하였으니, 상세한 상황은 이쪽을 보십시오.
Combine 소개
아래의 보도는 매우 참고할 만한 가치가 있으니, 상세한 상황은 이쪽을 보십시오.
Combine × VIPER 아키텍처
생각한 일
완성
샘플 코드
Presenter
Combine 도입 시 가장 큰 변화
주안점
ObservableObject
ObservableObject
PassthroughSubject
활용PassthroughSubject
에서 재생@Published
된 속성@Published
를 공개하는 게 귀찮아서 포기했습니다.샘플 코드
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
주안점
@Published
변수의 변경을 감시하고 화면 업데이트를 실시한다.샘플 코드
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)
}
}
SwiftUIArticleSearchView.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
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
주안점
샘플 코드
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라는 프로그램 라이브러리를 사용하여 실현
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
생각
NavigationLink
으로 인해 Router는 현재처럼 View를 참조하여 외부에서 화면 마이그레이션을 수행하기 어렵습니다여기 기사.Presenter에서 생성
NavigationLink
하는 방법UI 구성 요소
NavigationLink
를 사용하여Presenter에게 데이터 변경 사항을 알릴 때Presenter에게 이벤트 알림 방법@Binding
등TextField
했는데 프레젠테이션에 이벤트 알림을 일체화시켰는데 여기도 정리가 되나요?Swift Conceurrency와 공존/교환 등
TBD
생각
여기 기사. 참조가 될 수 있음
총결산
Reference
이 문제에 관하여(Combine을 사용한 VIPER 아키텍처 구현), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://zenn.dev/hicka04/articles/viper-combine텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)