docs: 添加 Perfetto 性能排查文档;refactor: 缓存 animatable value
- DEVELOPMENT.md 新增 Perfetto / Systrace trace 分析指南 - CalendarViewModel.onDrag/onExpandDrag 中缓存 _collapseAnimatable.value 到局部变量,避免在 coroutineScope.launch 闭包中重复读取 - .gitignore 添加 .claude/ 目录 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a0a4f885a
commit
e0b7700306
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,3 +21,4 @@ node_modules/
|
|||||||
# OMC runtime state
|
# OMC runtime state
|
||||||
.omc/
|
.omc/
|
||||||
logs/
|
logs/
|
||||||
|
.claude/
|
||||||
139
DEVELOPMENT.md
139
DEVELOPMENT.md
@ -111,3 +111,142 @@ CalendarMonthView (顶层屏幕)
|
|||||||
**折叠动画:** `CalendarViewModel.collapseProgress` 控制 0f(月)↔1f(周) 过渡。`BottomCard` 捕获垂直拖拽,释放时超过 50% 则弹簧动画吸附到最近状态。完全折叠后 `WeekPager` 替代 `CalendarPager` 实现高效单周分页。
|
**折叠动画:** `CalendarViewModel.collapseProgress` 控制 0f(月)↔1f(周) 过渡。`BottomCard` 捕获垂直拖拽,释放时超过 50% 则弹簧动画吸附到最近状态。完全折叠后 `WeekPager` 替代 `CalendarPager` 实现高效单周分页。
|
||||||
|
|
||||||
**分页映射:** 两个 Pager 均使用 `Int.MAX_VALUE` 页数,中心页为 `Int.MAX_VALUE / 2`。页码到日期为算术转换,无索引列表。两者均跳过初始 `snapshotFlow` 发射 (`.drop(1)`) 以保留首次渲染时的"今日"选中。
|
**分页映射:** 两个 Pager 均使用 `Int.MAX_VALUE` 页数,中心页为 `Int.MAX_VALUE / 2`。页码到日期为算术转换,无索引列表。两者均跳过初始 `snapshotFlow` 发射 (`.drop(1)`) 以保留首次渲染时的"今日"选中。
|
||||||
|
|
||||||
|
## 性能排查(Perfetto / Systrace)
|
||||||
|
|
||||||
|
项目使用 `composeTraceBeginSection` / `composeTraceEndSection` 在关键代码段插入 trace marker,Android 上会被记录到系统 trace 中。iOS 为空操作。
|
||||||
|
|
||||||
|
已有的 trace section:
|
||||||
|
- `MonthView:Compose` / `YearView:Compose` — 顶层重组耗时
|
||||||
|
- `YearView→MonthView` / `MonthView→YearView` — 年视图切换动画
|
||||||
|
- `YearGridView:$year` / `generateMiniMonthDays:$year-$month` — 年网格渲染
|
||||||
|
- `getMonthDays:$year-$month` — 月网格数据生成
|
||||||
|
|
||||||
|
### 分析折叠器卡顿的方法
|
||||||
|
|
||||||
|
1. **录制 trace**:Android Studio → Profiler → CPU → 选择 "Trace Java Methods" 或命令行:
|
||||||
|
```bash
|
||||||
|
adb shell perfetto -c - --txt \<<EOF
|
||||||
|
buffers: { size_kb: 65536 }
|
||||||
|
data_sources: {
|
||||||
|
config {
|
||||||
|
name: "linux.ftrace"
|
||||||
|
ftrace_config {
|
||||||
|
ftrace_events: "ftrace/print"
|
||||||
|
ftrace_events: "sched/sched_switch"
|
||||||
|
buffer_size_kb: 8192
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data_sources: {
|
||||||
|
config {
|
||||||
|
name: "android.packages_list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
duration_ms: 10000
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **用 Python 分析 trace**(无需 Perfetto UI):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def read_varint(data, offset):
|
||||||
|
result = 0; shift = 0
|
||||||
|
while offset < len(data):
|
||||||
|
byte = data[offset]
|
||||||
|
result |= (byte & 0x7F) << shift
|
||||||
|
offset += 1
|
||||||
|
if not (byte & 0x80): break
|
||||||
|
shift += 7
|
||||||
|
return result, offset
|
||||||
|
|
||||||
|
def parse_trace(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
# 1) 读取所有 TracePacket
|
||||||
|
packets = []
|
||||||
|
offset = 0
|
||||||
|
while offset < len(data):
|
||||||
|
if data[offset] != 0x0a:
|
||||||
|
offset += 1; continue
|
||||||
|
offset += 1
|
||||||
|
try:
|
||||||
|
length, new_offset = read_varint(data, offset)
|
||||||
|
if 0 < length < 1_000_000 and new_offset + length <= len(data):
|
||||||
|
packets.append(data[new_offset:new_offset + length])
|
||||||
|
offset = new_offset + length
|
||||||
|
else:
|
||||||
|
offset = new_offset
|
||||||
|
except:
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
# 2) 在 ftrace_events 中搜索自定义 marker
|
||||||
|
events = []
|
||||||
|
for pkt in packets:
|
||||||
|
# 找 field 2 (ftrace_events bundle)
|
||||||
|
po = 0
|
||||||
|
while po < len(pkt):
|
||||||
|
if po >= len(pkt): break
|
||||||
|
tag = pkt[po]; po += 1
|
||||||
|
fn = tag >> 3; wt = tag & 0x07
|
||||||
|
if wt == 0:
|
||||||
|
_, po = read_varint(pkt, po)
|
||||||
|
elif wt == 2:
|
||||||
|
length, po = read_varint(pkt, po)
|
||||||
|
chunk = pkt[po:po + length]
|
||||||
|
if fn == 2:
|
||||||
|
# 扫描 bundle 内的 FtraceEvent (field 1, 0x0a)
|
||||||
|
eo = 0
|
||||||
|
while eo < len(chunk):
|
||||||
|
if chunk[eo] != 0x0a:
|
||||||
|
eo += 1; continue
|
||||||
|
eo += 1
|
||||||
|
try:
|
||||||
|
el, eno = read_varint(chunk, eo)
|
||||||
|
if el > 0 and eno + el <= len(chunk):
|
||||||
|
evt = chunk[eno:eno + el]
|
||||||
|
# 提取 timestamp (field 1, varint)
|
||||||
|
if len(evt) > 1 and evt[0] == 0x08:
|
||||||
|
ts, _ = read_varint(evt, 1)
|
||||||
|
# 搜索 marker 字符串
|
||||||
|
for pat in [b'BC:', b'VM:', b'MonthView:']:
|
||||||
|
idx = evt.find(pat)
|
||||||
|
if idx >= 0:
|
||||||
|
me = idx
|
||||||
|
while me < len(evt) and 32 <= evt[me] < 127:
|
||||||
|
me += 1
|
||||||
|
name = evt[idx:me].decode()
|
||||||
|
events.append((ts, name))
|
||||||
|
break
|
||||||
|
eo = eno + el
|
||||||
|
else:
|
||||||
|
eo = eno
|
||||||
|
except:
|
||||||
|
eo += 1
|
||||||
|
po += length
|
||||||
|
elif wt in (1, 5):
|
||||||
|
po += 8 if wt == 1 else 4
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
events.sort()
|
||||||
|
return events
|
||||||
|
|
||||||
|
# 使用
|
||||||
|
events = parse_trace('cpu-perfetto-xxxx.trace')
|
||||||
|
for ts, name in events:
|
||||||
|
print(f"{ts}: {name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **关注点**:
|
||||||
|
- **触摸事件间隔**:统计相邻 `BC:delta` marker 的时间差。理想间隔 ≤16ms;若出现 >33ms 说明丢帧,>100ms 说明触摸断流。
|
||||||
|
- **重组耗时**:`VM:collapseProgress` → `MonthView:Compose` 的间隔,应在亚毫秒级。
|
||||||
|
- **ViewModel → Compose 延迟**:从 `snapTo` 调用到下一帧重组完成的间隔。
|
||||||
|
|
||||||
|
### 已知排查结论(2026-05-19)
|
||||||
|
|
||||||
|
对折叠器 trace 的分析显示:
|
||||||
|
- **重组本身很快**(VM progress → Compose 约 500μs),不是卡顿来源。
|
||||||
|
- **触摸事件采样间隔不均匀**是主要问题。某些拖拽序列中出现 30-50ms 的触摸事件间隔,偶尔有 >100ms 的断流。这属于系统/模拟器层的事件分发问题,而非 Compose 代码问题。
|
||||||
|
- 若在真机上复现,建议检查是否有 CPU 抢占或手指短暂离屏。
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import kotlinx.datetime.minus
|
|||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.todayIn
|
import kotlinx.datetime.todayIn
|
||||||
|
import plus.rua.project.composeTraceBeginSection
|
||||||
|
import plus.rua.project.composeTraceEndSection
|
||||||
import plus.rua.project.ui.COLLAPSE_THRESHOLD
|
import plus.rua.project.ui.COLLAPSE_THRESHOLD
|
||||||
import plus.rua.project.ui.FLING_VELOCITY_THRESHOLD_DP
|
import plus.rua.project.ui.FLING_VELOCITY_THRESHOLD_DP
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@ -186,8 +188,9 @@ class CalendarViewModel(
|
|||||||
* @param delta 拖拽增量,已归一化到 [0,1] 区间
|
* @param delta 拖拽增量,已归一化到 [0,1] 区间
|
||||||
*/
|
*/
|
||||||
fun onDrag(delta: Float) {
|
fun onDrag(delta: Float) {
|
||||||
|
val old = _collapseAnimatable.value
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f)
|
val new = (old + delta).coerceIn(0f, 1f)
|
||||||
_collapseAnimatable.snapTo(new)
|
_collapseAnimatable.snapTo(new)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,9 +206,9 @@ class CalendarViewModel(
|
|||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val progress = _collapseAnimatable.value
|
val progress = _collapseAnimatable.value
|
||||||
val shouldCollapse = when {
|
val shouldCollapse = when {
|
||||||
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true // 快速上滑→折叠
|
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> true
|
||||||
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false // 快速下滑→展开
|
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> false
|
||||||
else -> progress > COLLAPSE_THRESHOLD // 慢速按 progress 判断
|
else -> progress > COLLAPSE_THRESHOLD
|
||||||
}
|
}
|
||||||
if (shouldCollapse) {
|
if (shouldCollapse) {
|
||||||
_collapseAnimatable.animateTo(
|
_collapseAnimatable.animateTo(
|
||||||
@ -228,8 +231,9 @@ class CalendarViewModel(
|
|||||||
* @param delta 拖拽增量,已归一化到 [0,1] 区间
|
* @param delta 拖拽增量,已归一化到 [0,1] 区间
|
||||||
*/
|
*/
|
||||||
fun onExpandDrag(delta: Float) {
|
fun onExpandDrag(delta: Float) {
|
||||||
|
val old = _collapseAnimatable.value
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val new = (_collapseAnimatable.value + delta).coerceIn(0f, 1f)
|
val new = (old + delta).coerceIn(0f, 1f)
|
||||||
_collapseAnimatable.snapTo(new)
|
_collapseAnimatable.snapTo(new)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,9 +249,9 @@ class CalendarViewModel(
|
|||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val progress = _collapseAnimatable.value
|
val progress = _collapseAnimatable.value
|
||||||
val shouldExpand = when {
|
val shouldExpand = when {
|
||||||
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true // 快速下滑→展开
|
velocityDpPerSec < -FLING_VELOCITY_THRESHOLD_DP -> true
|
||||||
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false // 快速上滑→保持折叠
|
velocityDpPerSec > FLING_VELOCITY_THRESHOLD_DP -> false
|
||||||
else -> progress < 1f - COLLAPSE_THRESHOLD // 慢速按 progress 判断
|
else -> progress < 1f - COLLAPSE_THRESHOLD
|
||||||
}
|
}
|
||||||
if (shouldExpand) {
|
if (shouldExpand) {
|
||||||
_collapseAnimatable.animateTo(
|
_collapseAnimatable.animateTo(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user