为有牺牲多壮志,敢教日月换新天。

HarmonyOS:帧率和丢帧分析实践

★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★
➤博客园地址:为敢技术(https://www.cnblogs.com/strengthen/ 
➤GitHub地址:https://github.com/strengthen
➤原文地址:https://www.cnblogs.com/strengthen/p/18517566
➤如果链接不是为敢技术的博客园地址,则可能是爬取作者的文章。
➤原文已修改更新!强烈建议点击原文地址阅读!支持作者!支持原创!
★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★

  

丢帧问题概述

应用丢帧通常指的是在应用程序的界面绘制过程中,由于某些原因导致界面绘制的帧率下降,从而造成界面卡顿、动画不流畅等问题。以60Hz刷新率为例子,想要达到每秒60帧(即60fps)的流畅体验,每一帧需要在16.7ms内完成,如果超过16.7ms未完成渲染,就可能会出现丢帧。

本文主要是以Trace数据为切入点进行分析,相应的工具可以使用DevEco Studio内置的Frame分析,若开发者需要补充Trace相关知识,可以参考Trace打点信息说明等应用开发文档。

本文将主要介绍以下内容,来帮助开发者解决应用丢帧问题:

  • 丢帧问题原理
  • 发现丢帧问题
  • 丢帧问题分析
  • 常见丢帧问题
  • 丢帧问题优化建议

丢帧问题原理

在了解如何定位应用丢帧问题之前,开发者需要简单了解HarmonyOS中图形渲染的流程,便于在遇到卡顿时可以分析卡顿可能出现的阶段和原因。

在HarmonyOS中,图形系统采用了统一渲染的模式,遵循着一个典型的流水线模式,以90Hz刷新率为例,每个Vsync周期是11.1ms,整个过程如下图所示。如果是60Hz,每个Vsync的周期是16.7ms;如果是120Hz,则每个Vsync的周期是8.3ms。

图1 90Hz刷新率渲染流程

在整个渲染流程中,首先是由应用侧响应消费者的屏幕点击等输入事件,由应用侧处理完成后再提交给Render Service,由Render Service协调GPU等资源处理后,再将最终的图像统一送到屏幕上进行显示。

  1. 应用侧(App)处理用户的屏幕点击等输入事件,生成当前界面描述的数据结构。其中,界面描述数据包括UI元素的位置,大小,资源,UI元素的绘制指令,动效属性等。
  2. Render Service(渲染服务部件)是图形栈中负责界面内容绘制的模块,其主要职责就是对接ArkUI框架,支撑ArkUI应用的界面显示,包括控件、动效等UI元素。Render Service的RenderThread线程在Vsync下触发UI绘制,绘制过程包含3个阶段:Animation动效,Draw描画和Flush提交。
  3. Display是显示屏幕的抽象概念,可以是实际的物理屏也可以是虚拟屏。

其中应用侧的渲染流程如下图所示,了解ArkUI的渲染流程有助于我们定位应用侧的卡顿问题出现在哪个环节:

图2 ArkUI渲染管线结构与Frame Insight性能打点

  • Animation:动画阶段,在动画过程中会修改相应的FrameNode节点触发脏区标记,在特定场景下会执行用户侧ets代码实现自定义动画;
  • Events:事件处理阶段,比如手势事件处理。在手势处理过程中也会修改FrameNode节点触发脏区标记,在特定场景下会执行用户侧ets代码实现自定义事件;
  • UpdateUI:自定义组件(@Component)在首次创建挂载或者状态变量变更时会标记为需要rebuild状态,在下一次Vsync过来时会执行rebuild流程,rebuild流程会执行程序UI代码,通过调用View的方法生成相应的组件树结构和属性样式修改任务。
  • Measure:布局包装器执行相关的大小测算任务。
  • Layout:布局包装器执行相关的布局任务。
  • Render:绘制任务包装器执行相关的绘制任务,执行完成后会标记请求刷新RSNode绘制
  • SendMessage:请求刷新界面绘制。

在整个处理流程中,应用侧和Render Service侧都有可能出现卡顿导致最终用户观测到丢帧的可能,我们分别将这两种情况命名为AppDeadlineMissed和RenderDeadlineMissed。一般而言,前者可能是应用逻辑处理代码不够高效导致的,后者可能是界面结构过于复杂或者GPU负载过大等原因导致的。这两个故障模型通过Frame模板都可以直观地看到。相应的故障模型如下面两幅图所示。

图3 应用卡顿导致丢帧的故障模型

图4 Render Service卡顿导致丢帧的故障模型

丢帧问题思路分析

补充了图像渲染流程的基本知识和丢帧的故障模型后,接下来我们介绍丢帧问题的分析思路,下图展示了解决丢帧问题的简要流程:

图5 丢帧问题处理流程

从上图可以看到处理丢帧问题一般需要以下四个步骤:

  1. 识别卡顿:首先使用AppAnalyzer检测应用是否存在性能问题,如果检测存在丢帧问题,然后使用Frame Profiler、SmartPerf Host等工具录制Trace,查看应用平均帧率、丢帧率等,同时查看丢帧发生的位置。
  2. 分析丢帧原因:首先查看CPU调用判断系统是否存在异常,如果判断系统异常开发可以通过在线提单的方式进行反馈;如果系统没有异常,可以继续分析Trace查看卡顿帧的详细信息。最后查看函数调用栈,查看是否存在耗时函数。
  3. 选择优化方案:根据步骤2分析的丢帧原因,选择适合的优化方案。
  4. 验证优化效果:优化完成后需要重新测试验证丢帧问题是否得到解决,这里可以再次通过步骤1来确认优化效果

接下来本文将以“HMOS世界”应用的首页列表为例,介绍如何通过Frame分析、定位、解决卡顿问题的全过程。为了便于演示这个长列表的调优过程,这个列表初始加载了1000条数据。

我们在滑动列表的过程中,随着时间的推移,我们可以感觉到越来越卡顿,接下来我们将介绍如何分析并解决这个卡顿问题。

图6 ”HMOS世界”首页长列表示意图

第1步:识别丢帧

使用AppAnalyzer检测性能问题

首先使用AppAnalyzer工具进行性能问题检测,AppAnalyzer是DevEco Studio中提供的检测评分工具,用于测试并评价HarmonyOS应用或元服务的质量,能快速提供评估结果和改进建议,当前支持的测试类型包括兼容性、性能、UX测试和最佳实践等。因为本文主要是介绍丢帧问题的分析,所以下面重点介绍了使用AppAnalyzer对列表滑动响应和滑动过程中的流畅性能检测,具体使用可参考《应用与服务体检》

  1. 启动DevEco Studio,连接设备,打开应用。
    1. 单击菜单栏Tools > AppAnalyzer。
    2. 在AppAnalyzer页面Module选择框选择应用/服务工程模块。
    3. 根据应用的类别选择Category。
    4. 选择Rules,这里选择Benchmark(性能套餐),勾选”Fast Response to In-app Swipes”(应用内滑动操作响应快)、“Smooth In-app Swiping“(应用内滑动过程流畅)和“Smooth In-app Transitions“(应用内转场操作流畅)。

  2. 点击Start启动检测,检测过程中,手机需要保持解锁亮屏状态。
    1. 工具会先对应用功能进行自动检测,开发者不需要进行操作,在自动检测结束后需要根据提示手动遍历应用功能。

    2. 自动检测和手动遍历完成后点击Stop停止测试任务。

  3. 获得检测结果,下面列举了检测通过和未通过的示例。
    • 检测通过示例,如下图所示,无异常信息。

    • 检测未通过示例,例如下图结果,有多项检测未通过。

      点击左侧的菜单栏对应的选项,可以查看异常的具体信息,这里以”Fast Detection Of Smoothness During Sliding”选项为例,应用滑动时的卡顿率应该小于5ms/s,但是示例中有多帧超时达到8.49ms,存在进一步优化的空间,关于应用滑动流畅的体验标准可以参考应用/服务体检规则

录制Frame模板

发现卡顿丢帧问题后创建Frame模板录制,在录制期间复现卡顿丢帧场景,具体操作步骤请参见性能问题定位:深度录制

录制完成后,在时间轴上拖动鼠标选定要查看的时间段,这里选择了一个2.5s的时间区段。选中Frame主泳道,查看下面的Statistics栏,可以发现应用在这个时间段内丢了16帧,丢帧率达到了7%。

认识卡顿帧

下面是使用Frame Profiler录制的一段Trace,在时间轴上拖动鼠标选定要查看的时间段,这里我们选择了一个2.5s的时间区段。选中Frame主泳道,查看下面的Statistics栏,可以发现应用在这个时间段内丢了16帧,丢帧率达到了7%。

丢帧问题可能出现在Render Service侧,也有可能出现在App侧。上图中的丢帧主要出现在应用帧,针对这种丢帧现象我们继续分析,放大右侧的图表,选中超时的帧查看详细数据,期望时间为8.3ms(当前设备为120Hz),而实际处理时间为8.9ms。

说明

在“RS Frame”和“App Frame”标签的泳道中,正常完成渲染的帧显示为绿色,出现卡顿的帧显示为红色。其中期望结束时间点之前的部分为浅红色(两条白色竖线区间),超出期望结束时间的部分为深红色,异常帧显示为黄色。

发现问题后,接下来我们来分析这个丢帧问题。导致应用丢帧的原因非常多,可能是应用本身原因,可能是系统原因,也有可能是硬件层原因。不同卡顿原因在Trace中有不同表现,识别需要大量经验积累。

第2步:分析丢帧原因

丢帧问题分析过程,主要是结合App主进程和Render Service渲染进程Trace数据,先排查系统是否异常,再分析应用本身原因,开发者可以通过以下步骤来定位丢帧问题。

2.1 看线程状态和运行核,看是否被其他进程抢占资源,排除系统侧运行异常。

看线程状态

从下图可以看到,应用线程大部分时间处于Running状态,无特殊异常。运行在CPU10和CPU11上

图7 丢帧处应用主线程状态

看运行频率

查看关键任务是否跑在了小核,以低频运行。从CPU Slice和Frequency泳道,如图8所示,可以看到丢帧处应用线程和前面正常帧类似,都主要运行在大核上(该设备0~3号CPU是小核,4~11号CPU为大核)。鼠标悬浮在Frequency泳道上,可以看到CPU运行频率。

图8 丢帧处应用主线程运行核

通过上面的分析,可以看到应用线程正常运行在CPU大核上,且运行频率正常。到这里,这个示例可以排除系统异常。

如果应用线程运行出现以下问题,开发者可以进行在线提单反馈异常。

  • 执行频率较低
  • 线程在小核上工作
  • 线程频繁在Running和Runnable之间切换
  • 线程频繁在Running和Sleep之间切换
  • 不重要的线程占用了大核
说明

出于兼顾高性能、低功耗的需求,多核工程机常采用异构架构设计,根据CPU频率,区分大中小核等。

2.2找到Trace中每一帧耗时的部分,大致定位是App侧问题还是RS侧问题,并结合Trace标签,初步定位原因。

通过Frame泳道,我们可以快速发现丢帧的位置,并完成初步的定界:

  • App侧有红色出现,需要审视UI线程的处理逻辑是否过于复杂或低效,以及是否被其它任务抢占资源。
  • 如果是Render Service帧处理有红色出现,需要审视是否是界面布局过于复杂。可以借助DevEco Studio内的ArkUI Inspector、HiDumper等工具进一步分析,可以参考布局嵌套过深示例。

前面示例中的丢帧主要出现在应用侧,针对这种丢帧现象我们继续分析,放大右侧的图表,选中超时的帧(220#帧)查看详细数据,期望时间为8.3ms(当前设备为120Hz),而实际处理时间为8.9ms。

接下来通过Trace再看看每一帧的具体耗时情况。这里有一个小技巧,我们可以点击泳道信息区的收藏按钮,将应用帧处理的泳道收藏置顶,可以有效防止上下文信息丢失。点击图标跳转到卡顿帧应用侧Trace详情,如下图所示:

可以看到这这几帧的卡顿可能都是BuildLazyItem方法耗时较长导致,可以大致推测,是列表懒加载时,Item绘制时间较长导致的。

同时在ArkUI Component泳道上,可以直观的看到,自定义组件ArtileView的绘制频率比较高且比较耗时,对于太过频繁的绘制组件,可能也是影响应用丢帧的原因。

需要注意的是在Frame模板中,要想查看ArkUI Component泳道需要在泳道录制前进行手动勾选,如下图所示:

2.3查看ArkTS函数调用栈信息,排查应用代码。

可以结合Frame Profiler工具,选择ArkTS Callstack泳道查看热点函数,方便地跳回源码,定位具体是哪一个自定义组件绘制时间较长。如下图所示,可以看到自定义组件ArticleCardView的绘制频繁。下面以220#帧为例子,通过热点函数可以看到其中initialRenderView 和__lazyForEachItemGenFunction这两个方法比较耗时,占比分别达到52.7%和22.9%,其中绿色的”ArkTS”表示双击该行可以跳转到应用源码。

我们以initialRenderView函数的耗时为例进行分析,展开函数,可以看到主要是列表项ListItem的子组件ArticleCardView创建比较耗时。

展开其中一个组件函数调用链进行详细分析,通过查看函数调用,可以猜测是由于使用了@Prop变量,@Prop装饰的变量会对父组件传入状态值进行深拷贝,如果@Prop装饰器装饰的变量为复杂Object、class或其类型数组时,会增加状态创建时间以及占用大量内存。双击跳转到源码,可以看到自定义组件ActionButtonView中确实使用了@Prop装饰器变量。

其它函数耗时的详细调用这里不一一列举。

第3步:选择优化方案

选择优化方案需要一些经验的积累,开发者可以参考一些性能优化的最佳实践,来选择相应的优化方法。

下面我们对丢帧问题进行优化,针对前面的一些分析结果,我们可以从两方面来入手解决卡顿问题:

  • 使用组件复用能力@Reusable来减少组件的频繁创建。可复用组件从组件树上移除时,会进入到一个回收缓存区。后续创建新组件节点时,会复用缓存区中的节点,节约组件重新创建的时间。
  • 简化组件创建的逻辑,使用更高效的@Builder来构建列表项Item的子组件,替代原有@Component自定义组件的方式。此外使用@Builder以后,就不需要使用@Prop了,从而减少了数据的深拷贝耗时。

优化后示例代码如下:

@Component
struct DiscoverView {
  // ...
  build() {
    // 列表
    List() {
      LazyForEach(this.dataSource, (item: LearningResource) => {
        ListItem() {
          ArticleCardView() // 省略参数
            .reuseId('article')
        }
      }, (item: LearningResource) => item.id)
    }
  }
}

// 列表Item
@Reusable
@Component
export struct ArticleCardView {
  // ...
  aboutToReuse(params: Record<string, Object>): void {
    // ...
  }
  Row() {
    ActionButtonBuilder() // 省略参数
    ActionButtonBuilder()
    ActionButtonBuilder()
  }
  build() {
    // ...
  }
}

// 使用@Builder构建子组件
@Builder
function ActionButtonBuilder() {  // 省略参数
  // ...
}

第4步:验证优化效果

最后,我们可以使用步骤一的方式,来验证优化后的结果。下面用Frame模板录制后发现,丢帧情况得到明显改善,列表快速滑动15.9s,丢帧率为0%,丢帧问题得到解决。如下图:

如果此时问题仍未解决,可以再重新分析Trace定位问题,然后选择优化方式。

常见丢帧问题

下面列举了一些常见的丢帧问题以及对应的Trace,以及给出了一些优化方案,便于开发者遇到类似的问题,访问识别和定位。

自定义动画丢帧问题

在播放动画或者生成动画时,画面产生停滞而导致帧率过低的现象,称为动画丢帧。

播放动画时,系统需要在一个刷新周期内完成动画变化曲线的计算,完成组件布局绘制等操作。建议使用系统提供的动画接口,只需设置曲线类型、终点位置、时长等信息,就能够满足常用的动画功能,减少UI主线程的负载。

下面使用了自定义动画,动画曲线计算过程很容易引起UI线程高负载,易导致丢帧。 

@Entry
@Component
struct AnimationDemo1 {
  @State widthSize: number = 200;
  @State heightSize: number = 100;
  @State flag: boolean = true;

  computeSize() {
    let duration = 2000;
    let period = 16;
    let widthSizeEnd = 0;
    let heightSizeEnd = 0;
    if (this.flag) {
      widthSizeEnd = 100;
      heightSizeEnd = 50;
    } else {
      widthSizeEnd = 200;
      heightSizeEnd = 100;
    }
    let doTimes = duration / period;
    let deltaHeight = (heightSizeEnd - this.heightSize) / doTimes;
    let deltaWeight = (widthSizeEnd - this.widthSize) / doTimes;
    for (let i = 1; i <= doTimes; i++) {
      let t = period * (i);
      setTimeout(() => {
        this.heightSize = this.heightSize + deltaHeight;
        this.widthSize = this.widthSize + deltaWeight;
      }, t);
    }
    this.flag = !this.flag;
  }

  build() {
    Column() {
      Button('click me')
        .onClick(() => {
          let delay = 500;
          setTimeout(() => {
            this.computeSize();
          }, delay);
        })
        .width(this.widthSize)
        .height(this.heightSize)
        .backgroundColor(0x317aff)
    }.width('100%')
    .margin({ top: 5 })
  }
}

使用Frame Profiler录制Trace,可以看到动画帧率只有63fps左右,而当前设备是支持的设备刷新率是120Hz。

建议开发者通过系统提供的属性动效API实现上述动效功能,使用属性动画或者显式动画,下面以属性动画实现上面的效果为例:

@Entry
@Component
struct AnimationDemo2 {
  @State widthSize: number = 200;
  @State heightSize: number = 100;
  @State flag: boolean = true;

  build() {
    Column() {
      Button('click me')
        .onClick(() => {
          if (this.flag) {
            this.widthSize = 100;
            this.heightSize = 50;
          } else {
            this.widthSize = 200;
            this.heightSize = 100;
          }
          this.flag = !this.flag;
        })
        .width(this.widthSize)
        .height(this.heightSize)
        .backgroundColor(0x317aff)
        .animation({
          duration: 2000, // 动画时长
          curve: Curve.Linear, // 动画曲线
          delay: 500, // 动画延迟
          iterations: 1, // 播放次数
          playMode: PlayMode.Normal // 动画模式
        }) // 对Button组件的宽高属性进行动画配置
    }
    .width('100%')
    .margin({ top: 5 })
  }
}

使用Frame Profiler录制优化后的Trace,可以看到动画帧率有了较大的提升,达到了116.9fps。

布局嵌套过深

视图的嵌套层次会影响应用的性能。在屏幕刷新率为120Hz的设备上,每8.3ms刷新一帧,如果视图的嵌套层次多,可能会导致没法在8.3ms内完成一次屏幕刷新,就会造成丢帧卡顿,影响用户体验。因此推荐开发者移除多余的嵌套层次,使用相对布局 (RelativeContainer),缩短组件刷新耗时。

例如下面这个示例在列表中加载了2000条数据,同时子组件ChildComponent里面的布局嵌套了20层Stack组件。

class MyDataSource implements IDataSource {
  private dataArray: string[] = [];

  public pushData(data: string): void {
    this.dataArray.push(data);
  }

  public totalCount(): number {
    return this.dataArray.length;
  }

  public getData(index: number): string {
    return this.dataArray[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
  }
}

@Entry
@Component
struct StackDemo1 {
  // ... 此处省略LazyForEach数据初始化过程

  private data: MyDataSource = new MyDataSource();

  build() {
    List() {
      LazyForEach(this.data, (item: string) => {
        ListItem() {
          ChildComponent({ item: item })
        }
        .reuseId('child')
      }, (item: string) => item)
    }.cachedCount(5)
  }
}

@Reusable
@Component
struct ChildComponent {
  @State item: string = '';

  aboutToReuse(params: Record<string, Object>): void {
    this.item = params.item as string;
  }

  build() {
    Stack() {
      Stack() {
        // ... 此处省略Stack嵌套
        Text(this.item)
          .fontSize(50)
          .margin({ left: 10, right: 10 })
      }
      // ...
    }
  }
}

使用Frame Profiler进行录制,这里我们直接看应用侧的Trace数据,具体分析步骤可以看前面的丢帧问题分析思路章节

 

结合卡顿帧对应时间段的Trace数据,可以定位到FlushLayoutTask耗时过长,它的作用是重新测量和布局所有的Item,其中Measure方法耗时比较久,因此卡顿原因可能是布局处理逻辑过于复杂或低效。

开发者可以使用ArkUI Inspector,在DevEco Studio上查看应用在真机上的UI显示效果。利用ArkUI Inspector工具,开发者可以快速定位布局问题或其他UI相关问题,效果图如下:

可以直观的看到Item的嵌套比较深,接下来我们可以减少不必要的嵌套来尝试解决丢帧问题,示例代码如下:

@Reusable
@Component
struct ChildComponent {
  @State item: string = '';

  aboutToReuse(params: Record<string, Object>): void {
    this.item = params.item as string;
  }

  build() {
    Stack() {
      Text(this.item)
        .fontSize(50)
        .margin({ left: 10, right: 10 })
    }
  }
}

再次使用Frame Profiler进行录制,可以看到丢帧问题已得到解决。

UI冗余刷新

自定义组件中的变量被状态装饰器(@State,@Prop等)装饰后成为状态变量,而状态变量的改变会引起使用该变量的UI组件渲染刷新。状态变量的不合理使用可能会带来冗余刷新等性能问题。开发者可以使用状态变量组件定位工具(hidumper)获取状态管理相关信息,例如自定义组件拥有的状态变量、状态变量的同步对象和关联组件等,了解状态变量影响UI的范围,写出高性能应用代码。

下面通过一个点击按钮更改状态变量引起组件刷新的场景示例,结合hidumper工具,介绍状态变量使用范围不当,导致UI冗余刷新的问题定位。

在以下代码中,创建了自定义组件ComponentA、SpecialImage,每个组件都拥有一些状态变量和UI组件。组件ComponentA中存在Move和Scale两个按钮,在按钮的点击回调中改变状态变量的值刷新相应的组件。

// 常量声明
const animationDuration: number = 500; // move动画时长
const opacityChangeValue: number = 0.1; // opacity每次变化的值
const opacityChangeRange: number = 1; // opacity变化的范围
const translateYChangeValue: number = 180; // translateY每次变化的值
const translateYChangeRange: number = 250; // translateY变化的范围
const scaleXChangeValue: number = 0.6; // scaleX每次变化的值
const scaleXChangeRange: number = 0.8; // scaleX每次变化的值

// 样式属性类
class UIStyle {
  public translateX: number = 0;
  public translateY: number = 0;
  public scaleX: number = 0.3;
  public scaleY: number = 0.3;
}

@Component
struct ComponentA {
  @Link uiStyle: UIStyle; // uiStyle的属性被多个组件使用

  build() {
    Column() {
      // 使用状态变量的组件
      SpecialImage({ specialImageUiStyle: this.uiStyle })
      Stack() {
        Column() {
          Image($r('app.media.app_icon'))
            .height(78)
            .width(78)
            .scale({
              x: this.uiStyle.scaleX,
              y: this.uiStyle.scaleY
            })
        }

        Stack() {
          Text('Hello World')
        }
      }
      .translate({
        x: this.uiStyle.translateX,
        y: this.uiStyle.translateY
      })

      // 通过按钮点击回调修改状态变量的值,引起相应的组件刷新
      Column() {
        Button('Move')
          .onClick(() => {
            animateTo({ duration: animationDuration }, () => {
              this.uiStyle.translateY = (this.uiStyle.translateY + translateYChangeValue) % translateYChangeRange;
            })
          })
        Button('Scale')
          .onClick(() => {
            this.uiStyle.scaleX = (this.uiStyle.scaleX + scaleXChangeValue) % scaleXChangeRange;
          })
      }
    }
  }
}

@Component
struct SpecialImage {
  @Link specialImageUiStyle: UIStyle;
  private opacityNum: number = 0.5; // 默认透明度

  private isRenderSpecialImage(): number {
    // Image每次渲染时透明度增加0.1, 在0-1之间循环
    this.opacityNum = (this.opacityNum + opacityChangeValue) % opacityChangeRange;
    return this.opacityNum;
  }

  build() {
    Column() {
      Image($r('app.media.app_icon'))
        .size({ width: 200, height: 200 })
        .scale({
          x: this.specialImageUiStyle.scaleX,
          y: this.specialImageUiStyle.scaleY
        })
        .opacity(this.isRenderSpecialImage())
      Text("SpecialImage")
    }
  }
}

@Entry
@Component
struct DFXStateBeforeOptimization {
  @State uiStyle: UIStyle = new UIStyle();

  build() {
    Column() {
      ComponentA({
        uiStyle: this.uiStyle
      })
    }
    .width('100%')
    .height('100%')
  }
}

运行上述示例并分别点击按钮,可以看到点击Move按钮和Scale按钮时组件SpecialImage都出现了刷新,运行效果图如下。

图9 修改代码前点击Scale按钮和Move按钮时运行动图

点击Move按钮的时候SpecialImage组件却发生了旋转动画,这就造成了冗余刷新。下面通过这个示例代码结合hidumper工具来介绍冗余刷新的问题定位。

1. 首先在设备上打开应用,进入ComponentA组件所在的页面。

2. 使用以下命令获取示例应用的窗口Id。当前运行的示例应用包名为performancelibrary,可以在输出结果中找到对应窗口名performancelibrary0的WinId,即为应用的窗口Id。或者当应用正处于前台运行时,Focus window的值就是应用的窗口Id。此处示例应用的窗口Id为11,后面的流程中使用的命令都需要指定窗口Id。

hdc shell "hidumper -s WindowManagerService -a '-a'"
图10 命令行获取应用窗口Id运行界面

3. 基于上一步获取的窗口Id 11,使用-viewHierarchy命令携带-r 参数递归打印应用的自定义组件树。

hdc shell "hidumper -s WindowManagerService -a '-w 11 -jsdump -viewHierarchy -r'"

打印应用的自定义组件树结果如下:

-----------------ViewPU Hierarchy-----------------  
[-viewHierarchy, viewId=4, isRecursive=true]  
|--Index[4]  
-----------------ViewPU Hierarchy-----------------  
[-viewHierarchy, viewId=53, isRecursive=true]  
|--DFXStateManagementPage[53]  
  |--DFXStateManagementHome[55]  
-----------------ViewPU Hierarchy-----------------  
[-viewHierarchy, viewId=65, isRecursive=true]  
|--DFXStateBeforeOptimizationPage[65]  
  |--DFXStateBeforeOptimization[67]  
    |--ComponentA[70]  
      |--SpecialImage[73]

从结果中找到目标组件ComponentA,后面括号中的内容即为组件ComponentA的节点Id 70。

4. 使用命令-stateVariables携带参数-viewId(参数的值为ComponentA的节点Id)获取自定义组件ComponentA中的状态变量信息。

hdc shell "hidumper -s WindowManagerService -a '-w 11 -jsdump -stateVariables -viewId=70'"

打印组件ComponentA的状态变量信息如下:

--------------ViewPU State Variables--------------  
[-stateVariables, viewId=70, isRecursive=false]  
|--ComponentA[70]  
  @Link/@Consume (class SynchedPropertyTwoWayPU) 'uiStyle'[71]  
  |--Owned by @Component 'ComponentA'[70]  
  |--Sync peers: {  
    @Link/@Consume (class SynchedPropertyTwoWayPU) 'specialImageUiStyle'[74] <@Component 'SpecialImage'[73]>  
  }  
  |--Dependent components: 2 elmtIds: 'Stack[75]', 'Image[77]'

结果显示ComponentA拥有@Link/@Consume类型的状态变量uiStyle。每条状态变量的详细信息都包含状态变量的所属组件、同步对象和关联组件。

5. 以状态变量uiStyle为例。

① Sync peers表示uiStyle在自定义组件SpecialImage中存在@Link/@Consume类型的状态变量specialImageUiStyle订阅数据变化。

② Dependent components表示在ComponentA组件中存在组件Stack[75]和Image[77]使用了状态变量uiStyle,关联组件的数量为2。

所以当uiStyle变化时,影响的组件范围为自定义组件SpecialImage以及系统组件Stack[75]和Image[77]。

图11 ComponentA的状态变量信息

示例中组件SpecialImage仅使用了uiStyle传递到specialImageUiStyle中的属性scaleX、scaleY。但点击Move按钮修改uiStyle中的属性translateY时,引起的uiStyle变化也会导致组件SpecialImage的刷新。所以,可以将uiStyle中的属性scaleX、scaleY提取到状态变量scaleStyle中,属性translateX和translateY提取到状态变量translateStyle中,仅传递scaleStyle给组件SpecialImage,避免不必要的刷新。

由于提取后存在Class的嵌套,因此需要使用@Observed/@ObjectLink装饰器装饰相应的Class和状态变量。修改后的部分代码如下:

// 常量声明
const animationDuration: number = 500; // move动画时长
const opacityChangeValue: number = 0.1; // opacity每次变化的值
const opacityChangeRange: number = 1; // opacity变化的范围
const translateYChangeValue: number = 180; // translateY每次变化的值
const translateYChangeRange: number = 250; // translateY变化的范围
const scaleXChangeValue: number = 0.6; // scaleX每次变化的值
const scaleXChangeRange: number = 0.8; // scaleX每次变化的值

// 样式属性类,嵌套ScaleStyle, TranslateStyle
@Observed
class UIStyle {
  translateStyle: TranslateStyle = new TranslateStyle();
  scaleStyle: ScaleStyle = new ScaleStyle();
}

// 缩放属性类
@Observed
class ScaleStyle {
  public scaleX: number = 0.3;
  public scaleY: number = 0.3;
}

// 位移属性类
@Observed
class TranslateStyle {
  public translateX: number = 0;
  public translateY: number = 0;
}

@Component
struct ComponentA {
  @ObjectLink scaleStyle: ScaleStyle;
  @ObjectLink translateStyle: TranslateStyle;

  build() {
    Column() {
      SpecialImage({
        specialImageScaleStyle: this.scaleStyle
      })
      // 其他UI组件
      Stack() {
        Column() {
          Image($r('app.media.app_icon'))
            .scale({
              x: this.scaleStyle.scaleX,
              y: this.scaleStyle.scaleY
            })
        }

        Stack() {
          Text('Hello World')
        }
      }
      .translate({
        x: this.translateStyle.translateX,
        y: this.translateStyle.translateY
      })

      // 通过按钮点击回调修改状态变量的值,引起相应的组件刷新
      Column() {
        Button('Move')
          .onClick(() => {
            animateTo({ duration: animationDuration }, () => {
              this.translateStyle.translateY =
                (this.translateStyle.translateY + translateYChangeValue) % translateYChangeRange;
            })
          })
        Button('Scale')
          .onClick(() => {
            this.scaleStyle.scaleX = (this.scaleStyle.scaleX + scaleXChangeValue) % scaleXChangeRange;
          })
      }
    }
  }
}

@Component
struct SpecialImage {
  @Link specialImageScaleStyle: ScaleStyle;
  private opacityNum: number = 0.5; // 默认透明度

  // isRenderSpecialImage函数
  private isRenderSpecialImage(): number {
    // Image每次渲染时透明度增加0.1, 在0-1之间循环
    this.opacityNum = (this.opacityNum + opacityChangeValue) % opacityChangeRange;
    return this.opacityNum;
  }

  build() {
    Column() {
      Image($r('app.media.app_icon'))
        .scale({
          x: this.specialImageScaleStyle.scaleX,
          y: this.specialImageScaleStyle.scaleY
        })
        .opacity(this.isRenderSpecialImage())
      Text("SpecialImage")
    }
  }
}

@Entry
@Component
struct DFXStateAfterOptimization {
  @State uiStyle: UIStyle = new UIStyle();

  build() {
    Stack() {
      ComponentA({
        scaleStyle: this.uiStyle.scaleStyle,
        translateStyle: this.uiStyle.translateStyle,
      })
    }
  }
}

修改后的示例运行效果图如下,只有点击Scale按钮时SpecialImage产生刷新现象,点击Move按钮时SpecialImage不会刷新。

图12 修改代码后点击Scale按钮和Move按钮时运行动图

可以使用上文步骤再次获取ComponentA组件的状态变量信息如下,可以看到ComponentA中状态变量scaleStyle影响组件SpecialImage[74]和Image[78],状态变量translateStyle影响组件Stack[76],translateStyle的变化不会再导致SpecialImage的刷新。

--------------ViewPU State Variables--------------  
[-stateVariables, viewId=70, isRecursive=false]  
  
|--ComponentA[70]  
  @ObjectLink (class SynchedPropertyNestedObjectPU) 'scaleStyle'[71]  
  |--Owned by @Component 'ComponentA'[70]  
  |--Sync peers: {  
    @Link/@Consume (class SynchedPropertyTwoWayPU) 'specialImageScaleStyle'[75] <@Component 'SpecialImage'[74]>  
  }  
  |--Dependent components: 1 elmtIds: 'Image[78]'  
  @ObjectLink (class SynchedPropertyNestedObjectPU) 'translateStyle'[72]  
  |--Owned by @Component 'ComponentA'[70]  
  |--Sync peers: none  
  |--Dependent components: 1 elmtIds: 'Stack[76]'

主线程中执行冗余和耗时操作

应避免在主线程中执行冗余与易耗时操作,否则可能会阻塞UI渲染,引发界面卡顿或掉帧现象,特别是在高频回调中执行耗时操作。具体可以参考:主线程耗时操作优化指导

丢帧问题优化建议

前面我们简单介绍了图形渲染的流程,了解到了图像渲染的两个关键步骤:首先由应用侧响应消费者的屏幕点击等输入事件并且生成当前的界面描述数据结构,然后交给Render Service进行绘制。在这两个步骤中分别会出现AppDeadlineMissed和RenderDeadlineMissed卡顿。前者可能是应用逻辑处理代码不够高效导致的,可以结合Trace数据和热点函数进行分析;后者可能是界面结构过于复杂或者GPU负载过大等原因导致的,可以使用布局检查器ArkUI Inspector工具和HiDumper命令行工具辅助分析定位。

针对一些常见的丢帧问题,下面列举了一些优化建议:

  

posted @ 2024-10-31 13:33  为敢技术  阅读(19)  评论(0编辑  收藏  举报