一、痛点场景:全量更新阻塞主线程

当接口返回不可拆分的大数据集(如几千条数据,每条数据数十个属性)时,使用Repeat/LazyForEach渲染会遇到:

  • 必须在主线程操作:渲染依赖在主线程创建@Observed可观察对象
  • 双重性能消耗:O(n²)复杂度(千级卡片×多属性更新)
  • 线程切换失效:跨线程通信的序列化开销抵消优化收益

整体流程:
19335-jjvb1vnmkkf.png

此时想优化渲染卡顿,第一时间可能想到的是使用taskPool或worker,通过并发处理数据,避免主线程渲染卡顿,然而在此场景下,由于线程之间无共享内存,线程之间数据同步还涉及序列化和反序列化操作,且线程间通信对象不支持@State装饰器、@Prop装饰器、@Link装饰器等装饰器修饰的复杂类型,数据在其他线程处理完之后,还得回到主线程创建可观察的数据,切换线程只会带来额外的序列化反序列化开销,对整体性能并无优化效果。
06418-opd59064317.png

问题核心矛盾
无法避免主线程操作:渲染必须使用主线程创建的可观察对象
遍历+赋值双重耗时:O(n²)时间复杂度(1000卡片×数十属性)
数据传递开销:跨线程通信成本可能抵消优化收益

二、优化思路

  • 分离数据计算与渲染 后台线程处理数据计算,差异化部分单独通知到主线程更新(避免在主线程合并全量数据)

  • 减少主线程单次工作量 合并更新批次 + 分帧渲染

方案 效果 成本
跨线程数据计算 理论最优 需重构数据流架构
分帧批量更新 显著改善卡顿 改动量最小

跨线程数据计算虽然理论优化效果最好,但由于需要对代码架构做较大的重构,且实际嵌套的数据结构可能处理起来也会遇到各种坑,因此不在本次尝试范围,分帧、批量更新数据,将大量数据列表的foreach操作,从一整块耗时操作拆分到每一帧之间处理,对整体代码架构的冲击较小,优化成本最低。

2.1 先来看下已有代码结构和实际运行时的trace:

  • CardListComponent
import { CardItemComponent } from "./CardItemComponent";
import { CardModel } from "./CardModel";
import { CardViewModel } from "./CardViewModel"
import { displaySync } from "@kit.ArkGraphics2D";
import { ToastUtil } from "@pura/harmony-utils";

const TAG: string = 'CardListComponent'

@Entry
@ComponentV2
export struct CardListComponent {
  private timerId: number = -1;

  private readonly FRAME_60: number = 60;

  private readonly FRAME_120: number = 120;

  private startTime: number = 0;

  @Local viewModel: CardViewModel = CardViewModel.getInstance();

  aboutToAppear(): void {
    console.log(TAG, `aboutToAppear, listLenght: ${this.viewModel.cardModelList.length}`);

    this.timerId = setTimeout(() => {
      this.viewModel.updateListData()
    }, 3000)
  }

  aboutToDisappear(): void {
    console.log(TAG, `onHidden`)
  }

