性能优化是一个广泛的领域,本节聚焦于UI性能方面的两大核心问题:“慢” 与 “卡”。例如,在应用启动过程中,从用户点击应用图标开始,到应用首页完全加载呈现,这一过程若耗时过长,会极大地降低用户体验。此外,在用户与应用进行交互时,如点击按钮后,应用的响应迟缓,同样会对用户体验造成严重影响,这些都是 “慢” 的具体表现。而 “卡” 则主要与UI渲染紧密相关。目前,一般的渲染帧率可达120帧左右,即大约每八点几毫秒就需要完成一帧的渲染。若应用性能欠佳,一帧内需要处理的任务过多,无法在规定的时间内完成并传递至渲染进程,便会出现丢帧现象。少量丢帧时,用户或许难以察觉,但当丢帧数量较多时,用户便会明显感受到卡顿,这种情况在视频类应用或实时交互类应用(如游戏、在线视频等)中表现得尤为突出,严重影响用户的使用感受。

 

一个看似微小的错误写法,往往会积累成严重的性能问题。性能瓶颈,往往正是由这些小问题叠加而成。

下面,将从四个方面,也就是常说的 “四板斧”,来深入探讨如何进行性能优化。这一过程类似过筛子,先利用粗筛子解决较为明显的大问题,再借助细筛子处理细微之处;也如同二八原理所述,投入20%的精力,有望解决80%的性能问题,剩余特殊场景的问题再逐一攻克。

1. 合理使用并行化、异步化、预加载和缓存

合理地使用并行化、异步化、预加载和缓存等方法提升应用启动和响应速度,例如使用多线程并发、异步并发、Web预加载、懒加载+组件复用+缓存列表项等能力,提升系统资源利用率,减少主线程负载,加快应用的启动速度和响应速度。

1.1. 使用并行化提升启动速度

1.1.1. 使用多线程执行耗时操作

在日常开发中,我们常常遇到类似场景:主页中包含多个Tab页,分别展示不同内容。当首页加载完成后,用户切换到第二个Tab页时,系统需要请求并处理网络数据,导致页面加载速度较慢,响应延迟明显。为了解决这个问题,可以通过引入多线程并发机制,例如使用TaskPool,将数据加载任务提前在后台异步处理,从而减少页面切换时的等待时间,提升整体体验。

import taskpool from '@ohos.taskpool'
import { BusinessError } from '@kit.BasicServicesKit'

aboutToAppear(): void {
  // 在生命周期中,使用Taskpool加载和解析网络数据
  this.requestByTaskpool()
}

@Concurrent
getInfoFromHttp(): string[] {
  // 从网络加载数据
  return http.request()
}

requestByTaskpool(): void {
  // 创建任务项
  let task: taskpool.Task = new taskpool.Task(this.getInfoFromHttp)
  try {
    // 执行网络加载函数
    taskpool.execute(task, taskpool.Priority.HIGH).then((res: string[]) => {
    })
  } catch (err) {
    logger.error(TAG, "failed," + (err as BusinessError).toString())
  }
}

以上展示的为核心代码片段,主要用于演示性能优化的实现思路,并不构成完整的业务流程。在实际开发中,建议根据具体场景对相关逻辑进行拓展与调整。本节中的其他代码与此类似,均以突出优化策略为主,便于理解与参考。

1.1.2. 使用子线程处理耗时操作,降低主线程负载

UI主线程是应用中最重要的线程之一,在主线程上执行耗时操作会阻塞UI渲染,从而导致UI主线程的负载过高。因此,可以将耗时操作放在TaskPool或者Worker等后台线程中执行。可以从下图的几组数据中看出,把耗时操作放在子线程可以显著降低丢帧率,提升应用的流畅度。

 

1.1.3. 使用异步化执行耗时操作,提升响应速度

如果必须在主线程中执行耗时操作,可以考虑使用异步接口或者异步加载的方式(使用setTimeOut改造),延迟耗时操作的运行时机,提升页面的响应速度。

  • 不推荐
initWeb() {
  webview.WebviewController.initializeWebEngine()
}
  • 推荐
// setTimeOut改造
initDelay() {
  // 异步执行,避免阻塞首帧绘制
  setTimeOut(this.initWeb(), 10)
}

1.2. 使用预加载提升页面启动和响应速度

1.2.1. 使用Web组件的预连接、预加载、预渲染能力

优化手段包括:提前初始化内核、预解析DNS、预连接、预加载下一页、预渲染等。

// 开启预连接需要先使用上述方法预加载WebView内核。
webview.WebviewController.initializeWebEngine()
// 启动预连接,连接地址为即将打开的网址。
webview.WebviewController.prepareForPageLoad("https://www.example.com", true, 2)
  • 预连接
