Как работать с готовыми библиотеками под UIKit?
В прошлой статье мы начали разговор об адаптации существующих бизнес-решений под 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 немного глубже.