📲

Android端末のNavigation TimingをPlaywrightでPCから自動取得 (Node.js)

に公開

はじめに

Android端末のNavigation Timing APIを使って、ネットワークパフォーマンスを測定してみようと思います。
ChromeのRemote Debuggingを使えばAndroid端末のNavigation Timing取得は可能ですが、可能な限り自動化したいと思いPlaywrightを使ってPCからコマンド実行で取得することをめざします。


ChromeのRemote Debugging

Android端末の操作はまだNode.jsのみの対応だったので、Node.jsで試してみます。

サンプルコード

早速ですがサンプルコードです。
(ES Modulesをベースに書いてみたのでmjsの拡張子にしていますが、お好みで)

androidNavigationTiming.mjs
import { _android } from 'playwright-core'; // playwright-coreモジュールのインポート
import { promises as fs } from 'fs';        // fsモジュールのインポート
import { format } from 'date-fns';          // date-fnsモジュールのインポート

(async() => {
  // 定数定義
  const TARGET_URL = 'https://developer.chrome.com'; // 測定対象URL
  const DATETIME_FORMAT = 'yyyyMMdd-HHmmss';  // 出力時の日時フォーマット

  // 端末一覧を取得
  const deviceList = await _android.devices();

  if(deviceList.length === 0) {
    // デバイス未接続の場合
    console.log('No Android devices found.');
    return;
  } else {
    // 端末接続ありの場合

    // 保存ファイル名を生成
    const regrex = /https?:\/\//g;
    const prefix = TARGET_URL.replaceAll(regrex,'').replaceAll('/','_');  // TARGET_URLからプレフィクス生成
    const datetime = format(new Date(), DATETIME_FORMAT);                   // 実行時刻をフォーマット
    const filename = prefix + '_' + datetime;

    // ToDo: 複数端末接続時の挙動
    // 1台目の端末に接続
    let device = deviceList[0];

    // 端末のモデル名とシリアル番号を取得
    let deviceName = await device.model() + '_' + device.serial();
    console.log('Android device ' + deviceName + ' connected.');

    // ブラウザの立ち上げ及び測定対象URLへの接続
    let context = await device.launchBrowser();
    let page = await context.newPage();
    await page.goto(TARGET_URL);

    // 測定対象URLのNavigation Timingを取得
    let navigationTiming = await page.evaluate(() => 
      JSON.stringify(performance.getEntriesByType('navigation'))
    );

    // Navigation Timingを出力(参考)
    // console.log(navigationTiming);

    // 何かに使えるかもしれないのでAndroidのTelephony関連のダンプを出力
    let telephonyInfo = device.shell('dumpsys telephony.registry');
    await fs.writeFile(deviceName + '_' + filename + '_Telephony.txt', (await telephonyInfo).toString());
    // 何かに使えるかもしれないのでAndroidのLocation関連のダンプを出力
    let locationInfo = device.shell('dumpsys location');
    await fs.writeFile(deviceName + '_' + filename + '_Location.txt', (await locationInfo).toString());

    // Navigation TimingのJSONとスクリーンショットを出力
    await fs.writeFile(deviceName + '_' + filename + '_NavigationTiming.json', navigationTiming);
    await device.screenshot({ path: deviceName + '_' + filename + '_ScreenShot.png' });

    // 開いたタブを閉じる
    await page.close();
    
    // 端末とのコネクションを切断
    await context.close();
    await device.close();
  }
})();

実行結果

端末とPCをUSB接続の上、nodeコマンドで実行します。

% node androidNavigationTiming.mjs

4つのファイルが生成されます。

  • Navigation Timingファイル(.json)
    • Navigation Timingを記録したJSONファイル
  • スクリーンショット(.png)
    • 実行時のスクリーンショット (上手く撮れないこともある)
  • Telephony情報(.txt)
    • Android側のTelephony.registryのdumpsys (ネットワークの状態に関する情報を取得したかったため)
  • Location情報(.txt)
    • Android側のLocationのdumpsys (端末の位置情報を取得したかったため)


