프로젝트의 기능 중에 셀 안에 체크버튼을 누르면 Lottie 체크 애니메이션이 재생이 되고, 체크표시가 되어야 하는데 의도와는 다르게 작동해서 이에 대해 정리해보고자 합니다.

 

Lottie-Animaion


https://github.com/airbnb/lottie-ios

 

GitHub - airbnb/lottie-ios: An iOS library to natively render After Effects vector animations

An iOS library to natively render After Effects vector animations - airbnb/lottie-ios

github.com

 

Lottie는 JSON형식의 애니메이션 코드를 실시간으로 렌더링하여 출력하는 라이브러리입니다.

장점으로는 벡터기반 애니메이션 형식으로 확대해도 이미지가 깨지지 않는점입니다.

또한 JSON형식으로 저장되기 때문에 파일 크기가 작아 로딩시간이 짧습니다.

이 라이브러리를 사용해서 애니메이션을 출력하고자 했습니다.

 

 

기획


  • 테이블 뷰 셀안에 LottieAnimationView와 버튼이 있습니다.
  • LottieAnimationView는 체크 애니메이션을 재생합니다.
  • 버튼을 누르면 해당 셀의 가운데위치에서 체크애니메이션을 재생합니다.
  • 애니메이션이 종료되면 체크표시가 남아있도록 합니다.
  • 여러 개의 셀이 존재할 수 있고, 각자 독립적인 셀입니다.

처음 기획했던 의도는 위와 같습니다.

쉽게 말하면 To-do와 같이 해야 할 일을 불러와 완료되면 애니메이션 출력 후 완료표시를 남겨두는 것입니다.

 

하지만 기획했던 의도와 다르게 작동하는 버그가 발생하였습니다.

 

 

버그발생


애니메이션이 재생되지않음 / 다른셀에 있는 애니메이션 뷰가 반응함

위와 같이 버그가 발생했습니다. 버그내용은 다음과 같습니다.

  • 체크버튼을 눌러도 애니메이션이 재생을 건너뛰고 완료표시만 생김
  • 체크버튼을 누르면 다른 셀이 반응함

왜 이런 버그가 발생했는지 원인을 찾아보기로 했습니다.

 

 

원인분석


전체적으로 테이블뷰가 존재하고, 커스텀 셀을 구현하여 데이터모델에 맞춰 재사용되고 있습니다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // TableViewCell을 만들어서 해당 셀을 사용 (커스텀 셀)
    guard let cell = tableView.dequeueReusableCell(
        withIdentifier: TodayTableViewCell.identifier,
        for: indexPath
    ) as? TodayTableViewCell else {
        return UITableViewCell()
    }
    // 데이터모델의 데이터를 셀의 UI Property에 주입
    let study = viewModel.todo[indexPath.row]
    cell.configure(with: study)
    cell.setUIColor()
    
    // 버튼 이벤트를 위해 델리게이트 위임
    cell.delegate = self

    return cell
}
// 체크버튼 클릭 시 이벤트
@objc func checkButtonTapped() {
	// 애니메이션 재생
    checkView.play()
    // 해당 셀의 데이터 done을 true로 변경하는 메서드
    delegate?.checkButtonTapped(name: name)
}

// TableViewCell.Swift
// Cell 데이터 바인딩 메서드
func configure(with model: FirebaseDataModel) {
    self.name = model.name
    label.text = model.name
    guard let done = model.done else { return }
    // done값이 True면 체크표시로 남긴다.
	if done {
    	checkView.currentProgress = 20
    }
}

 

테이블 뷰의 셀을 최신화하기 위해 늘 사용해 왔던 메서드 tableView.reloadData()가 있습니다.

reloadData() 메서드가 실행되면 테이블뷰는 데이터를 가장 최신값을 기준으로 다시 불러오는 과정을 실행하게 됩니다.

이때 tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 메서드 역시 재실행하는데요

만들어둔 커스텀셀의 데이터 바인딩을 위해 구현한 configure메서드도 실행됩니다.

configure메서드가 실행되므로 셀의 데이터가 변경되어 사용자가 시점에서는 변경된 데이터로 즉시 볼 수 있게 됩니다.

 

configure메서드 내부를 살펴보면 done값이 true면 체크표시가 남도록 설계했었습니다.

이 결과 체크버튼을 누르면 즉시 데이터가 변경되어 reloadData() 메서드가 실행되어 애니메이션 재생을 건너뛰고 바로 체크표시로 남게 되었습니다.

 

두 번째로 발생한 버그는 셀의 재사용 특성에서 볼 수 있었습니다.

커스텀셀을 dequeueReuseableCell로 지정하여 셀이 생성되거나 스크롤 시 재사용큐에 저장된 셀을 사용하도록 구현되었습니다.

데이터를 추가하여 새로운 셀이 추가되니 다음과 같은 현상을 볼 수 있었습니다.

currentProgress 0: 재생전 / currentProgress 1: 재생후 체크표시

2가 적힌 셀을 재생시키고 체크표시를 해두었지만 새로운 데이터를 추가하여 셀을 추가하니

이미 재생된 셀이 나오거나 재생되지 않은 셀이 나오는 현상을 발견하였습니다.

 

재사용하는 셀의 초기 UI상태를 직접 설정하는 prepareForReuse() 메서드를 사용하지 않으니 UI가 뒤죽박죽 되었습니다.

 

 

 

해결한 방안


버그 1) 체크버튼을 눌러도 애니메이션이 재생을 건너뛰고 완료표시만 생김

-> 데이터바인딩이 되어 있어서 데이터값이 변경되면 tableView.reloadData() 메서드가 즉시 실행되는 구조였습니다.

-> 이를 데이터값이 변경은 되어도 tableView.reloadData()메서드가 실행되지 않도록 수정하여 애니메이션이 정상적으로 실행되도록 수정하였습니다.

커스텀 셀과 같은 UI는 데이터바인딩이 되어있지만 데이터 변경에 따른 애니메이션 출력은 진행 과정을 엄밀히 살펴보았어야 했습니다.

체크버튼 클릭 -> 애니메이션 재생 -> 애니메이션 종료 -> 데이터 값 변경 -> 이를 UI에 적용

 

 

 

버그 2) 체크버튼을 누르면 다른 셀이 반응함

-> 커스텀셀을 구현했고, dequeueReusableCell을 사용했습니다. 이에 따라 테이블뷰가 스크롤되거나 새로운 셀이 추가되면 재사용큐에 저장된 셀을 사용하거나 이전에 만들어진 셀을 바탕으로 셀이 생성됩니다.

-> 그 결과 UI가 뒤죽박죽 된 상태로 되었고 이를 방지하기 위해 셀이 추가되거나 재사용될 때 초기 UI 상태를 지정하는 prepareForReuse()를

 

 

 

결과


정상적으로 작동하게 되었습니다.

 

 

 

당연하게 테이블 뷰를 구성할 때 커스텀 셀을 만들었고 dequeueReusableCell을 이용하여 셀을 구성하였었습니다.

이번 버그를 고치기 위해 셀을 재사용한다는 점과 데이터와 UI가 바인딩된 상태에서 애니메이션구현까지 된다면 기획했던 과정이 잘 진행이 되는지 검토해야 한다는 점을 느꼈습니다. 그리고 tableView.reloadData()는 즉시 테이블 뷰를 새로고침해서 애니메이션이 부자연스럽게 작동한다는 것을 알았습니다.

 

개인 프로젝트를 진행하며 쉽고 편리하게 데이터를 저장할 수 있어서 많이 써왔었는데요

문득 궁금해졌습니다

사용하면서 이게 어디에 저장되는 것이고, 어떤 형식으로 저장되었는지

혹시 내가 코딩하면서 키가 잘못쓰여서 사용되지 않는 데이터가 있는지 많은 생각이 들었는데요

궁금증을 해결하고자 검색하고 앱 디렉토리를 탐색하며 얻은 정보를 바탕으로 작성하였습니다

 

UserDefaults


UserDefaults는 런타임동안 Key-Value 값 쌍을 영구적으로 저장하는 기본 데이터베이스에 대한 인터페이스입니다.

공식문서에 따르면 기본값을 설정하면 동기적으로 변경되고, 저장소에서는 비동기적으로 변경된다고 합니다.

Key만 정해준다면 다양한 타입의 데이터를 저장할 수 있습니다.

영구적인 저장이기 때문에 앱을 종료해도 데이터가 남아있습니다.

 

 

 

저장장소


UserDefaults를 이용하여 저장한 데이터는 xml형식의 Property List기반인 plist 파일로 저장되어 있습니다.

저장위치는 Sandbox내부의 Data Container에 저장됩니다.

갑자기 튀어나온 Sandbox는 나중에 자세하게 글로 남기겠습니다.

쉽게 앱이 외부의 데이터와 접근 및 허용을 하지 않게 가둔 틀이라고 생각하시면 됩니다.

 

저는 두 눈으로 plist로 저장된 데이터를 직접 보고 싶어서 디렉토리를 찾아보았습니다.

 

1. 장치에서 실행한 앱의 데이터를 보기

Xcode에서 아이폰에서 직접 앱을 돌려보기 위해 저장했던 장치입니다.

중간에 INSTALLED APPS에 설치된 앱의 목록이 뜨는데요.

