xfy 914e882fe1 优化月→年视图切换性能:Canvas 扁平化 + 首帧组合 + 动画交错
- MiniMonth 日期网格改 Canvas 绘制,单页 Composable 从 ~600 降到 ~120
- 年视图 beyondViewportPageCount 首帧 0、首帧后恢复 1,避免一次组合 36 个 MiniMonth
- toggleYearView 先启动动画再翻转 isYearView,月视图缩小与年视图组合交错
- 添加 ComposeTrace 跨平台 trace 工具用于性能分析

MonthView→YearView 总耗时 1732ms → 1033ms (↓40%),首帧 onMeasure 902ms → 129ms (↓86%)
2026-05-18 16:50:13 +08:00

297 lines
10 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.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.LocalDensity
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("", "", "", "", "", "", "")
/**
* 年视图 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")
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(
year = year,
month = month,
isSelected = month == selectedMonth,
today = today,
onClick = { onMonthClick(month) },
modifier = Modifier.weight(1f)
)
}
}
}
}
}
composeTraceEndSection()
}
/**
* 精简版月历:月份标题 + 星期行 + 日期数字网格。
*/
@Composable
private fun MiniMonth(
year: Int,
month: Int,
isSelected: Boolean,
today: LocalDate,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val days = remember(year, month) { generateMiniMonthDays(year, month) }
val titleColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
val weekdayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
val dayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
val otherMonthColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
val todayBgColor = MaterialTheme.colorScheme.primaryContainer
val todayTextColor = MaterialTheme.colorScheme.onPrimaryContainer
val textMeasurer = rememberTextMeasurer()
val dayTextStyle = remember {
TextStyle(fontSize = 8.sp, lineHeight = 12.sp)
}
Column(
modifier = modifier
.padding(2.dp)
.clickable(onClick = onClick)
.padding(vertical = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 月份标题
Text(
text = "${month}",
color = titleColor,
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center
)
// 星期行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
WEEKDAY_LABELS.forEach { label ->
Text(
text = label,
color = weekdayColor,
fontSize = 8.sp,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
}
// 日期网格 — Canvas 绘制
val density = LocalDensity.current
val dayRowCount = days.size / 7
val canvasHeight = with(density) { (dayRowCount * (12.sp.toPx() + 4.dp.toPx())).toDp() }
Canvas(modifier = Modifier.fillMaxWidth().height(canvasHeight)) {
val cellWidth = size.width / 7f
val rowHeightPx = size.height / dayRowCount
days.forEachIndexed { index, dayData ->
val row = index / 7
val col = index % 7
val centerX = col * cellWidth + cellWidth / 2f
val centerY = row * rowHeightPx + rowHeightPx / 2f
val isToday = dayData.date == today && dayData.isCurrentMonth
val text = if (dayData.isCurrentMonth) dayData.date.day.toString() else ""
val textColor: Color = when {
!dayData.isCurrentMonth -> otherMonthColor
isToday -> todayTextColor
else -> dayColor
}
if (isToday) {
val radius = cellWidth.coerceAtMost(rowHeightPx) / 2f * 0.8f
drawCircle(
color = todayBgColor,
radius = radius,
center = Offset(centerX, centerY)
)
}
if (text.isNotEmpty()) {
val measured = textMeasurer.measure(
text = text,
style = dayTextStyle.copy(color = textColor)
)
drawText(
textLayoutResult = measured,
topLeft = Offset(
x = centerX - measured.size.width / 2f,
y = centerY - measured.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)
)
}
}