meyou 987b965956
MiniMonth 纯 Canvas 绘制:消除 96 个 Text 组件的测量开销
trace 显示 TextStringSimpleNode::measure 累计 100+ms,是 measure 阶段
主要开销。MiniMonth 中 12 个标题 Text + 84 个星期标签 Text = 96 个
Text 组件。

修复:
1. YearGridView 中预计算 titleLayouts + weekdayLayouts
2. MiniMonth 改为单个 Canvas 统一绘制标题 + 星期行 + 日期
3. 消除所有 Text 组件,TextStringSimpleNode::measure 归零
2026-05-18 23:29:05 +08:00

363 lines
13 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package plus.rua.project.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
private val WEEKDAY_LABELS = listOf("", "", "", "", "", "", "")
private data class MiniMonthColors(
val titleSelected: Color,
val titleNormal: Color,
val weekday: Color,
val day: Color,
val otherMonth: Color,
val todayBg: Color,
val todayText: Color
)
/**
* 年视图 4×3 月历网格。
*
* @param year 显示的年份
* @param selectedMonth 当前选中月份1-12
* @param today 今天的日期
* @param onMonthClick 月份点击回调
* @param modifier 外部布局修饰符
*/
@Composable
fun YearGridView(
year: Int,
selectedMonth: Int,
today: LocalDate,
onMonthClick: (Int) -> Unit,
modifier: Modifier = Modifier
) {
composeTraceBeginSection("YearGridView:$year")
// P0-F: 主题色在 YearGridView 级别一次性读取并缓存
val colorScheme = MaterialTheme.colorScheme
val colors = remember(colorScheme) {
MiniMonthColors(
titleSelected = colorScheme.primary,
titleNormal = colorScheme.onSurface,
weekday = colorScheme.onSurface.copy(alpha = 0.4f),
day = colorScheme.onSurface.copy(alpha = 0.6f),
otherMonth = colorScheme.onSurface.copy(alpha = 0.2f),
todayBg = colorScheme.primaryContainer,
todayText = colorScheme.onPrimaryContainer
)
}
// P0-F: 预计算全年 12 个月的日期数据,翻年时复用
val monthDays = remember(year) {
(1..12).map { generateMiniMonthDays(year, it) }
}
// P0-G: 共享 TextMeasurer
val textMeasurer = rememberTextMeasurer()
val dayTextStyle = remember { TextStyle(fontSize = 8.sp, lineHeight = 12.sp) }
// P0-D: 预测量 1..31 × 3 种颜色 = 93 个 TextLayoutResult
val dayLayouts = remember(textMeasurer, dayTextStyle, colors) {
val days = 1..31
val colorList = listOf(colors.day, colors.todayText, colors.otherMonth)
days.flatMap { d ->
colorList.map { c ->
(d to c) to textMeasurer.measure(d.toString(), dayTextStyle.copy(color = c))
}
}.toMap()
}
// P0-H: 预测量月份标题(选中/非选中两种颜色)
val titleLayouts = remember(textMeasurer, colors) {
(1..12).flatMap { month ->
val text = "${month}"
listOf(
(month to true) to textMeasurer.measure(
text,
TextStyle(fontSize = 10.sp, color = colors.titleSelected, fontWeight = FontWeight.Bold)
),
(month to false) to textMeasurer.measure(
text,
TextStyle(fontSize = 10.sp, color = colors.titleNormal)
)
)
}.toMap()
}
// P0-H: 预测量星期标签
val weekdayLayouts = remember(textMeasurer, colors) {
WEEKDAY_LABELS.associate { label ->
label to textMeasurer.measure(label, TextStyle(fontSize = 8.sp, color = colors.weekday))
}
}
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 4×3 月历网格
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 4.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
(0 until 4).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
(0 until 3).forEach { col ->
val month = row * 3 + col + 1
MiniMonth(
month = month,
isSelected = month == selectedMonth,
today = today,
days = monthDays[month - 1],
colors = colors,
dayLayouts = dayLayouts,
titleLayouts = titleLayouts,
weekdayLayouts = weekdayLayouts,
onClick = { onMonthClick(month) },
modifier = Modifier.weight(1f)
)
}
}
}
}
}
composeTraceEndSection()
}
/**
* 精简版月历:月份标题 + 星期行 + 日期数字网格,全部 Canvas 绘制。
*
* 消除 Text 组件避免 TextStringSimpleNode::measure 开销。
*/
@Composable
private fun MiniMonth(
month: Int,
isSelected: Boolean,
today: LocalDate,
days: List<MiniDayData>,
colors: MiniMonthColors,
dayLayouts: Map<Pair<Int, Color>, androidx.compose.ui.text.TextLayoutResult>,
titleLayouts: Map<Pair<Int, Boolean>, androidx.compose.ui.text.TextLayoutResult>,
weekdayLayouts: Map<String, androidx.compose.ui.text.TextLayoutResult>,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
val dayRowCount = days.size / 7
val titleHeightPx = with(density) { 14.sp.toPx() }
val weekdayHeightPx = with(density) { 12.sp.toPx() }
val dayCellHeightPx = with(density) { (12.sp.toPx() + 4.dp.toPx()) }
val totalHeight = with(density) {
(titleHeightPx + weekdayHeightPx + dayRowCount * dayCellHeightPx).toDp()
}
Column(
modifier = modifier
.padding(2.dp)
.clickable(onClick = onClick)
.padding(vertical = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) {
val cellWidth = size.width / 7f
// 1. 绘制标题
val titleLayout = titleLayouts[month to isSelected]!!
drawText(
textLayoutResult = titleLayout,
topLeft = Offset(
(size.width - titleLayout.size.width) / 2f,
0f
)
)
// 2. 绘制星期行
val weekdayY = titleHeightPx
WEEKDAY_LABELS.forEachIndexed { i, label ->
val layout = weekdayLayouts[label]!!
drawText(
textLayoutResult = layout,
topLeft = Offset(
i * cellWidth + (cellWidth - layout.size.width) / 2f,
weekdayY + (weekdayHeightPx - layout.size.height) / 2f
)
)
}
// 3. 绘制日期网格
val dayGridY = titleHeightPx + weekdayHeightPx
days.forEachIndexed { index, dayData ->
val row = index / 7
val col = index % 7
val centerX = col * cellWidth + cellWidth / 2f
val centerY = dayGridY + row * dayCellHeightPx + dayCellHeightPx / 2f
val isToday = dayData.date == today && dayData.isCurrentMonth
val dayNum = if (dayData.isCurrentMonth) dayData.date.day else 0
val textColor: Color = when {
!dayData.isCurrentMonth -> colors.otherMonth
isToday -> colors.todayText
else -> colors.day
}
if (isToday) {
val radius = cellWidth.coerceAtMost(dayCellHeightPx) / 2f * 0.8f
drawCircle(
color = colors.todayBg,
radius = radius,
center = Offset(centerX, centerY)
)
}
if (dayNum > 0) {
dayLayouts[dayNum to textColor]?.let { layoutResult ->
drawText(
textLayoutResult = layoutResult,
topLeft = Offset(
x = centerX - layoutResult.size.width / 2f,
y = centerY - layoutResult.size.height / 2f
)
)
}
}
}
}
}
}
private data class MiniDayData(
val date: LocalDate,
val isCurrentMonth: Boolean
)
@Suppress("DEPRECATION") // monthNumber 无替代 API
private fun generateMiniMonthDays(year: Int, month: Int): List<MiniDayData> {
composeTraceBeginSection("generateMiniMonthDays:$year-$month")
val firstOfMonth = LocalDate(year, month, 1)
val offset = firstOfMonth.dayOfWeek.ordinal
val startDate = firstOfMonth.minus(DatePeriod(days = offset))
val nextMonth = if (month == 12) LocalDate(year + 1, 1, 1) else LocalDate(year, month + 1, 1)
val daysInMonth = nextMonth.minus(DatePeriod(days = 1)).day
val rows = ((offset + daysInMonth - 1) / 7) + 1
val totalDays = rows * 7
val result = (0 until totalDays).map { i ->
val date = startDate.plus(DatePeriod(days = i))
MiniDayData(
date = date,
isCurrentMonth = date.month.number == month && date.year == year
)
}
composeTraceEndSection()
return result
}
/**
* 年视图标题栏,显示年份文字和左右导航箭头。
*
* 年份切换时文字有垂直滑动过渡动画,方向由新旧年份大小决定。
*
* @param year 当前年份
* @param onYearChange 年份切换回调
* @param modifier 外部布局修饰符
*/
@Composable
fun YearHeader(
year: Int,
onYearChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.clickable { onYearChange(year - 1) }
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
AnimatedContent(
targetState = year,
transitionSpec = {
if (targetState > initialState) {
slideInVertically(tween(250)) { -it } togetherWith
slideOutVertically(tween(250)) { it }
} else {
slideInVertically(tween(250)) { it } togetherWith
slideOutVertically(tween(250)) { -it }
}
}
) { y ->
Text(
text = "${y}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
}
Text(
text = "",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.clickable { onYearChange(year + 1) }
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}