정적 패턴 작업: Swift MVVM 자습서

게시 됨: 2022-03-11

오늘 우리는 실시간 데이터 기반 응용 프로그램에 대한 사용자의 새로운 기술적 가능성과 기대가 프로그램, 특히 모바일 응용 프로그램을 구성하는 방식에서 새로운 문제를 만드는 방법을 볼 것입니다. 이 기사는 iOS와 Swift에 관한 것이지만 많은 패턴과 결론은 Android와 웹 애플리케이션에 동일하게 적용할 수 있습니다.

지난 몇 년 동안 최신 모바일 앱이 작동하는 방식에 중요한 발전이 있었습니다. 푸시 알림 및 WebSocket과 같은 보다 광범위한 인터넷 액세스와 기술 덕분에 오늘날의 많은 모바일 앱에서 사용자는 더 이상 런타임 이벤트의 유일한 소스가 아니며 더 이상 가장 중요한 이벤트가 아닐 수도 있습니다.

두 가지 Swift 디자인 패턴이 각각 최신 채팅 애플리케이션에서 얼마나 잘 작동하는지 자세히 살펴보겠습니다. 클래식 MVC(모델-뷰-컨트롤러) 패턴과 단순화된 불변 모델-뷰-뷰 모델 패턴(MVVM, 때로는 "ViewModel 패턴"으로 양식화되기도 함) "). 채팅 앱은 데이터 소스가 많고 데이터가 수신될 때마다 다양한 방식으로 UI를 업데이트해야 하기 때문에 좋은 예입니다.

채팅 애플리케이션

이 Swift MVVM 튜토리얼에서 지침으로 사용할 애플리케이션은 WhatsApp과 같은 채팅 애플리케이션에서 알고 있는 대부분의 기본 기능을 갖게 될 것입니다. 구현하고 MVVM과 MVC를 비교할 기능을 살펴보겠습니다. 신청:

  • 디스크에서 이전에 받은 채팅을 로드합니다.
  • GET 요청을 통해 기존 채팅을 서버와 동기화합니다.
  • 새 메시지가 사용자에게 전송되면 푸시 알림을 받습니다.
  • 채팅 화면에 있으면 WebSocket에 연결됩니다.
  • 채팅에 새 메시지를 POST 할 수 있습니다.
  • 현재 참여하지 않은 채팅에 대한 새 메시지가 수신되면 인앱 알림을 표시합니다.
  • 현재 채팅에 대한 새 메시지를 받으면 즉시 새 메시지를 표시합니다.
  • 읽지 않은 메시지를 읽을 때 읽은 메시지를 보냅니다.
  • 누군가가 우리의 메시지를 읽을 때 읽기 메시지를 받을 것입니다
  • 애플리케이션 아이콘의 읽지 않은 메시지 카운터 배지 업데이트
  • 수신되거나 변경된 모든 메시지를 Core Data로 다시 동기화합니다.

이 데모 애플리케이션에는 모델 구현을 좀 더 단순하게 유지하기 위한 실제 API, WebSocket 또는 Core Data 구현이 없습니다. 대신 대화를 시작하면 답장을 시작하는 챗봇을 추가했습니다. 그러나 다른 모든 라우팅 및 호출은 반환 전의 작은 비동기 일시 중지를 포함하여 스토리지 및 연결이 실제인 경우와 같이 구현됩니다.

다음 세 개의 화면이 구축되었습니다.

채팅 목록, 채팅 만들기 및 메시지 화면.

클래식 MVC

우선 iOS 애플리케이션을 빌드하기 위한 표준 MVC 패턴이 있습니다. 이것이 Apple이 모든 문서 코드를 구성하는 방식과 API 및 UI 요소가 작동할 것으로 예상하는 방식입니다. iOS 과정을 수강할 때 대부분의 사람들이 배우는 내용입니다.

종종 MVC는 수천 줄의 코드로 인해 부풀려진 UIViewController 로 인해 비난을 받습니다. 그러나 잘 적용되고 각 레이어가 잘 분리되어 있으면 View , Model 및 기타 Controller 사이의 중간 관리자 역할만 하는 매우 슬림한 ViewController 를 가질 수 있습니다.

