[Lecture 3] MVVM and the Swift type system


MVVM

Model, View, ViewModel로 구성된 디자인 아키텍처이다. 

지금 배우고 있는 SwiftUIMVVM 디자인 아키텍처를 따르고 있다. 

 

디자인 아키텍처?

상호작용 방식을 명확하게 하는 디자인 패러다임. 간단하게 개발 방법 및 규칙을 정해놓은 것이라고 보면 된다.

 

Model

모델에는 데이터와 로직이 담겨있다. 즉 Memorize 앱에서는 카드 자체와 점수를 계산하는 로직들이 담긴다고 보면 된다.

 

View

사용자에게 모델을 보여주는 부분이다. 

 

ViewModel

모델과 뷰를 바인딩해주는 역할을 한다. 여기서 중요한 점은 뷰모델은 뷰의 존재를 몰라야 한다. 즉 모델의 자료변경이 일어나면 해당 부분에 대해서 공표하기(Publish)만 할 뿐이다. 뷰는 뷰모델의 알림을 감지한 뒤 해당 변경이 자신이 나타내는 정보에 해당하는 사항이라면 새로운 뷰를 그려내어 변경된 부분을 사용자에게 보여준다.

 

반대로 사용자의 의도에 의해 모델의 변경이 일어난다면, 뷰는 곧바로 모델에게 알리는 것이 아니다. 뷰모델이 알아챌 수 있는 함수를 호출하여 뷰모델에게 의도를 전달한다. 뒤이어 뷰모델은 모델에게 변경을 지시하여 모델의 변경이 완료된다. 즉 뷰는 직접적으로 모델에 접근하여 변경을 일으킬 수 없다.

 

전체적인 구조와 이때 사용되는 키워드는 다음과 같다. 각 키워드가 어떻게 동작하는지는 뒤에 이어질 수업에서 나올 예정인가 보다.

 

Structure and Class

구조체와 클래스는 하나의 주제로 묶인 자료의 모음이다.

기본적으로 변수와 상수, 그리고 함수를 정의할 수 있다는 점이 둘의 공통점이지만 둘은 어떤 방식으로 이용하느냐에 따라 큰 차이점이 존재한다.

 

Value type and Reference Type

가장 큰 차이점이 바로 구조체는 값 타입, 클래스는 참조 타입이라는 점이다. 

 

구조체는 할당 혹은 인자로서 넘겨주게 되면 그 자체의 값이 복사되어 사본이 넘어간다. 따라서 해당 부분을 값 타입이라 부른다. 물론 복사가 일어날 때마다 사본이 생성되는 것은 아니고, 사본의 변경이 일어나면 그때서야 복사가 일어난다고 한다. 해당 부분은 copy on wirte이라는 정책으로 불린다.

 

클래스는 할당 혹은 인자로서 넘겨주게 되면 해당 객체를 가리키는 포인터가 생성된다. 여기서 메모리의 힙 영역 관리를 위해 swiftARC 정책에 의해 포인터의 개수를 모니터링하다가 포인터의 증감에 따라 개수가 0이 되면 메모리 해제가 일어나게 된다.

 

Initializer

구조체와 클래스 모두 자동적으로 "무료" 생성자가 주어지게 되는데, 여기서 구조체는 각 인자를 초기화할 수 있는 반면 클래스는 그렇지 못하다고 한다.

 

mutable

구조체의 경우 속성 변경이 일어나는 함수를 작성 시 mutating이라는 키워드를 적는다. copy on wirte 정책으로 인해 해당 부분의 변경이 일어나는지 확인해야 하기 때문이다. 중요한 점은 객체 또한 var로 선언해야 변경이 가능하다.

struct A{
    var value = "a"
    mutating func changeValueTo(newVlaue:String){
        self.value = newVlaue
    }
}

var a = A()

print(a.value)
a.changeValueTo(newVlaue: "A")
print(a.value)

//출력 결과
//a
//A

 

반면 클래스의 경우 속성 자체가 변수로 선언되어 있다면, 객체가 let으로 선언이 되어도 변경이 되는 것을 볼 수 있다.

