포스트

Dex (8)

Offline에서도 이미지가 보이도록 만들기

현재는 api를 통해 데이터를 받아와서 처리를 하는식으로 되어있다.

특히 Image의 경우엔 AsyncImage를 사용하여 이미지가 있는 url을 가져와서 있으면 이미지를 띄우고 없으면 placeholder의 이미지가 보여지는데 현재는 전부 ProgressView로 되어있다.

즉 이상태라면 어떤 유져는 네트워크 문제때문에 잠시 통신이 어려울때, 모든 이미지가 ProgressView가 나오게 될 것이다.

이부분을 방지하기위해 Offline에서도 이미지를 보여지게하도록 하려고 한다.

간단한 예시

1
2
3
4
5
6
7
8
9
AsyncImage(url: showShiny ? pokemon.shiny : pokemon.sprite) { image in
    image
        .resizable()
        .scaledToFit()
        .padding(.top, 50)
        .shadow(color: .black, radius: 6)
} placeholder: {
    ProgressView()
}

CoreData 이미지 속성 리팩터링

Image

현재 Coredata의 Pokemon Entity의 Attributes는 이렇게 이루어져있다.

이미지를 사용하는 shiny, sprite는 당연히 URI로 되어있다.

이제는 Shiny, Sprite에 대해 좀 더 세분화를 하려고 한다.

이유는 offline, online일때 Attribute를 다르게 사용하기 위함.

Image

이렇게 shiny, shinyURL / sprite, spriteURL로 나누어 준다.

보자마자 알듯, 뒤에 URL이 붙어있는 Attributes는 Online용, 기존의 명칭을 유지하되 Tyep이 Binary Data로 바뀐 Attributes는 Offline용이다.

Image

그리고 이 둘은 Optional을 그대로 유지하자.

이제 명칭을 바꿨으니 에러가 발생할것이고 수정은 간단하다.

현재 에러는 sprite → spriteURL, shiny → shinyURL 로 명칭이 바뀌었기에 이부분만 수정해주면 끝

그리고 Refactor → Rename으로 이제 변경을 해주도록하자.

이떄 주의점

Image

사진아래 박스를 보면 CoreData의 Attribute도 같이 이름이 바뀌기에 반드시 클릭하여 해당 Attribute는 명칭을 변경하지 않도록 한다.

그리고 코드를 추가해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private func getPokemon(from id: Int) {
    Task {
        for i in id..<152 {
            do {
                let fetchedPokemon = try await fetcher.fetchPokemon(i)
                
                // 생략
                pokemon.sprite = try await URLSession.shared.data(from: fetchedPokemon.spriteURL).0
                pokemon.shiny = try await URLSession.shared.data(from: fetchedPokemon.shinyURL).0

                try viewContext.save()
                
            } catch {
                print(error)
            }
        }
    }
}

이미지를 다운받아 sprite, shiny에 저장을 하는것이다.

이렇게하고 fetch 버튼을 클릭하면 로드 속도가 상당히 느려진다.

그래서 코드를 좀 분리 하려고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
private func storeSprites() {
    Task {
        do {
            for pokemon in allPokedex {
                pokemon.sprite = try await URLSession.shared.data(from: pokemon.spriteURL!).0
                pokemon.shiny = try await URLSession.shared.data(from: pokemon.shinyURL!).0
            }
        } catch {
                print(error)
            }
        }
}

이렇게 별도의 함수로 분리를 해주었다.

그러면 드는 의문점

  • 🙋 URLSession.shared.data(from: pokemon.spriteURL!).0 여기서 뒤에 0은 뭔가요?
    • ✅ 이건 FetchService의 fetchPokemon 함수 내부를 보면 알 수 있다.
      • let (data, response) = try await URLSession.shared.data(from: fetchURL) 이렇게 Tuple 형식으로 네트워크 통신에 대한 결과를 받는데, 0은 data, 1은 response를 의미한다.
      • 여기서 우리는 이미지 데이터가 필요하기에 0을 해준것.

그리고 확인을 위해 console에 출력을 해본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private func storeSprites() {
    Task {
        do {
            for pokemon in allPokedex {
                pokemon.sprite = try await URLSession.shared.data(from: pokemon.spriteURL!).0
                pokemon.shiny = try await URLSession.shared.data(from: pokemon.shinyURL!).0
                
                try viewContext.save()
                
                print("Sprites stored: \(pokemon.id): \(pokemon.name!.capitalized)") // new
            }
        } catch {
                print(error)
            }
        }
    }