다음은 앱의 MVC 구현에 대한 순서도입니다(명확성을 위해 CreateViewController 제외).

명확성을 위해 CreateViewController를 생략한 MVC 구현 순서도.

레이어를 자세히 살펴보겠습니다.

모델

모델 계층은 일반적으로 MVC에서 가장 문제가 적은 계층입니다. 이 경우 ChatWebSocket , ChatModelPushNotificationController 를 사용하여 ChatMessage 개체, 외부 데이터 소스 및 나머지 애플리케이션 간의 중재를 선택했습니다. ChatModel 은 응용 프로그램 내의 진실 소스이며 이 데모 응용 프로그램에서 메모리 내에서만 작동합니다. 실제 응용 프로그램에서는 아마도 Core Data의 지원을 받을 것입니다. 마지막으로 ChatEndpoint 는 모든 HTTP 호출을 처리합니다.

보다

UIViewController 에서 모든 뷰 코드를 조심스럽게 분리했기 때문에 뷰는 많은 책임을 처리해야 하기 때문에 꽤 큽니다. 다음을 수행했습니다.

  • 보기의 현재 상태를 정의하기 위해 (매우 권장되는) 상태 enum 패턴을 사용했습니다.
  • 버튼 및 기타 작업 트리거 인터페이스 항목에 연결되는 기능을 추가했습니다(예: 연락처 이름을 입력하는 동안 Return을 탭함).
  • 제약 조건을 설정하고 매번 대리자를 호출합니다.

혼합에서 UITableView 를 던지면 뷰가 이제 UIViewController 보다 훨씬 커져서 300개 이상의 코드 라인과 ChatView 에서 많은 혼합 작업이 발생합니다.

제어 장치

모든 모델 처리 논리가 ChatModel 로 이동했기 때문입니다. 덜 최적화되고 분리된 프로젝트에 숨어 있을 수 있는 모든 뷰 코드가 이제 뷰에 있으므로 UIViewController 는 매우 슬림합니다. 뷰 컨트롤러는 모델 데이터가 어떻게 생겼는지, 어떻게 가져오는지, 어떻게 표시되어야 하는지 전혀 알지 못합니다. 단지 조정합니다. 예제 프로젝트에서 어떤 UIViewController 도 150줄의 코드를 넘지 않습니다.

그러나 ViewController는 여전히 다음 작업을 수행합니다.

  • 뷰 및 기타 뷰 컨트롤러의 대리자 되기
  • 필요한 경우 뷰 컨트롤러 인스턴스화 및 푸시(또는 팝핑)
  • ChatModel 과 통화 송수신
  • 뷰 컨트롤러 주기의 단계에 따라 WebSocket 시작 및 중지
  • 비어 있는 메시지를 보내지 않는 것과 같은 논리적 결정을 내림
  • 보기 업데이트

여전히 많은 양이지만 대부분 조정, 콜백 블록 처리 및 전달입니다.

혜택

  • 이 패턴은 모든 사람이 이해하고 Apple에서 홍보합니다.
  • 모든 문서에서 작동
  • 추가 프레임워크가 필요하지 않음

단점

  • 보기 컨트롤러에는 많은 작업이 있습니다. 그들 중 많은 사람들이 기본적으로 뷰와 모델 레이어 간에 데이터를 주고받고 있습니다.
  • 여러 이벤트 소스를 처리하는 데 적합하지 않음
  • 수업은 다른 수업에 대해 많이 아는 경향이 있습니다.

문제 정의

이것은 Adobe Photoshop 또는 Microsoft Word와 같은 응용 프로그램이 작동할 것이라고 상상하는 것처럼 응용 프로그램이 사용자의 작업을 따르고 응답하는 한 매우 잘 작동합니다. 사용자가 작업을 수행하고 UI가 업데이트되고 반복됩니다.

