diff --git a/shared/src/androidMain/kotlin/plus/rua/project/ComposeTrace.android.kt b/shared/src/androidMain/kotlin/plus/rua/project/ComposeTrace.android.kt new file mode 100644 index 0000000..af5faa7 --- /dev/null +++ b/shared/src/androidMain/kotlin/plus/rua/project/ComposeTrace.android.kt @@ -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() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt index e02108d..9bd6179 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/CalendarViewModel.kt @@ -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 无替代 API,kotlinx-datetime 尚未提供新接口 fun getMonthDays(year: Int, month: Int): List { + 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 } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ComposeTrace.kt b/shared/src/commonMain/kotlin/plus/rua/project/ComposeTrace.kt new file mode 100644 index 0000000..7ad0e87 --- /dev/null +++ b/shared/src/commonMain/kotlin/plus/rua/project/ComposeTrace.kt @@ -0,0 +1,9 @@ +package plus.rua.project + +/** + * Systrace 包装,用于录制 Compose 性能 trace。 + * Android 实际调用 android.os.Trace;iOS 为空操作。 + */ +expect fun composeTraceBeginSection(name: String) + +expect fun composeTraceEndSection() diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 0cce0aa..38a9916 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -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 浮动按钮 diff --git a/shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt b/shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt index 28eaba0..daeeb0c 100644 --- a/shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/shared/src/commonMain/kotlin/plus/rua/project/ui/YearGridView.kt @@ -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 { + 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 { 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 } /** diff --git a/shared/src/iosMain/kotlin/plus/rua/project/ComposeTrace.ios.kt b/shared/src/iosMain/kotlin/plus/rua/project/ComposeTrace.ios.kt new file mode 100644 index 0000000..1a43761 --- /dev/null +++ b/shared/src/iosMain/kotlin/plus/rua/project/ComposeTrace.ios.kt @@ -0,0 +1,5 @@ +package plus.rua.project + +actual fun composeTraceBeginSection(name: String) {} // iOS: no-op + +actual fun composeTraceEndSection() {} // iOS: no-op \ No newline at end of file