Some days the ANR section in Play Console fills with the same red rows. I never see them on my own devices. Yet the not-responding reports gather on low-end phones.
If I cannot reproduce it, I cannot fix it. That was my reasoning, and I let one of these sit for a while. When you cannot reproduce a problem, what you can rely on is not the device but the line the trace says was blocking.
While running my wallpaper apps as an indie developer, I found two causes that were blocking the main thread. Neither was the feature itself. One was cleanup when a screen closed, the other was preparing a background the user can barely see. Here is the script I use to find them, and how I thought about the fixes.
In an ANR trace, look at one line first
An ANR is recorded when the main thread cannot get back to input handling within a set time. Per the official description, the rough thresholds are 5 seconds without responding to an input event, or a broadcast receiver running longer than 10 seconds in the foreground. A single synchronous task of a few seconds is enough. The trace lists every thread, but the first place to look is the top few lines of the block labelled "main".
If a function from your own app appears there, that is where it was stuck. If not, it was waiting inside the framework, and the caller is somewhere in your code.
Reading traces one at a time means reading the same cause over and over. So I wrote a script that pulls out only the "main" block, classifies the blocking line, and sorts by count.
| Category | Example line | Usual source |
|---|---|---|
| Billing client teardown/connect | BillingClientImpl.endConnection | Called when a screen is destroyed |
| Synchronous image decode | BitmapFactory.decodeStream | A background loaded on every page build |
| Synchronous SharedPreferences write | QueuedWork.waitToFinish | Waiting for a save when leaving a screen |
| Synchronous file/DB access | SQLiteDatabase. | A load right after launch |
| Network on the main thread | HttpURLConnection | A call that was never made async |
| Lock wait | waiting to lock | Contention with another thread |
The rules are regular expressions, as in the table. When you find a new cause in your own app, adding one line extends them.
The classification script
Save it as anr_blockers.py. I ran this script on Python 3 here to confirm it works. Change only the trace folder and the package name to yours.
"""Read ANR stack traces (plain text) and classify the line that blocked the main thread.
Usage: python3 anr_blockers.py --pkg com.example.app traces/*.txt
Expected format: Android ANR traces (thread blocks starting with `"main" prio=5 tid=1 ...`).
Stacks copied from the Play Console ANR detail also work if they start with `"main"`.
"""
import argparse, re, sys
from collections import Counter
# Rules: (label, regex matched against a frame). The first rule that matches wins.
# When you find a new cause in your own app, add one line here.
RULES = [
("Billing client teardown/connect", re.compile(r"BillingClient(Impl)?\.(endConnection|startConnection)")),
("Synchronous image decode", re.compile(r"BitmapFactory\.decode|ImageDecoder\.decode|BitmapDrawable|Drawable\.createFromPath")),
("Synchronous SharedPreferences write", re.compile(r"SharedPreferencesImpl.*(commit|awaitLoadedLocked|waitToFinish)|QueuedWork\.waitToFinish")),
("Synchronous file/DB access", re.compile(r"java\.io\.(File|FileInputStream|FileOutputStream)\.|SQLiteDatabase\.|SQLiteConnection\.")),
("Network on the main thread", re.compile(r"java\.net\.|okhttp3\.internal\.connection|HttpURLConnection")),
("Lock wait", 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):
"""Pull out only the "main" thread blocks from the whole trace."""
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: # Newest call is on top; take the first rule that matches
for label, rx in RULES:
if rx.search(fr):
return label, fr
return "Unclassified", frames[0] if frames else "(no frames)"
def first_own_frame(frames, pkg):
for fr in frames:
if fr.startswith(pkg):
return fr
return "(no frame from your package)"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--pkg", required=True, help="prefix of your own app package name")
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: # Never drop an unreadable file silently; always report it
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"Traces read: {len(a.files)} / main blocks: {total} / unreadable: {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} first own frame: {site}")
for p in unknown:
print(f"\n! Could not read: {p} (check that it starts with \"main\" prio=...)")
sys.exit(0 if not unknown else 2)
if __name__ == "__main__":
main()What it solves: the time spent reading the same ANR by eye, replaced by one run.
Three choices worth writing down.
- It looks only at the
"main"block. Other threads can hold clues, but for a first triage they are too much information. - Rules are applied top to bottom and the first match wins. The newest call is at the top of a stack, so the match nearest the top is the place that was actually blocking.
- Files it cannot read are reported with exit code 2, not silently dropped. I do not want to hunt for why the counts do not add up later.