feat: 添加 testTag 基础设施,扩展 Baseline Profile 覆盖路径

为 CalendarPager、BottomCard、FAB、工具页、日期检查器等关键 UI 元素添加 testTag,
启用 testTagsAsResourceId 支持 UI Automator 通过 resource-id 定位。
BaselineProfileGenerator 使用 testTag 选择器重写,覆盖工具页、日期检查器、
DatePicker 等更多导航路径。清理 DEVELOPMENT.md 移除已过时的性能瓶颈描述。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-27 10:03:16 +08:00
parent 9a0222b4a2
commit 4eadc479eb
9 changed files with 106 additions and 51 deletions

View File

@ -17,47 +17,21 @@
输出文件保存在 `logs/` 目录: 输出文件保存在 `logs/` 目录:
| 文件 | 说明 | | 文件 | 说明 |
|------|------| | ------------------------ | ----------------------------------------------- |
| `trace_*.perfetto-trace` | Perfetto trace在 https://ui.perfetto.dev 打开 | | `trace_*.perfetto-trace` | Perfetto trace在 https://ui.perfetto.dev 打开 |
| `framestats_*.txt` | GPU 帧统计 | | `framestats_*.txt` | GPU 帧统计 |
| `meminfo_*.txt` | 内存快照 | | `meminfo_*.txt` | 内存快照 |
| `report_*.md` | 追踪报告摘要 | | `report_*.md` | 追踪报告摘要 |
trace 中包含自定义标记: trace 中包含自定义标记:
- `MonthView:Compose` — 月视图重组 - `MonthView:Compose` — 月视图重组
- `YearView:Compose` — 年视图重组 - `YearView:Compose` — 年视图重组
- `YearGridView:*` — 年视图网格组合(首帧耗时关键指标) - `YearGridView:*` — 年视图网格组合(首帧耗时关键指标)
- `generateMiniMonthDays:*` — 月份网格计算 - `generateMiniMonthDays:*` — 月份网格计算
- `VM:collapseProgress` — 折叠动画 - `VM:collapseProgress` — 折叠动画
### 已知性能瓶颈
#### 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
```bash ```bash

View File

@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
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
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@ -84,6 +85,7 @@ fun BottomCard(
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("bottom_card")
.pointerInput(isCollapsed) { .pointerInput(isCollapsed) {
if (isCollapsed) { if (isCollapsed) {
// 折叠状态:下拉恢复到月视图 // 折叠状态:下拉恢复到月视图

View File

@ -61,6 +61,9 @@ import androidx.compose.runtime.snapshotFlow
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.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -177,6 +180,7 @@ fun CalendarMonthView(
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.statusBarsPadding() .statusBarsPadding()
.semantics { testTagsAsResourceId = true }
.onSizeChanged { size -> .onSizeChanged { size ->
screenWidthPx = size.width screenWidthPx = size.width
} }
@ -377,7 +381,8 @@ fun CalendarMonthView(
onClick = { isMenuExpanded = !isMenuExpanded }, onClick = { isMenuExpanded = !isMenuExpanded },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.padding(start = 24.dp, bottom = 32.dp), .padding(start = 24.dp, bottom = 32.dp)
.testTag("fab_menu"),
shape = CircleShape, shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer contentColor = MaterialTheme.colorScheme.onPrimaryContainer

View File

@ -14,6 +14,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import plus.rua.project.util.logd import plus.rua.project.util.logd
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
@ -91,7 +92,7 @@ fun CalendarPager(
state = pagerState, state = pagerState,
beyondViewportPageCount = 0, beyondViewportPageCount = 0,
flingBehavior = PagerDefaults.flingBehavior(state = pagerState), flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
modifier = modifier modifier = modifier.testTag("calendar_pager")
) { page -> ) { page ->
val pageOffset = abs(currentPageOffsetFraction) val pageOffset = abs(currentPageOffsetFraction)
val isCurrentPage = page == currentPage val isCurrentPage = page == currentPage

View File

@ -45,6 +45,9 @@ 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
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@ -97,6 +100,7 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
var datePickerTarget by remember { mutableStateOf<DatePickerTarget?>(null) } var datePickerTarget by remember { mutableStateOf<DatePickerTarget?>(null) }
Scaffold( Scaffold(
modifier = modifier.semantics { testTagsAsResourceId = true },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("日期检查器") }, title = { Text("日期检查器") },
@ -113,6 +117,7 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
rows = rows + ExpiryRow(nextId, null) rows = rows + ExpiryRow(nextId, null)
nextId++ nextId++
}, },
modifier = Modifier.testTag("date_checker_fab"),
shape = CircleShape, shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer contentColor = MaterialTheme.colorScheme.onPrimaryContainer
@ -120,7 +125,6 @@ fun DateCheckerScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
PlusIcon(color = MaterialTheme.colorScheme.onPrimaryContainer) PlusIcon(color = MaterialTheme.colorScheme.onPrimaryContainer)
} }
}, },
modifier = modifier
) { innerPadding -> ) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@ -261,7 +265,10 @@ private fun ProductionDateField(
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
trailingIcon = { trailingIcon = {
IconButton(onClick = onShowDatePicker) { IconButton(
onClick = onShowDatePicker,
modifier = Modifier.testTag("date_picker_button")
) {
CalendarIcon(color = MaterialTheme.colorScheme.onSurfaceVariant) CalendarIcon(color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
}, },

View File

@ -243,7 +243,11 @@ private fun DayCellImpl(
.fillMaxSize() .fillMaxSize()
.semantics { .semantics {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
contentDescription = "${date.year}${date.monthNumber}${date.day}" contentDescription = if (isToday) {
"今天 ${date.year}${date.monthNumber}${date.day}"
} else {
"${date.year}${date.monthNumber}${date.day}"
}
} }
.clip(CircleShape) .clip(CircleShape)
.drawBehind { .drawBehind {

View File

@ -22,6 +22,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
@ -39,6 +42,7 @@ fun ToolsScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Scaffold( Scaffold(
modifier = modifier.semantics { testTagsAsResourceId = true },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("工具") }, title = { Text("工具") },
@ -66,7 +70,6 @@ fun ToolsScreen(
} }
) )
}, },
modifier = modifier
) { innerPadding -> ) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@ -76,7 +79,8 @@ fun ToolsScreen(
) { ) {
ToolItem( ToolItem(
title = "日期检查器", title = "日期检查器",
onClick = onNavigateToDateChecker onClick = onNavigateToDateChecker,
modifier = Modifier.testTag("tool_date_checker")
) )
} }
} }

View File

@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -155,7 +156,9 @@ fun YearGridView(
} }
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier
.fillMaxSize()
.testTag("year_grid"),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// 4×3 月历网格 // 4×3 月历网格

