as

Settings
Sign out
Notifications
Alexa
Amazonアプリストア
AWS
ドキュメント
Support
Contact Us
My Cases
開発
テスト
公開
収益化
ユーザーエンゲージメント
デバイスの仕様
リソース

Fire TV向けのアプリのパフォーマンススクリプト

Fire TV向けのアプリのパフォーマンススクリプト

パフォーマンステストとは、Amazon Fire OSデバイスでの互換性、信頼性、速度、応答時間、安定性、リソース使用量といった領域を対象にアプリをテストするプロセスです。このテストは、アプリのパフォーマンスのボトルネックを特定して対処するために使用できます。パフォーマンステストには、主要業績評価指標(KPI)の収集と評価が含まれます。KPI指標を収集するには、Amazonデバイスで特定の手順を実行した後、ログなどのデバイスリソースを使用して指標を検索または計算します。

アプリをAmazonアプリストアに申請する前に、必ずパフォーマンステストを実行してください。このページでは、さまざまなカテゴリーのKPIをテストする手順と、自動化に使用できるサンプルコードを示します。このガイドで取り上げるKPIは次のとおりです。

セットアップ

最初に、開発用コンピューターに次のソフトウェアパッケージをインストールします。

ソフトウェアパッケージのインストールに加えて、次のことを行う必要があります。

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

レイテンシテストの測定手順

  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 input am view 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. テスト対象のアプリを起動し、[ホーム] ボタンを押します。テスト対象のアプリがバックグラウンドに移行したことを確認します。
  2. Systraceを起動します。
     adb shell atrace -o /sdcard/atrace.capture -t 15 -b 32000 -a com.example.page gfx input am view 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_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")
}

使用準備完了テストの測定手順

  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 input am view 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. テスト対象のアプリを起動し、[ホーム] ボタンを押します。テスト対象のアプリがバックグラウンドに移行したことを確認します。
  2. Systraceを起動します。
     adb shell atrace -o /sdcard/atrace.capture -t 15 -b 32000 -a com.example.page gfx input am view 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
値: 終了マーカーと開始マーカーの時間差

メモリ

メモリ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分間行ってから、アプリのメモリ消費量を計算します。

テスト手順

  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. テスト対象のアプリを起動し、[ホーム] ボタンを押します。テスト対象のアプリがバックグラウンドに移行したことを確認します。
  3. アプリを起動します。
     adb -s %s shell monkey --pct-syskeys 0 -p app.package.com.LEANBACK_LAUNCHER -c android.intent.category.%s 1
    
  4. アプリを開き、ビデオコンテンツを10分間再生します。
  5. [ホーム] ボタンを押して、アプリをバックグラウンドに送ります。
  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                           
       TOTAL   247077    48452     2680   186540    96748    49628       53   172578   134025    38552

KPIログファイルの作成

1回のイテレーションの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日