How to Accurately Track Card Exposure in Android RecyclerView
This article explains the shortcomings of hard‑coded exposure tracking in the Beike Zhaofang Android app, benchmarks industry practices, proposes a flexible API‑driven strategy, and provides a complete RecyclerView scroll‑listener implementation with a double‑ended queue to record precise card visibility durations.
Background
The Beike Zhaofang Android app currently uses a hard‑coded exposure timing mechanism. An exposure is only recorded when a card is fully displayed on the screen, and the same card may be counted multiple times during list scrolling.
Problems with the Existing Approach
The exposure threshold is fixed in the client; it should be configurable via an API.
The duration a card stays visible on the screen is not recorded.
Edge cases illustrate the flaws: a list with 1,000 items scrolled quickly would generate exposure events for unseen items, and a card that is only 80 % visible may still be counted as fully exposed.
Industry Benchmark
Products such as Toutiao and Baidu Mobile implement fine‑grained exposure tracking, including entry/exit timestamps and configurable visibility‑percentage thresholds.
Proposed Solution
Define a distinct exposure strategy for each card type.
Trigger exposure events based on threshold values delivered by the backend API.
Record the exact time each card remains on screen to calculate true exposure duration.
Handle page destruction, show/hide events (e.g., pressing the Home button) through API‑controlled feature switches.
Two diagrams illustrate the overall flow and the double‑ended queue used to cache visible ViewHolders.
Implementation Details
A double‑ended queue caches all currently visible ViewHolder objects. During the RecyclerView scroll callback, the first and last visible positions are obtained, the scroll direction is detected, and the visible proportion of each card is calculated using the top and bottom values of the item view. When the proportion exceeds the API‑provided threshold, a start‑time is recorded; when the card falls below the threshold, an exposure event is fired, yielding the total display period.
public class CardExposureHelper extends RecyclerView.OnScrollListener {
// Cache of visible cards
private Deque<BaseHomeCard> deque;
// Position of the top card in the queue
private int preFirstExposure;
// Position of the bottom card in the queue
private int preLastExposure;
/**
* Handle vertical exposure
* @param manager LinearLayoutManager
* @param isUp true if scrolling up
*/
private void onVerticalExposure(LinearLayoutManager manager, boolean isUp) {
int firstVisiblePosition = manager.findFirstVisibleItemPosition();
int lastVisiblePosition = manager.findLastVisibleItemPosition();
// Adjust positions based on exposure ratio
firstVisiblePosition = isVerticalExposure(firstVisiblePosition) ? firstVisiblePosition : firstVisiblePosition + 1;
lastVisiblePosition = isVerticalExposure(lastVisiblePosition) ? lastVisiblePosition : lastVisiblePosition - 1;
if (preFirstExposure == 0 && preLastExposure == 0) {
offerVerticalVisibleQueue(firstVisiblePosition, lastVisiblePosition, true);
} else if (isUp) {
// Scrolling up: dequeue cards that moved out at the top, enqueue new cards at the bottom
popVerticalVisibleQueue(preFirstExposure, firstVisiblePosition - 1, true);
offerVerticalVisibleQueue(preLastExposure + 1, lastVisiblePosition, false);
} else {
// Scrolling down: opposite strategy
popVerticalVisibleQueue(lastVisiblePosition + 1, preLastExposure, false);
offerVerticalVisibleQueue(firstVisiblePosition, preFirstExposure - 1, true);
}
preFirstExposure = firstVisiblePosition;
preLastExposure = lastVisiblePosition;
}
private void offerVerticalVisibleQueue(int start, int end, boolean isFirst) {
if (start >= 0 && end < recyclerView.getAdapter().getItemCount() && start <= end) {
if (isFirst) {
for (int i = end; i >= start; i--) {
onVerticalItemSlideInto(i, true);
}
} else {
for (int i = start; i <= end; i++) {
onVerticalItemSlideInto(i, false);
}
}
}
}
private void popVerticalVisibleQueue(int start, int end, boolean isFirst) {
if (start >= 0 && end < recyclerView.getAdapter().getItemCount() && start <= end) {
if (isFirst) {
for (int i = start; i <= end; i++) {
onVerticalItemSlideOut(i, isFirst);
}
} else {
for (int i = end; i >= start; i--) {
onVerticalItemSlideOut(i, isFirst);
}
}
}
}
private void onVerticalItemSlideInto(int position, boolean isFirst) {
BaseHomeCard card = getBaseHomeCard(position);
if (isFirst) {
deque.offerFirst(card);
} else {
deque.offerLast(card);
}
// Callback for exposure start
callItemExposure(card, position);
}
private void onVerticalItemSlideOut(int position, boolean isFirst) {
BaseHomeCard card;
if (isFirst) {
card = deque.removeFirst();
} else {
card = deque.removeLast();
}
// Callback for exposure end
callItemEndExposure(card, position, isFirst);
}
}Outlook
The implementation is currently a technical reserve for the Android client. Before production deployment, product managers need to define detailed product requirements and configuration options.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Beike Product & Technology
As Beike's official product and technology account, we are committed to building a platform for sharing Beike's product and technology insights, targeting internet/O2O developers and product professionals. We share high-quality original articles, tech salon events, and recruitment information weekly. Welcome to follow us.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
