perf(android): fixed settle wait and separate reset budget for color grid (#370)

- Replace the polling settle (up to 4 extra parses) after each horizontal
  swipe with one fixed 1200ms wait; no extra parse, clicks unchanged.
- moveColorsToStart gets its own budget (2x specHorizontalSwipes); anchor
  retries no longer consume swipes (max 2 consecutive, 6 total).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-10-12 03:15:30 +08:00
co-authored by Claude Opus 5.5
parent 9619c38ae3
commit 2ae3e8115f
2 changed files with 96 additions and 56 deletions
@@ -1705,9 +1705,10 @@ class PddProductDetailCollector(
break
}
diagnosticHorizontalSwipes++
pause(350)
val settleStartedAt = now()
pause(HORIZONTAL_SETTLE_MILLIS)
lastHorizontalSwipeDoneAt = now()
settleColorGrid(goodsId, config, evidence)
trace("color-settle waitedMs=${now() - settleStartedAt}")
}
trace("color-row-end row=$rowIndex end=$rowEnd skip=$rowSkipNote")
// An uncertain revisit cannot erase previously observed
@@ -1767,35 +1768,6 @@ class PddProductDetailCollector(
private var lastHorizontalSwipeDoneAt = -1L
/**
* After a horizontal swipe the grid may still be coasting, so positions
* parsed now can differ from the ones at click time. Re-read the color rows
* until two adjacent reads agree (text + bounds), at most four more reads
* 200ms apart. Never fails the task and never clicks.
*/
private fun settleColorGrid(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) {
val startedAt = now()
fun read(): List<String>? {
val screen = parse(goodsId, config, evidence)
if (screen.problem != null || !screen.pageEvidenceMatched) return null
return optionSignature(colorRows(screen).flatten())
}
var previous = read()
var reads = 0
var stable = false
while (previous != null && reads < 4) {
pause(200)
reads++
val current = read() ?: break
if (current == previous) {
stable = true
break
}
previous = current
}
trace("color-settle reads=$reads stable=$stable waitedMs=${now() - startedAt}")
}
private fun traceColorClickMiss(screen: ParsedPddScreen, value: VisibleSpecValue, rows: List<List<VisibleSpecValue>>, result: FreshActionResult) {
val rowIndex = rows.indexOfFirst { row -> row.any { it.text == value.text } }
val column = rows.getOrNull(rowIndex).orEmpty().sortedBy { it.node.bounds.left }.indexOfFirst { it.text == value.text }
@@ -1993,7 +1965,9 @@ class PddProductDetailCollector(
var anchorFailures = 0
var confirmed = false
var end = "limit"
for (attempt in 0 until config.limits.getValue("specHorizontalSwipes")) {
// Reset has its own swipe budget; anchor retries do not consume it.
val maxSwipes = config.limits.getValue("specHorizontalSwipes") * 2
while (swipes < maxSwipes) {
if (now() > deadline) return ColorStart(failure("RULE_NOT_MATCHED", "采集超过规则总超时"), false)
val screen = parse(goodsId, config, evidence)
screen.problem?.let { return ColorStart(failure(it.code, it.message), false) }
@@ -2025,7 +1999,11 @@ class PddProductDetailCollector(
// equal read must not count as stability.
previous = emptyList()
stable = 0
if ((reason == "anchorMissing" || reason == "anchorAmbiguous") && anchorFailures <= 2) {
if (
(reason == "anchorMissing" || reason == "anchorAmbiguous") &&
anchorFailures <= MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES &&
anchorRetries < MAX_RESET_ANCHOR_RETRIES
) {
anchorRetries++
continue
}
@@ -2390,6 +2368,10 @@ class PddProductDetailCollector(
private companion object {
const val PRICE_SETTLE_MILLIS = 300L
/** Fixed wait for the color grid to stop coasting after a horizontal swipe (no extra parse). */
const val HORIZONTAL_SETTLE_MILLIS = 1200L
const val MAX_RESET_ANCHOR_RETRIES = 6
const val MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES = 2
const val SPEC_PANEL_LOST_STABLE_READS = 2
const val COLOR_IMAGE_MAX_COUNT = 64
const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024
@@ -1213,7 +1213,7 @@ class PddProductDetailCollectorTest {
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 60_000))))
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
@@ -2187,23 +2187,32 @@ class PddProductDetailCollectorTest {
fun `shared container nine colors with click layout jitter does not sweep back`() =
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6, jitter = true)
private fun midStartGrid(failAttempts: Set<Int>): Pair<FakeCollectorDriver, PddCollectorResult> {
private fun midStartGrid(
failAttempts: Set<Int>,
initialOffset: Int = 2000,
traces: MutableList<String> = mutableListOf(),
captureCostMillis: Long = 0L,
elapsed: LongArray = LongArray(1),
): Pair<FakeCollectorDriver, PddCollectorResult> {
val colors = gridColors(47)
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
var clock = 0L
val driver = FakeCollectorDriver(
colors = colors,
prices = prices,
horizontalGrid = grid,
gridPixelStep = 640,
gridInitialOffset = 2000,
captureCostMillis = captureCostMillis,
captureAdvance = { clock += it },
gridInitialOffset = initialOffset,
gridRightFailAttempts = failAttempts,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
.collect(GOODS_ID, rule(gridConfig()))
elapsed[0] = clock
return driver to result
}
@@ -2217,45 +2226,73 @@ class PddProductDetailCollectorTest {
assertTrue(payload.missing.isEmpty())
}
private class SettleRun(val driver: FakeCollectorDriver, val result: PddCollectorResult, val traces: List<String>)
private class SettleRun(
val driver: FakeCollectorDriver,
val result: PddCollectorResult,
val traces: List<String>,
val elapsedMs: Long,
)
private fun inertiaGrid(inertia: Int): SettleRun {
// The grid keeps coasting until HORIZONTAL_SETTLE_MILLIS-ish of simulated
// clock time has passed since the last movement (timeBased), mirroring the
// ~1-1.3s measured on a device.
private fun inertiaGrid(timeBased: Boolean): SettleRun {
val colors = gridColors(47)
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
var clock = 0L
val driver = FakeCollectorDriver(
colors = colors,
prices = prices,
horizontalGrid = grid,
gridPixelStep = 640,
gridInertiaCaptures = inertia,
gridSettleClock = if (timeBased) ({ clock }) else null,
gridSettleMillis = if (timeBased) 1000L else 0L,
gridStrictClickPosition = true,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val traces = mutableListOf<String>()
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
.collect(GOODS_ID, rule(gridConfig()))
return SettleRun(driver, result, traces)
return SettleRun(driver, result, traces, clock)
}
@Test
fun `color grid is settled after a horizontal swipe so coasting never misses a click`() {
val calm = inertiaGrid(inertia = 0)
val coasting = inertiaGrid(inertia = 2)
fun `color grid waits a fixed time after a horizontal swipe without extra parses`() {
val calm = inertiaGrid(timeBased = false)
val coasting = inertiaGrid(timeBased = true)
println("SIM_ELAPSED_47_COLORS=${coasting.elapsedMs}ms settles=${coasting.traces.count { it.startsWith("color-settle") }}")
assertAll47Collected(coasting.result)
assertTrue(coasting.traces.none { it.startsWith("color-click-miss") })
// Waiting adds no clicks and never exceeds four extra reads.
assertEquals(calm.driver.clickCount, coasting.driver.clickCount)
val settles = coasting.traces.filter { it.startsWith("color-settle") }
assertTrue(settles.isNotEmpty())
settles.forEach { line ->
val reads = Regex("reads=([0-9]+)").find(line)!!.groupValues[1].toInt()
assertTrue(line, reads <= 4)
}
assertTrue(settles.any { it.contains("stable=true") })
settles.forEach { assertEquals("color-settle waitedMs=1200", it) }
// No polling reads: the same captures and clicks as a grid that never coasts.
assertEquals(calm.driver.captureCount, coasting.driver.captureCount)
assertEquals(calm.driver.clickCount, coasting.driver.clickCount)
}
@Test
fun `reset from the right end with scattered anchor failures still confirms the left end`() {
val traces = mutableListOf<String>()
val elapsed = LongArray(1)
val (driver, result) = midStartGrid(
failAttempts = setOf(1, 3, 5, 7),
initialOffset = 4675,
traces = traces,
captureCostMillis = 900L,
elapsed = elapsed,
)
println("SIM_ELAPSED_RIGHT_START=${elapsed[0]}ms")
assertTrue("elapsed ${elapsed[0]}", elapsed[0] < BASELINE_RIGHT_START_ELAPSED_MS * 7 / 10)
assertAll47Collected(result)
assertTrue(traces.toString(), traces.any { it.startsWith("color-start confirmed=true") && it.contains("anchorRetries=4") })
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
}
@Test
@@ -2452,6 +2489,13 @@ class PddProductDetailCollectorTest {
// #370: after a horizontal swipe that moved the grid, the next N captures
// still show it coasting (nodes drift 30px per remaining capture).
private val gridInertiaCaptures: Int = 0,
// #370: time-based coasting: captures within gridSettleMillis of the last
// grid movement (by gridSettleClock) show the nodes drifted by 30px.
private val gridSettleClock: (() -> Long)? = null,
private val gridSettleMillis: Long = 0L,
// #370: each capture() (a parse) costs this much simulated time.
private val captureCostMillis: Long = 0L,
private val captureAdvance: ((Long) -> Unit)? = null,
// #370: a click is NOT_FOUND when the node is not where the last capture
// placed it (position at click time differs from the parsed target).
private val gridStrictClickPosition: Boolean = false,
@@ -2469,6 +2513,9 @@ class PddProductDetailCollectorTest {
private var rightAttempts = 0
private var inertiaRemaining = 0
private var currentDrift = 0
// Parses between a spec swipe and the next click (discovery) cost captureCostMillis.
private var costlyParses = false
private var lastGridMoveAt = Long.MIN_VALUE / 2
private var lastSwipeFailure = "none"
private var selected: String? = initialSelectedColor
private var previousSelected: String? = null
@@ -2481,11 +2528,15 @@ class PddProductDetailCollectorTest {
private var quickConfirmationRecovered = false
private var adviceOpen = false
override fun capture(): UiSnapshot = captureFrame(advanceInertia = true)
override fun capture(): UiSnapshot {
if (captureCostMillis > 0 && costlyParses) captureAdvance?.invoke(captureCostMillis)
return captureFrame(advanceInertia = true)
}
private fun captureFrame(advanceInertia: Boolean): UiSnapshot {
captureCount++
currentDrift = inertiaRemaining * 30
currentDrift = inertiaRemaining * 30 +
if (gridSettleClock != null && gridSettleClock.invoke() - lastGridMoveAt < gridSettleMillis) 30 else 0
if (advanceInertia && inertiaRemaining > 0) inertiaRemaining--
fixedSnapshot?.let { return it }
if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
@@ -2643,6 +2694,7 @@ class PddProductDetailCollectorTest {
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clickCount++
costlyParses = false
clickedLabels += target.label
if (target.label in failedClicks) return FreshActionResult.FAILED
val current = captureFrame(advanceInertia = false).nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
@@ -2691,6 +2743,7 @@ class PddProductDetailCollectorTest {
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
swipes += direction to anchor
costlyParses = true
lastSwipeFailure = "none"
if (direction == SwipeDirection.RIGHT && horizontalGrid != null) {
rightAttempts++
@@ -2717,7 +2770,10 @@ class PddProductDetailCollectorTest {
val max = if (gridSharedContainer) sharedMax else horizontalGrid[r].size - gridVisibleColumns
gridOffsets[r] = (gridOffsets[r] + delta).coerceIn(0, maxOf(0, max))
}
if (gridOffsets.toList() != offsetsBefore) inertiaRemaining = gridInertiaCaptures
if (gridOffsets.toList() != offsetsBefore) {
inertiaRemaining = gridInertiaCaptures
gridSettleClock?.let { lastGridMoveAt = it.invoke() }
}
return true
}
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
@@ -2757,6 +2813,8 @@ class PddProductDetailCollectorTest {
companion object {
private const val GOODS_ID = "719834019024"
private const val GRID_VIEWPORT_RIGHT = 835
// Simulated elapsed time of the right-start 47 color scenario at 9619c38.
private const val BASELINE_RIGHT_START_ELAPSED_MS = 127_550L
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"