Download Container버튼을 통해 다운받아주시면 됩니다!

다운받은 파일을 패키지 내용 보기를 누르시면 접근하실 수 있습니다.

AppData\Library\Preferences\앱번들ID.plist

다크모드 기능 여부에 대한 key-value값이 저장되어 있네요

 

2. 시뮬레이터에서 실행한 앱의 데이터를 보기

시뮬레이터에서 실행한 앱의 데이터를 보기 위해서는 직접 디렉터리를 찾아가도 좋지만 명령어를 통해 더 빠르게 접근할 수 있습니다.

저는 시뮬레이터로 많은 앱을 돌려보니 정말 많은 앱 폴더가 생겼는데요.. 이 명령어로 쉽게 찾을 수 있었습니다.

 

※ 명령어 사용 전 시뮬레이터를 켜주세요

※ app.identifier 대신에 앱 번들 Identifier을 넣어주세요

$ xcrun simctl get_app_container booted app.identifier data

위 명령어를 쓰시면 해당 앱의 위치가 출력됩니다

$ cd(디렉터리 폴더)
$ open .

open . 을 쓰시면 파인더가 바로 열리고 Library\Preferences\앱번들ID.plist를 확인하실 수 있습니다.

 

 

데이터 저장


데이터를 저장할 땐 set메서드를 사용하면 됩니다.

Array의 append와 같이 기존의 값에서 추가하는 방식이 아닌 덮어 쓰이는 방식으로 만약 같은 키값에서 밸류의 값이 추가가 된다면 

set메서드를 사용하여 덮어쓰면 됩니다!

// 문자열
UserDefaults.standard.setValue("String", forKey: "String")
// Bool
UserDefaults.standard.setValue(true, forKey: "Bool")
// 배열
UserDefaults.standard.setValue([1, 2, 3, 4, 5], forKey: "IntegerArray")

 

구조체

// 구조체는 인코딩하여 저장하고 디코딩하여 가져오므로 Codable을 채택한다.
struct Person: Codable {
    let name: String
    let age: Int
    let job: String
}

let person = Person(name: "cheolsu", age: 20, job: "student")    
let encoder = JSONEncoder()
do {
    let encoded = try encoder.encode(person)
    UserDefaults.standard.setValue(encoded, forKey: "struct")
} catch {
    print("Error")
}

 

데이터 저장된 모습

 

데이터 불러오기


데이터를 불러오는 방법은 해당 타입에 맞게 캐스팅해 주거나, 해당타입의 메서드로 불러오면 됩니다.

// 문자열
UserDefaults.standard.string(forKey: "String")
// Bool
UserDefaults.standard.bool(forKey: "Bool")
// 배열
UserDefaults.standard.array(forKey: "IntegerArray")


// 구조체
let decoder = JSONDecoder()
guard let data = UserDefaults.standard.object(forKey: "struct") as? Data else { return }
do {
    let decoded = try decoder.decode(Person.self, from: data)
    print(decoded)
} catch {
    print("Error")
}

 

 

 

 

데이터 제거하기


// key로 저장된 데이터 제거
UserDefaults.standard.removeObject(forKey: "key")

 

 

 

 

 

저장된 데이터 확인하기


직접 디렉터리를 접근하여 찾는 방법도 있지만 모든 저장된 UserDefaults 데이터를 불러오는 메서드가 있습니다.

// UserDefaults.standard.dictionaryRepresentation()

for (key, value) in UserDefaults.standard.dictionaryRepresentation() {
    print("key: \(key), value: \(value)")
}

/* 출력
 key: struct, value: {length = 43, bytes = 0x7b226a6f 62223a22 73747564 656e7422 ... 61676522 3a32307d }
 key: String, value: String
 key: IntegerArray, value: (
     1,
     2,
     3,
     4,
     5
 )
 key: Bool, value: 1
 */

(위 메서드 사용 하면 UserDefaults에 저장된 시스템 기본설정값도 출력됩니다.)

 

 

 

열거형을 사용하여 키 분류하기


UserDefaults는 plist파일로 저장되기 때문에 보안이 강력하다고 하기는 어렵고, 데이터를 쉽게 저장하고 불러올 수 있지만 저장공간을 많이 차지하기 때문에 대용량 데이터를 저장하기에는 적합하지 않다고 생각합니다.

그래서 비교적 가벼운 데이터를 저장하는데요 (예로 자동로그인 여부, 다크모드 설정여부 등등)

그래도 많은 Key-Value타입의 데이터를 저장하면서 느꼈던 경험이 있습니다.

바로 키를 잘못입력해서 생성되는 불필요한 데이터가 생성되는 점과 호출할 때 키값이 뭐였더라.. 하는 점입니다.

코드를 짜고 여러 군데에서 호출하여 데이터를 수정하다 보면 쉽게 발생할 수 있을 거라 생각합니다.

한두 개야 뭐 앱 성능에 영향 주겠어? 싶지만 좋은 습관은 아닌 거 같아서 열거형으로 키값을 분류하는 방법을 적어놓고 사용하고자 합니다!

// 키 열거형
enum UserDefaultsKey: String {
    case string
    case bool
    case array
    case object
}

// 데이터 입력
// 문자열
UserDefaults.standard.setValue("String", forKey: UserDefaultsKey.string.rawValue)
// Bool
UserDefaults.standard.setValue(true, forKey: UserDefaultsKey.bool.rawValue)
// 배열
UserDefaults.standard.setValue([1, 2, 3, 4, 5], forKey: UserDefaultsKey.array.rawValue)

// 구조체
let person = Person(name: "cheolsu", age: 20, job: "student")
let encoder = JSONEncoder()

do {
    let encoded = try encoder.encode(person)
    UserDefaults.standard.set(encoded, forKey: UserDefaultsKey.object.rawValue)
} catch {
    print("Error")
}

// 데이터 가져오기
// 문자열
UserDefaults.standard.string(forKey: UserDefaultsKey.string.rawValue)
// Bool
UserDefaults.standard.bool(forKey: UserDefaultsKey.bool.rawValue)
// 배열
UserDefaults.standard.array(forKey: UserDefaultsKey.array.rawValue)

// 구조체
let decoder = JSONDecoder()
guard let data = UserDefaults.standard.object(forKey: UserDefaultsKey.object.rawValue) as? Data else { return }
do {
    let decoded = try decoder.decode(Person.self, from: data)
    print(decoded)
} catch {
    print("Error")
}

 

 

문제

수열 A가 주어졌을 때, 가장 긴 증가하는 부분 수열을 구하는 프로그램을 작성하시오.

예를 들어, 수열 A = {10, 20, 10, 30, 20, 50} 인 경우에 가장 긴 증가하는 부분 수열은 A = {1020, 10, 30, 20, 50} 이고, 길이는 4이다.

입력

첫째 줄에 수열 A의 크기 N (1 ≤ N ≤ 1,000,000)이 주어진다.

둘째 줄에는 수열 A를 이루고 있는 Ai가 주어진다. (-1,000,000,000 ≤ Ai ≤ 1,000,000,000)

출력

첫째 줄에 수열 A의 가장 긴 증가하는 부분 수열의 길이를 출력한다.

둘째 줄에는 정답이 될 수 있는 가장 긴 증가하는 부분 수열을 출력한다.

내가 푼 풀이

이제까지 가장 긴 증가하는 부분수열(LIS)를 구하는 방법은 두가지가 있었다.

첫번째는 DP방법으로 해당 인덱스의 원소까지의 부분수열 길이를 최신화 하는 방법이였다.

이는 시간복잡도가 $O(N^2)$가 걸리므로 주어진 문제의 입력은 최대 100만개의 원소이므로 시간초과가 일어났다.

 

두번째 방법으론 이분탐색이 있다.

이분탐색을 통해 이문제를 해결했지만 몇가지 주의해야할 점이 있었다.

이전에 이분탐색으로 구하면 최장증가 부분수열의 "길이"만 구할 수 있었다.

이분탐색을 통해 배열에서 원소가 위치하는 자리를 구하고 해당위치에 넣은결과 길이는 정답이지만, 부분수열이 옳지 않게 구하게 되었다.

 

예로 수열 [10, 20, 10, 30, 15, 50]이 주어졌을 때, 이분탐색을 이용하여 구한다면

[10, 15, 30, 50]배열이 구해지고 이는 최장증가 부분수열이 아니다.

이유는 15는 50이전의 원소로 30보다 나중에 나오는 원소이므로 옳지 않다.

 

부분수열은 구할 수 없지만 길이는 알고있다.

이 점을 이용하여 이분탐색을 통해 부분수열을 구할 때, 해당 원소의 인덱스를 저장한다.

괄호안 숫자는 해당원소가 주어진 수열의위치를 의미한다.

 

첫번째 원소 10

첫번째 원소는 비교대상이 없으므로 첫번째에 넣는다.

result = [10(1)]

idx = [1]

 

두번째 원소 20

두번쨰 원소는 이분탐색을 이용하여 10뒤에 오는 숫자임을 알 수 있다.

result = [10(1), 20(2)]

idx = [1, 2]

 

세번째 원소 10

세번째 원소는 이분탐색 결과 첫번째 오는 숫자와 같음을 알 수 있다.

result = [10(3), 20(2)]

