Tuesday, June 13, 2023

Androidデータ使用量の詳細分析:NetworkStatsManagerとKotlin

現代のモバイルアプリケーションにおいて、データ通信は不可欠な要素です。しかし、ユーザーにとってはデータ通信量が常に気になる問題でもあります。データ管理アプリ、ペアレンタルコントロール、あるいは企業向けのデバイス管理ソリューションなど、特定のアプリケーションがどれだけのデータを消費しているかを正確に把握することは、多くの開発者にとって重要な課題となります。この記事では、Androidプラットフォームが提供する強力なAPIであるNetworkStatsManagerをKotlinで活用し、アプリケーションごとのデータ使用量を精密に測定するための包括的な手法を解説します。単純なコードの断片を示すだけでなく、その背景にある概念、必要な権限の取り扱い、そして堅牢な実装のためのベストプラクティスまでを深く掘り下げていきます。

NetworkStatsManagerとは何か?:その役割と優位性

Androidアプリケーションのデータ使用量を測定するためのAPIとして、古くからTrafficStatsクラスが存在しました。しかし、TrafficStatsはデバイスが起動してからの累積データ使用量しか提供できず、特定の期間やネットワーク種別(Wi-Fi、モバイルデータなど)で絞り込んだ詳細な情報を得ることは困難でした。

この課題を解決するために、Android 6.0 Marshmallow (APIレベル 23) で導入されたのがNetworkStatsManagerです。これは、はるかに高機能で詳細なネットワーク統計情報へのアクセスを提供するシステムサービスです。NetworkStatsManagerの主な特徴と優位性は以下の通りです。

  • 期間指定のクエリ: 開始時刻と終了時刻を指定して、その期間内に発生したデータ使用量を取得できます。これにより、日次、週次、月次といった単位での使用量レポート作成が可能です。
  • ネットワーク種別でのフィルタリング: Wi-Fi経由での使用量と、モバイルデータ通信経由での使用量を個別に、あるいは合計して取得できます。
  • アプリケーション単位(UID)での集計: 特定のアプリケーション(正確にはそのUID)が消費したデータをピンポイントで測定できます。これは、本記事の主題でもあります。
  • システムレベルでのデータ収集: データはAndroid OSがカーネルレベルで収集・集計しているため、比較的正確で信頼性が高い情報源となります。

NetworkStatsManagerを使いこなすことで、開発者はユーザーに対して「どのアプリが、いつ、どのネットワークで、どれだけデータを使ったか」という非常に価値のある情報を提供できるようになります。

最初の関門:特別な権限 `PACKAGE_USAGE_STATS`

アプリケーションのデータ使用量は、ユーザーのプライバシーに関わる機密情報です。そのため、NetworkStatsManagerを利用するには、通常の権限よりも厳格な、特別な権限が必要となります。それがandroid.permission.PACKAGE_USAGE_STATSです。

この権限は、通常のランタイム権限リクエスト(requestPermissions()を呼び出すダイアログ)では許可できません。ユーザーが自らスマートフォンの設定画面に移動し、手動でアプリに対して「使用状況へのアクセス」を許可する必要があります。開発者は、ユーザーを適切な設定画面へスムーズに誘導する責任があります。

1. AndroidManifest.xmlへの権限追加

まず、アプリケーションのマニフェストファイルに権限を宣言します。この権限は保護レベルが`signature|appop`であるため、Android Studioは警告を表示するかもしれません。`tools:ignore="ProtectedPermissions"`を追加することで、この警告を抑制するのが一般的です。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.datastats">

    <uses-permission
        android:name="android.permission.PACKAGE_USAGE_STATS"
        tools:ignore="ProtectedPermissions" />

    <application
        ...>
        ...
    </application>
</manifest>

2. ランタイムでの権限確認とユーザー誘導

次に、実際にAPIを使用する前に、アプリが権限を保持しているかを確認するロジックを実装します。保持していない場合は、ユーザーに権限の必要性を説明し、設定画面へ誘導します。

以下に、権限の確認と誘導を行うためのヘルパー関数を示します。これは`Activity`や`Fragment`内から呼び出すことを想定しています。

