Fire TV的应用性能脚本
“性能测试”是亚马逊Fire OS设备上的应用测试过程,测试领域包括兼容性、可靠性、速度、响应时间、稳定性和资源使用等。您可以使用此测试来识别和解决应用的性能瓶颈。性能测试涉及收集和评估关键性能指标 (KPI)。要收集KPI指标,您需要在亚马逊设备上运行一组特定的步骤,然后使用日志等设备资源查找或计算指标。
在将您的应用提交到亚马逊应用商店之前,请务必运行性能测试。本页提供测试不同类别KPI的步骤,并包含可在自动化中使用的示例代码。本指南涵盖以下KPI:
- 延迟 - 第一帧时间 (TTFF)
- 准备好供使用 - 完全显示时间 (TTFD)
- 使用应用的核心功能(例如播放视频)后的内存
设置
要开始使用,请在开发计算机上安装以下软件程序包:
- Amazon Corretto
- Android Studio(确保在安装过程中安装平台工具)
- Appium
除了安装这些软件程序包之外,还需要完成以下操作:
- 为JAVA_HOME和ANDROID_HOME文件夹设置路径。
- 在设备上启用开发者模式并启用USB调试。有关说明,请参阅在亚马逊Fire TV上启用调试。
- 捕获所连接设备的序列号。要列出物理连接设备的序列号,您可以使用Android调试桥 (ADB) 命令
adb devices -l。
测试策略
测试期间您可使用应用启动器意图或Monkey工具多次进行应用的启动和强制停止。在每次迭代之间,您必须执行某些操作,例如捕获Atrace日志、执行导航操作、从Atrace日志中捕获计时器值,以及在强制停止应用之前捕获内存和RAM使用情况。根据您配置的迭代次数,此循环会继续。由于网络状况、系统负载和其他因素可能会影响测试结果,因此使用多次迭代来抵消外部因素的干扰。
要计算指标平均值,亚马逊建议对以下测试类别进行最少次数的迭代。
| 性能测试类别 | 建议的最小迭代次数 |
|---|---|
| 延迟 - 第一帧时间 (TTFF) | 50 |
| 准备好供使用 - 完全显示时间 (TTFD) | 10 |
| 内存 | 5 |
要测试的设备:
- Fire OS 6: Fire TV Stick 4K (2018)
- Fire OS 7: 带语音遥控器的Fire TV Stick (2020)
您可以使用在性能测试中捕获的日志、屏幕截图和其他工件进行调试或数据使用。清理工作包括强制停止Appium设备对象。
以下部分包含可以添加到测试自动化脚本中的代码示例。
获取设备类型
以下示例代码显示如何获取已连接设备的设备类型。
public String get_device_type() {
String deviceType = null;
try (BufferedReader read = new BufferedReader(new InputStreamReader
(Runtime.getRuntime().exec
("adb -s "+ DSN +" shell getprop ro.build.configuration")
.getInputStream())))
{
String outputLines = read.readLine();
switch (outputLines) {
case "tv":
deviceType = "FTV";
break;
case "tablet":
deviceType = "Tablet";
break;
}
}
catch (Exception e) {
System.out.println("获取设备类型信息时出现异常:" + e);
}
return deviceType;
}
import java.io.BufferedReader
import java.io.InputStreamReader
fun getDeviceType(): String? {
var deviceType: String? = null
try {
BufferedReader(InputStreamReader(Runtime.getRuntime().exec("adb -s $DSN shell getprop ro.build.configuration").inputStream)).use { read ->
val outputLines = read.readLine()
when (outputLines) {
"tv" -> deviceType = "FTV"
"tablet" -> deviceType = "Tablet"
}
}
} catch (e: Exception) {
println("获取设备类型信息时出现异常:$e")
}
return deviceType
}
检索主启动器活动的组件名称
以下示例代码显示如何检索主启动器活动的组件名称。此方法获取在测应用的主要活动,并通过组合应用程序包和主活动的名称来构造组件名称。
try (BufferedReader read = new BufferedReader(new InputStreamReader
(Runtime.getRuntime().exec("adb -s "+ DSN +" shell pm dump "+ appPackage +" | grep -A 1 MAIN").getInputStream()))) {
String outputLine = null;
String line;
while ((line = read.readLine()) != null) {
if (line.contains(appPackage + "/")) {
outputLine = line;
break;
}
}
outputLine = outputLine.split("/")[1];
String mainActivity = outputLine.split(" ")[0];
String componentName = appPackage + "/" + mainActivity;
return componentName;
}
catch (Exception e) {
System.out.println("检索应用主活动时出现异常" + e);
}
import java.io.BufferedReader
import java.io.InputStreamReader
try {
val process = Runtime.getRuntime().exec("adb -s $DSN shell pm dump $appPackage | grep -A 1 MAIN")
val inputStream = process.inputStream
val reader = BufferedReader(InputStreamReader(inputStream))
var line: String? = null
var outputLine: String? = null
while (reader.readLine().also { line = it } != null) {
if (line!!.contains("$appPackage/")) {
outputLine = line
break
}
}
outputLine = outputLine!!.split("/")[1]
val mainActivity = outputLine.split(" ")[0]
val componentName = "$appPackage/$mainActivity"
componentName
} catch (e: Exception) {
println("检索应用主活动时出现异常:$e")
}
使用主启动器活动的组件名称启动应用
借助以下示例代码使用主启动器活动的组件名称启动应用。该代码使用上一部分中定义的componentName变量,该变量通过组合应用程序包和主要活动来创建组件名称。
try (BufferedReader read = new BufferedReader(new InputStreamReader
(Runtime.getRuntime().exec("adb -s "+ DSN +" shell am start -n " + componentName).getInputStream()))) {
String deviceName = getDeviceName(DSN);
String line;
while ((line = read.readLine()) != null) {
if (line.startsWith("Starting: Intent")) {
System.out.println("应用启动成功,使用的是 - " + componentName);
break;
} else if (line.contains("Error")) {
System.out.println("应用启动错误");
}
}
} catch (Exception e) {
System.out.println("启动应用时出现异常:" + e);
}
import java.io.BufferedReader
import java.io.InputStreamReader
val process = Runtime.getRuntime().exec("adb -s $DSN shell am start -n $componentName")
val inputStream = process.inputStream
val reader = BufferedReader(InputStreamReader(inputStream))
try {
val deviceName = getDeviceName(DSN)
var line: String? = null
while (reader.readLine().also { line = it } != null) {
if (line!!.startsWith("Starting: Intent")) {
println("应用启动成功,使用的是 - $componentName")
break
} else if (line!!.contains("Error")) {
println("启动应用时出现错误")
}
}
} catch (e: Exception) {
println("启动应用时出现异常:$e")
}
使用Monkey工具启动应用
以下代码展示了如何使用Monkey工具启动应用。
try {
String monkeyCommand = null;
if (DEVICE_TYPE.equals(FTV)) {
monkeyCommand = " shell monkey --pct-syskeys 0 -p "
}
else {
monkeyCommand = " shell monkey -p "
}
BufferedReader launchRead = new BufferedReader(new InputStreamReader
(Runtime.getRuntime().exec("adb -s "+ DSN + monkeyCommand + appPackage +" -c android.intent.category.LAUNCHER 1").getInputStream()));
String line;
while ((line = launchRead.readLine()) != null) {
if (line.contains("Events injected")) {
System.out.println("使用Monkey工具成功启动应用 - " + appPackage);
launchRead.close();
return true;
}
else if (line.contains("Error") || line.contains("No activities found")) {
System.out.println("通过Monkey工具使用意图启动应用时出现错误”);
launchRead.close();
return false;
}
}
}
catch (Exception e) {
System.out.println("使用Monkey启动应用时出现异常" + e);
return false;
}
try {
val monkeyCommand: String?
if (DEVICE_TYPE == FTV) {
monkeyCommand = " shell monkey --pct-syskeys 0 -p "
}
else {
monkeyCommand = " shell monkey -p "
}
val launchRead = BufferedReader(InputStreamReader(
Runtime.getRuntime().exec("adb -s $DSN $monkeyCommand $appPackage -c android.intent.category.LAUNCHER 1").inputStream))
var line: String?
while (launchRead.readLine().also { line = it } != null) {
if (line!!.contains("Events injected")) {
println("使用Monkey工具成功启动应用 - $appPackage")
launchRead.close()
return true
}
else if (line!!.contains("Error") || line!!.contains("No activities found")) {
println("通过Monkey工具使用意图启动应用时出现错误")
launchRead.close()
return false
}
}
}
catch (e: Exception) {
println("使用Monkey启动应用时出现异常:$e")
return false
}
强制停止应用
以下示例代码示出如何强制停止应用。
try {
Runtime.getRuntime().exec("adb -s "+ DSN +" shell am force-stop " + appPackage);
System.out.println("已强制停止应用 - " + appPackage);
}
catch (Exception e) {
System.out.println("强制停止应用时出现异常" + e);
}
try {
Runtime.getRuntime().exec("adb -s ${DSN} shell am force-stop ${appPackage}")
println("已强制停止应用 - $appPackage")
} catch (e: Exception) {
println("强制停止应用时出现异常:$e")
}
一般命令
以下各部分提供了可在性能测试中使用的命令示例。
开始Atrace捕获
以下示例代码示出如何使用ADB命令启动Atrace日志。
public void startAtrace(String dsn) {
log.info("启动Atrace收集");
try {
String command = String.format(
"adb -s %s shell atrace -t 15 -b 32000 am wm gfx view input > /sdcard/atrace.log 2>&1 & echo $! > /sdcard/atrace.pid",
dsn
);
Process process = Runtime.getRuntime().exec(new String[]{"bash", "-c", command});
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
log.debug("Atrace输出:{}", line);
}
reader.close();
log.info("Atrace在后台启动");
} catch (Exception e) {
log.error("启动Atrace时出错:", e);
throw new RuntimeException("启动Atrace失败", e);
}
}
fun startAtrace(dsn: String) {
val command = "adb -s $dsn shell atrace -t 15 -b 32000 am wm gfx view input > /sdcard/atrace.log 2>&1 & echo \$! > /sdcard/atrace.pid"
try {
val process = ProcessBuilder("bash", "-c", command).start()
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { log.debug("Atrace输出:$it") }
}
log.info("Atrace在后台启动")
} catch (e: Exception) {
log.error("启动Atrace时出错", e)
throw RuntimeException("启动Atrace失败", e)
}
}
清除Atrace日志
您可以清除Atrace日志以开始新的会话。以下示例代码显示如何使用ADB命令清除跟踪日志。
public void clearTrace() throws IOException, InterruptedException {
log.info("清除现有跟踪数据");
try {
String deleteLogCmd = String.format("adb -s %s shell rm -rf /sdcard/atrace.log", dsn);
String deletePidCmd = String.format("adb -s %s shell rm -rf /sdcard/atrace.pid", dsn);
String clearTraceBuffer = String.format("adb -s %s shell 'echo 0 > /sys/kernel/debug/tracing/trace'", dsn);
String disableTracing = String.format("adb -s %s shell 'echo 0 > /sys/kernel/debug/tracing/tracing_on'", dsn);
Process logProcess = Runtime.getRuntime().exec(new String[]{"bash", "-c", deleteLogCmd});
Process pidProcess = Runtime.getRuntime().exec(new String[]{"bash", "-c", deletePidCmd});
Process clearTraceBufferProcess = Runtime.getRuntime().exec(new String[]{"bash", "-c", clearTraceBuffer});
Process disableTracingProcess = Runtime.getRuntime().exec(new String[]{"bash", "-c", disableTracing});
int logExitCode = logProcess.waitFor();
int pidExitCode = pidProcess.waitFor();
int clearTraceBufferExitCode = clearTraceBufferProcess.waitFor();
int disableTracingExitCode = disableTracingProcess.waitFor();
if (logExitCode != 0 || pidExitCode != 0 || clearTraceBufferExitCode!=0 || disableTracingExitCode!=0) {
throw new IOException("清除一个或多个跟踪文件失败。Exit codes: log=" + logExitCode + ", pid=" + pidExitCode);
}
log.info("跟踪数据已成功清除");
} catch (Exception e) {
log.error("清除跟踪时出错:", e);
throw e;
}
}
@Throws(IOException::class, InterruptedException::class)
fun clearTrace(dsn: String) {
log.info("清除现有跟踪数据")
try {
val logCmd = "adb -s $dsn shell rm -rf /sdcard/atrace.log"
val pidCmd = "adb -s $dsn shell rm -rf /sdcard/atrace.pid"
val clearTraceBuffer = "adb -s $dsn shell 'echo 0 > /sys/kernel/debug/tracing/trace'"
val disableTracing = "adb -s $dsn shell 'echo 0 > /sys/kernel/debug/tracing/tracing_on'"
val logProcess = ProcessBuilder("bash", "-c", logCmd).start()
val pidProcess = ProcessBuilder("bash", "-c", pidCmd).start()
val clearTraceBufferProcess = ProcessBuilder("bash", "-c", clearTraceBuffer).start()
val disableTracingProcess = ProcessBuilder("bash", "-c", disableTracing).start()
val logExit = logProcess.waitFor()
val pidExit = pidProcess.waitFor()
val clearTraceBufferExit = clearTraceBufferProcess.waitFor()
val disableTracingExit = disableTracingProcess.waitFor()
if (logExit != 0 || pidExit != 0 || clearTraceBufferExit != 0 || disableTracingExit != 0) {
throw IOException("Failed to clear trace files: log=$logExit, pid=$pidExit, clearBuffer=$clearTraceBufferExit, disableTracing=$disableTracingExit")
}
log.info("跟踪数据已成功清除")
} catch (e: Exception) {
log.error("清除跟踪时出错", e)
throw e
}
}
拉取Atrace日志
要保存Atrace日志的输出,请使用以下示例代码。该代码显示了如何使用ADB命令拉取Atrace日志。
公共字符串 p ullaTraceLog(整数迭代,la unchType launchType)抛出 IOException,In ter racetdException {
字符串时间戳 = DateTimeFormatt er 。OfPattern (“yyymmdd_hhmmss”)
。格式 (localDateTime)。现在 ());
String fileName = String.format("systrace_%s_launch_iter%d_%s_%s.txt",
launchType.toString().toLowerCase(),
iteration,
dsn,
timestamp
);
Path currentPath = Paths.get(System.getProperty("user.dir"));
Path baseDir = currentPath.resolve("configuration/output/logs/systrace");
Path outputDir = baseDir.resolve(launchType.toString().toLowerCase());
路径 outputPath = output Dir。 解析 (文件名 );
log.info("工作目录:{}", currentPath);
log.info("基本目录:{}", baseDir);
log.info("Systrace文件路径:{}", outputPath);
线程。睡觉 (5000 );
try {
Files.createDirectories(outputDir);
String pullCommand = String.format("adb -s %s pull /sdcard/atrace.log %s", dsn, outputPath);
log.info("执行拉取命令:{}", pullCommand);
Process process = Runtime.getRuntime().exec(new String[]{"bash", "-c", pullCommand});
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
log.debug("拉取输出:{}", line);
}
reader.close();
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IOException("ADB拉取失败,退出代码:" + exitCode);
}
log.info("已将设备{}的{}启动跟踪日志拉取至:{}", launchType, dsn, outputPath);
// 等待命令缓冲区刷新(模拟拉取后的等待)
线程。睡觉 (30000 );
log.info("等待拉取日志30秒");
// 清理设备跟踪文件
clearTrace();
return outputPath.toString();
} catch (Exception e) {
log.error("在设备{}上保存{}启动的跟踪文件时出错:", launchType, dsn, e);
throw new IOException("保存跟踪文件失败", e);
}
}
fun pullAtraceLog(dsn: String, iteration: Int, launchType: 字符串 String: {
val 时间戳 = local DateTime。现在 ()。格式 (DateTimeFormatter)OfPattern (“yyymmdd_hhmmss”)
val fil eName = “systrace_$ {launchType.LowerCase ()} _launch_iter$ {迭代} _$ {dsn} _$timestamp.t xt”
val outputDir = 路径。 获取 (系统) 。getProperty (“user.dir”)、“配置/输出/日志/系统跟踪” 、 launch Type。 小写 ())
val 输出路径 = output Dir。 解析 (文件名 )
线程。睡觉 (5000 )
try {
Files.createDirectories(outputDir)
val pullCmd = "adb -s $dsn pull /sdcard/atrace.log $outputPath"
val process = ProcessBuilder("bash", "-c", pullCmd).start()
进程。InputStream 。BufferedReader ()。useLines {lines ->
lines.forEach { log.debug("拉取输出:$it") }
}
if (process.waitFor() != 0) {
throw IOException("ADB拉取失败")
}
Thread.sleep(30000)
clearTrace(dsn)
return outputPath.toString()
} catch (e: Exception) {
log.error("在设备$dsn上保存$launchType启动的跟踪文件时出错:", e)
throw IOException("保存跟踪文件失败", e)
}
}
计算起始标记和结束标记之间的时差
以下示例代码显示如何计算起始和结束标记之间的时差。
private double calculateLatency(MarkerInfo startMarker, MarkerInfo endMarker, String launchType) {
if (startMarker == null || endMarker == null) {
log.error("缺少{}标记 - 开始:{},结束:{}",
launchType,
startMarker != null ? "found" : "missing",
endMarker != null ? "found" : "missing");
return -1;
}
double latencyMs = (endMarker.timestamp - startMarker.timestamp) * 1000;
return Double.parseDouble(formatTime(latencyMs / 1000.0));
}
private fun calculateLatency(
startMarker: MarkerInfo?,
endMarker: MarkerInfo?,
launchType: 字符串
): Double {
if (startMarker == null || endMarker == null) {
log.error(
"缺少{}标记 - 开始:{},结束:{}",
launchType,
if (startMarker != null) "found" else "missing",
if (endMarker != null) "found" else "missing"
)
return -1.0
}
val latencyMs = (endMarker.timestamp - startMarker.timestamp) * 1000
return formatTime(latencyMs / 1000.0).toDouble()
}
延迟 - 第一帧时间
延迟KPI,即第一帧时间 (TTFF),用于衡量应用启动后显示其第一个可视帧所用的时间。通过在冷和热启动期间进行测量,该KPI旨在复现不同场景下的真实用户行为。
在以编程方式启动应用之前,收集必要的信息,例如应用的程序包名称和主要活动。
以下示例代码示出如何测量TTFF延迟。
private double parseTTFFLaunch(List<String> fileContent, String packageName, String launchType) throws IOException {
final String START_MARKER = "AMS.startActivityAsUser";
final String END_MARKER = "eglSwapBuffersWithDamageKHR";
final Pattern TIMESTAMP_PATTERN =
Pattern.compile("\\[\\d+]\\s+\\.{3}1\\s+(\\d+\\.\\d+):\\s+tracing_mark_write:\\s+B\\|(\\d+)\\|(.*)");
String appPid = findAppPid(fileContent, packageName);
if (appPid == null) {
log.error("找不到程序包的PID:{}", packageName);
return -1;
}
MarkerInfo startMarker = null;
MarkerInfo endMarker = null;
int lineCount = 0;
for (String line : fileContent) {
lineCount++;
Matcher matcher = TIMESTAMP_PATTERN.matcher(line);
if (!matcher.find()) continue;
double timestamp = Double.parseDouble(matcher.group(1));
String processId = matcher.group(2);
String marker = matcher.group(3);
if (startMarker == null && marker.equals(START_MARKER)) {
startMarker = new MarkerInfo(timestamp, line, lineCount, processId);
log.info("在{}行找到{} TTFF起始标记|行:{}", launchType, lineCount, line);
continue;
}
if (endMarker == null && processId.equals(appPid) &&
(marker.contains("eglSwapBuffers") || marker.contains(END_MARKER))) {
endMarker = new MarkerInfo(timestamp, line, lineCount, processId);
log.info("Found {} TTFF end marker at line {} | Line: {}", launchType, lineCount, line);
break;
}
}
return calculateLatency(startMarker, endMarker, launchType + " TTFF");
}
@Throws(IOException::class)
private fun parseTTFFLaunch(fileContent: List<String>, packageName: String, launchType: String): Double {
val START_MARKER = "AMS.startActivityAsUser"
val END_MARKER = "eglSwapBuffersWithDamageKHR"
val TIMESTAMP_PATTERN = Regex("""\\[\\d+]\\s+\\.{3}1\\s+(\\d+\\.\\d+):\\s+tracing_mark_write:\\s+B\\|(\\d+)\\|(.*)""")
val appPid = findAppPid(fileContent, packageName)
if (appPid == null) {
log.error("找不到程序包的PID:{}", packageName)
return -1.0
}
var startMarker: MarkerInfo? = null
var endMarker: MarkerInfo? = null
var lineCount = 0
for (line in fileContent) {
lineCount++
val match = TIMESTAMP_PATTERN.matchEntire(line) ?: continue
val timestamp = match.groupValues[1].toDouble()
val processId = match.groupValues[2]
val marker = match.groupValues[3]
if (startMarker == null && marker == START_MARKER) {
startMarker = MarkerInfo(timestamp, line, lineCount, processId)
log.info("在{}行找到{} TTFF起始标记|行:{}", launchType, lineCount, line)
continue
}
if (endMarker == null && processId == appPid &&
(marker.contains("eglSwapBuffers") || marker.contains(END_MARKER))
) {
endMarker = MarkerInfo(timestamp, line, lineCount, processId)
log.info("在{}行找到{} TTFF结束标记|行:{}", launchType, lineCount, line)
break
}
}
return calculateLatency(startMarker, endMarker, "${launchType} TTFF")
}
延迟测试的测量说明
- 下载、安装和登录应用(如果适用)。
- 运行Atrace清除命令。
- 运行Atrace启动命令。
- 启动应用。
- 等待30秒。
- 拉取Atrace日志。
- 执行相应的操作:
- 对于冷启动,请强制停止应用程序。
- 对于热启动,请将应用发送到后台。
- 重复操作,进行50次迭代。
以下部分提供了有关冷启动和热启动场景的更多详细信息。
场景: 冷启动 - 第一帧时间
TTFF冷启动是指应用进程停止或设备重启后,应用启动并显示其第一帧所需的时间。测试冷启动时,您需要在应用被强制停止后启动应用,这模拟了首次使用或全新启动的场景。冷启动通常比热启动所需的时间更长,因为应用必须重新加载服务。
测试步骤
- 确保受测应用未在后台运行。如果是这样,请强制停止应用进程。
adb -s %s shell am force-stop %s - 启动Systrace。
adb shell atrace -o /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm - 启动应用。
adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1 - 计算绘制应用第一帧所用的时间。
- 强制关闭应用。
- 重复步骤2-5,进行50次迭代。
输出示例
起始标记: Binder:525_1F-3709 ( 525) [002] ...1 2856.568520: tracing_mark_write: B|525|AMS.startActivityAsUser
结束标记: RenderThread-12263 (12229) [002] ...1 2860.298611: tracing_mark_write: B|12229|eglSwapBuffersWithDamageKHR(相应应用PID的)
值: 末尾标记与起始标记之间的时差
场景: 热启动 - 第一帧时间
热启动TTFF是指当应用进程已在后台运行时,应用启动并显示其第一帧所用的时间。作为该启动活动的一部分,系统将应用从后台带到前台。测试热启动时,应在应用进入后台后启动该应用。热启动通常比冷启动更快,因为应用已经缓存了服务。
测试步骤
- 启动受测应用,然后按Home(主页)按钮。确保受测应用处于后台。
- 启动Systrace。
adb shell atrace -o /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm - 启动应用。
adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1 - 计算绘制应用第一帧所用的时间。
- 按下Home(主页)按钮。
- 重复步骤2-5,进行50次迭代。
输出示例
起始标记: Binder:525_7-1078 ( 525) [000] ...1 3452.891078: tracing_mark_write: B|525|AMS.startActivityAsUser
结束标记: RenderThread-15305 (15260) [000] ...1 3453.695058: tracing_mark_write: B|15260|eglSwapBuffersWithDamageKHR(相应应用PID的)
值: 末尾标记与起始标记之间的时差
准备好供使用 - 完全显示时间
准备好供使用 (RTU) KPI,即完全显示时间 (TTFD),用于衡量应用从启动到准备好供使用状态所用的时间。例如,准备好供使用状态可以是应用的登录或主页可用的状态。RTU指标可以帮助识别应用中的启动性能问题。通过在冷和热启动期间进行测量,该KPI旨在复现不同场景下的真实用户行为。
以下示例代码示出如何测量RTU指标。
private double parseRTULaunch(List < String > fileContent, String packageName, String launchType) throws IOException {
final String START_MARKER = "AMS.startActivityAsUser";
final String END_MARKER = "eglSwapBuffersWithDamageKHR";
final Pattern TIMESTAMP_PATTERN =
Pattern.compile("\\[\\d+]\\s+\\.{3}1\\s+(\\d+\\.\\d+):\\s+tracing_mark_write:\\s+B\\|(\\d+)\\|(.*)");
String appPid = findAppPid(fileContent, packageName);
if (appPid == null) {
log.error("找不到程序包的PID:{}", packageName);
return -1;
}
MarkerInfo startMarker = null;
MarkerInfo lastChoreographer = null;
MarkerInfo endMarker = null;
MarkerInfo reportFullyDrawnMarker = null;
int lineCount = 0;
// 第一遍:找到起始标记、最后Choreographer和reportFullyDrawn
for (String line : fileContent) {
lineCount++;
if (line.contains("ExoPlayer")) continue; // 跳过ExoPlayer行
Matcher matcher = TIMESTAMP_PATTERN.matcher(line);
if (!matcher.find()) continue;
double timestamp = Double.parseDouble(matcher.group(1));
String processId = matcher.group(2);
String marker = matcher.group(3);
if (startMarker == null && marker.equals(START_MARKER)) {
startMarker = new MarkerInfo(timestamp, line, lineCount, processId);
log.info("在{}行找到{}起始标记|行:{}", launchType, lineCount, line);
continue;
}
if (processId.equals(appPid)) {
// 跟踪Choreographer帧数(不包括ExoPlayer)
if (marker.contains(GFX_MARKER) && !line.contains("ExoPlayer")) {
lastChoreographer = new MarkerInfo(timestamp, line, lineCount, processId);
}
// 跟踪reportFullyDrawn(如果存在)
else if (marker.contains("Activity.reportFullyDrawn")) {
reportFullyDrawnMarker = new MarkerInfo(timestamp, line, lineCount, processId);
log.info("在行{}找到reportFullyDrawn|行:{}", lineCount, line);
}
}
}
if(reportFullyDrawnMarker == null) {
// 第二遍:找到最后一个Choreographer之后的第一个结束标记
if (lastChoreographer != null) {
lineCount = 0;
for (String line : fileContent) {
lineCount++;
if (line.contains("ExoPlayer")) continue; // 跳过ExoPlayer行
Matcher matcher = TIMESTAMP_PATTERN.matcher(line);
if (!matcher.find()) continue;
double timestamp = Double.parseDouble(matcher.group(1));
String processId = matcher.group(2);
String marker = matcher.group(3);
if (processId.equals(appPid) &&
timestamp > lastChoreographer.timestamp &&
marker.contains(END_MARKER)) {
endMarker = new MarkerInfo(timestamp, line, lineCount, processId);
log.info("在行{}的Choreographer后找到第一个{}eglSwapBuffers|行:{}",
launchType, lineCount, line);
break;
}
}
}
// 获取最后eglSwapBuffers的回退方法:
if (endMarker == null) {
lineCount = 0;
double timestamp = 0;
int finalLineCount =0;
String finalLine = null;
for (String line : fileContent) {
lineCount++;
if (line.contains("ExoPlayer")) continue; // 跳过ExoPlayer行
Matcher matcher = TIMESTAMP_PATTERN.matcher(line);
if (!matcher.find()) continue;
String processId = matcher.group(2);
String marker = matcher.group(3);
if (processId.equals(appPid) && marker.contains(END_MARKER)) {
timestamp = Double.parseDouble(matcher.group(1));
finalLine = line;
finalLineCount = lineCount;
}
}
if (finalLineCount > 0) {
endMarker = new MarkerInfo(timestamp, finalLine, finalLineCount, appPid);
log.info("回退方法: 在行{}找到最后{} eglSwapBuffers|行:{}", launchType,
finalLineCount, endMarker.line);
} else {
log.info("用任何方法都无法找到egl swapBuffers标记!!");
}
}
} else {
endMarker = reportFullyDrawnMarker;
}
return calculateLatency(startMarker, endMarker, launchType + " RTU");
}
@Throws(IOException::class)
private fun parseRTULaunch(fileContent: List<String>, packageName: String, launchType: String): Double {
val START_MARKER = "AMS.startActivityAsUser"
val END_MARKER = "eglSwapBuffersWithDamageKHR"
val TIMESTAMP_PATTERN = Pattern.compile("\\[\\d+]\\s+\\.{3}1\\s+(\\d+\\.\\d+):\\s+tracing_mark_write:\\s+B\\|(\\d+)\\|(.*)")
val appPid = findAppPid(fileContent, packageName)
if (appPid == null) {
log.error("找不到程序包的PID:{}", packageName)
return -1.0
}
var startMarker: MarkerInfo? = null
var lastChoreographer: MarkerInfo? = null
var endMarker: MarkerInfo? = null
var reportFullyDrawnMarker: MarkerInfo? = null
var lineCount = 0
// 第一遍:找到起始标记、最后Choreographer和reportFullyDrawn
for (line in fileContent) {
lineCount++
if (line.contains("ExoPlayer")) continue
val matcher = TIMESTAMP_PATTERN.matcher(line)
if (!matcher.find()) continue
val timestamp = matcher.group(1).toDouble()
val processId = matcher.group(2)
val marker = matcher.group(3)
if (startMarker == null && marker == START_MARKER) {
startMarker = MarkerInfo(timestamp, line, lineCount, processId)
log.info("在{}行找到{}起始标记|行:{}", launchType, lineCount, line)
continue
}
if (processId == appPid) {
// 跟踪Choreographer帧数(不包括ExoPlayer)
if (marker.contains(GFX_MARKER) && !line.contains("ExoPlayer")) {
lastChoreographer = MarkerInfo(timestamp, line, lineCount, processId)
}
// 跟踪reportFullyDrawn(如果存在)
else if (marker.contains("Activity.reportFullyDrawn")) {
reportFullyDrawnMarker = MarkerInfo(timestamp, line, lineCount, processId)
log.info("在行{}找到reportFullyDrawn|行:{}", lineCount, line)
}
}
}
if (reportFullyDrawnMarker == null) {
// 第二遍:找到最后一个Choreographer之后的第一个结束标记
if (lastChoreographer != null) {
lineCount = 0
for (line in fileContent) {
lineCount++
if (line.contains("ExoPlayer")) continue
val matcher = TIMESTAMP_PATTERN.matcher(line)
if (!matcher.find()) continue
val timestamp = matcher.group(1).toDouble()
val processId = matcher.group(2)
val marker = matcher.group(3)
if (processId == appPid &&
timestamp > lastChoreographer.timestamp &&
marker.contains(END_MARKER)
) {
endMarker = MarkerInfo(timestamp, line, lineCount, processId)
log.info(
"在行{}的Choreographer后找到第一个{} eglSwapBuffers|行:{}}",
launchType, lineCount, line
)
break
}
}
}
// 获取最后eglSwapBuffers的回退方法
if (endMarker == null) {
lineCount = 0
var timestamp = 0.0
var finalLineCount = 0
var finalLine: String? = null
for (line in fileContent) {
lineCount++
if (line.contains("ExoPlayer")) continue
val matcher = TIMESTAMP_PATTERN.matcher(line)
if (!matcher.find()) continue
val processId = matcher.group(2)
val marker = matcher.group(3)
if (processId == appPid && marker.contains(END_MARKER)) {
timestamp = matcher.group(1).toDouble()
finalLine = line
finalLineCount = lineCount
}
}
if (finalLineCount > 0) {
endMarker = MarkerInfo(timestamp, finalLine!!, finalLineCount, appPid)
log.info(
"回退方法: 在行{}找到最后{} eglSwapBuffers|行:{}",
launchType, finalLineCount, endMarker.line
)
} else {
log.info("用任何方法都无法找到egl swapBuffers标记!!")
}
}
} else {
endMarker = reportFullyDrawnMarker
}
return calculateLatency(startMarker, endMarker, "$launchType RTU")
}
准备好供使用测试的测量说明
- 下载、安装和登录应用(如果适用)。
- 运行Atrace清除命令。
- 运行Atrace启动命令。
- 启动应用。
- 等待30秒。
- 拉取Atrace日志。
- 执行相应的操作:
- 对于冷启动,请强制停止应用程序。
- 对于热启动,请将应用发送到后台。
- 重复操作,进行10次迭代。
以下部分提供了有关冷启动和热启动场景的更多详细信息。
场景: RTU冷启动 - 完全显示时间
RTU冷启动是指应用进程停止或设备重启后,应用启动、完全绘制以及为用户交互做好准备所用的时间。测试冷启动时,您需要在应用被强制停止后启动应用,这模拟了首次使用或全新启动的场景。冷启动通常比热启动所需的时间更长,因为应用必须重新加载服务。
测试步骤
- 确保受测应用未在后台运行。如果是这样,请强制停止应用进程。
adb -s %s shell am force-stop %s - 启动Systrace。
adb shell atrace -o /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm - 启动应用。
adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1 - 计算应用完全加载所需的时间。这是用户可以开始与应用交互的应用状态。
- 强制关闭应用。
- 重复步骤2-5,进行10次迭代。
输出示例
起始标记: 第60行 (PID 617): Binder:617_16-6754 ( 617) [001] ...1 2370075.156745: tracing_mark_write: B|617|AMS.startActivityAsUser
结束标记: ReportFullyDrawn标记: 第2647行 (PID 20378): RenderThread-20425 (20378) [001] ...1 2370076.764284: tracing_mark_write: B|20378|reportFullyDrawn
值: 末尾标记与起始标记之间的时差
场景: RTU热启动 - 完全显示时间
RTU热启动是指当应用进程已在后台运行时,应用启动、完全绘制以及为用户交互做好准备所用的时间。作为该启动活动的一部分,系统将应用从后台带到前台。测试热启动时,应在应用进入后台后启动该应用。热启动通常比冷启动更快,因为应用已经缓存了服务。
测试步骤
- 启动受测应用,然后按Home按钮。确保受测应用处于后台。
- 启动Systrace。
adb shell atrace -o /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm - 启动应用。
adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1 - 计算应用完全加载所需的时间。这是用户可以开始与应用交互的应用状态。
- 按下Home(主页)按钮。
- 重复步骤2-5,进行10次迭代。
输出示例
起始标记: 第60行 (PID 617): Binder:617_16-6754 ( 617) [001] ...1 2370075.156745: tracing_mark_write: B|617|AMS.startActivityAsUser
结束标记: ReportFullyDrawn标记: 第2647行 (PID 20378): RenderThread-20425 (20378) [001] ...1 2370076.764284: tracing_mark_write: B|20378|reportFullyDrawn
值: 末尾标记与起始标记之间的时差
内存
内存KPI详细概述了应用的内存消耗。除了内存值之外,此KPI还可以衡量前台和后台CPU使用情况、RAM使用情况、RAM空闲情况和其他详情。通过在应用处于前台和后台时进行测量,该KPI旨在复现不同场景下的真实用户行为。
内存脚本
以下代码使用Maestro工具执行脚本,该脚本播放内容10分钟并测量前台或后台内存使用情况。
public static boolean executeMaestroScript(String maestroScriptPath, String dsn) {
try {
File scriptFile = new File(maestroScriptPath);
if (!scriptFile.exists()) {
log.error("Maestro脚本文件未存在于路径:{}", maestroScriptPath);
return false;
}
String command = String.format('maestro --device %s test %s', dsn, maestroScriptPath);
log.info("执行Maestro命令:{}", command);
Process process = ShellUtils.executeCommand(command, 600);
List<String> outputLines = ShellUtils.readExecutionOutput(process, new ArrayList<>());
log.info("Maestro脚本执行输出:\n{}", String.join("\n", outputLines));
if (process.waitFor() == 0) {
log.info("Maestro脚本成功执行。");
return true;
} else {
log.error("Maestro脚本以非零退出代码终止。");
return false;
}
} catch (Exception e) {
log.error("执行Maestro脚本时出现异常:", e);
return false;
}
}
场景: 前台内存
前台内存KPI捕获应用在前台时的内存消耗。要测量这一点,您需要打开应用并播放视频或玩游戏10分钟,然后计算应用的内存消耗。
测试步骤
- 下载、安装和登录应用(如果适用)。
- 确保受测应用未在后台运行。如果是这样,请强制停止应用进程。
adb -s %s shell am force-stop %s - 启动应用。
adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1 - 打开应用,播放视频内容10分钟。
- 等待120秒。
- 使用以下命令计算应用的前台内存使用情况。
adb -s %s shell 'cat /proc/%s/statm' - 强制停止应用。
- 重复步骤2-7,进行5次迭代。
场景: 后台内存
后台内存KPI捕获应用在后台时的内存消耗。要测量这一点,您需要打开应用并播放视频或玩游戏10分钟,将应用置于后台,然后计算应用的内存消耗。虽然没有定义后台内存消耗的阈值,但当系统内存 (RAM) 不足时,应用在后台使用的内存量是停止后台应用的决定性因素。当系统需要更多内存来完成前台任务和其他优先任务时,将首先停止后台内存消耗量最高的应用。
测试步骤
- 下载、安装和登录应用(如果适用)。
- 启动受测应用,然后按Home按钮。确保受测应用处于后台。
- 启动应用。
adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1 - 打开应用,播放视频内容10分钟。
- 按Home按钮将应用发送到后台。
- 等待60秒。
- 使用以下命令计算应用的后台内存使用情况。
adb -s %s shell 'cat /proc/%s/statm' - 重复步骤2-7,进行5次迭代。
内存ADB转储日志
按比例分配共享库占用的内存 (PSS) 是指应用在设备上消耗的内存量。总计PSS用于计算应用位于前台或后台时的内存消耗。
Pss Pss Shared Private Shared Private SwapPss Heap Heap Heap
Total Clean Dirty Dirty Clean Clean Dirty Size Alloc Free
Native Heap 115268 0 384 115020 100 208 22 151552 119143 32408
Dalvik Heap 15846 0 264 15124 140 676 11 21026 14882 6144
Dalvik Other 8864 0 40 8864 0 0 0
Stack 136 0 4 136 0 0 0
Ashmem 132 0 264 0 12 0 0
Other dev 48 0 156 0 0 48 0
.so mmap 15819 9796 656 596 26112 9796 20
.apk mmap 2103 432 0 0 26868 432 0
.dex mmap 39396 37468 0 4 17052 37468 0
.oat mmap 1592 452 0 0 13724 452 0
.art mmap 2699 304 808 1956 12044 304 0
Other mmap 274 0 12 4 636 244 0
GL mtrack 42152 0 0 42152 0 0 0
Unknown 2695 0 92 2684 60 0 0
总计 247077 48452 2680 186540 96748 49628 53 172578 134025 38552
编写一个KPI日志文件
使用以下示例代码编写一次迭代的KPI日志文件。
File和FileWriter对象,并记住不要关闭FileWriter对象。
public void log_file_writer(BufferedReader bf_read) {
File adb_log_file = new File(kpi_log_file_path + "/KPI_Log.txt");
FileWriter fileWriter = new FileWriter(adb_log_file);
try {
String reader = null;
while ((reader = bf_read.readLine()) != null) {
fileWriter.write(reader.trim() + "\n");
}
}
catch (Exception e) {
System.out.println(e);
}
finally {
fileWriter.flush();
fileWriter.close();
bf_read.close();
}
}
import java.io.File
import java.io.FileWriter
import java.io.BufferedReader
fun logFileWriter(bfRead: BufferedReader) {
val adbLogFile = File("$kpi_log_file_path/KPI_Log.txt")
val fileWriter = FileWriter(adbLogFile)
try {
var reader: String?
while (bfRead.readLine().also { reader = it } != null) {
fileWriter.write(reader?.trim() + "\n")
}
} catch (e: Exception) {
println(e)
} finally {
fileWriter.flush()
fileWriter.close()
bfRead.close()
}
}
相关主题
- 要了解有关ADB命令的更多信息,请参阅Android开发者文档中的Android调试桥 (adb)。
- 有关Fire TV设备上的更多测试,请参阅测试标准组2: Fire TV设备上的应用行为。
Last updated: 2026年1月30日

