Stanford cs193p Lecture 2 - MVVM & Type (Code Demo)

rloyhvv·2021년 5월 3일
0
post-thumbnail

2강 후반부에서는 Memorize application에서 MVVM을 직접 구현한다.
<To Do List>

  • Use the special init function in both our Model and our ViewModel
  • Use generics in our implementation of our Model
  • Use a function as a type in our Model
  • See a class for the first time (our ViewModel will be a class)
  • Implement an intent in our MVVM
  • Make our UI reactive through our MVVM design

강의가 흘러가는대로 코드를 따라해보면 이해가 더욱 잘 된다.

강의를 다 듣고 완성한 코드이다. 왼쪽부터 Model, ViewModel, View인데 작아서 코드가 잘 안 보이네..ㅎ

Model 만들기

1강에서 View를 만들었다. 여기서 새로운 파일을 만들어 Model을 만든다. (MemoryGame.swift)

// MemoryGame.swift
import foundation
struct MemoryGame {}

이 Model에는 뭐가 있어야할까? Model은 Data + Logic이다. MemoryGame은 카드 쌍을 맞추는 게임이니 우선 카드 쌍을 만들어야한다. 그리고 카드를 골랐을 때 어떤 동작을 할지 규정하는 함수도 있어야 한다. 우선 이 두 요소를 넣으면 아래와 같다.

import Foundation

struct MemoryGame {
    // 카드가 담긴 array
    var cards: Array<Card>
    // 카드를 선택했을 때 실행하는 함수
    func choose(card: Card) {
    	print("card chosen: \(card)")
    }
    // 카드 struct
    struct Card {
    	var isFaceUp: Bool
        var isMatched: Bool
        var content: ???
    }
}

MemoryGame이라는 Model 안에는 Card가 담긴 Array, choose 함수, Card struct 정의가 들어있다. choose 함수 안에 있는 \()를 쓰면 card 값을 자동으로 변환하여 보여준다.

그럼 card 값은 뭐가 될까? 이는 Card struct 안에서 정의한다. isFaceUp, isMatched는 게임을 진행하면서 카드가 갖고있어야할 state이고 content가 앞선 질문의 답이 될 것이다. 게임은 Card의 content를 비교하여 같은 카드 쌍인지 아닌지를 판별할 것이다.

그럼 content는 어떤 타입일까? Int? String? Image? 이것은 UI와 연관된 내용이다. Model이 결정할 사항이 아니다. 따라서 content의 type은 generics로 정의한다.

import Foundation

struct MemoryGame<CardContent> {
    // 카드가 담긴 array
    var cards: Array<Card>
    // 카드를 선택했을 때 실행하는 함수
    func choose(card: Card) {
    	print("card chosen: \(card)")
    }
    // 카드 struct
    struct Card {
    	var isFaceUp: Bool
        var isMatched: Bool
        var content: CardContent
    }
}

강의처럼 CardContent라고 적었는데 다르게 써도 된다. (Content, T, ABC 이딴거 써도 된다.) 다만 우리가 임의로 만든 type이기 때문에 Model 이름 뒤에 적어줘야한다. (MemoryGame<CardContent> 처럼)

ViewModel 만들기

// EmojiMemoryGame.swift
class EmojiMemoryGame {    
	private(set) var model: MemoryGame<String>
}

이쯤에서 다시 새 파일을 만든다. 이 파일은 ViewModel이다. 이름에서 알 수 있듯이 이 ViewModel은 Card content가 Emoji일때, 즉 content가 'String'인 Model과 연결된 ViewModel이다. 두 가지가 눈에 띈다.

  1. Model은 struct를 사용했지만 ViewModel은 class를 사용했다.
  2. ViewModel 안에서 model을 사용할 때 private(set) 키워드를 사용했다.

먼저, class를 사용한 이유가 뭘까?

ViewModel은 Model과 View를 연결한다. Model이 바뀔 때마다 View가 자동으로 바뀌어야하고 ViewModel이 중간에서 Model의 변경 사항을 publish한다. 이 때 ViewModel이 struct라면 ViewModel을 reassign하는 경우가 발생한다. 하지만 class로 만들면 pointer를 사용하기 때문에 이 문제를 피할 수 있다.