  build() {
    Column() {
      Grid() {
        Repeat<CardModel>(this.viewModel.cardModelList).each((repeatItem) => {
          GridItem() {
            CardItemComponent({repeatCardObj: repeatItem})
          }
          .width('48%')
          .height('128vp')
        })
          .key((item) => JSON.stringify(item))
          .virtualScroll({})
      }
      .padding({
        left: '8vp',
        right: '8vp'
      })
      .columnsGap('8vp')
      .rowsGap('8vp')
      .columnsTemplate('1fr 1fr')
      .layoutDirection(GridDirection.Column)
      .width('100%')
      .height('100%')
    }
    .backgroundColor($r('sys.color.comp_background_gray'))
    .width('100%')
    .height('100%')
  }
}
  • CardItemComponent

    import { CardModel } from "./CardModel"
    import { util } from "@kit.ArkTS";
    const TAG: string = 'CardItemComponent'
    @ComponentV2
    export struct CardItemComponent {
    @Param @Require repeatCardObj: RepeatItem<CardModel>;
    
    aboutToAppear(): void {
      console.log(TAG, `aboutToAppear: ${this.repeatCardObj.item.name}`)
    }
    
    build() {
      Column({space: '8vp'}) {
        Row() {
          Image(this.repeatCardObj.item.icon)
            .width('50vp')
            .height('50vp')
            .interpolation(ImageInterpolation.None)
            .onClick((event) => {
              console.log(TAG, `onClick: itemId: ${this.repeatCardObj.item.id}, itemName: ${this.repeatCardObj.item.name}, itemHash: ${util.getHash(this.repeatCardObj.item)}`)
            })
        }
        .margin('8vp')
        .width('100%')
        .height('auto')
        .justifyContent(FlexAlign.Start)
    
        Text(this.repeatCardObj.item.name)
          .fontColor($r('sys.color.font_primary'))
          .fontSize('16vp')
          .margin({left: '8vp'})
    
        Text(this.repeatCardObj.item.desc)
          .fontColor($r('sys.color.font_secondary'))
          .fontSize('12vp')
          .margin({left: '8vp'})
      }
      .alignItems(HorizontalAlign.Start)
      .backgroundColor($r('sys.color.comp_background_list_card'))
      .borderRadius('16vp')
      .width('100%')
      .height('100%')
    }
    }
  • CardModel

    @ObservedV2
    export class CardModel {
    @Trace id: number = 0;
    
    @Trace icon: string | Resource = '';
    
    @Trace name: string = '';
    
    @Trace desc: string = '';
    }
  • CardViewModel

    import { ToastUtil } from "@pura/harmony-utils";
    import { CardModel } from "./CardModel";
    const ICON_LIST: Array<Resource> = []; // 自己本地初始化一个Resource列表 
    const MAX_LIST_LENGHT: number = 150000;
    const BATCH_SIZE: number = MAX_LIST_LENGHT/1000;
    @ObservedV2
    export class CardViewModel {
    private static instance: CardViewModel;
    @Trace cardModelList: Array<CardModel> = new Array<CardModel>();
    private timerId: number = 0;
    private updateCount: number = 0;
    public static getInstance(): CardViewModel {
      if (!CardViewModel.instance) {
        CardViewModel.instance = new CardViewModel();
      }
      return CardViewModel.instance;
    }
    constructor() {
      this.initCardModelList();
    }
    // 同步更新所有
    public updateListData() {
      let start: number = Date.now();
      // 模拟更新已有数据
      this.cardModelList.forEach((cardModel) => {
        let tempModel = new CardModel();
        tempModel.icon = ICON_LIST[CardViewModel.getRandomInt(0, ICON_LIST.length - 1)];
        cardModel.icon = tempModel.icon;
      });
      ToastUtil.showToast(`列表(${this.cardModelList.length})更新成功, 耗时 ${Date.now() - start} ms`);
    }
    initCardModelList() {
      for (let i = 0; i < MAX_LIST_LENGHT; i++) {
        let cardModel: CardModel = new CardModel();
        cardModel.id = i;
        cardModel.icon = ICON_LIST[CardViewModel.getRandomInt(0, ICON_LIST.length -1)]
        cardModel.name = `NameOfCard_${i}`
        cardModel.desc = `DescOfCard_${i}`
        this.cardModelList.push(cardModel)
      }
      ToastUtil.showToast(`列表(${this.cardModelList.length})初始化成功.`)
    }
    public static getRandomInt(min: number, max: number): number {
      min = Math.ceil(min);
      max = Math.floor(max);
      return Math.floor(Math.random() * (max - min + 1)) + min;
    }
    }

问题代码

// CardViewModel.ts
public updateListData() {
  this.cardModelList.forEach(cardModel => { // 全量遍历
    cardModel.icon = getRandomIcon(); // 同步更新所有对象属性
  });
}

从Profiler抓到的trace来看,foreach操作占据了大约900ms,在这期间,存在大量的丢帧情况,页面滑动直接无响应。当前模拟的场景150000条数据,每条数据只刷新一个属性,如果实际场景中对象结构复杂,需要刷新的属性多,可能耗时会更长。
45312-p0arha6yvxm.png

接下来的优化思路就很简单了,把这个大量数据的foreach操作拆分开,平均分摊到每一帧刷新之间,避免集中在某一时刻阻塞主线程渲染。

