Merge pull request #9 from xunrua/main

优化年月切换卡顿
This commit is contained in:
Sonetto 2026-05-26 09:37:41 +08:00 committed by GitHub
commit ea7558dec8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 97 additions and 33 deletions

View File

@ -27,8 +27,36 @@
trace 中包含自定义标记: trace 中包含自定义标记:
- `MonthView:Compose` — 月视图重组 - `MonthView:Compose` — 月视图重组
- `YearView:Compose` — 年视图重组 - `YearView:Compose` — 年视图重组
- `YearGridView:*` — 年视图网格组合(首帧耗时关键指标)
- `generateMiniMonthDays:*` — 月份网格计算
- `VM:collapseProgress` — 折叠动画 - `VM:collapseProgress` — 折叠动画
- `getMonthDays:*` — 月份网格计算
### 已知性能瓶颈
#### AnimatedGif 持续解码
`AnimatedGif` 中的 250×250 WebP 动画在 `repeatCount` 未限制时会以 11-14 FPS 无限循环解码,
持续消耗 CPU/GPU。已通过 `ImageOptions { repeatCount(2) }` 限制播放 3 次后停止。
#### YearGridView 首帧 168ms
切换到年视图时,`YearGridView` 首次组合需要:
- 创建 12 个 MiniMonth composable`sharedElement` + `clickable` + `Canvas` 等 modifier 节点)
- 124 次文本测量93 日期 + 24 标题 + 7 星期)
- `SharedTransitionLayout` 注册 12 个共享元素
文本测量已通过 `produceState` 延迟到第二帧执行,首帧 Canvas 渲染为空。
剩余 ~140ms 是 12 个 composable 节点创建 + layout 的 Compose 运行时开销,
需要通过 **Baseline Profile** 预编译相关类来优化。
确保 `macrobenchmark``BaselineProfileGenerator` 覆盖以下路径:
- 冷启动 → FAB → 年视图(触发 YearGridView 首次组合)
- 年视图 → 点击任意月份返回月视图(触发 sharedElement 转场)
#### CalendarPager 预加载
`beyondViewportPageCount` 已设为 0避免翻页时预加载相邻月页导致的帧丢失。
如需恢复预加载,注意 `compose:lazy:prefetch` 可能产生 400-700ms 卡顿。
## Baseline Profile ## Baseline Profile

View File

@ -19,4 +19,30 @@
- 双模块:`:shared`(UI + 逻辑) · `:androidApp`(薄壳) - 双模块:`:shared`(UI + 逻辑) · `:androidApp`(薄壳)
- iOS 入口为 `MainViewController.kt`,Xcode 工程位于 `iosApp/` - iOS 入口为 `MainViewController.kt`,Xcode 工程位于 `iosApp/`
## 构建
```bash
# Debug
./gradlew :app:assembleDebug # 构建 debug APK
./gradlew :app:installDebug # 安装 debug APK 到设备
# Release
./gradlew :app:assembleRelease # 构建 release APK
./gradlew :app:installBenchmark # 安装 benchmarkrelease + 可调试APK
# 测试
./gradlew :core:testDebugUnitTest # 运行全部测试
./gradlew :core:testDebugUnitTest --tests "plus.rua.project.ui.CalendarUtilsTest" # 运行单个测试
# Baseline Profile需要连接设备
./gradlew :macrobenchmark:updateBaselineProfile # 一键生成 + 自动复制到 :core
./gradlew :macrobenchmark:connectedBenchmarkAndroidTest # 仅运行基准测试
# 性能 Profiling需要连接设备
./scripts/profile.sh # 默认 8 秒
./scripts/profile.sh 15 # 自定义时长
```
构建产物位于 `app/build/outputs/apk/<variant>/` 目录。
线条小狗表情包来自 https://www.douban.com/group/topic/264788645/?_i=9181692phrDzjR,9241256phrDzjR 线条小狗表情包来自 https://www.douban.com/group/topic/264788645/?_i=9181692phrDzjR,9241256phrDzjR

View File