데이터를 fetch한 뒤에 저장을 해야 데이터를 저장하는 중에도 유저는 앱을 사용 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private func getPokemon(from id: Int) {
    Task {
        for i in id..<152 {
            do {
                // 생략
                
            } catch {
                print(error)
            }
        }
        
        storeSprites() // new
    }
}

이렇게 for loop가 끝난뒤에 저장을 해주는 작업을 한다.

이제 실행을 해보면

Image

이렇게 가져오는걸 알 수 있다.


Core Data 마이그레이션 오류 해결하기

코드를 전부 변경하면

Preview에서는 보이지만

1
load_eligibility_plist: Failed to open /Users/dongik/Library/Developer/CoreSimulator/Devices/67716C71-4CB6-41BB-94AA-B7F6F7E04778/data/Containers/Data/Application/26382ED0-6405-4C92-93CF-9B87E5E26C90/private/var/db/eligibilityd/eligibility.plist: No such file or directory(2)

이런식으로 Wanring이 발생하고

일단은 아무것도 보이지 않기에 fetch 버튼을 눌러본다.

바로 팅기면서 발생하는 Error

1
Thread 1: "This NSPersistentStoreCoordinator has no persistent stores (schema mismatch or migration failure).  It cannot perform a save operation."

해당 에러의 발생 이유는 우리가 그동안 코드를 작성하고 preview 뿐만아니라 실제 시뮬레이터에서도 실행을 하면서 작동 테스트를 했는데, Coredata내 Entity의 Attriubutes가 수정, 추가 되면서 현재 Xcode의 Coredata 모델과, 시뮬레이터의 앱 자체적으로 가지고있는 Coredata의 데이터가 서로 다르기 때문에 발생.

이럴때는 앱을 삭제하고 재설치를 해주면 해결이 된다.

BinaryData를 Image로 변환하기

우리는 위에서 CoreData 모델을 수정하면서 Binary Data로 타입을 지정하였다.

저장된 Binary Data는 단순히 raw data이기 때문에, 실제 화면에 보이게 하려면 SwiftUI에서 사용할 수 있는 Image 객체로 변환하는 과정이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// spriteImage, shinyImages는 각각의 Binary Data를 SwiftUI의 Image로 변환해주는 computed property이다.
var spriteImage: Image {
    if let data = sprite, let image = UIImage(data: data) {
        Image(uiImage: image)
    } else {
        Image(.bulbasaur) // 이미지가 없을 경우 표시할 기본 이미지
    }
}

var shinyImage: Image {
    if let data = shiny, let image = UIImage(data: data) {
        Image(uiImage: image)
    } else {
        Image(.shinybulbasaur)
    }
}

이떄 포인트는 먼저 data를 가져와서 그걸 UIImage에 저장하고, 그걸 다시 Image로 넘기는 방식으로한다.

그리고 해당 변수를 사용해주기 위해 AsyncImage 부분을 손본다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ContentView
NavigationLink(value: pokemon) {
    if pokemon.sprite == nil {
        AsyncImage(url: pokemon.spriteURL) { image in
            image
                .resizable()
                .scaledToFit()
        } placeholder: {
            ProgressView()
        }
        .frame(width: 100, height: 100)

    } else {
        pokemon.spriteImage
            .resizable()
            .scaledToFit()
            .frame(width: 100, height: 100)
    }
    
    VStack(alignment: .leading) {
        // 생략
    }
}

// DetailView
ZStack {
    // 생략
    
    if pokemon.sprite == nil || pokemon.shiny == nil {
        AsyncImage(url: showShiny ? pokemon.shinyURL : pokemon.spriteURL) { image in
            image
                .interpolation(.none)
                .resizable()
                .scaledToFit()
                .padding(.top, 50)
                .shadow(color: .black, radius: 6)
        } placeholder: {
            ProgressView()
        }
    } else {
        (showShiny ? pokemon.shinyImage : pokemon.spriteImage)
            .interpolation(.none)
            .resizable()
            .scaledToFit()
            .padding(.top, 50)
            .shadow(color: .black, radius: 6)
    }
}

이렇게 if절을 통해 데이터가 없다면 AsyncImage를 사용, 그렇지 않으면 저장된 이미지를 사용하게한다.

이제 데이터를 다운로드하고 CoreData에 저장한 뒤, 저장된 Binary Data를 이용해 오프라인 상태에서도 스프라이트 이미지를 안정적으로 표시할 수 있게 되었다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.