Несмотря на то, что загрузка изображения — распространенная для мобильной разработки задача, для нее нет нативных решений на уровне UI, в отличие от веба. Когда нужно загрузить и отобразить картинку, самурай может выбрать один из двух путей:

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

  2. Искать подходящее среди готовых решений с открытым кодом.

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

У самурая нет цели, есть только путь. И никакой самурай уж точно не станет изобретать велосипед, чтобы этот путь проехать. Вот и у меня нет цели дать готовый рецепт — только указать верное направление.

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

Готовые решения с открытым исходным кодом

Во время работы с разными проектами я чаще всего встречал подходы с использованием трех библиотек: SDWebImage, AlamofireImage и Kingfisher. Разберемся, как каждая из них решает самую тривиальную задачу — загрузку и отображение картинки в UIImageView.

SDWebImage

import SDWebImage

imageView.sd_setImage(
    with: URL(string: "<http://www.domain.com/path/to/image.jpg>"),
    placeholderImage: UIImage(named: "placeholder.png")
)

AlamofireImage

import AlamofireImage

imageView.af.setImage(
    withURL: URL(string: "<https://httpbin.org/image/png>"),
    placeholderImage: placeholderUIImage(named: "placeholder")
)

Kingfisher

import Kingfisher

imageView.kf.setImage(
    with: URL(string: "<https://example.com/image.png>"),
    placeholder: UIImage(named: "placeholderImage")
)

В документации ко всем трем библиотекам авторы говорят, что закладывали свой функционал через расширение для стандартного UIImageView. Однако давайте разберемся, что получится, если взять пример посложнее: используя AlamofireImage, добавим фильтр на картинку. 

import AlamofireImage

let url = URL(string: "<https://httpbin.org/image/png>")!
let placeholderImage = UIImage(named: "placeholder")!

let filter = AspectScaledToFillSizeWithRoundedCornersFilter(
    size: imageView.frame.size,
    radius: 20.0
)

imageView.af.setImage(
    withURL: url,
    placeholderImage: placeholderImage,
    filter: filter
)

Идем дальше и добавляем уже с помощью Kingfisher плейсхолдер и процессинг изображения. При желании или по необходимости можно добавить мутацию запроса и так далее. 

import Kingfisher

let url = URL(string: "<https://example.com/high_resolution_image.png>")
let processor = DownsamplingImageProcessor(size: imageView.bounds.size)
             |> RoundCornerImageProcessor(cornerRadius: 20)
imageView.kf.indicatorType = .activity
imageView.kf.setImage(
    with: url,
    placeholder: UIImage(named: "placeholderImage"),
    options: [
        .processor(processor),
        .scaleFactor(UIScreen.main.scale),
        .transition(.fade(1)),
        .cacheOriginalImage
    ])
{
    result in
    switch result {
    case .success(let value):
        print("Task done for: \\(value.source.url?.absoluteString ?? "")")
    case .failure(let error):
        print("Job failed: \\(error.localizedDescription)")
    }
}

Вся настройка в одном месте — это удобно. Прикинем, чем мы тут можем управлять:

  1. Выстраиваем пайплайн обработки изображения;

  2. Указываем тип индикатора загрузки;

  3. Задаем анимацию появления картинки;

  4. Управляем работой с кэшем;

  5. Отлавливаем события загрузки.

В документациях SDWebImage, AlamofireImage и Kingfisher авторы рекомендуют именно такой подход, потому что он самый простой и очевидный.

Не забудем про бизнес-логику и посмотрим на пример кода, который получится, если следовать таким простым подходам.

import UIKit
import Kingfisher
import Domain
import Networking


final class SomeCell: UITableViewCell {

    private lazy var authUseCase = Domain.UseCaseProvider.makeAuthUseCase()

    // Other code

    func setup(with imageID: String) {
        guard isLocalImage(imageId: imageID) else {
            setupImageByUrl(with: imageID)
            return
        }
        setupImageByKey(with: imageID)
    }

    private func setupImageByUrl(with imageID: String) {
        let modifier = AnyModifier { request -> URLRequest? in
            self.authUseCase.addAuthHeaders(to: request)
        }

        let url = Networking.APIBaseURL.appendingPathComponent("images/\\(imageID)")
        imageView.kf.setImage(
            with: url,
            placeholder: nil,
            options: [.requestModifier(modifier)]
        )
    }

