简介

在应用开发中,有的页面需要加载大量的数据,就会导致组件数量较多或者嵌套层级较深,从而引起组件负载加重,绘制耗时增长,如果不进行合理的处理,可能引起卡顿掉帧等性能问题。

问题场景

在日历应用的开发中,全年的日期页面需要加载一年中的所有日期,这样就最少需要365个Text组件用于显示日期。一次性绘制这么多的组件,时间会比较久,而且会耗费大量的资源,如果手机配置较差,可能会引起明显的卡顿或者转场动画的掉帧现象。

解决思路

由于一次性加载大量数据、绘制大量组件会导致卡顿,那么减少加载的数据量就是一种解决方法。但是由于业务需求,需要加载的数据总量和绘制的组件数量是不能减少的,那么只能想办法将数据进行拆分,将和数据相关的组件分成多次进行绘制。ArkTS中提供了DisplaySync(可变帧率),支持开发者设置回调监听,可以在回调里做一些数据的处理,在每一帧中绘制少量的数据,减少卡顿或者转场动画的掉帧现象。

优化示例

常规代码

通常情况下,会在进入页面后开始加载数据,即在aboutToAppear()中加载所有数据,并通过LazyForEach绘制所有的组件。

@Entry
@Component
struct Direct {
  ...
  // 初始化日历中一年的数据
  initCalenderData() {
    // 添加自定义trace标签,用于在trace抓取结果中查看相关运行时间信息
    hiTraceMeter.startTrace('push_data_direct', 1);
    for (let i = 1; i <= 12; i++) {
      // 获取每个月的日数据
      const monthDay: number[] = getMonthDate(i, this.currentYear);
      const month: Month1 = {
        month: i + '月',
        num: i,
        days: monthDay
      }
      this.contentData.pushData(month);
    }
    hiTraceMeter.finishTrace('push_data_direct', 1);
  }

  aboutToAppear() {
	...
    this.initCalenderData();
  }