class B{
    var value = "b"
    func changeValueTo(newVlaue:String){
        self.value = newVlaue
    }
}

let b = B()

print(b.value)
b.changeValueTo(newVlaue: "B")
print(b.value)

//출력 결과
//b
//B

 

Usage

swift의 공식문서를 보면 클래스보단 구조체의 사용을 지양한다고 적혀있다. 여러 가지 이유가 있겠지만, 클래스의 무분별한 사용으로 인해 포인터끼리 참조되는 순환 참조로 인한 메모리 누수를 피하기 위함이 아닐까 생각한다. 

 

Lecture 3에서는 이에 대한 이야기는 언급하지 않았고, swift에서 마주하는 대다수가 구조체이며, 클래스는 ViewModel의 구현에 사용된다고 하였다. Model에 저장된 Data의 바인딩이 주목적이므로 자료의 공유가 빈번하게 일어나기 때문에 포인터를 넘기는 것이 더 효율적이기 때문이라고 생각한다.

 

참고 영상

https://www.youtube.com/watch?v=--qKOhdgJAs&list=PLpGHT1n4-mAsxuRxVPv7kj4-dQYoC3VVu&index=6

 

 

GitHub - hyun083/CS193p-Spring-2021

Contribute to hyun083/CS193p-Spring-2021 development by creating an account on GitHub.

github.com


Lecture 1Lecture 2를 시청하고 주어진 조건에 맞게 Memorize 앱을 작성하면 된다. 열 가지 Required Tasks와  두 가지 Extra Credit이 주어진다.

 

정확한 조건은 CS193p 홈페이지에 올라와있다.


Required Tasks

주 내용은 세 개의 테마 버튼을 생성하여 테마 버튼을 누를 때마다 해당 테마의 이모지로 카드를 바꾸는 것이다.

여기서 포인트는 이모지의 순서를 랜덤 하게 배치하는 것.

 

따라서 3개의 이모지 배열을 생성. 버튼을 누를 때마다 @State 선언된 emojis 배열을 해당 배열로 교체하는 것으로 구성했다.

코드의 재사용을 위해 함수를 작성하였다.

@State var emojis = vehicles
@State var emojiCount = Int.random(in: 4..<vehicles.count)
    
func replaceCard(to arr:[String]){
    emojis = arr.shuffled()
    emojiCount = Int.random(in: 4..<arr.count)
}

함수가 수행될 때마다 emojis 배열이 변경이 되고, 변경이 될 때마다 swift는 교체된 배열을 가지고 새로운 View를 그리게 된다.

여기서 Tasks에 주어진 랜덤 한 순서의 이모지는 Array에 정의되어 있는 shuffled() 함수를 통해 만족하였다.

emojiCount에서 Int.random(in: ) 메서드를 사용했는데, 이 부분은 Extra Credit 부분에서 설명하겠다.

 

HStack{
    Button(action: { replaceCard(to: vehicles) }) {
        Theme(title: "Vehicles", symbol: "car")
    }
	
    Button(action: { replaceCard(to: flowers) }) {
        Theme(title: "Flowers", symbol: "camera.macro")
    }
	
    Button(action: { replaceCard(to: animals) }) {
        Theme(title: "Animals", symbol: "pawprint")
    }
}

struct Theme: View{
    let title: String
    let symbol: String
    
    var body: some View{
        VStack{
            Image(systemName: symbol)
                .font(.largeTitle)
            Text(title)
        }
        .font(.title3)
    }
}

다음으로 작성한 테마 View 구조체. 처음엔 3가지 버튼을 따로 작성하였다가 코드가 지저분해져서 Theme 구조체를 따로 만들었다.

title에는 화면에 보일 테마 이름, symbol은 화면에 보일 테마 이미지를 SF Symbol에 해당하는 이름을 입력받았다.

 


Extra Credit

첫 번째 조건은 테마선택 시 카드의 개수를 랜덤으로 배치하는 것이다.

이때 Int 타입에 정의된 Random(in: ) 타입 메서드를 사용하라고 명시되어 있다. 

random(in:)

Returns a random value within the specified range.

