 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/Stanford iOS Programming (SwiftUI)

Lecture 4 Review Part1: Memorize Game Logic

singularis7 2021. 8. 19. 16:52
반응형

이전에 구현한 Model 과 ViewModel을 SwiftUI로 구현한 View에 연동해보는 시간을 가질 것입니다. View 구현에서 카드를 불러오거나, 뒤집는 등의 Model이나 View Model을 통해 이루어져야 하는 코드를 지워줍니다.

struct ContentView: View {
    // Model의 내용을 보여주는 Agent @Main에서 설정함
    var viewModel: EmojiMemoryGame
    
    var body: some View {
    	...
    }
}

Swift UI를 통해 선언된 View 구조체의 상단에 viewModel 프로퍼티를 선언해준다.  Swift에서 모든 상수나 변수는 무조건 값을 가지고 있어야 하는데 위 코드에는 type 만 명시되어 있고 값을 할당하는 코드가 보이지 않는다. 이게 어떻게 된 것일까?

프로그램이 처음으로 시작되는 지점은 MemorizeApp.swift 에 @main 속성 키워드를 사용하여 구현되어있다. ContentView는 구조체이고 Swift는 사용자가 struct에 별도의 생성자를 선언하지 않으면 자동으로 멤버별 free initilaizer를 생성해준다. 아래의 코드는 Swift 가 생성해준 생성자를 통해 viewModel을 넘겨주는 예시이다.

import SwiftUI

@main
struct MemorizeApp: App {
    let game = EmojiMemoryGame()
    
    var body: some Scene {
        WindowGroup {
            ContentView(viewModel: game)
        }
    }
}

EmojiMemoryGame은 MemorizeApp 에서 ViewModel의 역할을 담당하고 있기 때문에 class로 선언되어있다. class 또한 free initializer를 제공하기 때문에 위와 같은 방식을 통해 생성할 수 있다.

그런 와중에 갑자기 약간에 의문이 들 수 있다. 왜? 우리의 ViewModel은 인자로 아무것도 받지 않는가?, 'game' ViewModel은 게임이 진행되면서 내용물이 바뀔텐데 let을 통해 선언해도 문제 없는 것인가?

class EmojiMemoryGame {
    static let emojis = ["🥇", "🥈", "🥉", "🎖", "✈️", "🚀", "🧸", "🪄", "⏰", "📱", "⌚️", "💻", "🖥", "⚽️", "🏀", "🏈", "🥎", "⚾️", "🏓", "🤿", "🥊", "🎗", "🚁", "🚦"]
    
    static func createMemoryGame() -> MemoryGame<String> {
        MemoryGame<String>(numberOfPairsOfCards: 4) { pairIndex in
            emojis[pairIndex]
        }
    }
    
    private var model: MemoryGame<String> = createMemoryGame()
    
    var cards: Array<MemoryGame<String>.Card> {
        return model.cards
    }
}

 

첫번째 의문점에 대한 내용은 다음과 같다.

Swift에서 class의 경우 프로퍼티의 기본값이 설정된 상태로 프로그래밍 되어있을 때 아무것도 인자로 받지 않는 생성자를 기본적으로 제공해준다. 위 코드를 살펴보면 모든 프로퍼티에 기본값이 설정되어있음을 볼 수 있을 것이다. 그러나 class에 기본값이 설정되지 않은 프로퍼티가 존재한다면 프로그래머가 별도의 생성자를 통해 값을 설정하도록 명시해주어야 한다.

두번째 의문점에 대한 내용은 다음과 같다.

class를 통해 인스턴스를 생성할 경우 reference type 이기 때문에 변수가 포인터 처럼 동작하게 된다. 따라서 'let variable = AnyClass()' 라는 구문이 있으면 variable이라는 포인터 값은 상수여서 immutable 하지만 포인터가 가리키고 있는 Heap 메모리 상의 메모리 내용은 mutable하다는 의미이다.

이제 조금 진행되려나 했는데 문제가 발생했다. 우리가 만든 card는 identifiable하지 않기 때문에 ForEach가 동작하지 않게 된다. 이전에 string 은 id 파라미터에 string self 값을 넘겨주어 "동작" 하게는 만들었는데 이번에도 특정 개체를 식별할 수 있는 hashable 한 요소가 필요할 것이다. 이 사실을 모른체 좀더 꼬이다 보면 위와 같이 컴파일러가 문제를 찾아주지 못하는 경우가 발생될 수 있기도 하니 조심하자!

우리가 만든 모델의 카드가 마치 identifiable 한 것처럼 동작하게 하려면 어떻게 해야 할까? 사실 이전에 "~와 같이 행동하다" 라는 Logic 을 구성할 때 protocol 을 사용해왔다. 대표적으로 SwiftUI에 View가 해당될 것이다. Swift에는 내가 만든 구조체에 대하여 identifiable 처럼 동작하게 하는 protocol를 제공해준다. 그 프로토콜의 이름은 무려 "Identifiable" 이다 ^^;; 

