Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 53 additions & 13 deletions packages/core/ios/Sources/JSRefreshRateListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import Foundation
import UIKit

typealias frame_time_callback = (_ frameTime: Double, _ displayFps: Double) -> Void

Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -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)
}
}
88 changes: 88 additions & 0 deletions packages/core/ios/Tests/JSRefreshRateMonitorTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading