Bootstrap

HarmonyOS开发实战指南:UI组件性能优化

前言

应用启动到UI页面展示过程包含框架初始化、页面加载和布局渲染三个步骤。其中页面加载和布局渲染的主要流程如下:

图1 页面首次加载过程流程图

  • 在执行页面文件时,前端UI描述会在后端创建相应的FrameNode节点树。该树主要用于处理UI组件属性更新、布局测算、事件处理。每个树节点和前端UI组件是一一对应的关系。
  • FrameNode节点树生成之后,根节点开始创建布局任务。该任务遍历所有子节点并创建子节点的布局包装任务。布局包装任务包括执行相关测算和布局任务。
  • 布局包装任务完成后,每个FrameNode将创建相应的渲染包装任务并进行内容绘制。

可以看到,应用启动后页面加载和渲染的性能与FrameNode树上的节点数量和以及每个节点上的属性相关。因此,为缩短页面加载和布局渲染时长,在前端使用UI组件时可以考虑以下优化方案:

  • 避免在自定义组件的生命周期内执行高耗时操作:自定义组件创建后在渲染前会调用其生命周期回调函数,若函数中包含高耗时操作将阻塞UI渲染,将增加主线程负担。
  • 按需注册组件属性:后端在创建FrameNode节点树时,对于组件上注册的每个属性也会保存在FrameNode节点上,包括渲染类属性集合(如颜色)和布局类属性集合(如长宽,对齐方式)。在FrameNode执行布局包装任务和渲染包装任务时,属性集合将作为输入参与。因此,在应用开发中应按需注册组件属性,避免设置冗余属性。
  • 使用@builder函数代替自定义组件:前端定义的每一个自定义组件都会在后端FrameNode节点树上创建一对一的的CustomNode类型的节点。CustomNode类作为FrameNode的子类,用于处理自定义组件相关业务逻辑。当在页面上大量使用自定义组件时,会成倍增加FrameNode节点树上CustomNode类型的节点数量,增加页面创建和渲染时长。因此,在满足业务需求的前提下,可以优先使用@builder函数代替自定义组件。
  • 合理使用布局容器组件:ArkUI提供了一系列布局容器组件布局容器组件布局容器组件用于开发者快速搭建页面。不同的业务场景应选择合适的布局容器组件,并合理使用该组件的特性功能可以有效缩短页面布局时长。

避免在自定义组件的生命周期内执行高耗时操作

图2 自定义组件生命周期流程图

如上图所示,自定义组件创建完成之后,在build函数执行之前,将先执行aboutToAppear()生命周期回调函数。此时若在该函数中执行耗时操作,将阻塞UI渲染,增加UI主线程负担。因此,应尽量避免在自定义组件的生命周期内执行高耗时操作。对于复杂计算的耗时场景,可以将计算结果进行缓存处理。对于不需要等待结果的高耗时任务,可以采用多线程处理该任务,通过并发的方式避免主线程阻塞。在aboutToAppear()生命周期函数内建议只做当前组件的初始化逻辑,其他业务逻辑可以按需提前或延后处理。假设在首页视频列表中的子组件内需要初始化创建一个复杂播放器对象,该对象的创建非常耗时。若在该组件的aboutToAppear()函数中对创建该对象,当首页加载渲染时,列表内每个子组件的渲染都将等待相应的播放器对象初始化创建完成,此时页面加载将非常耗时甚至可能出现白屏。伪代码如下:

@Component
export struct VideoCard{
  // ...
  aboutToAppear(): void {
    // 创建复杂对象任务,若该任务执行耗时1s,则组件将在1s后再渲染
    this.createComplexVideoPlayer();
  }
  // ...
}

@Component
export struct CardList {
  @State videoList: VideoItem[] = getVideoList();

  build() {
    List() {
      ForEach(this.videoList, (item: VideoItem) => {
        ListItem() {
          VideoCard({ item })
        }
      }, (item: VideoItem) => item.id)
    }
  }
}


对于该场景,可以考虑将创建播放器对象任务的时机延后。如,计算当前组件出现在页面中的位置,当子组件滑动到页面的三分之一处时再创建播放器对象并播放视频。此时,页面首次渲染时,不会出现主线程阻塞。

例如在生命周期aboutToAppear中应该避免使用ResourceManager的getXXXSync接口入参中直接使用资源信息,推荐使用资源id作为入参,推荐用法为:resourceManager.getStringSync($r('app.string.test').id)。 下面以getStringSync为例,测试一下这两种参数在方法中的使用是否会有耗时区别。

反例