idx = [1, 2, 1]

 

네번째 원소 30

네번째 원소는 20뒤에오는 숫자임을 알 수 있다.

result = [10(3), 20(2), 30(4)]

idx = [1, 2, 1, 3]

 

다섯번째 원소 15

다섯번째 원소는 이분탐색 결과 10과 20사이에 오는 숫자임을 알 수 있다.

이분탐색 결과 부분수열은 아래와 같다.

result = [10(3), 15(5), 30(4)]

idx = [1, 2, 1, 3, 2]

-> 이분탐색으로 만들어진 부분수열은 이미 정답과는 다른 부분수열이 만들어졌다. 해당원소의 위치를 배열안에 저장한다.

 

여섯번째 원소 50

여섯번째 원소는 이분탐색결과 마지막에 오는 숫자임을 알 수 있다.

result = [10(3), 15(5), 30(4), 50(6)]

idx = [1, 2, 1, 3, 2, 4]

 

-> 결과

result = [10(3), 15(5), 30(4), 50(6)]

idx = [1, 2, 1, 3, 2, 4]

 

이전의 이분탐색을 이용하면 result 배열을 얻고 길이는 정답이지만 부분 수열은 옳지않은 수열이 된다.

이분탐색을 통해 부분수열의 값이 변경될때 해당 원소가 위치하는 인덱스값을 저장함으로써 길이를 차감하며 부분수열을 구할 수 있게된다.

길이가 4이므로 역순으로 4 3 2 1의 원소를 뽑으면 [50, 30, 20 ,10]이고 이배열의 역순은 정답이 된다.

 

이분탐색을 이용하는 방법은 알았지만, 부분수열을 뽑아내는 과정은 쉽게 떠올리지 못했다.

 

코드로 구현하면 다음과 같다.

import Foundation

// 입력받기
let N = Int(readLine()!)!
var A = readLine()!.split(separator: " ").map{Int(String($0))!}
A.insert(0, at: 0)
let INF = 1000000001
// 이분탐색 결과배열과 인덱스 배열
var result = Array(repeating: INF, count: N+1)
var idxArr = [Int]()
result[1] = A[1]

// 이분탐색
func binarySearch(num: Int) {
    var left = 1
    var right = result.count-1
    
    while left <= right {
        let mid = (right + left) / 2
        
        if result[mid] < num {
            left = mid + 1
        } else {
            right = mid - 1
        }
    }
    // 해당위치에 넣고, 인덱스배열에 저장
    result[left] = num
    idxArr.append(left)
}

for i in 1...N {
    binarySearch(num: A[i])
}

var answer = [Int]()
var count = 0

// 부분수열의 길이 구하기
for i in 1...N {
    if result[i] != INF {
        count += 1
    } else {
        break
    }
}
// 부분수열 구하기
for i in (0..<idxArr.count).reversed() {
    if idxArr[i] == count {
        count -= 1
        answer.append(A[i+1])
    }
}
// 출력
print(answer.count)
print(answer.map{String($0)}.reversed().joined(separator: " "))

 

'코딩테스트 > 백준' 카테고리의 다른 글

BOJ-17404 RGB거리 2 Swift  (1) 2024.07.16
BOJ-2631 줄세우기 Swift  (0) 2024.06.17
BOJ-1450 냅색문제 Swift  (0) 2024.06.06
BOJ-1644 소수의 연속합 Swift  (0) 2024.06.06
BOJ-11657 타임머신 Swift  (1) 2024.06.04

이 글은 아래 영어로 된 글을 번역하였습니다.

제대로 읽지 않으면 이해하기 어려울 것 같아서 따로 번역하여 올립니다.(잘못 번역된 부분이 있을 수 있습니다.)

https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3

 

Clean Architecture and MVVM on iOS

When we develop software it is important to not only use design patterns, but also architectural patterns. There are many different…

tech.olx.com

 

우리가 소프트웨어를 개발할 때 디자인패턴만 사용하는 것뿐만 아니라 소프트웨어 아키텍처 패턴을 사용하는 것도 중요합니다.

소프트웨어 개발에 있어 다양한 많은 아키텍쳐 패턴들이 있습니다.

모바일 소프트웨어 개발에는 가장 범용적으로 MVVM, Clean Architecture과 Redux 패턴을 사용합니다.

 

글에서는 iOS App MVVM패턴과 Clean Architecture 적용시킨 프로젝트 예시를 보여줍니다.

Clean Architecture 표를 보면, 애플리케이션에 서로 다른 계층이 존재하는 걸 볼 수 있습니다.

가장 중요한 규칙은 내부계층이 외부계층의 의존성을 가지면 안된다는 것입니다.

외부에서 내부로 가리키는 화살표는 의존성 규칙입니다.

오직 외부계층이 내부계층의 의존성을 갖는다는것입니다.

 

계층들을 그룹화하면 세가지로 나타낼  있습니다. : Presentation, Domain, Data

Domain Layer(Business logic)은 가장 안쪽의 계층입니다.(다른 계층과 의존성을 갖지 않는, 완벽히 분리됨)

이 계층은 Entities, Use Cases, Repository Interfaces를 포함합니다.

이 계층은 잠재적으로 다른 프로젝트 안에서 재사용될 수 있습니다.

이러한 분리를 통해서 의존성이 필요하지 않으므로, 테스트 대상 내에서 호스트 앱을 사용하지 않을 수 있습니다.

이로 인해 Domain Use Cases 테스트가 단 몇 초 만에 완료됩니다.

Domain 계층은 다른 계층의 어떤 것이든 포함시키면 안 됩니다.(ex Presentation - UIKit, SwiftUI or Data Layer)

좋은 아키텍처가 Use Cases에 집중되는 이유는 아키텍트가 환경, 도구 그리고 프레임워크에 전념하지 않고 해당 Use Cases를 지원하는 구조를 안전하게 설명할 수 있기 때문입니다.

이것을 Screaming Architecture이라고 합니다.

 

Presentation Layer은 UI를 포함합니다(UIViewControllers, or SwiftUI Views)

Views는 하나 혹은 여러 개의 Use Cases를 실행하는 ViewModel에 의해 조정됩니다.

Presentation Layer 오직 Domain Layer에만 의존합니다.

 

Data Layer은 저장소 구현과 하나 이상의 Data Source를 포함합니다.

저장소들은 다른 Data Source로부터 데이터 조정하는 역할을 갖고 있습니다.

Data Source는 원격 혹은 로컬일 수 있습니다.

Data Layer은 오직 Domain Layer에만 의존합니다.

계층에서, 네트워크 JSON 데이터 매핑을 Domain Model 추가할 있습니다.

 

아래의 구성요소들은 계층사이의 의존성과 데이터흐름을 나타냅니다.

또한 리포지토리 인터페이스를 사용하는 종속성 반전 지점을 볼 수 있습니다.

계층의 설명은 기사 초반에 말했던 예제프로젝트를 기반으로 합니다.

Data Flow(데이터 흐름)

  1. View는 ViewModel로부터 메서드를 호출합니다.
  2. ViewModel은 Use case를 실행합니다.
  3. Use Case는 사용자와 리포지토리의 데이터를 결합합니다.
  4. 각 리포지토리는 원격데이터(네트워크), 영구적 DB저장소, 메모리에 있는 데이터를 반환합니다.
  5. 정보들은 항목의 아이템을 표시하는 View로 다시 이동합니다.

Dependency Direction(의존성 방향)

  • Presentation -> Domain Layer <- Data Repositories Layer
  • Presentation Layer (MVVM) = ViewModels(Presenters) + Views(UI) (뷰와 뷰모델)
  • Domain Layer = Entities + Use Cases + Repositories Interfaces (엔티티, 유스케이스, 리포지토리 인터페이스)
  • Data Repositories Layer = Repositories Implementations + API(Network) + Persistence DB (리포지토리 구현, API, 영구적 DB)

 

Example Project: "Movies App"

예제 프로젝트 주소: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM

 

GitHub - kudoleh/iOS-Clean-Architecture-MVVM: Template iOS app using Clean Architecture and MVVM. Includes DIContainer, FlowCoor

Template iOS app using Clean Architecture and MVVM. Includes DIContainer, FlowCoordinator, DTO, Response Caching and one of the views in SwiftUI - GitHub - kudoleh/iOS-Clean-Architecture-MVVM: Tem...

github.com

 

Domain Layer

예제프로젝트 안에서 Domain Layer을 찾아볼 수 있습니다.

여기에는 영화들을 검색하고, 최근 성공한 쿼리들을 저장하는 SearchMoviesUseCase와 Entities를 포함합니다.

또한 종속성 반전에 필요한 Data Repositories Interfaces를 포함하고 있습니다.

protocol SearchMoviesUseCase {
    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

final class DefaultSearchMoviesUseCase: SearchMoviesUseCase {

    private let moviesRepository: MoviesRepository
    private let moviesQueriesRepository: MoviesQueriesRepository
    
    init(moviesRepository: MoviesRepository, moviesQueriesRepository: MoviesQueriesRepository) {
        self.moviesRepository = moviesRepository
        self.moviesQueriesRepository = moviesQueriesRepository
    }
    
    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        return moviesRepository.fetchMoviesList(query: requestValue.query, page: requestValue.page) { result in
            
            if case .success = result {
                self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
            }

            completion(result)
        }
    }
}