다음으로 private(set)은 뭘까?
Swift에서 private는 '동일 파일 내에서만 접근이 허락되고 이 외에는 접근을 금지시킴' 이라는 의미로 사용된다.
뒤에 (set)이 붙으면 값을 수정하는 권한을 의미한다. (getter, setter의 의미)
따라서 private(set) var model의 의미는 model을 아무나 읽을 수는 있되 아무나 수정할 수 없음을 의미한다.

Data와 Logic을 갖는 Model을 아무나 수정하면 당연히 문제가 발생한다. 그런데 카드 게임을 하면 사용자가 Data를 변경하는 경우가 발생한다. 카드를 클릭하거나(isFaceUp), 카드 쌍을 맞췄을 때(isMatched) 상태를 변경한다. 즉, 사용자가 Model을 변경한다.

앞에서 MVVM을 배울 때 우리는 방법을 배웠다. View가 ViewModel에게 intent function을 전달하고, ViewModel이 사용자의 intent를 따라 Model을 수정한다고.

여기서도 마찬가지다. ViewModel 코드 안에 적절한 함수를 만들면 된다.
우선 Model에 choose라는 함수가 있으니 이와 연결할 함수를 만들자.

// EmojiMemoryGame.swift
class EmojiMemoryGame {
    private var model: MemoryGame<String>
        
    var cards: Array<MemoryGame<String>.Card> {
        model.cards
    }
      
    func choose(card: MemoryGame<String>.Card) {
        model.choose(card: card)
    }
}

강의에서는 좀 더 restrictive ViewModel을 위해 private로 변경하였다.
중간에 var cards는 View가 Model 안에 있는 요소에 접근할 때 필요하며 func choose 역시 View가 Model에게 사용자의 intent를 전달하려면 필요하다.

Initialize

여기까지 작성하고나면 ViewModel 코드에 error가 하나 발생한다.

Class 'EmojiMemoryGame' has no initializers

EmojiMemoryGame을 initialize해야한다. EmojiMemoryGame의 type은 MemoryGame<String>이고 이는 Model 코드에 정의되어있다. 이를 토대로 코드를 작성하면 이렇게 적을 수 있다.

private var model: MemoryGame<String> = MemoryGame<String>(cards: ???)

우리는 MemoryGame에서 Card content를 generic으로 만들었기 때문에 카드를 담은 array를 initialize하지 않았었다. 뭐가 들어있을지 모르니까.

강의에서는 array 자체를 정의하기보다 array에 들어가는 카드 개수를 정의하는 방식으로 구현했다.

// MemeoryGame.swift
import Foundation

struct MemoryGame<CardContent> {
    var cards: Array<Card>
    ...
    init(numberOfPairsOfCards: Int) {
    	cards = Array<Card>()
        for pairIndex in 0..<numberOfPairsOfCards {
        	cards.append(Card(isFaceUp: false, isMatched: false, content: ???))
           	cards.append(Card(isFaceUp: false, isMatched: false, content: ???))
        }
    }
    ...  
}

content는 진짜 type이 뭐냐에 따라 달라지기 때문에 EmojiMemoryGame, 즉 ViewModel에서 정의한다.
따라서 Model에 ViewModel에서 정의한 content를 가져오는 함수를 만들어야 한다.

// MemeoryGame.swift
import Foundation

struct MemoryGame<CardContent> {
    var cards: Array<Card>
    ...
    init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
    	cards = Array<Card>()
        for pairIndex in 0..<numberOfPairsOfCards {
        	let content = cardContentFactory(pairIndex)
        	cards.append(Card(isFaceUp: false, isMatched: false, content: content))
           	cards.append(Card(isFaceUp: false, isMatched: false, content: content))
        }
    }
    ...  
}

cardContentFactory라는 함수를 init의 argument로 받고 pairIndex 값마다 다른 content를 넘기게 만들었다. 다음으로 cardContentFactory에 들어갈 함수를 ViewModel에서 구현한다.

// EmojiMemoryGame.swift
class EmojiMemoryGame {
    private var model: MemoryGame<String> = createMemoryGame()
    func createMemoryGame() -> MemoryGame<String> {
	let emojis: Array<String> = ["👻", "🍪"]
        return MemoryGame<String>(numberOfPairsOfCards: 2) { pairIndex in
            return emojis[pairIndex]
        }
    }
    ...
}

private var model을 initialize하는 부분을 함수로 따로 만들었다. return하는 MemoryGame에서 { pairIndex in return emojis[pairIndex] } 가 cardContentFactory와 연결되는 함수이다. 따로 이름을 두고 정의하지 않고 {} 안에서 함수처럼 쓰이는 아이를 closure라고 부른다.

이렇게 적으면 또 에러가 발생한다.

Cannot use instance member 'createMemoryGame' within property initializer; property initializers run before 'self' is available

Initialize할 때 instance member를 사용하면 안 된다는 뜻이다.

Instance vs Type

struct User {
    let name: String
    
    func greeting() -> String {
    	"Hello World!"
}
let jane = User(name: "Jane")
jane.greeting()

여기서 jane이 instance이고 User가 type이다.
또한 greeting이라는 함수는 instance method, 다시 말해 instance member이다.
Instance member는 instance를 만든 다음에 그 instance를 통해서만 사용할 수 있다. Class를 initialize 하는 중간에는 instance가 만들어지지 않아 instance member를 사용할 수 없다.
쓰고 싶다면 static 키워드를 붙여 type method로 바꾸고 사용하면 된다.
(대신 type method로 바꾸면 instance member를 사용할 수 없다.)

위에서 발생한 오류 역시 static function으로 바꾸면 해결된다.

// EmojiMemoryGame.swift
class EmojiMemoryGame {
    private var model: MemoryGame<String> = createMemoryGame()
    static func createMemoryGame() -> MemoryGame<String> {
	let emojis: Array<String> = ["👻", "🍪"]
        return MemoryGame<String>(numberOfPairsOfCards: 2) { pairIndex in
            return emojis[pairIndex]
        }
    }
    ...
}

View 수정하기

1강에서 만들었던 View를 다시 살펴보자.

import SwiftUI

struct ContentView: View {
    var body: some View {
        HStack {
            ForEach(0..<4) { index in
                CardView(isFaceUp: false)
            }
        }
            .padding()
            .foregroundColor(Color.orange)
            .font(Font.largeTitle)
    }
}

struct CardView: View {
    var isFaceUp: Bool
    var body: some View {
        ZStack {
            if isFaceUp {
                RoundedRectangle(cornerRadius: 10.0).fill(Color.white)
                RoundedRectangle(cornerRadius: 10.0).stroke(lineWidth: 3.0)
                Text("👻")
            } else {
                RoundedRectangle(cornerRadius: 10.0).fill()
            }
        }
    }
}

위 코드는 제 마음대로 4개의 카드를 만들고 content로 멋대로 넣었다. Model과 ViewModel을 만들었으니 이제 여기서 데이터를 끌어오자.
View는 ViewModel로부터 Model의 정보를 가져온다. struct CardView는 이제 isFaceUp이라는 변수를 스스로 가질 게 아니라 Model에서 정의한 struct Card를 써야한다. 물론 content의 type은 ViewModel이 정의하며 실제로 카드를 불러올 때도 ViewModel을 사용한다.

import SwiftUI

struct ContentView: View {
    var viewModel: EmojiMemoryGame
    var body: some View {
        HStack {
            ForEach(0..<4) { index in
                CardView(card: ???)
            }
        }
            ...
    }
}

struct CardView: View {
    var card: MemoryGame<String>.Card
    var body: some View {
        ZStack {
            if isFaceUp {
                ...
                Text(card.content)
            } else {
            	...
            }
        }
    }
}

여기서 생각할 거리가 있다.
View에서 ViewModel을 사용하기 위해서 var viewModel: EmojiMemoryGame이라고 쓰면 viewModel이라는 variable가 만들어지기 때문에 초기값을 주어야한다. 그러면 var viewModel = EmojiMemoryGame()이라고 써야할까?

여기서 짚고넘어가야할 점은 우리가 만든 EmojiMemoryGame라는 ViewModel은 class로 만들었다는 점이다. 그리고 class는 pointer를 사용하기 때문에 여러 View에서 EmojiMemoryGame을 부르더라도 결국 하나의 EmojiMemoryGame을 참조할 뿐이다. 그렇기 때문에 View 코드 안에 EmojiMemoryGame을 생성하는 것은 바람직하지 않다.
그럼 어디에서 만들어야 좋을까?

AppDelegate & SceneDelegate


AppDelegate.swift와 SceneDelegate.swift 파일은 처음 프로젝트를 생성할 때부터 있었던 파일이다. 이게 뭔지를 설명하면 글이 너무 길어질 것 같아서 따로 적기로 하고 (강의에서도 추후 설명한다고 하니 넘어가자).

// SceneDelegate.swift
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    ...
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let contentView = ContentView()
        ...
    }