import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';

@Entry
@Component
struct Index {
  @State message: string = 'getStringSync';

  aboutToAppear(): void {
    hiTraceMeter.startTrace('getStringSync', 1);
    // getStringSync接口的入参直接使用资源,未使用资源ID
    getContext().resourceManager.getStringSync($r('app.string.app_name'));
    hiTraceMeter.finishTrace('getStringSync', 1);
  }

  build() {
    RelativeContainer() {
      Text(this.message)
        .fontSize(50)
        .fontWeight(FontWeight.Bold)
    }
    .height('100%')
    .width('100%')
  }
}

可以通过冷启动分析:Launch分析工具抓取Trace,根据hiTraceMeter性能打点,查看耗时为1.942ms。

正例

import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';

@Entry
@Component
struct Index {
  @State message: string = 'getStringSyncAfter';

  aboutToAppear(): void {
    hiTraceMeter.startTrace('getStringSyncAfter', 2);
    // getStringSync接口的入参使用了资源ID
    getContext().resourceManager.getStringSync($r('app.string.app_name').id);
    hiTraceMeter.finishTrace('getStringSyncAfter', 2);
  }

  build() {
    RelativeContainer() {
      Text(this.message)
        .fontSize(50)
        .fontWeight(FontWeight.Bold)
    }
    .height('100%')
    .width('100%')
  }
}

可以通过冷启动分析:Launch分析工具抓取Trace,根据hiTraceMeter性能打点,查看耗时为0.153ms。

表1 耗时统计

写法

耗时情况

资源信息为参数:getStringSync($r('app.string.app_name'))

1.942ms

资源ID为参数:getStringSync($r('app.string.app_name').id)

0.153ms

可得出结论:参数为资源信息时比参数为资源ID值时耗时更多。所以当需要使用类似方法时,使用资源ID值作为参数更优,可有效减少自定义组件生命周期耗时。

按需注册组件属性

在使用组件开发应用UI界面时,会为每个组件设置属性,进行UI样式、行为等逻辑处理。当应用中单个组件设置了大量属性且该组件在应用中被大量使用时,单个属性的设置对应用的整体性能会产生较大影响。比如,在RN框架开发中,单个组件需要设置21个属性,且该组件在ForEach循环中使用。在该场景下,由于不知道应用实际需要使用哪些属性,因此把所有的属性通过属性方法的方式设置到组件上。而在实际使用中,大部分应用只会用到其中很少的几个属性,其他属性均维持默认值,这导致了大量属性的冗余设置。该场景示例代码片段如下:

build() {
  Stack() {
    this.renderChildren()
  }
  .width(this.descriptor.layoutMetrics.frame.size.width)
  .height(this.descriptor.layoutMetrics.frame.size.height)
  .backgroundColor(convertColorSegmentsToString(this.descriptor.props.backgroundColor))
  .position({ y: this.descriptor.layoutMetrics.frame.origin.y, x: this.descriptor.layoutMetrics.frame.origin.x })
  .borderWidth(this.descriptor.props.borderWidth)
  .borderColor({
    left: convertColorSegmentsToString(this.descriptor.props.borderColor.left),
    top: convertColorSegmentsToString(this.descriptor.props.borderColor.top),
    right: convertColorSegmentsToString(this.descriptor.props.borderColor.right),
    bottom: convertColorSegmentsToString(this.descriptor.props.borderColor.bottom)
  })
  .borderRadius(this.descriptor.props.borderRadius)
  .borderStyle(this.getBorderStyle())
  .opacity(this.getOpacity())
  .transform(this.descriptor.props.transform != undefined ? convertMatrixArrayToMatrix4(this.descriptor.props.transform) : undefined)
  .clip(this.getClip())
  .hitTestBehavior(this.getHitTestMode())
  .shadow(this.getShadow())
}

从该场景中可以看到,在应用开发中,当注册了大量冗余属性的组件需要在视图上批量展示时对性能有较大影响。此时,可以考虑采用AttributeModifier动态注册组件属性的方式,替换使用属性方法静态注册组件属性的方式。

使用AttributeModifier动态注册组件属性相比于直接在组件上使用属性方法静态注册组件属性,主要存在以下两点区别:

  • 动态注册属性:系统提供AttributeModifier接口,支持开发者自定义AttributeModifier接口的实现类。 当应用运行时,系统调用AttributeModifier接口的实现类中与组件样式相关的方法,在该方法内按照开发者自定义的业务逻辑动态设置组件属性。
  • Diff更新属性:当组件创建或者更新时,重新执行组件的样式属性对象的更新接口。属性Diff逻辑基于Map实现,其中key值是属性类型,value值是属性修改器对象。更新场景下,通过key找到对应的属性修改器对象进行Diff对比,若有更新变化再通知native侧进行属性更新。

