HTML Diff
0 added 0 removed
Original 2026-01-01
Modified 2026-03-10
1 <p>В<a>прошлой статье</a>мы начали разговор об адаптации существующих бизнес-решений под SwiftUI. В этот раз рассмотрим простой пример, как можно использовать готовую библиотеку под стандартное iOS-приложение в приложении на SwiftUI. И возьмем для этого классическое решение: асинхронную загрузку изображений с помощью библиотеки SDWebImage.</p>
1 <p>В<a>прошлой статье</a>мы начали разговор об адаптации существующих бизнес-решений под SwiftUI. В этот раз рассмотрим простой пример, как можно использовать готовую библиотеку под стандартное iOS-приложение в приложении на SwiftUI. И возьмем для этого классическое решение: асинхронную загрузку изображений с помощью библиотеки SDWebImage.</p>
2 <p>Для удобства работа с библиотекой инкапсулирована в ImageManager, который вызывает:</p>
2 <p>Для удобства работа с библиотекой инкапсулирована в ImageManager, который вызывает:</p>
3 <ul><li>SDWebImageDownloader;</li>
3 <ul><li>SDWebImageDownloader;</li>
4 <li>SDImageCache; для скачивания изображений и кеширования.</li>
4 <li>SDImageCache; для скачивания изображений и кеширования.</li>
5 </ul><p>По традиции, связь с принимающим результат UIImageView реализуется 2-мя способами:</p>
5 </ul><p>По традиции, связь с принимающим результат UIImageView реализуется 2-мя способами:</p>
6 <ul><li>через передачу weak-ссылки на этот самый UIImageView;</li>
6 <ul><li>через передачу weak-ссылки на этот самый UIImageView;</li>
7 <li>через передачу closure-блока в метод ImageManager.</li>
7 <li>через передачу closure-блока в метод ImageManager.</li>
8 </ul><p>Обращение к ImageManager обычно инкапсулируется либо в расширении UIImageView:</p>
8 </ul><p>Обращение к ImageManager обычно инкапсулируется либо в расширении UIImageView:</p>
9 extension UIImageView { func setup(by key: String) { ImageManager.sharedInstance.setImage(toImageView: self, forKey: key) } }<p>Либо в классе-наследнике:</p>
9 extension UIImageView { func setup(by key: String) { ImageManager.sharedInstance.setImage(toImageView: self, forKey: key) } }<p>Либо в классе-наследнике:</p>
10 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) } }<p>Теперь попробуем прикрутить это решение к SwiftUI. Однако при адаптации мы должны учесть следующие особенности фреймворка:</p>
10 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) } }<p>Теперь попробуем прикрутить это решение к SwiftUI. Однако при адаптации мы должны учесть следующие особенности фреймворка:</p>
11 <p>- View - структура. Наследование не поддерживается; - Extension в привычном смысле бесполезны. Мы, конечно, можем написать некоторые методы для расширения функционала, но нам нужно как-то привязать это к DataFlow.</p>
11 <p>- View - структура. Наследование не поддерживается; - Extension в привычном смысле бесполезны. Мы, конечно, можем написать некоторые методы для расширения функционала, но нам нужно как-то привязать это к DataFlow.</p>
12 <p><strong>Получаем проблему получения обратной связи и необходимость адаптировать всю логику взаимодействия с UI к DataDriven Flow</strong>.</p>
12 <p><strong>Получаем проблему получения обратной связи и необходимость адаптировать всю логику взаимодействия с UI к DataDriven Flow</strong>.</p>
13 <p>Для решения мы можем пойти как со стороны View, так и со стороны адаптации Data Flow.</p>
13 <p>Для решения мы можем пойти как со стороны View, так и со стороны адаптации Data Flow.</p>
14 <p>Начнем с View.</p>
14 <p>Начнем с View.</p>
15 <p>Для начала вспомним, что SwiftUI существует не сам по себе, а как надстройка над UIKit. Разработчики SwiftUI предусмотрели механизм для использования в SwiftUI UIView, аналогов которых нет среди готовых контролов. Для таких случаев существуют протоколы UIViewRepresentable и UIViewControllerRepresentable для адаптации UIView и UIViewController соответственно.</p>
15 <p>Для начала вспомним, что SwiftUI существует не сам по себе, а как надстройка над UIKit. Разработчики SwiftUI предусмотрели механизм для использования в SwiftUI UIView, аналогов которых нет среди готовых контролов. Для таких случаев существуют протоколы UIViewRepresentable и UIViewControllerRepresentable для адаптации UIView и UIViewController соответственно.</p>
16 <p>Создадим структуру View, реализующую UIViewRepresentable, в котором переопределим методы</p>
16 <p>Создадим структуру View, реализующую UIViewRepresentable, в котором переопределим методы</p>
17 <ul><li>makeUiView;</li>
17 <ul><li>makeUiView;</li>
18 <li>updateUIView.</li>
18 <li>updateUIView.</li>
19 </ul><p>в которых укажем, какие именно UIView мы используем, и зададим их базовые настройки. И не забудем PropertyWrappers для изменяемых свойств.</p>
19 </ul><p>в которых укажем, какие именно UIView мы используем, и зададим их базовые настройки. И не забудем PropertyWrappers для изменяемых свойств.</p>
20 struct WrappedCachedImage : UIViewRepresentable { let height: CGFloat @State var imageUrl: String func makeUIView(context: Context) -&gt; 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 } }<p>Полученный новый контрол можем встраивать в View SwiftUI:</p>
20 struct WrappedCachedImage : UIViewRepresentable { let height: CGFloat @State var imageUrl: String func makeUIView(context: Context) -&gt; 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 } }<p>Полученный новый контрол можем встраивать в View SwiftUI:</p>
21 <p>У такого подхода есть преимущества:</p>
21 <p>У такого подхода есть преимущества:</p>
22 <ul><li>Не надо менять работу существующей библиотеки;</li>
22 <ul><li>Не надо менять работу существующей библиотеки;</li>
23 <li>Логика инкапсулирована во встроенном UIView.</li>
23 <li>Логика инкапсулирована во встроенном UIView.</li>
24 </ul><p>Но появляются и новые обязанности. Во-первых, необходимо следить за управлением памятью в связке View-UIView. Т. к. View структура, то вся работа с ними ведется фоново самим фреймворком. А вот очистка новых объектов ложится на плечи разработчика.</p>
24 </ul><p>Но появляются и новые обязанности. Во-первых, необходимо следить за управлением памятью в связке View-UIView. Т. к. View структура, то вся работа с ними ведется фоново самим фреймворком. А вот очистка новых объектов ложится на плечи разработчика.</p>
25 <p>Во-вторых, необходимы дополнительные действия для настройки (размеры, стили). Если для View эти параметры включены по умолчанию, то с UIView их надо синхронизировать.</p>
25 <p>Во-вторых, необходимы дополнительные действия для настройки (размеры, стили). Если для View эти параметры включены по умолчанию, то с UIView их надо синхронизировать.</p>
26 <p>Например, для настройки размеров мы можем использовать GeometryReader, чтобы наше изображение занимало всю ширину экрана и определенную нами высоту:</p>
26 <p>Например, для настройки размеров мы можем использовать GeometryReader, чтобы наше изображение занимало всю ширину экрана и определенную нами высоту:</p>
27 var body: some View { GeometryReader { geometry in VStack { WrappedCachedImage(height:300, imageUrl: imageUrl) .frame(minWidth: 0, maxWidth: geometry.size.width, minHeight: 0, maxHeight: 300) } } }<p>В принципе для таких случаев использование встраиваемых UIView может быть расценено, как оверинжениринг. Поэтому теперь попробуем решить через DataFlow SwiftUI.</p>
27 var body: some View { GeometryReader { geometry in VStack { WrappedCachedImage(height:300, imageUrl: imageUrl) .frame(minWidth: 0, maxWidth: geometry.size.width, minHeight: 0, maxHeight: 300) } } }<p>В принципе для таких случаев использование встраиваемых UIView может быть расценено, как оверинжениринг. Поэтому теперь попробуем решить через DataFlow SwiftUI.</p>
28 <p>View у нас зависит от переменной состояния или группы переменных, т. е. от некой модели, которая сама может этой переменной состояния являться. По сути, это взаимодействие построено на паттерне MVVM.</p>
28 <p>View у нас зависит от переменной состояния или группы переменных, т. е. от некой модели, которая сама может этой переменной состояния являться. По сути, это взаимодействие построено на паттерне MVVM.</p>
29 <p>Реализуем следующим образом:</p>
29 <p>Реализуем следующим образом:</p>
30 <ul><li>создадим кастомный View, внутри которого будем использовать контрол SwiftUI;</li>
30 <ul><li>создадим кастомный View, внутри которого будем использовать контрол SwiftUI;</li>
31 <li>создадим ViewModel, в которую перенесем логику работы с Model (ImageManager).</li>
31 <li>создадим ViewModel, в которую перенесем логику работы с Model (ImageManager).</li>
32 </ul><p>Для того, чтобы между View и ViewModel была связь, ViewModel должна реализовывать протокол ObservableObject и подключаться к View как<strong>ObservedObject</strong>.</p>
32 </ul><p>Для того, чтобы между View и ViewModel была связь, ViewModel должна реализовывать протокол ObservableObject и подключаться к View как<strong>ObservedObject</strong>.</p>
33 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 } } } }<p>View в методе onAppear своего life-cycle вызывает метод ViewModel и получает итоговое изображение из ее свойства @Published:</p>
33 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 } } } }<p>View в методе onAppear своего life-cycle вызывает метод ViewModel и получает итоговое изображение из ее свойства @Published:</p>
34 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() } } }<p>Также для работы с DataFlow SwiftUI есть декларативный<a>API Combine</a>. Работа с ним очень похожа на работу с реактивными фреймворками (тот же RxSwift): есть субъекты, есть подписчики, есть похожие методы управления, есть cancellable (вместо Disposable).</p>
34 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() } } }<p>Также для работы с DataFlow SwiftUI есть декларативный<a>API Combine</a>. Работа с ним очень похожа на работу с реактивными фреймворками (тот же RxSwift): есть субъекты, есть подписчики, есть похожие методы управления, есть cancellable (вместо Disposable).</p>
35 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) }<p>Если бы наш ImageManager изначально был написан с использованием Combine, то решение бы имело такой вид.</p>
35 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) }<p>Если бы наш ImageManager изначально был написан с использованием Combine, то решение бы имело такой вид.</p>
36 <p>Но т. к. ImageManager реализован у нас по другим принципам, то попробуем другой способ. Для генерации события мы будем использовать механизм PasstroughSubject, поддерживающий автозавершение подписок.</p>
36 <p>Но т. к. ImageManager реализован у нас по другим принципам, то попробуем другой способ. Для генерации события мы будем использовать механизм PasstroughSubject, поддерживающий автозавершение подписок.</p>
37 var didChange = PassthroughSubject&lt;UIImage, Never&gt;()<p>Новое значение будем отправлять при присвоении значения свойству UIImage нашей модели:</p>
37 var didChange = PassthroughSubject&lt;UIImage, Never&gt;()<p>Новое значение будем отправлять при присвоении значения свойству UIImage нашей модели:</p>
38 var data = UIImage() { didSet { didChange.send(data) } }<p>Обратите внимание, здесь нет модификатора свойств.</p>
38 var data = UIImage() { didSet { didChange.send(data) } }<p>Обратите внимание, здесь нет модификатора свойств.</p>
39 <p>Итоговое значение наш View "слушает" в методе onReceive:</p>
39 <p>Итоговое значение наш View "слушает" в методе onReceive:</p>
40 var body: some View { Image(uiImage: image) .onReceive(imageLoader.didChange) { im in self.image = im //какие-то действия с изображением } }<p>Итак, мы разобрали простой пример, как можно адаптировать существующий код под SwiftUI.</p>
40 var body: some View { Image(uiImage: image) .onReceive(imageLoader.didChange) { im in self.image = im //какие-то действия с изображением } }<p>Итак, мы разобрали простой пример, как можно адаптировать существующий код под SwiftUI.</p>
41 <p>Что остается добавить. Если существовавшее iOS-решение больше затрагивает UI-часть, то лучше использовать адаптацию через UIViewRepresentable. В остальных случаях нужна адаптация со стороны View-модель состояния.</p>
41 <p>Что остается добавить. Если существовавшее iOS-решение больше затрагивает UI-часть, то лучше использовать адаптацию через UIViewRepresentable. В остальных случаях нужна адаптация со стороны View-модель состояния.</p>
42 <p>В следующих частях мы рассмотрим, как адаптировать бизнес-логику существующего проекта к SwiftUI, работу с навигацией и затем копнем адаптацию к Combine немного глубже.</p>
42 <p>В следующих частях мы рассмотрим, как адаптировать бизнес-логику существующего проекта к SwiftUI, работу с навигацией и затем копнем адаптацию к Combine немного глубже.</p>
43  
43