diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 71e9f65..daf7c8b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,5 +1,35 @@ # 开发指南 +## 性能追踪 + +使用 `scripts/profile.sh` 一键抓取 Perfetto trace、帧统计和内存快照。 + +```bash +# 默认抓取 8 秒 +./scripts/profile.sh + +# 抓取 15 秒 +./scripts/profile.sh 15 + +# 应用已在运行时,不自动启动 +./scripts/profile.sh --no-launch +``` + +输出文件保存在 `logs/` 目录: + +| 文件 | 说明 | +|------|------| +| `trace_*.perfetto-trace` | Perfetto trace,在 https://ui.perfetto.dev 打开 | +| `framestats_*.txt` | GPU 帧统计 | +| `meminfo_*.txt` | 内存快照 | +| `report_*.md` | 追踪报告摘要 | + +trace 中包含自定义标记: +- `MonthView:Compose` — 月视图重组 +- `YearView:Compose` — 年视图重组 +- `VM:collapseProgress` — 折叠动画 +- `getMonthDays:*` — 月份网格计算 + ## Baseline Profile ```bash diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthPage.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthPage.kt index 88ed094..cedd2a9 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthPage.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthPage.kt @@ -1,6 +1,7 @@ package plus.rua.project.ui import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -8,10 +9,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.alpha +import androidx.compose.foundation.layout.offset import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -62,32 +65,14 @@ fun CalendarMonthPage( generateMonthDays(year, month) } val density = LocalDensity.current + val interactionSource = remember { MutableInteractionSource() } val weeks = remember(days) { days.chunked(7) } val anchorIndex = remember(weeks, selectedDate) { weeks.indexOfFirst { week -> week.any { it.date == selectedDate } } } - val hasAnchor = anchorIndex >= 0 - val h = rowHeightPx.toFloat() - - // Phase 1 结束点:选中行到顶部所需的比例 - val phase1End = if (hasAnchor && anchorIndex > 0 && weeks.size > 1) { - anchorIndex.toFloat() / (weeks.size - 1) - } else 0f - - val phase1 = if (phase1End > 0f) { - (collapseProgress / phase1End).coerceIn(0f, 1f) - } else if (collapseProgress > 0f) 1f else 0f - - val phase2 = if (phase1End < 1f && collapseProgress > phase1End) { - ((collapseProgress - phase1End) / (1f - phase1End)).coerceIn(0f, 1f) - } else 0f - - val belowRowsHeight = if (hasAnchor) { - (weeks.size - 1 - anchorIndex) * h - } else 0f - val totalHeightDp = if (rowHeightPx > 0) { + val h = rowHeightPx.toFloat() val totalPx = h * (1 + (effectiveWeeks - 1) * (1f - collapseProgress)) with(density) { totalPx.toDp() } } else null @@ -99,69 +84,122 @@ fun CalendarMonthPage( ) ) { weeks.forEachIndexed { weekIndex, week -> - val isAnchor = hasAnchor && weekIndex == anchorIndex - val isAbove = hasAnchor && weekIndex < anchorIndex - val isBelow = hasAnchor && weekIndex > anchorIndex - - val yOffsetPx = if (rowHeightPx > 0) { - when { - !hasAnchor -> weekIndex * h - collapseProgress * weeks.size * h - isAnchor -> anchorIndex * h * (1f - phase1) - isAbove -> weekIndex * h - phase1 * anchorIndex * h - isBelow -> weekIndex * h - phase1 * anchorIndex * h - phase2 * belowRowsHeight - else -> weekIndex * h - } - } else 0f - - val rowAlpha = when { - !hasAnchor -> (1f - collapseProgress).coerceIn(0f, 1f) - isAnchor -> 1f - isAbove -> (1f - phase1).coerceIn(0f, 1f) - isBelow -> (1f - phase2).coerceIn(0f, 1f) - else -> 1f + key(weekIndex) { + WeekRow( + weekIndex = weekIndex, + week = week, + anchorIndex = anchorIndex, + weeksSize = weeks.size, + collapseProgress = collapseProgress, + rowHeightPx = rowHeightPx, + selectedDate = selectedDate, + today = today, + shiftKindAt = shiftKindAt, + showLegalHoliday = showLegalHoliday, + onDateClick = onDateClick, + onRowHeightMeasured = onRowHeightMeasured, + interactionSource = interactionSource + ) } + } + } +} - if (rowAlpha > 0.01f) { - Row( - modifier = Modifier - .fillMaxWidth() - .zIndex(if (isAnchor) 1f else 0f) - .then( - if (rowHeightPx > 0) Modifier.height(with(density) { h.toDp() }) - else Modifier - ) - .then( - if (isAnchor && phase1 >= 1f) Modifier.background(MaterialTheme.colorScheme.surface) - else Modifier - ) - .graphicsLayer { - translationY = yOffsetPx - alpha = rowAlpha +@Composable +private fun WeekRow( + weekIndex: Int, + week: List, + anchorIndex: Int, + weeksSize: Int, + collapseProgress: Float, + rowHeightPx: Int, + selectedDate: LocalDate, + today: LocalDate, + shiftKindAt: (LocalDate) -> ShiftKind?, + showLegalHoliday: Boolean, + onDateClick: (LocalDate) -> Unit, + onRowHeightMeasured: ((Int) -> Unit)?, + interactionSource: MutableInteractionSource, +) { + val density = LocalDensity.current + val hasAnchor = anchorIndex >= 0 + val h = rowHeightPx.toFloat() + val isAnchor = hasAnchor && weekIndex == anchorIndex + val isAbove = hasAnchor && weekIndex < anchorIndex + val isBelow = hasAnchor && weekIndex > anchorIndex + + val phase1End = if (hasAnchor && anchorIndex > 0 && weeksSize > 1) { + anchorIndex.toFloat() / (weeksSize - 1) + } else 0f + + val phase1 = if (phase1End > 0f) { + (collapseProgress / phase1End).coerceIn(0f, 1f) + } else if (collapseProgress > 0f) 1f else 0f + + val phase2 = if (phase1End < 1f && collapseProgress > phase1End) { + ((collapseProgress - phase1End) / (1f - phase1End)).coerceIn(0f, 1f) + } else 0f + + val belowRowsHeight = if (hasAnchor) { + (weeksSize - 1 - anchorIndex) * h + } else 0f + + val yOffsetPx = if (rowHeightPx > 0) { + when { + !hasAnchor -> weekIndex * h - collapseProgress * weeksSize * h + isAnchor -> anchorIndex * h * (1f - phase1) + isAbove -> weekIndex * h - phase1 * anchorIndex * h + isBelow -> weekIndex * h - phase1 * anchorIndex * h - phase2 * belowRowsHeight + else -> weekIndex * h + } + } else 0f + + val rowAlpha = when { + !hasAnchor -> (1f - collapseProgress).coerceIn(0f, 1f) + isAnchor -> 1f + isAbove -> (1f - phase1).coerceIn(0f, 1f) + isBelow -> (1f - phase2).coerceIn(0f, 1f) + else -> 1f + } + + if (rowAlpha > 0.01f) { + Row( + modifier = Modifier + .fillMaxWidth() + .zIndex(if (isAnchor) 1f else 0f) + .then( + if (rowHeightPx > 0) Modifier.height(with(density) { h.toDp() }) + else Modifier + ) + .then( + if (isAnchor && phase1 >= 1f) Modifier.background(MaterialTheme.colorScheme.surface) + else Modifier + ) + .offset(y = with(density) { yOffsetPx.toDp() }) + .alpha(rowAlpha) + .then( + if (weekIndex == 0 && rowHeightPx == 0) { + Modifier.onSizeChanged { size -> + if (size.height > 0) { + onRowHeightMeasured?.invoke(size.height) + } } - .then( - if (weekIndex == 0 && rowHeightPx == 0) { - Modifier.onSizeChanged { size -> - if (size.height > 0) { - onRowHeightMeasured?.invoke(size.height) - } - } - } else Modifier - ) - .padding(vertical = ROW_PADDING_DP.dp) - ) { - week.forEach { dayData -> - DayCell( - date = dayData.date, - isCurrentMonth = dayData.isCurrentMonth, - isSelected = dayData.date == selectedDate, - isToday = dayData.date == today, - shiftKind = shiftKindAt(dayData.date), - showLegalHoliday = showLegalHoliday, - onClick = { onDateClick(dayData.date) }, - modifier = Modifier.weight(1f) - ) - } - } + } else Modifier + ) + .padding(vertical = ROW_PADDING_DP.dp) + ) { + week.forEach { dayData -> + DayCell( + date = dayData.date, + isCurrentMonth = dayData.isCurrentMonth, + isSelected = dayData.date == selectedDate, + isToday = dayData.date == today, + shiftKind = shiftKindAt(dayData.date), + showLegalHoliday = showLegalHoliday, + onClick = { onDateClick(dayData.date) }, + modifier = Modifier.weight(1f), + interactionSource = interactionSource + ) } } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 7814aae..fafd8c0 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -60,7 +60,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -558,10 +557,9 @@ private fun BottomCardArea( onExpandDrag = { delta -> viewModel.onExpandDrag(delta) }, onExpandDragEnd = { velocity -> viewModel.onExpandDragEnd(velocity) }, dragRangePx = dragRangePx, - modifier = modifier.graphicsLayer { - translationY = slideProgress * 200.dp.toPx() - alpha = 1f - slideProgress - } + modifier = modifier + .offset(y = with(density) { (slideProgress * 200).dp }) + .alpha(1f - slideProgress) ) } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt b/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt index 2cb53ef..2d68467 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/DayCell.kt @@ -1,10 +1,9 @@ package plus.rua.project.ui -import androidx.compose.animation.animateColor +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -69,6 +68,7 @@ fun DayCell( showLegalHoliday: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, lunarCache: LunarCache = LunarCache.default ) { val lunarData by produceState( @@ -89,83 +89,74 @@ fun DayCell( else -> DayCellState.NORMAL } - val transition = updateTransition(targetState = currentState, label = "dayCell") - - val revealProgress by transition.animateFloat( - transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, - label = "revealProgress" - ) { state -> - when (state) { + val revealProgress by animateFloatAsState( + targetValue = when (currentState) { DayCellState.SELECTED, DayCellState.SELECTED_TODAY -> 1f else -> 0f - } - } + }, + animationSpec = tween(150, easing = FastOutSlowInEasing), + label = "revealProgress" + ) - val contentColor by transition.animateColor( - transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, - label = "contentColor" - ) { state -> - when (state) { + val contentColor by animateColorAsState( + targetValue = when (currentState) { DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer DayCellState.SELECTED -> MaterialTheme.colorScheme.primary DayCellState.TODAY -> MaterialTheme.colorScheme.primary DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) DayCellState.NORMAL -> MaterialTheme.colorScheme.onSurface - } - } + }, + animationSpec = tween(150, easing = FastOutSlowInEasing), + label = "contentColor" + ) // 选中今天:实心填充 primaryContainer;其他状态不填充。 - val selectedFillColor by transition.animateColor( - transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, - label = "selectedFillColor" - ) { state -> - when (state) { + val selectedFillColor by animateColorAsState( + targetValue = when (currentState) { DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.primaryContainer else -> Color.Transparent - } - } + }, + animationSpec = tween(150, easing = FastOutSlowInEasing), + label = "selectedFillColor" + ) // 选中非今天:绘制描边圆,避免遮挡右上角角标。 - val selectedOutlineAlpha by transition.animateFloat( - transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, - label = "selectedOutlineAlpha" - ) { state -> - when (state) { + val selectedOutlineAlpha by animateFloatAsState( + targetValue = when (currentState) { DayCellState.SELECTED -> 1f else -> 0f - } - } + }, + animationSpec = tween(150, easing = FastOutSlowInEasing), + label = "selectedOutlineAlpha" + ) val selectedOutlineColor = MaterialTheme.colorScheme.primary - val lunarColor by transition.animateColor( - transitionSpec = { tween(150, easing = FastOutSlowInEasing) }, - label = "lunarColor" - ) { state -> - if (isAnnotationHighlight) { - when (state) { + val lunarColor by animateColorAsState( + targetValue = if (isAnnotationHighlight) { + when (currentState) { DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer.copy( alpha = 0.85f ) - DayCellState.SELECTED -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) DayCellState.TODAY -> MaterialTheme.colorScheme.primary DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.error.copy(alpha = 0.35f) DayCellState.NORMAL -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) } } else { - when (state) { + when (currentState) { DayCellState.SELECTED_TODAY -> MaterialTheme.colorScheme.onPrimaryContainer.copy( alpha = 0.7f ) - DayCellState.SELECTED -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) DayCellState.TODAY -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) DayCellState.OTHER_MONTH -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.26f) DayCellState.NORMAL -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) } - } - } + }, + animationSpec = tween(150, easing = FastOutSlowInEasing), + label = "lunarColor" + ) val holidayBadgeColor = when (holidayBadge) { "休" -> MaterialTheme.colorScheme.error @@ -206,7 +197,7 @@ fun DayCell( } } .clickable( - interactionSource = remember { MutableInteractionSource() }, + interactionSource = interactionSource, indication = null, onClick = onClick ), diff --git a/core/src/main/kotlin/plus/rua/project/ui/WeekPager.kt b/core/src/main/kotlin/plus/rua/project/ui/WeekPager.kt index 852f473..23eeab3 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/WeekPager.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/WeekPager.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -45,6 +46,7 @@ fun WeekPager( modifier: Modifier = Modifier ) { val initialWeekMonday = remember { selectedDate.toWeekMonday() } + val interactionSource = remember { MutableInteractionSource() } val pagerState = rememberPagerState( initialPage = START_PAGE, pageCount = { Int.MAX_VALUE } @@ -97,7 +99,8 @@ fun WeekPager( shiftKind = shiftKindAt(date), showLegalHoliday = showLegalHoliday, onClick = { onDateClick(date) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + interactionSource = interactionSource ) } } diff --git a/scripts/profile.sh b/scripts/profile.sh new file mode 100755 index 0000000..13451e5 --- /dev/null +++ b/scripts/profile.sh @@ -0,0 +1,242 @@ +#!/bin/bash +# +# YaYa 性能追踪脚本 +# 使用 Perfetto 抓取应用 trace,保存到 logs/ 目录 +# +# 用法: +# ./scripts/profile.sh # 默认抓取 8 秒 +# ./scripts/profile.sh 15 # 抓取 15 秒 +# ./scripts/profile.sh --no-launch # 不自动启动应用(应用已在运行) +# + +set -euo pipefail + +PACKAGE="plus.rua.project" +ACTIVITY="plus.rua.project.MainActivity" +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LOGS_DIR="${PROJECT_ROOT}/logs" + +# 解析参数 +DURATION_SEC=8 +NO_LAUNCH=false + +for arg in "$@"; do + case "$arg" in + --no-launch) + NO_LAUNCH=true + ;; + --help|-h) + echo "用法: $0 [秒数] [--no-launch]" + echo "" + echo "选项:" + echo " 秒数 抓取时长(默认 8 秒)" + echo " --no-launch 不自动启动应用" + echo " --help 显示此帮助" + exit 0 + ;; + [0-9]*) + DURATION_SEC="$arg" + ;; + esac +done + +DURATION_MS=$((DURATION_SEC * 1000)) +TIMESTAMP=$(date +%Y%m%d_%H%M%S) + +echo "========================================" +echo " YaYa 性能追踪" +echo " 包名: ${PACKAGE}" +echo " 时长: ${DURATION_SEC}s" +echo " 输出: ${LOGS_DIR}/" +echo "========================================" + +# 创建 logs 目录 +mkdir -p "${LOGS_DIR}" + +# 检查 adb +if ! command -v adb &>/dev/null; then + echo "错误: adb 未找到。请确保 Android SDK 的 platform-tools 在 PATH 中。" + exit 1 +fi + +# 检查设备连接 +DEVICE_COUNT=$(adb devices | grep -c "device$" || true) +if [ "$DEVICE_COUNT" -eq 0 ]; then + echo "错误: 没有已连接的 Android 设备。" + exit 1 +fi +if [ "$DEVICE_COUNT" -gt 1 ]; then + echo "警告: 检测到多个设备,将使用默认设备。" +fi + +# 检查应用是否已安装 +if ! adb shell pm list packages | grep -q "${PACKAGE}"; then + echo "错误: 应用 ${PACKAGE} 未安装。请先运行 ./gradlew :app:installDebug" + exit 1 +fi + +# 启动应用(如果未禁用) +if [ "$NO_LAUNCH" = false ]; then + echo "" + echo "[1/5] 启动应用..." + adb shell am start -n "${PACKAGE}/${ACTIVITY}" >/dev/null 2>&1 || true + sleep 2 +else + echo "" + echo "[1/5] 跳过启动 (--no-launch)" +fi + +# 抓取 Perfetto trace +echo "" +echo "[2/5] 抓取 Perfetto trace (${DURATION_SEC}s)..." +echo " 请在设备上操作应用,trace 正在记录..." + +TRACE_FILE="/data/misc/perfetto-traces/yaya_${TIMESTAMP}.perfetto-trace" +LOCAL_TRACE="${LOGS_DIR}/trace_${TIMESTAMP}.perfetto-trace" +LOCAL_CONFIG="${LOGS_DIR}/.perfetto_config_${TIMESTAMP}.txt" +DEVICE_CONFIG="/data/misc/perfetto-configs/yaya_config_${TIMESTAMP}.txt" + +# 生成本地配置文件,然后 push 到设备 +cat > "${LOCAL_CONFIG}" < /dev/null +rm -f "${LOCAL_CONFIG}" + +# 运行 perfetto(前台阻塞,直到 duration_ms 结束) +adb shell "perfetto --txt -c ${DEVICE_CONFIG} -o ${TRACE_FILE}" + +# 清理设备上的临时配置文件 +adb shell "rm -f ${DEVICE_CONFIG}" + +# 拉取 trace 文件 +echo " 拉取 trace 文件..." +adb pull "${TRACE_FILE}" "${LOCAL_TRACE}" +adb shell "rm -f ${TRACE_FILE}" || true + +# 抓取帧统计 +echo "" +echo "[3/5] 抓取帧统计..." +FRAMESTATS_FILE="${LOGS_DIR}/framestats_${TIMESTAMP}.txt" +adb shell dumpsys gfxinfo "${PACKAGE}" framestats > "${FRAMESTATS_FILE}" + +# 抓取内存信息 +echo "" +echo "[4/5] 抓取内存信息..." +MEMINFO_FILE="${LOGS_DIR}/meminfo_${TIMESTAMP}.txt" +adb shell dumpsys meminfo "${PACKAGE}" > "${MEMINFO_FILE}" + +# 生成报告摘要 +echo "" +echo "[5/5] 生成摘要..." +REPORT_FILE="${LOGS_DIR}/report_${TIMESTAMP}.md" + +# 计算帧率相关数据 +FRAME_COUNT=$(grep -c "FrameTimeline" "${FRAMESTATS_FILE}" 2>/dev/null || echo "0") +JANK_COUNT=$(grep -c "jank" "${FRAMESTATS_FILE}" 2>/dev/null || echo "0") + +# 获取应用版本 +APP_VERSION=$(adb shell dumpsys package "${PACKAGE}" | grep versionName | head -1 | awk '{print $1}' | cut -d= -f2 2>/dev/null || echo "unknown") + +# 获取设备信息 +DEVICE_MODEL=$(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r') +ANDROID_VERSION=$(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r') + +cat > "${REPORT_FILE}" <