简介

使用imageKnife后仍存在滑动白块问题的场景,常规的解决方案是设置更大的cachedCount缓存数量,但这种方案可能会导致首屏白屏和内场占用增多,针对这个问题,本文将主要提供一种动态预加载的方案,首先介绍相关原理,针对两种技术组合即LazyForeEach+ImageKnife、Repeat+ImageKnife,再分别结合prefetch提供对应场景的开发案例,最终对比不同方案的测试数据。

原理介绍

Imageknife原理介绍

ImageKnife是专门为OpenHarmony打造的一款图像加载缓存库,它封装了一套完整的图片加载流程,开发者只需根据ImageKnifeOption配置相关信息即可完成图片的开发,降低了开发难度,提升了开发效率。

详细介绍可参考:https://gitee.com/openharmony-tpc/ImageKnife

LazyForeEach原理介绍

LazyForEach从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会进行组件销毁回收以降低内存占用。

详细介绍可参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/ts-rendering-control-lazyforeach-V5

Repeat原理介绍

Repeat组件不开启virtualScroll开关时,Repeat基于数组类型数据来进行循环渲染,需要与容器组件配合使用,且接口返回的组件应当是允许包含在Repeat父容器组件中的子组件。Repeat循环渲染和ForEach相比有两个区别,一是优化了部分更新场景下的渲染性能,二是组件生成函数的索引index由框架侧来维护。

Repeat组件开启virtualScroll开关时,Repeat将从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了Repeat,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会缓存组件,并在下一次迭代中使用。

详细介绍可参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/ts-rendering-control-repeat-V5

prefetcher原理介绍

prefetch是一种动态预加载技术,通过考虑滚动速度、屏幕上的项目数量等因素,动态的下载或取消下载资源,确保相关资源在需要时能立即显示。

详细介绍可参考:https://ohpm.openharmony.cn/\#/cn/detail/@netteam%2Fprefetcher

页面抛滑白块优化解决方案原理介绍

使用LazyForEach/Repeat遍历数据项,通过实现Prefetcher接口监听数据项,选择合适的时机预取数据,使用ImageKnife三方库实现具体的预取功能,并管理缓存。

场景案例

本解决方案针对两种技术组合即LazyForeEach+ImageKnife+prefetch(首页)、Repeat+ImageKnife+prefetch(分类),其中提供对应场景的开发案例,界面效果。

关键代码如下:

1.Prefetcher结合LazyForeEach实现瀑布流页面关键代码

private readonly dataSource = new DataSource(); // 创建数据源
private readonly prefetcher = createPrefetcher() // 创建prefetcher
  .withDataSource(this.dataSource) // 绑定数据源
  .withAddItemsCallback(() => { // 增加数据源的回调函数
    this.dataCount = this.dataSource.totalCount();
    if (this.addItemsCount < 20) {
      this.addItemsCount ++;
      setTimeout(() => {
        this.dataSource.batchAdd(this.dataSource.totalCount());
      }, 1000);
    }
  })
  .withAgent(new ImageKnifeWaterFlowInfoFetchingAgent()); // 绑定获取数据源项引用的数据的代理