生成ファイル

いわゆるTime to First Byteでよく見る図の、startTimeからloadEventEndまでのタイミングが出力されます。
https://web.dev/articles/ttfb?hl=ja

出力結果
developer.chrome.com__20250316-235141.json
[
    {
        "name": "https://developer.chrome.com/?hl=ja",
        "entryType": "navigation",
        "startTime": 0,
        "duration": 2305.7000000029802,
        "initiatorType": "navigation",
        "deliveryType": "",
        "nextHopProtocol": "h3",
        "renderBlockingStatus": "non-blocking",
        "workerStart": 752.4000000059605,
        "redirectStart": 2.800000011920929,
        "redirectEnd": 749.1000000089407,
        "fetchStart": 756,
        "domainLookupStart": 756,
        "domainLookupEnd": 756,
        "connectStart": 756,
        "secureConnectionStart": 756,
        "connectEnd": 756,
        "requestStart": 756,
        "responseStart": 1675.2000000029802,
        "firstInterimResponseStart": 1675.2000000029802,
        "finalResponseHeadersStart": 0,
        "responseEnd": 1797,
        "transferSize": 142508,
        "encodedBodySize": 142208,
        "decodedBodySize": 142208,
        "responseStatus": 200,
        "serverTiming": [],
        "unloadEventStart": 0,
        "unloadEventEnd": 0,
        "domInteractive": 1975.2000000029802,
        "domContentLoadedEventStart": 1975.2000000029802,
        "domContentLoadedEventEnd": 1975.300000011921,
        "domComplete": 2305.7000000029802,
        "loadEventStart": 2305.7000000029802,
        "loadEventEnd": 2305.7000000029802,
        "type": "navigate",
        "redirectCount": 1,
        "activationStart": 0,
        "criticalCHRestart": 0,
        "notRestoredReasons": null
    }
]

スクリーンショット(.png)

実行時点でのデバイスのスクリーンショットを取得して保存します。
タイミングによって上手く撮れない (タブを閉じた後に記録される) ことがあります。

Telephony情報(.txt)

AndroidのTelephony.registryのdumpsysをそのまま出力しています。
接続しているセルラーネットワーク情報が出力されます。
見づらいデータですが、オフラインで集計するときに何か役に立つかもしれません。
(結果を一部マスクの上で抜粋しています。2147483647が頻出しますが、31bitの最大値に等しいことから無効値のように見えます)