import android.app.AppOpsManager
import android.content.Context
import android.content.Intent
import android.os.Process
import android.provider.Settings

/**
 * PACKAGE_USAGE_STATS権限が許可されているかを確認する
 * @param context Context
 * @return 権限が許可されていればtrue, そうでなければfalse
 */
private fun hasUsageStatsPermission(context: Context): Boolean {
    // AppOpsManagerは、ユーザーごとの権限設定を管理する
    val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager
    if (appOpsManager == null) {
        return false
    }

    // checkOpNoThrowは、指定された操作(この場合はアプリ使用状況の取得)が許可されているかをチェックする
    // 戻り値がMODE_ALLOWEDであれば許可されている
    val mode = appOpsManager.checkOpNoThrow(
        AppOpsManager.OPSTR_GET_USAGE_STATS,
        Process.myUid(), // 自アプリのUID
        context.packageName
    )
    return mode == AppOpsManager.MODE_ALLOWED
}

/**
 * ユーザーを使用状況アクセス許可の設定画面に誘導する
 * @param context Context
 */
private fun requestUsageStatsPermission(context: Context) {
    // Settings.ACTION_USAGE_ACCESS_SETTINGS は、使用状況アクセス許可の設定画面を開くためのIntentアクション
    val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
    context.startActivity(intent)
}

// --- 使用例 ---
// ActivityやFragmentの適切な場所(例: onResumeやボタンクリック時)で呼び出す
fun checkAndRequestPermission(context: Context) {
    if (!hasUsageStatsPermission(context)) {
        // ここでユーザーにダイアログなどを表示し、なぜ権限が必要かを丁寧に説明することが推奨される
        // 説明後、設定画面へ誘導
        requestUsageStatsPermission(context)
    } else {
        // 権限があるので、データ使用量を取得する処理を実行できる
        // fetchDataUsage()
    }
}

このプロセスは、ユーザー体験(UX)において非常に重要です。なぜこの権限が必要なのかを事前に明確に説明しないと、ユーザーは不信感を抱き、権限を許可してくれない可能性が高まります。

データ使用量測定の実装ステップ

権限の準備が整ったら、いよいよNetworkStatsManagerを使った実装に進みます。プロセスは大きく分けて「準備」「クエリ実行」「結果処理」の3段階です。

ステップ1: 必要なインスタンスの取得

まず、NetworkStatsManagerと、パッケージ名からUIDを取得するためにPackageManagerのインスタンスを取得します。

import android.app.usage.NetworkStatsManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager

// ContextはActivityやApplicationContextから取得
val nsm = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager
val pm = context.packageManager

ステップ2: ターゲットアプリのUIDを特定する

NetworkStatsManagerは、アプリケーションをパッケージ名ではなく、UID (User ID) で識別します。UIDは、Androidシステムが各アプリケーションをインストールする際に割り当てる、一意の整数値です。LinuxのユーザーIDの概念に基づいています。

パッケージ名からUIDを取得するには、PackageManagergetApplicationInfoメソッドを使用するのが最も直接的で効率的です。

/**
 * パッケージ名からUIDを取得する
 * @param packageName 調査対象のアプリのパッケージ名 (例: "com.google.android.chrome")
 * @return UID。見つからない場合は-1を返す。
 */
private fun getUidForPackage(context: Context, packageName: String): Int {
    return try {
        val applicationInfo = context.packageManager.getApplicationInfo(packageName, 0)
        applicationInfo.uid
    } catch (e: PackageManager.NameNotFoundException) {
        // パッケージが見つからない場合は例外が発生する
        e.printStackTrace()
        -1 // エラーを示す値を返す
    }
}

この関数は、指定されたパッケージ名を持つアプリがデバイスにインストールされていない場合にNameNotFoundExceptionをスローする可能性があるため、必ずtry-catchブロックで囲む必要があります。

ステップ3: クエリパラメータを準備する

NetworkStatsManagerのクエリメソッドを呼び出すには、いくつかのパラメータを定義する必要があります。

  • `networkType`: どのネットワークのデータを取得するかを指定します。主にConnectivityManager.TYPE_WIFIまたはConnectivityManager.TYPE_MOBILEを使用します。
  • `subscriberId`: モバイルデータ通信の場合、SIMカードの識別子(IMSI)を指定します。通常、特定のSIMカードに限定する必要がない限り、`null`を渡します。`null`を渡すと、デバイス上の全てのモバイルデータ通信が集計対象となります。IMSIを取得するには別途READ_PHONE_STATE権限が必要であり、プライバシーの観点から扱いは慎重になるべきです。
  • `startTime`, `endTime`: データの集計期間をミリ秒単位のエポックタイムスタンプ(`Long`型)で指定します。
  • `uid`: ステップ2で取得した、調査対象アプリのUID。

例えば、過去24時間分のデータを取得するための期間を設定するには、以下のようにします。

import java.util.Calendar

// 終了時刻は現在時刻
val endTime = System.currentTimeMillis()

// 開始時刻は24時間前
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -1)
val startTime = calendar.timeInMillis

ステップ4: `queryDetailsForUid`でデータをクエリする

準備が整ったら、`nsm.queryDetailsForUid()`を呼び出してネットワーク統計データを取得します。このメソッドはNetworkStatsオブジェクトを返します。このオブジェクトは、結果セットに対するイテレータのように機能します。

val targetUid = getUidForPackage(context, "com.android.chrome")
if (targetUid != -1) {
    // Wi-Fiデータのクエリ
    val networkStatsWifi = nsm.queryDetailsForUid(
        ConnectivityManager.TYPE_WIFI,
        null, // subscriberId (Wi-Fiでは不要)
        startTime,
        endTime,
        targetUid
    )
    
    // モバイルデータのクエリ
    val networkStatsMobile = nsm.queryDetailsForUid(
        ConnectivityManager.TYPE_MOBILE,
        null, // 全てのSIMカードを対象とする
        startTime,
        endTime,
        targetUid
    )

    // ... この後、結果を処理する ...
}

ステップ5: 結果の処理と集計

queryDetailsForUidから返されたNetworkStatsオブジェクトには、`Bucket` と呼ばれる単位でデータが含まれています。バケットは、特定の時間間隔における統計データのスナップショットです。このバケットを一つずつ取り出し、送受信したバイト数を合計していく必要があります。

ここで重要なのは、リソース管理です。NetworkStatsオブジェクトは、システムリソースを使用しているため、処理が終わったら必ずclose()メソッドを呼び出す必要があります。リソースリークを防ぐために、Kotlinの`use`拡張関数(Javaのtry-with-resourcesに相当)を使用するのが最も安全で簡潔な方法です。

以下に、`NetworkStats`オブジェクトから送受信バイト数を合計する関数の例を示します。

import android.app.usage.NetworkStats

private fun getTotalBytesFromStats(networkStats: NetworkStats): Long {
    var totalBytes = 0L
    val bucket = NetworkStats.Bucket() // バケットオブジェクトを再利用するため、ループの外で一度だけ作成
    while (networkStats.hasNextBucket()) {
        networkStats.getNextBucket(bucket)
        // 受信バイト数 (rx) と 送信バイト数 (tx) を合計に加算
        totalBytes += bucket.rxBytes + bucket.txBytes
    }
    return totalBytes
}

// --- 使用例 ---
var wifiUsageBytes = 0L
var mobileUsageBytes = 0L

// `use`ブロックを使うことで、ブロック終了時に自動的にnetworkStatsWifi.close()が呼ばれる
nsm.queryDetailsForUid(
    ConnectivityManager.TYPE_WIFI,
    null,
    startTime,
    endTime,
    targetUid
).use { networkStats ->
    wifiUsageBytes = getTotalBytesFromStats(networkStats)
}

nsm.queryDetailsForUid(
    ConnectivityManager.TYPE_MOBILE,
    null,
    startTime,
    endTime,
    targetUid
).use { networkStats ->
    mobileUsageBytes = getTotalBytesFromStats(networkStats)
}

val totalUsageBytes = wifiUsageBytes + mobileUsageBytes

このコードでは、`NetworkStats.Bucket`オブジェクトをループの外で一度だけ生成し、`getNextBucket(bucket)`を呼び出すことで、ループのたびに新しいオブジェクトを生成するオーバーヘッドを避けています。これは、`NetworkStatsManager`のAPIで推奨されている効率的なパターンです。

