Stop Allocating Rects in onTouchEvent: A Practical Caching Pattern for Android Hit-Testing
Allocating a new Rect inside onTouchEvent or onDraw creates needless GC churn. Here's the field-caching pattern, where to invalidate it, and the mutability traps to avoid.
26 Jan 2026, 11:22 UTC

If you've written a custom Android view that handles touch events, you've probably written this line inside onTouchEvent or onDraw:
Rect bounds = new Rect(left, top, right, bottom);
if (bounds.contains(x, y)) { ... }It works. It also allocates a new object every time a finger moves across the screen. A single swipe can fire dozens of ACTION_MOVE events, and if your view redraws at 60 or 120 fps with the same pattern in onDraw, you're handing the garbage collector a steady stream of short-lived objects for no functional gain. The fix is boring and effective: keep one Rect per hit region as a field, mutate it when geometry changes, and reuse it everywhere else.
Why Rect is worth reusing in the first place
android.graphics.Rect is a mutable container of four integers: left, top, right, bottom. Its contains(x, y) method is a handful of integer comparisons — effectively free at runtime. That means the cost of a naive hit-test is almost entirely the allocation, not the math. Integer bounds also avoid float-to-int conversions when you're comparing against MotionEvent.getX()/getY() values that you've already rounded to pixel coordinates.
Because Rect is mutable, reuse is safe and idiomatic: set(left, top, right, bottom) overwrites the fields in place, and offset(dx, dy) shifts the rectangle without creating anything. This is the same reason the framework itself passes a reusable Rect into methods like View.getHitRect(Rect outRect) — the caller supplies the storage so the callee doesn't allocate.
The pattern: allocate once, invalidate on layout
The workable structure for a custom view with, say, two tappable regions looks like this:
public class TogglePadView extends View {
private final Rect leftHotZone = new Rect();
private final Rect rightHotZone = new Rect();
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
int w = r - l;
int h = b - t;
// Refresh cached bounds whenever the view's size changes.
leftHotZone.set(0, 0, w / 2, h);
rightHotZone.set(w / 2, 0, w, h);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
int x = (int) event.getX();
int y = (int) event.getY();
if (leftHotZone.contains(x, y)) {
performLeftAction();
return true;
}
if (rightHotZone.contains(x, y)) {
performRightAction();
return true;
}
}
return super.onTouchEvent(event);
}
}Two things make this correct rather than just fast. First, the Rects are final fields allocated once at construction — zero per-event allocations. Second, they're refreshed in onLayout, which the framework calls whenever the view's size or position within its parent changes. That covers rotation, window resizing, and any parent-driven relayout, so the cache can't silently go stale across configuration changes.
If your hit regions depend on things other than layout — say, scroll position or an animated offset — update them with offset() or set() at the point that state changes, not inside the touch handler. The rule of thumb: mutate the cache when the underlying geometry changes, read it everywhere else.
Verifying the win (and the correctness)
You don't have to take the allocation argument on faith. Two quick checks:
- Allocations: In Android Studio's Memory Profiler, record a session while dragging across the view before and after the change. The "before" version shows recurring
Rectallocations tied to touch/draw activity; the "after" version should not. Look at the allocation count in the profiler's allocation tracking rather than expecting a visible FPS jump — on modern devices the benefit is mostly smoother GC behavior under load, not raw speed. - Hit accuracy: Log the result of each
contains()call alongside the raw event coordinates during a rotation or resize. You're checking for false negatives — touches that should register but don't because the cached bounds lag the real geometry. If you see misses right after a configuration change, your invalidation point is wrong.
Where to run these: the profiler workflow runs from Android Studio against a debuggable build on a device or emulator; no special permissions needed. The logging check is ordinary Log.d in your view, filtered in Logcat by your tag.
The trade-offs you actually need to respect
Mutability cuts both ways. A cached Rect is shared mutable state. If you pass it to another method that keeps a reference — or worse, hand it to a background thread while the UI thread mutates it — you get race conditions and hit-tests against half-updated bounds. Keep cached Rects confined to the UI thread, and if a caller needs a copy to hold onto, give it new Rect(cachedRect) explicitly. One deliberate allocation at a cold path beats a data race on a hot one.
Integer precision is a real limit. Rect stores ints. If your hit regions come from scaled or rotated drawing — a circular button, a path transformed by a matrix — integer pixel bounds are either wrong or too coarse. Use RectF for sub-pixel bounds, or Region/Path.contains-style logic for non-rectangular shapes. Caching applies to those too, but the invalidation cost is higher and the mutation API is less convenient.
Stale bounds are the classic bug. If you compute the Rect lazily on first touch instead of in onLayout, a view that starts at size zero will cache an empty rectangle and never register a hit. Always tie cache refresh to a lifecycle callback the framework guarantees, and when in doubt, recompute — a set() call is cheap; a missed tap is not.
Closing
The decision here isn't "Rect vs. something else" — it's "allocate per event vs. allocate once and invalidate carefully." For any custom view doing hit-testing or bounds math on a per-event or per-frame basis, field-cached Rects refreshed in onLayout are the default-correct pattern. Audit your touch handlers and draw methods for new Rect this week; each one you hoist into a field is a small, permanent reduction in GC churn, and the profiler will show it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.