as

Settings
Sign out
Notifications
Alexa
亚马逊应用商店
AWS
文档
Support
Contact Us
My Cases
开发
测试
应用发布
盈利
用户参与
设备规格
资源

Fire TV的应用性能脚本

Fire TV的应用性能脚本

“性能测试”是亚马逊Fire OS设备上的应用测试过程,测试领域包括兼容性、可靠性、速度、响应时间、稳定性和资源使用等。您可以使用此测试来识别和解决应用的性能瓶颈。性能测试涉及收集和评估关键性能指标 (KPI)。要收集KPI指标,您需要在亚马逊设备上运行一组特定的步骤,然后使用日志等设备资源查找或计算指标。

在将您的应用提交到亚马逊应用商店之前,请务必运行性能测试。本页提供测试不同类别KPI的步骤,并包含可在自动化中使用的示例代码。本指南涵盖以下KPI:

设置

要开始使用,请在开发计算机上安装以下软件程序包:

除了安装这些软件程序包之外,还需要完成以下操作:

  • 为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")
}

延迟测试的测量说明

  1. 下载、安装和登录应用(如果适用)。
  2. 运行Atrace清除命令。
  3. 运行Atrace启动命令。
  4. 启动应用。
  5. 等待30秒。
  6. 拉取Atrace日志。
  7. 执行相应的操作:
    • 对于冷启动,请强制停止应用程序。
    • 对于热启动,请将应用发送到后台。
  8. 重复操作,进行50次迭代。

以下部分提供了有关冷启动和热启动场景的更多详细信息。

场景: 冷启动 - 第一帧时间

TTFF冷启动是指应用进程停止或设备重启后,应用启动并显示其第一帧所需的时间。测试冷启动时,您需要在应用被强制停止后启动应用,这模拟了首次使用或全新启动的场景。冷启动通常比热启动所需的时间更长,因为应用必须重新加载服务。

测试步骤

  1. 确保受测应用未在后台运行。如果是这样,请强制停止应用进程。
     adb -s %s shell am force-stop %s
    
  2. 启动Systrace。
    adb shell atrace -o  /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm  
    
  3. 启动应用。
     adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1
    
  4. 计算绘制应用第一帧所用的时间。
  5. 强制关闭应用。
  6. 重复步骤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是指当应用进程已在后台运行时,应用启动并显示其第一帧所用的时间。作为该启动活动的一部分,系统将应用从后台带到前台。测试热启动时,应在应用进入后台后启动该应用。热启动通常比冷启动更快,因为应用已经缓存了服务。

测试步骤

  1. 启动受测应用,然后按Home(主页)按钮。确保受测应用处于后台。
  2. 启动Systrace。
    adb shell atrace -o  /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm  
    
  3. 启动应用。
     adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1
    
  4. 计算绘制应用第一帧所用的时间。
  5. 按下Home(主页)按钮。
  6. 重复步骤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")
}

准备好供使用测试的测量说明

  1. 下载、安装和登录应用(如果适用)。
  2. 运行Atrace清除命令。
  3. 运行Atrace启动命令。
  4. 启动应用。
  5. 等待30秒。
  6. 拉取Atrace日志。
  7. 执行相应的操作:
    • 对于冷启动,请强制停止应用程序。
    • 对于热启动,请将应用发送到后台。
  8. 重复操作,进行10次迭代。

以下部分提供了有关冷启动和热启动场景的更多详细信息。

场景: RTU冷启动 - 完全显示时间

RTU冷启动是指应用进程停止或设备重启后,应用启动、完全绘制以及为用户交互做好准备所用的时间。测试冷启动时,您需要在应用被强制停止后启动应用,这模拟了首次使用或全新启动的场景。冷启动通常比热启动所需的时间更长,因为应用必须重新加载服务。

测试步骤

  1. 确保受测应用未在后台运行。如果是这样,请强制停止应用进程。
     adb -s %s shell am force-stop %s
    
  2. 启动Systrace。
    adb shell atrace -o  /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm   
    
  3. 启动应用。
     adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1
    
  4. 计算应用完全加载所需的时间。这是用户可以开始与应用交互的应用状态。
  5. 强制关闭应用。
  6. 重复步骤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热启动是指当应用进程已在后台运行时,应用启动、完全绘制以及为用户交互做好准备所用的时间。作为该启动活动的一部分,系统将应用从后台带到前台。测试热启动时,应在应用进入后台后启动该应用。热启动通常比冷启动更快,因为应用已经缓存了服务。

测试步骤

  1. 启动受测应用,然后按Home按钮。确保受测应用处于后台。
  2. 启动Systrace。
    adb shell atrace -o  /sdcard/atrace.capture- t 15-b 32000-a com.example.page gfx 输入上午查看 wm   
    
  3. 启动应用。
     adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1
    
  4. 计算应用完全加载所需的时间。这是用户可以开始与应用交互的应用状态。
  5. 按下Home(主页)按钮。
  6. 重复步骤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分钟,然后计算应用的内存消耗。

测试步骤

  1. 下载、安装和登录应用(如果适用)。
  2. 确保受测应用未在后台运行。如果是这样,请强制停止应用进程。
     adb -s %s shell am force-stop %s
    
  3. 启动应用。
     adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1
    
  4. 打开应用,播放视频内容10分钟。
  5. 等待120秒。
  6. 使用以下命令计算应用的前台内存使用情况。
     adb -s %s shell 'cat /proc/%s/statm'
    
  7. 强制停止应用。
  8. 重复步骤2-7,进行5次迭代。

场景: 后台内存

后台内存KPI捕获应用在后台时的内存消耗。要测量这一点,您需要打开应用并播放视频或玩游戏10分钟,将应用置于后台,然后计算应用的内存消耗。虽然没有定义后台内存消耗的阈值,但当系统内存 (RAM) 不足时,应用在后台使用的内存量是停止后台应用的决定性因素。当系统需要更多内存来完成前台任务和其他优先任务时,将首先停止后台内存消耗量最高的应用。

测试步骤

  1. 下载、安装和登录应用(如果适用)。
  2. 启动受测应用,然后按Home按钮。确保受测应用处于后台。
  3. 启动应用。
     adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1
    
  4. 打开应用,播放视频内容10分钟。
  5. Home按钮将应用发送到后台。
  6. 等待60秒。
  7. 使用以下命令计算应用的后台内存使用情况。
     adb -s %s shell 'cat /proc/%s/statm'
    
  8. 重复步骤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日志文件。

已复制到剪贴板。

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()
    }
}

Last updated: 2026年1月30日