Telephony情報 出力結果
developer.chrome.com__20250316-235141.txt
    mCellIdentity=CellIdentityLte:{ mCi=******44 mPci=**6 mTac=***1 mEarfcn=276 mBands=[1] mBandwidth=15000 mMcc=440 mMnc=10 mAlphaLong=docomo mAlphaShort=docomo mAdditionalPlmns={} mCsgInfo=null}
    mCellInfo=[CellInfoLte:{mRegistered=YES mTimeStamp=1974541987150774ns mCellConnectionStatus=0 CellIdentityLte:{ mCi=******44 mPci=**6 mTac=***1 mEarfcn=276 mBands=[1] mBandwidth=15000 mMcc=440 mMnc=10 mAlphaLong=docomo mAlphaShort=docomo mAdditionalPlmns={} mCsgInfo=null} CellSignalStrengthLte: rssi=-69 rsrp=-95 rsrq=-6 rssnr=2147483647 cqiTableIndex=2147483647 cqi=2147483647 ta=1 level=3 parametersUseForLevel=0 android.telephony.CellConfigLte :{ isEndcAvailable = false }}, CellInfoLte:{mRegistered=NO mTimeStamp=1974541987150774ns mCellConnectionStatus=0 CellIdentityLte:{ mCi=********47 mPci=**6 mTac=********47 mEarfcn=42491 mBands=[42] mBandwidth=2147483647 mMcc=null mMnc=null mAlphaLong= mAlphaShort= mAdditionalPlmns={} mCsgInfo=null} CellSignalStrengthLte: rssi=-85 rsrp=-103 rsrq=-9 rssnr=2147483647 cqiTableIndex=2147483647 cqi=2147483647 ta=2147483647 level=2 parametersUseForLevel=0 android.telephony.CellConfigLte :{ isEndcAvailable = false }}, CellInfoLte:{mRegistered=NO mTimeStamp=1974541987150774ns mCellConnectionStatus=0 CellIdentityLte:{ mCi=********47 mPci=**1 mTac=********47 mEarfcn=42491 mBands=[42] mBandwidth=2147483647 mMcc=null mMnc=null mAlphaLong= mAlphaShort= mAdditionalPlmns={} mCsgInfo=null} CellSignalStrengthLte: rssi=-85 rsrp=-109 rsrq=-15 rssnr=2147483647 cqiTableIndex=2147483647 cqi=2147483647 ta=2147483647 level=1 parametersUseForLevel=0 android.telephony.CellConfigLte :{ isEndcAvailable = false }}, CellInfoLte:{mRegistered=NO mTimeStamp=1974541987150774ns mCellConnectionStatus=0 CellIdentityLte:{ mCi=********47 mPci=**7 mTac=********47 mEarfcn=42689 mBands=[42] mBandwidth=2147483647 mMcc=null mMnc=null mAlphaLong= mAlphaShort= mAdditionalPlmns={} mCsgInfo=null} CellSignalStrengthLte: rssi=-85 rsrp=-105 rsrq=-9 rssnr=2147483647 cqiTableIndex=2147483647 cqi=2147483647 ta=2147483647 level=2 parametersUseForLevel=0 android.telephony.CellConfigLte :{ isEndcAvailable = false }}, CellInfoLte:{mRegistered=NO mTimeStamp=1974541987150774ns mCellConnectionStatus=0 CellIdentityLte:{ mCi=********47 mPci=**2 mTac=********47 mEarfcn=42689 mBands=[42] mBandwidth=2147483647 mMcc=null mMnc=null mAlphaLong= mAlphaShort= mAdditionalPlmns={} mCsgInfo=null} CellSignalStrengthLte: rssi=-85 rsrp=-108 rsrq=-12 rssnr=2147483647 cqiTableIndex=2147483647 cqi=2147483647 ta=2147483647 level=1 parametersUseForLevel=0 android.telephony.CellConfigLte :{ isEndcAvailable = false }}, CellInfoLte:{mRegistered=NO mTimeStamp=1974541987150774ns mCellConnectionStatus=0 CellIdentityLte:{ mCi=********47 mPci=**7 mTac=********47 mEarfcn=42689 mBands=[42] mBandwidth=2147483647 mMcc=null mMnc=null mAlphaLong= mAlphaShort= mAdditionalPlmns={} mCsgInfo=null} CellSignalStrengthLte: rssi=-85 rsrp=-112 rsrq=-16 rssnr=2147483647 cqiTableIndex=2147483647 cqi=2147483647 ta=2147483647 level=1 parametersUseForLevel=0 android.telephony.CellConfigLte :{ isEndcAvailable = false }}, CellInfoLte:{mRegistered=NO mTimeStamp=1974541987150774ns mCellConnectionStatus=0 CellIdentityLte:{ mCi=********47 mPci=**8 mTac=********47 mEarfcn=42689 mBands=[42] mBandwidth=2147483647 mMcc=null mMnc=null mAlphaLong= mAlphaShort= mAdditionalPlmns={} mCsgInfo=null} CellSignalStrengthLte: rssi=-85 rsrp=-115 rsrq=-18 rssnr=2147483647 cqiTableIndex=2147483647 cqi=2147483647 ta=2147483647 level=1 parametersUseForLevel=0 android.telephony.CellConfigLte :{ isEndcAvailable = false }}]
