Адаптируем существующее бизнес-решение под SwiftUI. Часть 2

Доброго всем времени суток! С вами я, Анна Жаркова, ведущий мобильный разработчик компании «Usetech».
В этой части мы уже поговорим по делу, как можно адаптировать готовое решение к проекту на SwiftUI. Если вы еще не особо знакомы с этой технологией, то советую ознакомиться с кратким введением в тему.

Итак, рассмотрим простой пример, как можно использовать готовую библиотеку под стандартное iOS приложение в приложении на SwiftUI.

Возьмем классическое решение: асинхронная загрузка изображений с помощью библиотеки SDWebImage.

Для удобства работа с библиотекой инкапсулирована в ImageManager, который вызывает:

  • SDWebImageDownloader
  • SDImageCache

для скачивания изображений и кеширования.

По традиции, связь с принимающим результат UIImageView реализуется 2мя способами:

  • через передачу weak ссылки на этот самый UIImageView;
  • через передачу closure-блока в метод ImageManager

Обращение к ImageManager обычно инкапсулируется либо в расширении UIImageView:

extension UIImageView {
 func setup(by key: String) {
        ImageManager.sharedInstance.setImage(toImageView: self, forKey: key)
    }
}

либо в классе-наследнике:

class CachedImageView : UIImageView {
    private var _imageUrl: String?
    var imageUrl: String?  {
        get {
            return _imageUrl
        }
        set {
            self._imageUrl = newValue
            if let url = newValue, !url.isEmpty {
                self.setup(by: url)
            }
        }
    }
    
    func setup(by key: String) {
        ImageManager.sharedInstance.setImage(toImageView: self, forKey: key)
    }
}

Теперь попробуем прикрутить это решение к SwiftUI. Однако при адаптации мы должны учесть следующие особенности фреймворка:

— View – структура. Наследование не поддерживается

— Extension в привычном смысле бесполезны. Мы, конечно, можем написать некоторые методы для расширения функционала, но нам нужно как-то привязать это к DataFlow;

Получаем проблему получения обратной связи и необходимость адаптировать всю логику взаимодействия с UI к DataDriven Flow.

Для решения мы можем пойти как со стороны View, так и со стороны адаптации Data Flow.

Начнем с View.

Для начала вспомним, что SwiftUI существует не сам по себе, а как надстройка над UIKit. Разработчики SwiftUI предусмотрели механизм для использования в SwiftUI UIView, аналогов которых нет среди готовых контролов. Для таких случаев существуют протоколы UIViewRepresentable и UIViewControllerRepresentable для адаптации UIView и UIViewController соответственно.

Создадим структуру View, реализующую UIViewRepresentable, в котором переопределим методы:

  • makeUiView;
  • updateUIView

в которых укажем, какие именно UIView мы используем, и зададим их базовые настройки. И не забудем PropertyWrappers для изменяемых свойств.

struct WrappedCachedImage : UIViewRepresentable {
    let height: CGFloat
    @State var imageUrl: String
    
    func makeUIView(context: Context) -> CachedImageView {
        let frame = CGRect(x: 20, y: 0, width: UIScreen.main.bounds.size.width - 40, 
                                     height: height)
        return CachedImageView(frame: frame)
    }
    
    func updateUIView(_ uiView: CachedImageView, context: Context) {
        uiView.imageUrl = imageUrl
        uiView.contentMode = .scaleToFill
    }
}

Полученный новый контрол можем встраивать в View SwiftUI:

У такого подхода есть преимущества:

  • Не надо менять работу существующей библиотеки
  • Логика инкапсулирована во встроенном UIView.

Но появляются и новые обязанности. Во-первых, необходимо следить за управлением памятью в связке View-UIView. Т.к View структура, то вся работа с ними ведется фоново самим фреймворком. А вот очистка новых объектов ложится на плечи разработчика.

Во-вторых, необходимы дополнительные действия для настройки (размеры, стили). Если для View эти параметры включены по умолчанию, то с UIView их надо синхронизировать.

Например, для настройки размеров мы можем использовать GeometryReader, чтобы наше изображение занимало всю ширину экрана и определенную нами высоту:

 var body: some View {
      GeometryReader { geometry in 
        VStack {
           WrappedCachedImage(height:300, imageUrl: imageUrl) 
           .frame(minWidth: 0, maxWidth: geometry.size.width,
                     minHeight: 0, maxHeight: 300)
        }
    }
}

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

View у нас зависит от переменной состояния или группы переменных, т.е. от некой модели, которая сама может этой переменной состояния являться. По сути, это взаимодействие построено на паттерне MVVM.

Реализуем следующим образом:

  • создадим кастомный View, внутри которого будем использовать контрол SwiftUI;
  • создадим ViewModel, в которую перенесем логику работы с Model (ImageManager).

Для того, чтобы между View и ViewModel была связь, ViewModel должна реализовывать протокол ObservableObject и подключаться к View как ObservedObject.

class CachedImageModel : ObservableObject {
    @Published var image: UIImage = UIImage()
    
    private var urlString: String = ""
    
    init(urlString:String) {
        self.urlString = urlString
    }
    
    func loadImage() {
        ImageManager.sharedInstance
        .receiveImage(forKey: urlString) {[weak self] (im) in
            guard let self = self else {return}
            DispatchQueue.main.async {
                self.image = im
            }
        }
    }
}

View в методе onAppear своего life-cycle вызывает метод ViewModel и получает итоговое изображение из ее свойства @Published:

struct CachedLoaderImage : View {
    @ObservedObject var  model:CachedImageModel
    
    init(withURL url:String) {
        self.model = CachedImageModel(urlString: url)
    }
    
    var body: some View {
        Image(uiImage: model.image)
            .resizable()
            .onAppear{
                self.model.loadImage()
        }
    }
    
}

Также для работы с DataFlow SwiftUI есть декларативный API Combine. Работа с ним очень похожа на работу с реактивными фреймворками (тот же RxSwift): есть субъекты, есть подписчики, есть похожие методы управления, есть cancellable (вместо Disposable).

class ImageLoader: ObservableObject {
 @Published var image: UIImage?
 private var cancellable: AnyCancellable?

 func load(url: String) {
     cancellable = ImageManager.sharedInstance.publisher(for: url)
         .map { UIImage(data: $0.data) }
         .replaceError(with: nil)
         .receive(on: DispatchQueue.main)
         .assign(to: \.image, on: self)
 }

Если бы наш ImageManager изначально был написан с использованием Combine, то решение бы имело такой вид.

Но т.к. ImageManager реализован у нас по другим принципам, то попробуем другой способ. Для генерации события мы будем использовать механизм PasstroughSubject, поддерживающий автозавершение подписок.

    var didChange = PassthroughSubject<UIImage, Never>()

Новое значение будем отправлять при присвоении значения свойству UIImage нашей модели:

var data = UIImage() {
        didSet {
            didChange.send(data)
        }
    }
Обратите внимание, здесь нет модификатора свойств.

Итоговое значение наш View «слушает» в методе onReceive:

 var body: some View {
       Image(uiImage: image)
            .onReceive(imageLoader.didChange) { im in
            self.image = im
           //какие-то действия с изображением
        }
    }

Итак, мы разобрали простой пример, как можно адаптировать существующий код под SwiftUI.
Что остается добавить. Если существовавшее iOS решение больше затрагивает UI-часть, то лучше использовать адаптацию через UIViewRepresentable. В остальных случаях нужна адаптация со стороны View-модель состояния.

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

Более подробно про работу с View под SwiftUI смотрите тут.