Eliminate HarmonyOS Scroll White Blocks: Dynamic Prefetch + ImageKnife Caching
This article presents a complete HarmonyOS solution for eliminating white blocks during fast scrolling in image-heavy lists by combining dynamic prefetching via the prefetcher library with ImageKnife image caching, demonstrating two implementations using LazyForEach and Repeat components with performance benchmarks showing under 5% white block rate and minimal memory overhead.
Background: The White Block Problem
In HarmonyOS app development, waterfall or list pages with many images (e.g., e-commerce home, Xiaohongshu-style feeds) suffer from a common issue: when users scroll quickly, newly visible image positions briefly show white blank blocks before the images load. This "white block" phenomenon degrades perceived smoothness.
Conventional Approach and Its Drawbacks
The straightforward fix is increasing the cachedCount parameter on lazy-loading components to pre-load several screens worth of items off-screen, triggering image requests earlier.
WaterFlow() {
LazyForEach(this.dataSource, (item) => { ... })
}.cachedCount(5) // default caches 5 items beyond one screenHowever, this introduces two problems:
Worse first-screen white blocks : larger cachedCount means more components initialize on first load, lengthening first-screen render time and even causing white blocks on the first screen.
Memory spikes : many pre-created components stay in memory, especially images, leading to app kills or jank.
Therefore, a smarter preloading scheme is needed: one that prepares image resources ahead of time without hurting first-screen speed or inflating memory.
Technical Goals
During scrolling, images start loading before entering the viewport, so they appear instantly with no white block.
No impact on first-screen load speed, no extra memory burden.
Adapt to different scroll speeds, dynamically adjusting prefetch range.
Core Technical Foundations
ImageKnife Image Loading & Caching Library
ImageKnife is a powerful HarmonyOS image loading/caching library encapsulating download, memory cache, disk cache, and transformations. Developers configure ImageKnifeOption to load network, resource, or file images with automatic caching. The solution uses its preload capability: ImageKnife.getInstance().preload(option) places images into cache without immediate display, enabling instant rendering later.
LazyForEach: Lazy Data Iteration
LazyForEachis ArkUI's lazy-loading loop rendering component. Paired with an IDataSource implementation, it creates components on-demand based on the scroll container's visible area; components scrolled out of view are destroyed and recycled, reducing memory.
LazyForEach(this.dataSource, (item: ItemType) => {
// create component
}, (item: ItemType) => item.key)Repeat: Next-Gen Loop Rendering
Repeatis a newer ArkUI looping component supporting a virtualScroll switch. When enabled, it behaves like LazyForEach but offers two advantages over ForEach:
Optimized rendering performance for partial updates (precise updates).
Framework-managed indices in the item generator, avoiding manual key handling.
Repeat<ItemType>(this.items)
.each((obj: RepeatItem<ItemType>) => { ... })
.key((item) => item.id)The article demonstrates both LazyForEach + prefetch and Repeat + prefetch combinations.
prefetcher: Dynamic Prefetch Library
prefetcher(@netteam/prefetcher) is an ohpm library implementing dynamic preloading. It decides what to prefetch or cancel based on scroll speed, visible item count, etc., ensuring resources are ready when needed.
Core mechanism:
Bind a data source (implementing ITypedDataSource or a plain array).
Listen to scroll container's onScrollIndex and onVisibleAreaChange events to update the visible range in real time.
Compute the index range to prefetch, then call a custom IFetchAgent implementation to execute actual prefetch (e.g., via ImageKnife preload).
When scrolling fast, skip already-passed items and cancel their prefetch requests to save bandwidth.
Overall Solution Architecture
Scroll Container (WaterFlow/List)
↓ listen onScrollIndex, onVisibleAreaChange
prefetcher instance
↓ dynamically compute prefetch index range
IFetchAgent implementation (ImageKnifeWaterFlowInfoFetchingAgent)
↓ call ImageKnife.preload() to preload images
ImageKnife memory/disk cache
↓ image display reads directly from cache → no white blockEach data item must implement IDataReferenceItem to provide prefetch parameters (e.g., image URL) and result callbacks.
Solution 1: LazyForEach + ImageKnife + prefetch (Home Waterfall)
Data Source Implementation (ITypedDataSource)
A class implementing ITypedDataSource so prefetcher can observe data changes and fetch items dynamically.
// DataSource.ets
import { ITypedDataSource, DataChangeListener, Notifier, DataOperationType } from '@netteam/prefetcher';
export class WaterFlowDataSource implements ITypedDataSource<WaterFlowInfoItem> {
private data: WaterFlowInfoItem[] = [];
private notifier: Notifier;
private networkUtil: NetworkUtil = new NetworkUtil();
private pageIndex: number = 0;
constructor() {
this.notifier = new Notifier('data-set-changed-method');
}
// Add data (simulate network request)
async addData(fileName: string, pageNo: number, pageSize: number): Promise<WaterFlowInfoItem[]> {
const rawData = await this.networkUtil.getWaterFlowData(fileName, pageNo, pageSize);
const newItems = rawData.map(info => new WaterFlowInfoItem(info));
this.data.push(...newItems);
return newItems;
}
// Batch insert (for load more)
async batchAdd(startIndex: number) {
this.pageIndex = (this.pageIndex + 1) % 3; // assume 3 pages total
const newItems = await this.addData('waterflow_mock', this.pageIndex, 20);
this.data.splice(startIndex, 0, ...newItems);
this.notifier.notifyBatchUpdate([{
type: DataOperationType.ADD,
index: startIndex,
count: newItems.length,
key: newItems.map(item => item.key)
}]);
}
getData(index: number): WaterFlowInfoItem {
return this.data[index];
}
totalCount(): number {
return this.data.length;
}
registerDataChangeListener(listener: DataChangeListener): void {
this.notifier.registerDataChangeListener(listener);
}
unregisterDataChangeListener(listener: DataChangeListener): void {
this.notifier.unregisterDataChangeListener(listener);
}
}Data Item Model (IDataReferenceItem)
Each card item implements IDataReferenceItem so prefetcher knows what resource to prefetch and how to update UI after completion.
// WaterFlowInfoItem.ets
import { IDataReferenceItem } from '@netteam/prefetcher';
@Observed
export class WaterFlowInfoItem implements IDataReferenceItem<string, string> {
private static nextId = 0;
private _id: number = WaterFlowInfoItem.nextId++;
// Image URL
public imageUrl: string;
public title: string;
public cachedImageResource: ResourceStr = $r('app.media.placeholder');
constructor(imageUrl: string, title: string) {
this.imageUrl = imageUrl;
this.title = title;
}
get key(): string {
return this._id.toString();
}
// Return resource identifier to prefetch (image URL)
getFetchParameters(): string {
return this.imageUrl;
}
// Whether prefetch needed (no cached image yet)
hasToFetch(): boolean {
// If still placeholder, need to prefetch
return this.cachedImageResource === $r('app.media.placeholder');
}
// Prefetch success callback
onFetchDone(result: string): void {
// result is the prefetched image URL (or file path)
this.cachedImageResource = result; // ImageKnife already cached; this just marks it
}
// Prefetch failure callback
onFetchFail(_details: Error): void {
this.cachedImageResource = $r('app.media.error_image');
}
}Fetch Agent Implementation (IFetchAgent)
The agent calls ImageKnife to perform actual preloading.
// ImageKnifeFetchAgent.ets
import { IFetchAgent, FetchId } from '@netteam/prefetcher';
import { ImageKnife, ImageKnifeOption, ImageKnifeRequest } from '@ohos/imageknife';
export class ImageKnifeFetchAgent implements IFetchAgent<string, string> {
private requestMap: Map<FetchId, ImageKnifeRequest> = new Map();
async fetch(fetchId: FetchId, fetchParameters: string): Promise<string> {
return new Promise((resolve, reject) => {
const option = new ImageKnifeOption();
option.loadSrc = fetchParameters; // image URL
option.onLoadListener = {
onLoadSuccess: () => {
this.requestMap.delete(fetchId);
resolve(fetchParameters); // return URL as success
},
onLoadFailed: (err) => {
this.requestMap.delete(fetchId);
reject(err);
}
};
const request = ImageKnife.getInstance().preload(option);
this.requestMap.set(fetchId, request);
});
}
cancel(fetchId: FetchId): void {
const request = this.requestMap.get(fetchId);
if (request) {
ImageKnife.getInstance().cancel(request);
this.requestMap.delete(fetchId);
}
}
}Page Component Integration (WaterFlow + LazyForEach)
The page creates DataSource and prefetcher instances, binding lifecycle events.
// WaterFlowLazyPage.ets
import { createPrefetcher } from '@netteam/prefetcher';
import { WaterFlowDataSource } from './DataSource';
import { ImageKnifeFetchAgent } from './ImageKnifeFetchAgent';
@Entry
@Component
struct WaterFlowLazyPage {
private dataSource: WaterFlowDataSource = new WaterFlowDataSource();
private waterFlowScroller: Scroller = new Scroller();
private prefetcher = createPrefetcher()
.withDataSource(this.dataSource) // bind data source
.withAgent(new ImageKnifeFetchAgent()) // bind fetch agent
.withAddItemsCallback(() => { // triggered when adding data
this.dataSource.batchAdd(this.dataSource.totalCount());
});
@State dataCount: number = 0;
private addItemsCount: number = 0;
aboutToAppear() {
// Initial load of 20 items
this.dataSource.addData('waterflow_mock', 0, 20).then(() => {
this.dataCount = this.dataSource.totalCount();
});
}
build() {
Column() {
WaterFlow({ scroller: this.waterFlowScroller }) {
LazyForEach(this.dataSource, (item: WaterFlowInfoItem, index: number) => {
FlowItem() {
this.WaterFlowCard(item)
}
.height(280) // based on actual calculated height
.width('100%')
.margin({ bottom: 8 })
}, (item: WaterFlowInfoItem) => item.key)
}
.cachedCount(2) // keep minimal cached components
.onVisibleAreaChange([0.0, 1.0], (isVisible: boolean) => {
// Start/stop prefetch on visibility change
if (isVisible) {
this.prefetcher.start();
} else {
this.prefetcher.stop();
}
})
.onScrollIndex((start: number, end: number) => {
// Update prefetch range in real time on scroll
this.prefetcher.visibleAreaChanged(start, end);
})
.onReachEnd(() => {
// Load more on reaching end
if (this.addItemsCount < 5) { // prevent infinite load
this.addItemsCount++;
this.prefetcher.addItems(); // triggers addItemsCallback
}
})
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
@Builder
WaterFlowCard(item: WaterFlowInfoItem) {
Column() {
Image(item.cachedImageResource)
.width('100%')
.height(200)
.objectFit(ImageFit.Cover)
Text(item.title)
.fontSize(16)
.margin({ top: 8, left: 8, bottom: 8 })
}
.backgroundColor(Color.White)
.borderRadius(12)
.clip(true)
}
}Solution 2: Repeat + ImageKnife + prefetch (Category Page)
Repeatdoesn't require a full data source interface; a plain array works, but items must still implement IDataReferenceItem.
Data Item Model
Same as section 4.2: each card item implements IDataReferenceItem.
Page Component Implementation
// CategoryRepeatPage.ets
import { createPrefetcher } from '@netteam/prefetcher';
import { ImageKnifeFetchAgent } from './ImageKnifeFetchAgent';
@Entry
@Component
struct CategoryRepeatPage {
@Local items: WaterFlowInfoItem[] = []; // @Local ensures two-way binding
private waterFlowScroller: Scroller = new Scroller();
private prefetcher = createPrefetcher()
.withDataSource(this.items) // bind array directly
.withAgent(new ImageKnifeFetchAgent())
.withAddItemsCallback(async () => {
// Simulate loading more data
const newItems = await this.loadMoreData();
this.items.push(...newItems);
});
private pageIndex: number = 0;
aboutToAppear() {
this.loadInitialData();
}
async loadInitialData() {
const data = await this.fetchData(0, 20);
this.items = data;
}
async loadMoreData() {
this.pageIndex++;
return await this.fetchData(this.pageIndex, 20);
}
async fetchData(page: number, size: number): Promise<WaterFlowInfoItem[]> {
// Simulate network request
return [];
}
build() {
Column() {
WaterFlow({ scroller: this.waterFlowScroller }) {
Repeat<WaterFlowInfoItem>(this.items)
.each((obj: RepeatItem<WaterFlowInfoItem>) => {
FlowItem() {
this.WaterFlowCard(obj.item)
}
.height(280)
.width('100%')
})
.key((item: WaterFlowInfoItem) => item.key)
.cachedCount(2)
.onVisibleAreaChange([0.0, 1.0], (isVisible) => {
if (isVisible) this.prefetcher.start();
else this.prefetcher.stop();
})
.onScrollIndex((start, end) => {
this.prefetcher.visibleAreaChanged(start, end);
})
.onReachEnd(() => {
this.prefetcher.addItems();
})
.columnsTemplate('1fr 1fr')
}
.width('100%')
.height('100%')
}
}
@Builder
WaterFlowCard(item: WaterFlowInfoItem) {
// Same as before
}
}Performance Comparison & Validation
Test Environment
Device: HarmonyOS phone (Kirin 990, 8GB RAM)
Data: 100 waterfall cards, each image 200-300KB
~5 cards per screen (2 columns)
Test Method
Rapid up/down scrolling (>3 screens/second), measuring the percentage of newly visible cards that show white blocks.
Results
No optimization (cachedCount=0) : White block frequency 85%, memory peak 180MB, first-screen render time 400ms.
Traditional (cachedCount=5) : White block frequency 20%, memory peak 420MB, first-screen render time 1200ms.
This solution (prefetch + dynamic) : White block frequency <5%, memory peak 220MB, first-screen render time 480ms.
Conclusion
White blocks nearly eliminated (only occasional brief appearance under extremely slow network).
Memory usage only ~22% higher than no optimization, far below traditional cachedCount approach.
First-screen render time shows no significant increase.
Summary & Recommendations
Solution Recap
The article delivers a complete HarmonyOS white-block optimization stack, combining:
Lazy-loading framework (LazyForEach / Repeat) for memory efficiency.
Dynamic prefetch library prefetcher for intelligent prefetch/cancel decisions.
ImageKnife for efficient image loading and caching.
By implementing three key interfaces ( IDataReferenceItem, ITypedDataSource, IFetchAgent), prefetcher integrates seamlessly into existing projects, adjusting prefetch strategy in real time based on scroll behavior.
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.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
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.
