diff --git a/packages/core/ios/Sources/JSRefreshRateListener.swift b/packages/core/ios/Sources/JSRefreshRateListener.swift index cc084298f..708ecc034 100644 --- a/packages/core/ios/Sources/JSRefreshRateListener.swift +++ b/packages/core/ios/Sources/JSRefreshRateListener.swift @@ -5,6 +5,7 @@ */ import Foundation +import UIKit typealias frame_time_callback = (_ frameTime: Double, _ displayFps: Double) -> Void @@ -17,25 +18,59 @@ internal protocol RefreshRateMonitor { func startMonitoring(jsQueue: DispatchQueueType, frameTimeCallback: @escaping frame_time_callback) } +typealias refresh_rate_listener_factory = ( + _ jsQueue: DispatchQueueType, _ frameTimeCallback: @escaping frame_time_callback +) -> RefreshRateListener + internal final class JSRefreshRateMonitor: RefreshRateMonitor { private var refreshRateListener: RefreshRateListener - - init() { + private let notificationCenter: NotificationCenter + private let queue: DispatchQueueType + private let listenerFactory: refresh_rate_listener_factory + + init( + notificationCenter: NotificationCenter = .default, + queue: DispatchQueueType = sharedQueue, + listenerFactory: @escaping refresh_rate_listener_factory = { + JSRefreshRateListener(jsQueue: $0, frameTimeCallback: $1) + } + ) { self.refreshRateListener = NoOpRefreshRateListener() + self.notificationCenter = notificationCenter + self.queue = queue + self.listenerFactory = listenerFactory + + // CADisplayLink does not tick while the app is in the background, so the listener must be + // paused, otherwise the whole background duration is reported as a single JS frame. + notificationCenter.addObserver( + self, selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil) + notificationCenter.addObserver( + self, selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, object: nil) } - + + deinit { + notificationCenter.removeObserver(self) + } + public func startMonitoring(jsQueue: DispatchQueueType, frameTimeCallback: @escaping frame_time_callback) { self.refreshRateListener.stop() - self.refreshRateListener = JSRefreshRateListener(jsQueue: jsQueue, frameTimeCallback: frameTimeCallback) + self.refreshRateListener = listenerFactory(jsQueue, frameTimeCallback) self.refreshRateListener.start() } - - func appWillResignActive() { - self.refreshRateListener.stop() + + // Notifications are posted on the main thread, while `startMonitoring` runs on the shared queue. + @objc func appWillResignActive() { + queue.async { + self.refreshRateListener.stop() + } } - func appDidBecomeActive() { - self.refreshRateListener.start() + @objc func appDidBecomeActive() { + queue.async { + self.refreshRateListener.start() + } } class func requiresMainQueueSetup() -> Bool { @@ -50,7 +85,7 @@ private final class NoOpRefreshRateListener: RefreshRateListener { } -private final class JSRefreshRateListener: RefreshRateListener { +internal final class JSRefreshRateListener: RefreshRateListener { private var jsQueue: DispatchQueueType private var frameTimeCallback: frame_time_callback private var lastFrameTimestamp: TimeInterval = -1 @@ -65,6 +100,8 @@ private final class JSRefreshRateListener: RefreshRateListener { public func start() { jsQueue.async { self.jsDisplayLink?.invalidate() + // The first frame after (re)starting must not be measured against a frame from before the pause. + self.lastFrameTimestamp = -1 self.jsDisplayLink = CADisplayLink(target: self, selector: #selector(self.onFrameTick)) self.jsDisplayLink?.add(to: .current, forMode: .common) } @@ -78,14 +115,17 @@ private final class JSRefreshRateListener: RefreshRateListener { } @objc func onFrameTick(displayLink: CADisplayLink) { - let frameTimestamp = displayLink.timestamp + onFrame(timestamp: displayLink.timestamp, targetTimestamp: displayLink.targetTimestamp) + } + + func onFrame(timestamp frameTimestamp: TimeInterval, targetTimestamp: TimeInterval) { if lastFrameTimestamp != -1 { let frameDuration = frameTimestamp - lastFrameTimestamp frameTimeCallback(frameDuration, displayFps) } lastFrameTimestamp = frameTimestamp - + // Refresh rate the display is currently running at - displayFps = 1.0 / (displayLink.targetTimestamp - frameTimestamp) + displayFps = 1.0 / (targetTimestamp - frameTimestamp) } } diff --git a/packages/core/ios/Tests/JSRefreshRateMonitorTests.swift b/packages/core/ios/Tests/JSRefreshRateMonitorTests.swift new file mode 100644 index 000000000..d0bd3b356 --- /dev/null +++ b/packages/core/ios/Tests/JSRefreshRateMonitorTests.swift @@ -0,0 +1,88 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import UIKit +import XCTest + +@testable import DatadogSDKReactNative + +final class JSRefreshRateMonitorTests: XCTestCase { + func testListenerIsStoppedWhenAppResignsActiveAndRestartedWhenItBecomesActive() { + let notificationCenter = NotificationCenter() + let listener = MockRefreshRateListener() + let monitor = JSRefreshRateMonitor( + notificationCenter: notificationCenter, + queue: DispatchQueueMock(), + listenerFactory: { _, _ in listener } + ) + + monitor.startMonitoring(jsQueue: DispatchQueueMock(), frameTimeCallback: { _, _ in }) + XCTAssertEqual(listener.startCount, 1) + XCTAssertEqual(listener.stopCount, 0) + + notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) + XCTAssertEqual(listener.startCount, 1) + XCTAssertEqual(listener.stopCount, 1) + + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(listener.startCount, 2) + XCTAssertEqual(listener.stopCount, 1) + } + + func testAppActivityNotificationsAreIgnoredBeforeMonitoringStarts() { + let notificationCenter = NotificationCenter() + let listener = MockRefreshRateListener() + let monitor = JSRefreshRateMonitor( + notificationCenter: notificationCenter, + queue: DispatchQueueMock(), + listenerFactory: { _, _ in listener } + ) + + withExtendedLifetime(monitor) { + notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil) + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) + } + + XCTAssertEqual(listener.startCount, 0) + XCTAssertEqual(listener.stopCount, 0) + } + + func testFirstFrameAfterRestartIsNotMeasuredAgainstLastFrameBeforePause() { + var frameTimes: [Double] = [] + let listener = JSRefreshRateListener( + jsQueue: DispatchQueueMock(), + frameTimeCallback: { frameTime, _ in frameTimes.append(frameTime) } + ) + defer { listener.stop() } + + listener.start() + listener.onFrame(timestamp: 10.0, targetTimestamp: 10.016) + listener.onFrame(timestamp: 10.016, targetTimestamp: 10.032) + + // App goes to the background for a minute + listener.stop() + listener.start() + listener.onFrame(timestamp: 70.0, targetTimestamp: 70.016) + listener.onFrame(timestamp: 70.016, targetTimestamp: 70.032) + + XCTAssertEqual(frameTimes.count, 2) + XCTAssertEqual(frameTimes[0], 0.016, accuracy: 0.0001) + XCTAssertEqual(frameTimes[1], 0.016, accuracy: 0.0001) + } +} + +private final class MockRefreshRateListener: RefreshRateListener { + private(set) var startCount = 0 + private(set) var stopCount = 0 + + func start() { + startCount += 1 + } + + func stop() { + stopCount += 1 + } +}