import webview from '@ohos.web.webview'
// ...
controller: webview.WebviewController = new webview.WebviewController()
// ...
Web({ src: 'https://www.example.com', controller: this.controller })
  .onPageEnd((event) => {
    // 在确定即将跳转的页面时开启预加载
    this.controller.prefetchPage('https://www.example.com/nextpage')
  })
Button('下一页')
  .onClick(() => {
    // 跳转下一页
    this.controller.loadUrl('https://www.example.com/nextpage')
  })
  • 预加载
export class NWebNodeController extends NodeController {
  private rootNode: BuilderNode<Data[]> | null = null

  /**
   * 必须要重写的方法,用于构建节点数、返回节点挂载在对应
   * NodeContainer中
   * 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新
   */
  makeNode(uiContext: UIContext): FrameNode | null {
    if (this.rootNode) {
      return this.rootNode.getFrameNode()
    }
    return null // 返回null控制动态组件脱离绑定节点
  }

  @Builder
  buildReadMeSheet(): void {
    Column() {
      NodeContainer(getNWeb(this.helperUrl, this.getUIContext()))
        .width($r("app.string.full_size"))
        .height($r("app.string.full_size"))
    }
    .width($r("app.string.full_size"))
    .height($r("app.string.full_size"))
  }
}

1.2.2. 使用条件渲染实现预加载

页面布局复杂度较高,导致跳转该页面的响应时延较高,可以使用条件渲染的方式,添加页面的简单骨架图作为默认展示页面,等数据加载完成后再显示最终的复杂布局,加快点击响应速度。

条件渲染:使用if/else控制组件的加载渲染

显隐控制:使用Visibility.None、Visibility.Hidden和Visibility.Visible控制组件显示和隐藏

import skeletonComponent from "./skeletonComponent"
import businessComponent from "./businessComponent"

@State isInitialized: boolean = false

build() {
  // 当数据未就位时展示骨架图,提升用户体验,减少页面渲染时间
  if (!this.isInitialized) {
    // 网络数据未获取前使用骨架图
    skeletonComponent()
  } else {
    // 数据获取后再刷新显示内容
    businessComponent()
  }
}

1.2.3. 使用cachedCount属性实现预加载

推荐在使用List、Swiper、Grid、WaterFlow等组件时,配合使用cachedCount属性。

build() {
  Column() {
    List() {
      // ...
      LazyForEach(this.chatList, (msg: ChatModel) => {
        ListItem() {
          ChatView({ chatItem: msg })
        }
      }, (msg: ChatModel) => msg.user.userId)
    }
   .backgroundColor(Color.White)
   .listDirection(Axis.Vertical)
   // ...
   .cachedCount(this.list_cachedCount? Constants.CACHED_COUNT : 0) // 缓存列表数量
  }
}

1.3. 使用缓存提升启动速度和滑动帧率

在列表场景中,我们推荐使用LazyForEach+组件复用+缓存列表项的能力,替代Scroll/ForEach实现滚动列表场景的实现,加快页面启动速度,提升滑动帧率;也可以使用显隐控制对页面进行缓存,加快页面的显示响应速度。

  • 不推荐
ForEach(
  arr: any[],
  itemGenerator: (item: any, index?: number) => void,
  keyGenerator?: (item: any, index?: number) => string
)
  • 推荐
LazyForEach(
  dataSource: IDataSource,
  itemGenerator: (item: any) => void,
  keyGenerator?: (item: any) => string
)

使用LazyForEach时一定要配合设置合适的缓存列表项(cachedCount)+ 组件复用。

1.4. 组件复用功能和场景介绍

原理介绍:

  • 当一个自定义组件销毁时,如果标记了@Reusable,就会进入这个自定义组件所在父组件的复用缓存池。
  • 复用缓存池是一个Map套Array的数据结构,以reuseId为key,具有相同reuseId的组件在同一个Array中,reuseId默认是自定义组件的名字。
  • 发生复用行为时,会自动递归调用复用池中取出的自定义组件的aboutToReuse回调,应用可以在这个时候刷新数据。

1.4.1. 未使用组件复用

 

1.4.2. 使用组件复用

 

1.4.3. 组件复用运行机制

 

@Reusable
@Component
struct GoodItems {
  @State img: Resource = $r("app.media.photo61")
  @State webimg?: string = ""
  @State hei: number = 0
  @State introduce: string = ""
  @State price: string = ""
  @State numb: string = ""

  aboutToReuse(params: ESObject) {
    this.webimg = params.webimg as string
    this.img = params.img as Resource
    this.hei = params.hei as number
    this.introduce = params.introduce as string
    this.price = params.price as string
    this.numb = params.numb as string
  }

  build() {
    //...
  }
}