以一个简单的公共头像组件为例演示AttributeModifier方案的性能收益。公共头像组件要求对于有用户头像的数据,界面展示用户头像图片。对于没有用户头像的数据,界面展示灰色背景和用户名的第一个字符。现列表展示1000个头像组件,界面效果如下:

图3 表格展示头像组件界面

使用属性方法的方式给头像组件设置属性,代码如下:

import util from '@kit.ArkTS';

@Observed
class User {
  id: string;
  name: string;
  avatarImage: ResourceStr;

  constructor(id: string, name: string, avatarImage: ResourceStr) {
    this.id = id;
    this.name = name;
    this.avatarImage = avatarImage;
  }
}
//造数据
const DEFAULT_BACKGROUND_COLOR = Color.Grey;
const getUsers = () => {
  return Array.from(Array(1000), (item: User, i: number) => {
    return new User(
      util.generateRandomUUID(),
      i % 2 === 0 ? '张三' : '李四',
      i % 2 === 0 ? '' : $r('app.media.avatar')
    );
  });
}

@Entry
@Component
export struct AvatarGrid {
  @State users: User[] = getUsers();

  build() {
    Grid() {
      ForEach(this.users, (u: User) => {
        GridItem() {
          Avatar({ user: u })
        }
      }, (user: User) => user.id)
    }
    .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr')
    .columnsGap(4)
    .rowsGap(4)
  }
}
// 头像组件
@Component
struct Avatar {
  @ObjectLink user: User;

  build() {
    Row() {
      if (!this.user.avatarImage) {
        Text(this.user.name.charAt(0))
          .fontSize(28)
          .fontColor(Color.White)
          .fontWeight(FontWeight.Bold)
      }
    }
    .backgroundImage(this.user.avatarImage)
    .backgroundImageSize(ImageSize.Cover)
    .backgroundColor(DEFAULT_BACKGROUND_COLOR)
    .justifyContent(FlexAlign.Center)
    .size({ width: 50, height: 50 })
    .borderRadius(25)
    // .padding(2)
    // .margin(2)
    // .opacity(1)
    // .clip(false)
    // .layoutWeight(1)
    // .backgroundBlurStyle(BlurStyle.NONE)
    // .alignItems(VerticalAlign.Center)
    // .borderWidth(1)
    // .borderColor(Color.Pink)
    // .borderStyle(BorderStyle.Solid)
    // .expandSafeArea([SafeAreaType.SYSTEM])
    // .rotate({angle: 5})
    // .responseRegion({x: 0})
    // .mouseResponseRegion({x: 0})
    // .constraintSize({minWidth: 25})
    // .hitTestBehavior(HitTestMode.Default)
    // .backgroundImagePosition(Alignment.Center)
    // .foregroundBlurStyle(BlurStyle.NONE)
  }
}

将方案改为采用AttributeModifier动态注册属性的方式,需要新增自定义类实现AttributeModifier接口,并修改Avatar 组件的属性注册逻辑。具体改动代码如下:

// 1.自定义属性修改器,该类实现了AttributeModifier接口
class RowModifier implements AttributeModifier<RowAttribute> {
  private customImage: ResourceStr = '';
  private static instance: RowModifier;

  constructor() {}

  setCustomImage(customImage: ResourceStr) {
    this.customImage = customImage;
    return this;
  }
  // 采用单例模式,避免为每个组件都创建一个新的修改器,增加创建产生的性能开销
  public static getInstance(): RowModifier {
    if(RowModifier.instance){
      return RowModifier.instance;
    }else{
      return new RowModifier();
    }
  }
  // 2.实现AttributeModifier接口的applyNormalAttribute方法,自定义属性设置的逻辑
  applyNormalAttribute(instance: RowAttribute) {
    if (this.customImage) {
      instance.backgroundImage(this.customImage);
      instance.backgroundImageSize(ImageSize.Cover);
     } else {
      instance.backgroundColor(DEFAULT_BACKGROUND_COLOR);
      instance.justifyContent(FlexAlign.Center);
      // instance.padding(2)
      // instance.margin(2)
      // instance.opacity(1)
      // instance.clip(false)
      // instance.layoutWeight(1)
      // instance.backgroundBlurStyle(BlurStyle.NONE)
      // instance.alignItems(VerticalAlign.Center)
      // instance.borderWidth(1)
      // instance.borderColor(Color.Pink)
      // instance.borderStyle(BorderStyle.Solid)
      // instance.expandSafeArea([SafeAreaType.SYSTEM])
      // instance.rotate({ angle: 5 })
      // instance.responseRegion({x: 0})
      //instance.mouseResponseRegion({x: 0})
      // instance.constraintSize({minWidth: 25})
      // instance.hitTestBehavior(HitTestMode.Default)
      //instance.backgroundImagePosition(Alignment.Center)
      //instance.foregroundBlurStyle(BlurStyle.NONE)
    }
    instance.size({ width: 50, height: 50 });
    instance.borderRadius(25);
  }
}

