Skip to content
Merged
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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,58 @@ 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://github.com/user-attachments/assets/f0e5cf26-76be-4dd3-ae04-e17c6d13b49c)

<details>
<summary>Detailed numbers</summary>

| 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 |

</details>

### 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)

<details>
<summary>Detailed numbers</summary>

| 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 |

</details>

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

`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:
Expand Down
4 changes: 4 additions & 0 deletions example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ web-build/
# Expo prebuild generated dirs
android/
ios/

# Keep local modules native code
!modules/**/android/
!modules/**/ios/
12 changes: 12 additions & 0 deletions example/modules/frame-metrics/android/build.gradle
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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 animationMs: Double,
val layoutMs: Double,
val drawMs: Double,
)

class FrameMetricsModule : Module() {
private val frameSamples = CopyOnWriteArrayList<FrameSample>()
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(
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(
"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 uiThreadDurations = samples.map { it.animationMs + it.layoutMs + it.drawMs }.sorted()

mapOf(
"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(),
)
}
}
}
9 changes: 9 additions & 0 deletions example/modules/frame-metrics/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["FrameMetricsModule"]
},
"android": {
"modules": ["expo.modules.framemetrics.FrameMetricsModule"]
}
}
22 changes: 22 additions & 0 deletions example/modules/frame-metrics/ios/FrameMetrics.podspec
Original file line number Diff line number Diff line change
@@ -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
139 changes: 139 additions & 0 deletions example/modules/frame-metrics/ios/FrameMetricsModule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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 {
public func definition() -> ModuleDefinition {
Name("FrameMetrics")

OnCreate {
swizzleDisplayLinkFactory()
}

Function("startCollecting") {
let collector = FrameCallbackCollector.shared
collector.reset()
collector.isCollecting = true
}

Function("stopCollecting") { () -> [String: Any] in
let collector = FrameCallbackCollector.shared
collector.finalize()
let workTimes = collector.frameWorkTimes

guard !workTimes.isEmpty else {
return [
"avgUiThreadTime": 0,
"p95UiThreadTime": 0,
"p99UiThreadTime": 0,
]
}

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)]

return [
"avgUiThreadTime": avg,
"p95UiThreadTime": p95,
"p99UiThreadTime": p99,
]
}
}
}
35 changes: 35 additions & 0 deletions example/modules/frame-metrics/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NativeModule, requireNativeModule, Platform } from 'expo-modules-core';

interface FrameMetricsResult {
/** 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) */
avgDrawTime?: number;
}

declare class FrameMetricsModuleType extends NativeModule {
startCollecting(): void;
stopCollecting(): FrameMetricsResult;
}

const mod = requireNativeModule<FrameMetricsModuleType>('FrameMetrics');

export const isAndroid = Platform.OS === 'android';

export function startCollecting(): void {
mod.startCollecting();
}

export function stopCollecting(): FrameMetricsResult {
return mod.stopCollecting();
}

export type { FrameMetricsResult };
8 changes: 8 additions & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Loading
Loading