@ -100,7 +100,7 @@ class LunarCache(
// 默认:农历日期 // 默认:农历日期
val name = lunarDay.getName() val name = lunarDay.getName()
val text = if (name == "初一") { val text = if (name == "初一") {
"${lunarMonthName}" lunarMonthName
} else { } else {
name name
} }

View File

@ -11,6 +11,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import com.github.panpf.sketch.AsyncImage import com.github.panpf.sketch.AsyncImage
import com.github.panpf.sketch.rememberAsyncImageState
import com.github.panpf.sketch.request.ImageOptions
import com.github.panpf.sketch.request.repeatCount
import plus.rua.project.getWebpUri import plus.rua.project.getWebpUri
/** /**
@ -18,12 +21,16 @@ import plus.rua.project.getWebpUri
*/ */
private val WEBP_FILES = (1..152).map { "${it.toString().padStart(3, '0')}.webp" } private val WEBP_FILES = (1..152).map { "${it.toString().padStart(3, '0')}.webp" }
private const val REPEAT_COUNT = 2
/** /**
* 显示动画 GIF 图片切换日期时随机选择一个 * 显示动画 WebP 图片切换日期时随机选择一个
*
* 动画播放 3 1 + [REPEAT_COUNT]后停止避免持续解码导致的帧丢失
* *
* @param modifier 应用于图片的 Modifier * @param modifier 应用于图片的 Modifier
* @param contentDescription 无障碍描述 * @param contentDescription 无障碍描述
* @param seed 用于控制重新随机时机的 key变化时重新选择 GIF * @param seed 用于控制重新随机时机的 key变化时重新选择 WebP
*/ */
@Composable @Composable
fun AnimatedGif( fun AnimatedGif(
@ -47,14 +54,18 @@ fun AnimatedGif(
) )
} }
val state = rememberAsyncImageState(
options = remember { ImageOptions { repeatCount(REPEAT_COUNT) } }
)
AsyncImage( AsyncImage(
uri = uri, uri = uri,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier state = state,
.graphicsLayer { modifier = modifier.graphicsLayer {
scaleX = scale.value scaleX = scale.value
scaleY = scale.value scaleY = scale.value
alpha = scale.value.coerceIn(0f, 1f) alpha = scale.value.coerceIn(0f, 1f)
}, },
) )
} }

View File