@Component
struct Avatar {
  @ObjectLink user: User;

  build() {
    Row() {
      if (!this.user.avatarImage) {
        Text(this.user.name.charAt(0))
          .fontSize(28)
          .fontColor(Color.White)
          .fontWeight(FontWeight.Bold)
      }
    }
    // 3.将自定义RowModifier类作为参数传入,实现按需注册属性
    .attributeModifier(RowModifier.getInstance().setCustomImage(this.user.avatarImage))
  }
}

对于上述两种方案,逐渐增加注册的属性个数,通过DevEco Studio提供的场景化调优工具DevEco Profiler获取两个方案的页面加载耗时(PageRouterManager::LoadPage)和应用侧首帧耗时(First Frame - App Phase),对比如下:

表2 静态注册属性和动态注册属性在不同属性数量下耗时
  

静态注册属性

动态注册属性

注册的属性个数

6个

12个

18个

24个

6个

12个

18个

24个

PageRouterManager::LoadPage

639ms900μs

668ms624μs

719ms139μs

764ms437μs

640ms989μs

662ms112μs

705ms407μs

717ms294μs

First Frame - App Phase

45ms554μs

45ms638μs

52ms918μs

52ms643μs

44ms603μs

43ms923μs

46ms709μs

46ms355μs

图4 静态注册属性和动态注册属性在不同属性数量下LoadPage耗时

图5 静态注册属性和动态注册属性在不同属性数量下First Frame - App Phase耗时

可以看到,当注册的属性个数较少时,使用动态注册的方案收益并不明显。当注册的属性个数递增时,动态注册的收益效果同步线性递增。

优先使用@Builder方法代替自定义组件

在ArkUI中使用自定义组件时,在build阶段将在后端FrameNode树创建一个相应的CustomNode节点,在渲染阶段时也会创建对应的RenderNode节点,如下图所示。

图6 前后端UI组件树关系图

  • 前端UI描述结构会在后端创建相应的FrameNode节点树;
  • FrameNode节点树主要用于处理UI组件属性更新、布局测算、事件处理等业务逻辑;
  • CustomNode作为FrameNode的子类,用于处理自定义组件相关业务逻辑,比如执行build函数。
  • FrameNode节点树在渲染阶段生成后端渲染树进行UI渲染。

因此,在应用开发时,减少自定义组件的使用,尤其是自定义组件在循环中的使用,将成倍减少FrameNode节点树上CustomNode节点数量,有效缩短页面的加载和渲染时长。当在应用中使用自定义组件时,可以优先考虑使用@Builder函数代替自定义组件,@Builder函数不会在后端FrameNode节点树上创建一个新的树节点。如,在使用ForEach循环展示卡片列表信息时,若卡片组件仅作展示,无需使用自定义组件的复杂能力如生命周期函数时,可以创建一个@Builder函数代替创建自定义卡片组件,界面展示如下:

图7 卡片列表界面

使用自定义组件方案,示例代码如下:

import { util } from '@kit.ArkTS';

interface User {
  id: string;
  name: string;
  age?: number;
  avatarImage?: ResourceStr;
  //introduction: string;
  // ...
}

// 造数据
const DEFAULT_BACKGROUND_COLOR = Color.Pink;
const getUsers = () => {
  const USERS: User[] = [{
    id: '1',
    name: '张三',
  }, {
    id: '2',
    name: '李四',
  }, {
    id: '3',
    name: '王五',
  }];
  return Array.from(Array(30), (item: User, i: number) => {
    return {
      id: util.generateRandomUUID(),
      name: USERS[i%3].name,
      avatarImage: $r('app.media.avatar'),
      age: 18 + i
    } as User;
  });
}

// 用户卡片列表组件
@Component
export struct UserCardList {
  @State users: User[] = getUsers();

