commit
ea7558dec8
@ -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
|
||||||
|
|
||||||
|
|||||||
26
README.md
26
README.md
@ -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 # 安装 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/<variant>/` 目录。
|
||||||
|
|
||||||
线条小狗表情包来自 https://www.douban.com/group/topic/264788645/?_i=9181692phrDzjR,9241256phrDzjR
|
线条小狗表情包来自 https://www.douban.com/group/topic/264788645/?_i=9181692phrDzjR,9241256phrDzjR
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,11 +54,15 @@ 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)
|
||||||
|
|||||||
@ -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 ->
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user