@callstack/react-native-brownfield 5.3.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/android/build.gradle +1 -0
- package/android/consumer-rules.pro +6 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformance.kt +384 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformanceModule.kt +15 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactDelegateWrapper.kt +8 -5
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +78 -23
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfieldPackage.kt +1 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +89 -3
- package/android/src/main/java/com/callstack/reactnativebrownfield/constants/ReactNativeFragmentArgNames.kt +2 -0
- package/android/src/test/java/com/callstack/reactnativebrownfield/PerformanceMetricsTest.kt +54 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +59 -15
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +6 -8
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +6 -4
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/RNBundleTaskStubs.kt +27 -0
- package/ios/BrownfieldDisplayMetrics.swift +265 -0
- package/ios/BrownfieldPerformanceBridge.h +8 -0
- package/ios/BrownfieldPerformanceBridge.mm +103 -0
- package/ios/Expo/ExpoHostRuntime.swift +58 -14
- package/ios/Expo/ExpoUpdatesStartup.swift +49 -0
- package/ios/JSBundleLoadObserver.swift +56 -19
- package/ios/JSBundleTimingObserver.h +26 -0
- package/ios/JSBundleTimingObserver.m +140 -0
- package/ios/ReactHostPreloading.swift +7 -3
- package/ios/ReactNativeBrownfield.swift +82 -14
- package/ios/ReactNativeBrownfield.xcodeproj/project.pbxproj +16 -0
- package/ios/Vanilla/ReactNativeHostRuntime.swift +1 -1
- package/ios/Views/ReactNativeView.swift +21 -3
- package/ios/Views/ReactNativeViewController.swift +44 -42
- package/lib/commonjs/expo-config-plugin/template/android/consumer-rules.pro +7 -0
- package/lib/commonjs/index.js +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/expo-config-plugin/template/android/consumer-rules.pro +7 -0
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +7 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +7 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/expo-config-plugin/template/android/consumer-rules.pro +7 -0
- package/src/index.ts +13 -1
|
@@ -17,6 +17,10 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
|
|
|
17
17
|
private lateinit var permissionsCallback: Callback
|
|
18
18
|
private var permissionListener: PermissionListener? = null
|
|
19
19
|
private lateinit var moduleName: String
|
|
20
|
+
private var displaySession: BrownfieldDisplaySession? = null
|
|
21
|
+
|
|
22
|
+
/** Reattach after Fragment restoration; callbacks are intentionally not serialized. */
|
|
23
|
+
var onMetrics: OnDisplayMetrics? = null
|
|
20
24
|
|
|
21
25
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
22
26
|
/**
|
|
@@ -51,12 +55,34 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
|
|
|
51
55
|
container: ViewGroup?,
|
|
52
56
|
savedInstanceState: Bundle?
|
|
53
57
|
): View {
|
|
54
|
-
|
|
58
|
+
val args = arguments
|
|
59
|
+
// Only the measured factories set this argument. Plain fragments keep their props as is.
|
|
60
|
+
val isMeasured = args?.containsKey(ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY) == true
|
|
61
|
+
val session = if (isMeasured) {
|
|
62
|
+
BrownfieldDisplaySession(
|
|
63
|
+
moduleName,
|
|
64
|
+
args!!.getBoolean(ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY, false),
|
|
65
|
+
args.getBoolean(ReactNativeFragmentArgNames.ARG_COLLECT_THREAD_METRICS, false),
|
|
66
|
+
onMetrics
|
|
67
|
+
)
|
|
68
|
+
} else {
|
|
69
|
+
null
|
|
70
|
+
}
|
|
71
|
+
displaySession = session
|
|
72
|
+
val launchOptions = Bundle(
|
|
73
|
+
args?.getBundle(ReactNativeFragmentArgNames.ARG_LAUNCH_OPTIONS) ?: Bundle()
|
|
74
|
+
).apply { session?.let { putString("brownfieldPresentationID", it.id) } }
|
|
75
|
+
this.reactDelegate = ReactDelegateWrapper(
|
|
76
|
+
activity, this.reactHost, moduleName, launchOptions
|
|
77
|
+
)
|
|
78
|
+
return ReactNativeBrownfield.shared.createViewInternal(
|
|
55
79
|
activity,
|
|
56
80
|
moduleName,
|
|
57
81
|
this.reactDelegate as ReactDelegateWrapper,
|
|
58
|
-
launchOptions
|
|
59
|
-
|
|
82
|
+
launchOptions,
|
|
83
|
+
viewLifecycleOwner,
|
|
84
|
+
session,
|
|
85
|
+
true
|
|
60
86
|
)
|
|
61
87
|
}
|
|
62
88
|
|
|
@@ -69,9 +95,11 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
|
|
|
69
95
|
} catch (_: ClassCastException) {
|
|
70
96
|
(this.reactDelegate as ReactDelegateWrapper).onReactHostResume()
|
|
71
97
|
}
|
|
98
|
+
displaySession?.setResumed(true)
|
|
72
99
|
}
|
|
73
100
|
|
|
74
101
|
override fun onPause() {
|
|
102
|
+
displaySession?.cancel()
|
|
75
103
|
try {
|
|
76
104
|
super.onPause()
|
|
77
105
|
} catch (error: AssertionError) {
|
|
@@ -91,6 +119,12 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
|
|
|
91
119
|
}
|
|
92
120
|
}
|
|
93
121
|
|
|
122
|
+
override fun onDestroyView() {
|
|
123
|
+
displaySession?.cancel()
|
|
124
|
+
displaySession = null
|
|
125
|
+
super.onDestroyView()
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
override fun onRequestPermissionsResult(
|
|
95
129
|
requestCode: Int,
|
|
96
130
|
permissions: Array<String>,
|
|
@@ -143,6 +177,26 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
|
|
|
143
177
|
return fragment
|
|
144
178
|
}
|
|
145
179
|
|
|
180
|
+
@JvmStatic
|
|
181
|
+
@JvmOverloads
|
|
182
|
+
fun createReactNativeFragment(
|
|
183
|
+
moduleName: String,
|
|
184
|
+
initialProps: Bundle?,
|
|
185
|
+
waitForFullDisplay: Boolean,
|
|
186
|
+
collectThreadMetrics: Boolean = false,
|
|
187
|
+
onMetrics: OnDisplayMetrics? = null
|
|
188
|
+
): ReactNativeFragment {
|
|
189
|
+
return createReactNativeFragment(moduleName, initialProps).also { fragment ->
|
|
190
|
+
fragment.arguments?.putBoolean(
|
|
191
|
+
ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY, waitForFullDisplay
|
|
192
|
+
)
|
|
193
|
+
fragment.arguments?.putBoolean(
|
|
194
|
+
ReactNativeFragmentArgNames.ARG_COLLECT_THREAD_METRICS, collectThreadMetrics
|
|
195
|
+
)
|
|
196
|
+
fragment.onMetrics = onMetrics
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
146
200
|
@JvmStatic
|
|
147
201
|
fun createReactNativeFragment(
|
|
148
202
|
moduleName: String,
|
|
@@ -151,6 +205,22 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
|
|
|
151
205
|
return createReactNativeFragment(moduleName, PropsBundle.fromHashMap(initialProps))
|
|
152
206
|
}
|
|
153
207
|
|
|
208
|
+
@JvmStatic
|
|
209
|
+
@JvmOverloads
|
|
210
|
+
fun createReactNativeFragment(
|
|
211
|
+
moduleName: String,
|
|
212
|
+
initialProps: HashMap<String, *>,
|
|
213
|
+
waitForFullDisplay: Boolean,
|
|
214
|
+
collectThreadMetrics: Boolean = false,
|
|
215
|
+
onMetrics: OnDisplayMetrics? = null
|
|
216
|
+
): ReactNativeFragment = createReactNativeFragment(
|
|
217
|
+
moduleName,
|
|
218
|
+
PropsBundle.fromHashMap(initialProps),
|
|
219
|
+
waitForFullDisplay,
|
|
220
|
+
collectThreadMetrics,
|
|
221
|
+
onMetrics
|
|
222
|
+
)
|
|
223
|
+
|
|
154
224
|
@JvmStatic
|
|
155
225
|
fun createReactNativeFragment(
|
|
156
226
|
moduleName: String,
|
|
@@ -158,5 +228,21 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
|
|
|
158
228
|
): ReactNativeFragment {
|
|
159
229
|
return createReactNativeFragment(moduleName, initialProps.toHashMap())
|
|
160
230
|
}
|
|
231
|
+
|
|
232
|
+
@JvmStatic
|
|
233
|
+
@JvmOverloads
|
|
234
|
+
fun createReactNativeFragment(
|
|
235
|
+
moduleName: String,
|
|
236
|
+
initialProps: WritableMap,
|
|
237
|
+
waitForFullDisplay: Boolean,
|
|
238
|
+
collectThreadMetrics: Boolean = false,
|
|
239
|
+
onMetrics: OnDisplayMetrics? = null
|
|
240
|
+
): ReactNativeFragment = createReactNativeFragment(
|
|
241
|
+
moduleName,
|
|
242
|
+
initialProps.toHashMap(),
|
|
243
|
+
waitForFullDisplay,
|
|
244
|
+
collectThreadMetrics,
|
|
245
|
+
onMetrics
|
|
246
|
+
)
|
|
161
247
|
}
|
|
162
248
|
}
|
|
@@ -16,5 +16,7 @@ class ReactNativeFragmentArgNames private constructor() :
|
|
|
16
16
|
|
|
17
17
|
// re-export constants from ReactFragment to enable access
|
|
18
18
|
const val ARG_LAUNCH_OPTIONS: String = "arg_launch_options"
|
|
19
|
+
const val ARG_WAIT_FOR_FULL_DISPLAY = "arg_wait_for_full_display"
|
|
20
|
+
const val ARG_COLLECT_THREAD_METRICS = "arg_collect_thread_metrics"
|
|
19
21
|
}
|
|
20
22
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package com.callstack.reactnativebrownfield
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertNull
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class PerformanceMetricsTest {
|
|
8
|
+
@Test fun `pairs markers and sorts overlapping timeline`() {
|
|
9
|
+
val samples = JSBundleTimingAccumulator()
|
|
10
|
+
samples.record(BundleMarker.EVALUATION_START, 1, 20)
|
|
11
|
+
samples.record(BundleMarker.DOWNLOAD_START, 0, 10)
|
|
12
|
+
samples.record(BundleMarker.EVALUATION_END, 1, 40)
|
|
13
|
+
samples.record(BundleMarker.DOWNLOAD_END, 0, 30)
|
|
14
|
+
|
|
15
|
+
val result = samples.snapshot()
|
|
16
|
+
assertEquals(20.0, result.jsBundleLoadTime!!, 0.0)
|
|
17
|
+
assertEquals(20.0, result.jsBundleEvaluationTime!!, 0.0)
|
|
18
|
+
assertEquals(listOf("DOWNLOAD", "RUN_JS_BUNDLE"), result.timeline.map { it["tag"] })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test fun `keeps zero duration and rejects missing ambiguous and other instances`() {
|
|
22
|
+
val zero = JSBundleTimingAccumulator().apply {
|
|
23
|
+
record(BundleMarker.EVALUATION_START, 1, 5)
|
|
24
|
+
record(BundleMarker.EVALUATION_END, 1, 5)
|
|
25
|
+
}.snapshot()
|
|
26
|
+
assertEquals(0.0, zero.jsBundleEvaluationTime!!, 0.0)
|
|
27
|
+
|
|
28
|
+
val missing = JSBundleTimingAccumulator().apply {
|
|
29
|
+
record(BundleMarker.DOWNLOAD_END, 0, 8)
|
|
30
|
+
record(BundleMarker.EVALUATION_START, 2, 9)
|
|
31
|
+
record(BundleMarker.EVALUATION_END, 2, 10)
|
|
32
|
+
}.snapshot()
|
|
33
|
+
assertNull(missing.jsBundleLoadTime)
|
|
34
|
+
assertNull(missing.jsBundleEvaluationTime)
|
|
35
|
+
|
|
36
|
+
val ambiguous = JSBundleTimingAccumulator().apply {
|
|
37
|
+
record(BundleMarker.DOWNLOAD_START, 0, 1)
|
|
38
|
+
record(BundleMarker.DOWNLOAD_START, 0, 2)
|
|
39
|
+
record(BundleMarker.DOWNLOAD_END, 0, 3)
|
|
40
|
+
}.snapshot()
|
|
41
|
+
assertNull(ambiguous.jsBundleLoadTime)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test fun `frame accumulator includes unfinished deadline excess`() {
|
|
45
|
+
val samples = BrownfieldFrameSamples()
|
|
46
|
+
samples.begin(0)
|
|
47
|
+
samples.record(20_000_000, 10_000_000)
|
|
48
|
+
val result = samples.snapshot(50_000_000)
|
|
49
|
+
assertEquals(20.0, result.fps!!, 0.001)
|
|
50
|
+
assertEquals(0.6, result.busyRatio!!, 0.001)
|
|
51
|
+
assertEquals(50.0, result.sampledMs, 0.001)
|
|
52
|
+
assertEquals(1, result.frameCount)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -3,13 +3,16 @@ package com.callstack.react.brownfield.plugin
|
|
|
3
3
|
import com.android.build.api.dsl.AndroidSourceSet
|
|
4
4
|
import com.android.build.api.dsl.LibraryExtension
|
|
5
5
|
import com.android.build.api.variant.LibraryAndroidComponentsExtension
|
|
6
|
+
import com.android.build.api.variant.SourceDirectories
|
|
6
7
|
import com.callstack.react.brownfield.exceptions.NameSpaceNotFound
|
|
7
8
|
import com.callstack.react.brownfield.processors.VariantHelper
|
|
8
9
|
import com.callstack.react.brownfield.utils.Extension
|
|
9
10
|
import com.callstack.react.brownfield.utils.Utils
|
|
10
11
|
import com.callstack.react.brownfield.utils.capitalized
|
|
12
|
+
import org.gradle.api.GradleException
|
|
11
13
|
import org.gradle.api.Project
|
|
12
14
|
import org.gradle.api.Task
|
|
15
|
+
import org.gradle.api.file.DirectoryProperty
|
|
13
16
|
import org.gradle.api.tasks.Copy
|
|
14
17
|
import java.io.File
|
|
15
18
|
|
|
@@ -63,26 +66,67 @@ object RNSourceSets {
|
|
|
63
66
|
val capitalizedBundledAssetsVariantName = bundledAssetsVariantName.capitalized()
|
|
64
67
|
val appProject = getAppProject()
|
|
65
68
|
|
|
66
|
-
val
|
|
67
|
-
listOf(
|
|
68
|
-
// outputs for RN <= 0.81
|
|
69
|
-
"createBundle${capitalizedBundledAssetsVariantName}JsAndAssets",
|
|
70
|
-
// outputs for RN >= 0.82
|
|
71
|
-
"react/$bundledAssetsVariantName",
|
|
72
|
-
)
|
|
69
|
+
val jsBundleTaskName = "createBundle${capitalizedBundledAssetsVariantName}JsAndAssets"
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Wire the bundle task's own `jsBundleDir`/`resourcesDir` outputs as *generated*
|
|
73
|
+
* source directories
|
|
74
|
+
*/
|
|
75
|
+
wireGeneratedSourceDirectory(appProject, jsBundleTaskName, "getJsBundleDir", variant.sources.assets)
|
|
76
|
+
wireGeneratedSourceDirectory(appProject, jsBundleTaskName, "getResourcesDir", variant.sources.res)
|
|
79
77
|
|
|
80
78
|
if (Utils.hasExpoUpdates(appProject, variantName)) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
/**
|
|
80
|
+
* expo-updates registers `create<Variant>UpdatesResources` as
|
|
81
|
+
* `ExpoUpdatesPlugin.CreateUpdatesResourcesTask`, with a single
|
|
82
|
+
* `assetDir: DirectoryProperty` output (no `res` output exists for this task).
|
|
83
|
+
*/
|
|
84
|
+
val updatesResourcesTaskName = VariantHelper.getExpoUpdatesResourcesTaskName(variant.name)
|
|
85
|
+
wireGeneratedSourceDirectory(appProject, updatesResourcesTaskName, "getAssetDir", variant.sources.assets)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wires `taskName`'s `getterName` output (expected to be a `DirectoryProperty`) as a
|
|
92
|
+
* generated source directory for `sources`.
|
|
93
|
+
*
|
|
94
|
+
* Resolving the task by name (rather than importing its class) keeps this plugin from
|
|
95
|
+
* depending on RNGP's/expo-updates' internal task types. If the task, or its expected output
|
|
96
|
+
* property, isn't there, this fails the build immediately with a clear message instead of
|
|
97
|
+
* silently producing an empty/stale source directory.
|
|
98
|
+
*/
|
|
99
|
+
private fun wireGeneratedSourceDirectory(
|
|
100
|
+
appProject: Project,
|
|
101
|
+
taskName: String,
|
|
102
|
+
getterName: String,
|
|
103
|
+
sources: SourceDirectories?,
|
|
104
|
+
) {
|
|
105
|
+
if (sources == null) return
|
|
106
|
+
|
|
107
|
+
val task =
|
|
108
|
+
appProject.tasks.findByName(taskName)
|
|
109
|
+
?: throw GradleException(
|
|
110
|
+
"brownfield: expected task '$taskName' was not found in project " +
|
|
111
|
+
"'${appProject.path}'. Check that the plugin producing it is applied there.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
val getter =
|
|
115
|
+
runCatching { task.javaClass.getMethod(getterName) }.getOrElse {
|
|
116
|
+
throw GradleException(
|
|
117
|
+
"brownfield: task '$taskName' (${task.javaClass.name}) does not expose " +
|
|
118
|
+
"'$getterName()'. The plugin that registers it most likely changed its " +
|
|
119
|
+
"API; the brownfield Gradle plugin needs updating to match.",
|
|
120
|
+
it,
|
|
121
|
+
)
|
|
84
122
|
}
|
|
123
|
+
|
|
124
|
+
check(DirectoryProperty::class.java.isAssignableFrom(getter.returnType)) {
|
|
125
|
+
"brownfield: '$taskName.$getterName()' no longer returns a DirectoryProperty."
|
|
85
126
|
}
|
|
127
|
+
|
|
128
|
+
val taskProvider = appProject.tasks.named(taskName)
|
|
129
|
+
sources.addGeneratedSourceDirectory(taskProvider) { t -> getter.invoke(t) as DirectoryProperty }
|
|
86
130
|
}
|
|
87
131
|
|
|
88
132
|
private fun getLibraryNameSpace(): String {
|
|
@@ -102,9 +102,10 @@ class AgpCompatibilityMatrixTest {
|
|
|
102
102
|
File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n")
|
|
103
103
|
// Stands in for the RN app project. It has to be a real `com.android.application` because
|
|
104
104
|
// the brownfield plugin wires into AGP's own app-side tasks; the extra registrations cover
|
|
105
|
-
// the tasks the React Native Gradle plugin would otherwise contribute.
|
|
106
|
-
//
|
|
107
|
-
|
|
105
|
+
// the tasks the React Native Gradle plugin would otherwise contribute. The bundle tasks need
|
|
106
|
+
// their `jsBundleDir`/`resourcesDir` outputs, which the plugin wires as generated sources;
|
|
107
|
+
// the rest can stay empty, since this test is about AGP API compatibility.
|
|
108
|
+
writeRNBundleTaskStubs(File(projectDir, "app").apply { mkdirs() })
|
|
108
109
|
File(projectDir, "app/build.gradle.kts").writeText(
|
|
109
110
|
"""
|
|
110
111
|
plugins {
|
|
@@ -116,11 +117,8 @@ class AgpCompatibilityMatrixTest {
|
|
|
116
117
|
compileSdk = 34
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"createBundleDebugJsAndAssets",
|
|
122
|
-
"createBundleReleaseJsAndAssets",
|
|
123
|
-
).forEach { name -> tasks.register(name) }
|
|
120
|
+
tasks.register("generateAutolinkingPackageList")
|
|
121
|
+
apply(from = "$RN_BUNDLE_TASK_STUBS_SCRIPT")
|
|
124
122
|
""".trimIndent(),
|
|
125
123
|
)
|
|
126
124
|
|
|
@@ -64,10 +64,12 @@ class LegacyConsumerConfigurationTest {
|
|
|
64
64
|
""".trimIndent(),
|
|
65
65
|
)
|
|
66
66
|
File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n")
|
|
67
|
-
// ArtifactsResolver looks for an `app` project
|
|
68
|
-
// the task container,
|
|
69
|
-
|
|
70
|
-
File(projectDir, "app
|
|
67
|
+
// ArtifactsResolver looks for an `app` project. It only needs the RN bundle tasks whose
|
|
68
|
+
// outputs the plugin wires as generated sources; this test is about the task container,
|
|
69
|
+
// not discovery.
|
|
70
|
+
val appDir = File(projectDir, "app").apply { mkdirs() }
|
|
71
|
+
writeRNBundleTaskStubs(appDir)
|
|
72
|
+
File(appDir, "build.gradle.kts").writeText("apply(from = \"$RN_BUNDLE_TASK_STUBS_SCRIPT\")\n")
|
|
71
73
|
|
|
72
74
|
// The `tasks.register("removeDependenciesFromModuleFile")` block below is verbatim from
|
|
73
75
|
// the published setup docs. Do not "modernize" it — its whole purpose is to reproduce
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.plugin
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
|
|
5
|
+
internal const val RN_BUNDLE_TASK_STUBS_SCRIPT = "rn-bundle-task-stubs.gradle.kts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Writes a script plugin into [appDir] that stubs RNGP's `createBundle<Variant>JsAndAssets` tasks.
|
|
9
|
+
* The brownfield plugin wires their `jsBundleDir`/`resourcesDir` outputs as generated sources and
|
|
10
|
+
* fails fast when they are missing, so plain `tasks.register(name)` stubs are not enough.
|
|
11
|
+
* Apply it from the app build script with `apply(from = RN_BUNDLE_TASK_STUBS_SCRIPT)`.
|
|
12
|
+
*/
|
|
13
|
+
internal fun writeRNBundleTaskStubs(appDir: File) {
|
|
14
|
+
File(appDir, RN_BUNDLE_TASK_STUBS_SCRIPT).writeText(
|
|
15
|
+
"""
|
|
16
|
+
abstract class StubBundleTask : DefaultTask() {
|
|
17
|
+
@get:OutputDirectory abstract val jsBundleDir: DirectoryProperty
|
|
18
|
+
@get:OutputDirectory abstract val resourcesDir: DirectoryProperty
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
listOf(
|
|
22
|
+
"createBundleDebugJsAndAssets",
|
|
23
|
+
"createBundleReleaseJsAndAssets",
|
|
24
|
+
).forEach { name -> tasks.register<StubBundleTask>(name) }
|
|
25
|
+
""".trimIndent(),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Frame cadence, not CPU utilization. A missing sampler produces nil FPS/busyRatio.
|
|
4
|
+
@objcMembers public final class BrownfieldThreadMetrics: NSObject {
|
|
5
|
+
public let fps: NSNumber?
|
|
6
|
+
public let busyRatio: NSNumber?
|
|
7
|
+
public let sampledMs: Double
|
|
8
|
+
public let frameCount: Int
|
|
9
|
+
|
|
10
|
+
internal init(fps: Double?, busyRatio: Double?, sampledMs: Double, frameCount: Int) {
|
|
11
|
+
self.fps = fps.map { NSNumber(value: $0) }
|
|
12
|
+
self.busyRatio = busyRatio.map { NSNumber(value: $0) }
|
|
13
|
+
self.sampledMs = sampledMs
|
|
14
|
+
self.frameCount = frameCount
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@objcMembers public final class BrownfieldDisplayInterval: NSObject {
|
|
19
|
+
public let duration: Double
|
|
20
|
+
public let jsThread: BrownfieldThreadMetrics
|
|
21
|
+
public let uiThread: BrownfieldThreadMetrics
|
|
22
|
+
|
|
23
|
+
internal init(duration: Double, jsThread: BrownfieldThreadMetrics, uiThread: BrownfieldThreadMetrics) {
|
|
24
|
+
self.duration = duration
|
|
25
|
+
self.jsThread = jsThread
|
|
26
|
+
self.uiThread = uiThread
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// An immutable completed snapshot, delivered once through onMetrics.
|
|
31
|
+
@objcMembers public final class BrownfieldDisplayMetrics: NSObject {
|
|
32
|
+
public let presentationID: String
|
|
33
|
+
public let moduleName: String
|
|
34
|
+
public let initialDisplay: BrownfieldDisplayInterval?
|
|
35
|
+
public let fullDisplay: BrownfieldDisplayInterval?
|
|
36
|
+
public let isFinal: Bool
|
|
37
|
+
public let isCancelled: Bool
|
|
38
|
+
|
|
39
|
+
internal init(presentationID: String, moduleName: String, initialDisplay: BrownfieldDisplayInterval?,
|
|
40
|
+
fullDisplay: BrownfieldDisplayInterval?, isFinal: Bool, isCancelled: Bool = false) {
|
|
41
|
+
self.presentationID = presentationID
|
|
42
|
+
self.moduleName = moduleName
|
|
43
|
+
self.initialDisplay = initialDisplay
|
|
44
|
+
self.fullDisplay = fullDisplay
|
|
45
|
+
self.isFinal = isFinal
|
|
46
|
+
self.isCancelled = isCancelled
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Pure cadence accumulator, kept separate from run-loop and locking mechanics.
|
|
51
|
+
internal struct BrownfieldFrameSamples {
|
|
52
|
+
var start: CFTimeInterval?
|
|
53
|
+
var last: CFTimeInterval = 0
|
|
54
|
+
var budget: CFTimeInterval = 1 / 60
|
|
55
|
+
var frames = 0
|
|
56
|
+
var busy: CFTimeInterval = 0
|
|
57
|
+
|
|
58
|
+
mutating func begin(at now: CFTimeInterval) {
|
|
59
|
+
start = now
|
|
60
|
+
last = now
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
mutating func record(at now: CFTimeInterval, frameInterval: CFTimeInterval) {
|
|
64
|
+
budget = max(frameInterval, 1 / 240)
|
|
65
|
+
busy += max(0, now - last - budget)
|
|
66
|
+
frames += 1
|
|
67
|
+
last = now
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func snapshot(at now: CFTimeInterval) -> BrownfieldThreadMetrics {
|
|
71
|
+
guard let start, now > start else {
|
|
72
|
+
return BrownfieldThreadMetrics(fps: nil, busyRatio: nil, sampledMs: 0, frameCount: 0)
|
|
73
|
+
}
|
|
74
|
+
let elapsed = now - start
|
|
75
|
+
let blocked = busy + max(0, now - last - budget)
|
|
76
|
+
return BrownfieldThreadMetrics(fps: Double(frames) / elapsed,
|
|
77
|
+
busyRatio: min(1, blocked / elapsed), sampledMs: elapsed * 1000, frameCount: frames)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Lock protects the JS run-loop sampler from main-thread snapshots and cancellation.
|
|
82
|
+
internal final class BrownfieldFrameSampler: NSObject {
|
|
83
|
+
private let lock = NSLock()
|
|
84
|
+
private var link: CADisplayLink?
|
|
85
|
+
private var stopped = false
|
|
86
|
+
private var samples = BrownfieldFrameSamples()
|
|
87
|
+
|
|
88
|
+
func startOnCurrentRunLoop() {
|
|
89
|
+
lock.lock()
|
|
90
|
+
defer { lock.unlock() }
|
|
91
|
+
guard !stopped, link == nil else { return }
|
|
92
|
+
samples.begin(at: CACurrentMediaTime())
|
|
93
|
+
let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
|
|
94
|
+
self.link = link
|
|
95
|
+
link.add(to: .current, forMode: .common)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@objc private func tick(_ link: CADisplayLink) {
|
|
99
|
+
lock.lock()
|
|
100
|
+
defer { lock.unlock() }
|
|
101
|
+
guard !stopped else { return }
|
|
102
|
+
samples.record(at: CACurrentMediaTime(), frameInterval: link.targetTimestamp - link.timestamp)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func snapshot(at now: CFTimeInterval) -> BrownfieldThreadMetrics {
|
|
106
|
+
lock.lock()
|
|
107
|
+
defer { lock.unlock() }
|
|
108
|
+
return samples.snapshot(at: now)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func stop() {
|
|
112
|
+
lock.lock()
|
|
113
|
+
stopped = true
|
|
114
|
+
let oldLink = link
|
|
115
|
+
link = nil
|
|
116
|
+
lock.unlock()
|
|
117
|
+
oldLink?.invalidate()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// All session state belongs to the main thread. The JS sampler alone crosses threads.
|
|
122
|
+
internal final class BrownfieldDisplaySession {
|
|
123
|
+
let id = UUID().uuidString
|
|
124
|
+
let moduleName: String
|
|
125
|
+
private let waitForFullDisplay: Bool
|
|
126
|
+
private var callback: ((BrownfieldDisplayMetrics) -> Void)?
|
|
127
|
+
private let start = CACurrentMediaTime()
|
|
128
|
+
private let ui: BrownfieldFrameSampler?
|
|
129
|
+
private let js: BrownfieldFrameSampler?
|
|
130
|
+
private var initial: BrownfieldDisplayInterval?
|
|
131
|
+
private var fullWasMarked = false
|
|
132
|
+
private var finished = false
|
|
133
|
+
private var jsScheduled = false
|
|
134
|
+
private var tokens: [NSObjectProtocol] = []
|
|
135
|
+
/// Set once the appearance probe is inside the React Native root view.
|
|
136
|
+
var hasRootView = false
|
|
137
|
+
|
|
138
|
+
init(moduleName: String, waitForFullDisplay: Bool, collectThreadMetrics: Bool = false, callback: ((BrownfieldDisplayMetrics) -> Void)?) {
|
|
139
|
+
precondition(Thread.isMainThread)
|
|
140
|
+
self.moduleName = moduleName
|
|
141
|
+
self.waitForFullDisplay = waitForFullDisplay
|
|
142
|
+
self.callback = callback
|
|
143
|
+
let shouldSample = collectThreadMetrics && callback != nil
|
|
144
|
+
ui = shouldSample ? BrownfieldFrameSampler() : nil
|
|
145
|
+
js = shouldSample ? BrownfieldFrameSampler() : nil
|
|
146
|
+
guard callback != nil else {
|
|
147
|
+
finished = true
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
ui?.startOnCurrentRunLoop()
|
|
151
|
+
scheduleJS()
|
|
152
|
+
if js != nil {
|
|
153
|
+
tokens.append(NotificationCenter.default.addObserver(
|
|
154
|
+
forName: Notification.Name("BrownfieldPerformanceBridgeReady"), object: nil, queue: .main
|
|
155
|
+
) { [weak self] _ in
|
|
156
|
+
guard let self else { return }
|
|
157
|
+
if self.jsScheduled { self.cancel() } else { self.scheduleJS() }
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
if waitForFullDisplay {
|
|
161
|
+
tokens.append(NotificationCenter.default.addObserver(
|
|
162
|
+
forName: Notification.Name("BrownfieldMarkFullyDisplayed"), object: nil, queue: .main
|
|
163
|
+
) { [weak self] notification in
|
|
164
|
+
guard let self, notification.object as? String == self.id, !self.finished else { return }
|
|
165
|
+
self.fullWasMarked = true
|
|
166
|
+
if self.initial != nil { self.complete() }
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
// Cancellation releases samplers when the application stops drawing.
|
|
170
|
+
tokens.append(NotificationCenter.default.addObserver(
|
|
171
|
+
forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main
|
|
172
|
+
) { [weak self] _ in self?.cancel() })
|
|
173
|
+
tokens.append(NotificationCenter.default.addObserver(
|
|
174
|
+
forName: Notification.Name("RCTJavaScriptDidFailToLoadNotification"), object: nil, queue: .main
|
|
175
|
+
) { [weak self] _ in self?.cancel() })
|
|
176
|
+
tokens.append(NotificationCenter.default.addObserver(
|
|
177
|
+
forName: Notification.Name("BrownfieldPerformanceStop"), object: nil, queue: .main
|
|
178
|
+
) { [weak self] _ in self?.cancel() })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private func scheduleJS() {
|
|
182
|
+
guard !finished, !jsScheduled, let sampler = js else { return }
|
|
183
|
+
jsScheduled = BrownfieldPerformanceBridge.dispatch(toJavaScript: {
|
|
184
|
+
sampler.startOnCurrentRunLoop()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private func interval() -> BrownfieldDisplayInterval {
|
|
189
|
+
let now = CACurrentMediaTime()
|
|
190
|
+
return BrownfieldDisplayInterval(duration: (now - start) * 1000,
|
|
191
|
+
jsThread: js?.snapshot(at: now) ?? BrownfieldFrameSamples().snapshot(at: now),
|
|
192
|
+
uiThread: ui?.snapshot(at: now) ?? BrownfieldFrameSamples().snapshot(at: now))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func appeared() {
|
|
196
|
+
guard !finished, initial == nil else { return }
|
|
197
|
+
initial = interval()
|
|
198
|
+
if !waitForFullDisplay || fullWasMarked {
|
|
199
|
+
complete()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private func complete() {
|
|
204
|
+
guard !finished, let initial else { return }
|
|
205
|
+
let full = waitForFullDisplay ? interval() : initial
|
|
206
|
+
finished = true
|
|
207
|
+
let result = BrownfieldDisplayMetrics(presentationID: id, moduleName: moduleName,
|
|
208
|
+
initialDisplay: initial, fullDisplay: full, isFinal: true)
|
|
209
|
+
let completion = callback
|
|
210
|
+
callback = nil
|
|
211
|
+
stop()
|
|
212
|
+
completion?(result)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func cancel() {
|
|
216
|
+
guard !finished else { return }
|
|
217
|
+
finished = true
|
|
218
|
+
callback = nil
|
|
219
|
+
stop()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private func stop() {
|
|
223
|
+
ui?.stop()
|
|
224
|
+
js?.stop()
|
|
225
|
+
tokens.forEach(NotificationCenter.default.removeObserver)
|
|
226
|
+
tokens.removeAll()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
deinit { stop() }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// A child probe preserves the RN root view's type, layout, and touch handling.
|
|
233
|
+
internal final class BrownfieldAppearanceProbe: UIView {
|
|
234
|
+
let session: BrownfieldDisplaySession
|
|
235
|
+
/// Set when a view controller hosts the root. The probe then reports appearance only while
|
|
236
|
+
/// that controller is visible, for a root that arrives after `viewDidAppear`.
|
|
237
|
+
var isHostVisible: (() -> Bool)?
|
|
238
|
+
private var hasAttached = false
|
|
239
|
+
|
|
240
|
+
init(session: BrownfieldDisplaySession) {
|
|
241
|
+
self.session = session
|
|
242
|
+
super.init(frame: .zero)
|
|
243
|
+
isUserInteractionEnabled = false
|
|
244
|
+
isHidden = true
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
248
|
+
|
|
249
|
+
override func didMoveToWindow() {
|
|
250
|
+
super.didMoveToWindow()
|
|
251
|
+
if window != nil {
|
|
252
|
+
hasAttached = true
|
|
253
|
+
if let isHostVisible {
|
|
254
|
+
if isHostVisible() { session.appeared() }
|
|
255
|
+
} else {
|
|
256
|
+
DispatchQueue.main.async { [weak self] in
|
|
257
|
+
guard let self, self.window != nil else { return }
|
|
258
|
+
self.session.appeared()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else if hasAttached {
|
|
262
|
+
session.cancel()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
@interface BrownfieldPerformanceBridge : NSObject
|
|
5
|
+
// Call on main. Returns NO until the JS thread or an RN bridge/proxy is available.
|
|
6
|
+
+ (BOOL)dispatchToJavaScript:(dispatch_block_t)block;
|
|
7
|
+
@end
|
|
8
|
+
NS_ASSUME_NONNULL_END
|