@ -77,7 +77,7 @@ fun CalendarPager(
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
beyondViewportPageCount = 1, beyondViewportPageCount = 0,
flingBehavior = PagerDefaults.flingBehavior(state = pagerState), flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
modifier = modifier modifier = modifier
) { page -> ) { page ->

View File

@ -25,7 +25,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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
@ -109,29 +112,24 @@ fun YearGridView(
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
val dayTextStyle = remember { TextStyle(fontSize = 8.sp, lineHeight = 12.sp) } val dayTextStyle = remember { TextStyle(fontSize = 8.sp, lineHeight = 12.sp) }
// P0-D: 预测量 1..31 × 3 种颜色 = 93 个 TextLayoutResult // 延迟文本测量到下一帧,避免首帧阻塞
val dayLayouts = remember(textMeasurer, dayTextStyle, colors) { val dayLayouts by produceState<Map<Pair<Int, Color>, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, dayTextStyle, colors) {
val days = 1..31 val days = 1..31
val colorList = listOf(colors.day, colors.todayText, colors.otherMonth) val colorList = listOf(colors.day, colors.todayText, colors.otherMonth)
days.flatMap { d -> value = days.flatMap { d ->
colorList.map { c -> colorList.map { c ->
(d to c) to textMeasurer.measure(d.toString(), dayTextStyle.copy(color = c)) (d to c) to textMeasurer.measure(d.toString(), dayTextStyle.copy(color = c))
} }
}.toMap() }.toMap()
} }
// P0-H: 预测量月份标题(选中/非选中两种颜色) val titleLayouts by produceState<Map<Pair<Int, Boolean>, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, colors) {
val titleLayouts = remember(textMeasurer, colors) { value = (1..12).flatMap { month ->
(1..12).flatMap { month ->
val text = "${month}" val text = "${month}"
listOf( listOf(
(month to true) to textMeasurer.measure( (month to true) to textMeasurer.measure(
text, text,
TextStyle( TextStyle(fontSize = 10.sp, color = colors.titleSelected, fontWeight = FontWeight.Bold)
fontSize = 10.sp,
color = colors.titleSelected,
fontWeight = FontWeight.Bold
)
), ),
(month to false) to textMeasurer.measure( (month to false) to textMeasurer.measure(
text, text,
@ -141,9 +139,8 @@ fun YearGridView(
}.toMap() }.toMap()
} }
// P0-H: 预测量星期标签 val weekdayLayouts by produceState<Map<String, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, colors) {
val weekdayLayouts = remember(textMeasurer, colors) { value = WEEKDAY_LABELS.associateWith { label ->
WEEKDAY_LABELS.associateWith { label ->
textMeasurer.measure(label, TextStyle(fontSize = 8.sp, color = colors.weekday)) textMeasurer.measure(label, TextStyle(fontSize = 8.sp, color = colors.weekday))
} }
} }
@ -167,7 +164,6 @@ fun YearGridView(
) { ) {
(0 until 3).forEach { col -> (0 until 3).forEach { col ->
val month = row * 3 + col + 1 val month = row * 3 + col + 1
val sharedKey = "month_grid_${year}_$month"
with(sharedTransitionScope) { with(sharedTransitionScope) {
MiniMonth( MiniMonth(
year = year, year = year,
@ -184,7 +180,7 @@ fun YearGridView(
.weight(1f) .weight(1f)
.sharedElement( .sharedElement(
sharedContentState = rememberSharedContentState( sharedContentState = rememberSharedContentState(
key = sharedKey key = "month_grid_${year}_$month"
), ),
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = { _, _ -> boundsTransform = { _, _ ->
@ -214,9 +210,9 @@ private fun MiniMonth(
today: LocalDate, today: LocalDate,
days: List<MiniDayData>, days: List<MiniDayData>,
colors: MiniMonthColors, colors: MiniMonthColors,
dayLayouts: Map<Pair<Int, Color>, androidx.compose.ui.text.TextLayoutResult>, dayLayouts: Map<Pair<Int, Color>, androidx.compose.ui.text.TextLayoutResult>?,
titleLayouts: Map<Pair<Int, Boolean>, androidx.compose.ui.text.TextLayoutResult>, titleLayouts: Map<Pair<Int, Boolean>, androidx.compose.ui.text.TextLayoutResult>?,
weekdayLayouts: Map<String, androidx.compose.ui.text.TextLayoutResult>, weekdayLayouts: Map<String, androidx.compose.ui.text.TextLayoutResult>?,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -241,10 +237,13 @@ private fun MiniMonth(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) { Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) {
val dl = dayLayouts ?: return@Canvas
val tl = titleLayouts ?: return@Canvas
val wl = weekdayLayouts ?: return@Canvas
val cellWidth = size.width / 7f val cellWidth = size.width / 7f
// 1. 绘制标题 // 1. 绘制标题
val titleLayout = titleLayouts[month to isSelected]!! val titleLayout = tl[month to isSelected]!!
drawText( drawText(
textLayoutResult = titleLayout, textLayoutResult = titleLayout,
topLeft = Offset( topLeft = Offset(
@ -256,7 +255,7 @@ private fun MiniMonth(
// 2. 绘制星期行 // 2. 绘制星期行
val weekdayY = titleHeightPx + titleToWeekdayGapPx val weekdayY = titleHeightPx + titleToWeekdayGapPx
WEEKDAY_LABELS.forEachIndexed { i, label -> WEEKDAY_LABELS.forEachIndexed { i, label ->
val layout = weekdayLayouts[label]!! val layout = wl[label]!!
drawText( drawText(
textLayoutResult = layout, textLayoutResult = layout,
topLeft = Offset( topLeft = Offset(
@ -293,7 +292,7 @@ private fun MiniMonth(
} }
if (dayNum > 0) { if (dayNum > 0) {
dayLayouts[dayNum to textColor]?.let { layoutResult -> dl[dayNum to textColor]?.let { layoutResult ->
drawText( drawText(
textLayoutResult = layoutResult, textLayoutResult = layoutResult,
topLeft = Offset( topLeft = Offset(