그러나 최신 애플리케이션은 종종 한 가지 이상의 방식으로 연결됩니다. 예를 들어 REST API를 통해 상호 작용하고 푸시 알림을 수신하며 경우에 따라 WebSocket에도 연결합니다.

그것과 함께 갑자기 뷰 컨트롤러는 더 많은 정보 소스를 처리해야 하고 사용자가 트리거하지 않고 외부 메시지를 수신할 때마다(예: WebSocket을 통해 메시지 수신) 정보 소스는 오른쪽으로 되돌아가야 합니다. 뷰 컨트롤러. 기본적으로 동일한 작업을 수행하기 위해 모든 부분을 함께 붙이기 위해서는 많은 코드가 필요합니다.

외부 데이터 소스

푸시 메시지가 수신되면 어떻게 되는지 살펴보겠습니다.

 class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } }

푸시 알림을 받은 후 자체적으로 업데이트해야 하는 뷰 컨트롤러가 있는지 알아내기 위해 뷰 컨트롤러 스택을 수동으로 파헤쳐야 합니다. 이 경우에는 ChatsViewController UpdatedChatDelegate 또한 이미 의도한 Chat 을 보고 있기 때문에 알림을 표시하지 않아야 하는지 여부를 알기 위해 이 작업을 수행합니다. 이 경우 마침내 메시지를 뷰 컨트롤러에 대신 전달합니다. PushNotificationController 가 작업을 수행할 수 있으려면 애플리케이션에 대해 너무 많이 알아야 한다는 것은 분명합니다.

ChatWebSocketChatViewController 와 일대일 관계를 유지하는 대신 응용 프로그램의 다른 부분에도 메시지를 전달하는 경우 동일한 문제에 직면하게 됩니다.

다른 외부 소스를 추가할 때마다 상당히 침습적인 코드를 작성해야 한다는 것은 분명합니다. 이 코드는 응용 프로그램 구조에 크게 의존하고 데이터를 다시 계층 구조로 전달하여 작동하도록 위임하기 때문에 매우 취약합니다.

대의원

MVC 패턴은 또한 다른 뷰 컨트롤러를 추가하면 믹스에 복잡성을 추가합니다. 뷰 컨트롤러는 데이터와 참조를 전달할 때 델리게이트, 이니셜라이저, 그리고 스토리보드의 경우 prepareForSegue 를 통해 서로에 대해 아는 경향이 있기 때문입니다. 모든 뷰 컨트롤러는 모델 또는 중재 컨트롤러에 대한 자체 연결을 처리하며 업데이트를 보내고 받습니다.

또한 뷰는 대리자를 통해 뷰 컨트롤러와 다시 통신합니다. 이것이 작동하는 동안 데이터를 전달하기 위해 수행해야 하는 단계가 상당히 많다는 것을 의미하며, 저는 항상 콜백과 관련하여 많은 리팩토링을 하고 대리자가 실제로 설정되었는지 확인합니다.

ChatViewController 가 더 이상 updated(chat: Chat) 를 호출하지 않기 때문에 ChatsListViewController 의 오래된 데이터와 같이 다른 뷰 컨트롤러의 코드를 변경하여 하나의 뷰 컨트롤러를 손상시킬 수 있습니다. 특히 더 복잡한 시나리오에서는 모든 것을 동기화 상태로 유지하는 것이 어렵습니다.

뷰와 모델의 분리

뷰 컨트롤러에서 모든 뷰 관련 코드를 customView 로 제거하고 모든 모델 관련 코드를 특수 컨트롤러로 이동하면 뷰 컨트롤러가 매우 간결하고 분리됩니다. 그러나 여전히 한 가지 문제가 남아 있습니다. 보기가 표시하려는 것과 모델에 있는 데이터 사이에 간격이 있다는 것입니다. 좋은 예는 ChatListView 입니다. 우리가 표시하고자 하는 것은 대화 상대, 마지막 메시지 내용, 마지막 메시지 날짜 및 Chat 에 남아 있는 읽지 않은 메시지 수를 알려주는 셀 목록입니다.

채팅 화면의 읽지 않은 메시지 카운터.

