优化月→年视图切换性能: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 = coroutineScope.launch {
if (isYearView) {
// 年 → 月:先切换状态让月视图开始合成,再等一帧避免首帧抖动
// 年 → 月:先启动动画(年视图开始淡出),等一帧后翻转 isYearView月视图开始组合
composeTraceBeginSection("YearView→MonthView")
_yearViewAnimatable.snapTo(1f)
val animJob = launch {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { }
isYearView = false
withFrameNanos { }
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
animJob.join()
composeTraceEndSection()
} else {
// 月 → 年:先切换状态让年视图开始合成
// 月 → 年:先启动动画(月视图开始缩小),等一帧后翻转 isYearView年视图开始组合
composeTraceBeginSection("MonthView→YearView")
yearViewYear = selectedDate.year
isYearView = true
_yearViewAnimatable.snapTo(0f)
val animJob = launch {
_yearViewAnimatable.animateTo(
1f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { }
_yearViewAnimatable.animateTo(
1f, tween(400, easing = FastOutSlowInEasing)
)
isYearView = true
animJob.join()
composeTraceEndSection()
}
}
}
@ -140,6 +151,7 @@ class CalendarViewModel(
*/
@Suppress("DEPRECATION") // monthNumber 无替代 API
fun selectMonthFromYearView(month: Int) {
composeTraceBeginSection("YearView:SelectMonth")
val date = if (yearViewYear == today.year && today.month.number == month) today
else LocalDate(yearViewYear, month, 1)
selectedDate = date
@ -150,6 +162,7 @@ class CalendarViewModel(
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
composeTraceEndSection()
}
}
@ -286,6 +299,7 @@ class CalendarViewModel(
*/
@Suppress("DEPRECATION") // monthNumber 无替代 APIkotlinx-datetime 尚未提供新接口
fun getMonthDays(year: Int, month: Int): List<CalendarDay> {
composeTraceBeginSection("getMonthDays:$year-$month")
val firstOfMonth = LocalDate(year, month, 1)
val dayOfWeekOffset = firstOfMonth.dayOfWeek.ordinal
val startDate = firstOfMonth.minus(DatePeriod(days = dayOfWeekOffset))
@ -295,7 +309,7 @@ class CalendarViewModel(
val rows = ((dayOfWeekOffset + daysInMonth - 1) / 7) + 1
val totalDays = rows * 7
return (0 until totalDays).map { i ->
val result = (0 until totalDays).map { i ->
val date = startDate.plus(DatePeriod(days = i))
CalendarDay(
date = date,
@ -304,5 +318,7 @@ class CalendarViewModel(
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@ -59,6 +60,8 @@ import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.todayIn
import plus.rua.project.CalendarViewModel
import plus.rua.project.composeTraceBeginSection
import plus.rua.project.composeTraceEndSection
import kotlin.math.abs
import kotlin.time.Clock
@ -90,12 +93,23 @@ fun CalendarMonthView(
var screenHeightPx by remember { mutableIntStateOf(0) }
var calendarContentHeightPx by remember { mutableIntStateOf(0) }
var isMenuExpanded by remember { mutableStateOf(false) }
var yearPagerBeyondViewport by remember { mutableStateOf(0) }
// 视图切换时自动关闭菜单
LaunchedEffect(viewModel.isYearView) {
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 })
// 年视图分页器
@ -209,6 +223,7 @@ fun CalendarMonthView(
) {
// 月视图层:仅在非年视图时渲染,年视图激活时立即移除。
if (!viewModel.isYearView) {
composeTraceBeginSection("MonthView:Compose")
val dragRangeMinPx = with(density) { DRAG_RANGE_MIN_DP.dp.toPx() }
val dragRangePx = if (effectiveRowHeightPx > 0) {
maxOf((effectiveWeeks - 1) * effectiveRowHeightPx.toFloat(), dragRangeMinPx)
@ -313,10 +328,12 @@ fun CalendarMonthView(
)
}
}
composeTraceEndSection()
}
// 年视图层标题固定HorizontalPager 只包裹网格。
if (viewModel.isYearView) {
composeTraceBeginSection("YearView:Compose")
Column(
modifier = Modifier
.fillMaxSize()
@ -342,7 +359,7 @@ fun CalendarMonthView(
)
HorizontalPager(
state = yearPagerState,
beyondViewportPageCount = 1,
beyondViewportPageCount = yearPagerBeyondViewport,
flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState),
modifier = Modifier
.fillMaxWidth()
@ -375,6 +392,7 @@ fun CalendarMonthView(
)
}
}
composeTraceEndSection()
}
// FAB 浮动按钮

View File

@ -5,6 +5,7 @@ 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
@ -12,6 +13,7 @@ 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
@ -21,10 +23,14 @@ 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.draw.drawBehind
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
@ -32,6 +38,8 @@ 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("", "", "", "", "", "", "")
@ -52,6 +60,7 @@ fun YearGridView(
onMonthClick: (Int) -> Unit,
modifier: Modifier = Modifier
) {
composeTraceBeginSection("YearGridView:$year")
Column(
modifier = modifier.fillMaxSize(),
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 dayColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
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(
modifier = modifier
@ -139,46 +155,49 @@ private fun MiniMonth(
)
}
}
// 日期网格
days.chunked(7).forEach { week ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
week.forEach { dayData ->
val isToday = dayData.date == today && dayData.isCurrentMonth
val color = when {
!dayData.isCurrentMonth -> otherMonthColor
isToday -> MaterialTheme.colorScheme.onPrimary
else -> dayColor
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f)
) {
if (isToday) {
Box(
modifier = Modifier
.drawBehind {
drawCircle(
color = todayBgColor,
radius = size.minDimension / 2f,
center = Offset(size.width / 2f, size.height / 2f)
)
}
.clip(CircleShape)
)
}
Text(
text = if (dayData.isCurrentMonth) dayData.date.day.toString() else "",
color = color,
fontSize = 8.sp,
textAlign = TextAlign.Center,
lineHeight = 12.sp
// 日期网格 — 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
)
}
)
}
}
}
@ -192,6 +211,7 @@ private data class MiniDayData(
@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))
@ -200,13 +220,15 @@ private fun generateMiniMonthDays(year: Int, month: Int): List<MiniDayData> {
val rows = ((offset + daysInMonth - 1) / 7) + 1
val totalDays = rows * 7
return (0 until totalDays).map { i ->
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
}
/**

View File

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