build() {
  Column({ space: CommonConstants.SPACE_EIGHT }) {
    Column() {
      WaterFlow({ footer: this.footStyle, scroller: this.waterFlowScroller }) {
        LazyForEach(this.dataSource, (item: WaterFlowInfoItem) => { // 瀑布流中使用LazyForEach遍历数据源
          FlowItem() {
            WaterFlowImageView({
              waterFlowInfoItem: item,
              waterFlowItemWidth: this.waterFlowItemWidth
            })
          }
          .height(item.waterFlowHeadInfo.height / item.waterFlowHeadInfo.width * this.waterFlowItemWidth +
          this.getTitleHeight(item.waterFlowDescriptionInfo.title)) // 通过固定宽高比计算卡片的高度
          .backgroundColor(Color.White)
          .width($r('app.string.full_screen'))
          .clip(true)
          .borderRadius($r('app.float.rounded_size_16'))
        });
      }
      .cachedCount(3)
      .onVisibleAreaChange([0.0, 1.0], (isVisible: boolean) => {
        // 根据瀑布流卡片可见区域变化,调用prefetch的start()和stop()接口
        if (isVisible) {
          this.prefetcher.start();
        } else {
          this.prefetcher.stop();
        }
      })
      .onScrollIndex((start: number, end: number) => {
        // 列表滚动触发visibleAreaChanged,实时更新预取范围,触发调用prefetch、cancel接口
        this.prefetcher.visibleAreaChanged(start, end);
      })
      .nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST })
      .onReachEnd(() => {
        this.listenNetworkEvent();
      })
      .columnsTemplate(new BreakpointType({
        sm: BreakpointConstants.GRID_NUM_TWO,
        md: BreakpointConstants.GRID_NUM_THREE,
        lg: BreakpointConstants.GRID_NUM_FOUR
      }).getValue(this.currentBreakpoint))
      .columnsGap($r('app.float.water_flow_column_gap'))
      .rowsGap($r('app.float.water_flow_row_gap'))
      .layoutDirection(FlexDirection.Column)
      .itemConstraintSize({
        minWidth: $r('app.string.zero_screen'),
        maxWidth: $r('app.string.full_screen'),
        minHeight: $r('app.string.zero_screen'),
      });
    }
    .width($r('app.string.full_screen'))
    .height($r('app.string.full_screen'));
  }
  .height($r('app.string.full_screen'))
  .margin({
    top: $r('app.float.margin_8'),
    bottom: $r('app.float.navigation_height'),
    left: new BreakpointType({
      sm: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_SM,
      md: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_MD,
      lg: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_LG
    }).getValue(this.currentBreakpoint),
    right: new BreakpointType({
      sm: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_RIGHT_SM,
      md: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_RIGHT_MD,
      lg: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_RIGHT_LG
    }).getValue(this.currentBreakpoint)
  })
  .animation({
    duration: CommonConstants.ANIMATION_DURATION_TIME,
    curve: Curve.EaseOut,
    playMode: PlayMode.Normal
  });
}

2.Prefetcher结合Repeat实现瀑布流页面关键代码

@Local private readonly items: WaterFlowInfoItem[] = wrapArray([]);
private prefetcher = createPrefetcher()
  .withDataSource(this.items) // 绑定数据源
  .withAddItemsCallback(async () => { // 新增数据回调
    this.pageIndex = (this.pageIndex++) % CommonConstants.REPEAT_WATER_FLOW_PAGES;
    const waterFlowInfoItemArray =
      await this.addData(CommonConstants.MOCK_INTERFACE_WATER_FLOW_FILE_NAME,
        this.pageIndex, CommonConstants.WATER_FLOW_PAGE_SIZE);
    this.items.push(...waterFlowInfoItemArray);
  })
  .withAgent(new ImageKnifeWaterFlowInfoFetchingAgent()); // 预加载资源代理

build() {
  Column() {
    WaterFlow({ footer: this.footStyle, scroller: this.waterFlowScroller }) {
      Repeat<WaterFlowInfoItem>(this.items)
        .each((obj: RepeatItem<WaterFlowInfoItem>) => {
          FlowItem() {
            WaterFlowItemComponent({ waterFlowInfoItem: obj.item, waterFlowItemWidth: this.waterFlowItemWidth })
          }
          .height(obj.item.waterFlowHeadInfo.height / obj.item.waterFlowHeadInfo.width * this.waterFlowItemWidth +
          this.getTitleHeight(obj.item.waterFlowDescriptionInfo.title))
          .backgroundColor(Color.White)
          .width($r('app.string.full_screen'))
          .clip(true)
          .borderRadius($r('app.float.rounded_size_16'))
        })
        .key((item: WaterFlowInfoItem) => {
          return item.key;
        })
    }
    .cachedCount(3)
    .onVisibleAreaChange([0.0, 1.0], (isVisible: boolean) => {
      // 根据瀑布流卡片可见区域变化,调用prefetch的start()和stop()接口
      if (isVisible) {
        this.prefetcher.start();
      } else {
        this.prefetcher.stop();
      }
    })
    .onScrollIndex((start: number, end: number) => {
      // 列表滚动触发visibleAreaChanged,实时更新预取范围,触发调用prefetch、cancel接口
      this.prefetcher.visibleAreaChanged(start, end);
    })
    .nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST })
    .onReachEnd(() => {
      this.listenNetworkEvent();
    })
    .columnsTemplate(new BreakpointType({
      sm: BreakpointConstants.GRID_NUM_TWO,
      md: BreakpointConstants.GRID_NUM_THREE,
      lg: BreakpointConstants.GRID_NUM_FOUR
    }).getValue(this.currentBreakpoint))
    .columnsGap($r('app.float.water_flow_column_gap'))
    .rowsGap($r('app.float.water_flow_row_gap'))
    .layoutDirection(FlexDirection.Column)
    .itemConstraintSize({
      minWidth: $r('app.string.zero_screen'),
      maxWidth: $r('app.string.full_screen'),
      minHeight: $r('app.string.zero_screen'),
    });
  }
  .height($r('app.string.full_screen'))
  .margin({
    top: $r('app.float.margin_8'),
    bottom: $r('app.float.navigation_height'),
    left: new BreakpointType({
      sm: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_SM,
      md: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_MD,
      lg: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_LG
    }).getValue(this.currentBreakpoint),
    right: new BreakpointType({
      sm: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_RIGHT_SM,
      md: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_RIGHT_MD,
      lg: BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_RIGHT_LG
    }).getValue(this.currentBreakpoint)
  })
  .animation({
    duration: CommonConstants.ANIMATION_DURATION_TIME,
    curve: Curve.EaseOut,
    playMode: PlayMode.Normal
  });
}

