Mobile Development 20 min read

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.

51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
Eliminate HarmonyOS Scroll White Blocks: Dynamic Prefetch + ImageKnife Caching

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 screen

However, 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

LazyForEach

is 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

Repeat

is 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 block

Each 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)

Repeat

doesn'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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

HarmonyOSvirtual scrollingArkUImobile performancelazy loadingimage cachingLazyForEachRepeatWaterFlowdynamic prefetchingImageKnifeprefetcherwhite block optimization
51CTO HarmonyOS Developer Community
Written by

51CTO HarmonyOS Developer Community

The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.