format_json.json
{
  "mCellIdentity": {
    "mCi": "******44",
    "mPci": "**6",
    "mTac": "***1",
    "mEarfcn": 276,
    "mBands": [1],
    "mBandwidth": 15000,
    "mMcc": 440,
    "mMnc": 10,
    "mAlphaLong": "docomo",
    "mAlphaShort": "docomo",
    "mAdditionalPlmns": {},
    "mCsgInfo": null
  },
  "mCellInfo": [
    {
      "mRegistered": "YES",
      "mTimeStamp": "1974541987150774ns",
      "mCellConnectionStatus": 0,
      "CellIdentityLte": {
        "mCi": "******44",
        "mPci": "**6",
        "mTac": "***1",
        "mEarfcn": 276,
        "mBands": [1],
        "mBandwidth": 15000,
        "mMcc": 440,
        "mMnc": 10,
        "mAlphaLong": "docomo",
        "mAlphaShort": "docomo",
        "mAdditionalPlmns": {},
        "mCsgInfo": null
      },
      "CellSignalStrengthLte": {
        "rssi": -69,
        "rsrp": -95,
        "rsrq": -6,
        "rssnr": 2147483647,
        "cqiTableIndex": 2147483647,
        "cqi": 2147483647,
        "ta": 1,
        "level": 3,
        "parametersUseForLevel": 0
      },
      "android.telephony.CellConfigLte": {
        "isEndcAvailable": false
      }
    },
    {
      "mRegistered": "NO",
      "mTimeStamp": "1974541987150774ns",
      "mCellConnectionStatus": 0,
      "CellIdentityLte": {
        "mCi": "********47",
        "mPci": "**6",
        "mTac": "********47",
        "mEarfcn": 42491,
        "mBands": [42],
        "mBandwidth": 2147483647,
        "mMcc": null,
        "mMnc": null,
        "mAlphaLong": "",
        "mAlphaShort": "",
        "mAdditionalPlmns": {},
        "mCsgInfo": null
      },
      "CellSignalStrengthLte": {
        "rssi": -85,
        "rsrp": -103,
        "rsrq": -9,
        "rssnr": 2147483647,
        "cqiTableIndex": 2147483647,
        "cqi": 2147483647,
        "ta": 2147483647,
        "level": 2,
        "parametersUseForLevel": 0
      },
      "android.telephony.CellConfigLte": {
        "isEndcAvailable": false
      }
    },
    {
      "mRegistered": "NO",
      "mTimeStamp": "1974541987150774ns",
      "mCellConnectionStatus": 0,
      "CellIdentityLte": {
        "mCi": "********47",
        "mPci": "**1",
        "mTac": "********47",
        "mEarfcn": 42491,
        "mBands": [42],
        "mBandwidth": 2147483647,
        "mMcc": null,
        "mMnc": null,
        "mAlphaLong": "",
        "mAlphaShort": "",
        "mAdditionalPlmns": {},
        "mCsgInfo": null
      },
      "CellSignalStrengthLte": {
        "rssi": -85,
        "rsrp": -109,
        "rsrq": -15,
        "rssnr": 2147483647,
        "cqiTableIndex": 2147483647,
        "cqi": 2147483647,
        "ta": 2147483647,
        "level": 1,
        "parametersUseForLevel": 0
      },
      "android.telephony.CellConfigLte": {
        "isEndcAvailable": false
      }
    }
  ]
}

Location情報(.txt)

AndroidのLocationのdumpsysをそのまま出力しています。
端末の位置情報(緯度経度)が出力されるので、オフラインで解析するときなどに役に立つかもしれません。

