Получение, кеширование, ошибка и просмотр загрузки

Обновление 2019/08/14: теперь доступно как пакет Swift 🚀

Я хочу реализовать кулон SwiftUI удаленного просмотра изображений UIKit, который мы создали у моего нынешнего работодателя. Я не могу поделиться кодом, но поверьте мне, что по сравнению со следующей реализацией SwiftUI наш код UIKit более сложен, использует внешнюю структуру и содержит больше строк кода.

Цель

Начнем с краткого описания цели статьи:

Наша цель - создать представление, которое извлекает изображение по заданному URL-адресу, показывает представление загрузки во время выборки, кэширует изображение, использует существующее представление изображения SwiftUI и при необходимости показывает представление ошибки.

Помня об этом, приступим к программированию.

Реализация

Код в основном состоит из трех компонентов.

1. RemoteImageState

Просмотр удаленного изображения может иметь три разных состояния: ошибка, изображение и загрузка. Он должен иметь возможность зависеть от своего текущего состояния.

enum RemoteImageState {
    case error(_ error: Error)
    case image(_ image: UIImage)
    case loading
}

2. RemoteImageService

Нам нужен сервис, который позаботится о получении и кешировании изображения. В дополнение к этому, он должен управлять текущим состоянием связанного удаленного образа.

Получение изображения

HTTP-запрос выполняется с использованием стандартных инструментов (URLSession и URLRequest) в сочетании с простым издателем задачи данных и подписчиком приемника (объединить структуру ).

Кеширование изображения

Кеширование выполняется с помощью NSCache. Все просто, правда? Все удаленные изображения в одном приложении SwiftUI должны использовать один и тот же кеш, поэтому кеш статичен. Кэш можно легко очистить с помощью функции removeAllObjects().

Изменения состояния

Служба соответствует протоколу ObservableObject и распространяет изменения состояния, сделанные в функции fetchImage, через простой PassthroughSubject каждому подписчику.

final class RemoteImageService: ObservableObject {
    private var cancellable: AnyCancellable?
    
    static let cache = NSCache<NSURL, UIImage>()
    
    var state: RemoteImageState = .loading {
        didSet {
            objectWillChange.send()
        }
    }
    
    let objectWillChange = PassthroughSubject<Void, Never>()
    
    func fetchImage(atURL url: URL) {
        cancellable?.cancel()
        
        if let image = RemoteImageService.cache.object(forKey: url as NSURL) {
            state = .image(image)
            return
        }
        
        let urlSession = URLSession.shared
        let urlRequest = URLRequest(url: url)
        
        cancellable = urlSession.dataTaskPublisher(for: urlRequest)
            .map { UIImage(data: $0.data) }
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { completion in
                switch completion {
                    case .failure(let failure):
                        self.state = .error(failure)
                    default: ()
                }
            }) { image in
                if let image = image {
                    RemoteImageService.cache.setObject(image, forKey: url as NSURL)
                    self.state = .image(image)
                } else {
                    self.state = .error(RemoteImageServiceError.couldNotCreateImage)
                }
            }
    }
}

3. RemoteImage

Последний компонент - это само представление. Он использует экземпляр RemoteImageService и становится зависимым от состояния в службе.

Инициализатор ожидает URL и блок ViewBuilder для каждого состояния (ошибка, изображение и загрузка).

Получение изображения

Как только LoadingView появляется на экране, запускается функция fetchImage экземпляра RemoteImageService.

Настройка внешнего вида изображения

Внутренне представление RemoteImage использует существующее представление изображения SwiftUI. Чтобы иметь возможность настраивать внешний вид этого представления изображения, он отображается через связанный блок ViewBuilder .

Произошла ошибка

Если возникает ошибка, вызывается окно просмотра ошибок ViewBuilder. Ошибка передается в блок ViewBuilder и может использоваться для создания представления ошибки.

AnyView

В этой реализации мне пришлось использовать обертку стертого типа AnyView , чтобы стереть различные типы представлений, возвращаемых переключателем.

Обычно групповое представление может решить эту проблему. Но блок содержимого представления Group - это ViewBuilder блок, и вы не можете использовать приведенный ниже оператор switch (перечисление со связанными значениями) в блоке ViewBuilder.

struct RemoteImage<ErrorView: View, ImageView: View, LoadingView: View>: View {
    private let url: URL
    private let errorView: (Error) -> ErrorView
    private let imageView: (Image) -> ImageView
    private let loadingView: () -> LoadingView
    @ObservedObject private var service: RemoteImageService = RemoteImageService()
    
    var body: AnyView {
        switch service.state {
            case .error(let error):
                return AnyView(
                    errorView(error)
                )
            case .image(let image):
                return AnyView(
                    self.imageView(Image(uiImage: image))
                )
            case .loading:
                return AnyView(
                    loadingView()
                    .onAppear {
                        self.service.fetchImage(atURL: self.url)
                    }
                )
        }
    }
    
    init(url: URL, @ViewBuilder errorView: @escaping (Error) -> ErrorView, @ViewBuilder imageView: @escaping (Image) -> ImageView, @ViewBuilder loadingView: @escaping () -> LoadingView) {
        self.url = url
        self.errorView = errorView
        self.imageView = imageView
        self.loadingView = loadingView
    }
}

Пример

И последнее, но не менее важное: мы рассмотрим пример использования.

Созданное представление удаленного изображения легко использовать. Просто передайте инициализатору URL-адрес, представление ошибок, изображение и представление загрузки.

struct ContentView: View {
    private let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")!
    
    var body: some View {
        RemoteImage(url: url, errorView: { error in
            Text(error.localizedDescription)
        }, imageView: { image in
            image
            .resizable()
            .aspectRatio(contentMode: .fit)
        }, loadingView: {
            Text("Loading ...")
        })
    }
}

Взгляните на то, как я настраивал внешний вид изображения: я изменил его размер и изменил режим содержимого, чтобы он соответствовал.

Заключение

Поздравляю! Вы закончили этот урок о создании удаленного изображения в представлении SwiftUI.

Спасибо, что прочитали эту статью. Надеюсь, вы продолжите читать мои статьи. Будьте на связи.

Смотрите на GitHub по адресу https://github.com/crelies/RemoteImage-SwiftUI.