범위를 입력받는 인자를 통해서 명시된 범위 내의 랜덤한 Int를 반환하게 된다.

명시된 조건은 최소 4개의 카드를 보이는 것이기에 4.. <배열크기를 넘겨주었다.

 

두 번째 조건은 바로 화면 크기에 맞추어 카드의 크기를 결정하는 것.

중요한 점은 모든 카드가 배치되었을 때 화면 범위를 벗어나지 않게 끔 적절한 카드 크기를 찾아내는 것이다. (화면이 portrait 상태)

 

여기서 이번 Memorize 앱을 작성하는 데에 가장 긴 시간을 고민했다.

Lecture 2에서 작성된 코드는 LazyVgrid를 통해 고정된 크기의 카드를 나열하였다. 여기서 카드의 수량이 추가되면 카드목록이 화면에 한 번에 잘릴 수 있으니 스크롤 뷰를 통해 카드목록을 탐색할 수 있게 구현하였다.

이번에는 카드의 수량에 맞춰 카드의 크기를 조절해 스크롤 없이 카드목록을 볼 수 있게끔 카드의 크기를 계산하는  widthThatBestFits(cardCount: Int) -> CGFloat 함수를 작성하는 것이 두 번째 조건이었다. 

 

기기가 portrait(세로 방향) 상태일 경우에만 맞추면 된다고 명시되어 있길래 여기서 큰 힌트를 얻었다.

 

카드의 비율은 2:3, 여태 출시된 모든 아이폰의 화면 비율은 카드의 비율보다 세로비율이 더 길게 되어있다. 즉 상단의 제목과 하단의 테마버튼 영역을 제외하고서라도 카드를 정방 배열 형태(2*2, 3*3, 4*4)로 그리드 뷰를 구성한다면 카드와 동일한 2:3의 비율의 그리드뷰를 얻을 수 있고, 해당 뷰를 화면에 넘치지 않게끔 배치가 가능할 것이라 생각하였다.

 

따라서 LazyVgird의 너비를 랜덤 하게 주어지는 (카드목록의 수의 제곱근)으로 나눈 값을 LazyVgird에 들어갈 GridItem의 최소 크기로 지정한다면 SwiftUI는 정방배열의 LazyVgird를 그려내게 된다.

 

func widthThatBestFits(for width: CGFloat, cardCount: Int) -> CGFloat{
    return width/ceil(sqrt(CGFloat(cardCount)))-10
}

따라서 특정 너비와 카드의 수를 입력받아 (카드 수의 제곱근)의 반올림 값으로 특정 너비를 나누는 widthThatBestFits 함수를 구현하였다. 저기서 10을 빼준 것은 카드 간의 간격의 여유를 위해 뺀 것이다.

 

이어서 LazyVgrid의 너비값을 어떻게 가져와야 할지 검색을 해본 결과 GeometryReader라는 Container View를 찾게 되었다.

GeometryReader

A container view that defines its content as a function of its own size and coordinate space.
GeometryReader{ geometry in
	ScrollView{
		let width = geometry.size.width
	}
}

하위뷰의 좌표 정보와 크기 정보를 가져오는 Container View이며 예시와 같은 형태로 size에 정의된 widthheight 속성을 통해 하위 뷰의 너비와 높이에 접근할 수 있다.

 

GeometryReader{ geometry in
	ScrollView{
		let width = widthThatBestFits(for: geometry.size.width, cardCount: emojiCount)
		LazyVGrid(columns: [GridItem(.adaptive(minimum: width))], spacing: 7){
			ForEach(emojis[0..<emojiCount], id: \.self) { emoji in
				CardView(content:emoji)
					.aspectRatio(2/3, contentMode: .fit)
			}
		}
	}
	.foregroundColor(.red)
}

따라서 다음과 같이 GridItem의 최소 크기를 지정하였고, 예상대로 정방 형태의 그리드 뷰를 생성함으로써 portrait 상태에서는 어떠한 기기이든 스크롤 없이 주어진 카드의 전체를 확인할 수 있게 되었다.

 


이렇게 작성된 ContentView.swift 파일의 전체 코드는 다음과 같다.

 

 

+ Recent posts