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

Timer используется для планирования действий в приложении. Это может быть разовое действие или повторяющаяся процедура.

В этом руководстве вы разберётесь, как в iOS работает таймер, как он может влиять на отзывчивость UI, как оптимизировать потребление батареи при использовании таймера и как использовать CADisplayLink для анимации.

В качестве тестового полигона мы будем использовать приложение — примитивный планировщик задач.

Начинаем


Загрузите исходный проект. Откройте его в Xcode, посмотрите его структуру, скомпилируйте и выполните. Вы увидите простейший планировщик задач:



Добавим в него новую задачу. Тапните на значке +, введите название задачи, тапните Ok.

В добавленных задачах есть метка времени. Новая задача, которую вы только что создали, отмечена нулём секунд. Как видите, это значение не увеличивается.

Каждую задачу можно отметить как выполненную. Тапните на задаче. Название задачи станет перечеркнутым и она будет помечена как выполненная.

Создаём наш первый таймер


Создадим главный таймер нашего приложения. Класс Timer, известный также как NSTimer — удобный способ запланировать действие на определённый момент, как разовое, так и периодическое.

Откройте TaskListViewController.swift и добавьте в TaskListViewController эту переменную:

var timer: Timer?

Затем там же добавьте экстеншн:

// MARK: - Timer
extension TaskListViewController {

}

И вставьте этот код в экстеншн:

@objc func updateTimer() {
  // 1
  guard let visibleRowsIndexPaths = tableView.indexPathsForVisibleRows else {
    return
  }

  for indexPath in visibleRowsIndexPaths {
    // 2
    if let cell = tableView.cellForRow(at: indexPath) as? TaskTableViewCell {
      cell.updateTime()
    }
  }
}

В этом методе мы:

  1. Проверяем, есть ли в таблице задач видимые строки.
  2. Вызываем updateTime для каждой видимой ячейки. Этот метод обновляем метку времени в ячейке (посмотрите TaskTableViewCell.swift).

Затем добавьте в экстеншн этот код:

func createTimer() {
  // 1
  if timer == nil {
    // 2
    timer = Timer.scheduledTimer(timeInterval: 1.0,
                                 target: self,
                                 selector: #selector(updateTimer),
                                 userInfo: nil,
                                 repeats: true)
  }
}

Здесь мы:

  1. Проверяем, содержит ли timer экземпляр класса Timer.
  2. Если нет, создаём таймер, который каждую секунду вызывает updateTimer().

Затем нам нужно создать таймер, как только пользователь добавляет первую задачу. Добавьте createTimer() в самом начале метода presentAlertController(_:).

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



Добавляем допуск к таймеру


Увеличение количества таймеров приводит к худшей отзывчивости UI и большему потреблению батареи. Каждый таймер пытается исполниться точно в отведённое ему время, так как по умолчанию его допуск (tolerance) равен нулю.

Добавление допуска таймера — простой способ снизить потребление энергии. Это позволяет системе выполнить действие таймера между назначенным временем и назначенным временем плюс время допуска — но никогда не ранее назначенного интервала.

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

В методе createTimer(), сразу за присвоением timer, добавьте эту строчку:

timer?.tolerance = 0.1

Запустите приложение. В этом конкретном случае эффект будет неочевиден (у нас только один таймер), однако в реальной ситуации нескольких таймеров ваши пользователи получат более отзывчивый интерфейс и приложение будет более энергоэффективным.



Таймеры в бэкграунде


Интересно, а что происходит с таймерами, когда приложение уходит в бэкграунд? Чтобы разобраться с этим, добавим этот код в самом начале метода updateTimer():

if let fireDateDescription = timer?.fireDate.description {
  print(fireDateDescription)
}

Это позволит нам отследить события таймера в консоли.

Запустите приложение, добавьте задачу. Теперь нажмите на вашем устройстве кнопку Home, а затем вернитесь к нашему приложению.

В консоли вы увидите что-то вроде этого:



Разбираемся с циклами выполнения (Run Loops)


Цикл выполнения — это цикл обработки событий, который планирует работу и занимается обработкой входящих событий. Цикл держит поток занятым, пока он работает и переводит его в «спящее» состояние, когда работы для него нет.

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

Но почему вся эта информация важна для вас сейчас? Сейчас каждый таймер запускается в главном потоке и присоединяется к циклу выполнения. Вероятно, вы в курсе, что главный поток занимается отрисовкой пользовательского интерфейса, обработкой касаний и так далее. Если главный поток чем-то занят, интерфейс вашего приложения может стать «неотзывчивым» (подвисать).

Вы обратили внимание, что временна?я метка в ячейке не обновляется, когда вы тяните table view?



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

Разбираемся с режимами цикла выполнения


Режим цикла выполнения — это набор источников ввода, таких, как касания экрана или клики мышкой, за которыми может быть установлено наблюдение и набор «наблюдателей», получающих уведомления.

В iOS есть три режима цикла выполнения:

default: обрабатываются источники ввода, которые не являются NSConnectionObjects.
common: обрабатывается набор циклов ввода, для которых вы можете определить набор источников ввода, таймеров, «наблюдателей».
tracking: обрабатывается UI приложения.

Для нашего приложения наиболее подходящим выглядит режим common. Чтобы использовать его, замените содержимое метода createTimer() следующим:

if timer == nil {
  let timer = Timer(timeInterval: 1.0,
                    target: self,
                    selector: #selector(updateTimer),
                    userInfo: nil,
                    repeats: true)
  RunLoop.current.add(timer, forMode: .common)
  timer.tolerance = 0.1
  
  self.timer = timer
}

Главное отличие от предыдущего кода состоит в том, что мы перед присваиванием таймера TaskListViewController‘а добавляем этот таймер в цикл выполнения в режиме common.

Скомпилируйте и запустите приложение.



Теперь временны?е метки ячеек обновляются даже в случае скроллинга таблицы.

Добавляем анимацию на выполнение всех задач


Теперь добавим поздравительную анимацию на выполнение пользователем всех задач — шарик будет подниматься снизу экрана до самого верха.

Добавьте эти переменные в начале TaskListViewController:


// 1
var animationTimer: Timer?
// 2
var startTime: TimeInterval?, endTime: TimeInterval?
// 3
let animationDuration = 3.0
// 4
var height: CGFloat = 0

Назначение этих переменных:

  1. хранение таймера анимации.
  2. хранение времени начала и конца анимации.
  3. продолжительность анимации.
  4. высота анимации.

Теперь добавим следующий экстешн TaskListViewController в конце файла TaskListViewController.swift:

// MARK: - Animation
extension TaskListViewController {
  func showCongratulationAnimation() {
    // 1
    height = UIScreen.main.bounds.height + balloon.frame.size.height
    // 2
    balloon.center = CGPoint(x: UIScreen.main.bounds.width / 2,
      y: height + balloon.frame.size.height / 2)
    balloon.isHidden = false

    // 3
    startTime = Date().timeIntervalSince1970
    endTime = animationDuration + startTime!

    // 4
    animationTimer = Timer.scheduledTimer(withTimeInterval: 1 / 60, 
      repeats: true) { timer in
      // TODO: Animation here
    }
  }
}

Здесь мы делаем следующее:

  • вычисляем высоту анимации, получая высоту экрана устройства
  • центрируем шарик за пределами экрана и устанавливаем его видимость
  • присваиваем время начала и конца анимации
  • стартуем таймер анимации и обновляем анимацию 60 раз в секунду

Теперь нам нужно создать собственно логику для обновления поздравительной анимации. Добавим этот код после showCongratulationAnimation():

func updateAnimation() {
  // 1
  guard
    let endTime = endTime,
    let startTime = startTime 
    else {
      return
  }

  // 2
  let now = Date().timeIntervalSince1970

  // 3
  if now >= endTime {
    animationTimer?.invalidate()
    balloon.isHidden = true
  }

  // 4
  let percentage = (now - startTime) * 100 / animationDuration
  let y = height - ((height + balloon.frame.height / 2) / 100 * 
    CGFloat(percentage))

  // 5
  balloon.center = CGPoint(x: balloon.center.x + 
    CGFloat.random(in: -0.5...0.5), y: y)
}

Что мы делаем:

  1. проверяем, что endTime и startTime присвоены
  2. сохраняем текущее время в константе
  3. удостоверяемся в том, что конечное время еще не настало. Если уже настало, обновляем таймер и прячем наш шарик
  4. вычисляем новую y-координату шарика
  5. горизонтальное расположение шарика вычисляется относительно предыдущего положения

Теперь заменим // TODO: Animation here в showCongratulationAnimation() этим кодом:

self.updateAnimation()

Теперь updateAnimation() вызывается всякий раз при событии таймера.

Ура, мы только что создали анимацию. Однако, при запуске приложения не происходит ничего нового…

Показываем анимацию


Как вы, наверно, догадались, нет ничего, чтобы «запустило» нашу новую анимацию. Чтобы сделать это, нам нужен еще один метод. Добавим этот код в экстеншн анимации TaskListViewController:

func showCongratulationsIfNeeded() {
  if taskList.filter({ !$0.completed }).count == 0 {
    showCongratulationAnimation()
  }
}

Этот метод мы будем вызывать всякий раз, когда пользователь отметит задачу выполненной, он проверяет, все ли задачи выполнены. Если да, то он вызовет showCongratulationAnimation().

В заключение, добавим вызов этого метода в конце tableView(_:didSelectRowAt:):

showCongratulationsIfNeeded()

Запустите приложение, создайте пару задач, отметьте их как выполненные — и вы увидите нашу анимацию!



Останавливаем таймер


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

Сначала создадим новый метод для остановки таймера:

func cancelTimer() {
  timer?.invalidate()
  timer = nil
}

Это обновит таймер и сбросит его в nil, чтобы мы могли правильно его создать вновь позже. invalidate() — это единственный способ удалить Timer из цикла выполнения. Цикл выполнения удалит сильную ссылку на таймер или непосредственно после вызова invalidate() или чуть позже.

Теперь заменим метод showCongratulationsIfNeeded() следующим образом:

func showCongratulationsIfNeeded() {
  if taskList.filter({ !$0.completed }).count == 0 {
    cancelTimer()
    showCongratulationAnimation()
  } else {
    createTimer()
  }
}

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

Запустите приложение.



Теперь таймер останавливается и рестартует, как надо.

CADisplayLink для плавной анимации


Timer — неидеальный выбор для управления анимацией. Вы могли заметить пропуск нескольких кадров анимации, особенно, если вы запускаете приложение в симуляторе.

Мы установили таймер на частоту 60Hz. Таким образом, таймер обновляет анимацию каждые 16 мс. Рассмотрим ситуацию внимательнее:



При использовании Timer мы не знаем точное время запуска действия. Это может случиться или в начале или в конце кадра. Скажем, таймер выполнится в середине каждого кадра (голубые точки на рисунке). Единственно, что мы знаем наверняка, что вызов будет каждые 16 мс.

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

Нам поможет CADisplayLink


CADisplayLink вызывается один раз за кадр и пытается синхронизовать реальные кадры анимации, насколько это возможно. Теперь в вашем распоряжении будут все 16 мс и iOS не сбросит ни единого кадра.

Чтобы использовать CADisplayLink, вам нужно заменить animationTimer на новый тип.

Замените этот код

var animationTimer: Timer?

на этот:

var displayLink: CADisplayLink?

Вы заменили Timer на CADisplayLink. CADisplayLink — это представление таймера, который привязан к вертикальной развёртке дисплея. Это означает, что GPU устройства приостановит работу, пока экран не сможет дальше обрабатывать команды GPU. Таким образом мы получаем плавную анимацию.

Замените этот код

var startTime: TimeInterval?, endTime: TimeInterval?

на этот:

var startTime: CFTimeInterval?, endTime: CFTimeInterval?


Вы заменили TimeInterval на CFTimeInterval, что необходимо для работы с CADisplayLink.

Замените текст метода showCongratulationAnimation() на этот:

func showCongratulationAnimation() {
  // 1
  height = UIScreen.main.bounds.height + balloon.frame.size.height
  balloon.center = CGPoint(x: UIScreen.main.bounds.width / 2, 
    y: height + balloon.frame.size.height / 2)
  balloon.isHidden = false

  // 2
  startTime = CACurrentMediaTime()
  endTime = animationDuration + startTime!

  // 3
  displayLink = CADisplayLink(target: self, 
    selector: #selector(updateAnimation))
  displayLink?.add(to: RunLoop.main, forMode: .common)
}

Что мы тут делаем:

  1. Устанавливаем высоту анимации, координаты шарика и видимость — примерно так же, как делали раньше.
  2. Инициализируем startTime при помощи CACurrentMediaTime() (вместо of Date()).
  3. Создаём экземпляр класса CADisplayLink и добавляем его в цикл выполнения в режиме common.

Теперь заменим updateAnimation() следующим кодом:

// 1
@objc func updateAnimation() {
  guard
    let endTime = endTime,
    let startTime = startTime 
    else {
      return
  }
    
  // 2
  let now = CACurrentMediaTime()
  
  if now >= endTime {
    // 3
    displayLink?.isPaused = true
    displayLink?.invalidate()
    balloon.isHidden = true
  }
    
  let percentage = (now - startTime) * 100 / animationDuration
  let y = height - ((height + balloon.frame.height / 2) / 100 * 
    CGFloat(percentage))
    
  balloon.center = CGPoint(x: balloon.center.x + 
    CGFloat.random(in: -0.5...0.5), y: y)
}

  1. Добавляем objc к сигнатуре метода (у CADisplayLink параметр селектора требует такую сигнатуру).
  2. Заменяем инициализацию при помощи Date() на инициализацию даты CoreAnimation.
  3. Заменяем вызов animationTimer.invalidate() call на паузу CADisplayLink и invalidate. Это также удалит CADisplayLink из цикла выполнения.

Запустите приложение!


Прекрасно! Мы успешно заменили анимацию, основанную на Timer, на более подходящий CADisplayLink — и получили анимацию более плавную, без рывков.

Заключение


В этом руководстве вы разобрались, как класс Timer работает в iOS, что такое цикл выполнения и как он может сделать ваше приложение более отзывчивым в плане интерфейса, и как использовать CADisplayLink вместо Timer для плавной анимации.

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


  1. prs123
    06.05.2019 18:37

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


    1. infund Автор
      06.05.2019 18:45

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


      1. prs123
        06.05.2019 18:47

        а как тогда вот это понимать:

        Увеличение количества таймеров приводит к худшей отзывчивости UI и большему потреблению батареи. Каждый таймер пытается исполниться точно в отведённое ему время, так как по умолчанию его допуск (tolerance) равен нулю.
        ?


        1. infund Автор
          06.05.2019 18:50

          А чуть ниже, в том же разделе написано:

          Запустите приложение. В этом конкретном случае эффект будет неочевиден (у нас только один таймер), однако в реальной ситуации нескольких таймеров ваши пользователи получат более отзывчивый интерфейс и приложение будет более энергоэффективным.


          Посмотрите повнимательнее логику создания таймера и метод, который который он вызывает при срабатывании, все станет ясно.


  1. storoj
    06.05.2019 22:30

    таймер в данной статье порождает retain-цикл, т.к. таймер имеет сильную ссылку на target


    1. uxtuander
      08.05.2019 12:47

      В данном случае это не играет важной роли, так как у него всего один контроллер.


  1. Statusgman
    07.05.2019 00:00
    +1

    Спасибо за перевод!

    Небольшое уточнение:

    у каждого потока ест автоматически созданный для него цикл выполнения

    Насколько мне известно runloop будет создан у потока, только после вызова currentRunLoop (класса Runloop) на этом потоке. Если не сделать этого и не вызвать run() у currentRunLoop то таймер на этом потоке работать не будет.

    Кусочек оригинального текста проскочил:
    updateAnimation() is now called every time the animation timer fires.

    чтобы мы могла правильно


    1. infund Автор
      07.05.2019 00:02

      Спасибо, огрехи поправил! Что касается RunLoop, то пример, рассматриваемый в оригинальной публикации, вполне рабочий, я его повторил, конечно.


      1. Statusgman
        07.05.2019 18:48
        +1

        Да, я забыл сказать, что у main thread runloop будет запущен. Runloop не будет создан на других потоках (созданных вручную или используемых глобальными очередями GCD)
        Примеры из публикации корректны, я просто дополнил


  1. APcode_ars
    07.05.2019 10:30

    Подскажите плиз, какой самый не костыльный способ в Swift'е для работы с таймером по типу pause/resume?


    1. infund Автор
      07.05.2019 12:07

      «Из коробки» у таймера нет функционала pause/resume. Из этого следует вывод, что на «паузе» нужно делать invalidate() (если он добавлен в RunLoop) и обнулять его, а на «возобновить» — создавать его вновь, учитывая состояние, в котором он был в момент паузы.