実践的なコード:全てを統合した関数

これまでのステップを統合し、より実践的で再利用可能な関数を作成してみましょう。この関数は、パッケージ名と期間を引数に取り、Wi-Fiとモバイルデータの合計使用量をバイト単位で返します。

import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build

/**
 * 指定されたパッケージのデータ使用量を、特定の期間について測定する。
 *
 * @param context Context
 * @param packageName データ使用量を測定したいアプリのパッケージ名
 * @param startTime 測定期間の開始時刻 (エポックミリ秒)
 * @param endTime 測定期間の終了時刻 (エポックミリ秒)
 * @return データ使用量の情報を含むDataUsageクラスのインスタンス。権限がない、またはエラーが発生した場合はnullを返す。
 */
fun getPackageDataUsage(
    context: Context,
    packageName: String,
    startTime: Long,
    endTime: Long
): DataUsage? {
    // APIレベル 23 (Marshmallow) 未満では NetworkStatsManager は利用できない
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
        return null
    }

    // 使用状況アクセス権限のチェック
    if (!hasUsageStatsPermission(context)) {
        // 権限がない場合はnullを返す。呼び出し元でユーザーへの権限要求を処理する必要がある。
        Log.e("DataUsage", "PACKAGE_USAGE_STATS permission is not granted.")
        return null
    }

    val nsm = context.getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager
    
    val uid: Int
    try {
        uid = context.packageManager.getApplicationInfo(packageName, 0).uid
    } catch (e: PackageManager.NameNotFoundException) {
        Log.e("DataUsage", "Package not found: $packageName", e)
        return null
    }

    try {
        val mobileBytes = queryNetworkUsageForUid(nsm, ConnectivityManager.TYPE_MOBILE, null, startTime, endTime, uid)
        val wifiBytes = queryNetworkUsageForUid(nsm, ConnectivityManager.TYPE_WIFI, null, startTime, endTime, uid)
        
        return DataUsage(
            packageName = packageName,
            wifiUsageBytes = wifiBytes,
            mobileUsageBytes = mobileBytes,
            totalUsageBytes = mobileBytes + wifiBytes
        )
    } catch (e: SecurityException) {
        // 稀に、権限があってもSecurityExceptionが発生する場合がある
        Log.e("DataUsage", "SecurityException while querying network stats", e)
        return null
    } catch (e: Exception) {
        Log.e("DataUsage", "An unexpected error occurred", e)
        return null
    }
}

/**
 * NetworkStatsManagerにクエリを実行し、バイト数を集計するヘルパー関数
 */
private fun queryNetworkUsageForUid(
    nsm: NetworkStatsManager,
    networkType: Int,
    subscriberId: String?,
    startTime: Long,
    endTime: Long,
    uid: Int
): Long {
    var totalBytes = 0L
    nsm.queryDetailsForUid(networkType, subscriberId, startTime, endTime, uid).use { networkStats ->
        val bucket = NetworkStats.Bucket()
        while (networkStats.hasNextBucket()) {
            networkStats.getNextBucket(bucket)
            totalBytes += bucket.rxBytes + bucket.txBytes
        }
    }
    return totalBytes
}

/**
 * データ使用量の結果を保持するデータクラス
 */
data class DataUsage(
    val packageName: String,
    val wifiUsageBytes: Long,
    val mobileUsageBytes: Long,
    val totalUsageBytes: Long
)

この`getPackageDataUsage`関数は、APIレベルのチェック、権限チェック、例外処理を含んでおり、より堅牢になっています。結果をデータクラス`DataUsage`で返すことで、呼び出し元での扱いも容易になります。

データの単位変換

関数はバイト単位で値を返しますが、ユーザーに表示する際には、より分かりやすい単位(KB, MB, GB)に変換する必要があります。以下にそのためのヘルパー関数を示します。

import java.text.DecimalFormat