// Repository Interfaces
protocol MoviesRepository {
    func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

protocol MoviesQueriesRepository {
    func fetchRecentsQueries(maxCount: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void)
    func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void)
}

Use Cases를 만드는 다른 방법은 start() 함수와 함께 UseCase 프로토콜을 사용하는 것이고, 모든 UseCase는 해당 프로토콜을 준수하는 것입니다.

예제프로젝트의 Ues Case 중 하나는 이 접근 방식을 따릅니다 : FetchRecentMovieQueriesUseCase

Use Cases는 Interactors라고도 불립니다.

Use Case 다른 Use Case들과 의존될 있습니다.

 

 

Presentation Layer

Presentation Layer은 MoviesListView로부터 관찰되는 항목이 포함된 MovieListViewModel을 포함합니다.

MoviesListViewModel은 UIKit을 임폴트 하지 않습니다.

뷰모델이 UIKit, SwiftUI와 같은 모든 UI 프레임워크를 채택하지 않으면서 쉽게 재사용하고 리팩터링 할 수 있습니다.

예로, 앞으로는 뷰모델은 변하지 않으므로 UIKit에서 SwiftUI로의 Views 리팩토링은 더욱더 쉬워질 것입니다.

// Note: We cannot have any UI frameworks(like UIKit or SwiftUI) imports here. 

protocol MoviesListViewModelInput {
    func didSearch(query: String)
    func didSelect(at indexPath: IndexPath)
}

protocol MoviesListViewModelOutput {
    var items: Observable<[MoviesListItemViewModel]> { get }
    var error: Observable<String> { get }
}

protocol MoviesListViewModel: MoviesListViewModelInput, MoviesListViewModelOutput { }

struct MoviesListViewModelActions {
    // Note: if you would need to edit movie inside Details screen and update this 
    // MoviesList screen with Updated movie then you would need this closure:
    //  showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void
    let showMovieDetails: (Movie) -> Void
}

final class DefaultMoviesListViewModel: MoviesListViewModel {
    
    private let searchMoviesUseCase: SearchMoviesUseCase
    private let actions: MoviesListViewModelActions?
    
    private var movies: [Movie] = []
    
    // MARK: - OUTPUT
    let items: Observable<[MoviesListItemViewModel]> = Observable([])
    let error: Observable<String> = Observable("")
    
    init(searchMoviesUseCase: SearchMoviesUseCase,
         actions: MoviesListViewModelActions) {
        self.searchMoviesUseCase = searchMoviesUseCase
        self.actions = actions
    }
    
    private func load(movieQuery: MovieQuery) {
        
        searchMoviesUseCase.execute(movieQuery: movieQuery) { result in
            switch result {
            case .success(let moviesPage):
                // Note: We must map here from Domain Entities into Item View Models. Separation of Domain and View
                self.items.value += moviesPage.movies.map(MoviesListItemViewModel.init)
                self.movies += moviesPage.movies
            case .failure:
                self.error.value = NSLocalizedString("Failed loading movies", comment: "")
            }
        }
    }
}

// MARK: - INPUT. View event methods
extension MoviesListViewModel {
    
    func didSearch(query: String) {
        load(movieQuery: MovieQuery(query: query))
    }
    
    func didSelect(at indexPath: IndexPath) {
        actions?.showMovieDetails(movies[indexPath.row])
    }
}

// Note: This item view model is to display data and does not contain any domain model to prevent views accessing it
struct MoviesListItemViewModel: Equatable {
    let title: String
}

extension MoviesListItemViewModel {
    init(movie: Movie) {
        self.title = movie.title ?? ""
    }
}

 

MoviesListViewModelInput과 MoviesListViewModelOutput의 인터페이스를 사용하여, 임의의 ViewModel을 쉽게 만들어 MoviesListViewContoller를 테스트할 수 있게 만듭니다.

또한 MoviesSearchFlowCoordinator에게 언제 다른 뷰를 나타낼지 전달할 MoviesListViewModelActions클로저가 있습니다.

액션 클로저가 Coordinator에 호출될 때, 영화의 자세한 내용의 뷰를 나타냅니다.

우리는 나중에 필요하다면 쉽게 많은 액션들을 추가하기 위해 구조체를 사용하여 액션을 그룹화합니다.

 

Presentation Layer은 또한 MoviesListViewModel의 데이터가 바인딩된 MoviesListViewController를 포함합니다.

 

UI는 비즈니스 로직이나 애플리케이션 로직(Business Models and UseCases)에 접근할 수 없고, 오직 뷰모델만 가능합니다.

이것이 바로 관심사의 분리입니다.

우리는 비즈니스 모델을 UI로 직접적으로 전달할 수 없습니다.

이것은 우리가 비즈니스모델을 뷰모델 내부의 뷰모델을 매핑하고 뷰에 전달하는 이유입니다.

 

우리는 또한 영화 검색을 시작하기 위해 뷰에서 뷰모델로 검색 이벤트 호출을 추가합니다.

import UIKit

final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {
    
    private var viewModel: MoviesListViewModel!
    
    final class func create(with viewModel: MoviesListViewModel) -> MoviesListViewController {
        let vc = MoviesListViewController.instantiateViewController()
        vc.viewModel = viewModel
        return vc
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        bind(to: viewModel)
    }
    
    private func bind(to viewModel: MoviesListViewModel) {
        viewModel.items.observe(on: self) { [weak self] items in
            self?.moviesTableViewController?.items = items
        }
        viewModel.error.observe(on: self) { [weak self] error in
            self?.showError(error)
        }
    }
    
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchText = searchBar.text, !searchText.isEmpty else { return }
        viewModel.didSearch(query: searchText)
    }
}

항목들을 관찰하고, 항목이 바뀌면 뷰를 다시 불러옵니다.

여기엔 아래 MVVM섹션에서 설명할 간단한 Observable 사용합니다.

 

또한 MoviesSearchFlowCoordinator내부의 MoviesListViewModel 작업에 showMovieDetails함수를 할당하여 영화의 세부장면을 표시합니다.

 

protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController() -> UIViewController
    func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
}

final class MoviesSearchFlowCoordinator {
    
    private weak var navigationController: UINavigationController?
    private let dependencies: MoviesSearchFlowCoordinatorDependencies

    init(navigationController: UINavigationController,
         dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.navigationController = navigationController
        self.dependencies = dependencies
    }
    
    func start() {
        // Note: here we keep strong reference with actions closures, this way this flow do not need to be strong referenced
        let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails)
        let vc = dependencies.makeMoviesListViewController(actions: actions)
        
        navigationController?.pushViewController(vc, animated: false)
    }
    
    private func showMovieDetails(movie: Movie) {
        let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
        navigationController?.pushViewController(vc, animated: true)
    }
}

ViewController의 크기와 책임을 줄이기 위해 프레젠테이션 로직에 Flow Coordinator을 사용합니다.

필요한 동안 Flow가 할당 해제 되지 않기 위해 Flow(액션 클로저, 자체기능)에 대한 강한 참조를 하고 있습니다.

 

이러한 접근방식은 다른 뷰들을 수정 없이 같은 뷰모델을 사용하기 쉽게 해 줍니다.

iOS 13.0 이상 버전을 확인하고 UIKit 대신에 SwiftUI를 만들고 같은 뷰모델로 데이터 바인딩을 합니다. 그렇지 않다면 UIKit을 생성합니다.

예제프로젝트에서 MoviesQueriesSuggestionList에 대한 SwiftUI예제를 추가해 두었습니다.

적어도 Xcode 11 베타버전이 필요합니다.

// MARK: - Movies Queries Suggestions List
func makeMoviesQueriesSuggestionsListViewController(didSelect: @escaping MoviesQueryListViewModelDidSelectAction) -> UIViewController {
   if #available(iOS 13.0, *) { // SwiftUI
       let view = MoviesQueryListView(viewModelWrapper: makeMoviesQueryListViewModelWrapper(didSelect: didSelect))
       return UIHostingController(rootView: view)
   } else { // UIKit
       return MoviesQueriesTableViewController.create(with: makeMoviesQueryListViewModel(didSelect: didSelect))
   }
}

 

 

Data Layer

Data Layer은 DefaultMoviesRepository를 포함합니다.

이것은 Domain Layer(의존성이 분리됨) 내부에 정의된 인터페이스를 준수합니다.

또한 JSON 데이터와 CoreData Entities Domain Model 매핑하는 기능을 추가합니다.

final class DefaultMoviesRepository {
    
    private let dataTransferService: DataTransfer
    
    init(dataTransferService: DataTransfer) {
        self.dataTransferService = dataTransferService
    }
}

extension DefaultMoviesRepository: MoviesRepository {
    
