优化月→年视图切换性能:Canvas 扁平化 + 首帧组合 + 动画交错

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

MonthView→YearView 总耗时 1732ms → 1033ms (↓40%),首帧 onMeasure 902ms → 129ms (↓86%)
This commit is contained in:
xfy 2026-05-18 16:50:13 +08:00
parent 996d8c104f
commit 914e882fe1
6 changed files with 131 additions and 54 deletions

View File

@ -0,0 +1,7 @@
package plus.rua.project
import android.os.Trace
actual fun composeTraceBeginSection(name: String) = Trace.beginSection(name)
actual fun composeTraceEndSection() = Trace.endSection()

View File

@ -116,21 +116,32 @@ class CalendarViewModel(
yearViewJob?.cancel() yearViewJob?.cancel()
yearViewJob = coroutineScope.launch { yearViewJob = coroutineScope.launch {
if (isYearView) { if (isYearView) {
// 年 → 月:先切换状态让月视图开始合成,再等一帧避免首帧抖动 // 年 → 月:先启动动画(年视图开始淡出),等一帧后翻转 isYearView月视图开始组合
composeTraceBeginSection("YearView→MonthView")
_yearViewAnimatable.snapTo(1f)
val animJob = launch {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { }
isYearView = false isYearView = false
withFrameNanos { } animJob.join()
_yearViewAnimatable.animateTo( composeTraceEndSection()
0f, tween(400, easing = FastOutSlowInEasing)
)
} else { } else {
// 月 → 年:先切换状态让年视图开始合成 // 月 → 年:先启动动画(月视图开始缩小),等一帧后翻转 isYearView年视图开始组合
composeTraceBeginSection("MonthView→YearView")
yearViewYear = selectedDate.year yearViewYear = selectedDate.year
isYearView = true
_yearViewAnimatable.snapTo(0f) _yearViewAnimatable.snapTo(0f)
val animJob = launch {
_yearViewAnimatable.animateTo(
1f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { } withFrameNanos { }
_yearViewAnimatable.animateTo( isYearView = true
1f, tween(400, easing = FastOutSlowInEasing) animJob.join()
) composeTraceEndSection()
} }
} }
} }
@ -140,6 +151,7 @@ class CalendarViewModel(
*/ */
@Suppress("DEPRECATION") // monthNumber 无替代 API @Suppress("DEPRECATION") // monthNumber 无替代 API
fun selectMonthFromYearView(month: Int) { fun selectMonthFromYearView(month: Int) {
composeTraceBeginSection("YearView:SelectMonth")
val date = if (yearViewYear == today.year && today.month.number == month) today val date = if (yearViewYear == today.year && today.month.number == month) today
else LocalDate(yearViewYear, month, 1) else LocalDate(yearViewYear, month, 1)
selectedDate = date selectedDate = date
@ -150,6 +162,7 @@ class CalendarViewModel(
_yearViewAnimatable.animateTo( _yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing) 0f, tween(400, easing = FastOutSlowInEasing)
) )
composeTraceEndSection()
} }
} }
@ -286,6 +299,7 @@ class CalendarViewModel(
*/ */
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口 @Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
fun getMonthDays(year: Int, month: Int): List<CalendarDay> { fun getMonthDays(year: Int, month: Int): List<CalendarDay> {
composeTraceBeginSection("getMonthDays:$year-$month")
val firstOfMonth = LocalDate(year, month, 1) val firstOfMonth = LocalDate(year, month, 1)
val dayOfWeekOffset = firstOfMonth.dayOfWeek.ordinal val dayOfWeekOffset = firstOfMonth.dayOfWeek.ordinal
val startDate = firstOfMonth.minus(DatePeriod(days = dayOfWeekOffset)) val startDate = firstOfMonth.minus(DatePeriod(days = dayOfWeekOffset))
@ -295,7 +309,7 @@ class CalendarViewModel(
val rows = ((dayOfWeekOffset + daysInMonth - 1) / 7) + 1 val rows = ((dayOfWeekOffset + daysInMonth - 1) / 7) + 1
val totalDays = rows * 7 val totalDays = rows * 7
return (0 until totalDays).map { i -> val result = (0 until totalDays).map { i ->
val date = startDate.plus(DatePeriod(days = i)) val date = startDate.plus(DatePeriod(days = i))
CalendarDay( CalendarDay(
date = date, date = date,
@ -304,5 +318,7 @@ class CalendarViewModel(
isSelected = date == selectedDate isSelected = date == selectedDate
) )
} }
composeTraceEndSection()
return result
} }
} }

View File

@ -0,0 +1,9 @@
package plus.rua.project
/**
* Systrace 包装用于录制 Compose 性能 trace
* Android 实际调用 android.os.TraceiOS 为空操作
*/
expect fun composeTraceBeginSection(name: String)
expect fun composeTraceEndSection()

View File

@ -36,6 +36,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
@ -59,6 +60,8 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import plus.rua.project.CalendarViewModel import plus.rua.project.CalendarViewModel
import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
@ -90,12 +93,23 @@ fun CalendarMonthView(
var screenHeightPx by remember { mutableIntStateOf(0) } var screenHeightPx by remember { mutableIntStateOf(0) }
var calendarContentHeightPx by remember { mutableIntStateOf(0) } var calendarContentHeightPx by remember { mutableIntStateOf(0) }
var isMenuExpanded by remember { mutableStateOf(false) } var isMenuExpanded by remember { mutableStateOf(false) }
var yearPagerBeyondViewport by remember { mutableStateOf(0) }
// 视图切换时自动关闭菜单 // 视图切换时自动关闭菜单
LaunchedEffect(viewModel.isYearView) { LaunchedEffect(viewModel.isYearView) {
isMenuExpanded = false isMenuExpanded = false
} }
// 年视图首帧后恢复预组合,避免首帧同时组合 3 页 × 12 月 = 36 个 MiniMonth
LaunchedEffect(viewModel.isYearView) {
if (viewModel.isYearView) {
withFrameNanos { }
yearPagerBeyondViewport = 1
} else {
yearPagerBeyondViewport = 0
}
}
val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE }) val pagerState = rememberPagerState(initialPage = START_PAGE, pageCount = { Int.MAX_VALUE })
// 年视图分页器 // 年视图分页器
@ -209,6 +223,7 @@ fun CalendarMonthView(
) { ) {
// 月视图层:仅在非年视图时渲染,年视图激活时立即移除。 // 月视图层:仅在非年视图时渲染,年视图激活时立即移除。
if (!viewModel.isYearView) { if (!viewModel.isYearView) {
composeTraceBeginSection("MonthView:Compose")
val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() } val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() }
val dragRangePx = if (effectiveRowHeightPx > 0) { val dragRangePx = if (effectiveRowHeightPx > 0) {
maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx) maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx)
@ -313,10 +328,12 @@ fun CalendarMonthView(
) )
} }
} }
composeTraceEndSection()
} }
// 年视图层标题固定HorizontalPager 只包裹网格。 // 年视图层标题固定HorizontalPager 只包裹网格。
if (viewModel.isYearView) { if (viewModel.isYearView) {
composeTraceBeginSection("YearView:Compose")
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -342,7 +359,7 @@ fun CalendarMonthView(
) )
HorizontalPager( HorizontalPager(
state = yearPagerState, state = yearPagerState,
beyondViewportPageCount = 1, beyondViewportPageCount = yearPagerBeyondViewport,
flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState), flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -375,6 +392,7 @@ fun CalendarMonthView(
) )
} }
} }
composeTraceEndSection()
} }
// FAB 浮动按钮 // FAB 浮动按钮