SceneDelegate 파일을 보면 위 코드를 찾을 수 있다. 저기서 말하는 ContentView가 ContentView.swift, View 파일에서 정의한 View이며 여기서 argument로 ViewModel을 넘기면 된다.

// SceneDelegate.swift
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    ...
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    	let game = EmojiMemoryGame()
        let contentView = ContentView(viewModel: game)
        ...
    }

결과

강의에서 말하는 굵직굵직한 설명은 다 다룬 것 같다. 2강을 다 듣고 나서 작성한 코드이다.

// ContentView.swift
import SwiftUI
struct ContentView: View {
    var viewModel: EmojiMemoryGame
    var body: some View {
        HStack {
            ForEach(viewModel.cards) { card in
                CardView(card: card).onTapGesture { viewModel.choose(card: card) }
            }
        }
            .padding()
            .foregroundColor(Color.orange)
            .font(Font.largeTitle)
    }
}
struct CardView: View {
    var card: MemoryGame<String>.Card
    var body: some View {
        ZStack {
            if card.isFaceUp {
                RoundedRectangle(cornerRadius: 10.0).fill(Color.white)
                RoundedRectangle(cornerRadius: 10.0).stroke(lineWidth: 3.0)
                Text(card.content)
            } else {
                RoundedRectangle(cornerRadius: 10.0).fill()
            }
        }
    }
}
// EmojiMemoryGame.swift
import SwiftUI

class EmojiMemoryGame {
    private var model: MemoryGame<String> = EmojiMemoryGame.createMemoryGame()
    static func createMemoryGame() -> MemoryGame<String> {
        let emojis: Array<String> = ["👻", "🍪"]
        return MemoryGame<String>(NumberOfPairsOfCards: emojis.count) { pairIndex in
            return emojis[pairIndex]
        }
    }
    var cards: Array<MemoryGame<String>.Card> {
        model.cards
    }
    func choose(card: MemoryGame<String>.Card) {
        model.choose(card: card)
    }
}
// MemoryGame.swift
import Foundation
struct MemoryGame<CardContent> {
    var cards: Array<Card>
    func choose(card: Card) {
        print ("card chosen: \(card)")
    }
    init(NumberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
        cards = Array<Card>()
        for pairIndex in 0..<NumberOfPairsOfCards {
            let content = cardContentFactory(pairIndex)
            cards.append(Card(content: content, id: pairIndex * 2))
            cards.append(Card(content: content, id: pairIndex * 2 + 1))
        }
    }
    // Identifiable: View에서 ForEach(viewModel.cards)를 쓰려면 viewModel.cards 안에 있는 각각의 element가 식별 가능해야한다.
    // 그래서 type을 Identifiable로 바꾸었다.
    // 얘는 protocol인데 추후 강의에서 설명한다고 한다
    struct Card: Identifiable {
        var isFaceUp: Bool = false
        var isMatched: Bool = false
        // custom type (don't care type)
        var content: CardContent
        var id: Int
    }
}

참고

Youtube video: Stanford CS193p iPhone Application Development Spring 2020
What Is the Difference Between Instance Methods and Type Methods in Swift
Swift - Access Control

0개의 댓글