Как стать автором
Обновить

[Async/await] Проблемы Swift 5.4

Время на прочтение4 мин
Количество просмотров3.5K

Всем привет! Меня зовут Никита, я работаю в компании Технократия и занимаюсь iOS-разработкой. С сегодняшнего дня мы начинаем регулярный выпуск статей, в которых я буду рассказывать о современном подходе к написанию асинхронного кода в Swift. 

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

Введение

Со времен iOS 2 разработчикам было предложено работать с NSOperation и NSOperationQueue для реализации асинхронных задач. Данная библиотека давала возможность выстраивать цепочку задач и исполнять их на абстрактных очередях, инкапсулирующих работу с потоками. 

В 2009 году Apple представила новую библиотеку - Grand Central Dispatch (GCD), которая также работала с очередями, но была легче в использовании, в связи с уходом от ООП концепции по работе с задачами. Их стало легче создавать, запускать, но стало сложнее выстраивать логику и взаимосвязь между ними.

Шли годы, никаких новых технологий по работе с многопоточность Apple не представляла, если не считать Combine, представленный в 2019 году. Однако в прошлом году Apple представила новую концепцию по работе с асинхронным кодом, которая обещает увеличить производительность, упростить написание кода и снизить порог входа новых разработчиков.

Недостатки Swift ниже 5.5

Прежде чем знакомиться с новой технологией давайте разберёмся с недостатками текущих решений. Начнем по порядку: 

1. Pyramid of doom 🥲

Разберем небольшой пример, который иллюстрирует данную проблему.

struct User { }

func saveUserToCoreData(user: User, completion: @escaping (User) -> Void) { }
func loadUserFromNetworkFromNetwork(for id: String, completion: @escaping (User) -> Void) { }

У нас есть структура User, которая хранит данные о пользователе, а также реализованы 2 метода:

  1. saveUserToCoreData - сохраняет пользователя в CoreData.

  2. loadUserFromNetwork - загружает данные о пользователе из сети

Последние две метода принимают замыкание, которое вызовется по завершению их работы. 

Нам необходимо реализовать метод, который сначала скачает данные, потом сохранит их в CoreData, а после передаст сохраненного пользователя в замыкание. 

Решение будет выглядеть так:

func fetchUserData(for id: String, completion: @escaping ((User) -> Void)) {
    loadUserDataFromNetwork(for: id) { user in
        saveToCoreData(user: user) { savedUser in
            completion(savedUser)
        }
    }
}

let userID = "testID"
fetchUserData(for: userID) {
    print("User is \($0))")
}

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

2. Error handling

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

Давайте попробуем решить данный вопрос и добавим в наш код возможность обрабатывать ошибки. Для этого в замыкания будем передавать тип Result - который хранит либо данные, либо ошибку. Обновленные методы выглядят так:

func saveUserToCoreDataWithError(data: User, completion: @escaping (Result<User, Error>) -> Void) { }
func loadUserFromNetworkWithError(for id: String, completion: @escaping (Result<User, Error>) -> Void) { }

Теперь наши методы работают с Result, необходимо актуализировать метод

func fetchUserDataWithError(for id: String, completion: @escaping ((Result<User, Error>) -> Void)) {
    loadUserFromNetworkWithError(for: id) { result in
        do {
            saveUserToCoreDataWithError(data: try result.get()) { savedResult in
                guard let savedUser = try? savedResult.get() else { return }
                completion(.success(savedUser))
            }
        } catch {
            // completion(.failure(_)) -  Обработка ошибки
        }
    }
}

В таком случае вложенность еще увеличилась и код стал трудным для восприятия. Также к недостатку можно отнести сам принцип обработки ошибки - он работает благодаря тому, что мы внедрили дополнительный тип данных Result, хотя в Swift уже есть try-catch блоки для обработки ошибок в синхронных функциях. 

3. Many mistakes are easy to make 😣

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

guard let savedUser = try? savedResult.get() else { return } 

В случае, если данные пользователя не получилось сохранить, то мы не выполним completion блок и логика работы нашей программы будет нарушена.  
Поэтому здесь перед return необходимо вернуть .failure в  completion блоке.

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

4. Недостатки библиотек от Apple 😧

Однако помимо проблем у языка Swift, они также присутствуют в представленных Apple библиотеках, а именно: 

GCD

  1. Задачу, поставленную в очередь, трудно отменить, только если не костылять с DispatchWorkItem.

  2. При множественном вызове .sync метода у concurrent очереди может произойти создание большого количества потоков, что приведет к крэшу приложения. Такая проблема называется Thread Explosion.

  3. Сложно выстраивать цепочки из задач, делить ресурсы.

NSOperation

Их необходимо наследовать от NSOperation, переопределять методы и выносить всю логику в отдельную сущность, что ведет к написанию большого количества кода.

  1. Возможна циклическая зависимость задач, что приведет к deadlock.

  2. Трудно написать асинхронную задачу.

5. Работа с потоками

Так или иначе наш код сейчас взаимодействует с потоками. Мы можем работать с ними вручную, либо использовать библиотеки, которые просто инкапсулируют работу с ними. Однако у самой модели потоков есть ряд недостатков: 

  1. У каждого Thread есть свой стек, что дает минус по памяти.

  2. Переключение между ними занимает время. 

  3. Возможна проблема “Thread explosion” - когда  система не справляется с большим количеством потоков и не может грамотно и эффективно управлять ими -> мы получаем крэш.

Выводы

Таким образом, мы вспомнили основные недостатки написания асинхронного кода в Swift ниже 5.5, а также основные недостатки текущих библиотек от Apple.

В следующих частях мы ознакомимся с новой для языка Swift концепцией асинхронного программирования, ее синтаксисом и возможностями.


Также подписывайтесь на наш телеграм-канал «Голос Технократии». Каждое утро мы публикуем новостной дайджест из мира ИТ, а по вечерам делимся интересными и полезными мастридами.

Теги:
Хабы:
-6
Комментарии1

Публикации

Истории

Работа

iOS разработчик
23 вакансии
Swift разработчик
32 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн