İçeriğe geç
Blog

Not a Bug, the Device: Debugging Android Fragmentation

Bugs that appear on one phone model and nowhere else are a capability problem, not a mystery. Here is the workflow we use to make them reproducible on hardware we already own.

IIlhan Seyhan7 min read1 views
Not a Bug, the Device: Debugging Android Fragmentation

Some bugs only exist on hardware you will never hold. This post is the workflow we use to turn “one player on one phone sees a black screen” into a test we can run on a device that is already on our desk: cluster the reports, log the device instead of just the error, read crash reports as a filter, and reproduce GPU and memory constraints on purpose.

First prove it is the device, not the build

A device-class bug looks different from a logic bug in aggregate: it clusters. Before touching code, group every crash, ANR and bug report by model, SoC and GPU family, Android version, RAM tier, graphics API and screen aspect ratio. If most of the events come from a group that is a small slice of your sessions, you are looking at a hardware or driver problem, and the fix will almost certainly be a fallback path rather than a rewrite.

Play Console's Android vitals gives you that slicing without extra work, and it also gives you a sense of scale. Google's bad behaviour thresholds sit at 1.09% for user-perceived crash rate and 0.47% for user-perceived ANR rate. Read them as a ruler, not a goal: if one device family alone pushes you near those numbers, that family is the whole task list.

Log the device, not just the error

The single change that saved us the most time was attaching a device fingerprint to every session, before any error happens. When a report arrives from a phone we do not have, the fingerprint is what makes it actionable: it tells us which code paths that device took.

using UnityEngine;

public static class DeviceFingerprint
{
    public static void Log()
    {
        Debug.Log($"[dev] model={SystemInfo.deviceModel} os={SystemInfo.operatingSystem}");
        Debug.Log($"[dev] gpu={SystemInfo.graphicsDeviceName} vendor={SystemInfo.graphicsDeviceVendor}");
        Debug.Log($"[dev] api={SystemInfo.graphicsDeviceType} {SystemInfo.graphicsDeviceVersion}");
        Debug.Log($"[dev] ram={SystemInfo.systemMemorySize}MB gfx={SystemInfo.graphicsMemorySize}MB cores={SystemInfo.processorCount}");
        Debug.Log($"[dev] screen={Screen.width}x{Screen.height} dpi={Screen.dpi} quality={QualitySettings.GetQualityLevel()}");
        Debug.Log($"[dev] astc={SystemInfo.SupportsTextureFormat(TextureFormat.ASTC_6x6)}");
    }
}

Push the same values into your crash reporter as custom keys, keep a rolling buffer of the last few dozen log lines, and upload that buffer with the report. Both of our shipped games, Kart Üçlüsü and Thornguard: Tower Defense, use the same fingerprint code, which is one of the cases where shared tooling actually pays off: one improvement to the logger improves diagnostics in both.

Two details matter more than the field list. First, log capability rather than identity: “supports ASTC = false” is a lead, while a model name is only a search query. Second, keep a remote switch that raises log verbosity for a single device model or user id, so you can ask for detail without shipping a chatty build to everyone.

Read a crash report as a filter, not an answer

Stack traces from a device you cannot touch rarely name the cause. What they do well is eliminate whole categories:

  • Native crash inside a GPU driver library (libGLESv2 or a vendor .so): treat it as your resource usage or a shader tripping a driver path, not as something you can patch. You cannot fix the driver, but you can avoid the pattern.
  • Abort with an allocation failure, or no report at all: memory. Reports that simply stop existing are the strongest signal of a killed process.
  • ANR with the main thread in loading or shader work: slow storage and first-run shader compilation on weak CPUs, not a deadlock.
  • One stack, many manufacturers, a single OS version: an OS behaviour change, so look at what that version changed instead of at the device.

Breadcrumbs sharpen the filter more than any symbol table. We log one short line at each milestone: scene loaded, first frame presented, atlases resident, save file parsed, network ready. The useful information is the last breadcrumb before the silence, and recording it costs almost nothing.

Recreating GPU and driver differences without the GPU

Most “only on that phone” visual bugs come from three places: shader precision, texture format fallbacks, and colour space handling.

Precision is the sneakiest. In GLSL, mediump only guarantees roughly a five-bit exponent and a ten-bit significand, as ARM's own benchmarking write-up explains, and Unity's half maps to a 16-bit value with about three decimal digits of precision. Some mobile GPUs honour that hint aggressively; desktop editors and other drivers quietly compute everything at 32-bit. So a shader that multiplies elapsed time by a large constant, or packs UV offsets into a half, looks correct everywhere you test and bands, steps or wobbles on exactly one GPU family.

The practical move is to build a worst-case profile you can run on any device you already own: force the lowest quality tier, force ETC2 instead of ASTC, restrict the graphics API to OpenGL ES 3, and deliberately switch suspect shader variables from float to half. If the artefact appears, you have your repro without the phone. Watching adb logcat -s Unity during the first minute of a fresh install is the other half of this: runtime shader compilation warnings only show up on device, and they often name the exact variant that fails.

Texture format fallbacks are the second class, and they hurt most where fine detail carries meaning. A card face that decompresses to a blurrier format is not a crash, it is a readability regression, which is why we treat compression settings as a design constraint rather than a build setting — the same argument we made about card UI readability.

Low-memory devices: the process is killed, not crashed

On a 2 GB phone your app usually does not crash. It gets killed while backgrounded, or during a scene transition when the old scene and the new one are both resident, and the player experiences it as “the game restarted and I lost my run”. There is no stack trace, because nothing failed inside your code.

Three tools cover this. First, react to the warning you are given: Unity raises Application.lowMemory when the OS reports memory pressure, and logging it tells you which devices live permanently on the edge.

void OnEnable()  { Application.lowMemory += OnLowMemory; }
void OnDisable() { Application.lowMemory -= OnLowMemory; }

void OnLowMemory()
{
    long mb = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong() / (1024 * 1024);
    Debug.Log($"[mem] lowMemory allocated={mb}MB scene={UnityEngine.SceneManagement.SceneManager.GetActiveScene().name}");
    Resources.UnloadUnusedAssets();
}

Second, trigger the condition on purpose. adb shell am send-trim-memory <package> RUNNING_CRITICAL fires the callback on demand, and adb shell dumpsys meminfo <package> gives you numbers you can compare between scenes. Third, read the kill itself: adb logcat | grep -iE 'lmkd|Killing' shows the system removing your process, which is the only evidence a low-memory kill leaves behind. Peak allocation, not average, decides whether you survive — the same lesson as chasing spikes rather than averages when profiling frame time on mobile.

The repro recipe we follow

  1. Write the hypothesis as one falsifiable sentence: “on this GPU family with ES 3.0 drivers, the outline shader clips at high zoom”.
  2. Build a device profile from the fingerprint: graphics API, texture formats, RAM, quality tier, OS version.
  3. Recreate those constraints on hardware you own — forced API and formats, capped memory budget, quality tier locked, resolution scaled down, plus a warm device if heat is part of the story.
  4. Add a debug menu toggle that forces the suspect path, so the repro is one tap instead of a special build.
  5. If you still cannot see it, rent the hardware. Firebase Test Lab runs builds on physical and virtual devices, which is enough to confirm a visual artefact from a screenshot.
  6. Verify with a staged rollout and watch the metric for that cluster, not the global average. A fix that helps 3% of devices is invisible in a global crash rate.

None of this makes fragmentation pleasant, but it changes the question from “why does this phone hate us” to “which capability differs, and what does our code do when it is missing”. That second question has an answer you can test.

Share:

Comments

Be the first to comment.