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

게시 됨: 2022-03-11

따라서 새로운 iOS 프로젝트를 시작하고 디자이너로부터 필요한 모든 .pdf.sketch 문서를 받았으며 이 새 앱을 빌드하는 방법에 대한 비전이 이미 있습니다.

디자이너의 스케치에서 UI 화면을 ViewController .swift , .xib.storyboard 파일로 전송하기 시작합니다.

여기에는 UITextField , 거기에는 UITableView , 몇 가지 UILabels 및 약간의 UIButtons 가 있습니다. IBOutletsIBActions 도 포함됩니다. 좋습니다. 우리는 여전히 UI 영역에 있습니다.

그러나 이제 이러한 모든 UI 요소를 사용하여 작업을 수행해야 합니다. UIButtons 은 손가락 터치를 수신하고 UILabelsUITableViews 는 표시할 내용과 형식을 알려주는 누군가가 필요합니다.

갑자기 3,000줄 이상의 코드가 있습니다.

3,000줄의 Swift 코드

당신은 많은 스파게티 코드로 끝났습니다.

이를 해결하기 위한 첫 번째 단계는 MVC( Model-View-Controller ) 디자인 패턴을 적용하는 것입니다. 그러나 이 패턴에는 고유한 문제가 있습니다. 하루를 절약하는 MVVM( Model-View-ViewModel ) 디자인 패턴이 있습니다.

스파게티 코드 다루기

순식간에 시작하는 ViewController 가 너무 똑똑해지고 너무 방대해졌습니다.

네트워킹 코드, 데이터 파싱 코드, UI 프레젠테이션을 위한 데이터 조정 코드, 앱 상태 알림, UI 상태 변경. 모든 코드는 재사용할 수 없고 이 프로젝트에만 들어갈 수 있는 단일 파일의 if -ology 안에 갇혀 있습니다.

귀하의 ViewController 코드는 악명 높은 스파게티 코드가 되었습니다.

어떻게 된거야?

가능한 이유는 다음과 같습니다.

백엔드 데이터가 UITableView 내부에서 어떻게 동작하는지 확인하기 위해 서두르셨으므로 ViewController임시 메소드 안에 몇 줄의 네트워킹 코드를 넣어 네트워크에서 해당 .json 을 가져옵니다. 다음으로 해당 .json 내부의 데이터를 처리해야 하므로 이를 수행하기 위해 또 다른 임시 메서드를 작성했습니다. 또는 더 나쁜 것은 같은 방법 내에서 그렇게 했다는 것입니다.

ViewController 는 사용자 인증 코드가 나왔을 때 계속 성장했습니다. 그런 다음 데이터 형식이 변경되기 시작했고 UI가 진화했고 일부 급진적인 변경이 필요했으며 이미 방대한 if -ology에 if 를 계속 추가했습니다.

그러나 UIViewController 가 어떻게 손에 잡히지 않았습니까?

UIViewController 는 UI 코드 작업을 시작하기 위한 논리적인 장소입니다. iOS 기기에서 앱을 사용하는 동안 표시되는 실제 화면을 나타냅니다. Apple조차도 다른 앱과 애니메이션 UI 사이를 전환할 때 기본 시스템 앱에서 UIViewControllers 를 사용합니다.

Apple은 iOS UI 코드의 핵심이자 MVC 디자인 패턴의 일부이기 때문에 UIViewController 내부에 UI 추상화를 기반으로 합니다.

관련: iOS 개발자가 저지르는 10가지 가장 일반적인 실수

MVC 디자인 패턴으로 업그레이드

MVC 디자인 패턴

MVC 디자인 패턴에서 View 는 비활성화되어야 하며 요청 시 준비된 데이터만 표시합니다.

컨트롤러모델 데이터에 대해 작업하여 에 대해 준비한 다음 해당 데이터를 표시해야 합니다.

View 는 또한 사용자 터치와 같은 모든 작업에 대해 컨트롤러 에 알릴 책임이 있습니다.

언급했듯이 UIViewController 는 일반적으로 UI 화면을 빌드하는 시작점입니다. 이름에 "보기"와 "컨트롤러"가 모두 포함되어 있습니다. 이것은 "보기를 제어"한다는 것을 의미합니다. "컨트롤러"와 "보기" 코드가 모두 내부에 들어가야 한다는 의미는 아닙니다.