    public func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        
        let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
                                                                     page: page))
        return dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
            switch response {
            case .success(let moviesResponseDTO):
                completion(.success(moviesResponseDTO.toDomain()))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

// MARK: - Data Transfer Object (DTO)
// It is used as intermediate object to encode/decode JSON response into domain, inside DataTransferService
struct MoviesRequestDTO: Encodable {
    let query: String
    let page: Int
}

struct MoviesResponseDTO: Decodable {
    private enum CodingKeys: String, CodingKey {
        case page
        case totalPages = "total_pages"
        case movies = "results"
    }
    let page: Int
    let totalPages: Int
    let movies: [MovieDTO]
}
...
// MARK: - Mappings to Domain

extension MoviesResponseDTO {
    func toDomain() -> MoviesPage {
        return .init(page: page,
                     totalPages: totalPages,
                     movies: movies.map { $0.toDomain() })
    }
}

데이터 전송객체 DTO는 JSON응답을 Domain으로 매핑하는 중간 객체로 사용됩니다.

또한 응답의 마지막을 캐시한다면 영구 데이터전송객체를 영구객체에 매핑하여 영구 저장소에 저장됩니다.

(마지막 응답값을 데이터전송객체를 매핑하여 저장소에 저장한다는 얘기인듯합니다..)

(인터넷 접속과 관계없이 최근에 불러온 데이터를 보여주기 위해서 저장해 두는 것?)

 

일반적인 데이터저장소는 API 데이터서비스와 영구저장소와 함께 주입될 수 있습니다.

데이터 저장소는 두 개의 의존성을 갖고 데이터를 반환합니다.

규칙은 먼저 캐시 된 데이터를 출력하기위해 영구저장소를 호출하는것입니다.(NSManagerObject는 DTO 객체를 통해 매핑되고, 캐시된 데이터클로저에서 검색됨)

그다음 최신 업데이트된 데이터를 리턴할 API Data Service를 호출합니다.

그 다음 영구 저장소는 최신으로 업데이트됩니다.(DTO가 영구객체에 매핑되고 저장됩니다.)

그 다음 DTO는 Domain으로 매핑되고 업데이트된 데이터/ 완료 클로저에서 검색됩니다.

이 방식은 유저들이 즉각적으로 데이터를 볼 수 있습니다.

네트워크 연결이 없음에도 사용자들은 영구저장소로부터 가장 최신의 데이터를 볼 수 있습니다.

 

저장소와 API는 완전히 다른 구현으로 대체될 수 있습니다(예를 들어 CoreData에서 Realm으로)

앱의 모든 나머지 계층들은 이러한 변화에 영항을 받지 않지만 DB 세부사항이기 떄문입니다.

 

Infrastructure Layer(Network)

이것은 네트워크 프레임워크를 둘러싼 래퍼입니다. Alamofire일 수 있습니다.(아니면 다른 네트워크 프레임워크)

이것은 네트워크 파라미터들로 구성할 수 있습니다.(예를 들어 기본 URL)

또한 엔드포인트 정의를 지원하고 데이터 매핑 메서드를 포함합니다(Decodable 사용한)

struct APIEndpoints {
    
    static func getMovies(with moviesRequestDTO: MoviesRequestDTO) -> Endpoint<MoviesResponseDTO> {

        return Endpoint(path: "search/movie/",
                        method: .get,
                        queryParametersEncodable: moviesRequestDTO)
    }
}


let config = ApiDataNetworkConfig(baseURL: URL(string: appConfigurations.apiBaseURL)!,
                                  queryParameters: ["api_key": appConfigurations.apiKey])
let apiDataNetwork = DefaultNetworkService(session: URLSession.shared,
                                           config: config)

let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
                                                             page: page))
dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
    let moviesPage = try? response.get()
}

 

 

MVVM

Model-View-ViewModel 패턴은 UI와 Domain사이의 관심사를 완전한 분리됨을 제공합니다.

클린 아키텍처와 함께 사용하면 Presentation과 UI 계층의 관심사 분리에 도움을 줍니다.

 

다른 뷰 구현은 같은 뷰모델이 사용될 수 있습니다.

예를 들어, CarsAroundListView와 CarsAroundMapView는 둘 다 CarsAroundViewModel을 사용할 수 있습니다.

또한 하나의 UIKit 뷰와 다른 하나의 SwiftUI도 같은 뷰모델로 지정할 수 있습니다.

뷰모델 내부에 UIKit, WatchKit, SwiftUI를 임폴트하지 않는 것을 기억하는 것이 중요합니다.

이러한 방식은 필요하다면 다른 플랫폼에서 쉽게 재사용될 있다는점입니다.

View와 ViewModel 간의 데이터바인딩은 예시로 클로저, 델리게이트 또는 관찰자(RxSwift 같은)로 이루어집니다.

Combine과 SwiftUI 또한 사용될 수 있지만 최소한의 iOS 13 버전이상을 사용해야 합니다.

View는 ViewModel과 직접적인 관계가 있으며, View내부에서 이벤트가 발생할 때마다 ViewModel에게 전달합니다

ViewModel에서는 View에 대한 직접참조는 없습니다.(데이터바인딩만 참조)

이 예시에서, 우리는 타사 의존성을 피하기 위해 클로저의 와 didSet의 간단한 조합을 사용합니다.

public final class Observable<Value> {
    
    private var closure: ((Value) -> ())?

    public var value: Value {
        didSet { closure?(value) }
    }

    public init(_ value: Value) {
        self.value = value
    }

    public func observe(_ closure: @escaping (Value) -> Void) {
        self.closure = closure
        closure(value)
    }
}

이것은 여러 관찰자와 관찰자 제거가 포함된 전체 구현을 볼 수 있는 매우 간단한 버전의 Observable입니다.

 

ViewController의 데이터바인딩 예시:

final class ExampleViewController: UIViewController {
    
    private var viewModel: MoviesListViewModel!
    
    private func bind(to viewModel: ViewModel) {
        self.viewModel = viewModel
        viewModel.items.observe(on: self) { [weak self] items in
            self?.tableViewController?.items = items
            // Important: You cannot use viewModel inside this closure, it will cause retain cycle memory leak (viewModel.items.value not allowed)
            // self?.tableViewController?.items = viewModel.items.value // This would be retain cycle. You can access viewModel only with self?.viewModel
        }
        // Or in one line
        viewModel.items.observe(on: self) { [weak self] in self?.tableViewController?.items = $0 }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind(to: viewModel)
        viewModel.viewDidLoad()
    }
}


protocol ViewModelInput {
    func viewDidLoad()
}

protocol ViewModelOutput {
    var items: Observable<[ItemViewModel]> { get }
}

protocol ViewModel: ViewModelInput, ViewModelOutput {}

클로저 관찰에서 뷰모델 접근은 허용하지 않습니다. 이것은 메모리 릭 현상을 발생하기 때문입니다.

오직 self와 함께 뷰모델을 접근할 수 있습니다: self?.viewModel

 

final class MoviesListItemCell: UITableViewCell {

    private var viewModel: MoviesListItemViewModel! { didSet { unbind(from: oldValue) } }
  
    func fill(with viewModel: MoviesListItemViewModel) { 
        self.viewModel = viewModel
        bind(to: viewModel)
    }
    
    private func bind(to viewModel: MoviesListItemViewModel) {
        viewModel.posterImage.observe(on: self) { [weak self] in self?.imageView.image = $0.flatMap(UIImage.init) }
    }
    
    private func unbind(from item: MoviesListItemViewModel?) {
        item?.posterImage.remove(observer: self)
    }
}

뷰가 재사용가능하다면 바인딩을 해제해야 합니다.(테이블  셀)

 

MVVMs Communication (MVVM의 통신)

Delegation

하나의 MVVM 뷰모델은 델리게이트 패턴을 사용하여 또다른 하나의 MVVM 뷰모델과 통신합니다.

예를 들어, ItemsListViewModel과 ItemEditViewModel이 있습니다.

그다음 ItemEditViewModelDidEditItem메서드를 갖는 ItemEditViewModelDelegate 프로토콜을 만듭니다.

그리고 이것을 다음 프로토콜을 채택하도록 만듭니다. Extension ListItemsviewModel: ItemEditViewModelDelegate

// Step 1: Define delegate and add it to first ViewModel as weak property
protocol MoviesQueryListViewModelDelegate: class {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery)
}
...
final class DefaultMoviesQueryListViewModel: MoviesListViewModel {
    private weak var delegate: MoviesQueryListViewModelDelegate?
    
    func didSelect(item: MoviesQueryListViewItemModel) { 
        // Note: We have to map here from View Item Model to Domain Enity
        delegate?.moviesQueriesListDidSelect(movieQuery: MovieQuery(query: item.query))
    }
}

// Step 2:  Make second ViewModel to conform to this delegate
extension MoviesListViewModel: MoviesQueryListViewModelDelegate {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery) {
        update(movieQuery: movieQuery)
    }
}

경우 델리게이트를 Responders 지정할 있습니다: ItemEditViewModelResponder

 

Closures

또 다른 통신방법은 FlowCoordinator로부터 주입되거나 할당받은 클로저를 사용하는 것입니다.

이 예제프로젝트에서는 MoviesListViewModel이 액션 클로저 showMovieQueriesSuggestions를 사용하여 MoviesQueriesSuggestionsView를 표시하는 방법을 볼 수 있습니다.

또한 매개변수 (_ didSelect: MovieQuery) -> Void를 전달하여 해당 View에서 재호출 할 수 있습니다.

통신은 MoviesSearchFlowCoordinator내부와 연결되어 있습니다.

// MoviesQueryList.swift

// Step 1: Define action closure to communicate to another ViewModel, e.g. here we notify MovieList when query is selected
typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void

