Mobile app architecture - David Broza - Do iOS 2018

rbw·2023년 3월 22일
0

TIL

목록 보기
77/97

https://www.youtube.com/watch?v=AVdfJVSum_Y&list=PLw-3TTKkn1fM0K30mImLB0JPF6aWBhSEM&index=12

위 영상을 보고 번역/정리한 글, 자세한 내용은 영상보시길츄천


발표자분의 상황은 지속적으로 변화하는 팀이고, 테스트가 잘 되는 구조를 원했습니다.

쉽게 배울수 있고 하나의 컴포넌트는 하나의 책임을 가지는 아키텍처를 요구함

또 뷰 모델은 어디에 둘지, 네트워킹은 어디에 둘지 명확해야함

발표자 분이 사용하는 구조에 대해서 알아보는 세션인듯

Key Components

  • Views
  • Presenters
  • Navigator?
  • Assembly
  • Interactor?

여기서 보여주는 구조의 흐름도는 다음과 같습니다.

View

  • Allows you to display things
  • Is an interface
  • ViewControllers or Views
  • Should not know about UIKit

이는 단순히 우리가 알고 있는 것을 표시할 수 있도록 합니다.

예제 코드

protocol TODOListView: class {
    func showTODOs(todo: [TODOItem]) -> ()
}

class TODOListViewController: UIViewController, TODOListView {
    func showTODOs(todo: [TODOItem]) {
        // ...
    }
}

Presenter

  • Receives actions - UI Input
  • Implements logic
  • Sends display events to view

이는 뷰 컨트롤러가 로드되거나 사용자가 버튼을 누를 때 사용자로 부터 UI 작업을 수신할 수 있습니다. 뷰 컨트롤러는 프레젠터에게 이 작업을 알리고, 프레젠터는 이 작업을 처리하는 로직을 구현하고 디스플레이 이벤트를 뷰로 보냅니다.

class TODOListPresenter {
    weak var view: TODOListView?
    func didLoad() {
        let todos = self.todoListRepository.getAllTODO()
        self.view?.showTODOs(todo: todos)
    }
}

여기서 멋진 점은 레포지토리가 뷰의 인터페이스라서 테스트하기 더 쉬워진다는 점이라고 하네요

아래처럼 테스트용 레포지토리를 만들고 실제 프레젠터를 인스턴스화하여 테스트를 진행하면 됩니다.

class TODOListPresenterTest: XCTestCase {
    var mockRepository: MockTodoRepo!
    var presenter: TODOListPresenter!

    override func setUp() {
        self.mockRepository = MockTodoRepo()
        self.presenter = TODOListPresenter(repository: mockRepository)
    }

    func testDidLoad(){
        // verify는 메소드가 호출되었는지 확인하는 정도의 테스트라고 하네요
        // 이 팀에서 만들어서 사용하는 방법이라고 함니다
        XCTAssertFalse(verify(MockTodoRepo.getAllTODOs))
        presenter.didLoad()
        XCTAssertTrue(verify(MockTodoRepo.getAllTODOS))
    }
}

위 코드에서 뷰도 인터페이스이기 때문에 모의 뷰도 포함하여 테스트를 하는 좀 더 현실적인 테스트 코드는 다음과 같습니다.

위 코드에서 아래 코드들이 추가되었다고 생각하시면 됩니다

// Property
var view: MockTodoView!

// setUp
self.view = MockTodoView()
self.presenter.view = self.view

// testDidLoad
XCTAssertTrue(verify(MockTodoView.showTODOs))

Assembly

  • Wires together presenters, views, navigators, ...
  • Creates other assemblies
  • Acts as a facotry for view controllers
  • Assembly tree defines your whole app

연결하는 친구라서 이름이 어셈블리인듯하네여

class TODOAssembly {
    var navigationController: UINavigationController
    var todoReposirtory: TODORepository

    init(navigationController: UINavigationController,
    todoRepository: TODORepository) {
        ...
    }

    var todoViewController: TODOListViewController {
        let todoPresenter = TODOListPresenter(repository: todoRepository)
        let vc = TODOListViewController(presenter: todoPresenter)
        return vc
    }
}

어셈블리 트리 구조는 다음과 같슴다.

간단한 예시 코드

class LoginAssembly {
    // ...
    var loginViewController: LoginViewController {
        return LoginViewControlelr()
    }
}
class TrafficAssembly {
    // ...
    var trafficViewController: TrafficViewController {
        return UIViewController()
    }
}
class CoreAssembly {
    lazy var loginAssembly = LoginAssembly(self)
    lazy var trafficAssembly = TrafficAssembly(self)

    var rootViewController: UIViewController {
        return trafficAssembly.trafficViewController
    }
}

이를 AppDelegate에서 초기 윈도우에 설정하는 코드는 다음과 같습니다.

let coreAssembly = CoreAssembly()

// 2018년 세션이라 @Main이 아닌가보네용
@UIApplicationMain
class AppDelgate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: ...) -> Bool {
        self.window?.rootViewController = coreAssembly.rootViewController

        return true
    }
}

위 코드처럼 앱이 실행되면 처음 하는 작업은 루트 뷰 컨트롤러를 코어 어셈블리의 루트뷰로 할당하는 것입니다.

코어 어셈블리 코드

final class CoreAssembly {
    var naviagationController: UINavigationController
    var appStateRepository: AppStateRepository

    lazy var tourAssembly = TourAssembly(self.navigationController)
    lazy var todoAssembly = TODOAssembly(self.navigaitionController)