뷰와 컨트롤러 코드의 이러한 혼합은 UIViewController 내부에서 작은 하위 뷰의 IBOutlets 을 이동하고 UIViewController 에서 직접 해당 하위 뷰를 조작할 때 종종 발생합니다. 대신 사용자 정의 UIView 하위 클래스 내부에 해당 코드를 래핑해야 합니다.

이로 인해 View 및 Controller 코드 경로가 교차될 수 있음을 쉽게 알 수 있습니다.

MVVM 구조

여기서 MVVM 패턴이 유용합니다.

UIViewController 는 MVC 패턴의 컨트롤러 여야 하고 이미 Views 로 많은 일을 하고 있기 때문에 우리는 그것들을 새로운 패턴인 MVVMView 에 병합할 수 있습니다.

MVVM 디자인 패턴

MVVM 디자인 패턴에서 Model 은 MVC 패턴과 동일합니다. 간단한 데이터를 나타냅니다.

보기 는 준비된 데이터만 표시해야 하는 .xib.storyboard 파일과 함께 UIView 또는 UIViewController 개체로 표시됩니다. (예를 들어 View 내부에 NSDateFormatter 코드가 있는 것을 원하지 않습니다.)

ViewModel 에서 오는 단순하고 형식이 지정된 문자열만.

ViewModel 은 모든 비동기 네트워킹 코드, 시각적 표현을 위한 데이터 준비 코드, 모델 변경을 수신하는 코드를 숨깁니다. 이 모든 것은 이 특정 View 에 맞게 모델링된 잘 정의된 API 뒤에 숨겨져 있습니다.

MVVM 사용의 이점 중 하나는 테스트입니다. ViewModel 은 순수한 NSObject (또는 struct )이고 UIKit 코드와 연결되어 있지 않기 때문에 UI 코드에 영향을 주지 않고 단위 테스트에서 더 쉽게 테스트할 수 있습니다.

이제 View ( UIViewController / UIView )는 훨씬 간단해졌으며 ViewModelModelView 사이의 접착제 역할을 합니다.

Swift에서 MVVM 적용하기

스위프트의 MVVM

작동 중인 MVVM을 보여주기 위해 여기에서 이 튜토리얼을 위해 만든 예제 Xcode 프로젝트를 다운로드하여 검사할 수 있습니다. 이 프로젝트는 Swift 3 및 Xcode 8.1을 사용합니다.

프로젝트에는 Starter 및 Finished의 두 가지 버전이 있습니다.

Finished 버전은 Starter 가 동일한 프로젝트이지만 메서드와 개체가 구현되지 않은 완성된 미니 애플리케이션입니다.

먼저 Starter 프로젝트를 다운로드하고 이 자습서를 따르십시오. 나중에 프로젝트에 대한 빠른 참조가 필요한 경우 완성된 프로젝트를 다운로드하십시오.

튜토리얼 프로젝트 소개

튜토리얼 프로젝트는 게임 중 플레이어의 행동을 추적하기 위한 농구 애플리케이션입니다.

농구 응용 프로그램

픽업 게임에서 사용자 움직임과 전체 점수를 빠르게 추적하는 데 사용됩니다.

두 팀은 15점(최소 2점 차이)에 도달할 때까지 경기를 합니다. 각 선수는 1점에서 2점까지 득점할 수 있으며, 각 선수는 어시스트, 리바운드, 파울을 할 수 있습니다.

프로젝트 계층 구조는 다음과 같습니다.

프로젝트 계층

모델

  • Game.swift
    • 게임 논리를 포함하고 전체 점수를 추적하고 각 플레이어의 움직임을 추적합니다.
  • Team.swift
    • 팀 이름과 선수 목록을 포함합니다(각 팀에 3명의 선수).
  • Player.swift
    • 이름을 가진 싱글 플레이어.

보다

  • HomeViewController.swift
    • GameScoreboardEditorViewController 를 표시하는 루트 뷰 컨트롤러
  • GameScoreboardEditorViewController.swift
    • Main.storyboard 의 Interface Builder 보기로 보완되었습니다.
    • 이 튜토리얼의 관심 화면입니다.
  • PlayerScoreboardMoveEditorView.swift
    • PlayerScoreboardMoveEditorView.xib 의 Interface Builder 보기로 보완됨
    • 위 뷰의 서브뷰 역시 MVVM 디자인 패턴을 사용합니다.

뷰모델

  • ViewModel 그룹이 비어 있습니다. 이것이 이 튜토리얼에서 구축할 것입니다.