  build() {
    List({space: 8}) {
      ForEach(this.users, (item: User) => {
        ListItem() {
          UserCard({name: item.name, age: item.age, avatarImage: item.avatarImage})
        }
      }, (item: User) => item.id)
    }
    .alignListItem(ListItemAlign.Center)
  }
}

// 用户卡片自定义组件
@Component
struct UserCard {
  @Prop avatarImage: ResourceStr;
  @Prop name: string;
  @Prop age: number;

  build() {
    Row() {
      Row(){
        Image(this.avatarImage)
          .size({width: 50, height: 50})
          .borderRadius(25)
          .margin(8)
        Text(this.name)
          .fontSize(30)
      }
      Text(`年龄:${this.age.toString()}`)
        .fontSize(20)
    }
    .backgroundColor(DEFAULT_BACKGROUND_COLOR)
    .justifyContent(FlexAlign.SpaceBetween)
    .borderRadius(8)
    .padding(8)
    .height(66)
    .width('80%')
  }
}

改用@Builder函数的方式代替自定义组件UserCard的方案,具体修改的代码如下:

// 1. 自定义@Builder函数组件
@Builder
function UserCardBuilder(name: string, age?: number, avatarImage?: ResourceStr) {
  Row() {
    Row(){
      Image(avatarImage)
        .size({width: 50, height: 50})
        .borderRadius(25)
        .margin(8)
      Text(name)
        .fontSize(30)
    }
    Text(`年龄:${age?.toString()}`)
      .fontSize(20)
  }
  .backgroundColor(DEFAULT_BACKGROUND_COLOR)
  .justifyContent(FlexAlign.SpaceBetween)
  .borderRadius(8)
  .padding(8)
  .height(66)
  .width('80%')
}

@Component
export struct UserCardList {
  @State users: User[] = getUsers();

  build() {
    List({space: 8}) {
      ForEach(this.users, (item: User) => {
        ListItem() {
          // 2. 在build函数中使用@Builder函数
          UserCardBuilder(item.name,item.age,item.avatarImage)
        }
      }, (item: User) => item.id)
    }
    .alignListItem(ListItemAlign.Center)
  }
}

将组件数量从30个递增到3000个,通过profiler获取页面加载标签PageRouterManager::LoadPage和页面UI刷新任务标签UITaskScheduler::FlushTask的耗时,对比两种方案的耗时如下:

图8 两种方案LoadPage标签耗时对比

图9 两种方案UITaskSchedule标签耗时对比

通过对比图可以看到,@Builder方案在页面加载和刷新UI页面(包括布局、渲染和动画)方面优于自定义组件方案。随着组件个数增加,收益也线性增加。

最后

有很多小伙伴不知道学习哪些鸿蒙开发技术?不知道需要重点掌握哪些鸿蒙应用开发知识点?但是又不知道从哪里下手,而且学习时频繁踩坑,最终浪费大量时间。所以本人整理了一些比较合适的鸿蒙(HarmonyOS NEXT)学习路径和一些资料的整理供小伙伴学习

点击领取→纯血鸿蒙Next全套最新学习资料希望这一份鸿蒙学习资料能够给大家带来帮助,有需要的小伙伴自行领取~~

一、鸿蒙(HarmonyOS NEXT)最新学习路线

有了路线图,怎么能没有学习资料呢,小编也准备了一份联合鸿蒙官方发布笔记整理收纳的一套系统性的鸿蒙(OpenHarmony )学习手册(共计1236页)与鸿蒙(OpenHarmony )开发入门教学视频,内容包含:(ArkTS、ArkUI开发组件、Stage模型、多端部署、分布式应用开发、音频、视频、WebGL、OpenHarmony多媒体技术、Napi组件、OpenHarmony内核、Harmony南向开发、鸿蒙项目实战等等)鸿蒙(HarmonyOS NEXT)…等技术知识点。

获取以上完整版高清学习路线,请点击→纯血版全套鸿蒙HarmonyOS学习资料

二、HarmonyOS Next 最新全套视频教程

三、《鸿蒙 (OpenHarmony)开发基础到实战手册》

OpenHarmony北向、南向开发环境搭建

四、大厂面试必问面试题

五、鸿蒙南向开发技术

六、鸿蒙APP开发必备


完整鸿蒙HarmonyOS学习资料,请点击→纯血版全套鸿蒙HarmonyOS学习资料

总结
总的来说,华为鸿蒙不再兼容安卓,对中年程序员来说是一个挑战,也是一个机会。只有积极应对变化,不断学习和提升自己,他们才能在这个变革的时代中立于不败之地。 

                        

;