// Step 2: Call action closure when needed
class MoviesQueryListViewModel {
    init(didSelect: MoviesQueryListViewModelDidSelectAction? = nil) {
        self.didSelect = didSelect
    }
    func didSelect(item: MoviesQueryListItemViewModel) {
        didSelect?(MovieQuery(query: item.query))
    }
}

// MoviesQueryList.swift

// Step 3: When presenting MoviesQueryListView we need to pass this action closure as paramter (_ didSelect: MovieQuery) -> Void
struct MoviesListViewModelActions {
    let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void
}

class MoviesListViewModel { 
    var actions: MoviesListViewModelActions?

    func showQueriesSuggestions() {
        actions?.showMovieQueriesSuggestions { self.update(movieQuery: $0) } 
        //or simpler actions?.showMovieQueriesSuggestions(update)
    }
}

// FlowCoordinator.swift

// Step 4: Inside FlowCoordinator we connect communication of two viewModels, by injecting actions closures as self function
class MoviesSearchFlowCoordinator {
    func start() {
        let actions = MoviesListViewModelActions(showMovieQueriesSuggestions: self.showMovieQueriesSuggestions)
        let vc = dependencies.makeMoviesListViewController(actions: actions)  
        present(vc)
    }

    private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -> Void) {
        let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect)
        present(vc)
    }
}

 

 

Layer Separation into frameworks (Modules)

이제 예제 앱의 각 계층(도메인, 프레젠테이션, UI, 데이터, 인프라네트워크)들은 별도의 프레임워크와 쉽게 분리할 수 있습니다.

그다음 CocoaPod 사용해 이러한 프레임워크를 당신의 기본 앱에 포함시킬 있습니다.

 

Dependency Injection Container (의존성 주입 컨테이너?)

의존성 주입은 한 객체가 다른 객체의 종속성을 제공하는 기술입니다.

앱 내 DIContainer 모든 주입의 중심단위입니다.

 

종속성 팩토리 프로토콜 사용

많은 보기 중 하나는 종속성 생성을 DIContainer에 위임하는 종속성 프로토콜을 선언하는 것입니다.

이를 수행하기 위해 MoviesSearchFlowCoordinatorDependencies프로토콜을 정의하고 MoviesSceneDIContainer가 이 프로토콜을 준수하도록 생성합니다. 그다음 이 주입이 필요한 MoviesSearchFlowCoordinator에 주입하여 MoviesListViewController를 생성하고 표시해야 합니다.

여기에 단계가 있습니다.

// Define Dependencies protocol for class or struct that needs it
protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController() -> MoviesListViewController
}

class MoviesSearchFlowCoordinator {
    
    private let dependencies: MoviesSearchFlowCoordinatorDependencies

    init(dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.dependencies = dependencies
    }
...
}

// Make the DIContainer to conform to this protocol
extension MoviesSceneDIContainer: MoviesSearchFlowCoordinatorDependencies {}

// And inject MoviesSceneDIContainer `self` into class that needs it
final class MoviesSceneDIContainer {
    ...
    // MARK: - Flow Coordinators
    func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
        return MoviesSearchFlowCoordinator(navigationController: navigationController,
                                           dependencies: self)
    }
}

클로저 사용

또 다른 옵션은 클로저를 사용하는 것입니다.

이를 수행하기 위해 주입이 필요로 하는 클래스 내부에 클로저 정의가 필요합니다. 그리고 클로저를 주입합니다.

// Define makeMoviesListViewController closure that returns MoviesListViewController
class MoviesSearchFlowCoordinator {
   
    private var makeMoviesListViewController: () -> MoviesListViewController

    init(navigationController: UINavigationController,
         makeMoviesListViewController: @escaping () -> MoviesListViewController) {
        ...
        self.makeMoviesListViewController = makeMoviesListViewController
    }
    ...
}

// And inject MoviesSceneDIContainer's `self`.makeMoviesListViewController function into class that needs it
final class MoviesSceneDIContainer {
    ...
    // MARK: - Flow Coordinators
    func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
        return MoviesSearchFlowCoordinator(navigationController: navigationController,
                                           makeMoviesListViewController: self.makeMoviesListViewController)
    }
    
    // MARK: - Movies List
    func makeMoviesListViewController() -> MoviesListViewController {
        ...
    }
}

 

 

 

결론

모바일 개발에서 대부분 사용된 아키텍처 패턴들은 클린 아키텍처, MVVM, Redux입니다.

MVVM과 클린 아키텍처는 별도로 사용할 수 있습니다. 그러나 MVVM은 오직 Presentation Layer내부에서의 관심사 분리만 제공하지만, 클린아키텍처는 코드를 쉽게 테스트하고 재사용하고 이해하도록 모듈식 계층을 분할합니다.

유스케이스가 저장소를 호출하는 것 외에 다른 작업이 없더라도 유스케이스 생성을 건너뛰지 않는 것이 중요합니다.

이 방식은, 당신의 아키텍처가 새로운 개발자가 당신의 유스케이스들을 봤을 때 자체적으로 설명될 것입니다.

 

이것은 출발점으로 유용할지라도, 모든 면에서 적합하진 않습니다.

당신의 프로젝트에서 요구사항을 충족하는 아키텍처를 고르세요.

 

클린 아키텍처는 TDD에서 매우 잘 작동합니다(Test Driven Development)(테스트 중심 개발)

이 아키텍쳐는 프로젝트를 테스트하기 용이하게 만들어주고 계층들을 쉽게 교체할 수 있습니다.(UI나 데이터)

도메인 기반 설계는 클린 아키텍쳐와 맞습니다.


URLSession


URLSession은 네트워크 작업을 처리하기 위해 구현된 클래스입니다.

저는 프로젝트에서 에러 핸들링 과정과 함께 URLSession을 아래와 같이 사용했습니다.

// URLSession NetworkError
enum NetworkError: Error {
    case invalidUrl
    case transportError
    case serverError(code: Int)
    case missingData
    case decodingError(error: Error)
}

// URLSessionConfiguration
private let session = URLSession(configuration: .default)
// 반환 타입 정의
typealias NetworkResult = (Result<WeatherDataModel, NetworkError>) -> ()

// URLSession GET Data
func dataFetch(nx: Int, ny: Int, convenience: Bool, completion: @escaping NetworkResult) {
    
    // URL 확인
        let urls = getUrl(convenience: convenience, nx: nx, ny: ny)
        for url in urls {
            guard let url = url.url else {
            completion(.failure(.invalidUrl))
            return
        }
        var request: URLRequest = URLRequest(url: url)
        request.httpMethod = "GET"
        let dataTask = session.dataTask(with: request) { data, response, error in
            // 연결 확인
            guard error == nil else {
                completion(.failure(.transportError))
                print(error?.localizedDescription)
                return
            }
            // 서버 확인
            let successRange = 200..<300
            guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { return }
            if !successRange.contains(statusCode) {
                completion(.failure(.serverError(code: statusCode)))
                return
            }
            // 데이터 확인
            guard let loadData = data else {
                completion(.failure(.missingData))
                return
            }
            // 디코딩
            do {
                let parsingData: WeatherDataModel = try
                JSONDecoder().decode(WeatherDataModel.self, from: loadData)
                completion(.success(parsingData))
            } catch let error {
                completion(.failure(.decodingError(error: error)))
            }
        }.resume()
    }
}

 

최신기술?인 async await을 사용하지않고 escaping 클로저로 비동기 데이터 통신을 처리했습니다.

 

절차는 아래와 같습니다!

  1. URL 생성 (URL생성에 문제가 생기면 오류리턴)
  2. 생성된 URL을 바탕으로 URLComponent를 만들고, URLRequest 생성
  3. URLSession을 통해 데이터 통신 (클로저 안에서 진행되고, 리턴값은 escaping 선언에 따라 해당 함수가 종료되면 실행)
  4. 네트워크 연결, 서버연결, 데이터불러오기 확인절차를 모두 진행하고 실패하면 오류 리턴
  5. 위 사항까지 모두 정상적으로 진행되었다면 디코딩 진행(JSON 모델과 받을 데이터의 모델과 형식이 같은지 주의)
  6. 디코딩이 완료되면 해당 데이터를 리턴

 

이렇게 작성하고 데이터를 받는 부분에서 불러올땐 클로저를 통해 데이터를 받았습니다.

APIManager.shared.dataFetch(nx: location.x, ny: location.y, convenience: false) {[weak self] result in
    guard let self = self else { return }
    switch result {
    case .success(let data):
        self.convertDataFromCategory(response: data)
    case .failure(.decodingError(error: let error)):
        print("decodingError: \(error.localizedDescription)")
    case .failure(.invalidUrl):
        print("invalidURL")
    case .failure(.missingData):
        print("missingData")
    case .failure(.serverError(code: let code)):
        print("serverError \(code)")
    case .failure(.transportError):
        print("transportError")
    }
}

 

이렇게 네트워크 통신작업을 처리했는데 이것을 RxSwift로 리팩토링 하고자 합니다.

 

 

RxSwift URLSession


 

RxSwift는 반응형 프로그래밍으로 이전에 작성했던 코드와는 다른 개념이지만, RxSwift는 Swift에서 사용하던 UI나 프로토콜등 많은 프레임워크가 Reactive 형식으로 확장되서 지원하고있습니다.