2.2 优化后的代码和trace:

  • CardListComponent:
import { CardItemComponent } from "./CardItemComponent";
import { CardModel } from "./CardModel";
import { CardViewModel } from "./CardViewModel"
import { displaySync } from "@kit.ArkGraphics2D";
import { ToastUtil } from "@pura/harmony-utils";
const TAG: string = 'CardListComponent'
@Entry
@ComponentV2
export struct CardListComponent {
  private timerId: number = -1;

  private readonly FRAME_60: number = 60;

  private readonly FRAME_120: number = 120;

  private displaySync: displaySync.DisplaySync | undefined = undefined;

  private refreshIndex: number = 0;  // 当前处理到的索引
  private isRefreshing: boolean = false; // 刷新状态标志
  private readonly batchSize: number = 100; // 每帧处理的卡片数量

  private startTime: number = 0;

  @Local viewModel: CardViewModel = CardViewModel.getInstance();

  aboutToAppear(): void {
    console.log(TAG, `aboutToAppear, listLenght: ${this.viewModel.cardModelList.length}`);

    // Creating a DisplaySync Object
    this.displaySync = displaySync.create();

    // Set the expected frame rate
    let range: ExpectedFrameRateRange = {
      expected: this.FRAME_120,
      min: this.FRAME_60,
      max: this.FRAME_120
    };
    this.displaySync.setExpectedFrameRateRange(range);

    ToastUtil.showToast(`3s后开始刷新数据, 启用分帧刷新: ${CardViewModel.getInstance().enableFrameUpdate}`)
    this.timerId = setTimeout(() => {
      console.log(TAG, `frameUpdate start`);
        this.startTime = Date.now();
        this.isRefreshing = true;
        // Enable frame callback listening
        this.displaySync?.on('frame', this.processBatch)
        this.displaySync?.start();

    }, 3000)
  }

  aboutToDisappear(): void {
    console.log(TAG, `onHidden`)
    this.stopBatchRefresh();
  }

  // 每帧处理一批数据
  private processBatch = () => {
    if (!this.isRefreshing) return;

    const startIdx = this.refreshIndex;
    const endIdx = Math.min(startIdx + this.batchSize, this.viewModel.cardModelList.length);
    this.viewModel.updateOnFrame(startIdx, endIdx);
    this.refreshIndex = endIdx;

    // 检查是否完成
    if (this.refreshIndex >= this.viewModel.cardModelList.length) {
      ToastUtil.showToast(`刷新结束, index: ${this.refreshIndex}, 耗时: ${Date.now() - this.startTime} ms`)
      this.stopBatchRefresh();
    }
  }

  // 停止刷新并清理
  stopBatchRefresh() {
    this.isRefreshing = false;
    this.displaySync?.off('frame', this.processBatch);
  }

  ...其他代码省略
}

CardViewModel:

   ... 其他代码省略
  // 分帧刷新
  updateOnFrame(startIdx: number, endIdx: number) {
    if (startIdx > this.cardModelList.length || endIdx > this.cardModelList.length) {
      console.log("CardViewModel", `enter return, startIdx: ${startIdx}, endIndex: ${endIdx}, length: ${this.cardModelList.length}`);
      return;
    }
    // 处理当前批次
    for (let i = startIdx; i < endIdx; i++) {
      const cardModel = this.cardModelList[i];
      const newIcon = ICON_LIST[CardViewModel.getRandomInt(0, ICON_LIST.length - 1)];
      cardModel.icon = newIcon;
    }
  }
  ...

可以看到,分帧刷新后,数据刷新从集中的900~1000ms均匀分摊到12s内,掉帧情况大大改善,滑动页面也感受不到卡顿的情况。
03082-xt3tu231lu.png

2.3 实践建议

  • 动态批处理:根据displaySync返回的帧间隔动态调整BATCH_SIZE

  • 内存管理:及时清理已完成批次的对象引用

  • 中断处理:页面隐藏时停止刷新任务

  • 性能平衡点:单帧处理量建议50-200条(需实测)

▶️ 优化本质:将同步阻塞操作 → 异步分帧执行,用时间换流畅度

有其他更优解决方案欢迎指导交流

参考:

高负载场景分帧渲染
应用多线程并发案例-ArkUI数据更新场景