1. 「よく使う順」はAPI1発では作れない
Androidでランチャーやアプリドロワーを自作していると、「最近使った順」「よく使う順」で
アプリを並べたくなる場面がある。UsageStatsManager を見つけて「これで完了」と思いがちだが、
実装すると次のことに気づく。
UsageStatsManagerは「順位」を返してくれるAPIではない。
生データから、アプリ一覧用の ranking policy を自分で設計する必要がある。
権限、集計期間、ランチャー起動可能アプリとの突合、統計が無いアプリの扱い、同率の処理、
UIのフォールバック — これらすべてを自分で決めないと、実用的な一覧にはならない。
本記事は、あるAndroidランチャー系アプリの実装で実際に扱った内容を一般化したものであり、
特定アプリの実装紹介ではなく UsageStatsManager を使う際に共通して踏むべき設計ポイントの
整理として書く。
2. Usage Access permission
UsageStatsManager から利用統計を取得するには android.permission.PACKAGE_USAGE_STATS が
必要だが、これは通常のアプリ権限とは扱いが異なる。
- Manifestに
<uses-permission>として宣言するだけでは有効にならない - ユーザーが端末の「使用状況へのアクセス」設定画面で、アプリごとに手動で許可する必要がある
- 通常の実行時パーミッションダイアログ(
requestPermissions)では取得できない
そのため実装は、まず現在許可されているかどうかを AppOpsManager 経由で
OPSTR_GET_USAGE_STATS の状態を見て判定する必要がある(Android Qで unsafeCheckOpNoThrow、
それ以前は checkOpNoThrow を使うAPIの違いはあるが、判定内容は同じ)。
許可されていない場合、アプリ側でできることは Settings.ACTION_USAGE_ACCESS_SETTINGS を
Intentで起動し、設定画面へ誘導することだけである。adbでの付与や隠しAPI経由の代替手段は、
一般ユーザー向け配布アプリとしては現実的でないため、ここでは扱わない。
3. ランチャーアプリ一覧とUsageStatsは別物
これが最初にハマりやすいポイントである。UsageStatsManager が返す packageName の集合と、
ユーザーがホーム画面から起動できる「起動可能アプリ」の一覧は、同じものではない。
ランチャーから直接起動できないpackageが、UsageStats側の結果に含まれることがある。
したがって設計は次の2段構えになる。
-
PackageManagerに対してACTION_MAIN+CATEGORY_LAUNCHERで解決できる Activity を
問い合わせ、「起動可能アプリ一覧」を作る(この一覧が正) -
UsageStatsManagerから取れた統計をpackageNameをキーとして (1) にマージする
Android 11 (API 30) 以降はパッケージ可視性の制限があるため、全パッケージを見る
QUERY_ALL_PACKAGES は避け、Manifestの <queries> に ACTION_MAIN + CATEGORY_LAUNCHER
の <intent> を宣言して解決できる範囲だけを問い合わせる、という設計が今回のような
launcher-style queryには適している(他の用途で唯一の正解というわけではない)。
UsageStatsにだけ存在してランチャー一覧に無いpackageは「使わない」(一覧に足さない)。
逆にランチャー一覧にあってUsageStatsに無いpackageは、統計0件として扱う。
4. 集計期間 (aggregation window) を決める
queryUsageStats / queryEvents はどちらも startTime / endTime を渡す必要があり、
「直近どれだけの期間を見るか」はAPI側が決めてくれるものではなく、呼び出し側が決めるポリシーである。
窓を短くしすぎると、偶然その日だけ多く開いたアプリに順位が引っ張られる。長くしすぎると、
最近始めた新しい習慣が反映されにくくなる。「正しい期間」はAndroidの標準として決まっているわけではなく、
アプリごとのポリシーとして選ぶ必要がある。
ここで注意したいのは、queryUsageStats と queryEvents を「同じ意味で30日分取れるAPI」として
一括りにしないことである。両者は保持期間の扱いが異なる。
queryUsageStats(interval, begin, end) は、指定した range について interval 単位
(日次・週次など)に集約済みの UsageStats を返すAPIである。指定した begin/end は
内部でその interval の境界(nearest whole interval period)へ拡張されることはあるが、
返却されるデータは基本的に指定 range をカバーするよう設計されている。
一方 queryEvents(begin, end) が返すのは、フォアグラウンド遷移などの生イベント列である。
Android公式ドキュメントには、queryEvents() が返すイベントはシステムに「数日間 (a few
days)」しか保持されないと明記されている。つまり begin に30日前を指定しても、30日分の
raw event history がそのまま返ってくることは保証されない。集計済みバケットとイベント単位の
生データとでは、システム側の保持期間そのものが異なる、という公式仕様として扱う必要がある。
長期間の正確なイベントベース回数が必要なら、アプリ側で定期的にクエリして自前で履歴を
蓄積するなど、queryEvents の一回のクエリに頼らない設計が必要になる。
用途によって窓を分けるのも有効な設計である。たとえば「最近使った順」は queryUsageStats の
集計値(比較的長い期間、例: 30日)を使い、直近の利用傾向を強く反映したい「おすすめ」的な
並びは queryEvents の生イベント(数日間しか保持されない制約を踏まえた短い期間、例: 7日)を
使う、といった具合である。片方の窓を変えても、もう片方のランキングには影響しないよう、
集計処理は独立させておくのが安全である。
5. ランキングに使う指標 (ranking dimensions)
UsageStatsManager 系のAPIから得られる代表的な値は次のとおりである。
-
UsageStats.lastTimeUsed: そのpackageが最後に使用された時刻 -
UsageEventsのイベント列: フォアグラウンド遷移などの時系列イベント
注意したいのは、「起動回数」に相当する値をAPIが直接返してくれるわけではないという点である。
queryUsageStats の結果には launch count に相当するフィールドは無く、「回数」を出したい場合は、
queryEvents から得られるフォアグラウンド遷移イベント(ACTIVITY_RESUMED など。古いAPI level
では MOVE_TO_FOREGROUND が対応する)を自分で数えて、擬似的な使用回数(フォアグラウンド遷移
カウント)として扱うことになる。これは実際の「アプリを起動した回数」と完全に一致するとは限らない
(同一アプリ内の画面遷移や、他アプリからの一瞬の割り込み復帰などが影響しうる)。加えて、
前節で触れたとおり queryEvents の raw events はシステム側に数日間しか保持されないため、
30日windowを指定しても30日分のexact countとして扱うことはできない。「これはOS標準の
launch countではなく、保持されている範囲のフォアグラウンド遷移から導出した近似値である」と
明示しておくことが重要である。長期間にわたる正確な使用回数が必要な場合は、アプリ側で
取得結果を継続的に保存・集計する別設計が必要になる。
より高度なランキング(「よく使う」に近い直感的な並び)を作りたい場合は、フォアグラウンド遷移の
生イベントから「利用セッション」(package内で前景Activityが存在し続けた区間)を組み立て、
セッション数と直近度を組み合わせたスコアを計算する、という設計も考えられる。この場合、
画面回転などによる瞬間的な前景断絶を1セッションとして扱う近接セッションの統合や、
一瞬だけ前景化して閉じたセッションを「利用」とみなさない最小継続時間フィルタといった、
生イベントをそのまま数えるだけでは出てこないノイズ処理が必要になる。
6. 統計が無いアプリをどう扱うか
ランチャー一覧にはあるが、UsageStatsに対応するpackageのエントリが無いアプリ(インストール直後、
一度も使っていない、集計期間から漏れている、など)は必ず出てくる。これを実装では「統計0件」として
明示的にデフォルト値(launch count = 0、lastUsed = 0 など)を持たせ、降順ソートの最後尾に
自然に落ちるようにする。除外したりエラー扱いにしたりはしない。統計が無いことは「使われていない」
ことの確からしいシグナルではあるが、「表示しない理由」にはならない。
7. 0件をどう解釈するか(権限とデータの区別)
「UsageStatsのクエリ結果が0件」には、性質の異なる2つの原因がありうる。
- Usage Access権限がそもそも許可されていない(クエリ自体が空、または信頼できない結果を返す)
- 権限はあるが、実際にそのアプリを使っていない、または集計期間内に使用イベントが無い
この2つを区別せずに扱うと、「権限が無いだけなのに、まるで誰も使っていないかのような順位」で
一覧が表示されてしまう。実装としては、まず権限の有無を明確にチェックし、権限が無い場合は
利用統計に依存するソートモード自体を無効化する(後述のUIフォールバックを参照)のが妥当である。
権限が無い状態で「0件」を「未使用」として扱ってはならない。
8. Tie-break(同率時の決定的な順序)
ランキング値(直近使用時刻、回数、スコアなど)が同じアプリが複数存在するケースは珍しくない。
特に、統計が無いアプリ同士は容易に「全員0」で並ぶ。二次的な比較基準(tie-break)を
用意しておかないと、ソートのたびに順序が揺れたり、OS内部の実装依存の順序に左右されたりする。
設計としては、主指標(直近時刻・回数・スコアなど)→副次指標(より新しい方を優先、等)→
アプリ名→packageName(必要ならクラス名)という段階的な比較チェーンを組み、
同じ入力に対して常に同じ順序が出ることを保証する。入力の並び順をシャッフルしても
逆順にしても結果が変わらないことをテストしておくと安心できる。
9. Refresh timing(いつクエリし直すか)
UsageStatsManager はイベントを常時監視してプッシュ通知してくれるAPIではなく、
呼び出した時点までの利用状況をスナップショットとして返すクエリAPIである。したがって
「いつ再クエリするか」も設計対象になる。
典型的なタイミングは、画面の初期表示時、アプリが再びフォアグラウンドに戻ってきた時
(他アプリを使った後の復帰では、その間の利用がまだ反映されていない可能性がある)、
権限設定画面から戻ってきた時(権限状態が変わった可能性がある)の3つである。
一方で、復帰のたびに毎回律儀にクエリし直すと、短時間に何度も同じような結果を取りに行くことになる。
直近のクエリから一定時間内であればスキップする、実行中のクエリがあれば重複実行しない、
といった簡単なガードを入れておくと、無駄なクエリと画面のちらつきの両方を抑えられる。
10. UI fallback
APIの都合(権限なし・統計なし・クエリ失敗)をそのままUIに露出させて、空白やクラッシュに
してしまうと、体験として破綻する。最低限、次のフォールバックを用意しておきたい。
-
権限が無い場合: 利用統計に依存するソートモードを選択不可にし、名前順など統計不要な
並びにフォールバックする。同時に、権限設定画面へのショートカットをメニュー等に用意する。 -
統計の反映を待っている間: 名前順の暫定表示 → 統計反映後の並び、という二段階の
切り替わりがそのまま見えると使用感が悪化しやすい。反映を少しだけ待ってから描画する、
ただし待ちすぎないよう上限を設けてタイムアウトさせる、といった bounded wait が有効である。 -
クエリ失敗・結果0件の場合: 空白のままにしたり、そのまま例外で落ちたりしない。
取得できなかった場合でも、統計不要な並び等で一覧そのものは表示し続ける。
11. 最小限のKotlin擬似コード
実クラス名やアプリ固有の実装を出さず、考え方だけを示す。
data class UsageRank(
val packageName: String,
val lastUsed: Long?,
val foregroundEventCount: Int?,
)
// launcher一覧へ、packageName単位でusage統計をマージする。統計が無ければ初期値のまま残す。
fun mergeLauncherAppsWithUsage(
launcherApps: List<LauncherAppInfo>,
usage: Map<String, UsageRank>,
): List<LauncherAppInfo> = launcherApps.map { app ->
val rank = usage[app.packageName]
app.copy(
lastUsed = rank?.lastUsed ?: 0L,
usageCount = rank?.foregroundEventCount ?: 0,
)
}
// 主指標のあと、必ずname/packageNameでtie-breakし決定的な順序にする。
fun rankApps(apps: List<LauncherAppInfo>): List<LauncherAppInfo> = apps.sortedWith(
compareByDescending<LauncherAppInfo> { it.lastUsed }
.thenByDescending { it.usageCount }
.thenBy { it.label.lowercase() }
.thenBy { it.packageName },
)
12. 全体の流れ(図)
Launcher apps --------┐
(PackageManager, │
ACTION_MAIN + ├─> join by packageName
CATEGORY_LAUNCHER) │
│
UsageStats/UsageEvents -┘
(用途別のquery範囲。
raw eventsはsystem側で数日程度しか保持されない)
|
v
ranking policy
(窓・指標・欠損値の扱いを決定)
|
v
deterministic sort
(tie-break込みで常に同じ順序)
|
v
UI
(権限なし/未反映/0件のフォールバック込み)
13. checklist
- Usage Access権限の有無をどう判定するか
- 権限が無い場合、UIはどのソートモードを無効化し、どこへ誘導するか
- 集計期間(窓)は何日か。用途ごとに窓を分けるか
-
指定したwindow(beginTime〜endTime)を、
queryEventsが実際にシステム側で
保持している(数日程度の)event historyと混同していないか - 「回数」はOS標準のlaunch countか、自前で導出した近似値か
- ランチャー起動可能アプリ一覧とUsageStatsをどうjoinするか
- 統計が無いアプリのデフォルト値は何か
- 0件(権限なし)と0件(未使用)を区別しているか
- 同率時のtie-break順序は決定的か(入力順に依存しないか)
- 再クエリのタイミングと、重複クエリの抑制はどうなっているか
14. まとめ
UsageStatsManager はアプリの利用状況という「生データ」を提供するAPIであり、
「よく使う順」「最近使った順」という「順位」まで作ってくれるわけではない。
- 権限は実行時ダイアログではなく設定画面への誘導が必要
- ランチャー起動可能アプリとUsageStatsのpackage集合は別物であり、joinが要る
-
queryEventsの生イベントはシステム側に数日間しか保持されず、queryUsageStatsの
集計バケットとは保持期間の扱いが異なる - 集計期間・ランキング指標・欠損値の扱いは、いずれもアプリ側のポリシーとして自分で決める
- 0件は「権限なし」と「未使用」を区別する
- 同率処理は決定的な順序になるよう設計し、UIは権限なし・未反映・0件のいずれでも破綻させない
この一連の設計判断を済ませて初めて、「よく使うアプリ順」は実用的な機能になる。