1.4.4. 推荐场景

  • 列表滚动
    当应用需要展示大量数据的列表,并且用户进行滚动操作时,频繁创建和销毁列表项的视图可能导致卡顿和性能问题。在这种情况下,使用列表组件的组件复用机制可以重用已经创建的列表项视图,提高滚动的流畅度。
  • 动态布局更新
    如果应用中的界面需要频繁地进行布局更新,例如根据用户的操作或数据变化动态改变视图结构和样式,重复创建和销毁视图可能导致频繁的布局计算,影响帧率。在这种情况下,使用组件复用可以避免不必要的视图创建和布局计算,提高性能。
  • 地图渲染
    在地图渲染这种场景下,频繁创建和销毁数据项的视图可能导致性能问题。使用组件复用可以重用已创建的视图,只更新数据的内容,减少视图的创建和销毁,能有效提高性能。

1.5. 组件复用的四种常用优化手段

组件复用的四种常用优化手段如下:

  • 减少复用组件的嵌套层级
  • 优化状态管理,精准控制刷新范围
  • 复用组件的嵌套结构会变更的场景,如有条件语句控制组件结构,需要使用reuseId标记不同结构的组件构成,提升复用性能
  • 不要使用函数/方法作为复用组件的入参。复用时会触发组件的构造,如果函数入参中存在耗时操作,会影响复用性能。

1.5.1. 减少复用组件的嵌套层级

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

在日常组件复用场景下,过深的自定义组件的嵌套会增加组件复用的使用难度,比如需要逐个实现所有嵌套组件中aboutToReuse回调实现数据更新;因此推荐优先使用@Builder替代自定义组件,减少嵌套层级,利于维护切能提升页面加载速度。

优化前:

@Reusable
@Component
struct ComponentA {
  @State desc: string = ''

  aboutToReuse(params: ESObject): void {
    this.desc = params.desc as string
  }

  build() {
    // 在复用组件中嵌套使用自定义组件
    ComponentB({ desc: this.desc })
  }
}

@Component
struct ComponentB {
  @State desc: string = ''
  
  // 嵌套的组件中也需要实现aboutToReuse来进行UI的刷新
  aboutToReuse(params: ESObject): void {
    this.desc = params.desc as string
  }

  build() {
    Column() {
      Text('子组件' + this.desc)
        .fontSize(30)
        .fontWeight(30)
    }
  }
}

优化后:

@Reusable
@Component
struct ChildComponent {
  @State desc: string = ""
  
  aboutToReuse(params: ESObject): void {
    this.desc = params.desc as string;
  }

  build() {
    Column() {
      // 使用@Builder
      this.childComponentBuilder({ paramA: this.desc })
    }
  }
}

class Temp {
  paramA: string = ""
}

@Builder
childComponentBuilder($$: Temp) {
  Column() {
    Text('子组件' + $$.paramA)
      .fontSize(30)
      .fontWeight(30)
  }
}

1.5.2. 优化状态管理,精准控制刷新范围

  • 使用@Link/@ProjectLink代替@Prop

@Prop是深拷贝,@Link/@ObjectLink是引用传递。所以在@Prop和@ObjectLink使用效果相同的场景下,优先使用@ObjectLink的方式加快对象创建速度,减少系统内存开销。

@State+@Prop、@State+@Link和@State+@Observed+@ObjectLink装饰器方案区别:

 

@State+@Prop

@State+@Link

@State+@Observed+@ObjectLink

支持接收类型

Object、class、string、number、boolean、enum及这些类型的数组

Object、class、string、number、boolean、enum及这些类型的数组

被@Observed装饰的类实例、继承Date或者 Array的class实例

支持观察复杂类型的二层属性变化

监听机制

父组件到子组件的单向绑定

父子组件间的双向绑定

父子组件间的双向绑定

父组件修改同步子组件

子组件修改同步父组件

是,不可修改变量本身,只能修改变量属性

内存消耗

修饰复杂类型方式

深拷贝

共享同一地址

共享同一地址

  • 使用AttributeModifier替代@State等状态变量刷新

@State状态变量管理的组件,单属性变更时会导致所有属性进行同步刷新,当属性过多时会有冗余刷新的情况。这种情况下可以使用AttributeModifier替代@State实现精准刷新,提升刷新性能。

优化前:

@State fontSize: number = 0

aboutToReuse(params: ESObject): void {
  this.fontSize = params.fontSize
}

build() {
  Text(this.videoDesc)
   .textAlign(TextAlign.Center)
   .fontStyle(FontStyle.Normal)
   .fontColor(Color.Pink)
   .id('videoName')
   .margin({ left: 10 })
   .fontWeight(30)
    // 此处使用属性直接进行刷新,会造成所有属性都刷新
   .fontSize(this.fontSize)
}

优化后:

attribute: MyTextModifier

aboutToReuse(params: ESObject): void {
  this.attribute.setFontSize(params.fontSize)
}

