October 18, 2024

89. Task + [weak self]

Swift Concurrency все еще преподносит сюрпризы ребятам, привыкшим к GCD и слабым ссылкам в замыканиях. Покажу простой пример, почему можно не ослаблять ссылку в Task.

План

  1. Сделаем класс, который будет символизировать сервис, делающий важную работу для экрана (можно назвать вьюмоделью).
  2. Реализуем в классе 2 метода для задачи, оба будут использовать async/await, при этом один будет создавать внутри таск и ослаблять self, а другой будет использовать проверку на отмену Task. В ходе выполнения Task добавим ожидание в 3 секунды для имитации долгой работы и расставим принты для начала и окончания Task, чтобы точно понимать, завершился Task или нет. Для проверки сайд-эффектов будем задавать случайное число в приватное свойство класса.
  3. Сверстаем UI для демонстрации: смоделируем переход на новый экран, у которого при появлении вызываются оба метода вьюмодели одновременно.
  4. Протестируем реализацию, чтобы узнать будет ли изменяться приватное свойство класса при закрытии экрана раньше времени (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, в частности акторы - возможно как-нибудь в другой раз.

Код для этой статьи можно посмотреть тут, другие статьи по разработке - тут, а про инвестиции - тут.