View File

@ -91,25 +91,80 @@ class BaselineProfileGenerator {
device.waitForIdle() device.waitForIdle()
} }
// 8. 上下滑动触发月视图↔周视图切换(覆盖 BottomCard 拖拽 + collapse 动画) // 8. 拖拽 BottomCard 触发月视图↔周视图折叠/展开
val calendarArea = device.findObject(By.res("plus.rua.project:id/calendar_pager")) val bottomCard = device.findObject(By.res("plus.rua.project:id/bottom_card"))
?: device.findObject(By.textContains("2026")) if (bottomCard != null) {
if (calendarArea != null) { val bounds = bottomCard.visibleBounds
calendarArea.swipe(Direction.UP, 0.5f) val centerX = bounds.centerX()
val centerY = bounds.centerY()
val dragDistance = (bounds.height() * 0.4).toInt()
// 向上拖拽 → 折叠到周视图
device.drag(centerX, centerY, centerX, centerY - dragDistance, 20)
device.waitForIdle() device.waitForIdle()
calendarArea.swipe(Direction.DOWN, 0.5f) // 向下拖拽 → 展开到月视图
device.drag(centerX, centerY - dragDistance, centerX, centerY, 20)
device.waitForIdle() device.waitForIdle()
} }
// 9. 左右滑动切换月份(覆盖 CalendarPager 翻页) // 9. 展开 FAB 并进入工具页面
if (calendarArea != null) { val fabMenu = device.findObject(By.res("plus.rua.project:id/fab_menu"))
calendarArea.swipe(Direction.LEFT, 0.5f) if (fabMenu != null) {
fabMenu.click()
device.waitForIdle() device.waitForIdle()
calendarArea.swipe(Direction.RIGHT, 0.5f) }
val toolsButton = device.findObject(By.text("工具"))
if (toolsButton != null) {
toolsButton.click()
device.waitForIdle() device.waitForIdle()
} }
// 10. 进入关于页面(覆盖 AboutScreen + AnimatedGif // 10. 进入日期检查器(覆盖 DateCheckerScreen
val dateCheckerEntry = device.findObject(By.res("plus.rua.project:id/tool_date_checker"))
if (dateCheckerEntry != null) {
dateCheckerEntry.click()
device.waitForIdle()
}
// 11. 点击日历图标打开 DatePickerDialog覆盖 DatePicker
val datePickerBtn = device.findObject(By.res("plus.rua.project:id/date_picker_button"))
if (datePickerBtn != null) {
datePickerBtn.click()
device.waitForIdle()
}
// 12. 等待 DatePickerDialog 并点击确定
device.wait(Until.findObject(By.text("确定")), 2000)
val confirmBtn = device.findObject(By.text("确定"))
if (confirmBtn != null) {
confirmBtn.click()
device.waitForIdle()
}
// 13. 点击 FAB 添加新行(覆盖 FAB + LazyColumn items 重组)
val dateCheckerFab = device.findObject(By.res("plus.rua.project:id/date_checker_fab"))
if (dateCheckerFab != null) {
dateCheckerFab.click()
device.waitForIdle()
}
// 14. 返回工具页
device.pressBack()
device.waitForIdle()
// 15. 返回主界面
device.pressBack()
device.waitForIdle()
// 16. 左右滑动切换月份(覆盖 CalendarPager 翻页)
val calendarPager = device.findObject(By.res("plus.rua.project:id/calendar_pager"))
if (calendarPager != null) {
calendarPager.swipe(Direction.LEFT, 0.5f)
device.waitForIdle()
calendarPager.swipe(Direction.RIGHT, 0.5f)
device.waitForIdle()
}
// 17. 进入关于页面(覆盖 AboutScreen + AnimatedGif
val aboutButton = device.findObject(By.text("关于")) val aboutButton = device.findObject(By.text("关于"))
if (aboutButton != null) { if (aboutButton != null) {
aboutButton.click() aboutButton.click()