Адаптируем существующее бизнес-решение под 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 смотрите тут.