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

—

от автора

В этой части мы уже поговорим по делу, как можно адаптировать готовое решение к проекту на 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 смотрите тут

ссылка на оригинал статьи https://habr.com/ru/post/500386/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *