[iOS] How to invalidate Timer properly?

Weak reference timer to prevent memory leaks

Posted by Kim Heebeom on July 19, 2019

앱을 만들다 보면 Timer를 종종 사용하게 되는데요, 일정 시간 뒤에 특정 콜백을 실행하거나 주기적으로 이벤트를 발생시키기에 아주 편리한 방법이죠. 하지만 별 생각 없이 사용했다간 타이머에 의해서 메모리 릭이 발생한다는 걸 뒤늦게 알게 될 수도 있습니다. (저처럼요.. 😹)

Timer를 생성할 때 반복옵션을 사용하지 않으면 지정한 시간 뒤에 콜백이 호출되고 자동으로 invalidate 처리가 되기 때문에 별문제가 되진 않습니다. 문제는 타이머의 repeats 옵션을 주었을 때 발생합니다.

이 문제를 해결하기 위한 몇 가지 방법을 살펴보겠습니다.


Problem case

일정 시간 간격으로 롤링이 되는 UIScrollView를 만드는 요구사항이 있었습니다. 당연하게도 아래처럼 Timer를 사용했습니다.

class MyView: UIView {
  var scrollView: UIScrollView!
  var timer: Timer?

  func startAutoScroll() {
    timer = Timer.scheduledTimer(
      timeInterval: 10,
      target: self,
      selector: #selector(scrollToNextPage),
      userInfo: nil,
      repeats: true
    )
  }

  func scrollToNextPage() {
    ...
  }
}

그리고 deinit 시점에 타이머의 invalidate()를 호출해주도록 만들었습니다.

  deinit {
    timer?.invalidate()
    timer = nil
  }

명확하고 문제가 없어 보입니다만 한 가지 간과하고 있는 것이 있었습니다.

Timer의 타겟이 약한 참조가 아니라는 것입니다. Timer는 타겟인 MyView를 강한 참조로 가지고 있습니다. 이 말은 MyViewTimer가 서로에 대해 강한 참조를 하고 있다는 것이고, 그럼 한쪽에서 명시적으로 참조를 끊지 않는 이상 반대쪽에서 deinit이 호출되는 일은 없다는 뜻이겠죠.

Apple’s documentation에는 타이머의 참조를 없애기 위해 반드시 invalidate()를 호출하라고 명시되어있습니다.

Solution

외부(상위뷰)에서 invalidate 호출

MyView를 포함하는 상위뷰가 소멸되는 시점에 MyView가 가진 Timer의 invalidate를 호출하는 방법이 있습니다.

class MyViewController: UIViewController {
  var myView: MyView!
  ...

  override func viewWillDisappear(animated: Bool){
    super.viewWillDisappear(animated)
    myView.viewWillDisappear()
  }
}
class MyView: UIView {
  var timer: Timer?

  ...

  func viewWillDisappear() {
    timer?.invalidate()
  }
}

MyView가 UIViewController였다면 아래처럼 할 수도 있습니다.

  override func viewWillDisappear(animated: Bool) {
    super.viewWillDisappear(animated)
    timer?.invalidate()
  }

간단한 방법이지만, 좋은 방법이라는 느낌은 들지 않습니다. Timer를 생성하는 모든 곳에서 동일한 코드들이 들어가게 될 테니까요. 깜빡하고 빠트릴 여지도 충분히 있습니다.

그에 비해 아래 방법은 굉장히 깔끔합니다.


Timer Wrapper

Timertarget을 강한 참조를 갖는 것이 문제였다면 약한 참조를 갖도록 다리를 하나 놓아주면 됩니다. (진짜로 Timer가 target를 약한 참조로 갖는다는 것은 아닙니다)

아래처럼 WeakTimer라는 것을 만들었습니다.

final class WeakTimer {
  private weak var timer: Timer?
  private weak var target: AnyObject?
  private let action: (Timer) -> Void

  private init(
    timeInterval: TimeInterval,
    target: AnyObject,
    repeats: Bool,
    action: @escaping (Timer) -> Void
    ) {
    self.target = target
    self.action = action
    self.timer = Timer.scheduledTimer(
      timeInterval: timeInterval,
      target: self,
      selector: #selector(fire),
      userInfo: nil,
      repeats: repeats
    )
  }

  class func scheduledTimer(
    timeInterval: TimeInterval,
    target: AnyObject,
    repeats: Bool,
    action: @escaping (Timer) -> Void
    ) -> Timer {
    return WeakTimer(
      timeInterval: timeInterval,
      target: target,
      repeats: repeats,
      action: action
    ).timer!
  }

  @objc private func fire(timer: Timer) {
    if target == nil {
      timer.invalidate()
    } else {
      action(timer)
    }
  }
}

여기서 중요한 건, WeakTimertimertarget에 대해 각각 약한 참조를 갖고 있습니다. MyViewTimer에 대한 참조를 유지하면서 Timer의 참조는 WeakTimer로 바꾸었습니다. 일정한 시간마다 fire()가 호출되고, target이 release 되었는지를 체크해서 timer의 invalidate를 호출하는 구조입니다.

MyView에 대한 참조 카운트가 증가하지 않기 때문에 이전의 문제와 같은 일은 발생하지 않습니다. 그리고 사용하는 곳에서는 이렇게 코드를 바꿉니다.

  func startAutoScroll() {
    timer = WeakTimer.scheduledTimer(
      timeInterval: TimeInterval(scrollInterval),
      target: self,
      repeats: true
    ) { [weak self] _ in
      self?.scrollToNextPage()
    }
  }

깔-끔 🥳