View File

@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -21,10 +23,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset 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.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.datetime.DatePeriod import kotlinx.datetime.DatePeriod
@ -32,6 +38,8 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
private val WEEKDAY_LABELS = listOf("", "", "", "", "", "", "") private val WEEKDAY_LABELS = listOf("", "", "", "", "", "", "")
@ -52,6 +60,7 @@ fun YearGridView(
onMonthClick: (Int) -> Unit, onMonthClick: (Int) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
composeTraceBeginSection("YearGridView:$year")
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@ -84,6 +93,7 @@ fun YearGridView(
} }
} }
} }
composeTraceEndSection()
} }
/** /**
@ -107,7 +117,13 @@ private fun MiniMonth(
val weekdayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) val weekdayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
val dayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) val dayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
val otherMonthColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) val otherMonthColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
val todayBgColor = MaterialTheme.colorScheme.primary val todayBgColor = MaterialTheme.colorScheme.primaryContainer
val todayTextColor = MaterialTheme.colorScheme.onPrimaryContainer
val textMeasurer = rememberTextMeasurer()
val dayTextStyle = remember {
TextStyle(fontSize = 8.sp, lineHeight = 12.sp)
}
Column( Column(
modifier = modifier modifier = modifier
@ -139,46 +155,49 @@ private fun MiniMonth(
) )
} }
} }
// 日期网格 // 日期网格 — Canvas 绘制
days.chunked(7).forEach { week -> val density = LocalDensity.current
Row( val dayRowCount = days.size / 7
modifier = Modifier val canvasHeight = with(density) { (dayRowCount * (12.sp.toPx() + 4.dp.toPx())).toDp() }
.fillMaxWidth() Canvas(modifier = Modifier.fillMaxWidth().height(canvasHeight)) {
.padding(vertical = 2.dp), val cellWidth = size.width / 7f
horizontalArrangement = Arrangement.SpaceEvenly val rowHeightPx = size.height / dayRowCount
) {
week.forEach { dayData -> days.forEachIndexed { index, dayData ->
val isToday = dayData.date == today && dayData.isCurrentMonth val row = index / 7
val color = when { val col = index % 7
!dayData.isCurrentMonth -> otherMonthColor val centerX = col * cellWidth + cellWidth / 2f
isToday -> MaterialTheme.colorScheme.onPrimary val centerY = row * rowHeightPx + rowHeightPx / 2f
else -> dayColor
} val isToday = dayData.date == today && dayData.isCurrentMonth
Box( val text = if (dayData.isCurrentMonth) dayData.date.day.toString() else ""
contentAlignment = Alignment.Center, val textColor: Color = when {
modifier = Modifier.weight(1f) !dayData.isCurrentMonth -> otherMonthColor
) { isToday -> todayTextColor
if (isToday) { else -> dayColor
Box( }
modifier = Modifier
.drawBehind { if (isToday) {
drawCircle( val radius = cellWidth.coerceAtMost(rowHeightPx) / 2f * 0.8f
color = todayBgColor, drawCircle(
radius = size.minDimension / 2f, color = todayBgColor,
center = Offset(size.width / 2f, size.height / 2f) radius = radius,
) center = Offset(centerX, centerY)
} )
.clip(CircleShape) }
)
} if (text.isNotEmpty()) {
Text( val measured = textMeasurer.measure(
text = if (dayData.isCurrentMonth) dayData.date.day.toString() else "", text = text,
color = color, style = dayTextStyle.copy(color = textColor)
fontSize = 8.sp, )
textAlign = TextAlign.Center, drawText(
lineHeight = 12.sp textLayoutResult = measured,
topLeft = Offset(
x = centerX - measured.size.width / 2f,
y = centerY - measured.size.height / 2f
) )
} )
} }
} }
} }
@ -192,6 +211,7 @@ private data class MiniDayData(
@Suppress("DEPRECATION") // monthNumber 无替代 API @Suppress("DEPRECATION") // monthNumber 无替代 API
private fun generateMiniMonthDays(year: Int, month: Int): List<MiniDayData> { private fun generateMiniMonthDays(year: Int, month: Int): List<MiniDayData> {
composeTraceBeginSection("generateMiniMonthDays:$year-$month")
val firstOfMonth = LocalDate(year, month, 1) val firstOfMonth = LocalDate(year, month, 1)
val offset = firstOfMonth.dayOfWeek.ordinal val offset = firstOfMonth.dayOfWeek.ordinal
val startDate = firstOfMonth.minus(DatePeriod(days = offset)) val startDate = firstOfMonth.minus(DatePeriod(days = offset))
@ -200,13 +220,15 @@ private fun generateMiniMonthDays(year: Int, month: Int): List<MiniDayData> {
val rows = ((offset + daysInMonth - 1) / 7) + 1 val rows = ((offset + daysInMonth - 1) / 7) + 1
val totalDays = rows * 7 val totalDays = rows * 7
return (0 until totalDays).map { i -> val result = (0 until totalDays).map { i ->
val date = startDate.plus(DatePeriod(days = i)) val date = startDate.plus(DatePeriod(days = i))
MiniDayData( MiniDayData(
date = date, date = date,
isCurrentMonth = date.month.number == month && date.year == year isCurrentMonth = date.month.number == month && date.year == year
) )
} }
composeTraceEndSection()
return result
} }
/** /**

View File

@ -0,0 +1,5 @@
package plus.rua.project
actual fun composeTraceBeginSection(name: String) {} // iOS: no-op
actual fun composeTraceEndSection() {} // iOS: no-op