Предисловие

Начну с того, что в поисках информации по 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 и извлечение из него (об этом информации достаточно), разбить все это на сервисы и т.д. реализовать множество различных проверок. Моя же задача была предоставить пример.

Собственно по теме текущего материала я предоставил все, что хотел. По мере возможности постараюсь выложить изолированный сервис контейнер для загрузки изображений, вне контекста, но это уже другая история.

Если есть что добавить, или исправить — добро пожаловать в комментарии.

Комментарии (8)


  1. ws233
    29.11.2021 09:00
    +1

    Но ведь NSURLSession уже умеет работать с NSURLCache. И на самом деле городить свое собственное кеширование смысла нет. Достаточно правильно настроить кеширование запросов сессии (с обеих сторон: клиентской и серверной) и тупо дергать каждый раз запрос -- первый раз он скачает инфу с сервера, дальше будет брать из кеша, пока этот Кеш не почистится системой или не инвалидируется. просто и легко.


    1. cbepxbeo Автор
      01.12.2021 09:09
      +1

      В описанном вами варианте используется dataTask, а не downloadTask. Apple же обозначает dataTask как средство загрузки небольшого кол-ва данных.


      1. ws233
        01.12.2021 15:06
        +1

        Хорошее пояснение, спасибо. А есть у Вас ссылки соответсвующие? И еще вопрос, а где-то указан этот барьер между небольшим и большим количеством данных? Бывают же и джейсоны на пару мегабайт, и картинки на сотни или даже десятки килобайт. Т.е.похоже, что тут не столько на размер надо обращать внимание, сколько на применимость как раз. Т.е.если нужен Кеш, пожалуйста, бери себе dataTask, нет? Ну, и если я буду использовать dataTask, а не downloadTask, Apple может зареджектить? Есть у вас чуть больше инфы на эту тему? Сам хочу разобраться.


        1. cbepxbeo Автор
          01.12.2021 21:21
          +1

          Ух, вопрос у Вас конечно интересный. Почитать о позиции Apple по этому поводу можете вот здесь - Тыц

          Чтобы понять допустимый объем данных, я думаю можно с другой стороны зайти т.к. ответа(официального) насколько я знаю нет. В dataTask (NSURLSessionDataTask) обмен идет через NSDAta, данные соответственно отправляются и загружаются с помощью объектов NSData и все данные сохраняются в оперативку, соответственно в фоновом режиме с ними работать нельзя (закрыли приложение, загрузка прекратилась), а downloadTask (NSURLSessionDownloadTask) пишет сразу на диск, может работать в фоне и работа ведется с файлами, что по моему мнению удобнее и производительнее. Например многопоточная загрузка 100+ изображений, весом в 20+ мб, через dataTask при неправильном проектировании, вполне может положить приложение (не проверял, но думаю это вполне возможно), а downloadTask ограничен только объемом диска по сути. Ну и в целом алгоритм мне более приятен. Я качаю на диск и получаю данные когда надо, память у Apple достаточно быстрая, проблем возникнуть не должно, параллельно/последовательно могу подконтрольно помещать данные в NSCache (тот который очищается при закрытии и самоочищается при необходимости), быстро, доступно, удобно да и вопрос с хранением решается сам по себе. А dataTask дает мне данные в оперативку (из них конечно я извлеку их быстрее), но теперь мне нужно это сохранить и даже если использовать автоматическое сохранение, процесс сохранения так или иначе присутствует. Понимаете к чему я клоню?) С downloadTask, я решаю, когда и что помещать в оперативку и получаю данные на диск, а dataTask, однозначно поместит данные в оперативку и лишает меня этого выбора.

          В общем для коротких запросов, я использую dataTask и сохраняю в NSObject с последующим сохранением класса в UserDefault целиком (до 500кб идеально), например список тех же самых изображений. А все изображения, независимо от размера я качаю downloadTask. Плюс учитывайте, что SwiftUI и Image, не умеют в Data и так или иначе приходится сначала получать UIImage, а уже из него Image. Так что загрузка для меня приоритетнее.


          1. ws233
            02.12.2021 09:13
            +1

            Снова спасибо. Опять хороший ответ.

            Итак, зафиксируемся:
            1. dataTask умеет кешировать запросы из коробки
            2. dataTask можно использовать для загрузки небольших изображений. Нигде явного лимита не указано. И лимит этот косвенно можно выявить из других признаков и рассуждений (хоть и не тех, что вы привели, по моему мнению, но об этом уже не будем).
            вы с этим согласны.

            Но вы опасаетесь, что dataTask сохраняет ВСЮ загруженную информацию сразу в память, что по Вашему мнению может привести к закрытию приложения системой. Вы, видимо, зацепились за первое предложение из вот этой ссылки, что сами и предоставили.

            Ок, давайте разбираться.

            Мы же с Вами уже разобрались, что dataTask кешурет данные. Т.е.они все же складываются на диск. А раз они складываются на диск, то с диска же они и читаются в оперативную память. Так? Уверен, что так.

            Давайте теперь разберемся, а как же они читаются? А читаются они стандартным для iOS способом, применимым повсюду. Через NSData. В этом можно убедиться в конкретном примере, посмотрев тип данных первого параметра замыкания, вызываемого в результате выполнения dataTask.

            Что ж открываем документацию, читаем:

            The size of the data is subject to a theoretical limit of about 8 exabytes (1 EB = 10¹⁸ bytes; in practice, the limit should not be a factor).

            Переведу и поясню. Теоретически, размер буфера в NSData ограничен 10ю в 18й степени (безумное число), а практически, вы не должны заботиться об этом лимите. Все работает для вас с такими объемами.

            Как Вы думаете, как Apple удалось такого достичь на мобильных телефонах с гораздо-гораздо меньшим объемом оперативной памяти?

            Все верно! Буфер NSData хранится на диске, а не в оперативной памяти. Это очень легко проверить, открыв какой-нить фильмец на пару гигабайт размером через NSData.

            Таким образом, первое предложение, за которое вы зацепились, значит не что иное, как то, что данные вам возвращаются в виде объекта NSData, с которым вы можете работать, как-будто они находятся в оперативной памяти. Вам просто не нужно это делать самостоятельно, как если бы вы работали с downloadTask.

            Что же касается замечания о том, что надо конструировать UIImage. Так он (как и Image в Свифт) работает поверх NSData все с тем же механизмом. Я это четко осознал, когда бил на тайлы 100МБ jpeg военную карту Москвы на 4S. Просто прикиньте, какой размер эта карта занимает попиксельно после декодирования jpeg. И тем не менее данная карта прекрасно отображалась на экранчике мобильного телефона. Правда, плохо масштабировалась, что я позже и решал тайлингом.

            Таким образом, ваши опасения о том, что dataTask истратит всю вашу оперативную память, выглядят сомнительно. В Apple не глупые люди сидят и все для вас они уже сделали. Смысла повторять их работу я пока не увидел.

            Есть еще аргументы для меня и сообщества в пользу ручного кеширования и повторения работы Apple самостоятельно вместо того, чтобы взять коробочное решение?


            1. cbepxbeo Автор
              02.12.2021 09:48
              +1

              Спасибо за развернутый ответ столь исключительного качества. Вы дали мне много пищи для ума и признаться, я открыл для себя много нового, углубившись в изучение этого вопроса.

              Из объективных преимуществ, могу выделить только возможность производить загрузку в фоне.

              Предположительное преимущество, я его не проверял, но обязательно проверю в будущем. Я склонен полагать, что dataTask, все же кэширует по следующей модели сеть -> озу -> диск, в то время, как downloadTask безоговорочно качает на диск мимо озу, это я проверял и приложение выдержало мои безумные тесты без каких либо проблем.

              Мое субъективное преимущество для меня лично - присутствие контроля.

              Это знаете, как коробка автомат и механика, вопрос предпочтений. Мне нравится контролировать все этапы, downloadTask дает мне больше возможностей в этом плане, но это скорее вопрос психологического комфорта.

              С Вами в целом согласен, коробочное решение от Apple чаще предпочтительнее нежели велосипед, тут и обсуждать нечего. Но я Вам и не доказываю, что мой вариант лучше, а мотивирую свой выбор в его пользу.

              Apple очень круто подходят к проектированию, но я часто предпочитаю свои решения, понимаю, что это может выглядеть глупо. Мотивация в том, что я адепт принципа kiss и люблю самодостаточные конкретные решения. Как пример, могу привести реализацию ожидания данных с разных запросов через didSet, и буферным свойством, которое сигнализирует о готовности (чтобы не выполнять избыточный код в наблюдаемом). Это самый быстрый и производительный способ, который мне удалось использовать в формате небольшого приложения. Понятно, что на большем проекте количество велосипедов, просто нивелируют прирост производительности, но в рамках одного конкретного, многокомпонентного запроса, это получается более быстрым решением.


              1. ws233
                02.12.2021 09:58
                +1

                Спасибо. Снова хорошие мысли.

                Вопрос контроля. Не смотрел на проблемы собственных реализаций с этой стороны. А ведь действительно. Многие могут реализовывать свои решения потому, что не понимают, как управлять тем, что есть у Apple. Что касается управления кешом через dataTask, так я, наверное, и не знаю-то, как это делается в деталях ^.^ Вы тоже натолкнули меня на хорошие мысли, спасибо за это.

                Ну, и желаю Вам опубликовать статью по итогам исследований нативного кеширования. Материала должно набраться много и очень толкового.


  1. AlexBogatyrev
    01.12.2021 09:01

    По SwiftUI можно попробовать лекции Стэнфордского университета - "CS193p (Developing Applications for iOS using SwiftUI)"