89. Task + [weak self]
Swift Concurrency все еще преподносит сюрпризы ребятам, привыкшим к GCD и слабым ссылкам в замыканиях. Покажу простой пример, почему можно не ослаблять ссылку в Task.
План
- Сделаем класс, который будет символизировать сервис, делающий важную работу для экрана (можно назвать вьюмоделью).
- Реализуем в классе 2 метода для задачи, оба будут использовать
async/await, при этом один будет создавать внутри таск и ослаблятьself, а другой будет использовать проверку на отменуTask. В ходе выполненияTaskдобавим ожидание в 3 секунды для имитации долгой работы и расставим принты для начала и окончанияTask, чтобы точно понимать, завершилсяTaskили нет. Для проверки сайд-эффектов будем задавать случайное число в приватное свойство класса. - Сверстаем UI для демонстрации: смоделируем переход на новый экран, у которого при появлении вызываются оба метода вьюмодели одновременно.
- Протестируем реализацию, чтобы узнать будет ли изменяться приватное свойство класса при закрытии экрана раньше времени (3 сек).
Реализация
Класс-вьюмодель
final class DemoTaskViewModel {
private var demoInt = 0
func runTask() {
Task { [weak self] in // <- ослабляем селф
print("стартовали таск, int = \(self?.demoInt)")
try? await Task.sleep(nanoseconds: 3000000000)
let randomInt = Int.random(in: 0...100000)
self?.demoInt = randomInt
print("таск завершился, int = \(self?.demoInt)")
}
}
func runAsyncTask() async {
print("стартовали async-таск, int = \(demoInt)")
try? await Task.sleep(nanoseconds: 3000000000)
guard !Task.isCancelled else { return } // <- проверяем на отмену
let randomInt = Int.random(in: 0...100000)
demoInt = randomInt
print("async-таск завершился, int = \(demoInt)")
}
}
По логике подхода GCD может показаться, что первый метод не должен завершиться, если мы быстро закроем экран, потому что ссылка на self слабая, а второй метод и вовсе захватывает self сильно, и там могут быть проблемы. Скоро все увидим.
Демо экран
struct DemoTaskRootView: View {
var body: some View {
NavigationView {
NavigationLink(destination: DemoTaskChildView()) {
Text("Вперед в SwiftUI")
}
}
}
}
Ничего хитрого: просто контейнер для навигации и переход на следующий экран:
struct DemoTaskChildView: View {
private let vm = DemoTaskViewModel()
var body: some View {
Text("Контент")
.onAppear(perform: vm.runTask)
.task { await vm.runAsyncTask() }
}
}
Просто вызываем оба метода вьюмодели, при этом один внутри .onAppear, т.к. там обычный метод, не async, а другой в модификаторе .task, который предназначен для async-методов.
Тестируем реализацию
Запускаем превью и нажимаем на кнопку "Вперед в SwiftUI", а потом сразу закрываем экран кнопкой "назад" или свайпом, и видим такие принты в консоли:
стартовали таск, int = Optional(0) стартовали async-таск, int = 0 таск завершился, int = Optional(70570)
В случае с первым методом принт выводит Optional, потому что у нас ослаблен self, а мы не делали разворачивание через guard let self, но это некритично для нашего примера.
Самое важное: мы видим, что async-Task не завершился, и не создал нам сайд-эффект, т.е. мы не сделали лишнюю работу, которую планировали предотвратить при закрытии экрана.
Бонус
Если вам кажется, что в UIViewController наш Task отработал бы по-другому, то давайте создадим такой пример и посмотрим.
Верстаем экран и обертку
final class DemoTaskVC: UIViewController {
private let vm = DemoTaskViewModel()
private var demoTask: Task<Void, Never>? // <- храним ссылку для отмены таска из второго метода
override func viewDidLoad() {
super.viewDidLoad()
vm.runTask()
demoTask = Task {
await vm.runAsyncTask()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
demoTask?.cancel() // <- отменяем таск из второго метода
}
}
struct DemoHost: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> some UIViewController {
DemoTaskVC()
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}
Подставляем новый экран в наш пример
struct DemoTaskRootView: View {
var body: some View {
NavigationView {
NavigationLink(destination: DemoHost()) {
Text("Вперед в UIKit")
}
}
}
}
Тестируем
Запускаем превью и нажимаем на кнопку "Вперед в UIKit", а потом сразу закрываем экран кнопкой "назад" или свайпом, и видим такие принты в консоли:
стартовали таск, int = Optional(0) стартовали async-таск, int = 0 таск завершился, int = Optional(79481)
Получили тот же результат: Task с ослаблением self работает до победного, даже если экран уже закрыли, а Task с проверкой на отмену завершился при отмене и не выполнил лишнюю работу.
Заключение
Swift Concurrency классный инструмент, позволяющий нам меньше переживать про ослабление self. Документацию можно почитать тут и тут, а лучше всего попрактиковаться на пет-проектах, чтобы набить руку перед использованием в рабочих проектах.
Не забывайте отменять таск при необходимости 👌
В этой статье мы не разбирали проблемы Concurrency, в частности акторы - возможно как-нибудь в другой раз.
Код для этой статьи можно посмотреть тут, другие статьи по разработке - тут, а про инвестиции - тут.