3.Prefetcher必须要实现的接口

  • IDataReferenceItem接口:要与Prefetcher一起使用的数据源项的接口。用作预取器数据源的数据源项或数组元素实现此接口。

核心代码:

export type FetchParameters = string;
export type PathToResultFile = string;
const IMAGE_UNAVAILABLE = $r('app.media.default_image');

@Observed
export class WaterFlowInfoItem implements IDataReferenceItem<FetchParameters, PathToResultFile> {
  private static nextKey = -1;
  private _key: number = WaterFlowInfoItem.getKey();

  private static getKey() {
    return ++WaterFlowInfoItem.nextKey;
  }

  public waterFlowHeadInfo: WaterFlowHeadInfo;
  public waterFlowDescriptionInfo: WaterFlowDescriptionInfo;
  cachedImage: ResourceStr = '';
 
  get key(): string {
    return this._key.toString();
  }

  regenerateKey() {
    this._key = WaterFlowInfoItem.getKey();
  }

  constructor(info: WaterFlowInfo) {
    this.waterFlowHeadInfo = info.waterFlowHead;
    this.waterFlowDescriptionInfo = info.waterFlowDescription;
  }
 // 预取完成时的回调函数
  onFetchDone(result: PathToResultFile): void {
    this.cachedImage = result;
  }
 // 预取失败时的回调函数
  onFetchFail(_details: Error): void {
    this.cachedImage = IMAGE_UNAVAILABLE;
  }
 // 获取需要预取的资源链接
  getFetchParameters(): FetchParameters {
    return this.waterFlowHeadInfo.source;
  }
 // 判断是否需要预取
  hasToFetch(): boolean {
    return !this.cachedImage;
  }
}
  • ITypedDataSource接口:可以链接到Prefetcher的数据源的接口。不需要修改数据源的实际实现。该接口确保数据源项类型与获取代理类型 IFetchAgent 匹配。

核心代码:

export class DataSource implements ITypedDataSource<WaterFlowInfoItem> {
  private data: WaterFlowInfoItem[] = [];
  private readonly notifier: Notifier;
  private netWorkUtil: NetworkUtil = new NetworkUtil();
  private pageIndex: number = CommonConstants.NUMBER_DEFAULT_VALUE

  constructor(notificationMode: NotificationMode = 'data-set-changed-method') {
    this.notifier = new Notifier(notificationMode);
  }

 // 具体的添加数据方法
  async addData(fileName: string, pageNo: number, pageSize: number): Promise<WaterFlowInfoItem[]> {
    let waterFlowInfoArray: WaterFlowInfo[] =
      await this.netWorkUtil.getWaterFlowData(CommonConstants.MOCK_INTERFACE_PATH_NAME, fileName, pageNo, pageSize);

    for (let index = 0; index < waterFlowInfoArray.length; index++) {
      if (waterFlowInfoArray[index].waterFlowDescription.userName.length > CommonConstants.MAX_NAME_LENGTH) {
        waterFlowInfoArray[index].waterFlowDescription.userName =
          waterFlowInfoArray[index].waterFlowDescription.userName.substring(0, CommonConstants.MAX_NAME_LENGTH);
      }
      this.data.push(new WaterFlowInfoItem(waterFlowInfoArray[index]));
    }
    return this.data;
  }

 // 外部调用的添加数据的方法
  async batchAdd(startIndex: number) {
    this.pageIndex = (this.pageIndex++) % CommonConstants.LAZY_FOREACH_WATER_FLOW_PAGES;
    const items =
      await this.addData(CommonConstants.MOCK_INTERFACE_WATER_FLOW_FILE_NAME,
        this.pageIndex, CommonConstants.WATER_FLOW_PAGE_SIZE);
    this.data.splice(startIndex, 0, ...items);
    this.notifier.notifyBatchUpdate([
      {
        type: DataOperationType.ADD,
        index: startIndex,
        count: items.length,
        key: items.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);
  }

  deleteAllAsReload(): void {
    this.data.length = 0;
    this.notifier.notifyReloaded();
  }

  batchDelete(startIndex: number, count: number) {
    if (startIndex >= 0 && startIndex < this.data.length) {
      const deleted = this.data.splice(startIndex, count);
      this.notifier.notifyBatchUpdate([
        {
          type: DataOperationType.DELETE,
          index: startIndex,
          count: deleted.length
        }
      ]);
    }
  }
}
  • IFetchAgent接口:该实现负责获取数据源项引用的数据。预取器构建器 API 确保数据源项 IDataReferenceItem 和绑定到预取器实例的获取代理具有匹配的类型。

核心代码:

/*
* Implementing IFetchAgent for prefetcher using ImageKnife's caching capability
*/
export class ImageKnifeWaterFlowInfoFetchingAgent implements IFetchAgent<FetchParameters, PathToResultFile> {
  private readonly logger = new Logger("FetchAgent");
  private readonly fetchToRequestMap: HashMap<FetchId, ImageKnifeRequest> = new HashMap()

  /*
  * Asynchronous prefetching function encapsulated with ImageKnife
  */
  async prefetch(fetchId: FetchId, loadSrc: string): Promise<string> {
    return new Promise((resolve, reject) => {
      let imageKnifeOption = new ImageKnifeOption()
      if (typeof loadSrc == 'string') {
        imageKnifeOption.loadSrc = loadSrc;
      } else {
        imageKnifeOption = loadSrc;
      }

      imageKnifeOption.onLoadListener = {
        onLoadSuccess: () => {
          this.fetchToRequestMap.remove(fetchId);
          resolve(loadSrc);
        },
        onLoadFailed: (err) => {
          this.fetchToRequestMap.remove(fetchId);
          reject(err);
        }
      }
      let request = ImageKnife.getInstance().preload(imageKnifeOption);
      this.fetchToRequestMap.set(fetchId, request);
    })
  }

 // 实现prefetcher的fetch接口
  async fetch(fetchId: FetchId, fetchParameters: FetchParameters): Promise<PathToResultFile> {
    this.logger.debug(`Fetch ${fetchId}`);
    let path = await this.prefetch(fetchId, fetchParameters);
    return path;
  }

 // 实现prefetcher的cancel接口
  cancel(fetchId: FetchId) {
    this.logger.debug(`Fetch ${fetchId} cancel`);
    if (this.fetchToRequestMap.hasKey(fetchId)) {
      const request = this.fetchToRequestMap.get(fetchId);
      ImageKnife.getInstance().cancel(request);
      this.fetchToRequestMap.remove(fetchId);
    }
  }
}

性能分析

本案例中的页面一屏大概可以加载4至6条数据,每张图片的大小在200-300KB之间。针对使用LazyForeEach的场景,使用prefetch方案前后,快速滑动场景下的白块的数量效果对比如下:

使用prefetch方案前

使用prefetch方案后

在快速滑动的场景下,因为动态预加载能取消对快速划过的图片的数据请求,节省了大量的网络资源,从而减少了白块的数量。

示例代码


HarmonyOS码上奇行
9.2k 声望3.3k 粉丝

欢迎关注 HarmonyOS 开发者社区:[链接]