perf(icons): share FontFamily + TextMeasurer across Material Symbol icons

Material Symbol icons were allocating a fresh Font wrapper on every
composition (Font(resource = ...) returns a new instance each call),
which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily
and forced a fresh TextMeasurer per call site. Tint was also baked into
the TextStyle passed to measure(), so any tint change (selected /
unselected reaction icons, hover states) produced a cache miss.

Hoist the FontFamily + TextMeasurer to CompositionLocals provided once
at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop)
via ProvideMaterialSymbols. Every icon in the subtree now reuses:

- One FontFamily (Skia typeface cache hit for every subsequent glyph)
- One TextMeasurer with a 64-entry LRU, shared across all call sites
  so its measurement cache is hit across 300+ MaterialSymbols usages
- Tint applied via drawText(layout, color = tint) instead of TextStyle,
  so the measured TextLayoutResult is reused across tint variations

Icon(symbol = ...) now delegates to Material3's Icon(painter = ...),
restoring proper sizing/semantics without the custom Box+drawBehind
workaround. autoMirror handled inside the painter's onDraw. The
previously-unused MaterialSymbolPainter is now the single render path.

The weight parameter is dropped from the Icon/Painter APIs since the
app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT).

A local fallback path keeps @Preview composables that don't wrap in
the theme renderable (per-composition allocation, same cost as before).

