Fire TV向けのアプリのパフォーマンススクリプト
パフォーマンステストとは、Amazon Fire OSデバイスでの互換性、信頼性、速度、応答時間、安定性、リソース使用量といった領域を対象にアプリをテストするプロセスです。このテストは、アプリのパフォーマンスのボトルネックを特定して対処するために使用できます。パフォーマンステストには、主要業績評価指標(KPI)の収集と評価が含まれます。KPI指標を収集するには、Amazonデバイスで特定の手順を実行した後、ログなどのデバイスリソースを使用して指標を検索または計算します。
アプリをAmazonアプリストアに申請する前に、必ずパフォーマンステストを実行してください。このページでは、さまざまなカテゴリーのKPIをテストする手順と、自動化に使用できるサンプルコードを示します。このガイドで取り上げるKPIは次のとおりです。
- レイテンシ - 最初のフレームまでの時間(TTFF)
- 使用準備完了 - 表示完了までの時間(TTFD)
- アプリのコア機能(ビデオストリーミングなど)を使用した後のメモリ
セットアップ
最初に、開発用コンピューターに次のソフトウェアパッケージをインストールします。
- Amazon Corretto
- Android Studio(セットアップ中にプラットフォームツールをインストールしてください)
- Appium
ソフトウェアパッケージのインストールに加えて、次のことを行う必要があります。
- JAVA_HOMEフォルダとANDROID_HOMEフォルダのパスを設定します。
- デバイスで開発者モードを有効にし、USBデバッグを有効にします。手順については、Amazon Fire TVでデバッグを有効にするを参照してください。
- 接続されているデバイスのシリアル番号を取得します。物理的に接続されているデバイスのシリアル番号を表示するには、Android Debug Bridge(ADB)コマンドの
adb devices -lを使用できます。
テスト戦略
テストでは、アプリランチャーインテントまたはMonkeyツールを使用して、アプリの起動と強制停止を数回繰り返します。イテレーションごとに、Atraceログのキャプチャ、ナビゲーションアクションの実行、Atraceログからのタイマー値のキャプチャ、アプリを強制停止する前のメモリとRAMの使用量のキャプチャなど、特定のアクションを実行する必要があります。このループは、開発者が構成したイテレーション回数分続きます。テスト結果にはネットワークの状態、システムの負荷、その他の要因が影響する可能性があるため、複数回のイテレーションを行うことで外部要因による干渉を平均化します。
指標の平均値を計算するにあたり、Amazonでは、テストのカテゴリーごとに以下の最小イテレーション回数を実行することを推奨しています。
| パフォーマンステストのカテゴリー | 推奨される最小イテレーション回数 |
|---|---|
| レイテンシ - 最初のフレームまでの時間(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("App Launch Error");
}
}
} 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("App Launch Error")
}
}
} 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("エラー") || line.contains("アクティビティが見つかりませんでした")) {
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("エラー") || line!!.contains("アクティビティが見つかりませんでした")) {
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("1つ以上のトレースファイルを消去できませんでした。終了コード: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("トレースファイルを消去できませんでした:log=$logExit、pid=$pidExit、clearBuffer=$clearTraceBufferExit、disableTracing=$disableTracingExit")
}
log.info("トレースデータが正常に消去されました")
} catch (e: Exception) {
log.error("トレースの消去中にエラーが発生しました", e)
throw e
}
}
Atraceログの取得
Atraceログの出力を保存するには、次のサンプルコードを使用します。このコードは、adbコマンドを使用してAtraceログを取得する方法を示しています。
public String pullAtraceLog(int iteration, LaunchType launchType) throws IOException, InterruptedException {
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")
.format(LocalDateTime.now());
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());
Path outputPath = outputDir.resolve(fileName);
log.info("作業ディレクトリ:{}", currentPath);
log.info("ベースディレクトリ:{}", baseDir);
log.info("Systraceファイルパス:{}", outputPath);
Thread.sleep(5000);
try {
Files.createDirectories(outputDir);
String pullCommand = String.format("adb -s %s pull /sdcard/atrace.log %s", dsn, outputPath);
log.info("pullコマンドを実行します:{}", 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("pullの出力:{}", line);
}
reader.close();
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IOException("adb pullが失敗しました。終了コード:" + exitCode);
}
log.info("{}起動のatraceログを取得しました。デバイス:{}、出力先:{}", launchType, dsn, outputPath);
// コマンドバッファーのフラッシュを待機します(pullの後の待ち時間をシミュレートします)。
Thread.sleep(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): String {
val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
val fileName = "systrace_${launchType.lowercase()}_launch_iter${iteration}_${dsn}_$timestamp.txt"
val outputDir = Paths.get(System.getProperty("user.dir"), "configuration/output/logs/systrace", launchType.lowercase())
val outputPath = outputDir.resolve(fileName)
Thread.sleep(5000)
try {
Files.createDirectories(outputDir)
val pullCmd = "adb -s $dsn pull /sdcard/atrace.log $outputPath"
val process = ProcessBuilder("bash", "-c", pullCmd).start()
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { log.debug("pullの出力:$it") }
}
if (process.waitFor() != 0) {
throw IOException("adb pullが失敗しました")
}
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 ? "あり" : "なし",
endMarker != null ? "あり" : "なし");
return -1;
}
double latencyMs = (endMarker.timestamp - startMarker.timestamp) * 1000;
return Double.parseDouble(formatTime(latencyMs / 1000.0));
}
private fun calculateLatency(
startMarker: MarkerInfo?,
endMarker: MarkerInfo?,
launchType: String
): Double {
if (startMarker == null || endMarker == null) {
log.error(
"{}マーカーが見つかりません - 開始:{}、終了:{}",
launchType,
if (startMarker != null) "あり" else "なし",
if (endMarker != null) "あり" else "なし"
)
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("{} TTFF終了マーカーが行{}に見つかりました | 行:{}", 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 input am view 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ウォームスタートは、アプリプロセスが既にバックグラウンドで実行されているときに、アプリが起動して最初のフレームを表示するまでにかかる時間を表します。この起動アクティビティの一部として、システムはアプリをバックグラウンドからフォアグラウンドに移動させます。ウォームスタートをテストするときは、アプリがバックグラウンドに移行した後にアプリを起動します。ウォームスタート起動は通常、アプリに既にサービスがキャッシュされているため、コールドスタート起動よりも高速です。
テスト手順
- テスト対象のアプリを起動し、[ホーム] ボタンを押します。テスト対象のアプリがバックグラウンドに移行したことを確認します。
- Systraceを起動します。
adb shell atrace -o /sdcard/atrace.capture -t 15 -b 32000 -a com.example.page gfx input am view 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_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 (of the Respective app 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) {
// 2回目のパス:最後の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("どのアプローチを使用してもeglSwapBuffersマーカーを見つけることができません");
}
}
} 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) {
// 2回目のパス:最後の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(
"Fallback Approach: 最後の{} eglSwapBuffersが行{}に見つかりました | 行:{}",
launchType, finalLineCount, endMarker.line
)
} else {
log.info("どのアプローチを使用してもeglSwapBuffersマーカーを見つけることができません")
}
}
} 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 input am view 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ウォームスタートは、アプリプロセスが既にバックグラウンドで実行されているときに、アプリが起動して描画が完了し、ユーザーが操作できるようになるまでにかかる時間を表します。この起動アクティビティの一部として、システムはアプリをバックグラウンドからフォアグラウンドに移動させます。ウォームスタートをテストするときは、アプリがバックグラウンドに移行した後にアプリを起動します。ウォームスタート起動は通常、アプリに既にサービスがキャッシュされているため、コールドスタート起動よりも高速です。
テスト手順
- テスト対象のアプリを起動し、[ホーム] ボタンを押します。テスト対象のアプリがバックグラウンドに移行したことを確認します。
- Systraceを起動します。
adb shell atrace -o /sdcard/atrace.capture -t 15 -b 32000 -a com.example.page gfx input am view 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
値: 終了マーカーと開始マーカーの時間差
メモリ
メモリ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スクリプトは0以外の終了コードで終了しました。");
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)が不足すると、アプリのバックグラウンドメモリ使用量がバックグラウンドアプリの停止を決定する要因となります。システムがフォアグランドのタスクや優先度の高いタスクにメモリを必要としている場合、バックグランドメモリ使用量の最も多いアプリが最初に停止されます。
テスト手順
- アプリをダウンロードしてインストールし、該当する場合はアプリにサインインします。
- テスト対象のアプリを起動し、[ホーム] ボタンを押します。テスト対象のアプリがバックグラウンドに移行したことを確認します。
- アプリを起動します。
adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1 - アプリを開き、ビデオコンテンツを10分間再生します。
- [ホーム] ボタンを押して、アプリをバックグラウンドに送ります。
- 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
TOTAL 247077 48452 2680 186540 96748 49628 53 172578 134025 38552
KPIログファイルの作成
1回のイテレーションの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 Debug Bridge(adb)を参照してください。
- Fire TVデバイスでのその他のテストについては、テスト基準グループ2: Fire TVデバイスでのアプリの動作を参照してください。
Last updated: 2026年1月30日

