Play Console の ANR の欄に、同じような赤い行が並ぶ日があります。手元の端末では一度も再現しません。ところが低スペックの端末の画面ばかりに、応答なしが集まっています。
再現できないものは直せないと考えて、しばらく寝かせてしまった時期がありました。再現できないときに頼れるのは、端末の中身ではなく、トレースに残った「止まっていた行」です。
壁紙アプリの運用で、メインスレッドを止めていた原因を二つ見つけました。どちらも機能の本体ではなく、画面を閉じるときの後片付けと、ユーザーの目に見えない背景の準備でした。その見つけ方をスクリプトにしたものと、直した考え方をお伝えします。
ANR のトレースで、まず見るのは一行だけ
ANR は、メインスレッドが一定時間、入力などの処理を返せなかったときに記録されます。公式の説明では、入力イベントに5秒以内に応答できない場合や、ブロードキャストレシーバーが前面で10秒以内に終わらない場合が、その目安です。つまり、数秒の同期処理が一度あれば足ります。トレースには全スレッドの状態が並びますが、最初に見るのは "main" と書かれた塊の、いちばん上の数行です。
そこに自分のアプリの関数名が出ていれば、そこが止めていた場所です。出ていなければ、フレームワークの中で待っていて、その呼び出し元が自分のコードにあります。
1件ずつ読むと、同じ原因の ANR を何度も読むことになります。そこで、"main" の塊だけを取り出して、止めていた行を分類し、件数順に並べるスクリプトを作りました。
| 分類 | 当たる行の例 | よくある出どころ |
|---|---|---|
| 課金クライアントの終了/接続 | BillingClientImpl.endConnection | 画面の破棄時に呼んでいる |
| 画像の同期デコード | BitmapFactory.decodeStream | ページ生成のたびに背景を読んでいる |
| SharedPreferences の同期書き込み | QueuedWork.waitToFinish | 画面を離れるときの保存待ち |
| ファイル/DB の同期アクセス | SQLiteDatabase. | 起動直後の読み込み |
| ネットワーク(メイン上) | HttpURLConnection | 非同期にし忘れた通信 |
| ロック待ち | waiting to lock | 別スレッドとの取り合い |
分類のルールは、上の表のとおり正規表現で持たせました。自分のアプリで新しい原因が見つかったら、1行足すだけで増やせます。
分類スクリプト
anr_blockers.py という名前で置きます。スクリプトは、手元の Python 3 で実際に走らせて確かめています。 トレースの置き場所とパッケージ名だけ、ご自身のものに変えてください。
"""ANR のスタックトレース(テキスト)から、メインスレッドを止めた行を拾って分類する。
使い方: python3 anr_blockers.py --pkg com.example.app traces/*.txt
想定する形式: Android の ANR トレース(`"main" prio=5 tid=1 ...` で始まるスレッドの塊)。
Play Console の ANR 詳細からコピーしたスタックでも、先頭が `"main"` なら読めます。
"""
import argparse, re, sys
from collections import Counter
# 分類ルール: (ラベル, フレームに含まれる文字列の正規表現)。上から順に最初に当たったものを採用する。
# 自分のアプリで見つけた原因が増えたら、ここへ1行足していく。
RULES = [
("課金クライアントの終了/接続", re.compile(r"BillingClient(Impl)?\.(endConnection|startConnection)")),
("画像の同期デコード", re.compile(r"BitmapFactory\.decode|ImageDecoder\.decode|BitmapDrawable|Drawable\.createFromPath")),
("SharedPreferences の同期書き込み", re.compile(r"SharedPreferencesImpl.*(commit|awaitLoadedLocked|waitToFinish)|QueuedWork\.waitToFinish")),
("ファイル/DB の同期アクセス", re.compile(r"java\.io\.(File|FileInputStream|FileOutputStream)\.|SQLiteDatabase\.|SQLiteConnection\.")),
("ネットワーク(メイン上)", re.compile(r"java\.net\.|okhttp3\.internal\.connection|HttpURLConnection")),
("ロック待ち", re.compile(r"waiting to lock|Object\.wait|LockSupport\.park")),
]
THREAD_HEAD = re.compile(r'^"(?P<name>[^"]+)"\s+prio=\d+')
FRAME = re.compile(r"^\s+(at\s+)?(?P<fq>[\w.$<>]+)\((?P<src>[^)]*)\)")
def main_blocks(text):
"""トレース全体から "main" スレッドの塊だけを取り出す。"""
out, cur = [], None
for line in text.splitlines():
m = THREAD_HEAD.match(line)
if m:
if cur is not None:
out.append(cur)
cur = [] if m.group("name") == "main" else None
continue
if cur is not None:
cur.append(line)
if cur is not None:
out.append(cur)
return out
def frames_of(block):
out = []
for line in block:
f = FRAME.match(line)
if f:
out.append(f.group("fq") + "(" + f.group("src") + ")")
elif "waiting to lock" in line or "waiting on" in line:
out.append(line.strip())
return out
def classify(frames):
for fr in frames: # 上(新しい呼び出し)から見て、最初に当たったルールを採る
for label, rx in RULES:
if rx.search(fr):
return label, fr
return "未分類", frames[0] if frames else "(フレームなし)"
def first_own_frame(frames, pkg):
for fr in frames:
if fr.startswith(pkg):
return fr
return "(自分のパッケージのフレームなし)"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pkg", required=True, help="自分のアプリのパッケージ名の接頭辞")
ap.add_argument("files", nargs="+")
a = ap.parse_args()
by_label, by_site, unknown = Counter(), Counter(), []
for path in a.files:
text = open(path, encoding="utf-8", errors="replace").read()
blocks = main_blocks(text)
if not blocks: # 読めないファイルは黙って捨てず、必ず報告する
unknown.append(path)
continue
for b in blocks:
fr = frames_of(b)
label, hit = classify(fr)
by_label[label] += 1
by_site[(label, first_own_frame(fr, a.pkg))] += 1
total = sum(by_label.values())
print(f"読んだトレース: {len(a.files)} 件 / main の塊: {total} 件 / 読めなかった: {len(unknown)} 件")
for label, n in by_label.most_common():
print(f"\n[{label}] {n} 件")
for (lb, site), c in by_site.most_common():
if lb == label:
print(f" {c:>3} 件 最初の自分のフレーム: {site}")
for p in unknown:
print(f"\n! 読めませんでした: {p} (先頭が \"main\" prio=… で始まっているか確認)")
sys.exit(0 if not unknown else 2)
if __name__ == "__main__":
main()何を解決するかというと、「同じ原因の ANR を、何度も目で読む時間」を、一回の実行に置き換えます。
なぜこう書いたかを、三つだけ書き残します。
"main"の塊だけを見ます。他のスレッドの状態は、原因の手がかりになることもありますが、最初の切り分けでは情報が多すぎると私は考えています。- ルールは上から順に、最初に当たったものを採ります。フレームは新しい呼び出しが上に来るので、いちばん上に近い原因が、実際に止めていた場所だと判断しています。
- 読めなかったファイルは、黙って数から外さず、終了コード2と一緒に報告します。数が合わなくなる原因を、あとから探したくないからです。