그러나 우리는 우리가 보고자 하는 것에 대해 알지 못하는 모델을 전달하고 있습니다. 대신 다음과 같은 메시지가 포함된 연락처와의 Chat 입니다.

 class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }

이제 마지막 메시지와 메시지 수를 가져오는 몇 가지 추가 코드를 빠르게 추가할 수 있지만 날짜 형식을 문자열로 지정하는 것은 보기 계층에 확실히 속하는 작업입니다.

 var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate }

따라서 마지막으로 표시할 때 ChatItemTableViewCell 의 날짜 형식을 지정합니다.

 func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }

매우 간단한 예에서도 뷰가 필요로 하는 것과 모델이 제공하는 것 사이에 긴장이 있음이 분명합니다.

"ViewModel 패턴"에 대한 정적 이벤트 기반 테이크라고도 하는 정적 이벤트 기반 MVVM

Static MVVM은 뷰 모델과 함께 작동하지만 MVC를 사용하여 뷰 컨트롤러를 통해 했던 것처럼 뷰 모델을 통해 양방향 트래픽을 생성하는 대신 이벤트에 대한 응답으로 UI를 변경해야 할 때마다 UI를 업데이트하는 변경 불가능한 뷰 모델을 생성합니다. .

이벤트 enum 에 필요한 관련 데이터를 제공할 수 있는 한 이벤트는 코드의 거의 모든 부분에서 트리거될 수 있습니다. 예를 들어, 수신 received(new: Message) 이벤트 수신은 푸시 알림, WebSocket 또는 일반 네트워크 호출에 의해 트리거될 수 있습니다.

다이어그램으로 살펴보겠습니다.

MVVM 구현 순서도.

언뜻보기에는 고전적인 MVC 예제보다 훨씬 더 복잡한 것처럼 보입니다. 정확히 동일한 작업을 수행하는 데 훨씬 더 많은 클래스가 포함되기 때문입니다. 그러나 자세히 살펴보면 더 이상 양방향 관계가 없습니다.

훨씬 더 중요한 것은 UI에 대한 모든 업데이트가 이벤트에 의해 트리거되기 때문에 발생하는 모든 일에 대해 앱을 통과하는 경로가 하나뿐이라는 것입니다. 어떤 이벤트를 기대할 수 있는지 즉시 알 수 있습니다. 필요한 경우 새 항목을 추가하거나 기존 이벤트에 응답할 때 새 동작을 추가해야 하는 위치도 분명합니다.

리팩토링 후에 위에서 보여드린 것처럼 많은 새로운 클래스가 생겼습니다. GitHub에서 정적 MVVM 버전 구현을 찾을 수 있습니다. 그러나 변경 사항을 cloc 도구와 비교할 때 실제로 추가 코드가 많지 않다는 것이 분명해집니다.

무늬 파일 공백 논평 암호
MVC 30 386 217 1807년
MVVM 51 442 359 1981년

코드 라인이 9%만 증가했습니다. 더 중요한 것은 이러한 파일의 평균 크기가 60줄에서 39줄로 줄었다는 것입니다.

코드 라인 파이 차트. 보기 컨트롤러: MVC 287 대 MVVM 154 또는 47% 미만 조회수: MVC 523 대 MVVM 392 또는 26% 더 적습니다.

또한 결정적으로 가장 큰 하락은 일반적으로 MVC에서 가장 큰 파일인 보기와 보기 컨트롤러에서 찾을 수 있습니다. 보기는 원래 크기의 74%에 불과하고 보기 컨트롤러는 이제 원래 크기의 53%에 불과합니다.

추가 코드의 대부분은 MVC의 고전적인 @IBAction 또는 대리자 패턴 없이도 시각적 트리의 버튼 및 기타 개체에 블록을 연결하는 데 도움이 되는 라이브러리 코드입니다.

이 디자인의 다른 레이어를 하나씩 살펴보겠습니다.

이벤트