https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
This commit is contained in:
Claude
2026-04-24 03:01:23 +00:00
parent a436df7a21
commit 72bef059f2
5 changed files with 221 additions and 213 deletions
@@ -20,29 +20,11 @@
*/
package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
private val IconDefaultSize = 24.dp
import androidx.compose.material3.Icon as Material3Icon
@Composable
fun Icon(
@@ -50,49 +32,13 @@ fun Icon(
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current,
weight: Int = MaterialSymbolsDefaults.WEIGHT,
) {
val fontFamily = rememberMaterialSymbolsFontFamily(weight)
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val mirror = symbol.autoMirror && LocalLayoutDirection.current == LayoutDirection.Rtl
val semanticsModifier =
if (contentDescription != null) {
Modifier.semantics {
this.contentDescription = contentDescription
this.role = Role.Image
}
} else {
Modifier
}
Box(
modifier =
modifier
.defaultMinSize(IconDefaultSize, IconDefaultSize)
.drawBehind {
val sizePx = size.minDimension
val fontSize = with(density) { sizePx.toSp() }
val layout =
textMeasurer.measure(
text = symbol.glyph,
style =
TextStyle(
fontFamily = fontFamily,
fontSize = fontSize,
color = tint,
),
)
val tx = (size.width - layout.size.width) / 2f
val ty = (size.height - layout.size.height) / 2f
if (mirror) {
scale(scaleX = -1f, scaleY = 1f, pivot = Offset(size.width / 2f, size.height / 2f)) {
translate(tx, ty) { drawText(layout) }
}
} else {
translate(tx, ty) { drawText(layout) }
}
}.then(semanticsModifier),
Material3Icon(
painter = rememberMaterialSymbolPainter(symbol, tint),
contentDescription = contentDescription,
modifier = modifier,
// Tint is already baked into the painter draw; pass Unspecified so Material3 doesn't
// double-tint via a ColorFilter.
tint = Color.Unspecified,
)
}
@@ -23,57 +23,63 @@ package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
@Composable
fun rememberMaterialSymbolPainter(
symbol: MaterialSymbol,
tint: Color = LocalContentColor.current,
weight: Int = MaterialSymbolsDefaults.WEIGHT,
): Painter {
val fontFamily = rememberMaterialSymbolsFontFamily(weight)
val textMeasurer = rememberTextMeasurer()
val fontFamily = materialSymbolsFontFamily()
val textMeasurer = materialSymbolsTextMeasurer()
val density = LocalDensity.current
return remember(symbol, fontFamily, tint, density) {
MaterialSymbolPainter(symbol, fontFamily, tint, textMeasurer, density)
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
return remember(symbol, fontFamily, textMeasurer, density, tint, rtl) {
MaterialSymbolPainter(symbol, fontFamily, textMeasurer, density, tint, rtl)
}
}
private class MaterialSymbolPainter(
private val symbol: MaterialSymbol,
private val fontFamily: FontFamily,
private val tint: Color,
private val textMeasurer: TextMeasurer,
private val density: Density,
private val tint: Color,
private val rtl: Boolean,
) : Painter() {
override val intrinsicSize: Size = Size.Unspecified
override fun DrawScope.onDraw() {
val sizePx = size.minDimension
val fontSize = with(density) { sizePx.toSp() }
// TextStyle omits color; we override at draw time so TextMeasurer's cache hits across tints.
val layout =
textMeasurer.measure(
text = symbol.glyph,
style =
TextStyle(
fontFamily = fontFamily,
fontSize = fontSize,
color = tint,
),
style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
)
val tx = (size.width - layout.size.width) / 2f
val ty = (size.height - layout.size.height) / 2f
translate(tx, ty) { drawText(layout) }
if (symbol.autoMirror && rtl) {
scale(scaleX = -1f, scaleY = 1f, pivot = Offset(size.width / 2f, size.height / 2f)) {
translate(tx, ty) { drawText(layout, color = tint) }
}
} else {
translate(tx, ty) { drawText(layout, color = tint) }
}
}
}
@@ -21,27 +21,79 @@
package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.material_symbols_outlined
import org.jetbrains.compose.resources.Font
// Held on CompositionLocal so every Material Symbol call site in the tree reuses one FontFamily
// instance. Without this, every Icon() composition allocates a new Font wrapper, breaks its own
// remember cache, and forces a fresh TextMeasurer per call site.
val LocalMaterialSymbolsFontFamily: ProvidableCompositionLocal<FontFamily?> = staticCompositionLocalOf { null }
// Shared TextMeasurer so its internal LRU cache is hit by every icon draw in the tree.
val LocalMaterialSymbolsTextMeasurer: ProvidableCompositionLocal<TextMeasurer?> = staticCompositionLocalOf { null }
private const val TEXT_MEASURER_CACHE_SIZE = 64
/**
* Builds the Material Symbols FontFamily and a shared TextMeasurer once for the subtree and
* exposes them via CompositionLocal. Wrap app roots (AmethystTheme, desktop MaterialTheme) in this.
*/
@Composable
fun rememberMaterialSymbolsFontFamily(weight: Int = MaterialSymbolsDefaults.WEIGHT): FontFamily {
fun ProvideMaterialSymbols(content: @Composable () -> Unit) {
val font =
Font(
resource = Res.font.material_symbols_outlined,
weight = FontWeight(weight),
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
variationSettings =
FontVariation.Settings(
FontVariation.weight(weight),
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
),
)
return remember(font) { FontFamily(font) }
// Keyless remember: the Font wrapper identity changes every composition but the underlying
// resource is a compile-time constant, so one FontFamily for the lifetime of the subtree.
val fontFamily = remember { FontFamily(font) }
val textMeasurer = rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
CompositionLocalProvider(
LocalMaterialSymbolsFontFamily provides fontFamily,
LocalMaterialSymbolsTextMeasurer provides textMeasurer,
content = content,
)
}
@Composable
internal fun materialSymbolsFontFamily(): FontFamily = LocalMaterialSymbolsFontFamily.current ?: localMaterialSymbolsFontFamilyFallback()
@Composable
internal fun materialSymbolsTextMeasurer(): TextMeasurer =
LocalMaterialSymbolsTextMeasurer.current
?: rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
@Composable
private fun localMaterialSymbolsFontFamilyFallback(): FontFamily {
val font =
Font(
resource = Res.font.material_symbols_outlined,
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
variationSettings =
FontVariation.Settings(
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
),
)
return remember { FontFamily(font) }
}