param( [ValidateSet('DetailReturn', 'Tabs', 'RapidTabs')] [string]$Scenario = 'DetailReturn', [int]$Iterations = 3, [int]$WarmupIterations = 3, [double]$MaxP95Ms = 32, [string]$Label = 'navigation', [string]$Serial, [string]$AdbPath = "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" ) # Run on an unlocked device with the app's home page and media already loaded. # Use the same device, build type, refresh rate, and scenario for before/after runs. $ErrorActionPreference = 'Stop' if ($Iterations -lt 1 -or $WarmupIterations -lt 1) { throw 'Iteration counts must be positive.' } if ($Label -notmatch '^[a-zA-Z0-9_-]+$') { throw 'Label must be a simple filename.' } $deviceArgs = if ($Serial) { @('-s', $Serial) } else { @() } $outputDirectory = Join-Path $PSScriptRoot '..\build\perf' New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null function Invoke-Adb([string[]]$AdbArguments) { $output = & $AdbPath @deviceArgs @AdbArguments if ($LASTEXITCODE -ne 0) { throw "adb failed: $($AdbArguments -join ' ')" } return $output } function Get-Window { Invoke-Adb @('shell', 'uiautomator', 'dump', '/sdcard/luma-perf-window.xml') | Out-Null return [xml](Invoke-Adb @('shell', 'cat', '/sdcard/luma-perf-window.xml')) } function Get-Center($Node) { if (-not $Node -or $Node.bounds -notmatch '^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$') { throw 'Required control is not visible. Open the loaded home page first.' } return @([int](([int]$Matches[1] + [int]$Matches[3]) / 2), [int](([int]$Matches[2] + [int]$Matches[4]) / 2)) } function Tap($Point) { Invoke-Adb @('shell', 'input', 'tap', "$($Point[0])", "$($Point[1])") | Out-Null } $window = Get-Window $homeNode = $window.SelectSingleNode('//node[@content-desc="首页" or @content-desc="Home"]') $homePoint = Get-Center $homeNode $homeMarker = $window.SelectSingleNode('//node[@text="继续观看" or @text="Continue Watching" or @text="媒体库" or @text="My Media"]') if (-not $homeMarker) { throw 'A loaded home media section must be visible before measuring.' } if ($Scenario -eq 'DetailReturn') { $resumeLabel = $window.SelectSingleNode('//node[@text="继续观看" or @text="Continue Watching"]') if (-not $resumeLabel) { throw 'A visible Continue Watching row is required for the detail return scenario.' } $resumeBottom = (Get-Center $resumeLabel)[1] $mediaNode = $window.SelectNodes('//node[@clickable="true" and descendant::node[@content-desc!=""]]') | Where-Object { (Get-Center $_)[1] -gt $resumeBottom -and (Get-Center $_)[1] -lt $homePoint[1] } | Select-Object -First 1 $media = Get-Center $mediaNode } else { $tabs = @('记录', '服务器', '设置', '首页') | ForEach-Object { ,(Get-Center $window.SelectSingleNode("//node[@content-desc='$_']")) } } $samples = @() # Warm the same navigation paths before comparing builds. for ($iteration = 1 - $WarmupIterations; $iteration -le $Iterations; $iteration++) { if ($Scenario -eq 'DetailReturn') { Tap $media $detailReady = $false for ($attempt = 0; $attempt -lt 5; $attempt++) { $detail = Get-Window if ($detail.SelectSingleNode('//node[@content-desc="返回" or @content-desc="Back"]')) { $detailReady = $true break } } if (-not $detailReady) { throw 'Media detail did not open; refusing to measure another screen.' } } Invoke-Adb @('shell', 'dumpsys', 'gfxinfo', 'com.example.myapplication', 'reset') | Out-Null if ($Scenario -eq 'DetailReturn') { Invoke-Adb @('shell', 'input', 'keyevent', '4') | Out-Null Start-Sleep -Milliseconds 900 } else { $pause = if ($Scenario -eq 'RapidTabs') { '0.12' } else { '0.6' } $commands = $tabs | ForEach-Object { "input tap $($_[0]) $($_[1]); sleep $pause" } Invoke-Adb @('shell', ($commands -join '; ')) | Out-Null Start-Sleep -Milliseconds 900 } $stats = Invoke-Adb @('shell', 'dumpsys', 'gfxinfo', 'com.example.myapplication', 'framestats') $text = $stats -join "`n" $frames = [int]([regex]::Match($text, 'Total frames rendered: (\d+)').Groups[1].Value) if ($frames -lt 10) { throw "Only $frames frames captured; the scenario did not run." } $returned = Get-Window if (-not $returned.SelectSingleNode("//node[@text='$($homeMarker.text)']")) { throw 'The scenario did not return to the original home media section.' } if ($iteration -le 0) { continue } $stats | Set-Content -LiteralPath (Join-Path $outputDirectory "$Label-$Scenario-$iteration.txt") $samples += [pscustomobject]@{ Iteration = $iteration Frames = $frames P95Ms = [int]([regex]::Match($text, '(?m)^95th percentile: (\d+)ms').Groups[1].Value) JankyFrames = [int]([regex]::Match($text, '(?m)^Janky frames: (\d+)').Groups[1].Value) JankPercent = [double]([regex]::Match($text, '(?m)^Janky frames: \d+ \(([\d.]+)%\)').Groups[1].Value) } } $samples | Format-Table -AutoSize $medianP95 = ($samples.P95Ms | Sort-Object)[[int][math]::Floor($samples.Count / 2)] $summary = [pscustomobject]@{ Scenario = $Scenario; MedianP95Ms = $medianP95; MaxP95Ms = $MaxP95Ms; Samples = $samples } $summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $outputDirectory "$Label-$Scenario.json") if ($medianP95 -gt $MaxP95Ms) { throw "Median P95 ${medianP95}ms exceeds ${MaxP95Ms}ms." } Write-Output "PASS: median P95 ${medianP95}ms <= ${MaxP95Ms}ms."