이벤트는 항상 enum 이며 일반적으로 연결된 값이 있습니다. 종종 모델의 엔터티 중 하나와 겹치지만 반드시 그런 것은 아닙니다. 이 경우 애플리케이션은 두 개의 주요 이벤트 enum ( ChatEventMessageEvent )으로 분할됩니다. ChatEvent 는 채팅 개체 자체의 모든 업데이트를 위한 것입니다.

 enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }

다른 하나는 모든 Message 관련 이벤트를 처리합니다.

 enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) }

*Event enum 을 적당한 크기로 제한하는 것이 중요합니다. 10개 이상의 케이스가 필요한 경우 일반적으로 하나 이상의 주제를 다루려고 한다는 신호입니다.

참고: enum 개념은 Swift에서 매우 강력합니다. 나는 enum 을 연관된 값과 함께 많이 사용하는 경향이 있습니다. 그렇지 않으면 선택적 값으로 가질 수 있는 많은 모호성을 제거할 수 있기 때문입니다.

Swift MVVM 튜토리얼: 이벤트 라우터

이벤트 라우터는 애플리케이션에서 발생하는 모든 이벤트의 진입점입니다. 연관된 값을 제공할 수 있는 모든 클래스는 이벤트를 생성하여 이벤트 라우터로 보낼 수 있습니다. 따라서 모든 종류의 소스에 의해 트리거될 수 있습니다. 예:

  • 특정 뷰 컨트롤러로 이동하는 사용자
  • 특정 버튼을 탭하는 사용자
  • 응용 프로그램 시작
  • 다음과 같은 외부 이벤트:
    • 실패 또는 새 데이터와 함께 반환되는 네트워크 요청
    • 푸시 알림
    • 웹소켓 메시지

이벤트 라우터는 이벤트 소스에 대해 가능한 한 적게 알아야 하며 가급적이면 전혀 알지 않아야 합니다. 이 예제 응용 프로그램의 이벤트에는 발생 위치에 대한 표시기가 없으므로 모든 종류의 메시지 소스를 쉽게 혼합할 수 있습니다. 예를 들어, WebSocket은 새로운 푸시 알림으로 received(message: Message, contact: String) 과 같은 동일한 이벤트를 트리거합니다.

이벤트는 이러한 이벤트를 추가로 처리해야 하는 클래스로 라우팅됩니다. 일반적으로 호출되는 유일한 클래스는 모델 계층(데이터를 추가, 변경 또는 제거해야 하는 경우)과 이벤트 핸들러입니다. 두 가지 모두에 대해 조금 더 자세히 설명하겠지만 이벤트 라우터의 주요 기능은 모든 이벤트에 대한 하나의 쉬운 액세스 지점을 제공하고 작업을 다른 클래스로 전달하는 것입니다. 다음은 ChatEventRouter 의 예입니다.

 class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } }

여기서는 거의 진행되지 않습니다. 우리가 하는 유일한 일은 모델을 업데이트하고 이벤트를 ChatEventHandler 에 전달하여 UI가 업데이트되도록 하는 것입니다.

Swift MVVM 튜토리얼: 모델 컨트롤러

이것은 이미 꽤 잘 작동하고 있었기 때문에 MVC에서 사용하는 것과 정확히 같은 클래스입니다. 이는 애플리케이션의 상태를 나타내며 일반적으로 Core Data 또는 로컬 스토리지 라이브러리에 의해 지원됩니다.

MVC에서 올바르게 구현된 모델 레이어는 다른 패턴에 맞게 리팩토링할 필요가 거의 없습니다. 가장 큰 변화는 모델 변경이 더 적은 수의 클래스에서 발생하여 변경이 발생하는 위치를 좀 더 명확하게 한다는 것입니다.

이 패턴에 대한 대안으로 모델의 변경 사항을 관찰하고 처리되는지 확인할 수 있습니다. 이 경우에는 *EventRouter*Endpoint 클래스만 모델을 변경하도록 하기로 했기 때문에 모델이 업데이트되는 위치와 시기에 대한 명확한 책임이 있습니다. 대조적으로, 변경 사항을 관찰하는 경우 ChatEventHandler 를 통해 오류와 같은 모델 변경이 아닌 이벤트를 전파하는 추가 코드를 작성해야 하므로 이벤트가 애플리케이션을 통해 흐르는 방식이 덜 명확해집니다.