  build() {
    Column({ space: 12 }) {
      ...
      Grid() {
        LazyForEach(this.contentData, (monthItem: Month1) => {
          // 每个月的日期
          GridItem() {
            Flex({ wrap: FlexWrap.Wrap }) {
			  ...
              // 日期信息
              ForEach(monthItem.days, (day: number) => {
                Text(day.toString())
                  ...
              })
            }
 		...
 		}
    }
    ...
  }
}

在上面的代码中,要在页面上显示一年中的所有日期,在aboutToAppear()方法中,将每个月的信息放入到一个数组里面,并通过LazyForEach通知Grid进行绘制。编译运行后,通过SmartPerfHost工具,抓取Trace,并查看耗时和掉帧率,如图1所示。其中push_data_direct是自定义添加的Trace标签,可以看到加载数据的开始时间和耗时,Expected Timeline是期望绘制一帧的时间,Actual Timeline是实际绘制一帧的时间。

图1 直接加载所有数据Trace图

通过图中信息可以看到,在aboutToAppear()中直接加载全部数据时,实际上就是在一帧中绘制全部的日期组件。期望的一帧的耗时应该是8ms(120Hz刷新率),绘制全部组件的实际耗时大概是126ms,正常情况下这个时间内应该是绘制15帧左右,而在这段代码中只绘制了1帧,会引起比较严重的卡顿现象。

优化代码

通过DisplaySync中的帧回调方法,将数据拆分到每一帧中进行加载和绘制。

@Entry
@Component
struct EveryFrameMonth {
  ...
  private calenderDisplaySync: displaySync.DisplaySync | undefined = undefined;

  startDisplaySync() {
    // 设置期望帧率120帧
    let range: ExpectedFrameRateRange = {
      expected: 120,
      min: 0,
      max: 120
    };

	// 从1月份开始获取每个月的日期数据
    let current: number = 1;
    // 最多12个月
    const MAX: number = 12;
    let draw60 = (intervalInfo: displaySync.IntervalInfo) => {
      if (current <= MAX) {
        hiTraceMeter.startTrace('push_data_every_frame', 1000);
        // 获取current月的日期数据
        const monthDay: number[] = getMonthDate(current, this.currentYear);
        const month: Month1 = {
          month: current + '月',
          num: current,
          days: monthDay
        }
        this.contentData.pushData(month);
        current = current + 1;
        hiTraceMeter.finishTrace('push_data_every_frame', 1000);
      } else {
        // 加载完数据后停止回调,否则会一直消耗资源
        if (this.calenderDisplaySync) {
          this.calenderDisplaySync.stop();
        }
      }
    };

    this.calenderDisplaySync = displaySync.create();
    this.calenderDisplaySync.setExpectedFrameRateRange(range);
    this.calenderDisplaySync.on("frame", draw60);
    this.calenderDisplaySync.start();
  }

  aboutToAppear() {
    this.date.push(this.currentMonth); // 存入月份信息
    this.date.push(this.currentDay); // 存入日期信息
    this.date.push(this.currentWeekDay); // 存入周信息
    this.startDisplaySync();
  }

  aboutToDisappear(): void {
    // 页面销毁时停止帧回调监听,防止内存泄漏
    if (this.calenderDisplaySync !== undefined) {
      this.calenderDisplaySync.stop();
    }
  }

  build() {
    Column({ space: 12 }) {
      ...
      // 每个月的日期
      Grid() {
        LazyForEach(this.contentData, (monthItem: Month1) => {
          GridItem() {
            Flex({ wrap: FlexWrap.Wrap }) {
              ...
              ForEach(monthItem.days, (day: number) => {
                Text(day.toString())
                ...
              })
            }
            ...  
  }
}

在上面的代码中,aboutToAppear()方法中调用了startDisplaySync()方法,在startDisplaySync()中添加了帧回调的监听,并在每一帧回调中只加载一个月的日期数据。编译运行后,通过SmartPerfHost工具,抓取Trace,并查看耗时和掉帧率,如图2所示。其中push_data_every_frame是自定义添加的Trace标签,可以看到加载数据的开始时间和耗时,Expected Timeline是期望绘制一帧的时间,Actual Timeline是实际绘制一帧的时间。

图2 每帧加载一个月的数据

从图2中可以看到,将每个月的数据拆分到单独的帧中加载时,每一帧的实际耗时变短了——期望耗时是8ms,实际耗时14ms(实际每帧绘制时间不同,此处以第一帧举例)。但是,由于每一帧的实际耗时都比预期长,就会导致预期帧减少的问题,即图中Expected Timeline标签中的空白现象。那么可以按照这个思路做进一步优化,继续拆解每帧加载的数据量。

@Entry
@Component
struct EveryFrameHalfMonth {
  ...
  private calenderDisplaySync: displaySync.DisplaySync | undefined = undefined;

  startDisplaySync() {
    // 设置期望帧率
    let range: ExpectedFrameRateRange = {
      expected: 120,
      min: 0,
      max: 120
    };
	// 从1月份开始获取每个月的日期数据
    let current: number = 1;
    // 最多12个月
    const MAX: number = 12;
    let isAddNew: boolean = true;
    let draw60 = (intervalInfo: displaySync.IntervalInfo) => {
      if (current <= MAX) {
        hiTraceMeter.startTrace('push_data_every_frame_half_month', 1001);
        // 获取current月中所有的日期数据
        const monthDay: number[] = getMonthDate(current, this.currentYear);
        // 找出日期的中间数
        const centerNumber: number = Math.floor(monthDay.length / 2);
        if (isAddNew) {
          const temp: MonthDayDataSource = new MonthDayDataSource();
          // 取前半个月的日期数据
          const firstHalfMonth: number[] = monthDay.slice(0, centerNumber);
          temp.pushData(firstHalfMonth);
          const month: Month = {
            month: current + '月',
            num: current,
            days: temp
          }
          this.contentData.pushData(month);
          isAddNew = false;
        } else {
          // 取后半个月的日期数据
          const secondHalfMonth: number[] = monthDay.slice(centerNumber, monthDay.length - 1);
          // current从1开始计数,数组中的数据索引从0开始,所以这里获取数据时需要使用current-1
          this.contentData.getData(current - 1).days.pushData(secondHalfMonth);
          this.contentData.getData(current - 1).days.notifyDataChange(current - 1);
          isAddNew = true;
          current = current + 1;
        }
        hiTraceMeter.finishTrace('push_data_every_frame_half_month', 1001);
      } else {
        if (this.calenderDisplaySync) {
          this.calenderDisplaySync.stop();
        }
      }
    };

    this.calenderDisplaySync = displaySync.create();
    this.calenderDisplaySync.setExpectedFrameRateRange(range);
    this.calenderDisplaySync.on("frame", draw60);
    setTimeout(() => {
      if (this.calenderDisplaySync) {
        this.calenderDisplaySync.start();
      }
    }, 10)
  }

  aboutToAppear() {
    this.date.push(this.currentMonth); // 存入月份信息
    this.date.push(this.currentDay); // 存入日期信息
    this.date.push(this.currentWeekDay); // 存入周信息
    this.startDisplaySync();
  }

  aboutToDisappear(): void {
    // 页面销毁时停止帧回调监听,防止内存泄漏
    if (this.calenderDisplaySync !== undefined) {
      this.calenderDisplaySync.stop();
    }
  }

  // 自定义日历选取器内容
  build() {
    Column({ space: 12 }) {
      ...
      // 每个月的日期
      Grid() {
        LazyForEach(this.contentData, (monthItem: Month) => {
          // 设置ListItemGroup头部组件,显示年份和月份
          GridItem() {
            Flex({ wrap: FlexWrap.Wrap }) {
              ...
              LazyForEach(monthItem.days, (day: number) => {
                Text(day.toString())
                ...
              })
            }
            ...
  }
}

在上面这段代码中,将每个月的数据再次进行了拆分,每次只加载半个月的数据。编译运行后,通过SmartPerfHost工具,抓取Trace,并查看耗时和掉帧率,如图3所示。其中push_data_every_frame_half_month是自定义添加的Trace标签,可以看到加载数据的开始时间和耗时,Expected Timeline是期望绘制一帧的时间,Actual Timeline是实际绘制一帧的时间。

图3 每帧加载半个月的数据

从图中可以看到,除了第1帧和第2帧有所延迟,其他的帧都没有问题。其中,第1帧实际耗时比期望耗时多158μs左右,时间上的影响很小;通过push_data_every_frame_half_month标签可以看到,第1帧运行到一半时才开始加载数据,导致了第2帧的结束时间比预期要晚一点,实际上第2帧的绘制时间只有不到5ms,对性能的影响也很小。

总结

通过上面的示例代码和优化过程,可以看到在需要加载大量数据的页面,一次性全部加载时会引起比较严重的性能问题,一帧的绘制耗时很长,在性能较差的手机上可能会引起明显的卡顿掉帧现象;而将数据合理拆分后,可以有效减少帧绘制的耗时,从而减少卡顿掉帧现象的发生。

码牛课堂也为了积极培养鸿蒙生态人才,让大家都能学习到鸿蒙开发最新的技术,针对一些在职人员、0基础小白、应届生/计算机专业、鸿蒙爱好者等人群,整理了一套纯血版鸿蒙(HarmonyOS Next)全栈开发技术的学习路线。大家可以进行参考学习:https://qr21.cn/FV7h05

①全方位,更合理的学习路径
路线图包括ArkTS基础语法、鸿蒙应用APP开发、鸿蒙能力集APP开发、次开发多端部署开发、物联网开发等九大模块,六大实战项目贯穿始终,由浅入深,层层递进,深入理解鸿蒙开发原理!

②多层次,更多的鸿蒙原生应用
路线图将包含完全基于鸿蒙内核开发的应用,比如一次开发多端部署、自由流转、元服务、端云一体化等,多方位的学习内容让学生能够高效掌握鸿蒙开发,少走弯路,真正理解并应用鸿蒙的核心技术和理念。

③实战化,更贴合企业需求的技术点
学习路线图中的每一个技术点都能够紧贴企业需求,经过多次真实实践,每一个知识点、每一个项目,都是码牛课堂鸿蒙研发团队精心打磨和深度解析的成果,注重对学生的细致教学,每一步都确保学生能够真正理解和掌握。

为了能让大家更好的学习鸿蒙(HarmonyOS NEXT)开发技术,这边特意整理了《鸿蒙开发学习手册》(共计890页),希望对大家有所帮助:https://qr21.cn/FV7h05

《鸿蒙开发学习手册》:https://qr21.cn/FV7h05

如何快速入门:

  1. 基本概念
  2. 构建第一个ArkTS应用
  3. ……

开发基础知识:https://qr21.cn/FV7h05

  1. 应用基础知识
  2. 配置文件
  3. 应用数据管理
  4. 应用安全管理
  5. 应用隐私保护
  6. 三方应用调用管控机制
  7. 资源分类与访问
  8. 学习ArkTS语言
  9. ……

基于ArkTS 开发:https://qr21.cn/FV7h05

  1. Ability开发
  2. UI开发
  3. 公共事件与通知
  4. 窗口管理
  5. 媒体
  6. 安全
  7. 网络与链接
  8. 电话服务
  9. 数据管理
  10. 后台任务(Background Task)管理
  11. 设备管理
  12. 设备使用信息统计
  13. DFX
  14. 国际化开发
  15. 折叠屏系列
  16. ……

鸿蒙开发面试真题(含参考答案):https://qr21.cn/FV7h05

大厂鸿蒙面试题::https://qr18.cn/F781PH

鸿蒙开发面试大盘集篇(共计319页):https://qr18.cn/F781PH

1.项目开发必备面试题
2.性能优化方向
3.架构方向
4.鸿蒙开发系统底层方向
5.鸿蒙音视频开发方向
6.鸿蒙车载开发方向
7.鸿蒙南向开发方向

Logo

由 GitCode & CSDN 共同运营的 HarmonyOS 开发者社区,服务鸿蒙开发者,帮助开发者快速了解鸿蒙、学习鸿蒙、获取最新最全的鸿蒙开发者资料与体系课程,共同赋能鸿蒙生态。

更多推荐