Предисловие
Начну с того, что в поисках информации по SwiftUI, я не нашел ничего лучше, чем туториал от корпорации Apple - вот он. Если плохо с английским, браузер Chrome в помощь, справляется на ура. Достаточно повторить весь предоставленный материал и 99% вопросов отпадут сами собой. Материал рассчитан на людей, которые имеют какой-либо незначительный опыт разработки и хотят познакомиться со SwiftUI.
Немного моего скромного мнения о SwiftUI
Фреймворк уже достаточно мощный и к Storyboard я более не вернусь. Конечно часто приходится использовать UIKit, но думаю с течением времени эта необходимость сойдет на нет. Производительность при правильном проектировании, просто поражает воображение, скорость разработки так же удивляет. Доказывать никому и ничего не буду, как говорится, сколько людей, столько и мнений, пожалуй начну.
О чем публикация
Шпаргалка по SwiftUI
Некоторые нюансы работы SwiftUI
PageView на SwiftUI
WebImage на SwiftUI (AsyncImage)
В общем, постараюсь поделится своими значимыми наблюдениями, которые я выполнил, в момент разработки клиент-серверного приложения. Суть приложения - обои на рабочий стол iPhone. Кто хочет пощупать, оно есть в AppStore - сильвупле. Приложение умеет сохранять картинки (Data) в .cache, сохранять картинки в фотопленку, показывать картинки, работать с сетью (запросы списков, авторизация и т.д.), грубо говоря ничего сложного. Об этом и как это реализовано, в том числе и будет идти речь.
Шпаргалка по SwiftUI
@State - своеобразная обертка для свойств, их можно передавать и они реактивно связаны с представлением.
struct MyView: View { @State var text: String = "" var body: some View { TextField("Placeholder", text: $text) Text(text)
Если попробовать объяснить простым языком, в представленном выше листинге, можно наблюдать свойство структуры представления - text, по сути переменная, которая одновременно является и String т.е. можно использовать её как обычно, плюс находится в обертке (Binding<String>). Некоторые представления требуют именно Binding<>, как например TextField() (почему так происходит, поймете чуть ниже), указывать такие свойства необходимо через $ (это не отсылка к php). Соответственно, указывая это свойство в любом представлении (в SwiftUI, каждый элемент является представлением), например в Text(), мы получаем реактивную связку и изменяя TextField(), мы сразу увидим изменения в Text(). Просто великолепная возможность, кроме этого мы можем менять значения таких свойств из методов без дополнительных действий, к примеру
struct MyView: View { var myProperty = 0 mutating func myMethod(){ self.myProperty += 1 } var body: some View { ...
как вам известно, в структурах, для изменения обычных свойств методами, необходимо использовать mutating, а вот свойств в обертке Binding<> как вы уже возможно догадались, это не касается, они поддаются прямому изменению.
struct MyView: View { @State var myProperty = 0 func myMethod(){ self.myProperty += 1 } var body: some View { ...
Свойства Binding<> не могут быть вычисляемыми и им нельзя назначать сеттеры, но есть выход - для всех действий надлежит использовать модификатор onChange
//-------- БЕЗ Binding struct MyView: View { var myProperty: Int = 0 { didSet { print("свойство myProperty изменилось") } } mutating func myMethod(){ self.myProperty += 1 } var body: some View { VStack { Text(String(myProperty)) } ... //-------- С Binding struct MyView: View { @State var myProperty: Int = 0 func myMethod(){ self.myProperty += 1 } var body: some View { VStack { Text(String(myProperty)) } .onChange(of: myProperty) { _ in print("свойство myProperty изменилось") } ...
@Binding - получаемое структурой извне, свойство, с оберткой @State
struct MyView: View { @State var myProperty: String = "Текст" var body: some View { Text(myProperty) MySecondView(myProperty: $myProperty) } } struct MySecondView: View { @Binding var myProperty: String var body: some View { TextField("Placeholder", text: $myProperty) } }
Выше можно наблюдать, как текст в MyView связан с полем ввода в MySecondView. Теперь я думаю стало понятно, почему каждый элемент вашего представления является представлением, а так же иногда требует именно свойства в Binding<> обертке. Это очень изящное и простое решение, которое позволят связывать различные структуры между собой, разбивать сложные представления на части.
@StateObject - обертка для объекта, которая по сути является аналогичной @State, часто бывает удобно для структуризации, с той лишь разницей, что нельзя передать объект @Binding целиком (для этого существует другое решение), а только его свойства
struct MyView: View { @StateObject var myObject: MyClass = MyClass() var body: some View { Text(myObject.text) Text(myObject.title) MySecondView(myProperty: $myObject.text) } } struct MySecondView: View { @Binding var myProperty: String var body: some View { TextField("Placeholder", text: $myProperty) } } class MyClass: ObservableObject { @Published var title: String = "" @Published var text: String = "" }
@ObservedObject - как раз то, что помогает принять @StateObject из другого представления (никогда не следует использовать его для инициализации экземпляра).
struct MyView: View { @StateObject var myObject: MyClass = MyClass() var body: some View { Text(myObject.text) Text(myObject.title) MySecondView(myObject: myObject) } } struct MySecondView: View { @ObservedObject var myObject : MyClass var body: some View { TextField("Placeholder", text: $myObject.text) } }
@EnvironmentObject - я бы назвал это точкой входа в приложение, если вы знакомы с различными фрейворками, работой с Docker и т.д., вам хорошо знаком файл .env (переменные окружения), так вот @EnvironmentObject по своей сути и есть объект окружения, но со своим методами и содержанием, который может так же содержать объекты. Объявляется в начале приложения и передается посредством модификатора главному представлению.
@main struct myApp: App { @StateObject var myObject: MyClass = MyClass() var body: some Scene { WindowGroup { MyView() .environmentObject(myObject) } } }
Это позволяет использовать его во всех представлениях, просто указав его наличие.
struct MyView: View { @EnvironmentObject var myObject: MyClass var body: some View { Text(myObject.text) Text(myObject.title) MySecondView() } } struct MySecondView: View { @EnvironmentObject var myObject: MyClass var body: some View { TextField("Placeholder", text: $myObject.text) } }
Что с одной стороны удобно, но может вызвать ряд неудобств т.к. @EnvironmentObject (среда) передается после инициализации структуры, следовательно использовать init() у Вас не получится (будет ошибка).
.onAppear модификатор - иногда необходимо сделать какие-либо действия в момент инициализации представления и очевидным решением будет внедрение функционала в init()
struct MyView: View { @StateObject var myObject: MyClass = MyClass() var body: some View { Text(myObject.text) Text(myObject.title) MySecondView(myObject) } } struct MySecondView: View { @ObservedObject var myObject : MyClass func hello() { self.myObject.text = "hello" } init(_ myObject: MyClass){ self.myObject = myObject self.hello() } var body: some View { TextField("Placeholder", text: $myObject.text) } }
но как мы выяснили ранее, использование @EnvironmentObject блокирует эту возможность, onAppear как раз таки решает эту проблему
struct MyView: View { @StateObject var myObject: MyClass = MyClass() var body: some View { Text(myObject.text) Text(myObject.title) MySecondView(myObject: myObject) } } struct MySecondView: View { @ObservedObject var myObject : MyClass func hello() { self.myObject.text = "hello" } var body: some View { TextField("Placeholder", text: $myObject.text) .onAppear{ self.hello() } } }
Код заключенный в модификатор будет выполнен в момент появления представления на экране, что бывает более удобно нежели работа с инициализатором, который выполняется в момент инициализации и в целом может здорово повысить производительность. Конечно следует учитывать отсутствие возможности задавать свои имена при передачи аргументов и необходимость гарантировать появление.
.onDisappear модификатор - противоположность onAppear, срабатывает когда представление покидает экран.
Модификаторы - средство стилизации/изменения представлений, можно писать свои посредством расширения View, рассказывать особо нечего, если вам тяжело понять как они работают, посмотрите на следующий листинг
struct MySecondView: View { @ObservedObject var myObject : MyClass func hello() { self.myObject.text = "hello" } var body: some View { TextField("Placeholder", text: $myObject.text) .onAppear{ self.myObject .clearText() .clearTitle(){ print("Заголовок очищен") } } } } class MyClass: ObservableObject { @Published var title: String = "cell" @Published var text: String = "cell" public func clearText() -> MyClass { self.text = "d" return self } public func clearTitle(_ closure: @escaping () -> Void) -> MyClass { self.title = "d" return self } }
собственно, наглядный пример, который позволяет понять принцип их работы.
Нюансы SwiftUI
TableView
Первый кровопийца в моем списке пожирателей нервной системы. Самый простой и популярный способ представить полноэкранный просмотр чего либо, достаточно применить один модификатор.
TabView { ... Content() Content() ... } .tabViewStyle(.page(indexDisplayMode: .never))
Он манит своей легкостью в использовании, забейте в Google "PageView SwiftUI" и найдете десятки вариаций использования TabView, но по факту подобная реализация от лукавого. Если мы добавим всего пару сотен пустых представлений с цветом, все начинает лагать даже на iPhone 13 Pro
struct MyView: View { var body: some View { TabView { ForEach((1...100).reversed(), id: \.self) { _ in Color.gray Color.green } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) } }
Когда речь идет о тысячах, речи о производительности и быть не может. С помощью в туториале от Apple, который упоминался выше, я нашел для себя вариант решения проблемы, об этом ниже, когда буду разбирать PageView.
DispatchQueue
Все изменения в ObservableObject объектах, необходимо производить в главном потоке т.к. SwiftUI подобен UIKit, где вносить изменения в представления разрешено только в главном потоке. У Вас конечно получится и вне, жесткого ограничения нет, но к каким результатам это приведет неизвестно. Делается очень просто.
... func myMethod(){ let cell: Int = 10 let result: Int = cell * 20 DispatchQueue.main.async { self.property = result } } ...
Combine
Сейчас в нем не предусмотрена реализация URLSession.shared.downloadTask, поэтому не нужно искать легких путей и использовать для загрузки файлов предусмотренный dataTask, согласно документации Apple его надлежит применять исключительно для загрузки небольшого количества данных. Файлы к этому конечно же не относятся. Я понимаю, что он манит своей простотой, но тем не менее, это крайне негативно сказывается на производительности. Пример реализации будет ниже в WebImage.
PageView
Apple позволят использовать компоненты других фреймворков в SwiftUI и решение вполне себе очевидно. Использовать UIViewControllerRepresentable
struct PageView: UIViewControllerRepresentable { @ObservedObject var store: Store func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIPageViewController { let pageViewController = UIPageViewController( transitionStyle: .scroll, navigationOrientation: .horizontal) pageViewController.dataSource = context.coordinator pageViewController.delegate = context.coordinator return pageViewController } func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) { pageViewController.setViewControllers( [context.coordinator.controllers[store.currentPage]], direction: .forward, animated: true) } class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { var parent: PageView var controllers = [UIViewController]() init(_ pageViewController: PageView) { parent = pageViewController controllers = parent.store.arrayPages.map { UIHostingController(rootView: $0) } } func pageViewController( _ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { guard let index = controllers.firstIndex(of: viewController) else { return nil } if index == 0 { return controllers.last } return controllers[index - 1] } func pageViewController( _ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { guard let index = controllers.firstIndex(of: viewController) else { return nil } if index + 1 == controllers.count { return controllers.first } return controllers[index + 1] } func pageViewController( _ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { if completed, let visibleViewController = pageViewController.viewControllers?.first, let index = controllers.firstIndex(of: visibleViewController) { parent.store.currentPage = index } } } }
Я думаю простейшая реализация не требует каких либо комментариев, как вы можете заметить, мы без проблем можем использовать @StateObject, @EnvironmentObject и прочие плюшки SwiftUI, пример использования
struct MyView: View { @StateObject var store: Store = Store() var body: some View { PageView(store: store) } } class Store: ObservableObject { @Published var currentPage: Int = 0 var arrayPages : [Color] { var result : [Color] = [] for _ in 0...1000 { result.append(Color.green) result.append(Color.gray) } return result } }
Пример примитивный, но думаю раскрывает возможности, можете скопировать код в пустой проект и побаловаться, нет намека на лаги даже на винтажном iPhone 6s. Никто не мешает передавать вам готовый массив или получать его где либо еще, передавать отдельный счетчик страниц и т.д. В приложении я использовал @StateObject т.к. в моей архитектуре это удобно. В листинге просто пример, не стоит использовать его как руководство.
WebImage
SwiftUI не имеет встроенных (не имел до выхода iOS 15) средств для загрузки изображений из интернета и по сей день не имеет средств для их загрузки с последующим кэшированием/сохранением. Ниже я приведу пример кода, который покажет на простом примере, как качать изображения из сети, а затем их использовать. ПОЖАЛУЙСТА не используйте это в своих проектах, пример очень сильно упрощен для наглядности и выполнен не правильно, выполнен в рамках структуры (классы - ссылочный тип, структуры копируемый) и негативно влияет на ОЗУ, не выносит операции в поток утилит и т.д. создан исключительно для ознакомления и вашей возможности побаловаться. Поэтому если решение Вам понравится, разберите каждую строчку кода и выполните реализацию самостоятельно и правильно.
Для начала создадим структуру
struct WebImage: View { let url: String var body: some View { ... } }
Нам нужно будет передавать в нее ссылку
struct MyView: View { @StateObject var store: Store = Store() var body: some View { WebImage(url: "https://mysite.example/image.jpg") } }
Так как мы планируем хранить изображение в памяти нам нужен метод, который переделает ссылку в имя файла.
func urlToNameFile(url: String) -> String { var result: String result = url.replacingOccurrences(of: "http://", with: "domen-", options: .literal, range: nil) result = result.replacingOccurrences(of: "https://", with: "domen-", options: .literal, range: nil) result = result.replacingOccurrences(of: "/", with: "-", options: .literal, range: nil) result = result.replacingOccurrences(of: ".ru", with: "-", options: .literal, range: nil) return result }
Создадим метод проверки наличия файла/директории
func checkPath(url: URL) -> Bool { let manager = FileManager.default return manager.fileExists(atPath: url.path) }
Создадим промежуточное вычисляемое свойство, содержащее путь к папке с кэшем
@State private var cacheFolderCell: URL? = nil
Затем нам нужен метод возвращающий путь к папке с кэшем
func getPathCacheFolder() -> URL? { let manager = FileManager.default let defaultPathsSearch: URL do { defaultPathsSearch = try manager.url( for: FileManager.SearchPathDirectory.cachesDirectory, in: FileManager.SearchPathDomainMask.userDomainMask, appropriateFor: nil, create: true) } catch { return nil } let cacheFolder = defaultPathsSearch.appendingPathComponent("image") if !checkPath(url: cacheFolder) { do { try manager.createDirectory(at: cacheFolder, withIntermediateDirectories: false, attributes: nil) } catch {return nil } } self.cacheFolderCell = cacheFolder return cacheFolder }
В методе мы находим папку .cache, затем создаем папку image и возвращаем путь к ней в виде URL
Далее, так как мы все таки делаем пример, создадим страшную вещь.
var cacheFolder: URL { if let directory = self.cacheFolderCell { return directory } else { return self.getPathCacheFolder()! } }
В общем если что-либо пойдет не так, приложение крашнется) Не делайте так. Мы получаем удобный доступ к опциональному значению через явное извлечении. Можно конечно просто «!» ставить и избежать этого обходного пути, но разве это интересно? Продолжим.
Заводим флаг загрузки.
@State var isLoadedImage: Bool = false
Реализуем метод извлечения изображения из Data
func getImage(fileName: String) -> Image? { let imagePath = cacheFolder.appendingPathComponent(fileName) guard checkPath(url: imagePath) else {return nil} guard let nsData = NSData(contentsOfFile: imagePath.path) else { return nil } let data = Data(referencing: nsData) guard let uiImage = UIImage(data: data) else { return nil } return Image(uiImage: uiImage) }
Создание свойства с изображением и для прогресса загрузки.
@State var image: Image? = nil @State var progressCell: NSKeyValueObservation? = nil @State var progress: Double = 0
И теперь когда у нас есть, все что необходимо - реализуем загрузку изображения.
func downloadImage(){ if isLoadedImage { return } let nameImage = self.urlToNameFile(url: self.url) if let image = getImage(fileName: nameImage) { self.image = image self.isLoadedImage = true } else { guard let valideUrl = URL(string: url) else { return } let session = URLSession.shared let task = session.downloadTask(with: valideUrl){ location, response, error in progressCell?.invalidate() if let _ = error { //делаем что-нибудь return } guard let localUrl = location else { return } let fileUrl = cacheFolder.appendingPathComponent(nameImage) if !checkPath(url: fileUrl) { do { try FileManager.default.moveItem(atPath: localUrl.path, toPath: fileUrl.path) } catch { return } } guard let image = getImage(fileName: nameImage) else { return } self.image = image self.isLoadedImage = true } self.progressCell = task.progress.observe(\.fractionCompleted) { progress, _ in self.progress = progress.fractionCompleted } } }
Реализуем в body отображение
var body: some View { VStack{ if image != nil { image! .resizable() .aspectRatio(contentMode: .fill) } else { Image(systemName: "photo") .resizable() .aspectRatio(contentMode: .fill) } } .onAppear { self.downloadImage() } }
В совокупности должен получится вот такой код (c вашей ссылкой на изображение)
struct MyView: View { @StateObject var store: Store = Store() var body: some View { WebImage(url: "https://mysite.example/image.jpg") } } struct WebImage: View { let url: String @State private var cacheFolderCell: URL? = nil var cacheFolder: URL { if let directory = self.cacheFolderCell { return directory } else { return self.getPathCacheFolder()! } } @State var isLoadedImage: Bool = false @State var image: Image? = nil @State var progressCell: NSKeyValueObservation? = nil @State var progress: Double = 0 var body: some View { VStack{ if image != nil { image! .resizable() .aspectRatio(contentMode: .fill) } else { Image(systemName: "photo") .resizable() .aspectRatio(contentMode: .fill) } } .onAppear { self.downloadImage() } } func getImage(fileName: String) -> Image? { let imagePath = cacheFolder.appendingPathComponent(fileName) guard checkPath(url: imagePath) else {return nil} guard let nsData = NSData(contentsOfFile: imagePath.path) else { return nil } let data = Data(referencing: nsData) guard let uiImage = UIImage(data: data) else { return nil } return Image(uiImage: uiImage) } func downloadImage(){ if isLoadedImage { return } let nameImage = self.urlToNameFile(url: self.url) if let image = getImage(fileName: nameImage) { self.image = image self.isLoadedImage = true } else { guard let valideUrl = URL(string: url) else { return } let session = URLSession.shared let task = session.downloadTask(with: valideUrl){ location, response, error in progressCell?.invalidate() if let _ = error { //делаем что-нибудь return } guard let localUrl = location else { return } let fileUrl = cacheFolder.appendingPathComponent(nameImage) if !checkPath(url: fileUrl) { do { try FileManager.default.moveItem(atPath: localUrl.path, toPath: fileUrl.path) } catch { return } } guard let image = getImage(fileName: nameImage) else { return } self.image = image self.isLoadedImage = true } self.progressCell = task.progress.observe(\.fractionCompleted) { progress, _ in self.progress = progress.fractionCompleted } task.resume() session.finishTasksAndInvalidate() } } func urlToNameFile(url: String) -> String { var result: String result = url.replacingOccurrences(of: "http://", with: "domen-", options: .literal, range: nil) result = result.replacingOccurrences(of: "https://", with: "domen-", options: .literal, range: nil) result = result.replacingOccurrences(of: "/", with: "-", options: .literal, range: nil) result = result.replacingOccurrences(of: ".ru", with: "-", options: .literal, range: nil) return result } func checkPath(url: URL) -> Bool { let manager = FileManager.default return manager.fileExists(atPath: url.path) } func getPathCacheFolder() -> URL? { let manager = FileManager.default let defaultPathsSearch: URL do { defaultPathsSearch = try manager.url( for: FileManager.SearchPathDirectory.cachesDirectory, in: FileManager.SearchPathDomainMask.userDomainMask, appropriateFor: nil, create: true) } catch { return nil } let cacheFolder = defaultPathsSearch.appendingPathComponent("image") if !checkPath(url: cacheFolder) { do { try manager.createDirectory(at: cacheFolder, withIntermediateDirectories: false, attributes: nil) } catch {return nil } } self.cacheFolderCell = cacheFolder return cacheFolder } }
Но повторюсь, подобные вещи, таким способом не реализуются. Сюда нужно сделать загрузку в NSCache и извлечение из него (об этом информации достаточно), разбить все это на сервисы и т.д. реализовать множество различных проверок. Моя же задача была предоставить пример.
Собственно по теме текущего материала я предоставил все, что хотел. По мере возможности постараюсь выложить изолированный сервис контейнер для загрузки изображений, вне контекста, но это уже другая история.
Если есть что добавить, или исправить — добро пожаловать в комментарии.