당장 card struct 를 identifiable 한 것처럼 동작하도록 설정하러 가보자!

구조체에 identifiable 프로토콜을 추가해주니 무언가 오류가 난다. Xcode가 제공해준 Solution을 한번 살펴보도록 하자!

갑자기 struct에 id 프로퍼티가 생겨났다! 무엇을 의미하는 것일까? id 값은 hashable 해야 한다. 덕분에 다른 값 비교할때 유일하게 구분할 수 있어야 한다. 따라서 id의 값은 int, string 등 무언가 특정하게 구분할 수 있는 타입이라면 문제없이 사용할 수 있다. 

이번의 경우 카드마다 일련번호를 매겨보는 것을 상상하면서 id 프로퍼티의 타입으로 Int를 사용할 것이다. 또한 카드를 배열에 넣을 때 pairIndex를 활용하여 각 카드에 대한 일련번호를 지정해줄 것이다.

init(numberOfPairsOfCards: Int, createCardContent: (Int) -> CardContent) {
      cards = Array<Card>()
      // Add numberOfPairsOfCards x 2 cards to cards array
      for pairIndex in 0..<numberOfPairsOfCards {
          let content: CardContent = createCardContent(pairIndex)
          cards.append(Card(content: content, id: pairIndex*2))
          cards.append(Card(content: content, id: pairIndex*2+1))
      }
}

이제 카드를 보여주는 작업은 잘 동작한다. 이전 Demoware에서 구현했던 내용중에 카드를 선택하면 카드의 방향이 뒤집히는 논리가 있었는데, 카드 방향에 대한 정보가 Model을 통해 처리되기 시작하면서 카드를 뒤집는 논리를 다시 구현해야하는 상황에 놓였다. 어떻게 해야 할까? 다음과 같은 두가지 사항을 생각해볼 수 있다.

  1. 카드를 "선택" 했다는 사용자의 이벤트를 어떻게 잡아낼 것인가?
  2. 이벤트에 담긴 사용자의 의도인 카드를 뒤집는 행위를 어떻게 처리할 것인가?

첫번째 문제에 대한 해답은 비교적 쉽게 떠올릴 수 있다. SwiftUI에서 제공해주는 이벤트 핸들러 ".onTabGesture()" 를 활용해보는 것이다. 실제로 @State 를 통해 구현했던 데모웨어에서도 사용했던 방법이기도 하다. 

 

두번째 의문에 대한 구현이 onTabGesture 내부에 code placeholder에 담겨야 할 것으로 보인다. 이전 MVVM 강의에서 사용자의 Intent 를 처리하는 내용에 대해 들었는데 다시 한번 복기해보자 

Model에 항공편 혹은 호텔 정보가 담긴 여행 어플리케이션을 생각해보자!
일반적으로 휴가를 위한 교통편이나 호텔을 예약하는 것이 자주 발생될 수 있는 사용자의 의도일 것이다. 이 상황에서 예약 버튼을 클릭했을 때 사용자의 의도가 데이터의 user id가 어쩌구 저쩌구 해서 DB에 어쩌구 저쩌구 기록해야지는 아닐 것이기에 ViewModel에서 bookit과 같은 간단한 함수를 호출함으로써 사용자의 의도를 직관적으로 처리한다.
이것이 바로 사용자의 Intent 이다.

이제 사용자의 Intent를 처리해줄 함수를 ViewModel에 정의해보도록 하자!

일종의 Tip 으로 "MARK" 키워드를 사용하면 상단에 보이는 것처럼 손쉽게 바로가기 기능을 사용할 수 있다. 이제 onTabGesture 의 내부 코드로 방금 정의한 intent 함수를 불러오고 카드를 뒤집고자 하는 사용자의 의도를 처리할 수 있도록 함수를 실제 구현해보자!

var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))]) {
                ForEach(viewModel.cards) { card in
                    CardView(card: card)
                        .aspectRatio(2/3, contentMode: .fit)
                        .onTapGesture {
                            viewModel.choose(card)
                        }
                }
            }
        }
        .foregroundColor(.red)
        .padding()
}

 View에서 사용자의 Intent 를 처리하는 코드의 예시는 위와 같다.

// MARK: - Intent(s)
func choose(_ card: MemoryGame<String>.Card) {
	model.choose(card)
}

지금은 네트워크나 데이터베이스를 통한 수정을 하지 않기 때문에 비교적 간단한 코드가 들어가지만 App이 복잡해질수록 다양한 구현이 들어갈 수 있다.

