diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index daf7c8b..4272a49 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,8 +27,36 @@ trace 中包含自定义标记: - `MonthView:Compose` — 月视图重组 - `YearView:Compose` — 年视图重组 +- `YearGridView:*` — 年视图网格组合(首帧耗时关键指标) +- `generateMiniMonthDays:*` — 月份网格计算 - `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 diff --git a/README.md b/README.md index 35149a4..56dfaea 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,30 @@ - 双模块:`:shared`(UI + 逻辑) · `:androidApp`(薄壳) - 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 # 安装 benchmark(release + 可调试)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//` 目录。 + 线条小狗表情包来自 https://www.douban.com/group/topic/264788645/?_i=9181692phrDzjR,9241256phrDzjR diff --git a/core/src/main/kotlin/plus/rua/project/LunarCache.kt b/core/src/main/kotlin/plus/rua/project/LunarCache.kt index 3fa733e..5c1a6c9 100644 --- a/core/src/main/kotlin/plus/rua/project/LunarCache.kt +++ b/core/src/main/kotlin/plus/rua/project/LunarCache.kt @@ -100,7 +100,7 @@ class LunarCache( // 默认:农历日期 val name = lunarDay.getName() val text = if (name == "初一") { - "${lunarMonthName}月" + lunarMonthName } else { name } diff --git a/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt b/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt index f24f9c0..e102446 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt @@ -11,6 +11,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer 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 /** @@ -18,12 +21,16 @@ import plus.rua.project.getWebpUri */ 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 contentDescription 无障碍描述 - * @param seed 用于控制重新随机时机的 key,变化时重新选择 GIF + * @param seed 用于控制重新随机时机的 key,变化时重新选择 WebP */ @Composable fun AnimatedGif( @@ -47,14 +54,18 @@ fun AnimatedGif( ) } + val state = rememberAsyncImageState( + options = remember { ImageOptions { repeatCount(REPEAT_COUNT) } } + ) + AsyncImage( uri = uri, contentDescription = contentDescription, - modifier = modifier - .graphicsLayer { - scaleX = scale.value - scaleY = scale.value - alpha = scale.value.coerceIn(0f, 1f) - }, + state = state, + modifier = modifier.graphicsLayer { + scaleX = scale.value + scaleY = scale.value + alpha = scale.value.coerceIn(0f, 1f) + }, ) } diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt index 73b8a1b..fffa161 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt @@ -77,7 +77,7 @@ fun CalendarPager( HorizontalPager( state = pagerState, - beyondViewportPageCount = 1, + beyondViewportPageCount = 0, flingBehavior = PagerDefaults.flingBehavior(state = pagerState), modifier = modifier ) { page -> diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index 6042dae..cc2fb8a 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -25,7 +25,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -109,29 +112,24 @@ fun YearGridView( val textMeasurer = rememberTextMeasurer() 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, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, dayTextStyle, colors) { val days = 1..31 val colorList = listOf(colors.day, colors.todayText, colors.otherMonth) - days.flatMap { d -> + value = days.flatMap { d -> colorList.map { c -> (d to c) to textMeasurer.measure(d.toString(), dayTextStyle.copy(color = c)) } }.toMap() } - // P0-H: 预测量月份标题(选中/非选中两种颜色) - val titleLayouts = remember(textMeasurer, colors) { - (1..12).flatMap { month -> + val titleLayouts by produceState, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, colors) { + value = (1..12).flatMap { month -> val text = "${month}月" listOf( (month to true) to textMeasurer.measure( text, - TextStyle( - fontSize = 10.sp, - color = colors.titleSelected, - fontWeight = FontWeight.Bold - ) + TextStyle(fontSize = 10.sp, color = colors.titleSelected, fontWeight = FontWeight.Bold) ), (month to false) to textMeasurer.measure( text, @@ -141,9 +139,8 @@ fun YearGridView( }.toMap() } - // P0-H: 预测量星期标签 - val weekdayLayouts = remember(textMeasurer, colors) { - WEEKDAY_LABELS.associateWith { label -> + val weekdayLayouts by produceState?>(null, textMeasurer, colors) { + value = WEEKDAY_LABELS.associateWith { label -> textMeasurer.measure(label, TextStyle(fontSize = 8.sp, color = colors.weekday)) } } @@ -167,7 +164,6 @@ fun YearGridView( ) { (0 until 3).forEach { col -> val month = row * 3 + col + 1 - val sharedKey = "month_grid_${year}_$month" with(sharedTransitionScope) { MiniMonth( year = year, @@ -184,7 +180,7 @@ fun YearGridView( .weight(1f) .sharedElement( sharedContentState = rememberSharedContentState( - key = sharedKey + key = "month_grid_${year}_$month" ), animatedVisibilityScope = animatedVisibilityScope, boundsTransform = { _, _ -> @@ -214,9 +210,9 @@ private fun MiniMonth( today: LocalDate, days: List, colors: MiniMonthColors, - dayLayouts: Map, androidx.compose.ui.text.TextLayoutResult>, - titleLayouts: Map, androidx.compose.ui.text.TextLayoutResult>, - weekdayLayouts: Map, + dayLayouts: Map, androidx.compose.ui.text.TextLayoutResult>?, + titleLayouts: Map, androidx.compose.ui.text.TextLayoutResult>?, + weekdayLayouts: Map?, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -241,10 +237,13 @@ private fun MiniMonth( horizontalAlignment = Alignment.CenterHorizontally ) { 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 // 1. 绘制标题 - val titleLayout = titleLayouts[month to isSelected]!! + val titleLayout = tl[month to isSelected]!! drawText( textLayoutResult = titleLayout, topLeft = Offset( @@ -256,7 +255,7 @@ private fun MiniMonth( // 2. 绘制星期行 val weekdayY = titleHeightPx + titleToWeekdayGapPx WEEKDAY_LABELS.forEachIndexed { i, label -> - val layout = weekdayLayouts[label]!! + val layout = wl[label]!! drawText( textLayoutResult = layout, topLeft = Offset( @@ -293,7 +292,7 @@ private fun MiniMonth( } if (dayNum > 0) { - dayLayouts[dayNum to textColor]?.let { layoutResult -> + dl[dayNum to textColor]?.let { layoutResult -> drawText( textLayoutResult = layoutResult, topLeft = Offset(