Swift MVVM 튜토리얼: 이벤트 핸들러

이벤트 핸들러는 ChatEventRouter가 ChatEventRouter 에서 함수를 호출할 때마다 빌드되는 업데이트된 뷰 모델을 수신하기 위해 뷰 또는 뷰 컨트롤러가 리스너로 자신을 등록(및 등록 취소)할 수 있는 ChatEventHandler 입니다.

이전에 MVC에서 사용한 모든 뷰 상태를 대략적으로 반영한 것을 볼 수 있습니다. 사운드 또는 Taptic 엔진 트리거와 같은 다른 유형의 UI 업데이트를 원하는 경우 여기에서도 수행할 수 있습니다.

 protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } }

이 클래스는 특정 이벤트가 발생할 때마다 올바른 리스너가 올바른 뷰 모델을 얻을 수 있도록 하는 것 외에는 아무것도 하지 않습니다. 새로운 리스너는 초기 상태를 설정하는 데 필요한 경우 추가되는 즉시 뷰 모델을 얻을 수 있습니다. 보존 주기를 방지하려면 항상 목록에 weak 참조를 추가해야 합니다.

Swift MVVM 튜토리얼: 모델 보기

다음은 많은 MVVM 패턴이 수행하는 작업과 정적 변형이 수행하는 작업 간의 가장 큰 차이점 중 하나입니다. 이 경우 뷰 모델은 모델과 뷰 사이에 영구적인 양방향 바인딩 중간체로 설정되는 대신 변경 불가능합니다. 왜 그렇게 할까요? 잠시 멈추고 설명하겠습니다.

모든 가능한 경우에 잘 작동하는 응용 프로그램을 만드는 데 있어 가장 중요한 측면 중 하나는 응용 프로그램의 상태가 올바른지 확인하는 것입니다. UI가 모델과 일치하지 않거나 오래된 데이터가 있는 경우 우리가 하는 모든 작업으로 인해 잘못된 데이터가 저장되거나 응용 프로그램이 충돌하거나 예기치 않은 방식으로 작동할 수 있습니다.

이 패턴을 적용하는 목표 중 하나는 절대적으로 필요한 경우가 아니면 애플리케이션에 상태가 없다는 것입니다. 상태란 정확히 무엇입니까? 상태는 기본적으로 특정 유형의 데이터 표현을 저장하는 모든 위치입니다. 한 가지 특별한 유형의 상태는 UI가 현재 있는 상태이며, 물론 UI 기반 애플리케이션에서는 이를 방지할 수 없습니다. 다른 유형의 상태는 모두 데이터와 관련됩니다. 채팅 목록 화면에서 UITableView 를 백업하는 Chat 배열의 복사본이 있는 경우 이는 중복 상태의 예입니다. 기존의 양방향 바인딩 보기 모델은 사용자의 Chat 복제본의 또 다른 예입니다.

모든 모델이 변경될 때마다 새로 고쳐지는 불변 뷰 모델을 전달하여 이러한 유형의 중복 상태를 제거합니다. 왜냐하면 그것이 UI에 적용된 후 더 이상 사용되지 않기 때문입니다. 그러면 피할 수 없는 두 가지 유형의 상태(UI와 모델)만 있으며 서로 완벽하게 동기화됩니다.

따라서 여기에서 보기 모델은 일부 MVVM 응용 프로그램과 상당히 다릅니다. 뷰가 모델의 상태를 반영하는 데 필요한 모든 플래그, 값, 블록 및 기타 값에 대한 변경할 수 없는 데이터 저장소 역할만 하지만 뷰에서 어떤 식으로든 업데이트할 수는 없습니다.