간단하게 사용자의 의도가 잘 처리되고 있는지 확인(Debugging)해보기 위해서 콘솔창에 간단한 메시지를 출력해보자!

카드를 터치할 때마다 우측 하단의 콘솔창에 메시지가 뜨고 있다. 사용자의 intent가 원활하게 처리될 수 있는 상태이다. 디버거를 활용하는 방법도 있으나 간단하게 동작 상태를 확인하고 싶을 때 print를 사용하는 것은 생각보다 나쁘지 않은 선택이 될 수 있다. 이제 실제 의도 였던 카드를 뒤집는 행위를 구현하면 끝난다!

라고 생각했는데 오류가 발생했다! 대충 내용이 card는 immutable 한 친구라고 설명하고 있다. 왜 일까? 사실 함수의 인자로 넘어온 card가 수정될 수 없는 것은 당연한 일이다. card는 구조체로 정의되어있으며 다른 어딘가로 할당하면 값복사가 발생하여 원본과 다른 인스턴스가 되어버리기 때문이다.

그러면 원본을 수정하려면 어떻게 해야할까? 원리는 간단하다. 우리가 변경하고자하는 카드는 모델에 cards 배열에 담겨 있으니 choose 함수의 인자로 넘어온 card 와 동일한 카드를 배열에서 찾아 수정하기만하면된다.

mutating func choose(_ card: Card) {
	let chosenIndex = index(of: card)
    cards[chosenIndex].isFaceUp.toggle()
    print("chosen card \(cards[chosenIndex])")
}
    
func index(of targetCard: Card) -> Int {
	var answer = 0
    for (index, card) in cards.enumerated() {
      if card.id == targetCard.id {
          answer = index
          break
      }
    }
    return answer
}

mutating 키워드가 눈에 띌 것이다. 구조체의 경우 프로퍼티가 수정된다는 의미는 수정된 값으로 새로운 인스턴스를 생성하여 self 를 새로운 값으로 설정하는 것과 같은 의미를 지닌다. 따라서 컴파일러에게 self를 수정하는 함수임을 알려주기위해 mutating 키워드를 붙이게 된다.

실제로 ViewModel의 프로퍼티로 모델을 갖고 있는데, 모델을 let 으로 선언하면 self에 새로운 값을 할당할 수 없으므로 mutating 키워드로 선언된 함수를 호출할 수 없게된다.

이러한 논리가 Swift에 담긴 이유는 Swift가 Copy On Write 이라는 메모리 최적화 기법을 사용하고 있기 때문이다. 

이제 Model의 카드에 대한 진리값들은 사용자의 의도에 따라 수정될 수 있는 상태가 되었다. 하지만 Model 데이터의 수정 내용이 View 에 반영되지를 않는다. 어떻게 수정해야 할까? 이 시점이 바로 MVVM 패턴이 힘을 발휘하는 시점이다. 3개의 키워드를 추가해서 UI를 reactive 하게 만들어보도록 하자! 즉 모델을 갱신하면 이와 관련된 모든 UI가 변동사항을 반영하도록 하는 것이다.

우선 ViewModel을 마치 ObservableObject 처럼 동작하도록 프로토콜을 추가해준다.

class EmojiMemoryGame: ObservableObject {
   ...
}

ObservableObject는 세상에 "뭔가 변경되었다" 라고 Publish 할 수 있는 개체이다.

ObservableObject
A type of object with a publisher that emits before the object has changed.

ObservableObject 프로토콜은 objectwillchange 라는 publisher 를 제공한다. 따라서 어느 프로퍼티 값이 수정되기 전에 publisher 를 통해 해당값이 바뀔 것임을 알리는 메시지를 세상에 알릴 수 있다.

매번 이렇게 명시해주는 것은 귀찮은 일이기 때문에 감시하길 원하는 값에 @Published 키워드를 붙여서 해당 프로퍼티가 변경되는지 Swift가 감시하다가 변동되는 시점에 자동으로 메시지를 세상에 발생시키도록 설정할 수 있다. 아주 훌륭한 기능이다. 

어떻게 Swift 가 어느 Model의 변경 사항을 파악할 수 있었을까? Model의 choose 함수에 mutating 키워드를 명시해두었기 때문에 Swift가 choose 함수가 실행될 때마다 Model의 변경 여부를 파악할 수 있게 되었다.

MVVM에서는 변동사항을 뿌리는 것도 있었지만 변동사항을 Subscribe 하여 View에 반영하는 부분도 있었다. @ObservedObject 키워드를 viewModel 프로퍼티 선언 앞에 적어두면 Model의 변경 사항이 감지될 때 마자 View 를 갱신하도록 선언하는 것과 같은 의미이다.

ObservedObject
A property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.

 

 

반응형