@State
이전에도 보았드시 SwiftUI에서 모든 View struct는 읽기전용이다. 따라서 View를 처리할 때 let이나 computed var를 사용하는 것만이 유의미하다. 다만 @ObservedObject와 같이 property wrapper가 사용되는 경우에는 var을 사용한다.
왜 View는 Read-Only 일까?
View는 매번 생성되고 버려진다. View의 body는 주변에 붙어있다. View의 body가 화면에 있다면 당연히 어딘가에 존재한다. 하지만 그 body를 만든 View는 아마도 오래전에 버려졌을 것이다. 여튼 View 구조체는 mutable한 var을 가질만큼 오래 살아있지 않는다.
하지만 걱정할 필요없다. 우리의 View는 Stateless하기 때문에 자신의 상태는 필요하지 않으며 모든 데이터를 Model로 부터 받아서 그리면 되기 때문이다. 따라서 View는 "주로" 읽기 전용으로 설정되는 것이 좋다.
하지만 때에 따라서 VIew가 State를 가져야 할 수 있다. 예를들어 우리가 확인해야 하는 View 내부에서의 임시적인 일들을 생각해볼 수 있다.
- 편집모드에 들어가서 발생되는 모든 변화를 수집한 후에 ViewModel에서 Model을 변경하기 위한 Intent 함수를 호출하는 경우
- 임시적으로 정보를 수집하기위해 다른 View를 보여주고 있다가 사용자에게 안내하는 경우
- View 내부에서 발생될 수 있는 일시적인 변화인 애니메이션의 시작과 끝점을 정의하고 싶은 경우
@State는 위와 같이 View 내부에서 처리될 수 있는 일시적인 현상을 처리하기 위해 사용할 Storage 를 정의할 때 사용된다.
특이한 점이라면 모든 @State를 사용한 변수는 접근 제어자 개념에서 private 하게 정의된다는 점이다. 자신의 View 내부에서만 사용되며 @State가 가리키는 데이터가 변경될 때마다 View의 body가 다시 계산되도록 처리한다. 변화에 반응하여 화면을 다시 그려낸다는 측면에서 보면 MVVM에서 @ObservedObject와 유사하게 동작하고 있다.
@State는 어떻게 동작하는가?
@State를 사용하여 View 내부에서 변수를 정의하면 C언어의 malloc 처럼 메모리 중 Heap 영역에 일부 공간을 할당한 후 변수를 Heap에서 할당받은 공간을 가리키는 포인터로 사용한다. 이러한 방식으로 동작하는 이유는 View는 struct로 정의되어있기 때문에 read-only 하기 때문이다.
Heap에서 할당받은 메모리 공간은 lifetime of your view 동안 존재한다. 여기서 lifetime of your view는 lifetime of its body on screen을 의미하기 때문에 View가 현재 화면에 보이는 동안 @State가 가리키고 있는 메모리 공간은 계속 유지되며 View 구조체 자체가 파괴되었다가 생성될 때 해당 메모리 지점을 다시 가리키게 된다.
강의에서는 @State에 담기는 작은 데이터 조각 또한 진리의 데이터 근원이기 때문에 잦은 사용을 자제할 것을 권장하고 있다. 앞으로 배울 강의에서 SwiftUI의 상태 혹은 데이터에 대해 생각해볼 때 데이터가 어디에 위치해있고 한곳에만 존재해야함을 이해하기위해서는 Source of Truth가 매우 중요하다는 점을 알게될 것이다. 만약 데이터를 다른 곳에서 사용하길 원한다면 복사본을 전달하거나 어떻게든 데이터의 근원에 Bind 해야만 한다.
Learn by Demo
몇가지 주제에 대해서 공부해봅시다
Access Control
@Published private var model: MemoryGame<String> = createMemoryGame()
ViewModel에 Model을 연동하기 위해 사용한 코드이다. 자세히 보면 private 접근 제어자가 사용된 것을 확인할 수 있는데 이는 MVVM에서 ViewModel이 View와 Model사이의 Gatekeeper 역할을 담당하기 때문이다. 따라서 ViewModel은 공개하고 싶은 정보에 대해 별도의 public intent 함수를 정의하여 제어할 수 있다.
private(set) var cards: Array<Card>
private var indexOfTheOneAndOnlyFaceUpCard: Int?
Model에서 cards를 private(set)으로 정의하여 다른 코드가 cards를 볼 수는 있지만 이에 대해 아무런 설정 혹은 변경을 할 수 없도록 보호하였다. "indexOfTheOneAndOnlyFaceUpCard" 변수도 마찬가지로 private로 선언되어 있는데, 이는 Model 내부에서 게임 로직을 구현할 때만 사용되는 변수이기 때문에 외부 코드에 보여질 필요가 없기 때문이다.
이렇듯 Access Control (접근 제어자)는 다른 코드가 내부 데이터 구조를 보거나 수정하지 못하도록 보호하는 용도로 사용된다. 앞으로 어느 클래스나 구조체 내부에서만 사용되는 변수를 정의할 때에는 가능한 private, private(set)을 사용하여 표시해야 한다.
지난 시간동안 구현해본 Model 코드에서 접근 제어자가 적절하게 사용되었는지 검사해보도록 하자!
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>
private var indexOfTheOneAndOnlyFaceUpCard: Int?
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }),
!cards[chosenIndex].isFaceUp,
!cards[chosenIndex].isMatched{
// 이미 선택된 카드가 하나 존재하는 경우
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
// 선택된 카드가 하나도 없다면?
// 모든 카드를 뒤집는다.
for index in cards.indices {
cards[index].isFaceUp = false
}
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
cards[chosenIndex].isFaceUp.toggle()
}
print("\(cards)")
}
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))
}
}
struct Card: Identifiable {
var isFaceUp: Bool = false
var isMatched: Bool = false
var content: CardContent
var id: Int
}
}
먼저 choose 메서드를 생각해본다. choose는 사람들이 카드를 선택할 때마다 호출되어야 하기 때문에 private를 적용할 수 없다. 생성자도 private 로 정의할 수는 있지만 일반적인 경우 대부분 private를 적용하지 않는 경향이 있다. Card 구조체에도 private를 붙일 수 있다. 그러나 상단에 cards 배열에 제한적으로 접근할 수 있도록 설정해두었다는 측면에서 Card 구조체를 public하게 만들어야 한다. Card 구조체에 대한 내부 프로퍼티 또한 사람들에게 보여져야 하기 때문에 private로 설정하면 안된다.
다만 잠시 Card 구조체에에 대하여 잠시 생각해보면 구조체의 프로퍼티 중에서 다른 사람들에게 보이지만 변할 수 없는 프로퍼티임을 알려야하는 경우가 존재하는데 이 때 해당되는 변수를 let을 통해 정의해주면 된다. 수정 후 코드는 다음과 같다.
struct Card: Identifiable {
var isFaceUp: Bool = false
var isMatched: Bool = false
// 인스턴스가 생성된 후 변할 수 없는 프로퍼티
let content: CardContent
let id: Int
}
이번에는 ViewModel 코드를 검사해볼 차례이다.
class EmojiMemoryGame: ObservableObject {
static let emojis = [
"🥇", "🥈", "🥉", "🎖", "✈️", "🚀", "🧸", "🪄", "⏰", "📱",
"⌚️", "💻", "🖥", "⚽️", "🏀", "🏈", "🥎", "⚾️", "🏓", "🤿",
"🥊", "🎗", "🚁", "🚦"
]
static func createMemoryGame() -> MemoryGame<String> {
MemoryGame<String>(numberOfPairsOfCards: 4) { pairIndex in
emojis[pairIndex]
}
}
@Published private var model: MemoryGame<String> = createMemoryGame()
var cards: Array<MemoryGame<String>.Card> {
return model.cards
}
// MARK: - Intent(s)
func choose(_ card: MemoryGame<String>.Card) {
model.choose(card)
}
}
Model의 경우 ViewModel의 Gatekeeper 역할의 사유로 이미 private 처리가 되어있으며 choose와 같이 사용자의 intent를 처리하는 메서드는 private로 설정하면 안된다. 왜냐하면 사람들이 ViewModel에 있는 intent 메서드를 통해 View 와 Model 사이에 발생할 수 있는 여러 상호작용을 처리하기 때문이다.
그렇다면 상단에 static 키워드를 사용하여 정의된 타입 프로퍼티 혹은 타입 메서드는 private로 처리할 수 있는가? 가능하다. 생각해보면 ViewModel 자신 외에 나머지 다른 코드가 MemoryGame을 생성하고 싶어할 이유가 없기 때문이다. 따라서 private 하도록 수정해주어야 한다.
private static let emojis = [
"🥇", "🥈", "🥉", "🎖", "✈️", "🚀", "🧸", "🪄", "⏰", "📱",
"⌚️", "💻", "🖥", "⚽️", "🏀", "🏈", "🥎", "⚾️", "🏓", "🤿",
"🥊", "🎗", "🚁", "🚦"
]
private static func createMemoryGame() -> MemoryGame<String> {
MemoryGame<String>(numberOfPairsOfCards: 4) { pairIndex in
emojis[pairIndex]
}
}
이번에는 View 코드에 반영해볼 수 있는 접근 제어자에 대하여 생각해보자! View는 수명이 길지 않은 데이터 구조이기 때문에 흥미로운 친구이다. View의 body는 화면에서 꽤 오랬동안 남아있지만 View 자체는 매우 일시적이다. 따라서 많은 public, private 변수를 갖는 것은 실제로 의미가 없다.
@main
struct MemorizeApp: App {
let game = EmojiMemoryGame()
var body: some Scene {
WindowGroup {
ContentView(viewModel: game)
}
}
}
다음으로 App 자체를 의미하는 @main에 대하여 확인해보도록 하자! 우리의 앱에는 하나의 앱개체만 있으며 화면을 그리기 위해 ContentView를 활용하고 있다. 우리의 앱 자체는 본질적으로 global하기 때문에 기본적으로 private로 선언하되 내 코드 외에 다른 부분에서 접근해야 하는 경우가 있을 때에만 private을 제거한다.
위에서 언급된 접근제어자 키워드 외에도 다른 여러가지 키워드가 존재한다. public은 private와 반대되는 개념이지만 실제로는 라이브러리 코드에 사용된다. open 이라는 키워드도 존재하는데 public과 유사한 개념이다. internal 키워드는 앱 내부 어디서나 사용될 수 있음을 의미한다. 이것이 기본값이기 때문에 접근 제어자를 명시하지 않으면 internal로 설정된다.
일반적으로는 위에서 언급한 private 혹은 private(set)이 많이 사용되기 때문에 잘 기억해 두어야 겠다.
때때로 어느 구조체나 클래스의 이름을 변경해야 하는 상황이 발생할 수 있다. 실제로 우리가 생성한 ContentView는 처음에 생성할 당시 기본값 이름을 여전히 갖고 있기에 대표적인 예시가 될 수 있을 것이다.
언제나 이름을 짖는 것은 참 뜻깊은 일이다.
ContentView는 EmojiMemoryGame을 그리는 역할을 수행하고 있다. 따라서 본질적으로는 EmojiMemoryGameView가 되어야 할 것이다. 다만 문제는 직접 구조체의 이름을 바꾸고자 한다면 ContentView를 참조하고있는 모든 코드를 수정해야 하기 때문에 여간 쉬운일이 아닐 것이다. 다만 Xcode IDE의 특정 기능을 배운다면 이건 일도 아니다. struct 부분에 마우스 커서를 올린 후 command 키를 누른 상태로 클릭해보자!
이 메뉴에서 해당 코드에 대한 설명이나 정의에 관하여 찾아볼 수 있는등 다양한 부가 기능을 제공하고 있다. 그중에서 Rename 기능을 활용하면 우리의 고민을 손쉽게 해결할 수 있다.
해당 버튼을 클릭하면 Swift가 프로젝트에서 ContentVIew와 관련있는 파일과 코드를 검색한다. 수정하고 싶은 코드 부분을 클릭하여 추가한 후에 struct 이름을 변경하면 나머지 코드 혹은 파일 이름도 자동으로 반영된다. 수정을 마쳤다면 우측 상단에 rename 버튼을 클릭함으로써 수정사항을 실제로 반영할 수 있다.
다음으로 수정해볼 이름은 ui 코드에 있는 viewModel 코드이다. 큰 앱을 만들 때에는 viewModel이 여러개 존재할 수 있으며 하나의 View에 여러개의 ViewModel이 사용되는 경우도 있다고 한다. 따라서 실제로 코딩할 때에는 ViewModel을 하나의 viewModel로 이름 짖는 경우는 거의 존재하지 않는다. 지금의 경우 ViewModel은 게임 그 자체를 의미하기 때문에 game 이라고 이름지어줄 것이다. 이름 변경 법은 위와 동일하다.
타입에 대한 별명을 붙여서 코드를 간결하게 표현할 수 있다. typealias 키워드를 통해 가능하며 구조체나 클래스 내부에 정의함으로써 namespace도 정리할 수 있다.
typealias Card = MemoryGame<String>.Card
이렇듯 코드를 정리할 때에는 접근제어자를 확인하는 것 외에도 변수명이나 타입명 등 여러가지를 고민해야 하며 대다수의 경우 Swift의 type inference 기능을 최대한 활용하여 코드를 정리하였다.
다음시간에는 아래와 같은 내용들을 정리해보자 26:15
Computed Properties
Functional Programming
Extensions
' Apple > Stanford iOS Programming (SwiftUI)' 카테고리의 다른 글
Lecture 5 Review Part3: Properties Layout @ViewBuilder (0) | 2021.08.23 |
---|---|
Lecture 5 Review Part2: Properties Layout @ViewBuilder (0) | 2021.08.23 |
Lecture 4 Review Part2: Memorize Game Logic (0) | 2021.08.20 |
Lecture 4 Review Part1: Memorize Game Logic (0) | 2021.08.19 |
Lecture 3 Review Part 2: MVVM and the Swift type system (0) | 2021.08.15 |