    init(...) {
        // 맨 위 2개 프로퍼티에 할당하는거라 생략했스민다.
    }
    // 첫 실행이라면 tour로 아니면 todo로 return 하는 읽기속성
    var rootViewController: UIViewController {
        if appStateRepository.isNewInstall {
            return tourAssembly.tourViewController
        } else {
            return todoAssembly.todoViewController
        }
    }
}

테스트를 하는 코드는 다음과 같습니다.

class RootAssemblyTest: XCTestCase {
    func testRootViewControllerTour() {
        let mockAppStateRepo = MockStateRepository(isNewInstall: true)
        let assembly = RootAssembly(navigationController: UINavigationController(), appStateRepository: mockAppStateRepo)
        XCTAssert(type(of: assembly.rootViewControole) == TourViewController.self)
    }
    // 아래는 TODO 테스트 하는 함수, 위 코드와 거의 동일
}

간단해보이지만 까다롭다고 하네요. 투두리스트에서 항목을 누른다고 가정하면서 설명합니다.

protocol TODOListNavigator {
    func showTODODetail(id: Int) -> ()
}

네비게이터의 구현 코드는 다음과 같습니다.

어셈블리가 모든 뷰의 팩토리이므로, 네비게이터도 어셈블리에 의해 구현되어야 한다고 합니다.

class TODOAssembly {
    var todoViewController: TODOListViewController {
        // 위 투두어셈블리와 거의 동일한데,
        //TODOListPresenter에 navigator: self 라는 인자를 전달합니다.
    }
    var todoDetailViewController: TODODetailViewController {
        let presenter = TODODetailPresenter(repository: self.todoRepository)
        let vc = TODODetailViewController(presenter: presenter)
        return vc
    }
}

extension TODOAssembly: TODOListNavigator {
    func showTODODetail(id: Int) {
        let vc = self.todoDetailViewController
        vc.presenter.selectedTodoId = id
        self.navigationController.pushViewController(vc, animated: true)
    }
}

네비게이션 컨트롤러도 어셈블리의 일부라는 것을 명심해야 합니다

네비게이터의 테스트 코드는 다음과 같습니다.

// XCTestCase
var mockNavigator: MockNavigator!
var presenter: TODOListPresenter!

override func setUp() {
    self.mockNavigator = MockNavigator()
    self.presneter = TODOListPresenter(navigator: mockNavigator)
}

func testOnTodoTap() {
    XCTAsserFalse(verify(MockNavigator.showTODODetail))
    prsenter.onTodoTap(id: 4)
    XCTAssertTrue(verify(MockNavigator.showTODODetail))
}

View State

protocol TODODetailView: class {
    func showTodo(todo: TODOItemDetail)
    func showError(error: Error)
    func showLoading()
}
class TODODetailViewContorller: UIViewController {
    var isLoading: Bool = false
    var error: Error? = nil
    var data: TODOItemDetail?
    //...
}

위 처럼 에러나 데이터를 로딩하는 상태를 들고 있을 때 테스트하기가 점점 힘들어집니다.

그래서 아래와 같이 상태를 열거형을 사용해서 표현한다면 테스트를 더 쉽게 할 수 있도록 해줍니다.

enum ViewState<T> {
    case initial
    case loading
    case data(T)
    case error(error)
}
protocol TODODetailStateView: class {
    var state: ViewState<TODOItemDetail> { get set }
}

// 아래와 같이 상태라는 변수를 설정합니다
class TODODetailViewController: UIViewController {
    var state: ViewState<TODOItemDetail> = .inital {
        didSet {
            // 변경사항은 여기서 처리합니다.
        }
    }
}

Simple View models

mvvm에서의 뷰 모델은 데이터를 표시하는 것 외에 다른 작업을 수행하지만, 여기서 설명하는 어셈블리 아키텍처에서는 로직이 없는 뷰 모델입니다.

struct TodoTavleViewCellViewModel {
    var formattedDate: String
    var content: String
}
class TodoTableViewCell: UITableViewCell {
    var viewModel: TodoTavleViewCellViewModel? {
        didSet {
            guard let viewModel = viewModel else { return }
            date.text = viewModel.formattedDate
            content.text = viewModel.content
        }
    }
    @IBOutlet wear var date: UILabel!
    @IBOutlet wear var content: UILabel!
}

보고나서~

아키텍처에 대해 생각을 하게 돠는 영상이라 조큼 어려운거같습니다.

보면서 느낀점은 확실히 테스트하기 편해지는 아키텍처라고 생각이 들었습니다. 의존성 주입을 사용하기도 하고, 각자의 책임에 맞게 잘게 나눈게 제일 큰 영향이 있지 않나 싶습니다.

프레젠터는 MVC에서의 뷰 컨트롤러 부분을 닮았다고 생각했고, 어셈블리는 뭔가 코디네이터랑 비슷하다고 생각을 했습니다. 아직 잘 모르기는 합니다만 ㅋㅌㅋ

코디네이터가 라우터 같은 느낌으로 알고 있는데 여기서는 네비게이터가 그런 역할을 하지 싶은데 어셈블리에서 결국 진행을 하기 때문에 그런 생각을 했던거 같습니다. 비즈니스 로직은 프레젠터, 화면 전환은 어셈블리 & 네비게이터 ? 어렵네여 hmm,,

좀 더 아키텍처 관련 영상이나 글들을 보면서 지식을 넓혀봐야겠슴다..

profile
hi there 👋

0개의 댓글