따라서 단순한 불변 struct 가 될 수 있습니다. 이 struct 를 가능한 한 단순하게 유지하기 위해 뷰 모델 빌더로 인스턴스화합니다. 뷰 모델에 대한 흥미로운 점 중 하나는 뷰에서 이전에 발견된 상태 enum 메커니즘을 대체하는 shouldShowBusyshouldShowError 와 같은 동작 플래그를 얻는다는 것입니다. 이전에 분석한 ChatItemTableViewCell 에 대한 데이터는 다음과 같습니다.

 struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }

뷰 모델 빌더는 이미 뷰에 필요한 정확한 값과 작업을 처리하기 때문에 모든 데이터는 미리 형식이 지정됩니다. 또한 항목을 탭하면 트리거되는 블록이 새로 추가되었습니다. 뷰 모델 빌더에 의해 어떻게 만들어지는지 봅시다.

모델 빌더 보기

보기 모델 빌더는 보기 모델의 인스턴스를 빌드하여 Chat 또는 Message 와 같은 입력을 특정 보기에 완벽하게 맞춰진 보기 모델로 변환할 수 있습니다. 뷰 모델 빌더에서 발생하는 가장 중요한 일 중 하나는 뷰 모델의 블록 내부에서 실제로 일어나는 일을 결정하는 것입니다. 뷰 모델 빌더에 의해 첨부된 블록은 가능한 한 빨리 아키텍처의 다른 부분의 기능을 호출하여 매우 짧아야 합니다. 이러한 블록에는 비즈니스 로직이 없어야 합니다.

 class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }

이제 모든 사전 형식화는 같은 장소에서 발생하며 여기에서도 동작이 결정됩니다. 이 계층 구조에서 매우 중요한 클래스이며 데모 애플리케이션의 다양한 빌더가 구현되고 더 복잡한 시나리오를 처리하는 방법을 보는 것은 흥미로울 수 있습니다.

Swift MVVM 튜토리얼: 컨트롤러 보기

이 아키텍처의 뷰 컨트롤러는 거의 수행하지 않습니다. 보기와 관련된 모든 것을 설정하고 해체합니다. 적시에 리스너를 추가 및 제거하는 데 필요한 모든 수명 주기 콜백을 가져오기 때문에 이 작업을 수행하는 것이 가장 적합합니다.

때로는 탐색 모음의 제목이나 버튼과 같이 루트 보기에서 다루지 않는 UI 요소를 업데이트해야 합니다. 그렇기 때문에 주어진 뷰 컨트롤러에 대한 전체 뷰를 포함하는 뷰 모델이 있는 경우 일반적으로 뷰 컨트롤러를 이벤트 라우터에 대한 리스너로 등록합니다. 뷰 모델을 나중에 뷰에 전달합니다. 그러나 특정 회사에 대한 페이지 상단의 라이브 주식 시세와 같이 업데이트 속도가 다른 화면 부분이 있는 경우 UIView 를 리스너로 직접 등록하는 것도 괜찮습니다.

ChatsViewController 의 코드는 이제 너무 짧아서 페이지도 채 걸리지 않습니다. 남은 것은 기본 보기를 재정의하고, 탐색 모음에서 추가 버튼을 추가 및 제거하고, 제목을 설정하고, 자신을 리스너로 추가하고, ChatListListening 프로토콜을 구현하는 것입니다.

 class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } }

ChatsViewController 가 최소한으로 제거되므로 다른 곳에서 수행할 수 있는 작업이 남아 있지 않습니다.

Swift MVVM 튜토리얼: 보기

불변 MVVM 아키텍처의 보기는 여전히 작업 목록이 있기 때문에 여전히 상당히 무거울 수 있지만 MVC 아키텍처와 비교하여 다음 책임을 제거할 수 있었습니다.

  • 새로운 상태에 대한 응답으로 변경해야 할 사항 결정
  • 작업에 대한 대리자 및 기능 구현
  • 제스처 및 트리거된 애니메이션과 같은 보기 간 트리거 처리
  • 표시할 수 있는 방식으로 데이터 변환(예: Date 에서 String 으로)

