Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

internal actual fun getDeviceAvailableCoreProcessors(): Int {
return Runtime.getRuntime().availableProcessors()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

import platform.Foundation.NSProcessInfo

internal actual fun getDeviceAvailableCoreProcessors(): Int = NSProcessInfo.processInfo.activeProcessorCount.toInt()
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

import platform.Foundation.NSProcessInfo

internal actual fun getDeviceAvailableCoreProcessors(): Int = NSProcessInfo.processInfo.activeProcessorCount.toInt()
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,25 @@

package androidx.compose.foundation.text

import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalPlatformBackgroundTextMeasurementExecutor
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.MultiParagraphIntrinsics
import androidx.compose.ui.text.ParagraphIntrinsics
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.resolveDefaults
import androidx.compose.ui.util.trace
import kotlinx.coroutines.Runnable

@Suppress("ComposableNaming")
@OptIn(InternalComposeUiApi::class)
@Composable
@NonRestartableComposable
internal actual fun BackgroundTextMeasurement(
Expand All @@ -32,10 +43,47 @@ internal actual fun BackgroundTextMeasurement(
fontFamilyResolver: FontFamily.Resolver,
softWrap: Boolean,
) {
// TODO: https://youtrack.jetbrains.com/issue/CMP-7818
val executor = LocalPlatformBackgroundTextMeasurementExecutor.current
if (executor != null && shouldPrefetch(text.length)) {
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current

try {
val task = Runnable {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CMP-10613 is a blocker to run it in parallel (and merge this PR before fixing it)

trace("BackgroundTextMeasurement") {
Snapshot.withMutableSnapshot {
val resolvedStyle = resolveDefaults(style, layoutDirection)
val intrinsics =
ParagraphIntrinsics(
text = text,
style = resolvedStyle,
density = density,
fontFamilyResolver = fontFamilyResolver,
annotations =
emptyList<AnnotatedString.Range<AnnotatedString.Annotation>>(),
placeholders = emptyList(),
softWrap = softWrap,
)
// It is important that maxIntrinsicWidth is called before minIntrinsicWidth
// because the primary role of background text measurement is to warm the
// platform word layout cache.

// maxIntrinsicWidth premeasures all words in the given text. This warms
// the platform word layout cache so that when the UI thread starts
// measuring the Text composable, the text layout would be faster.
intrinsics.maxIntrinsicWidth
// minIntrinsicWidth creates a BreakIterator which in turn initializes and
// caches an instance of BreakIteratorCache in `android.icu.text`
intrinsics.minIntrinsicWidth
}
}
}
executor.execute(task)
} catch (_: Exception) {}
}
}

@Suppress("ComposableNaming")
@OptIn(InternalComposeUiApi::class)
@Composable
@NonRestartableComposable
internal actual fun BackgroundTextMeasurement(
Expand All @@ -45,5 +93,79 @@ internal actual fun BackgroundTextMeasurement(
placeholders: List<AnnotatedString.Range<Placeholder>>?,
softWrap: Boolean,
) {
// TODO: https://youtrack.jetbrains.com/issue/CMP-7818
val executor = LocalPlatformBackgroundTextMeasurementExecutor.current
if (executor != null && shouldPrefetch(text.length)) {
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current

try {
val task = Runnable {
trace("BackgroundTextMeasurement") {
Snapshot.withMutableSnapshot {
val resolvedStyle = resolveDefaults(style, layoutDirection)
val intrinsics =
MultiParagraphIntrinsics(
annotatedString = text,
style = resolvedStyle,
density = density,
placeholders = placeholders ?: emptyList(),
fontFamilyResolver = fontFamilyResolver,
softWrap = softWrap,
)
// It is important that maxIntrinsicWidth is called before minIntrinsicWidth
// because the primary role of background text measurement is to warm the
// platform word layout cache.

// maxIntrinsicWidth premeasures all words in the given text. This warms
// the platform word layout cache so that when the UI thread starts
// measuring the Text composable, the text layout would be faster.
intrinsics.maxIntrinsicWidth
// minIntrinsicWidth creates a BreakIterator which in turn initializes and
// caches an instance of BreakIteratorCache in `android.icu.text`
intrinsics.minIntrinsicWidth
}
}
}
executor.execute(task)
} catch (_: Exception) {}
}
}

/**
* The minimum number of CPU cores that should exist for us to consider attempting text prefetch.
*/
private const val PrefetchTextMinimumCoreCount = 4

/**
* Defines the shortest text length that can be considered for prefetching. Texts that are shorter
* than this number are usually not worth creating a threading overhead.
*/
private const val MinTextLengthThreshold = 8

/**
* Defines the longest text length that can be considered for prefetching. Texts that are longer
* than this number have a chance to flood the cache to cause overflow, essentially leading to
* double measurement that causes performance regression.
*/
private const val MaxTextLengthThreshold = 1000

/** Reading the core count is expensive. Do it once and cache it globally. */
private var backingCoreCountSatisfactory: Boolean? = null

@VisibleForTesting
internal val coreCountSatisfactory: Boolean
get() {
if (backingCoreCountSatisfactory == null) {
backingCoreCountSatisfactory =
getDeviceAvailableCoreProcessors() >= PrefetchTextMinimumCoreCount
}
return backingCoreCountSatisfactory!!
}

internal fun shouldPrefetch(textLength: Int): Boolean {
return textLength >= MinTextLengthThreshold &&
textLength < MaxTextLengthThreshold &&
coreCountSatisfactory
}

internal expect fun getDeviceAvailableCoreProcessors(): Int
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.foundation.text

//language=Js
internal actual fun getDeviceAvailableCoreProcessors(): Int = js("window.navigator.hardwareConcurrency")
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.platform

actual typealias PlatformBackgroundTextMeasurementExecutor = java.util.concurrent.Executor
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.platform

import androidx.compose.ui.InternalComposeUiApi
import kotlinx.coroutines.Runnable

@InternalComposeUiApi
actual interface PlatformBackgroundTextMeasurementExecutor {
actual fun execute(runnable: Runnable)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package androidx.compose.ui.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.LocalHostDefaultProvider
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -66,7 +65,18 @@ val LocalPlatformPrefetchScheduler = staticCompositionLocalOf<PlatformPrefetchSc
error("CompositionLocal LocalPlatformPrefetchScheduler not present")
}

@OptIn(InternalComposeApi::class)
/**
* CompositionLocal that provides an optional [PlatformBackgroundTextMeasurementExecutor] associated
* with the current scene. When not present, `null` will be provided.
*
* Intended for scheduling or performing background text measurement work on platforms that
* support off-main-thread text measurement.
*/
@InternalComposeUiApi
val LocalPlatformBackgroundTextMeasurementExecutor = staticCompositionLocalOf<PlatformBackgroundTextMeasurementExecutor?> {
null
}

@Composable
internal fun ProvidePlatformCompositionLocals(
vararg values: ProvidedValue<*>,
Expand Down Expand Up @@ -94,6 +104,7 @@ internal fun ProvidePlatformCompositionLocals(
LocalPlatformScreenReader provides platformContext.screenReader,
LocalPlatformWindowInsets provides platformContext.windowInsets,
LocalPlatformPrefetchScheduler provides platformContext.prefetchScheduler,
LocalPlatformBackgroundTextMeasurementExecutor provides platformContext.backgroundTextMeasurementExecutor,
androidx.lifecycle.compose.LocalLifecycleOwner provides platformContext.architectureComponentsOwner.lifecycleOwner,
LocalSavedStateRegistryOwner provides platformContext.architectureComponentsOwner.savedStateRegistryOwner,
LocalSaveableStateRegistry provides saveableStateRegistry,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.platform

import androidx.compose.ui.InternalComposeUiApi
import kotlinx.coroutines.Runnable

@InternalComposeUiApi
expect interface PlatformBackgroundTextMeasurementExecutor {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expect interface is wrong API shape/antipattern that we're trying to avoid (I know that we still have a few in the codebase)

fun execute(runnable: Runnable)
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ interface PlatformContext {
*/
val prefetchScheduler: PlatformPrefetchScheduler get() = NoOpPlatformPrefetchScheduler

/**
* Executor used to perform background text measurement work.
*
* If non-null, text measurement may be offloaded to this executor to avoid
* blocking the UI thread. Default is `null` which indicates no platform
* provided background executor.
*/
val backgroundTextMeasurementExecutor: PlatformBackgroundTextMeasurementExecutor? get() = null

interface RootForTestListener {
fun onRootForTestCreated(root: PlatformRootForTest)
fun onRootForTestDisposed(root: PlatformRootForTest)
Expand Down