다운로드한 Xcode 프로젝트에는 이미 View 개체( UIViewUIViewController )에 대한 자리 표시자가 포함되어 있습니다. 이 프로젝트에는 ViewModel 개체( Services 그룹)에 데이터를 제공하는 방법 중 하나를 시연하기 위해 만든 몇 가지 사용자 지정 개체도 포함되어 있습니다.

Extensions 그룹에는 이 자습서의 범위에 속하지 않고 설명이 필요 없는 UI 코드에 대한 유용한 확장이 포함되어 있습니다.

이 시점에서 앱을 실행하면 완성된 UI가 표시되지만 사용자가 버튼을 눌러도 아무 일도 일어나지 않습니다.

이는 앱 로직에 연결하지 않고 UI 요소를 모델의 데이터로 채우지 않고 뷰와 IBActions 만 생성했기 때문입니다(나중에 배우게 될 Game 개체에서).

ViewModel과 View와 Model 연결하기

MVVM 디자인 패턴에서 View는 Model에 대해 아무것도 알지 못합니다. View가 아는 유일한 것은 ViewModel로 작업하는 방법입니다.

보기를 검사하여 시작하십시오.

GameScoreboardEditorViewController.swift 파일에서 이 시점에서 fillUI 메서드는 비어 있습니다. UI를 데이터로 채우려는 위치입니다. 이를 달성하려면 ViewController 에 대한 데이터를 제공해야 합니다. ViewModel 개체를 사용하여 이 작업을 수행합니다.

먼저 이 ViewController 에 필요한 모든 데이터를 포함하는 ViewModel 개체를 만듭니다.

비어 있을 ViewModel Xcode 프로젝트 그룹으로 이동하여 GameScoreboardEditorViewModel.swift 파일을 만들고 프로토콜로 만듭니다.

 import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: String { get } var score: String { get } var isFinished: Bool { get } var isPaused: Bool { get } func togglePause(); }

이와 같은 프로토콜을 사용하면 상황이 깨끗하고 멋지게 유지됩니다. 사용할 데이터만 정의해야 합니다.

다음으로 이 프로토콜에 대한 구현을 만듭니다.

GameScoreboardEditorViewModelFromGame.swift 라는 새 파일을 만들고 이 객체를 NSObject 의 하위 클래스로 만듭니다.

또한 GameScoreboardEditorViewModel 프로토콜을 준수하도록 합니다.

 import Foundation class GameScoreboardEditorViewModelFromGame: NSObject, GameScoreboardEditorViewModel { let game: Game struct Formatter { static let durationFormatter: DateComponentsFormatter = { let dateFormatter = DateComponentsFormatter() dateFormatter.unitsStyle = .positional return dateFormatter }() } // MARK: GameScoreboardEditorViewModel protocol var homeTeam: String var awayTeam: String var time: String var score: String var isFinished: Bool var isPaused: Bool func togglePause() { if isPaused { startTimer() } else { pauseTimer() } self.isPaused = !isPaused } // MARK: Init init(withGame game: Game) { self.game = game self.homeTeam = game.homeTeam.name self.awayTeam = game.awayTeam.name self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) self.isFinished = game.isFinished self.isPaused = true } // MARK: Private fileprivate var gameTimer: Timer? fileprivate func startTimer() { let interval: TimeInterval = 0.001 gameTimer = Timer.schedule(repeatInterval: interval) { timer in self.game.time += interval self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game) } } fileprivate func pauseTimer() { gameTimer?.invalidate() gameTimer = nil } // MARK: String Utils fileprivate static func timeFormatted(totalMillis: Int) -> String { let millis: Int = totalMillis % 1000 / 100 // "/ 100" <- because we want only 1 digit let totalSeconds: Int = totalMillis / 1000 let seconds: Int = totalSeconds % 60 let minutes: Int = (totalSeconds / 60) return String(format: "%02d:%02d.%d", minutes, seconds, millis) } fileprivate static func timeRemainingPretty(for game: Game) -> String { return timeFormatted(totalMillis: Int(game.time * 1000)) } fileprivate static func scorePretty(for game: Game) -> String { return String(format: "\(game.homeTeamScore) - \(game.awayTeamScore)") } }

ViewModel이 이니셜라이저를 통해 작동하는 데 필요한 모든 것을 제공했음을 주목하십시오.

이 ViewModel 아래의 모델인 Game 개체를 제공했습니다.

지금 앱을 실행하면 이 ViewModel 데이터를 View 자체에 연결하지 않았기 때문에 여전히 작동하지 않습니다.

따라서 GameScoreboardEditorViewController.swift 파일로 돌아가서 viewModel 이라는 공용 속성을 생성합니다.

GameScoreboardEditorViewModel 유형으로 만드십시오.

GameScoreboardEditorViewController.swift 내부의 viewDidLoad 메서드 바로 앞에 배치합니다.

 var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }

다음으로 fillUI 메소드를 구현해야 합니다.

이 메서드가 viewModel 속성 관찰자( didSet )와 viewDidLoad 메서드의 두 곳에서 어떻게 호출되는지 주목하십시오. viewDidLoad 메서드가 호출되기 전에 ViewController 를 생성하고 View에 연결하기 전에 ViewModel을 할당할 수 있기 때문입니다.

반면에 ViewController의 뷰를 다른 뷰에 연결하고 viewDidLoad 를 호출할 수 있지만 그 시점에 viewModel 이 설정되지 않으면 아무 일도 일어나지 않습니다.

그렇기 때문에 먼저 데이터가 UI를 채우도록 모든 것이 설정되어 있는지 확인해야 합니다. 예기치 않은 사용으로부터 코드를 보호하는 것이 중요합니다.

따라서 fillUI 메서드로 이동하여 다음 코드로 바꿉니다.

 fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } // we are sure here that we have all the setup done self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam self.scoreLabel.text = viewModel.score self.timeLabel.text = viewModel.time let title: String = viewModel.isPaused ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) }

이제 pauseButtonPress 메서드를 구현합니다.

 @IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }

이제 이 ViewController 에 실제 viewModel 속성을 설정하기만 하면 됩니다. 당신은 이것을 "외부에서" 합니다.

HomeViewController.swift 파일을 열고 ViewModel의 주석을 제거하십시오. showGameScoreboardEditorViewController 메소드에서 라인 생성 및 설정:

 // uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel

이제 앱을 실행합니다. 다음과 같이 표시되어야 합니다.

iOS 앱

점수, 시간 및 팀 이름을 담당하는 중간 보기는 더 이상 Interface Builder에서 설정한 값을 표시하지 않습니다.

이제 실제 Model 개체( Game 개체)에서 데이터를 가져오는 ViewModel 개체 자체의 값을 표시합니다.

훌륭한! 하지만 플레이어 뷰는 어떻습니까? 그 버튼은 여전히 ​​​​아무것도하지 않습니다.

플레이어 이동 추적에 대한 보기가 6개 있다는 것을 알고 있습니다.

이를 위해 PlayerScoreboardMoveEditorView 라는 별도의 하위 보기를 만들었습니다. 이 하위 보기는 현재 실제 데이터와 아무 작업도 하지 않고 PlayerScoreboardMoveEditorView.xib 파일 내에서 Interface Builder를 통해 설정된 정적 값을 표시합니다.

약간의 데이터를 제공해야 합니다.

GameScoreboardEditorViewControllerGameScoreboardEditorViewModel 과 동일한 방식으로 수행합니다.

Xcode 프로젝트에서 ViewModel 그룹을 열고 여기에서 새 프로토콜을 정의합니다.

PlayerScoreboardMoveEditorViewModel.swift 라는 새 파일을 만들고 내부에 다음 코드를 넣습니다.

 import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: String { get } var twoPointMoveCount: String { get } var assistMoveCount: String { get } var reboundMoveCount: String { get } var foulMoveCount: String { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }

이 ViewModel 프로토콜은 상위 뷰인 GameScoreboardEditorViewController 에서 했던 것처럼 PlayerScoreboardMoveEditorView 에 맞도록 설계되었습니다.

사용자가 수행할 수 있는 5가지 다른 동작에 대한 값이 있어야 하며 사용자가 작업 버튼 중 하나를 터치할 때 반응해야 합니다. 플레이어 이름에 대한 String 도 필요합니다.

이 작업을 수행한 후 부모 보기( GameScoreboardEditorViewController )에서 했던 것처럼 이 프로토콜을 구현하는 구체적인 클래스를 만듭니다.

다음으로 이 프로토콜의 구현을 만듭니다. 새 파일을 만들고 이름을 PlayerScoreboardMoveEditorViewModelFromPlayer.swift 로 지정하고 이 개체를 NSObject 의 하위 클래스로 만듭니다. 또한 PlayerScoreboardMoveEditorViewModel 프로토콜을 준수하도록 합니다.

 import Foundation class PlayerScoreboardMoveEditorViewModelFromPlayer: NSObject, PlayerScoreboardMoveEditorViewModel { fileprivate let player: Player fileprivate let game: Game // MARK: PlayerScoreboardMoveEditorViewModel protocol let playerName: String var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String func onePointMove() { makeMove(.onePoint) } func twoPointsMove() { makeMove(.twoPoints) } func assistMove() { makeMove(.assist) } func reboundMove() { makeMove(.rebound) } func foulMove() { makeMove(.foul) } // MARK: Init init(withGame game: Game, player: Player) { self.game = game self.player = player self.playerName = player.name self.onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" self.twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" self.assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" self.reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" self.foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } // MARK: Private fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } }

이제 이 인스턴스를 "외부에서" 생성할 객체가 필요하고 이를 PlayerScoreboardMoveEditorView 내부의 속성으로 설정해야 합니다.

HomeViewControllerGameScoreboardEditorViewController 에서 viewModel 속성을 어떻게 설정했는지 기억하십니까?

같은 방식으로 GameScoreboardEditorViewController 는 PlayerScoreboardMoveEditorView의 상위 보기이며 해당 PlayerScoreboardMoveEditorViewGameScoreboardEditorViewController 개체 생성을 PlayerScoreboardMoveEditorViewModel 합니다.

먼저 GameScoreboardEditorViewModel 을 확장해야 합니다.

GameScoreboardEditorViewMode l을 열고 다음 두 속성을 추가합니다.

 var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }

또한 initWithGame 메서드 바로 위에 있는 다음 두 속성으로 GameScoreboardEditorViewModelFromGame 을 업데이트합니다.

 let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]

initWithGame 안에 다음 두 줄을 추가하십시오.

 self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)

물론 누락된 playerViewModelsWithPlayers 메서드를 추가합니다.

 // MARK: Private Init fileprivate static func playerViewModels(from players: [Player], game: Game) -> [PlayerScoreboardMoveEditorViewModel] { var playerViewModels: [PlayerScoreboardMoveEditorViewModel] = [PlayerScoreboardMoveEditorViewModel]() for player in players { playerViewModels.append(PlayerScoreboardMoveEditorViewModelFromPlayer(withGame: game, player: player)) } return playerViewModels }

엄청난!

홈 및 원정 선수 배열로 ViewModel( GameScoreboardEditorViewModel )을 업데이트했습니다. 여전히 이 두 배열을 채워야 합니다.

viewModel 을 사용하여 UI를 채운 동일한 위치에서 이 작업을 수행합니다.

fillUI 를 열고 GameScoreboardEditorViewController 메소드로 이동하십시오. 메서드 끝에 다음 줄을 추가합니다.

 homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2]

현재로서는 PlayerScoreboardMoveEditorView 내부에 실제 viewModel 속성을 추가하지 않았기 때문에 빌드 오류가 발생했습니다.