    private func setupImageByKey(with imageID: String) {
        if ImageCache.default.isCached(forKey: imageID) {
            ImageCache.default.retrieveImage(forKey: imageID) { result in
                switch result {
                case .success(let value):
                    self.imageView.kf.setImage(with: URL(string: imageID), placeholder: value.image)
                case .failure(let error):
                    print(error)
                }
            }
        }
    }
}

Глядя на этот пример, самурай даже в шлеме обратит внимание на несколько вещей:

  1. К работе с версткой зачем-то подключаются библиотеки, не относящиеся к визуальному представлению, но задействующие некую бизнес-логику, например импорты Networking и Domain;

  2. Создание и хранение юзкейса работы с бизнес-логикой находится внутри визуального компонента, а это противоречит хагакурэ и архитектурным принципам, которые приняты в отрасли;

  3. Работа с логикой запросов происходит внутри визуального компонента, который об этой логике ничего знать не должен;

  4. Мы не управляем кэшем, потому что при установке картинки снаружи, не известно, что произойдет под капотом.

Минусы такого подхода

Такой подход встречается очень часто, хотя он никуда не годится. Вот главные его проблемы:

  1. Бизнес-логика на UI-слое;

  2. Сильная завязка на конкретную библиотеку;

  3. Нет управления источником и ходом загрузки изображений.

Теперь давайте подробнее разберем каждую из этих проблем.

Бизнес-логика на UI-слое

Если у вас на UI-слое поселится бизнес-логика, код перестанет быть расширяемым и не получится переиспользовать UI-элемент в другом контексте. Кроме того, придется прибегать к копипасту, если понадобится такая же логика обработки и загрузки изображения в другом UI-элементе.

Сильная завязка на конкретную библиотеку

Целиком зависеть от сторонней библиотеки опасно, потому что:

  1. В ней может найтись уязвимость;

  2. В будущем вам может перестать хватать ее функций;

  3. Могут всплыть проблемы с производительностью;

  4. Разработчики могут перестать ее поддерживать и обновлять.

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

Нет управления источником и ходом загрузки изображений

В этом подходе UI-слой отвечает еще и за старт загрузки. Способа загрузить изображение заранее явно нет. Мы можем загрузить все изображения через менеджер, например, синглтон default, и надеяться, что логика внутри UI-элемента работает с этим же менеджером. Или создавать UI-элемент, и устанавливать ViewModel до появления элемента в иерархии View.

Кроме того, для нас, как для клиентов UI-элемента, нет понимания, какая логика на самом деле инкапсулирована за установкой ViewModel. Обратите внимание на setup(with imageID: String), при вызове которого мы можем как скачивать картинку, так и получать ее только из кэша, и неопределенность сохраняется до перехода в реализацию метода.

func setup(with imageID: String) {
    guard isLocalImage(imageId: imageID) else {
        setupImageByUrl(with: imageID)
        return
    }
    setupImageByKey(with: imageID)
}

Решение

От всех трех проблем можно и нужно избавиться. Давайте разберемся, как.

Избавляемся от бизнес-логики на UI-слое

Считаю, что ViewModel должна иметь только те данные и флаги, которые UI может однозначно интерпретировать в директивы для отображения:

  1. Скрыть кнопку;

  2. Показать загрузчик;

  3. Покрасить в указанный цвет;

  4. Установить UIImage;

  5. И так далее.

ViewModel не должна иметь ссылок на картинки, лишних переменных, на основании которых внутри View будут строиться логические выражения. Например, isLoggedIn — это переменная бизнес-логики, isAuthButtonHidden — переменная ViewModel.

struct ViewModel: Equatable {
    let image: UIImage?
}

Получилось

Избавляемся от завязки на конкретную библиотеку

Решение элементарное: просто закрываем наш загрузчик протоколом.

protocol ImageDownloaderProtocol {
    func getImage(from url: URL) async throws -> UIImage
}

Возьмем Kingfisher и реализуем этот протокол. Сконфигурировать его пайплайн и другие опции, такие как менеджер и кэш, можно в конструкторе еще на этапе сборки экрана.

init(manager: KingfisherManager, options: KingfisherOptionsInfo? = nil) {
    self.manager = manager
    self.options = options
}

И переделаем интерфейс Kingfisher'а с callback’ов на Swift Concurrency.

func getImage(from url: URL) async throws -> UIImage {
    return try await withCheckedThrowingContinuation { continuation in
        self.manager.retrieveImage(
            with: url,
            options: options
        ) { result in
            switch result {
            case .success(let image):
                continuation.resume(returning: image.image)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

Теперь, когда у нас есть интерфейс для загрузки изображения и ViewModel, основанная на примитивах, выберем место и подход, как эти компоненты объединять.

Получаем управление ходом загрузки

Управление скачиванием обеспечит элемент архитектуры, содержащий бизнес-логику. В MVI или VIPInteractor, в MVVMViewModel. Демо-приложение я делал на MVC, поэтому у меня за бизнес-логику отвечает ViewController, которых я для демонстрации реализовал два.

Один реализовывал загрузку всех изображений перед отображением данных на UICollectionView.

override func viewDidLoad() {
    super.viewDidLoad()

    setupUI()
    setupLayout()
    Task {
        let photos = await photosRepo.fetchPhotos()
        self.viewModels = photos.map(photoToViewModel)
        await downloadAllImages()
    }
}

Второй запускал загрузку по методу UICollectionViewDelegate collectionView(_:willDisplay:forItemAt:) и перезагружал ячейку, если к моменту загрузки она отображалась на экране.

func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
    if tasks[indexPath] == nil {
        tasks[indexPath] = Task {
            await loadImages(for: indexPath)
            if collectionView.indexPathsForVisibleItems.contains(indexPath) {
                collectionView.reconfigureItems(at: [indexPath])
            }
        }
    }
}

Проблему сборки ViewModel я решил при помощи паттерна Builder.

protocol ViewModelBuilderProtocol {
    associatedtype DTO
    associatedtype ViewModel

    func buildViewModel(for object: DTO) -> ViewModel
}

Суть в том, что Controller или то, что у вас отвечает за бизнес-логику, оперируя состоянием загрузок и другой логикой, например, авторизацией, по триггерам заполняет builder информацией и просит выдать новую ViewModel.

Примером такого подхода можно назвать объект URLComponent из Foundation.

Так как загрузка теперь не часть View, мы можем сами решать, когда запускать загрузку картинки, нужно ли обновлять UI и так далее.

Я решил делать builder кэширующим и превью, и картинки и итоговую вью-модель. Для того, чтобы избежать состояния гонки, я добавил ConcurrentQueue на загрузку и модификацию кэшированных данных.

Блок-схема итогового решения

Вывод

Понимаю, что вариантов реализации может быть сколь угодно много, поэтому не хотелось рекомендовать какой-то конкретный фреймворк. Хотел показать, как делать не надо, чтобы каждый, кто прочитает, смог найти слабые места в своем подходе и легко их залатать.

Спасибо, что дочитали, самураи! Краткий совет из бусидо разработчика напоследок: всегда держите в голове базовые принципы разделения ответственности, абстрагирования и упрощения каждого отдельно взятого элемента

Комментарии (2)


  1. Gargo
    03.10.2023 20:09

    1)основная проблема с `UITableViewCell` в том, что вы пишете асинхронный код внутри переиспользуемого компонента. Даже если у вас там будут не запросы в сеть, а например, асинхронные анимации ячейки (чистый UI), то вы получите точно такие же проблемы, пока не вынесете ваш код хотя бы в `UITableView`.

    2)а как же Nuke?


    1. AlekseyNikitin Автор
      03.10.2023 20:09

      1) Ты прав. Проблема, в том числе, в асинхронном коде, но не только при использовании UITableViewCell. Можно экстраполировать до практически любой View. И подход, который я описывал в статье, как раз в переносе всей логики вне UI, а в UI оставлять только максимально примитивные действия.

      2) Действительно, документация Nuke не подталкивает разработчиков использовать расширения к UImageView. Однако, все остальные проблемы на месте. Как минимум зависимость от сторонней библиотеки. Так что и для Nuke (и для любой другой вышедшей в будущем библиотеки) статья актуальна.