Location情報 出力結果
      last location=Location[gps 35.******,139.****** hAcc=15.286787 et=+22d22h4m3s206ms alt=118.6220703125 vAcc=9.578282 vel=0.0 sAcc=0.44611505 {Bundle[{satellites=15, maxCn0=38, meanCn0=24}]}]
      last location=Location[network 35.******,139.****** hAcc=13.0 et=+22d22h4m2s894ms alt=57.099998474121094 vAcc=7.719068 {Bundle[{verticalAccuracy=7.719068}]}]

環境の準備

OSと端末

  • macOS Sequioia 15.3.1
  • Xperia 1 IV (Android 14)

OSはmacOSで試しましたが、他のOSでも順次試してみる予定です。

Node.js

今回はmacOSだったので、homebrewからインストール。
手順などは以下のように色々なサイトにあると思いますので割愛します。
https://qiita.com/sakana_hug/items/7440df68734f3d5ce772

Windowsだと以下のような手順になるかと思います。
https://qiita.com/charopevez/items/44c611daa210293421db

必要なnpmモジュールのインストール

以下のモジュールをインストールします。

npm install @types/node fs date-fns playwright-core
  • @types/node
    • fsモジュールを使うときにエラーになるため、定義モジュールをインストールしておく
  • fs
    • JSONファイルをPC側に書き込むためのモジュール
  • date-fns
    • 日付・時刻のフォーマットのためのモジュール
  • playwright-core
    • Android端末のChrome操作自動化に必要なPlaywrightのコアモジュール

インストール結果はこんな感じになりました。

% npm list
hoge@ /Users/hoge
├── @types/node@22.13.10
├── date-fns@4.1.0
├── fs@0.0.1-security
└── playwright-core@1.51.0

Playwright

MicrosoftがリリースしているE2Eテストやブラウザ自動化が可能なツール。
Getting Started > Installation
に従ってインストール。
https://playwright.dev/docs/intro

adb

Android SDK platform toolsからダウンロードして展開。
https://developer.android.com/tools/releases/platform-tools?hl=ja

実行パスを通すなどはお好みで良いかと思いますが、私は通しておきました。

$ export PATH=$PATH:~/platform-tools/

Android Studioだと、パスなどは気にせずadbもインストールされるかもしれません。

VSCode

エディタならなんでも良いですが、TerminalでのNode.jsの実行も楽だったので。
https://code.visualstudio.com

端末

USBでPCと接続の上、adbで制御できるように設定します。
ちょうど同じ内容に触れられている記事があったのでそちらを参照ください。
https://zenn.dev/link/comments/2a708961882fe3

設定手順のスクリーンショットは参考までに貼っておきます。
「接続中は画面をオフにしない」のトグルもONにしておいた方が良いと思います。画面OFFの場合、今回試した制御は待ち状態になってしまって完了しません。
最後にPCに接続した際に、端末側で「この端末からのUSBデバッグを許可しますか」のダイアログが出ますので、忘れずに許可してください。

(参考) 設定手順のスクリーンショット


Developer Options


USB DebuggingのON


常時画面ON


ChromeのCommand Line制御有効化

VSCodeでの実行

サンプルコードをVSCodeに貼り付けて保存の上で、VSCodeのターミナルからnodeコマンドを実行すると、mjsファイルと同じディレクトリに 結果が出力 されます。

% node androidNavigationTiming.mjs


VSCodeでの実行

自動化

端末と接続した状態で上記のスクリプトを定期的に実行させれば、自動実行可能です。
macOSならcronかAutomator、Windowsならタスクスケジューラを使えば実行可能かと思います。

https://fuyutsuki.net/mac-cron-automater/

https://zenn.dev/tonokokko/articles/3b65c0c4a5408b

おわりに

最初はPythonで作ろうと思ったのですが、Android対応はまだNode.js限定のようですね。
まだToDoとして

  • 複数端末が接続されている場合にどうするのか
  • Chromeのタブが上手くクローズされない (どんどん増えていく)

あたりの課題はあるので、ぼちぼち考えてみようかなと思います。

Discussion