2. Task(Detached) техническое определение
Изучение данного блока предполагает предварительное знание синтаксиса языка Swift. Для успешного освоения этого материала, необходимо иметь базовое понимание синтаксиса языка Swift. Это включает в себя знание основных структур данных, операторов, циклов, функций, абстракций и других ключевых элементов языка. Без этих фундаментальных знаний будет сложно понять более сложные концепции и примеры, которые будут рассматриваться в данном блоке.
Мы уже познакомились с определением задач, примерно понимаем чем и как они отличаются, теперь начнем погружаться уже в технические детали и будем на примерах изучать Task.
Создание задачи¶
Для тех, кто знаком с GCD: Не стоит воспринимать создание задачи как аналог DispatchQueue.global().async - это совсем не так. Это не просто перенос работы на бекграунд поток - здесь наследуется контекст и не всегда происходит переход на бекграунд поток, поэтому иногда тяжёлые задачи могут попасть на главный поток и привести к фризам.
В Structured Concurrency eсть 2 способа создать задачу:
1. Создание родительской(Detached) таски.¶
/// Создаётся задача верхнего уровня, которая выкидывается из текущей иерархии задач,
/// Ответственность за задачу (отмену, локальное хранилище) будет лежать на разработчике
/// - Parameters:
/// - priority: приоритет задачи, по умолчанию - medium
/// - operation: код, который будет выполняться в таске
Task.detached(priority: TaskPriority?, operation: () async -> _)
Рассмотрим пример:
Обработка нажатия на кнопку в контроллере
final class ViewController: UIViewController {
@objc
private func handleTapOnButton(_ sender: UIButton) {
/// Здесь главный поток, на котором мы можем например показать спиннер
showLoadingIndicator()
/// Здесь всё также главной поток и мы начинаем скачивать картинку
fetchImage(by: 1)
}
func fetchImage(by id: Int) {
/// Создание родительской задачи, необходимо для того, чтобы уйти
/// с главного исполнителя на Глобальный, чтобы не перегружать его
let task = Task.detached {
/// Внутри уже будет не главный исполнитель - а другой
}
}
}
2. Создание дочерней задачи. Task.¶
/// Создаётся задача, которая укладывается в текущую иерархию задач.
/// Так как задача будет находится в иерархии задач, значит она будет наследовать
/// исполнителя, его контекст, локальное хранилище.
/// - Parameters:
/// - priority: приоритет задачи, по умолчанию - medium
/// - operation: код, который будет выполняться в таске
Task(priority: TaskPriority?, operation: () async -> _)
Рассмотрим аналогичный пример:
final class ViewController: UIViewController {
@objc
private func handleTapOnButton(_ sender: UIButton) {
/// Здесь главный поток, на котором мы можем например показать спиннер
showLoadingIndicator()
/// Здесь всё также главной поток и мы начинаем скачивать картинку
fetchImage(by: 1)
}
func fetchImage(by id: Int) {
/// Создание родительской задачи, необходимо для того, чтобы уйти
/// с главного исполнителя на Глобальный, чтобы не перегружать его
let task = Task {
/// Здесь останется главный исполнитель, потому что задача наследует контекст
}
}
}
Задача с собеседования¶
Следующая задача предлагается на собеседованиях уровня Junior-Middle.
Есть два экрана. На одном контроллере есть кнопка, которая презентует TaskExampleViewController. В методе viewDidLoad, которого выполняется очень долгая задача. Не дожидаясь завершения задачи, пользователь смахивает экран вниз.
- Что выведется в консоль?
- Если что-то не так, то как это поправить?
import UIKit
final class TaskExampleViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
print("TaskExampleViewController", #function)
doHardWork()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
print("TaskExampleViewController", #function)
}
deinit {
print("TaskExampleViewController", #function)
}
private func doHardWork() {
Task {
var array = [Int]()
(0...100_000_000).forEach {
array.append($0)
}
print(array.count)
}
}
}
Ответ
- Выведется только TaskExampleViewController* viewDidLoad***, а вот дальше произойдёт фриз UI главного потока. Весь наш интерфейс заблокируется пока не завершится выполнение задачи.
- Если мы получили фриз UI, значит код внутри задачи попал на главный исполнитель и стал выполняться на главном потоке, тем самым заблокировав какое-либо изменение интерфейса. Чтобы избежать этого, необходимо использовать родительскую задачу. И тогда она не будет наследовать контекст главного исполнителя и в консоль выведутся все три строки.
После данной задачи возникает вопрос. О каком главном исполнителе идёт речь?
У нас нет помеченных функций и нигде явно не указан исполнитель.
Указание исполнителей¶
Класс/структура/протоколы¶
- Главный исполнитель
Иногда у нас существуют классы, структуры, протоколы - все методы которых должны выполняться на главном потоке, и помечать каждый метод с помощью @MainActor не хочется, если методов много - то это долго. Поэтому мы можем взять и перед сущностью написать @MainActor - что будет означать, что ВСЕ методы класса будут иметь контекст главного исполнителя.
import UIKit
@MainActor
final class MyClass { ... }
@MainActor
struct ScreenState { ... }
@MainActor
protocol ScreenDisplayLogic { ... } /// методы, содержащиеся в данном протоколе - работают с отрисовкой, поэтому им нужен главный поток.
Здесь стоит обратить внимание на то от кого наследуется наш контроллер, и какой метод мы предопределяем. Мы не будем сейчас в это детально углубляться - рассмотрим это в теме Actors.
- Глобальный исполнитель
Если же мы просто создаём класс - то он по умолчанию имеет @globalActor. Но писать его не имеет никакого смысла, поэтому он скрыт от глаз разработчика.
Поэтому ещё одним из вариантов решения задачи из собеседования будет создание какого-то сервиса. Опишем логику ниже:
/// Сервис, который будет выполнять сложные задачи
final class HardWorkService {
func doHardWork() {
Task {
var array = [Int]()
(0...100_000_000).forEach {
array.append($0)
}
print(array.count)
}
}
}
final class TaskExampleViewController: UIViewController {
let service = HardWorkService()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
print("TaskExampleViewController", #function)
doHardWork()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
print("TaskExampleViewController", #function)
}
deinit {
print("TaskExampleViewController", #function)
}
private func doHardWork() {
/// Здесь главный контекст, но когда мы обращаемся к сервису
/// и переходим в его для исполнения кода, то код метода уже будет
/// иметь исполнителя класса
service.doHardWork()
}
}
/// В этом случае на экран выведуться все 3 принта
- Создание своих Actor.
Мы можем создавать свои Actors и помечать ими класс/структуры/протоколы и другие сущности. Но к этой задаче мы подойдём в разделе Actor
Функции¶
Помимо указания исполнителя над сущностью, мы также можем указать исполнителя конкретно для каждой функции.
/// Сервис, который будет выполнять сложные задачи
final class HardWorkService {
@MainActor
func doHardWork() {
Task {
/// Здесь будет главный поток
var array = [Int]()
(0...100_000_000).forEach {
array.append($0)
}
print(array.count)
}
}
}
Итог¶
При создании задачи, нам всегда важно обращать внимание на:
- Где создаётся задача?
- Какой контекст имеет класс, в котором она находится или функция?
- Есть ли у класса родительский класс и какой у него контекст?
- При создании родительской задачи - не забывать, что ответственность по управлению задачей лежит теперь на нас.
💡 Рекомендация
При начале работы вам возможно будет сложновато следить за исполнителем задачи. Поэтому в течение разработки можно пользоваться assert(Thread.isMainThread). Ассерт даст явно вам понять, что вы вышли на главный поток там где это не нужно