URLSession도 Swift나 Objective-C로 작성되어 사용되었지만 Rx버전으로도 확장되었습니다.

extension Reactive where Base: URLSession {
    public func response(request: URLRequest) -> Observable<(response: HTTPURLResponse, data: Data)> {
        return Observable.create { observer in
            let d: Date?
            // shouldLogRequest: RxCocoa확장 라이브러리를 사용할 때, 네트워크 요청 로깅을 제어하기 위해 사용하는 함수?
            // 디버깅 목적으로 사용
            // 정확한 역할은 모르지만,, 코드 흐름을 보고 유추하자면.. 라이브러리 네트워크 연결이 됬다면 true를 리턴하는듯 하다.
            if URLSession.rx.shouldLogRequest(request) {
                d = Date()
            }
            else {
                d = nil
            }
            // URLSession Task
            let task = self.base.dataTask(with: request) { data, response, error in
                if URLSession.rx.shouldLogRequest(request) {
                    let interval = Date().timeIntervalSince(d ?? Date())
                    // HTTPMethod, URLRequest의 url문자열 출력 (올바른 url이 아니면 "<unknown url>"
                    print(convertURLRequestToCurlCommand(request))
                    #if os(Linux)
                    print(convertResponseToString(response, error.flatMap { $0 as NSError }, interval))
                    #else
                    // 네트워크 연결상태 확인 status code (200..<300)
                    print(convertResponseToString(response, error.map { $0 as NSError }, interval))
                    #endif
                }
                // 응답오류, 데이터 불러오기 오류
                guard let response = response, let data = data else {
                    observer.on(.error(error ?? RxCocoaURLError.unknown))
                    return
                }
                // HTTP응답없음 오류 리턴
                guard let httpResponse = response as? HTTPURLResponse else {
                    observer.on(.error(RxCocoaURLError.nonHTTPResponse(response: response)))
                    return
                }
                // 응답값과 데이터 리턴 후 종료
                observer.on(.next((httpResponse, data)))
                observer.on(.completed)
            }
            
            task.resume()
            // Observable Dispose
            return Disposables.create(with: task.cancel)
        }
    }
}

 

※ 코드 분석

  • URLRequest를 인자로 받아 Observable<(response, data)> 형식으로 리턴한다.
  • 라이브러리와 네트워크 통신을 디버깅목적으로 확인하는URLSession.rx.shouldLogRequest(request)을 거친다.
  • 서버 통신확인, 서버로부터 응답과 url응답 확인한다. 오류가 발생한다면 오류 이벤트를 방출한다.
  • 정상적으로 통신이 됬다면 해당 http응답값과 data를 이벤트 방출하고 종료한다.

URLResponse: 네트워크요청에 대한 응답을 나타낸다.

HTTPResponse: HTTP프로토콜을 사용한 네트워크에 대한 응답을 나타낸다. (URLResponse의 서브클래스)

 

 

사용 예시

func getData(nx: Int, ny: Int, convenience: Bool) -> Observable<WeatherDataModel> {
    // components의 리턴값은 Observable<URLComponents>
    return components(nx: nx, ny: ny, convenience: convenience)
        // URLComponents를 URLRequest로 변환
        .map{ url in
            return URLRequest(url: url.url!)
        }
        // 변환된 URLRequest를 통해 통신
        // URLSession.shared.rx.response에는 이미 에러대응이 구현되어있음.
        // 리턴값은 Observable<(response: HTTPURLResponse, data: Data)>
        .flatMap { request -> Observable<(response: HTTPURLResponse, data: Data)> in
            return URLSession.shared.rx.response(request: request)
        }
        // response, data를 인자로 받고 정한 데이터모델로 디코딩 (실패시 임의의 값 넣기)
        .map{ _, data -> WeatherDataModel in
            let decoder = JSONDecoder()
            let decoded = try? decoder.decode(WeatherDataModel.self, from: data)
            return decoded ?? WeatherDataModel(response: DataResponse(header: ResponseHeader(resultCode: "04"), body: nil))
        }
}

 

RxURLSession은 에러대응이 구현되어 있지만, 디코딩 에러에 대한 대응은 따로 구현해줘야 합니다.

또한 리턴값이 Observable이지만 네트워크 통신같은 경우는 성공과 실패의 결과만 있어도 되므로 Traits중 하나인 Single을 리턴하도록 합니다.

 

Single


Observable보다 더 좁은 범위로 .success 와 .error만 방출한다.

데이터 로딩과 같은 1회성 프로세스에 적합하다.

Observable에서 asSingle로 변환할 수 있다.

 

typealias NetworkResult = Result<WeatherDataModel, NetworkError>

func example(nx: Int, ny: Int, page: Int) -> Single<NetworkResult> {
        return Single<NetworkResult>.create {[weak self] single in
            // URL 확인
            guard let components = self?.component(nx: nx, ny: ny, page: page) else {
                single(.failure(NetworkError.invalidUrl))
                return Disposables.create()
            }
            
            let request = URLRequest(url: components.url!)
            
            URLSession.shared.dataTask(with: request) { data, response, error in
                // 에러 확인
                if let error = error {
                    single(.failure(NetworkError.transportError))
                    return
                }
                
                let successRange = 200..<300
                guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { return }
                // 접속 코드 확인
                if !successRange.contains(successRange) {
                    single(.failure(NetworkError.serverError(code: statusCode)))
                    return
                }
                // 데이터 확인
                guard let loaded = data else {
                    single(.failure(NetworkError.missingData))
                    return
                }
                // 디코딩 확인
                do {
                    let decoded = try JSONDecoder().decode(WeatherDataModel.self, from: loaded)
                    single(.success(NetworkResult.success(decoded)))
                } catch {
                    single(.failure(NetworkError.decodingError))
                }
            }.resume()
            
            
            return Disposables.create()
        }
    }

 

문제

RGB거리에는 집이 N개 있다. 거리는 선분으로 나타낼 수 있고, 1번 집부터 N번 집이 순서대로 있다.

집은 빨강, 초록, 파랑 중 하나의 색으로 칠해야 한다. 각각의 집을 빨강, 초록, 파랑으로 칠하는 비용이 주어졌을 때, 아래 규칙을 만족하면서 모든 집을 칠하는 비용의 최솟값을 구해보자.

  • 1번 집의 색은 2번, N번 집의 색과 같지 않아야 한다.
  • N번 집의 색은 N-1번, 1번 집의 색과 같지 않아야 한다.
  • i(2 ≤ i ≤ N-1)번 집의 색은 i-1, i+1번 집의 색과 같지 않아야 한다.

입력

첫째 줄에 집의 수 N(2 ≤ N ≤ 1,000)이 주어진다. 둘째 줄부터 N개의 줄에는 각 집을 빨강, 초록, 파랑으로 칠하는 비용이 1번 집부터 한 줄에 하나씩 주어진다. 집을 칠하는 비용은 1,000보다 작거나 같은 자연수이다.

출력

첫째 줄에 모든 집을 칠하는 비용의 최솟값을 출력한다.

 

접근방법: DP

해당문제를 풀기전 규칙을 먼저 이해해야한다. 규칙은 단순하다.

직선 위 N개의 집이 주어졌을 때, 서로 인접한 집과의 색이 같지 않고, 첫번째와 마지막의 집의 색이 같으면 안된다.

직선을 원으로 만들어 첫번째와 마지막 집을 인접시키면 모든 집은 인접한 집과 다른 색을 갖는다는 점과 같지만

이 문제에선 이 방식은 사용하지않지만 규칙을 이해하는데 쉬웠다.

 

첫번째 집부터 마지막집까지 색칠하는 과정을 거치는데 첫번째와 마지막집의 색이 같으면 안되므로 첫번째집의 색을 알고 있어야한다.

이를 DP방식을 이용하여 풀려면 이전까지 색칠한 비용을 알고 이용하여 그다음 집까지 색칠비용을 구한다.

 

인접한 집과 색이 같으면 안되므로 

첫번째 집이 빨강이면 두번째 집은 파랑과 초록이 가능하다

두번째 집이 파랑이라면 세번째 집은 빨강과 초록이 가능하다.

세번째 집이 초록이라면 네번째 집은 빨강과 파랑이 가능하다.

 

주어진 비용을 2차원 배열로 저장하면 빨강: 0, 초록: 1, 파랑: 2 인덱스를 갖는 배열이 된다.

위 방식을 사용하면 dp배열을 아래와 같이 정의할 수 있다.

dp[n][0]: n번째 집을 빨강으로 색칠한 최소비용

dp[n][1]: n번째 집을 초록으로 색칠한 최소비용

dp[n][2]: n번째 집을 파랑으로 색칠한 최소비용

dp[n][0] = arr[n][0] + min(dp[n-1][1], dp[n-1][2]) (arr: 색칠비용이 담긴 2차원배열)

 

이렇게 마지막 집까지 모두 색칠했을 때, 마지막 집과 첫번째 집의 색이 다른 경우의 비용을 갱신하여 값을 구한다.

 

코드로 구현하면 다음과 같다.

import Foundation

