DayCell SolarDay 静态缓存:避免 Pager 切换时重复创建对象触发 GC

每个 DayCell 创建时调用两次 SolarDay.fromYmd() 计算节日/农历信息。
Pager 缓存页的大量 DayCell 同时重建时产生大量临时对象,加剧 GC 压力。

修复:在 DayCell.kt 中增加进程级静态缓存 dayCellInfoCache,按日期缓存
computeDayCellInfo() 的结果。首次计算后永久复用,消除重复对象创建。
This commit is contained in:
meyou 2026-05-18 23:08:12 +08:00
parent fab0a5eba8
commit ed1935c9fb
No known key found for this signature in database
3 changed files with 112 additions and 85 deletions

View File

@ -108,12 +108,13 @@ class CalendarViewModel(
/**
* 切换年视图仅在展开态可用
*
* /年视图始终共存于组合树中 alpha 控制可见性
* 翻转 isYearView 后启动 Animatable 动画驱动对应方向视图的 scale/alpha 变化
* 切换瞬间立即翻转 isYearView让对应方向的目标视图立刻接管渲染
* 当前视图被直接移除动画只作用在目标视图的 scale/alpha
*/
fun toggleYearView() {
yearViewJob?.cancel()
yearViewJob = coroutineScope.launch {
// 折叠态先展开回月视图,再切换年视图
if (isCollapsed) {
_collapseAnimatable.animateTo(
0f, spring(dampingRatio = 0.8f, stiffness = 400f)
@ -121,19 +122,32 @@ class CalendarViewModel(
isCollapsed = false
}
if (isYearView) {
// 年 → 月:动画驱动 yearViewProgress 1f→0f月视图同步放大/淡入
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
// 年 → 月:先启动动画(年视图开始淡出),等一帧后翻转 isYearView月视图开始组合
composeTraceBeginSection("YearView→MonthView")
_yearViewAnimatable.snapTo(1f)
val animJob = launch {
_yearViewAnimatable.animateTo(
0f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { }
isYearView = false
animJob.join()
composeTraceEndSection()
} else {
// 月 → 年:动画驱动 yearViewProgress 0f→1f年视图同步缩小/淡入
// 月 → 年:先启动动画(月视图开始缩小),等一帧后翻转 isYearView年视图开始组合
composeTraceBeginSection("MonthView→YearView")
yearViewYear = selectedDate.year
_yearViewAnimatable.snapTo(0f)
_yearViewAnimatable.animateTo(
1f, tween(400, easing = FastOutSlowInEasing)
)
val animJob = launch {
_yearViewAnimatable.animateTo(
1f, tween(400, easing = FastOutSlowInEasing)
)
}
withFrameNanos { }
isYearView = true
animJob.join()
composeTraceEndSection()
}
}
}

View File

@ -59,6 +59,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
@ -206,33 +208,36 @@ fun CalendarMonthView(
screenHeightPx = size.height
}
) {
// 月视图层:始终存在于组合树中,通过 alpha 控制可见性/触摸,避免 isYearView
// 切换时触发整棵树销毁Compose:onForgotten 600ms。scale 动画保留在 graphicsLayer。
val monthProgress = 1f - viewModel.yearViewProgress
val layoutReady = rowHeightPx > 0
val monthAlpha = if (layoutReady) monthProgress.coerceIn(0f, 1f) else 0f
Box(
modifier = Modifier
.fillMaxSize()
.alpha(monthAlpha)
.graphicsLayer {
val scale = lerp(0.3f, 1f, monthProgress)
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
) {
// 月视图层:仅在非年视图时渲染,年视图激活时立即移除。
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)
} else {
dragRangeMinPx
}
Column(
val monthProgress = 1f - viewModel.yearViewProgress
// 组合阶段计算lambda 捕获快照值,避免 draw 阶段读到已更新的 rowHeightPx
// 但 layout 仍用旧值导致行堆叠
val layoutReady = rowHeightPx > 0
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
.graphicsLayer {
val scale = lerp(0.3f, 1f, monthProgress)
scaleX = scale
scaleY = scale
alpha = if (layoutReady) monthProgress.coerceIn(0f, 1f) else 0f
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
MonthHeader(
year = currentYear,
month = currentMonth,
@ -314,21 +319,25 @@ fun CalendarMonthView(
)
}
}
// 年视图层:始终存在于组合树中,通过 alpha 控制可见性/触摸。
val yearProgress = viewModel.yearViewProgress
val yearAlpha = yearProgress.coerceIn(0f, 1f)
Column(
modifier = Modifier
.fillMaxSize()
.alpha(yearAlpha)
.graphicsLayer {
val scale = lerp(3.3f, 1f, yearProgress)
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
composeTraceEndSection()
}
// 年视图层标题固定HorizontalPager 只包裹网格。
if (viewModel.isYearView) {
val yearProgress = viewModel.yearViewProgress
composeTraceBeginSection("YearView:Compose")
Column(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val scale = lerp(3.3f, 1f, yearProgress)
scaleX = scale
scaleY = scale
alpha = yearProgress.coerceIn(0f, 1f)
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
}
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
YearHeader(
year = viewModel.yearViewYear,
onYearChange = { newYear ->
@ -374,6 +383,9 @@ fun CalendarMonthView(
)
}
}
composeTraceEndSection()
}
// FAB 浮动按钮
FloatingActionButton(
onClick = { isMenuExpanded = !isMenuExpanded },

View File

@ -38,6 +38,44 @@ import com.tyme.solar.SolarDay
import kotlinx.datetime.LocalDate
import plus.rua.project.ShiftKind
// P0-C: 静态缓存 SolarDay 计算结果,避免 Pager 滑动/切换时重复创建对象触发 GC
@Suppress("DEPRECATION") // monthNumber 无替代 API
private fun computeDayCellInfo(date: LocalDate): Triple<String, Boolean, String?> {
val solarDay = SolarDay.fromYmd(date.year, date.monthNumber, date.day)
val holidayBadge = solarDay.getLegalHoliday()?.let { if (it.isWork()) "" else "" }
val lunarDay = solarDay.getLunarDay()
// 农历传统节日(仅当天)
val lunarFestival = lunarDay.getFestival()
if (lunarFestival != null) {
return Triple(lunarFestival.getName(), true, holidayBadge)
}
// 节气(当天才显示)
val termDay = solarDay.getTermDay()
if (termDay.getDayIndex() == 0) {
return Triple(termDay.getSolarTerm().getName(), true, holidayBadge)
}
// 公历节日(仅当天)
val solarFestival = solarDay.getFestival()
if (solarFestival != null) {
return Triple(solarFestival.getName(), true, holidayBadge)
}
// 默认:农历日期
val name = lunarDay.getName()
val text = if (name == "初一") {
val lunarMonth = lunarDay.getLunarMonth()
"${lunarMonth.getName()}"
} else {
name
}
return Triple(text, false, holidayBadge)
}
private val dayCellInfoCache = mutableMapOf<LocalDate, Triple<String, Boolean, String?>>()
enum class DayCellState {
NORMAL, OTHER_MONTH, TODAY, SELECTED, SELECTED_TODAY
}
@ -124,53 +162,16 @@ fun DayCell(
val selectedOutlineColor = MaterialTheme.colorScheme.primary
data class DayAnnotation(val text: String, val isHighlight: Boolean)
val holidayBadge = remember(date) {
@Suppress("DEPRECATION") // monthNumber 无替代 API
val solarDay = SolarDay.fromYmd(date.year, date.monthNumber, date.day)
solarDay.getLegalHoliday()?.let { if (it.isWork()) "" else "" }
}
val annotation = remember(date) {
@Suppress("DEPRECATION") // monthNumber 无替代 API
val solarDay = SolarDay.fromYmd(date.year, date.monthNumber, date.day)
val lunarDay = solarDay.getLunarDay()
// 农历传统节日(仅当天)
val lunarFestival = lunarDay.getFestival()
if (lunarFestival != null) {
return@remember DayAnnotation(lunarFestival.getName(), true)
}
// 节气(当天才显示)
val termDay = solarDay.getTermDay()
if (termDay.getDayIndex() == 0) {
return@remember DayAnnotation(termDay.getSolarTerm().getName(), true)
}
// 公历节日(仅当天)
val solarFestival = solarDay.getFestival()
if (solarFestival != null) {
return@remember DayAnnotation(solarFestival.getName(), true)
}
// 默认:农历日期
val name = lunarDay.getName()
val text = if (name == "初一") {
val lunarMonth = lunarDay.getLunarMonth()
"${lunarMonth.getName()}"
} else {
name
}
DayAnnotation(text, false)
// P0-C: 使用静态缓存避免每次重组时重复创建 SolarDay 对象
val (annotationText, isAnnotationHighlight, holidayBadge) = remember(date) {
dayCellInfoCache.getOrPut(date) { computeDayCellInfo(date) }
}
val lunarColor by transition.animateColor(
transitionSpec = { tween(150, easing = FastOutSlowInEasing) },
label = "lunarColor"
) { state ->
if (annotation.isHighlight) {
if (isAnnotationHighlight) {
when (state) {
DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer.copy(
alpha = 0.85f
@ -250,7 +251,7 @@ fun DayCell(
style = MaterialTheme.typography.bodyMedium
)
Text(
text = annotation.text,
text = annotationText,
textAlign = TextAlign.Center,
color = lunarColor,
fontSize = 7.sp,