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
@@ -61,6 +61,7 @@ import com.halilibo.richtext.ui.resolveDefaults
import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.ThemeType
private val DarkColorPalette = private val DarkColorPalette =
@@ -591,7 +592,7 @@ fun AmethystTheme(
colorScheme = colors, colorScheme = colors,
typography = Typography, typography = Typography,
shapes = Shapes, shapes = Shapes,
content = content, content = { ProvideMaterialSymbols(content) },
) )
val view = LocalView.current val view = LocalView.current
@@ -20,29 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.commons.icons.symbols 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.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.Color
import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.material3.Icon as Material3Icon
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
@Composable @Composable
fun Icon( fun Icon(
@@ -50,49 +32,13 @@ fun Icon(
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current, tint: Color = LocalContentColor.current,
weight: Int = MaterialSymbolsDefaults.WEIGHT,
) { ) {
val fontFamily = rememberMaterialSymbolsFontFamily(weight) Material3Icon(
val textMeasurer = rememberTextMeasurer() painter = rememberMaterialSymbolPainter(symbol, tint),
val density = LocalDensity.current contentDescription = contentDescription,
val mirror = symbol.autoMirror && LocalLayoutDirection.current == LayoutDirection.Rtl modifier = modifier,
// Tint is already baked into the painter draw; pass Unspecified so Material3 doesn't
val semanticsModifier = // double-tint via a ColorFilter.
if (contentDescription != null) { tint = Color.Unspecified,
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),
) )
} }
@@ -23,57 +23,63 @@ package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope 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.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
@Composable @Composable
fun rememberMaterialSymbolPainter( fun rememberMaterialSymbolPainter(
symbol: MaterialSymbol, symbol: MaterialSymbol,
tint: Color = LocalContentColor.current, tint: Color = LocalContentColor.current,
weight: Int = MaterialSymbolsDefaults.WEIGHT,
): Painter { ): Painter {
val fontFamily = rememberMaterialSymbolsFontFamily(weight) val fontFamily = materialSymbolsFontFamily()
val textMeasurer = rememberTextMeasurer() val textMeasurer = materialSymbolsTextMeasurer()
val density = LocalDensity.current val density = LocalDensity.current
return remember(symbol, fontFamily, tint, density) { val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
MaterialSymbolPainter(symbol, fontFamily, tint, textMeasurer, density) return remember(symbol, fontFamily, textMeasurer, density, tint, rtl) {
MaterialSymbolPainter(symbol, fontFamily, textMeasurer, density, tint, rtl)
} }
} }
private class MaterialSymbolPainter( private class MaterialSymbolPainter(
private val symbol: MaterialSymbol, private val symbol: MaterialSymbol,
private val fontFamily: FontFamily, private val fontFamily: FontFamily,
private val tint: Color,
private val textMeasurer: TextMeasurer, private val textMeasurer: TextMeasurer,
private val density: Density, private val density: Density,
private val tint: Color,
private val rtl: Boolean,
) : Painter() { ) : Painter() {
override val intrinsicSize: Size = Size.Unspecified override val intrinsicSize: Size = Size.Unspecified
override fun DrawScope.onDraw() { override fun DrawScope.onDraw() {
val sizePx = size.minDimension val sizePx = size.minDimension
val fontSize = with(density) { sizePx.toSp() } val fontSize = with(density) { sizePx.toSp() }
// TextStyle omits color; we override at draw time so TextMeasurer's cache hits across tints.
val layout = val layout =
textMeasurer.measure( textMeasurer.measure(
text = symbol.glyph, text = symbol.glyph,
style = style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
TextStyle(
fontFamily = fontFamily,
fontSize = fontSize,
color = tint,
),
) )
val tx = (size.width - layout.size.width) / 2f val tx = (size.width - layout.size.width) / 2f
val ty = (size.height - layout.size.height) / 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 package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.remember 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.FontFamily
import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight 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.Res
import com.vitorpamplona.amethyst.commons.resources.material_symbols_outlined import com.vitorpamplona.amethyst.commons.resources.material_symbols_outlined
import org.jetbrains.compose.resources.Font 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 @Composable
fun rememberMaterialSymbolsFontFamily(weight: Int = MaterialSymbolsDefaults.WEIGHT): FontFamily { fun ProvideMaterialSymbols(content: @Composable () -> Unit) {
val font = val font =
Font( Font(
resource = Res.font.material_symbols_outlined, resource = Res.font.material_symbols_outlined,
weight = FontWeight(weight), weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
variationSettings = variationSettings =
FontVariation.Settings( FontVariation.Settings(
FontVariation.weight(weight), FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL), FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE), FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE), 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) }
} }
@@ -71,6 +71,7 @@ import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
@@ -740,6 +741,7 @@ fun App(
MaterialTheme( MaterialTheme(
colorScheme = darkColorScheme(), colorScheme = darkColorScheme(),
) { ) {
ProvideMaterialSymbols {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
@@ -886,6 +888,7 @@ fun App(
} }
} }
} }
}
@Composable @Composable
fun MainContent( fun MainContent(