// 입력받기
let N = Int(readLine()!)!
var arr = [[0]]
let INF = 100000000
var ans = INF
for _ in 1...N {
    arr.append(readLine()!.split(separator: " ").map{Int(String($0))!})
}

// 첫번째 집이 어떤색을 칠하는지? 가 기준이 된다.
// 집이 나열된 선분을 순환구조로 나타낸다면, 인접한 집과의 색은 같으면 안된다.
// 첫번째 집을 세가지 색 모두 칠해보고 색칠비용의 최솟값을 구한다.
for i in 0...2 {
    // dp[i][j]: i번째집을 j색으로 칠했을 때의 최소비용
    var dp = Array(repeating: Array(repeating: 0, count: 3), count: N+1)
    // 첫번째 집과 마지막집의 색이 같으면 안되므로, 우리는 첫번째 집의 색을 기억하고 있어야한다.
    // 따라서 첫번째 집의 색 이외의 경우의수는 임의의 최댓값을 넣는다.
    for j in 0..<3 {
        if i == j {
            dp[1][j] = arr[1][j]
        } else {
            dp[1][j] = INF
        }
    }
    
    // 규칙에따라 현재집은 이전의 집과 다른색을 칠해야한다.
    // n번째 집까지 색칠한 최소비용: n-1번째까지 칠한 최소비용 + n번째 집의 색칠비용
    for j in 2...N {
        dp[j][0] = arr[j][0] + min(dp[j-1][1], dp[j-1][2])
        dp[j][1] = arr[j][1] + min(dp[j-1][0], dp[j-1][2])
        dp[j][2] = arr[j][2] + min(dp[j-1][0], dp[j-1][1])
    }
    // 첫번째 집과 마지막번째 집의 색이 같지 않다면, 최소비용을 갱신한다.
    for k in 0..<3 {
        if i != k {
            ans = min(ans, dp[N][k])
        }
    }
}
print(ans)

 

'코딩테스트 > 백준' 카테고리의 다른 글

BOJ-14003 가장 긴 증가하는 부분 수열 5 Swift  (0) 2024.08.16
BOJ-2631 줄세우기 Swift  (0) 2024.06.17
BOJ-1450 냅색문제 Swift  (0) 2024.06.06
BOJ-1644 소수의 연속합 Swift  (0) 2024.06.06
BOJ-11657 타임머신 Swift  (1) 2024.06.04

공공데이터 포털을 통해 API신청하여 데이터를 불러왔었습니다.

API신청시 통신할 수 있는 개인 인증키가 주어지는데, 문득 의문점이 들었습니다.

현재 이 데이터를 받아서 사용하는 프로젝트가 깃허브에 올라와있고 public인데, 누가 악용할 수 있지 않을까?

검색해보니 몇가지 악용사례가 있었다고 합니다.

(AWS의 인증키가 악용되어 많은 요금이 청구되는 사건..)

공공데이터포털이라 악용사례가 적을 순 있어도, 개인 인증키는 어떠한 경우에도 공유하면 안되기 때문에 이를 가려보고자 합니다.

정말 좋은 방법을 적어주신 분의 글을 참고하여 작성합니다.

https://leeari95.tistory.com/76

 

[iOS] Github에서 API KEY를 숨기기 위한 여러가지 방법들

다들 API KEY 관리를 어떻게 하시나요? 개발자마다 각각 방식이 다 다른 것 같아요. 이 글에서는 제가 협업하면서, 개인 프로젝트를 진행하면서 알게 된 관리 방법들을 적어보았습니다. 혹시 또 다

leeari95.tistory.com

 

xcconfig과 gitignore을 활용

사실 프로젝트를 private로 돌리면 끝이지만, 포트폴리오용으로도 사용가능성이 있기에, public으로 설정해야 한다면 깃허브에는 올라가지 않도록 gitignore을 활용해보려 합니다.

 

먼저 인증키를 저장할 xcconfig을 생성합니다.

 

 

생성한 xcconfig에 인증키를 작성합니다.

 

 

해당 프로젝트의 config을 설정해줍니다.

 

 

config에 저장된 개인키를 plist의 Information Property List에 추가합니다.

다른 plist 파일을 만들어서 넣어도 상관없지만, 저는 Info.plist에 넣었습니다.

인증키는 API_KEY = 1234 로 저장되있으므로 Value는 $(API_KEY)로 저장합니다.

(Key값은 상관없습니다.)

 

xcconfig 파일은 gitignore에 넣어서 제외시키도록 합니다.

// .gitignore
*.xcconfig

제 프로젝트에는 xcconfig파일 한개만 존재하고, 이는 인증키 숨기기위해 사용했으므로 xcconfig확장자를 갖는 모든 파일을 제외시켰습니다.

만약 여러개를 사용한다면 파일명을 정확히 넣어서 제외시켜주세요.

 

키값을 불러오기

// plist에 저장된 Information Property List의 값 중 인증키가 저장된 키의 값 불러오기
guard let url = Bundle.main.url(forResource: "Info", withExtension: "plist") else { return }
guard let dictionary = NSDictionary(contentsOf: url) else { return }
print(dictionary["ApiKey"] as! String)

// 1234

plist의 값은 키,값이 존재하는 Dictionary형태로 해당 키의 값을 불러옵니다.

불러온 값을 이용하여 네트워크 통신을 하면 완료!

 

 

xcconfig 파일을 제외하고 깃허브에 올리면 개인키가 저장된 xcconfig은 로컬저장소에만 존재하고

해당 파일 없이 깃허브에 업로드 됩니다.

위 작업을 브랜치 생성후 pr한다음 main에서 확인해본 결과 xcconfig이 로컬저장소에 남아있어서 개인키가 정상적으로 활용되었습니다.

다만 clone을 이용하여 해당 프로젝트를 다운로드받는다면 개인키가 저장된 xcconfig은 존재하지 않으므로 추가해줘야합니다.

프로젝트를 만들때 깃허브에 Git-Flow방식을 사용하여 올렸습니다.

깃허브에 브랜치 생성 및 커밋 pr 작업을 소스트리 프로그램을 이용하곤 하는데

매번 작업물과 관계없는 파일이 커밋내용에 올라가곤 했습니다.

심지어 아무것도 작업하지 않은상태에서 다른 브랜치로 체크아웃을 하려고 해도

작업물이 남아있어서변경이 안되는 번거로움을 겪었습니다.

 

특히 이 UserInterfaceState파일이 자꾸 생성되곤 했습니다.

 

이번 기회에 gitignore을 사용해보려고 합니다.

 

.gitignore

gitignore은 깃허브에 원격 저장소에 저장할 파일중 제외할 목록을 담고 있는 파일입니다.

따로 이름을 지정하지 않고 .gitignore 확장자로 생성합니다.

작성자는 위처럼 상시 재구성되며 값이 변경되어 커밋목록에 올라오는 파일을 제외하기 위해 사용하려고 하지만

다른 많은 목적들이 존재합니다.

 

- API Key같은 보안유지가 필요한 값을 담는 파일

- 용량이 매우 커서 원격저장소에서 받는것보다 다른 다운로드방법이 더 빠르고 간편한 경우

 

또한 gitignore의 파일형식은 .gitignore이므로 txt 파일로 만들어지지 않도록 유의해야합니다.

.gitignore의 파일 위치는 프로젝트의 최상단 위치에 존재하도록 구성합니다.(readme랑 같은 위치)

 

 

.gitignore 생성

파일을 만드는 방법은 텍스트파일이 아닌 ".gitignore"로 만들어주면 됩니다.

 

점자(.)로 시작하는 파일은 시스템파일로 인식하여 숨김처리가 되어 있을 수 있습니다.

이렇게 gitignore을 생성하면 빈파일로 구성이 됩니다.

이 파일을 프로젝트 폴더의 최상단위치에 넣어주면 됩니다.

 

.gitignore 구성

제외할 목록을 지정해보도록 합니다.

 

- 특정 폴더 모두 제외시키기

/폴더이름
ex) /Secrets

- 특정 확장자 모두 제외시키기

*.확장자
ex) *.xcuserstate

- 특정 파일 제외시키기

파일명
폴더위치/파일명
ex) UserInterfaceState.xcuserstate
ex) xcuserdata/UserInterfaceState.xcuserstate

 

또한 gitignore은 템플릿이 존재합니다.

https://www.toptal.com/developers/gitignore

 

gitignore.io

Create useful .gitignore files for your project

www.toptal.com

 

자신의 프로젝트에서 사용하는 라이브러리, 언어에 맞춘 gitignore을 생성해주는 사이트 입니다.

 

*주의

  • gitignore은 제외할 파일이 생성되기 전에 제외목록을 지정해줘야 적용됩니다.
  • 그럼에도 불구하고 이미 만들어진 파일을 제외시키려면 파일을 제거하고 다시 생성해주면 됩니다.
  • gitignore의 확장자는 txt가 아닌 .gitignore입니다.
  • gitignore의 위치는 깃허브 원격폴더의 최상단위치입니다.

 

소스트리에서 gitignore 적용하기

설정 -> 고급 -> 저장소별 무시 목록 [편집]

 

해당 파일을 우클릭하여 무시 선택하기

 

gitignore에 설정만 해주어도, 설정 이후의 새로운 파일이 만들어지면 자동으로 제외됩니다.

+ Recent posts