From 57c6bca39dd5281bded055b0db776efe9ff9a256 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Mar 2026 13:45:24 +0800 Subject: [PATCH 1/8] feat(example): add animation benchmark demo Add a benchmark screen that measures per-frame UI thread overhead across 4 animation approaches: ease, reanimated shared value, reanimated CSS, and RN Animated. Uses an Expo local module (frame-metrics) wrapping: - Android: FrameMetrics API (ANIMATION_DURATION, LAYOUT_MEASURE_DURATION, DRAW_DURATION) for precise per-component breakdown - iOS: CADisplayLink for frame delivery timing Configurable view count (10-500) with results showing avg/p95/p99 of UI thread time per frame, plus Android-specific breakdown table. --- example/.gitignore | 4 + .../frame-metrics/android/build.gradle | 12 + .../framemetrics/FrameMetricsModule.kt | 112 ++++ .../frame-metrics/expo-module.config.json | 9 + .../ios/FrameMetricsModule.swift | 67 ++ example/modules/frame-metrics/src/index.ts | 51 ++ example/src/demos/BenchmarkDemo.tsx | 596 ++++++++++++++++++ example/src/demos/index.ts | 6 + 8 files changed, 857 insertions(+) create mode 100644 example/modules/frame-metrics/android/build.gradle create mode 100644 example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt create mode 100644 example/modules/frame-metrics/expo-module.config.json create mode 100644 example/modules/frame-metrics/ios/FrameMetricsModule.swift create mode 100644 example/modules/frame-metrics/src/index.ts create mode 100644 example/src/demos/BenchmarkDemo.tsx diff --git a/example/.gitignore b/example/.gitignore index 03d5ee3..0b9ce13 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -7,3 +7,7 @@ web-build/ # Expo prebuild generated dirs android/ ios/ + +# Keep local modules native code +!modules/**/android/ +!modules/**/ios/ diff --git a/example/modules/frame-metrics/android/build.gradle b/example/modules/frame-metrics/android/build.gradle new file mode 100644 index 0000000..b8049de --- /dev/null +++ b/example/modules/frame-metrics/android/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +android { + namespace "expo.modules.framemetrics" + defaultConfig { + versionCode 1 + versionName "1.0.0" + } +} diff --git a/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt new file mode 100644 index 0000000..bb857ef --- /dev/null +++ b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt @@ -0,0 +1,112 @@ +package expo.modules.framemetrics + +import android.os.Handler +import android.os.HandlerThread +import android.view.FrameMetrics +import android.view.Window +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.util.concurrent.CopyOnWriteArrayList + +private data class FrameSample( + val totalMs: Double, + val animationMs: Double, + val layoutMs: Double, + val drawMs: Double, +) + +class FrameMetricsModule : Module() { + private val frameSamples = CopyOnWriteArrayList() + private var listener: Window.OnFrameMetricsAvailableListener? = null + private var handlerThread: HandlerThread? = null + + override fun definition() = ModuleDefinition { + Name("FrameMetrics") + + Function("startCollecting") { + frameSamples.clear() + + val activity = appContext.currentActivity + ?: throw IllegalStateException("No activity") + + val thread = HandlerThread("FrameMetricsHandler").also { it.start() } + handlerThread = thread + val handler = Handler(thread.looper) + + val l = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + frameSamples.add(FrameSample( + totalMs = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1_000_000.0, + animationMs = frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) / 1_000_000.0, + layoutMs = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) / 1_000_000.0, + drawMs = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION) / 1_000_000.0, + )) + } + listener = l + + activity.runOnUiThread { + activity.window.addOnFrameMetricsAvailableListener(l, handler) + } + } + + Function("stopCollecting") { + val activity = appContext.currentActivity + val l = listener + if (activity != null && l != null) { + activity.runOnUiThread { + try { + activity.window.removeOnFrameMetricsAvailableListener(l) + } catch (_: Exception) {} + } + } + listener = null + handlerThread?.quitSafely() + handlerThread = null + + val samples = frameSamples.toList() + if (samples.isEmpty()) { + return@Function mapOf( + "avgFrameTime" to 0.0, + "p95FrameTime" to 0.0, + "p99FrameTime" to 0.0, + "droppedFrames" to 0, + "totalFrames" to 0, + "frameDurations" to emptyList(), + "avgAnimationTime" to 0.0, + "p95AnimationTime" to 0.0, + "p99AnimationTime" to 0.0, + "avgUiThreadTime" to 0.0, + "p95UiThreadTime" to 0.0, + "p99UiThreadTime" to 0.0, + "avgLayoutTime" to 0.0, + "avgDrawTime" to 0.0, + ) + } + + val totalDurations = samples.map { it.totalMs }.sorted() + val avg = totalDurations.average() + val p95 = totalDurations[(totalDurations.size * 0.95).toInt().coerceAtMost(totalDurations.size - 1)] + val p99 = totalDurations[(totalDurations.size * 0.99).toInt().coerceAtMost(totalDurations.size - 1)] + val dropped = totalDurations.count { it > 16.67 } + + val animDurations = samples.map { it.animationMs }.sorted() + val uiThreadDurations = samples.map { it.animationMs + it.layoutMs + it.drawMs }.sorted() + + mapOf( + "avgFrameTime" to avg, + "p95FrameTime" to p95, + "p99FrameTime" to p99, + "droppedFrames" to dropped, + "totalFrames" to totalDurations.size, + "frameDurations" to samples.map { it.totalMs }, + "avgAnimationTime" to animDurations.average(), + "p95AnimationTime" to animDurations[(animDurations.size * 0.95).toInt().coerceAtMost(animDurations.size - 1)], + "p99AnimationTime" to animDurations[(animDurations.size * 0.99).toInt().coerceAtMost(animDurations.size - 1)], + "avgUiThreadTime" to uiThreadDurations.average(), + "p95UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.95).toInt().coerceAtMost(uiThreadDurations.size - 1)], + "p99UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.99).toInt().coerceAtMost(uiThreadDurations.size - 1)], + "avgLayoutTime" to samples.map { it.layoutMs }.average(), + "avgDrawTime" to samples.map { it.drawMs }.average(), + ) + } + } +} diff --git a/example/modules/frame-metrics/expo-module.config.json b/example/modules/frame-metrics/expo-module.config.json new file mode 100644 index 0000000..198c3a0 --- /dev/null +++ b/example/modules/frame-metrics/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["FrameMetricsModule"] + }, + "android": { + "modules": ["expo.modules.framemetrics.FrameMetricsModule"] + } +} diff --git a/example/modules/frame-metrics/ios/FrameMetricsModule.swift b/example/modules/frame-metrics/ios/FrameMetricsModule.swift new file mode 100644 index 0000000..25d4576 --- /dev/null +++ b/example/modules/frame-metrics/ios/FrameMetricsModule.swift @@ -0,0 +1,67 @@ +import ExpoModulesCore +import QuartzCore + +public final class FrameMetricsModule: Module { + private var displayLink: CADisplayLink? + private var frameDurations: [Double] = [] + private var lastTimestamp: CFTimeInterval = 0 + + public func definition() -> ModuleDefinition { + Name("FrameMetrics") + + Function("startCollecting") { + self.frameDurations = [] + self.lastTimestamp = 0 + + DispatchQueue.main.async { + self.displayLink?.invalidate() + let link = CADisplayLink(target: self, selector: #selector(self.onFrame(_:))) + link.add(to: .main, forMode: .common) + self.displayLink = link + } + } + + Function("stopCollecting") { () -> [String: Any] in + DispatchQueue.main.sync { + self.displayLink?.invalidate() + self.displayLink = nil + } + + let durations = self.frameDurations + guard !durations.isEmpty else { + return [ + "avgFrameTime": 0, + "p95FrameTime": 0, + "p99FrameTime": 0, + "droppedFrames": 0, + "totalFrames": 0, + "frameDurations": [] as [Double], + ] + } + + let sorted = durations.sorted() + let avg = sorted.reduce(0, +) / Double(sorted.count) + let p95 = sorted[Int(Double(sorted.count) * 0.95)] + let p99 = sorted[Int(min(Double(sorted.count) * 0.99, Double(sorted.count - 1)))] + let dropped = sorted.filter { $0 > 16.67 }.count + + return [ + "avgFrameTime": avg, + "p95FrameTime": p95, + "p99FrameTime": p99, + "droppedFrames": dropped, + "totalFrames": sorted.count, + "frameDurations": durations, + ] + } + } + + @objc private func onFrame(_ link: CADisplayLink) { + let now = link.timestamp + if lastTimestamp > 0 { + let duration = (now - lastTimestamp) * 1000 + frameDurations.append(duration) + } + lastTimestamp = now + } +} diff --git a/example/modules/frame-metrics/src/index.ts b/example/modules/frame-metrics/src/index.ts new file mode 100644 index 0000000..8bd04ce --- /dev/null +++ b/example/modules/frame-metrics/src/index.ts @@ -0,0 +1,51 @@ +import { NativeModule, requireNativeModule, Platform } from 'expo-modules-core'; + +interface FrameMetricsResult { + /** Average frame duration in ms */ + avgFrameTime: number; + /** P95 frame duration in ms */ + p95FrameTime: number; + /** P99 frame duration in ms */ + p99FrameTime: number; + /** Number of frames that exceeded the frame budget */ + droppedFrames: number; + /** Total frames collected */ + totalFrames: number; + /** All frame durations in ms */ + frameDurations: number[]; + /** Average time spent evaluating animators (Android only) */ + avgAnimationTime?: number; + /** P95 animation time (Android only) */ + p95AnimationTime?: number; + /** P99 animation time (Android only) */ + p99AnimationTime?: number; + /** Average UI thread time: anim + layout + draw (Android only) */ + avgUiThreadTime?: number; + /** P95 UI thread time (Android only) */ + p95UiThreadTime?: number; + /** P99 UI thread time (Android only) */ + p99UiThreadTime?: number; + /** Average layout/measure time (Android only) */ + avgLayoutTime?: number; + /** Average draw time (Android only) */ + avgDrawTime?: number; +} + +declare class FrameMetricsModuleType extends NativeModule { + startCollecting(): void; + stopCollecting(): FrameMetricsResult; +} + +const mod = requireNativeModule('FrameMetrics'); + +export const isAndroid = Platform.OS === 'android'; + +export function startCollecting(): void { + mod.startCollecting(); +} + +export function stopCollecting(): FrameMetricsResult { + return mod.stopCollecting(); +} + +export type { FrameMetricsResult }; diff --git a/example/src/demos/BenchmarkDemo.tsx b/example/src/demos/BenchmarkDemo.tsx new file mode 100644 index 0000000..c41d37d --- /dev/null +++ b/example/src/demos/BenchmarkDemo.tsx @@ -0,0 +1,596 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, + Text, + StyleSheet, + Animated as RNAnimated, + Easing as RNEasing, + Pressable, +} from 'react-native'; +import { EaseView } from 'react-native-ease'; +import Animated, { + createCSSAnimatedComponent, + css, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import { + isAndroid, + startCollecting, + stopCollecting, + type FrameMetricsResult, +} from '../../modules/frame-metrics/src'; + +const ANIMATION_DISTANCE = 100; +const ANIMATION_DURATION = 2000; +const BENCHMARK_DURATION = 5000; +const SETTLE_DELAY = 500; + +const BOX_SIZE = 20; +const BOX_MARGIN = 2; + +// --------------------------------------------------------------------------- +// Animation approaches +// --------------------------------------------------------------------------- + +function EaseBox() { + return ( + + ); +} + +function EaseApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +function ReanimatedSVBox() { + const translateX = useSharedValue(0); + + useEffect(() => { + translateX.value = withRepeat( + withTiming(ANIMATION_DISTANCE, { + duration: ANIMATION_DURATION, + easing: Easing.linear, + }), + -1, + true, + ); + }, [translateX]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + return ; +} + +function ReanimatedSVApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +const CSSView = createCSSAnimatedComponent(View); + +const slideKeyframes = css.keyframes({ + from: { transform: [{ translateX: 0 }] }, + to: { transform: [{ translateX: ANIMATION_DISTANCE }] }, +}); + +const cssStyles = css.create({ + box: { + animationName: slideKeyframes, + animationDuration: `${ANIMATION_DURATION}ms`, + animationIterationCount: 'infinite', + animationDirection: 'alternate', + animationTimingFunction: 'linear', + }, +}); + +function ReanimatedCSSBox() { + return ; +} + +function ReanimatedCSSApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +function AnimatedBox() { + const translateX = useRef(new RNAnimated.Value(0)).current; + + useEffect(() => { + const animation = RNAnimated.loop( + RNAnimated.sequence([ + RNAnimated.timing(translateX, { + toValue: ANIMATION_DISTANCE, + duration: ANIMATION_DURATION, + easing: RNEasing.linear, + useNativeDriver: true, + }), + RNAnimated.timing(translateX, { + toValue: 0, + duration: ANIMATION_DURATION, + easing: RNEasing.linear, + useNativeDriver: true, + }), + ]), + ); + animation.start(); + return () => animation.stop(); + }, [translateX]); + + return ( + + ); +} + +function AnimatedApproach({ count }: { count: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Approach registry +// --------------------------------------------------------------------------- + +const APPROACHES = [ + { key: 'ease', label: 'Ease', color: '#4a90d9', component: EaseApproach }, + { + key: 'reanimated-sv', + label: 'Reanimated SV', + color: '#6c5ce7', + component: ReanimatedSVApproach, + }, + { + key: 'reanimated-css', + label: 'Reanimated CSS', + color: '#a55eea', + component: ReanimatedCSSApproach, + }, + { + key: 'animated', + label: 'RN Animated', + color: '#e17055', + component: AnimatedApproach, + }, +] as const; + +type ApproachKey = (typeof APPROACHES)[number]['key']; + +// --------------------------------------------------------------------------- +// Benchmark component +// --------------------------------------------------------------------------- + +export function BenchmarkDemo() { + const [viewCount, setViewCount] = useState(100); + const [activeApproach, setActiveApproach] = useState( + null, + ); + const [results, setResults] = useState< + Partial> + >({}); + const [running, setRunning] = useState(false); + const [statusText, setStatusText] = useState(''); + const abortRef = useRef(false); + + const runSingle = useCallback( + (key: ApproachKey): Promise => { + return new Promise((resolve) => { + setActiveApproach(key); + + // Let views mount and settle + setTimeout(() => { + if (abortRef.current) { + resolve(null); + return; + } + startCollecting(); + + setTimeout(() => { + const result = stopCollecting(); + setActiveApproach(null); + resolve(result); + }, BENCHMARK_DURATION); + }, SETTLE_DELAY); + }); + }, + [], + ); + + const runBenchmark = useCallback( + async (keys: ApproachKey[]) => { + abortRef.current = false; + setRunning(true); + setResults({}); + + for (const key of keys) { + if (abortRef.current) { + break; + } + setStatusText( + `Running: ${APPROACHES.find((a) => a.key === key)?.label}...`, + ); + const result = await runSingle(key); + if (result) { + setResults((prev) => ({ ...prev, [key]: result })); + } + // Brief pause between approaches + await new Promise((r) => setTimeout(r, 300)); + } + + setStatusText(''); + setRunning(false); + }, + [runSingle], + ); + + const stopBenchmark = useCallback(() => { + abortRef.current = true; + try { + stopCollecting(); + } catch {} + setActiveApproach(null); + setRunning(false); + setStatusText(''); + }, []); + + // Render the active approach + const ActiveComponent = activeApproach + ? APPROACHES.find((a) => a.key === activeApproach)?.component + : null; + + return ( + + Animation Benchmark + + Measures per-frame time with {viewCount} simultaneously animating views. + {'\n'}Lower is better. + + + {/* View count slider */} + + Views: {viewCount} + + {[10, 50, 100, 200, 500].map((n) => ( + !running && setViewCount(n)} + > + + {n} + + + ))} + + + + {/* Run buttons */} + + {running ? ( + + Stop + + ) : ( + <> + {APPROACHES.map((a) => ( + runBenchmark([a.key])} + > + {a.label} + + ))} + runBenchmark(APPROACHES.map((a) => a.key))} + > + Run All + + + )} + + + {/* Status / active animation area */} + {running && ( + + {statusText} + {activeApproach && ActiveComponent && ( + + )} + + )} + + {/* Results */} + {Object.keys(results).length > 0 && ( + + Results + + {/* Primary table: animation time (Android) or frame time (iOS) */} + + {isAndroid + ? 'UI thread time per frame: anim + layout + draw (ms). Lower is better.' + : 'Frame delivery time (ms). Lower is better.'} + + + + Approach + + Avg + P95 + P99 + + {APPROACHES.filter((a) => results[a.key]).map((a) => { + const r = results[a.key]!; + const avg = isAndroid ? r.avgUiThreadTime ?? 0 : r.avgFrameTime; + const p95 = isAndroid ? r.p95UiThreadTime ?? 0 : r.p95FrameTime; + const p99 = isAndroid ? r.p99UiThreadTime ?? 0 : r.p99FrameTime; + return ( + + + {a.label} + + + {avg.toFixed(2)} + + + {p95.toFixed(2)} + + + {p99.toFixed(2)} + + + ); + })} + + {/* Android-only: full breakdown */} + {isAndroid && ( + <> + + UI Thread Breakdown (avg ms) + + + + Approach + + Anim + Layout + Draw + + {APPROACHES.filter((a) => results[a.key]).map((a) => { + const r = results[a.key]!; + return ( + + + {a.label} + + + {(r.avgAnimationTime ?? 0).toFixed(2)} + + + {(r.avgLayoutTime ?? 0).toFixed(2)} + + + {(r.avgDrawTime ?? 0).toFixed(2)} + + + ); + })} + + )} + + )} + + ); +} + +function overheadColor(overheadMs: number): { color: string } { + if (overheadMs < 2) { + return { color: '#2ecc71' }; + } + if (overheadMs < 5) { + return { color: '#f39c12' }; + } + return { color: '#e74c3c' }; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + title: { + fontSize: 28, + fontWeight: '800', + color: '#fff', + marginBottom: 4, + }, + subtitle: { + fontSize: 13, + color: '#8888aa', + marginBottom: 20, + lineHeight: 18, + }, + sliderSection: { + marginBottom: 16, + }, + sliderLabel: { + fontSize: 14, + fontWeight: '600', + color: '#e0e0ff', + marginBottom: 8, + }, + sliderRow: { + flexDirection: 'row', + gap: 8, + }, + countButton: { + paddingHorizontal: 14, + paddingVertical: 8, + backgroundColor: '#2a2a4a', + borderRadius: 8, + }, + countButtonActive: { + backgroundColor: '#4a90d9', + }, + countButtonText: { + color: '#8888aa', + fontWeight: '600', + fontSize: 14, + }, + countButtonTextActive: { + color: '#fff', + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 16, + }, + runButton: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 8, + }, + runAllButton: { + backgroundColor: '#2a2a4a', + }, + stopButton: { + backgroundColor: '#d94a4a', + }, + runButtonText: { + color: '#fff', + fontWeight: '700', + fontSize: 13, + }, + animationArea: { + backgroundColor: '#16213e', + borderRadius: 16, + padding: 12, + marginBottom: 16, + maxHeight: 200, + overflow: 'hidden', + }, + runningLabel: { + fontSize: 13, + fontWeight: '600', + color: '#8888aa', + marginBottom: 8, + }, + boxContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + box: { + width: BOX_SIZE, + height: BOX_SIZE, + backgroundColor: '#4a90d9', + borderRadius: 4, + margin: BOX_MARGIN, + }, + resultsSection: { + backgroundColor: '#16213e', + borderRadius: 16, + padding: 16, + }, + resultsSectionTitle: { + fontSize: 16, + fontWeight: '700', + color: '#e0e0ff', + marginBottom: 12, + }, + subtitleText: { + fontSize: 13, + color: '#8888aa', + marginBottom: 12, + }, + breakdownTitle: { + marginTop: 20, + }, + tableHeader: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#2a2a4a', + paddingBottom: 8, + marginBottom: 4, + }, + tableRow: { + flexDirection: 'row', + paddingVertical: 6, + }, + tableCell: { + flex: 1, + fontSize: 13, + color: '#e0e0ff', + textAlign: 'center', + fontVariant: ['tabular-nums'], + }, + tableCellLabel: { + flex: 1.5, + textAlign: 'left', + fontWeight: '600', + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index c97dd27..7a846ae 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import { BackgroundColorDemo } from './BackgroundColorDemo'; +import { BenchmarkDemo } from './BenchmarkDemo'; import { BannerDemo } from './BannerDemo'; import { BorderRadiusDemo } from './BorderRadiusDemo'; import { ButtonDemo } from './ButtonDemo'; @@ -78,6 +79,11 @@ export const demos: Record = { title: 'Comparison', section: 'Advanced', }, + 'benchmark': { + component: BenchmarkDemo, + title: 'Benchmark', + section: 'Advanced', + }, }; interface SectionData { From c48ae5eeb566cb0a67ef67e762c1ed89dd79adaf Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Mar 2026 14:51:39 +0800 Subject: [PATCH 2/8] feat(example): add iOS frame metrics via CADisplayLink swizzle - Add podspec for iOS autolinking - Swizzle CADisplayLink factory to measure callback duration per frame - Aggregate per-frame callback time using display link timestamp - Make UI thread time fields non-optional (both platforms report them) - Update iOS label to "Display link callback time per frame" --- .../frame-metrics/ios/FrameMetrics.podspec | 22 +++ .../ios/FrameMetricsModule.swift | 128 +++++++++++++++++- example/modules/frame-metrics/src/index.ts | 12 +- example/src/demos/BenchmarkDemo.tsx | 10 +- 4 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 example/modules/frame-metrics/ios/FrameMetrics.podspec diff --git a/example/modules/frame-metrics/ios/FrameMetrics.podspec b/example/modules/frame-metrics/ios/FrameMetrics.podspec new file mode 100644 index 0000000..03f3374 --- /dev/null +++ b/example/modules/frame-metrics/ios/FrameMetrics.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'FrameMetrics' + s.version = '1.0.0' + s.summary = 'Frame metrics collection module' + s.description = 'Expo module for collecting frame timing metrics' + s.license = 'MIT' + s.author = 'Janic Duplessis' + s.homepage = 'https://github.com/AppAndFlow/react-native-ease' + s.platforms = { :ios => '15.1' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/AppAndFlow/react-native-ease.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.source_files = "**/*.{h,m,swift}" + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } +end diff --git a/example/modules/frame-metrics/ios/FrameMetricsModule.swift b/example/modules/frame-metrics/ios/FrameMetricsModule.swift index 25d4576..2750462 100644 --- a/example/modules/frame-metrics/ios/FrameMetricsModule.swift +++ b/example/modules/frame-metrics/ios/FrameMetricsModule.swift @@ -1,5 +1,101 @@ import ExpoModulesCore import QuartzCore +import ObjectiveC + +// MARK: - Display Link Swizzle + +/// Aggregates per-frame callback durations across all swizzled CADisplayLinks. +final class FrameCallbackCollector { + static let shared = FrameCallbackCollector() + + var isCollecting = false + private var currentFrameTimestamp: CFTimeInterval = 0 + private var currentFrameWorkMs: Double = 0 + private(set) var frameWorkTimes: [Double] = [] + + func reset() { + isCollecting = false + currentFrameTimestamp = 0 + currentFrameWorkMs = 0 + frameWorkTimes = [] + } + + func finalize() { + if currentFrameWorkMs > 0 { + frameWorkTimes.append(currentFrameWorkMs) + } + isCollecting = false + } + + func recordCallback(timestamp: CFTimeInterval, durationMs: Double) { + guard isCollecting else { return } + if timestamp != currentFrameTimestamp { + if currentFrameTimestamp > 0 { + frameWorkTimes.append(currentFrameWorkMs) + } + currentFrameTimestamp = timestamp + currentFrameWorkMs = 0 + } + currentFrameWorkMs += durationMs + } +} + +/// Wraps an original CADisplayLink target to measure callback duration. +final class DisplayLinkProxy: NSObject { + let originalTarget: AnyObject + let originalSelector: Selector + + init(target: AnyObject, selector: Selector) { + self.originalTarget = target + self.originalSelector = selector + super.init() + } + + @objc func proxyCallback(_ displayLink: CADisplayLink) { + let start = CACurrentMediaTime() + _ = originalTarget.perform(originalSelector, with: displayLink) + let elapsed = (CACurrentMediaTime() - start) * 1000.0 + FrameCallbackCollector.shared.recordCallback( + timestamp: displayLink.timestamp, + durationMs: elapsed + ) + } +} + +private var proxyAssocKey: UInt8 = 0 +private var originalFactoryIMP: IMP? +private var didSwizzle = false + +private func swizzleDisplayLinkFactory() { + guard !didSwizzle else { return } + didSwizzle = true + + let metaCls: AnyClass = object_getClass(CADisplayLink.self)! + let sel = NSSelectorFromString("displayLinkWithTarget:selector:") + guard let method = class_getInstanceMethod(metaCls, sel) else { return } + + originalFactoryIMP = method_getImplementation(method) + + typealias OrigFunc = @convention(c) (AnyClass, Selector, AnyObject, Selector) -> CADisplayLink + + let block: @convention(block) (AnyObject, AnyObject, Selector) -> CADisplayLink = { + _, target, selector in + let proxy = DisplayLinkProxy(target: target, selector: selector) + let orig = unsafeBitCast(originalFactoryIMP!, to: OrigFunc.self) + let factorySel = NSSelectorFromString("displayLinkWithTarget:selector:") + let link = orig( + CADisplayLink.self, factorySel, proxy, + #selector(DisplayLinkProxy.proxyCallback(_:))) + // Keep proxy alive via associated object + objc_setAssociatedObject(link, &proxyAssocKey, proxy, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return link + } + + let imp = imp_implementationWithBlock(unsafeBitCast(block, to: AnyObject.self)) + method_setImplementation(method, imp) +} + +// MARK: - Module public final class FrameMetricsModule: Module { private var displayLink: CADisplayLink? @@ -9,10 +105,18 @@ public final class FrameMetricsModule: Module { public func definition() -> ModuleDefinition { Name("FrameMetrics") + OnCreate { + swizzleDisplayLinkFactory() + } + Function("startCollecting") { self.frameDurations = [] self.lastTimestamp = 0 + let collector = FrameCallbackCollector.shared + collector.reset() + collector.isCollecting = true + DispatchQueue.main.async { self.displayLink?.invalidate() let link = CADisplayLink(target: self, selector: #selector(self.onFrame(_:))) @@ -27,6 +131,10 @@ public final class FrameMetricsModule: Module { self.displayLink = nil } + let collector = FrameCallbackCollector.shared + collector.finalize() + let workTimes = collector.frameWorkTimes + let durations = self.frameDurations guard !durations.isEmpty else { return [ @@ -36,15 +144,28 @@ public final class FrameMetricsModule: Module { "droppedFrames": 0, "totalFrames": 0, "frameDurations": [] as [Double], + "avgUiThreadTime": 0, + "p95UiThreadTime": 0, + "p99UiThreadTime": 0, ] } let sorted = durations.sorted() let avg = sorted.reduce(0, +) / Double(sorted.count) - let p95 = sorted[Int(Double(sorted.count) * 0.95)] - let p99 = sorted[Int(min(Double(sorted.count) * 0.99, Double(sorted.count - 1)))] + let p95 = sorted[min(Int(Double(sorted.count) * 0.95), sorted.count - 1)] + let p99 = sorted[min(Int(Double(sorted.count) * 0.99), sorted.count - 1)] let dropped = sorted.filter { $0 > 16.67 }.count + var avgWork: Double = 0 + var p95Work: Double = 0 + var p99Work: Double = 0 + if !workTimes.isEmpty { + let sortedWork = workTimes.sorted() + avgWork = sortedWork.reduce(0, +) / Double(sortedWork.count) + p95Work = sortedWork[min(Int(Double(sortedWork.count) * 0.95), sortedWork.count - 1)] + p99Work = sortedWork[min(Int(Double(sortedWork.count) * 0.99), sortedWork.count - 1)] + } + return [ "avgFrameTime": avg, "p95FrameTime": p95, @@ -52,6 +173,9 @@ public final class FrameMetricsModule: Module { "droppedFrames": dropped, "totalFrames": sorted.count, "frameDurations": durations, + "avgUiThreadTime": avgWork, + "p95UiThreadTime": p95Work, + "p99UiThreadTime": p99Work, ] } } diff --git a/example/modules/frame-metrics/src/index.ts b/example/modules/frame-metrics/src/index.ts index 8bd04ce..70cba9c 100644 --- a/example/modules/frame-metrics/src/index.ts +++ b/example/modules/frame-metrics/src/index.ts @@ -19,12 +19,12 @@ interface FrameMetricsResult { p95AnimationTime?: number; /** P99 animation time (Android only) */ p99AnimationTime?: number; - /** Average UI thread time: anim + layout + draw (Android only) */ - avgUiThreadTime?: number; - /** P95 UI thread time (Android only) */ - p95UiThreadTime?: number; - /** P99 UI thread time (Android only) */ - p99UiThreadTime?: number; + /** Average UI/main thread work time per frame (ms) */ + avgUiThreadTime: number; + /** P95 UI/main thread work time (ms) */ + p95UiThreadTime: number; + /** P99 UI/main thread work time (ms) */ + p99UiThreadTime: number; /** Average layout/measure time (Android only) */ avgLayoutTime?: number; /** Average draw time (Android only) */ diff --git a/example/src/demos/BenchmarkDemo.tsx b/example/src/demos/BenchmarkDemo.tsx index c41d37d..530cdb3 100644 --- a/example/src/demos/BenchmarkDemo.tsx +++ b/example/src/demos/BenchmarkDemo.tsx @@ -352,11 +352,11 @@ export function BenchmarkDemo() { Results - {/* Primary table: animation time (Android) or frame time (iOS) */} + {/* Primary table: main thread work per frame */} {isAndroid ? 'UI thread time per frame: anim + layout + draw (ms). Lower is better.' - : 'Frame delivery time (ms). Lower is better.'} + : 'Display link callback time per frame (ms). Lower is better.'} @@ -368,9 +368,9 @@ export function BenchmarkDemo() { {APPROACHES.filter((a) => results[a.key]).map((a) => { const r = results[a.key]!; - const avg = isAndroid ? r.avgUiThreadTime ?? 0 : r.avgFrameTime; - const p95 = isAndroid ? r.p95UiThreadTime ?? 0 : r.p95FrameTime; - const p99 = isAndroid ? r.p99UiThreadTime ?? 0 : r.p99FrameTime; + const avg = r.avgUiThreadTime; + const p95 = r.p95UiThreadTime; + const p99 = r.p99UiThreadTime; return ( Date: Fri, 20 Mar 2026 16:38:13 +0800 Subject: [PATCH 3/8] feat: add benchmark section to README and enable Reanimated feature flags Add benchmark charts comparing per-frame overhead of Ease vs Reanimated (SV/CSS) vs RN Animated on both Android and iOS. Enable Reanimated experimental feature flags in the example app for fair comparison (SYNCHRONOUSLY_UPDATE_UI_PROPS, USE_SYNCHRONIZABLE_FOR_MUTABLES, DISABLE_COMMIT_PAUSING_MECHANISM). --- README.md | 18 ++++++++++++++++++ example/package.json | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 7a23380..ad19824 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,24 @@ Setting `useHardwareLayer` rasterizes the view into a GPU texture for the durati No-op on iOS where Core Animation already composites off the main thread. +## Benchmarks + +The example app includes a benchmark that measures per-frame animation overhead across different approaches. All approaches run the same animation (translateX loop, linear, 2s) on a configurable number of views. + +### Android (release build, emulator, M4 MacBook Pro) + +UI thread time per frame: anim + layout + draw (ms). Lower is better. + +![Android benchmark](https://quickchart.io/chart?w=700&h=400&c=%7B%22type%22%3A%22bar%22%2C%22data%22%3A%7B%22labels%22%3A%5B%2210%20views%22%2C%22100%20views%22%2C%22500%20views%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Ease%22%2C%22data%22%3A%5B0.21%2C0.36%2C0.6%5D%2C%22backgroundColor%22%3A%22%235B9BD5%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%22%2C%22data%22%3A%5B1.15%2C2.71%2C8.31%5D%2C%22backgroundColor%22%3A%22%23ED7D31%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%20%28FF%29%22%2C%22data%22%3A%5B0.75%2C1.81%2C5.37%5D%2C%22backgroundColor%22%3A%22%23FFC000%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%22%2C%22data%22%3A%5B0.99%2C2.19%2C5.5%5D%2C%22backgroundColor%22%3A%22%23A855F7%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%20%28FF%29%22%2C%22data%22%3A%5B0.45%2C1.01%2C2.37%5D%2C%22backgroundColor%22%3A%22%23D8B4FE%22%7D%2C%7B%22label%22%3A%22RN%20Animated%22%2C%22data%22%3A%5B0.36%2C0.71%2C1.6%5D%2C%22backgroundColor%22%3A%22%2370AD47%22%7D%5D%7D%2C%22options%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Android%20-%20Avg%20UI%20Thread%20Time%20per%20Frame%20%28ms%29%22%2C%22fontSize%22%3A16%7D%2C%22scales%22%3A%7B%22yAxes%22%3A%5B%7B%22ticks%22%3A%7B%22beginAtZero%22%3Atrue%7D%2C%22scaleLabel%22%3A%7B%22display%22%3Atrue%2C%22labelString%22%3A%22ms%22%7D%7D%5D%7D%2C%22plugins%22%3A%7B%22datalabels%22%3A%7B%22display%22%3Afalse%7D%7D%7D%7D) + +### iOS (release build, simulator, iPhone 16 Pro, M4 MacBook Pro) + +Display link callback time per frame (ms). Lower is better. + +![iOS benchmark](https://quickchart.io/chart?w=700&h=400&c=%7B%22type%22%3A%22bar%22%2C%22data%22%3A%7B%22labels%22%3A%5B%2210%20views%22%2C%22100%20views%22%2C%22500%20views%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Ease%22%2C%22data%22%3A%5B0.01%2C0.01%2C0.01%5D%2C%22backgroundColor%22%3A%22%235B9BD5%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%22%2C%22data%22%3A%5B1.33%2C3.72%2C6.84%5D%2C%22backgroundColor%22%3A%22%23ED7D31%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%20%28FF%29%22%2C%22data%22%3A%5B1.08%2C3.33%2C6.54%5D%2C%22backgroundColor%22%3A%22%23FFC000%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%22%2C%22data%22%3A%5B1.06%2C2.71%2C4.16%5D%2C%22backgroundColor%22%3A%22%23A855F7%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%20%28FF%29%22%2C%22data%22%3A%5B0.63%2C2.48%2C3.7%5D%2C%22backgroundColor%22%3A%22%23D8B4FE%22%7D%2C%7B%22label%22%3A%22RN%20Animated%22%2C%22data%22%3A%5B0.83%2C3.32%2C4.91%5D%2C%22backgroundColor%22%3A%22%2370AD47%22%7D%5D%7D%2C%22options%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22iOS%20-%20Avg%20Display%20Link%20Callback%20Time%20per%20Frame%20%28ms%29%22%2C%22fontSize%22%3A16%7D%2C%22scales%22%3A%7B%22yAxes%22%3A%5B%7B%22ticks%22%3A%7B%22beginAtZero%22%3Atrue%7D%2C%22scaleLabel%22%3A%7B%22display%22%3Atrue%2C%22labelString%22%3A%22ms%22%7D%7D%5D%7D%2C%22plugins%22%3A%7B%22datalabels%22%3A%7B%22display%22%3Afalse%7D%7D%7D%7D) + +Ease stays near zero because animations run entirely on platform APIs (Core Animation render server on iOS, ObjectAnimator on Android) — no JS thread, no display link callbacks, no shadow tree updates. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). + ## How It Works `EaseView` is a native Fabric component. The JS side flattens your `animate` and `transition` props into flat native props. When those props change, the native view: diff --git a/example/package.json b/example/package.json index 53e7b2d..d21ccc9 100644 --- a/example/package.json +++ b/example/package.json @@ -48,5 +48,13 @@ }, "resolutions": { "@react-native/gradle-plugin": "0.83.0" + }, + "reanimated": { + "staticFeatureFlags": { + "ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS": true, + "IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS": true, + "USE_SYNCHRONIZABLE_FOR_MUTABLES": true, + "DISABLE_COMMIT_PAUSING_MECHANISM": true + } } } From a7d98864f69687c6966fe514b9bb22247787caa6 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Mar 2026 16:39:58 +0800 Subject: [PATCH 4/8] fix: remove extra detail from benchmark description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad19824..60e53c2 100644 --- a/README.md +++ b/README.md @@ -443,7 +443,7 @@ Display link callback time per frame (ms). Lower is better. ![iOS benchmark](https://quickchart.io/chart?w=700&h=400&c=%7B%22type%22%3A%22bar%22%2C%22data%22%3A%7B%22labels%22%3A%5B%2210%20views%22%2C%22100%20views%22%2C%22500%20views%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Ease%22%2C%22data%22%3A%5B0.01%2C0.01%2C0.01%5D%2C%22backgroundColor%22%3A%22%235B9BD5%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%22%2C%22data%22%3A%5B1.33%2C3.72%2C6.84%5D%2C%22backgroundColor%22%3A%22%23ED7D31%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%20%28FF%29%22%2C%22data%22%3A%5B1.08%2C3.33%2C6.54%5D%2C%22backgroundColor%22%3A%22%23FFC000%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%22%2C%22data%22%3A%5B1.06%2C2.71%2C4.16%5D%2C%22backgroundColor%22%3A%22%23A855F7%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%20%28FF%29%22%2C%22data%22%3A%5B0.63%2C2.48%2C3.7%5D%2C%22backgroundColor%22%3A%22%23D8B4FE%22%7D%2C%7B%22label%22%3A%22RN%20Animated%22%2C%22data%22%3A%5B0.83%2C3.32%2C4.91%5D%2C%22backgroundColor%22%3A%22%2370AD47%22%7D%5D%7D%2C%22options%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22iOS%20-%20Avg%20Display%20Link%20Callback%20Time%20per%20Frame%20%28ms%29%22%2C%22fontSize%22%3A16%7D%2C%22scales%22%3A%7B%22yAxes%22%3A%5B%7B%22ticks%22%3A%7B%22beginAtZero%22%3Atrue%7D%2C%22scaleLabel%22%3A%7B%22display%22%3Atrue%2C%22labelString%22%3A%22ms%22%7D%7D%5D%7D%2C%22plugins%22%3A%7B%22datalabels%22%3A%7B%22display%22%3Afalse%7D%7D%7D%7D) -Ease stays near zero because animations run entirely on platform APIs (Core Animation render server on iOS, ObjectAnimator on Android) — no JS thread, no display link callbacks, no shadow tree updates. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). +Ease stays near zero because animations run entirely on platform APIs (Core Animation render server on iOS, ObjectAnimator on Android). Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). ## How It Works From 8d71a193eef0ac97c386a36e4522398587b8ac20 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Mar 2026 16:40:29 +0800 Subject: [PATCH 5/8] docs: clarify why Ease shows ~0ms on iOS in benchmark --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 60e53c2..7b36e0c 100644 --- a/README.md +++ b/README.md @@ -443,7 +443,7 @@ Display link callback time per frame (ms). Lower is better. ![iOS benchmark](https://quickchart.io/chart?w=700&h=400&c=%7B%22type%22%3A%22bar%22%2C%22data%22%3A%7B%22labels%22%3A%5B%2210%20views%22%2C%22100%20views%22%2C%22500%20views%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Ease%22%2C%22data%22%3A%5B0.01%2C0.01%2C0.01%5D%2C%22backgroundColor%22%3A%22%235B9BD5%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%22%2C%22data%22%3A%5B1.33%2C3.72%2C6.84%5D%2C%22backgroundColor%22%3A%22%23ED7D31%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%20%28FF%29%22%2C%22data%22%3A%5B1.08%2C3.33%2C6.54%5D%2C%22backgroundColor%22%3A%22%23FFC000%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%22%2C%22data%22%3A%5B1.06%2C2.71%2C4.16%5D%2C%22backgroundColor%22%3A%22%23A855F7%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%20%28FF%29%22%2C%22data%22%3A%5B0.63%2C2.48%2C3.7%5D%2C%22backgroundColor%22%3A%22%23D8B4FE%22%7D%2C%7B%22label%22%3A%22RN%20Animated%22%2C%22data%22%3A%5B0.83%2C3.32%2C4.91%5D%2C%22backgroundColor%22%3A%22%2370AD47%22%7D%5D%7D%2C%22options%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22iOS%20-%20Avg%20Display%20Link%20Callback%20Time%20per%20Frame%20%28ms%29%22%2C%22fontSize%22%3A16%7D%2C%22scales%22%3A%7B%22yAxes%22%3A%5B%7B%22ticks%22%3A%7B%22beginAtZero%22%3Atrue%7D%2C%22scaleLabel%22%3A%7B%22display%22%3Atrue%2C%22labelString%22%3A%22ms%22%7D%7D%5D%7D%2C%22plugins%22%3A%7B%22datalabels%22%3A%7B%22display%22%3Afalse%7D%7D%7D%7D) -Ease stays near zero because animations run entirely on platform APIs (Core Animation render server on iOS, ObjectAnimator on Android). Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). +Ease stays near zero because animations run entirely on platform APIs. On iOS, Core Animation runs on a separate render server process off the main thread, which is why Ease shows ~0ms. On Android, ObjectAnimator runs on the UI thread but is significantly lighter than other approaches. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). ## How It Works From adecc1d4cb47980b26cc92a440ba8d66e048eea3 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Mar 2026 17:17:47 +0800 Subject: [PATCH 6/8] docs: use GitHub-hosted images for benchmark charts --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b36e0c..fbae63a 100644 --- a/README.md +++ b/README.md @@ -435,13 +435,13 @@ The example app includes a benchmark that measures per-frame animation overhead UI thread time per frame: anim + layout + draw (ms). Lower is better. -![Android benchmark](https://quickchart.io/chart?w=700&h=400&c=%7B%22type%22%3A%22bar%22%2C%22data%22%3A%7B%22labels%22%3A%5B%2210%20views%22%2C%22100%20views%22%2C%22500%20views%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Ease%22%2C%22data%22%3A%5B0.21%2C0.36%2C0.6%5D%2C%22backgroundColor%22%3A%22%235B9BD5%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%22%2C%22data%22%3A%5B1.15%2C2.71%2C8.31%5D%2C%22backgroundColor%22%3A%22%23ED7D31%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%20%28FF%29%22%2C%22data%22%3A%5B0.75%2C1.81%2C5.37%5D%2C%22backgroundColor%22%3A%22%23FFC000%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%22%2C%22data%22%3A%5B0.99%2C2.19%2C5.5%5D%2C%22backgroundColor%22%3A%22%23A855F7%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%20%28FF%29%22%2C%22data%22%3A%5B0.45%2C1.01%2C2.37%5D%2C%22backgroundColor%22%3A%22%23D8B4FE%22%7D%2C%7B%22label%22%3A%22RN%20Animated%22%2C%22data%22%3A%5B0.36%2C0.71%2C1.6%5D%2C%22backgroundColor%22%3A%22%2370AD47%22%7D%5D%7D%2C%22options%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Android%20-%20Avg%20UI%20Thread%20Time%20per%20Frame%20%28ms%29%22%2C%22fontSize%22%3A16%7D%2C%22scales%22%3A%7B%22yAxes%22%3A%5B%7B%22ticks%22%3A%7B%22beginAtZero%22%3Atrue%7D%2C%22scaleLabel%22%3A%7B%22display%22%3Atrue%2C%22labelString%22%3A%22ms%22%7D%7D%5D%7D%2C%22plugins%22%3A%7B%22datalabels%22%3A%7B%22display%22%3Afalse%7D%7D%7D%7D) +![Android benchmark](https://github.com/user-attachments/assets/f0e5cf26-76be-4dd3-ae04-e17c6d13b49c) ### iOS (release build, simulator, iPhone 16 Pro, M4 MacBook Pro) Display link callback time per frame (ms). Lower is better. -![iOS benchmark](https://quickchart.io/chart?w=700&h=400&c=%7B%22type%22%3A%22bar%22%2C%22data%22%3A%7B%22labels%22%3A%5B%2210%20views%22%2C%22100%20views%22%2C%22500%20views%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22Ease%22%2C%22data%22%3A%5B0.01%2C0.01%2C0.01%5D%2C%22backgroundColor%22%3A%22%235B9BD5%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%22%2C%22data%22%3A%5B1.33%2C3.72%2C6.84%5D%2C%22backgroundColor%22%3A%22%23ED7D31%22%7D%2C%7B%22label%22%3A%22Reanimated%20SV%20%28FF%29%22%2C%22data%22%3A%5B1.08%2C3.33%2C6.54%5D%2C%22backgroundColor%22%3A%22%23FFC000%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%22%2C%22data%22%3A%5B1.06%2C2.71%2C4.16%5D%2C%22backgroundColor%22%3A%22%23A855F7%22%7D%2C%7B%22label%22%3A%22Reanimated%20CSS%20%28FF%29%22%2C%22data%22%3A%5B0.63%2C2.48%2C3.7%5D%2C%22backgroundColor%22%3A%22%23D8B4FE%22%7D%2C%7B%22label%22%3A%22RN%20Animated%22%2C%22data%22%3A%5B0.83%2C3.32%2C4.91%5D%2C%22backgroundColor%22%3A%22%2370AD47%22%7D%5D%7D%2C%22options%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22iOS%20-%20Avg%20Display%20Link%20Callback%20Time%20per%20Frame%20%28ms%29%22%2C%22fontSize%22%3A16%7D%2C%22scales%22%3A%7B%22yAxes%22%3A%5B%7B%22ticks%22%3A%7B%22beginAtZero%22%3Atrue%7D%2C%22scaleLabel%22%3A%7B%22display%22%3Atrue%2C%22labelString%22%3A%22ms%22%7D%7D%5D%7D%2C%22plugins%22%3A%7B%22datalabels%22%3A%7B%22display%22%3Afalse%7D%7D%7D%7D) +![iOS benchmark](https://github.com/user-attachments/assets/c39a7a71-bf21-4276-b02f-b29983989832) Ease stays near zero because animations run entirely on platform APIs. On iOS, Core Animation runs on a separate render server process off the main thread, which is why Ease shows ~0ms. On Android, ObjectAnimator runs on the UI thread but is significantly lighter than other approaches. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). From 7fa9c836528d8391a162b6700ea71300f14d77f7 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Mar 2026 17:20:09 +0800 Subject: [PATCH 7/8] docs: add detailed benchmark numbers in collapsible sections --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index fbae63a..5844bc1 100644 --- a/README.md +++ b/README.md @@ -437,12 +437,46 @@ UI thread time per frame: anim + layout + draw (ms). Lower is better. ![Android benchmark](https://github.com/user-attachments/assets/f0e5cf26-76be-4dd3-ae04-e17c6d13b49c) +
+Detailed numbers + +| Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated | +|-------|--------|------|---------------|---------------------|----------------|----------------------|-------------| +| 10 | Avg | 0.21 | 1.15 | 0.75 | 0.99 | 0.45 | 0.36 | +| 10 | P95 | 0.33 | 1.70 | 1.53 | 1.44 | 0.80 | 0.62 | +| 10 | P99 | 0.48 | 1.94 | 2.26 | 1.62 | 1.35 | 0.98 | +| 100 | Avg | 0.36 | 2.71 | 1.81 | 2.19 | 1.01 | 0.71 | +| 100 | P95 | 0.56 | 3.09 | 2.29 | 2.67 | 1.91 | 1.08 | +| 100 | P99 | 0.71 | 3.20 | 2.63 | 2.97 | 2.25 | 1.36 | +| 500 | Avg | 0.60 | 8.31 | 5.37 | 5.50 | 2.37 | 1.60 | +| 500 | P95 | 0.75 | 9.26 | 6.36 | 6.34 | 2.86 | 1.88 | +| 500 | P99 | 0.87 | 9.59 | 6.89 | 6.88 | 3.22 | 3.84 | + +
+ ### iOS (release build, simulator, iPhone 16 Pro, M4 MacBook Pro) Display link callback time per frame (ms). Lower is better. ![iOS benchmark](https://github.com/user-attachments/assets/c39a7a71-bf21-4276-b02f-b29983989832) +
+Detailed numbers + +| Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated | +|-------|--------|------|---------------|---------------------|----------------|----------------------|-------------| +| 10 | Avg | 0.01 | 1.33 | 1.08 | 1.06 | 0.63 | 0.83 | +| 10 | P95 | 0.02 | 1.67 | 1.59 | 1.34 | 1.01 | 1.18 | +| 10 | P99 | 0.03 | 1.90 | 1.68 | 1.50 | 1.08 | 1.31 | +| 100 | Avg | 0.01 | 3.72 | 3.33 | 2.71 | 2.48 | 3.32 | +| 100 | P95 | 0.01 | 5.21 | 4.50 | 3.83 | 3.39 | 4.28 | +| 100 | P99 | 0.02 | 5.68 | 4.75 | 4.91 | 3.79 | 4.55 | +| 500 | Avg | 0.01 | 6.84 | 6.54 | 4.16 | 3.70 | 4.91 | +| 500 | P95 | 0.01 | 7.69 | 7.32 | 4.59 | 4.22 | 5.66 | +| 500 | P99 | 0.02 | 8.10 | 7.45 | 4.71 | 4.33 | 5.89 | + +
+ Ease stays near zero because animations run entirely on platform APIs. On iOS, Core Animation runs on a separate render server process off the main thread, which is why Ease shows ~0ms. On Android, ObjectAnimator runs on the UI thread but is significantly lighter than other approaches. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/). ## How It Works From 82bfc2d9aca455c778713acc5b98a6c86cae7452 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Mar 2026 17:22:58 +0800 Subject: [PATCH 8/8] refactor: clean up unused fields from frame-metrics module Remove unused return values (avgFrameTime, p95/p99FrameTime, droppedFrames, totalFrames, frameDurations, p95/p99AnimationTime) from both native modules and TypeScript types. Also remove the separate CADisplayLink for frame delivery timing on iOS since we only use the swizzle-based callback measurement. --- .../framemetrics/FrameMetricsModule.kt | 29 +-------- .../ios/FrameMetricsModule.swift | 62 ++----------------- example/modules/frame-metrics/src/index.ts | 20 +----- 3 files changed, 9 insertions(+), 102 deletions(-) diff --git a/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt index bb857ef..aee368b 100644 --- a/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt +++ b/example/modules/frame-metrics/android/src/main/java/expo/modules/framemetrics/FrameMetricsModule.kt @@ -9,7 +9,6 @@ import expo.modules.kotlin.modules.ModuleDefinition import java.util.concurrent.CopyOnWriteArrayList private data class FrameSample( - val totalMs: Double, val animationMs: Double, val layoutMs: Double, val drawMs: Double, @@ -35,7 +34,6 @@ class FrameMetricsModule : Module() { val l = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> frameSamples.add(FrameSample( - totalMs = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1_000_000.0, animationMs = frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION) / 1_000_000.0, layoutMs = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) / 1_000_000.0, drawMs = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION) / 1_000_000.0, @@ -65,45 +63,22 @@ class FrameMetricsModule : Module() { val samples = frameSamples.toList() if (samples.isEmpty()) { return@Function mapOf( - "avgFrameTime" to 0.0, - "p95FrameTime" to 0.0, - "p99FrameTime" to 0.0, - "droppedFrames" to 0, - "totalFrames" to 0, - "frameDurations" to emptyList(), - "avgAnimationTime" to 0.0, - "p95AnimationTime" to 0.0, - "p99AnimationTime" to 0.0, "avgUiThreadTime" to 0.0, "p95UiThreadTime" to 0.0, "p99UiThreadTime" to 0.0, + "avgAnimationTime" to 0.0, "avgLayoutTime" to 0.0, "avgDrawTime" to 0.0, ) } - val totalDurations = samples.map { it.totalMs }.sorted() - val avg = totalDurations.average() - val p95 = totalDurations[(totalDurations.size * 0.95).toInt().coerceAtMost(totalDurations.size - 1)] - val p99 = totalDurations[(totalDurations.size * 0.99).toInt().coerceAtMost(totalDurations.size - 1)] - val dropped = totalDurations.count { it > 16.67 } - - val animDurations = samples.map { it.animationMs }.sorted() val uiThreadDurations = samples.map { it.animationMs + it.layoutMs + it.drawMs }.sorted() mapOf( - "avgFrameTime" to avg, - "p95FrameTime" to p95, - "p99FrameTime" to p99, - "droppedFrames" to dropped, - "totalFrames" to totalDurations.size, - "frameDurations" to samples.map { it.totalMs }, - "avgAnimationTime" to animDurations.average(), - "p95AnimationTime" to animDurations[(animDurations.size * 0.95).toInt().coerceAtMost(animDurations.size - 1)], - "p99AnimationTime" to animDurations[(animDurations.size * 0.99).toInt().coerceAtMost(animDurations.size - 1)], "avgUiThreadTime" to uiThreadDurations.average(), "p95UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.95).toInt().coerceAtMost(uiThreadDurations.size - 1)], "p99UiThreadTime" to uiThreadDurations[(uiThreadDurations.size * 0.99).toInt().coerceAtMost(uiThreadDurations.size - 1)], + "avgAnimationTime" to samples.map { it.animationMs }.average(), "avgLayoutTime" to samples.map { it.layoutMs }.average(), "avgDrawTime" to samples.map { it.drawMs }.average(), ) diff --git a/example/modules/frame-metrics/ios/FrameMetricsModule.swift b/example/modules/frame-metrics/ios/FrameMetricsModule.swift index 2750462..6ddac6d 100644 --- a/example/modules/frame-metrics/ios/FrameMetricsModule.swift +++ b/example/modules/frame-metrics/ios/FrameMetricsModule.swift @@ -98,10 +98,6 @@ private func swizzleDisplayLinkFactory() { // MARK: - Module public final class FrameMetricsModule: Module { - private var displayLink: CADisplayLink? - private var frameDurations: [Double] = [] - private var lastTimestamp: CFTimeInterval = 0 - public func definition() -> ModuleDefinition { Name("FrameMetrics") @@ -110,82 +106,34 @@ public final class FrameMetricsModule: Module { } Function("startCollecting") { - self.frameDurations = [] - self.lastTimestamp = 0 - let collector = FrameCallbackCollector.shared collector.reset() collector.isCollecting = true - - DispatchQueue.main.async { - self.displayLink?.invalidate() - let link = CADisplayLink(target: self, selector: #selector(self.onFrame(_:))) - link.add(to: .main, forMode: .common) - self.displayLink = link - } } Function("stopCollecting") { () -> [String: Any] in - DispatchQueue.main.sync { - self.displayLink?.invalidate() - self.displayLink = nil - } - let collector = FrameCallbackCollector.shared collector.finalize() let workTimes = collector.frameWorkTimes - let durations = self.frameDurations - guard !durations.isEmpty else { + guard !workTimes.isEmpty else { return [ - "avgFrameTime": 0, - "p95FrameTime": 0, - "p99FrameTime": 0, - "droppedFrames": 0, - "totalFrames": 0, - "frameDurations": [] as [Double], "avgUiThreadTime": 0, "p95UiThreadTime": 0, "p99UiThreadTime": 0, ] } - let sorted = durations.sorted() + let sorted = workTimes.sorted() let avg = sorted.reduce(0, +) / Double(sorted.count) let p95 = sorted[min(Int(Double(sorted.count) * 0.95), sorted.count - 1)] let p99 = sorted[min(Int(Double(sorted.count) * 0.99), sorted.count - 1)] - let dropped = sorted.filter { $0 > 16.67 }.count - - var avgWork: Double = 0 - var p95Work: Double = 0 - var p99Work: Double = 0 - if !workTimes.isEmpty { - let sortedWork = workTimes.sorted() - avgWork = sortedWork.reduce(0, +) / Double(sortedWork.count) - p95Work = sortedWork[min(Int(Double(sortedWork.count) * 0.95), sortedWork.count - 1)] - p99Work = sortedWork[min(Int(Double(sortedWork.count) * 0.99), sortedWork.count - 1)] - } return [ - "avgFrameTime": avg, - "p95FrameTime": p95, - "p99FrameTime": p99, - "droppedFrames": dropped, - "totalFrames": sorted.count, - "frameDurations": durations, - "avgUiThreadTime": avgWork, - "p95UiThreadTime": p95Work, - "p99UiThreadTime": p99Work, + "avgUiThreadTime": avg, + "p95UiThreadTime": p95, + "p99UiThreadTime": p99, ] } } - - @objc private func onFrame(_ link: CADisplayLink) { - let now = link.timestamp - if lastTimestamp > 0 { - let duration = (now - lastTimestamp) * 1000 - frameDurations.append(duration) - } - lastTimestamp = now - } } diff --git a/example/modules/frame-metrics/src/index.ts b/example/modules/frame-metrics/src/index.ts index 70cba9c..f23039f 100644 --- a/example/modules/frame-metrics/src/index.ts +++ b/example/modules/frame-metrics/src/index.ts @@ -1,30 +1,14 @@ import { NativeModule, requireNativeModule, Platform } from 'expo-modules-core'; interface FrameMetricsResult { - /** Average frame duration in ms */ - avgFrameTime: number; - /** P95 frame duration in ms */ - p95FrameTime: number; - /** P99 frame duration in ms */ - p99FrameTime: number; - /** Number of frames that exceeded the frame budget */ - droppedFrames: number; - /** Total frames collected */ - totalFrames: number; - /** All frame durations in ms */ - frameDurations: number[]; - /** Average time spent evaluating animators (Android only) */ - avgAnimationTime?: number; - /** P95 animation time (Android only) */ - p95AnimationTime?: number; - /** P99 animation time (Android only) */ - p99AnimationTime?: number; /** Average UI/main thread work time per frame (ms) */ avgUiThreadTime: number; /** P95 UI/main thread work time (ms) */ p95UiThreadTime: number; /** P99 UI/main thread work time (ms) */ p99UiThreadTime: number; + /** Average time spent evaluating animators (Android only) */ + avgAnimationTime?: number; /** Average layout/measure time (Android only) */ avgLayoutTime?: number; /** Average draw time (Android only) */