优化月→年视图切换性能: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:
parent
996d8c104f
commit
914e882fe1
@ -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()
|
||||
@ -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<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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package plus.rua.project
|
||||
|
||||
/**
|
||||
* Systrace 包装,用于录制 Compose 性能 trace。
|
||||
* Android 实际调用 android.os.Trace;iOS 为空操作。
|
||||
*/
|
||||
expect fun composeTraceBeginSection(name: String)
|
||||
|
||||
expect fun composeTraceEndSection()
|
||||
@ -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 浮动按钮
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package plus.rua.project
|
||||
|
||||
actual fun composeTraceBeginSection(name: String) {} // iOS: no-op
|
||||
|
||||
actual fun composeTraceEndSection() {} // iOS: no-op
|
||||
Loading…
x
Reference in New Issue
Block a user