PlayerScoreboardMoveEditorView` init method inside the 위에 다음 코드를 추가하십시오.

 var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }

그리고 fillUI 메서드를 구현합니다.

 fileprivate func fillUI() { guard let viewModel = viewModel else { return } self.name.text = viewModel.playerName self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount }

마지막으로 앱을 실행하고 UI 요소의 데이터가 어떻게 Game 개체의 실제 데이터인지 확인합니다.

iOS 앱

이 시점에서 MVVM 디자인 패턴을 사용하는 기능적인 앱이 있습니다.

보기에서 모델을 멋지게 숨기고 보기가 MVC에 익숙한 것보다 훨씬 간단합니다.

지금까지 View와 ViewModel을 포함하는 앱을 만들었습니다.

해당 보기에는 ViewModel과 동일한 하위 보기(플레이어 보기)의 6개 인스턴스도 있습니다.

그러나 알 수 있듯이 UI에 데이터를 한 번만 표시할 수 있으며( fillUI 메서드에서) 해당 데이터는 정적입니다.

보기의 데이터가 해당 보기의 수명 동안 변경되지 않으면 이러한 방식으로 MVVM을 사용할 수 있는 훌륭하고 깨끗한 솔루션이 있는 것입니다.

ViewModel을 동적으로 만들기

데이터가 변경되므로 ViewModel을 동적으로 만들어야 합니다.

이것이 의미하는 바는 Model이 변경될 때 ViewModel이 public 속성 값을 변경해야 한다는 것입니다. UI를 업데이트할 뷰로 변경 사항을 다시 전파합니다.

이를 수행하는 방법은 많이 있습니다.

Model이 변경되면 ViewModel이 먼저 알림을 받습니다.

뷰에 변경 사항을 전파하려면 몇 가지 메커니즘이 필요합니다.

일부 옵션에는 RxSwift가 포함됩니다. RxSwift는 상당히 큰 라이브러리이며 익숙해지는 데 시간이 걸립니다.

ViewModel은 각 속성 값이 변경될 때마다 NSNotification 을 실행할 수 있지만 이는 보기가 할당 해제될 때 알림 구독 및 구독 취소와 같은 추가 처리가 필요한 많은 코드를 추가합니다.

키-값-관찰(KVO)은 또 다른 옵션이지만 사용자는 해당 API가 화려하지 않다는 것을 확인할 것입니다.

이 튜토리얼에서는 Bindings, Generics, Swift 및 MVVM 문서에 잘 설명되어 있는 Swift 제네릭 및 클로저를 사용합니다.

이제 예제 앱으로 돌아가 보겠습니다.

ViewModel 프로젝트 그룹으로 이동하여 새 Swift 파일 Dynamic.swift 를 만듭니다.

 class Dynamic<T> { typealias Listener = (T) -> () var listener: Listener? func bind(_ listener: Listener?) { self.listener = listener } func bindAndFire(_ listener: Listener?) { self.listener = listener listener?(value) } var value: T { didSet { listener?(value) } } init(_ v: T) { value = v } }

View 수명 주기 동안 변경될 것으로 예상되는 ViewModel의 속성에 이 클래스를 사용합니다.

먼저 PlayerScoreboardMoveEditorView 및 해당 ViewModel인 PlayerScoreboardMoveEditorViewModel 로 시작합니다.

PlayerScoreboardMoveEditorViewModel 을 열고 속성을 확인합니다.

playerName 은 변경되지 않을 것으로 예상되므로 그대로 둘 수 있습니다.

다른 5가지 속성(5가지 이동 유형)이 변경되므로 이에 대해 조치를 취해야 합니다. 해결책? 방금 프로젝트에 추가한 위에서 언급한 Dynamic 클래스입니다.

PlayerScoreboardMoveEditorViewModel 내부에서 이동 횟수를 나타내는 5개의 문자열에 대한 정의를 제거하고 다음으로 대체합니다.

 var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get }

이제 ViewModel 프로토콜은 다음과 같이 보일 것입니다.

 import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }

Dynamic 유형을 사용하면 해당 특정 속성의 값을 변경할 수 있으며 동시에 변경 수신기 개체(이 경우 보기가 됨)에 알릴 수 있습니다.

이제 실제 ViewModel 구현 PlayerScoreboardMoveEditorViewModelFromPlayer 를 업데이트합니다.

이것을 교체하십시오:

 var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String

다음과 함께:

 let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>

참고: 실제 속성을 변경하지 않을 것이기 때문에 let 을 사용하여 이러한 속성을 상수로 선언해도 됩니다. Dynamic 개체의 value 속성을 변경합니다.

이제 Dynamic 개체를 초기화하지 않았기 때문에 빌드 오류가 발생합니다.

PlayerScoreboardMoveEditorViewModelFromPlayer 의 init 메서드 내에서 이동 속성 초기화를 다음으로 교체합니다.

 self.onePointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .onePoint))") self.twoPointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .twoPoints))") self.assistMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .assist))") self.reboundMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .rebound))") self.foulMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .foul))")

PlayerScoreboardMoveEditorViewModelFromPlayer 내부에서 makeMove 메서드로 이동하여 다음 코드로 바꿉니다.

 fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount.value = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount.value = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount.value = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount.value = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount.value = "\(game.playerMoveCount(for: player, move: .foul))" }

보시다시피 Dynamic 클래스의 인스턴스를 만들고 여기에 String 값을 할당했습니다. 데이터를 업데이트해야 하는 경우 Dynamic 속성 자체를 변경하지 마십시오. 오히려 value 속성을 업데이트하십시오.

엄청난! PlayerScoreboardMoveEditorViewModel 은 이제 동적입니다.

이를 활용하여 이러한 변경 사항을 실제로 수신할 뷰로 이동합니다.

PlayerScoreboardMoveEditorView 및 해당 fillUI 메서드를 엽니다( String 값을 Dynamic 개체 유형에 할당하려고 하기 때문에 이 시점에서 이 메서드에 빌드 오류가 표시되어야 합니다.)

"오류가 발생한" 줄을 교체합니다.

 self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount

다음과 함께:

 viewModel.onePointMoveCount.bindAndFire { [unowned self] in self.onePointCountLabel.text = $0 } viewModel.twoPointMoveCount.bindAndFire { [unowned self] in self.twoPointCountLabel.text = $0 } viewModel.assistMoveCount.bindAndFire { [unowned self] in self.assistCountLabel.text = $0 } viewModel.reboundMoveCount.bindAndFire { [unowned self] in self.reboundCountLabel.text = $0 } viewModel.foulMoveCount.bindAndFire { [unowned self] in self.foulCountLabel.text = $0 }

다음으로 이동 작업을 나타내는 5가지 방법을 구현합니다( 버튼 작업 섹션).

 @IBAction func onePointAction(_ sender: Any) { viewModel?.onePointMove() } @IBAction func twoPointsAction(_ sender: Any) { viewModel?.twoPointsMove() } @IBAction func assistAction(_ sender: Any) { viewModel?.assistMove() } @IBAction func reboundAction(_ sender: Any) { viewModel?.reboundMove() } @IBAction func foulAction(_ sender: Any) { viewModel?.foulMove() }

앱을 실행하고 일부 이동 버튼을 클릭합니다. 액션 버튼을 클릭할 때 플레이어 보기 내부의 카운터 값이 어떻게 변경되는지 확인할 수 있습니다.

iOS 앱

PlayerScoreboardMoveEditorViewPlayerScoreboardMoveEditorViewModel 작업이 완료되었습니다.

이것은 간단했습니다.

이제 기본 보기( GameScoreboardEditorViewController )에서 동일한 작업을 수행해야 합니다.

먼저 GameScoreboardEditorViewModel 을 열고 보기의 수명 주기 동안 변경될 것으로 예상되는 값을 확인합니다.

time , score , isFinished , isPaused 정의를 Dynamic 버전으로 바꿉니다.

 import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: Dynamic<String> { get } var score: Dynamic<String> { get } var isFinished: Dynamic<Bool> { get } var isPaused: Dynamic<Bool> { get } func togglePause() var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get } }

ViewModel 구현( GameScoreboardEditorViewModelFromGame )으로 이동하여 프로토콜에 선언된 속성으로 동일한 작업을 수행합니다.

이것을 교체하십시오:

 var time: String var score: String var isFinished: Bool var isPaused: Bool

다음과 함께:

 let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>

이제 ViewModel의 유형을 StringBool 에서 Dynamic<String>Dynamic<Bool> 으로 변경했기 때문에 몇 가지 오류가 발생합니다.

수정합시다.

다음으로 교체하여 togglePause 메서드를 수정합니다.

 func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }

유일한 변경 사항은 더 이상 속성에 직접 속성 값을 설정하지 않는다는 것입니다. 대신 개체의 value 속성에서 설정합니다.

이제 다음을 교체하여 initWithGame 메서드를 수정합니다.

 self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true

다음과 함께:

 self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)

지금 요점을 파악해야 합니다.

String , IntBool 과 같은 기본 값을 해당 개체의 Dynamic<T> 버전으로 래핑하여 경량 바인딩 메커니즘을 제공합니다.

수정해야 할 오류가 하나 더 있습니다.

startTimer 메서드에서 오류 줄을 다음으로 바꿉니다.

 self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)

플레이어의 ViewModel과 마찬가지로 ViewModel을 동적으로 업그레이드했습니다. 하지만 여전히 뷰( GameScoreboardEditorViewController )를 업데이트해야 합니다.

전체 fillUI 메서드를 다음으로 교체합니다.

 fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam viewModel.score.bindAndFire { [unowned self] in self.scoreLabel.text = $0 } viewModel.time.bindAndFire { [unowned self] in self.timeLabel.text = $0 } viewModel.isFinished.bindAndFire { [unowned self] in if $0 { self.homePlayer1View.isHidden = true self.homePlayer2View.isHidden = true self.homePlayer3View.isHidden = true self.awayPlayer1View.isHidden = true self.awayPlayer2View.isHidden = true self.awayPlayer3View.isHidden = true } } viewModel.isPaused.bindAndFire { [unowned self] in let title = $0 ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) } homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2] }

유일한 차이점은 4개의 동적 속성을 변경하고 각각에 변경 수신기를 추가했다는 것입니다.

이 시점에서 앱을 실행하면 시작/일시 중지 버튼을 토글하면 게임 타이머가 시작되고 일시 중지됩니다. 이것은 게임 중 타임아웃에 사용됩니다.

점수 버튼 중 하나( 1 점 및 2 점 버튼)를 누를 때 UI에서 점수가 변경되지 않는 것을 제외하고 거의 완료되었습니다.

이는 기본 Game 모델 개체의 점수 변경 사항을 ViewModel까지 실제로 전파하지 않았기 때문입니다.

따라서 약간의 검사를 위해 Game 모델 개체를 엽니다. 해당 updateScore 메소드를 확인하십시오.

 fileprivate func updateScore(_ score: UInt, withScoringPlayer player: Player) { if isFinished || score == 0 { return } if homeTeam.containsPlayer(player) { homeTeamScore += score } else { assert(awayTeam.containsPlayer(player)) awayTeamScore += score } if checkIfFinished() { isFinished = true } NotificationCenter.default.post(name: Notification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: self) }

이 방법은 두 가지 중요한 작업을 수행합니다.

먼저 두 팀의 점수를 기반으로 게임이 종료되면 isFinished 속성을 true 로 설정합니다.

그 후 점수가 변경되었다는 알림을 게시합니다. GameScoreboardEditorViewModelFromGame 에서 이 알림을 수신하고 알림 핸들러 메서드에서 동적 점수 값을 업데이트합니다.

initWithGame 메소드의 맨 아래에 다음 행을 추가하십시오(오류를 피하기 위해 super.init() 호출을 잊지 마십시오):

 super.init() subscribeToNotifications()

Below initWithGame method, add deinit method, since you want to do the cleanup properly and avoid crashes caused by the NotificationCenter .

 deinit { unsubscribeFromNotifications() }

Finally, add the implementations of these methods. Add this section right below the deinit method:

 // MARK: Notifications (Private) fileprivate func subscribeToNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(gameScoreDidChangeNotification(_:)), name: NSNotification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: game) } fileprivate func unsubscribeFromNotifications() { NotificationCenter.default.removeObserver(self) } @objc fileprivate func gameScoreDidChangeNotification(_ notification: NSNotification){ self.score.value = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) if game.isFinished { self.isFinished.value = true } }

Now, run the app, and click on the player views to change scores. Since you've already connected dynamic score and isFinished in the ViewModel with the View, everything should work when you change the score value inside the ViewModel.

How to Further Improve the App

While there's always room for improvement, this is out of scope of this tutorial.

For example, we do not stop the time automatically when the game is over (when one of the teams reaches 15 points), we just hide the player views.

You can play with the app if you like and upgrade it to have a “game creator” view, which would create a game, assign team names, assign player names and create a Game object that could be used to present GameScoreboardEditorViewController .

We can create another “game list” view that uses the UITableView to show multiple games in progress with some detailed info in the table cell. In cell select, we can show the GameScoreboardEditorViewController with the selected Game .

The GameLibrary has already been implemented. Just remember to pass that library reference to the ViewModel objects in their initializer. For example, “game creator's” ViewModel would need to have an instance of GameLibrary passed through the initializer so that it would be able to insert the created Game object into the library. “Game list's” ViewModel would also need this reference to fetch all games from the library, which will be needed by the UITableView.

The idea is to hide all of the dirty (non-UI) work inside the ViewModel and have the UI (View) only act with prepared presentation data.

지금 무엇?

After you get used to the MVVM, you can further improve it by using Uncle Bob's Clean Architecture rules.

An additional good read is a three-part tutorial on Android architecture:

  • Android Architecture: Part 1 – Every new beginning is hard,
  • Android Architecture: Part 2 – The clean architecture,
  • Android Architecture: Part 2 – The clean architecture.

Examples are written in Java (for Android), and if you are familiar with Java (which is much closer to Swift then Objective-C is to Java), you'll get ideas on how to further refactor your code inside the ViewModel objects so that they don't import any iOS modules ( UIKit or CoreLocation eg).

These iOS modules can be hidden behind the pure NSObjects , which is good for code reusability.

MVVM is a good choice for most iOS apps, and hopefully, you will give it a try in your next project. Or, try it in your current project when you are creating a UIViewController .

Related: Working With Static Patterns: A Swift MVVM Tutorial