fun formatBytes(bytes: Long): String {
    if (bytes < 1024) return "$bytes B"
    val kb = bytes / 1024.0
    if (kb < 1024) return "${DecimalFormat("#.##").format(kb)} KB"
    val mb = kb / 1024.0
    if (mb < 1024) return "${DecimalFormat("#.##").format(mb)} MB"
    val gb = mb / 1024.0
    return "${DecimalFormat("#.##").format(gb)} GB"
}

// --- 使用例 ---
val usageInfo = getPackageDataUsage(context, "com.example.app", startTime, endTime)
usageInfo?.let {
    val formattedTotal = formatBytes(it.totalUsageBytes)
    Log.d("DataUsage", "Total usage for ${it.packageName}: $formattedTotal")
}

高度なトピックと考慮事項

フォアグラウンド vs. バックグラウンド使用量の分離

NetworkStats.Bucketには、`getState()`というメソッドがあります。これは、そのデータ使用量がフォアグラウンドで発生したものか、バックグラウンドで発生したものかを示します。

  • `NetworkStats.Bucket.STATE_FOREGROUND`: アプリがフォアグラウンド状態(ユーザーがアクティブに使用中)の時に発生した通信。
  • `NetworkStats.Bucket.STATE_DEFAULT`: アプリがバックグラウンド状態の時に発生した通信。
  • `NetworkStats.Bucket.STATE_ALL`: フォアグラウンドとバックグラウンドの両方を含む、そのUIDの全ての通信。

これを利用して、ループ内で状態を判別し、フォアグラウンドとバックグラウンドの使用量を別々に集計することができます。

var foregroundBytes = 0L
var backgroundBytes = 0L

nsm.queryDetailsForUid(...).use { networkStats ->
    val bucket = NetworkStats.Bucket()
    while (networkStats.hasNextBucket()) {
        networkStats.getNextBucket(bucket)
        val bytes = bucket.rxBytes + bucket.txBytes
        when (bucket.state) {
            NetworkStats.Bucket.STATE_FOREGROUND -> foregroundBytes += bytes
            NetworkStats.Bucket.STATE_DEFAULT -> backgroundBytes += bytes
            // STATE_ALLは個別の集計には通常使わない
        }
    }
}

この機能により、「ユーザーが使っていない間に、アプリがどれだけデータを消費しているか」という、非常に重要なインサイトを得ることが可能になります。

デバイス全体のデータ使用量

特定のアプリではなく、デバイス全体のデータ使用量を取得したい場合は、`querySummaryForDevice`メソッドを使用します。このメソッドはUIDを引数に取らず、デバイス全体の集計値を返します。

val summary = nsm.querySummaryForDevice(
    ConnectivityManager.TYPE_MOBILE,
    null,
    startTime,
    endTime
)
val totalDeviceMobileBytes = summary.rxBytes + summary.txBytes

APIの制約と注意点

  • データの正確性: `NetworkStatsManager`が提供するデータは非常に有用ですが、通信事業者が請求するデータ量と完全に一致するとは限りません。キャリア側の計測方法や、特定の種類の通信(VoLTEなど)の扱いの違いにより、差異が生じることがあります。
  • VPNとテザリング: VPNを使用している場合や、テザリング(インターネット共有)を行っている場合のデータは、個別のアプリの使用量として正確に計上されないことがあります。これらのトラフィックは、システムレベルや特別なUIDに割り当てられることが多いです。
  • データ集計の遅延: システムがネットワーク統計を記録・永続化するまでには、若干のタイムラグが存在する可能性があります。そのため、完全にリアルタイムのデータトラフィックを監視する目的には適していません。

結論

NetworkStatsManagerは、Androidアプリのデータ使用量を詳細に分析するための、強力かつ柔軟なフレームワークです。この記事では、その基本的な使い方から、必須となるPACKAGE_USAGE_STATS権限の適切な取り扱い、堅牢な実装のためのエラーハンドリング、そしてフォアグラウンド・バックグラウンド使用量の分離といった高度なトピックまでを網羅しました。提供したコード例と解説を参考にすることで、開発者はユーザーに対して透明性の高いデータ使用情報を提供し、アプリケーションの価値をさらに高めることができるでしょう。データ使用量の可視化は、ユーザーが自身のデバイスをより良く理解し、管理するための手助けとなる重要な機能です。


0 개의 댓글:

Post a Comment