build() {
  Text(this.videoDesc)
    // 此处使用属性直接进行刷新,会造成所有属性都刷新
   .attributeModifier(this.attribute)
}

export class MyTextModifier implements AttributeModifier<TextAttribute> {
  private static instance: MyTextModifier
  private fontSize: number = 0

  setFontSize(fontSize: number) {
    this.instance.fontSize = fontSize;
    return this
  }

  applyNormalAttribute(instance: TextAttribute): void {
    instance.fontSize(this.fontSize)
    instance.textAlign(TextAlign.Center)
    instance.fontStyle(FontStyle.Normal)
    instance.fontColor(Color.Pink)
    instance.id('videoName')
    instance.margin({ left: 10 })
    instance.fontWeight(30)
  }
}

1.5.3. 使用reuseId标记不同结构的组件构成

如果在复用的自定义组件中,有使用if/else条件语句来控制布局的组成结构,导致在不同逻辑下会创建不同布局嵌套结构的组件。此时我们应该使用reuseId来区分不同结构的组件,确保系统能够根据reuseId缓存各种结构的组件,提升复用性能。

@Component
struct ReuseID {
  build() {
    Column() {
      List({ scroller: this.scroller }) {
        LazyForEach(
          this.lazyChatList, 
          (chatInfo: ChatSessionEntity | IChat.PublicChat, index: number) => {
            ListItem() {
              // 使用reuseId进行组件复用的控制
              InnerRecentChat({ chatInfo: chatInfo })
                .reuseId(this.lazyChatList.getReuseIdByIndex(index))
            }
           .height(72)
           .swipeAction({
              end: this.ChatSwiper(
                chatInfo, 
                imHelper.chat.checkChatInvalid(chatInfo)
              )
            })
          }, (item: IRenderChatType) => item.sessionId 
            +!!item.unreadcount + item.isTop + item.priority)
      }
     .cachedCount(3)
     .backgroundColor('#fff')
     .onScrollIndex(startIndex => {
        this.listStartIndex = startIndex;
      })
     .width('100%')
     .height('100%')
    }
  }
}
// InnerRecentChat.ets
@Reusable
@Component
struct InnerRecentChat {
  aboutToReuse(params: ESObject): void {
    this.chatInfo = params.chatInfo
  }

  build() {
    Button({ type: ButtonType.Normal }) {
      Row() {
        if (this.chatInfo['isPublicChat']) {
          PublicChatItem({ chatInfo: chatInfo as IChat.PublicChat })
        } else {
          ChatItem({ chatInfo: chatInfo as ChatSessionEntity })
            .onClick(() => {
              const sessionType = (chatInfo as ChatSessionEntity).sessionType
              autoOpenChat({ sessionId: chatInfo.sessionId, sessionType })
              imLogic.chat.chatSort()
            })
        }
      }.padding({ left: 16, right: 16 })
    }
   .type(ButtonType.Normal)
   .width('100%')
   .height('100%')
   .backgroundColor('#fff')
   .borderRadius(0)
  }
}

1.5.4. 不要使用函数/方法作为复用组件的入参

优化前:

build() {
  Column() {
    List() {
      LazyForEach(this.data, (item: string) => {
        ListItem() {
          // 此处sum参数是函数获取的,每次组件复用都会重复触发此函数的调用
          ChildComponent({ desc: item, sum: this.count() })
        }
       .width('100%')
       .height(100)
      }, (item: string) => item)
    }
  }
}

优化后:

@State sum: number = 0

aboutToAppear(): void {
  this.sum = this.count()
}

build() {
  Column() {
    List() {
      LazyForEach(this.data, (item: string) => {
        ListItem() {
          ChildComponent({ desc: item, sum: this.sum })
        }
       .width('100%')
       .height(100)
      }, (item: string) => item)
    }
  }
}

1.6. 组件复用的其他场景

1.6.1. 组合型

  • 场景

复用组件之间有不同,情况非常多,但是他们拥有共同的子组件,不太适用reuseId的用法。

  • 解决方法

将复用组件改为builder函数,让内部的子组件之间复用。由于内部拥有共同的子组件,适合将复用组件改为builder函数,这样子组件的缓存池就会在父组件上共享文章链接列表结构种类太多将复用组件改为builder函数,这样子组件的缓存池就会在父组件上共享。

列表结构种类太多,如下图:

 

将复用组件改为builer函数来优化,如下图:

 

1.6.2. 全局型

  • 场景

如下图所示,有时候应用在多个tab页之间切换,tab页之间结构相似,也希望tab页之间的自定义组件可以复用,提升页面切换性能。

 

  • 解决方法

将要复用的组件封装在BuilderNode中,将BuilderNode的NodeController作为复用的最小单元,自行管理复用池。

 

--未完待续--


 

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