특히 마지막 포인트는 상당히 큰 장점이 있습니다. MVC에서 보기 또는 보기 컨트롤러가 표시할 데이터 변환을 담당할 때 이 스레드에서 발생하는 데 필요한 UI의 실제 변경을 실행하지 않아도 됩니다. 그리고 메인 스레드에서 UI 변경이 아닌 코드를 실행하면 애플리케이션의 응답성이 떨어질 수 있습니다.

대신 이 MVVM 패턴을 사용하면 탭에 의해 트리거되는 블록부터 뷰 모델이 빌드되고 리스너에 전달되는 순간까지 모든 것이 별도의 스레드에서 실행될 수 있으며 UI 업데이트를 끝냅니다. 애플리케이션이 메인 스레드에서 더 적은 시간을 소비하면 더 원활하게 실행됩니다.

뷰 모델이 뷰에 새 상태를 적용하면 다른 상태 레이어로 머무르지 않고 증발할 수 있습니다. 이벤트를 유발할 수 있는 모든 것은 뷰의 항목에 연결되며 뷰 모델과 다시 통신하지 않습니다.

한 가지 기억하는 것이 중요합니다. 뷰 컨트롤러를 통해 뷰 모델을 뷰에 매핑해야 하는 것은 아닙니다. 이전에 언급했듯이 뷰의 일부는 특히 업데이트 속도가 다양한 경우 다른 뷰 모델에서 관리할 수 있습니다. 공동 작업자를 위해 채팅 창을 열어 둔 상태에서 다른 사람들이 Google 스프레드시트를 편집하고 있다고 가정해 보겠습니다. 채팅 메시지가 도착할 때마다 문서를 새로 고치는 것은 그다지 유용하지 않습니다.

잘 알려진 예는 더 많은 텍스트를 입력할 때 검색 상자가 더 정확한 결과로 업데이트되는 유형 찾기 구현입니다. 이것은 CreateAutocompleteView 클래스에서 자동 완성을 구현하는 방법입니다. 전체 화면은 CreateViewModel 에 의해 제공되지만 텍스트 상자는 대신 AutocompleteContactViewModel 을 수신합니다.

또 다른 예는 "로컬 루프"(필드에 오류 상태를 첨부 또는 제거하고 양식을 유효하다고 선언)로 구축하거나 이벤트를 트리거하여 수행할 수 있는 양식 유효성 검사기를 사용하는 것입니다.

더 나은 분리를 제공하는 정적 불변 뷰 모델

뷰 모델이 이제 모델과 뷰를 연결하기 때문에 정적 MVVM 구현을 사용하여 마침내 모든 레이어를 완전히 분리할 수 있었습니다. 또한 사용자 작업으로 인해 발생하지 않은 이벤트를 더 쉽게 관리할 수 있게 했으며 애플리케이션의 여러 부분 간의 많은 종속성을 제거했습니다. 뷰 컨트롤러가 하는 유일한 일은 이벤트 핸들러에 수신하려는 이벤트에 대한 리스너로 자신을 등록(및 등록 취소)하는 것입니다.

혜택:

  • 보기 및 보기 컨트롤러 구현은 훨씬 더 가벼운 경향이 있습니다.
  • 수업은 더 전문화되고 분리되어 있습니다.
  • 이벤트는 어디에서나 쉽게 트리거될 수 있습니다.
  • 이벤트는 시스템을 통해 예측 가능한 경로를 따릅니다.
  • 상태는 한 곳에서만 업데이트됩니다.
  • 기본 스레드에서 작업을 수행하는 것이 더 쉽기 때문에 앱의 성능이 향상될 수 있습니다.
  • Views receive tailor-made view models and are perfectly separated from the models

단점:

  • A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
  • Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
  • Event enum s can easily grow pretty large in complex scenarios and might be hard to split up

The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.

There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:

  • Some form of the MVVM pattern
  • Some form of Reactive (using RxSwift, sometimes combined with MVVM)
  • The model-view-presenter pattern (MVP)
  • The view-interactor-presenter-entity-router pattern (VIPER)

Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.

MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.

The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.

MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.

VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.


So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!

관련: Swift 튜토리얼: MVVM 디자인 패턴 소개