鸿蒙NEXT开发【应用冷启动优化】常见性能场景

应用冷启动概述

应用启动时延是影响用户体验的关键要素,当用户点击桌面应用图标、通知或其他入口启动应用,到用户的数据显示在屏幕上,如果这段时间花费的时间比较长,这样肯定会影响用户的体验。

应用启动可以分为冷启动和热启动,当应用启动时,后台没有该应用的进程,这时系统会重新创建一个新的进程分配给该应用, 这种启动方式就叫做冷启动;而热启动是当应用程序已经在后台运行,用户再次打开应用程序时,应用程序仍然在内存中,可以直接从内存中加载并继续之前的状态,而不需要重新初始化和加载资源。

当应用冷启动时延大于1100ms时,可以认为是应用启动缓慢

应用冷启动流程

在优化应用冷启动体验前,需要先了解应用冷启动的流程和几个重要的生命周期。应用冷启动的过程大致可分成以下四个阶段:应用进程创建&初始化、Application&Ability初始化、Ability/AbilityStage生命周期、加载绘制首页,如下图所示。

1

  1. 应用进程创建&初始化阶段:该阶段主要是系统完成应用进程的创建以及初始化的过程,包含了启动页图标(startWindowIcon)的解码。
  2. Application&Ability初始化:该阶段主要是资源加载、虚拟机创建、Application&Ability相关对象的创建与初始化、依赖模块的加载等。
  3. Ability/AbilityStage生命周期:该阶段主要是AbilityStage/Ability的启动生命周期,执行相应的生命周期回调。
  4. 加载绘制首页:该阶段主要是加载首页内容、测量布局、刷新组件并绘制。
  5. 网络数据二次刷新:该阶段主要是应用根据业务需要对网络数据进行请求、处理、二次刷新。

可见如果想要提升应用冷启动速度,需要缩短以上几个阶段的耗时。

识别启动缓慢问题

如果开发者需要分析启动过程的耗时瓶颈,优化应用或服务的冷启动速度,可使用Profiler提供的Launch场景分析能力,录制启动过程中的关键数据进行分析,从而识别出导致启动缓慢的原因所在。Profiler Launch可以拆解应用冷启动过程,抓取不同阶段的耗时数据,帮助开发者快速分析冷启动过程的耗时瓶颈

2

从上图可以看到Launch将应用的冷启动过程拆解为以下几个阶段:

  1. Create Process:应用进程创建阶段,对应的trace打点为AbilityManagerService::StartAbility,MissionListManager::StartAbilityLocked##{bundleName}
  2. Application Launching:应用启动阶段,对应的trace打点为AppMgrServiceInner::AttachApplication##{bundleName}
  3. UI Ability Launching:UIAbility启动阶段,对应的trace打点为MainThread::HandleLaunchAbility##{bundleName}
  4. UI Ability OnForeground:应用进入前台阶段,对应的trace打点为AbilityThread::HandleAbilityTransaction
  5. First Frame - App Phase:App首帧渲染提交阶段,对应的trace打点为H:ReceiveVsync,H:MarshRSTransactionData
  6. First Frame - Render Phase:RS首帧渲染提交阶段,对应的trace打点为H:ReceiveVsync,H:RSMainThread::ProcessCommandUn
  7. EntryAbility:应用启动之后的阶段,渲染完成,首页显示。

从Create Process阶段开始到First Frame - Render Phase阶段,这段时间可以看作是应用的冷启动时间,在Launch泳道到鼠标左键拖动这两个阶段的区间,可以看到应用冷启动时间为215.2ms,这个启动速度是比较快的。

3

冷启动缓慢示例分析

运行如下示例代码,我们可以明显的感知应用启动比较缓慢。接下来我们通过这个示例,结合Launch来分析应用冷启动缓慢问题。

const LARGE_NUMBER = 200000000;
const DELAYED_TIME = 1000;

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

  aboutToAppear(): void {
    console.log('aboutToAppear');
    this.computeTask();
  }

  computeTask(): void {
    let count = 0;
    while (count < LARGE_NUMBER) {
      count++;
    }
  }

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

首先是创建Launch模板录制,可以看到整个的启动时间比较长,其中UI Ability OnForeground这个阶段占据应用冷启动过程的大部分时间,耗时达到了4.1s,所以我们需要重点分析这个阶段的耗时。

4

针对应用冷启动问题的性能分析,有以下两种方式可以选择,一种是分析主线程的Trace数据,另一种则是分析采样得到的函数热点。

分析主线程的Trace数据

5

  1. 单击“Launch”泳道上的UI Ability OnForeground阶段,在下方的“Details”详情面板中,可查看到所选阶段的耗时统计情况;
  2. 展开UI Ability OnForeground统计信息折叠表,可以看到各函数的具体耗时信息;
  3. 根据Duration找到耗时最长的函数aboutToAppear;
  4. 单击图标按钮,可直接跳转至主线程的打点任务中,查看相关Trace数据,如下图所示。

6

可以看到在UI Ability OnForeground阶段的耗时基本是由aboutToAppear造成的,我们再看aboutToAppear中的代码逻辑,可以推断是由于计算任务computeTask耗时造成的。

分析采样得到的函数热点

我们也可以分析采样得到的函数热点直观的显示应用冷启动过程中具体函数的耗时,如下图:

7

  1. 单击“Launch”泳道上的UI Ability OnForeground阶段;
  2. 选择“ArkTS Callstack”泳道,其会基于时间轴展示CPU使用率和状态的变化,以及当前调用栈名称和调用类型;
  3. 下方“Details”详情面板中查看到这段时间内的函数热点,其会以Top-Down形式的树状列表进行展示。很明显aboutToAppear函数中的computeTask函数耗时最多,占整个阶段的96.7%,双击该函数可以跳转到源码。
  4. 此外,点击底部Flame Chart按钮打开火焰图可以更直观的看出热点函数的耗时情况,如下图所示。

8

冷启动速度优化

通过前面的分析,冷启动缓慢是由于在aboutToAppear执行了耗时计算任务,我们可以将该computeTask以异步延时的方式处理,优化后的代码如下:

const LARGE_NUMBER = 100000000;
const DELAYED_TIME = 1000;

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

  aboutToAppear(): void {
    console.log('aboutToAppear');
    this.computeTaskAsync();
  }

  ...

  computeTask(): void {
    let count = 0;
    while (count < LARGE_NUMBER) {
      count++;
    }
  }

  // 运算任务异步处理
  private computeTaskAsync(): void {
    setTimeout(() => { // 这里使用setTimeout来实现异步延迟运行
      this.computeTask();
    }, DELAYED_TIME);
  }
}

然后重新编译运行程序以及录制Launch,可以看到优化后UI Ability OnForeground阶段耗时大幅度缩短,如下图所示:

9

查看首帧卡顿

为了识别首帧是否卡顿,可以先在Launch的Frame泳道进行查看。应用的首帧渲染提交在First Frame - App Phase阶段,APP侧下面的这一帧表示应用渲染的首帧,如下图所示,此处首帧为36号帧:

10

如上所示36号帧被标记为了红色,表示首帧出现了卡顿。鼠标左键36号帧,可以看到它的期望提交渲染时间为左边白色竖线区域所示,这里出现了比较严重的延时。发现问题后,开发者可以参考前面讲到的示例进行问题定位和优化。

提升应用冷启动速度

本文将通过公共类优化的方法,包括[非冷启动必须的服务或模块延迟加载]、[减少主线程非UI耗时操作]和[网络请求提前发送],以及结合应用启动的几个阶段分别介绍提升应用冷启动速度的相关方法。

非冷启动必须的服务或模块延迟加载

应用在启动前加载过多不必要启动项,同时这些启动项在主线程串行执行,该阶段耗时接近450ms。

11

应用冷启动过程中,加载自身不必要的串行启动项,会导致冷启动耗时增加。建议延后加载或者并行处理。

减少主线程非UI耗时操作

在应用启动流程中,主要聚焦在执行UI相关操作中,为了更快的显示首页内容,不建议在主线程中执行非UI相关的耗时操作,建议通过异步任务进行延迟处理或放到其他子线程中执行。

在冷启动过程中如果存在图片下载、网络请求前置数据、数据反序列化等非UI操作开发者可以根据实际情况移至子线程中进行。

网络请求提前发送

当前大多数应用的首页内容需从网络获取,发送网络请求的时机显得尤为重要。应用发送网络请求后等待网络数据的返回,网络请求的这段时间应用可以继续执行启动流程,直到网络数据返回后进行解析,反序列化之后就可以加载首页数据,因此网络请求的发起时机越早,整个冷启动的完成时延阶段越短。

可将网络请求及网络请求前的初始化流程放置在AbilityStage/UIAbility的onCreate()生命周期中,在AbilityStage/UIAbility中仅执行网络相关预处理,等待网络请求发送后可继续执行首页数据准备、UI相关操作。在服务端处理流程相同的情况下,应用可以更早的拿到网络数据并行展示。

图1 应用首页框架加载时进行网络数据请求

12

将网络请求提前至AbilityStage/UIAbility生命的onCreate()生命周期回调函数中,可以将首刷或二刷的时间提前,减少用户等待时间。此处为了体现性能收益,将网络请求放到了更早的AbilityStage的onCreate()生命周期回调中。

图2 网络请求提前至AbilityStage的onCreate()周期回调中
13

【优化前】:在首页根组件的onAppear()周期回调中发起网络请求。

// entry/src/main/ets/pages/Index.ets
import { httpRequest } from '../utils/NetRequest';
import { number } from '../utils/Calculator';

AppStorage.link('netData');
PersistentStorage.persistProp('netData', undefined);

@Entry
@Component
struct Index {
  @State message: string = 'Hello World' + number; // 为了体现性能收益,引用耗时函数的执行结果number
  @StorageLink('netData') netData: PixelMap | undefined = undefined;
  build(){
    Row(){
      Image(this.netData)
        .objectFit(ImageFit.Contain)
        .width('50%')
        .height('50%')
    }
    .onAppear(() => {
      // 发送网络请求
      httpRequest();
    })
  }
}

// entry/src/main/ets/utils/NetRequest.ets
import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';
// 通过http的request方法从网络下载图片资源
export function httpRequest() {
  hiTraceMeter.startTrace('Http Request', 1);
  http.createHttp()
    // 实际开发需要将"https://www.example1.com/POST?e=f&g=h"替换成为真实要访问的网站地址
    .request('https://www.example1.com/POST?e=f&g=h',
      (error: BusinessError, data: http.HttpResponse) => {
        if (error) {
          // 下载失败时不执行后续逻辑
          return;
        }
        // 处理网络请求返回的数据
        transcodePixelMap(data);
      }
    )
}
// 使用createPixelMap将ArrayBuffer类型的图片装换为PixelMap类型
function transcodePixelMap(data: http.HttpResponse) {
  if (http.ResponseCode.OK === data.responseCode) {
    const imageData: ArrayBuffer = data.result as ArrayBuffer;
    // 通过ArrayBuffer创建图片源实例
    const imageSource: image.ImageSource = image.createImageSource(imageData);
    const options: image.InitializationOptions = {
      'alphaType': 0, // 透明度
      'editable': false, // 是否可编辑
      'pixelFormat': 3, // 像素格式
      'scaleMode': 1, // 缩略值
      'size': { height: 100, width: 100 }
    }; // 创建图片大小
    // 通过属性创建PixelMap
    imageSource.createPixelMap(options).then((pixelMap: PixelMap) => {
      AppStorage.set('netData', pixelMap);
      hiTraceMeter.finishTrace('Http Request', 1);
    });
  }
}

// entry/src/main/ets/utils/Calculator.ets
const LARGE_NUMBER = 100000000;
function computeTask(): number {
  let count = 0;
  while (count < LARGE_NUMBER) {
    count++;
  }
  return count;
}
export let number = computeTask();

【优化后】

  1. 在NetRequest.ets中进行Http请求以及数据处理。
// NetRequest.ets
import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';
// 通过http的request方法从网络下载图片资源
export function httpRequest() {
  hiTraceMeter.startTrace('Http Request', 1);
  http.createHttp()
    // 实际开发需要将"https://www.example1.com/POST?e=f&g=h"替换成为真实要访问的网站地址
    .request('https://www.example1.com/POST?e=f&g=h',
      (error: BusinessError, data: http.HttpResponse) => {
        if (error) {
          // 下载失败时不执行后续逻辑
          return;
        }
        // 处理网络请求返回的数据
        transcodePixelMap(data);
      }
    )
}

// 使用createPixelMap将ArrayBuffer类型的图片装换为PixelMap类型
function transcodePixelMap(data: http.HttpResponse) {
  if (http.ResponseCode.OK === data.responseCode) {
    const imageData: ArrayBuffer = data.result as ArrayBuffer;
    // 通过ArrayBuffer创建图片源实例
    const imageSource: image.ImageSource = image.createImageSource(imageData);
    const options: image.InitializationOptions = {
      'alphaType': 0, // 透明度
      'editable': false, // 是否可编辑
      'pixelFormat': 3, // 像素格式
      'scaleMode': 1, // 缩略值
      'size': { height: 100, width: 100 }
    }; // 创建图片大小
    // 通过属性创建PixelMap
    imageSource.createPixelMap(options).then((pixelMap: PixelMap) => {
      AppStorage.set('netData', pixelMap);
      hiTraceMeter.finishTrace('Http Request', 1);
    });
  }
}
  1. 在AbilityStage的onCreate()生命周期回调中发起网络请求。
// MyAbilityStage.ets
import { AbilityStage, Want } from '@kit.AbilityKit';
import { httpRequest } from '../utils/NetRequest';
export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    // 发送网络请求
    httpRequest();
  }

  onAcceptWant(want: Want): string {
    // 仅specified模式下触发
    return 'MyAbilityStage';
  }
}
  1. 在首页Index.ets中展示请求获取的图片。
// Index.ets
import { number } from '../utils/Calculator';

AppStorage.link('netData');
PersistentStorage.persistProp('netData', undefined);

@Entry
@Component
struct Index {
  @State message: string = 'Hello World' + number; // 为了体现性能收益,引用耗时函数的执行结果number
  @StorageLink('netData') netData: PixelMap | undefined = undefined;
  build() {
    Row() {
      Image(this.netData)
        .objectFit(ImageFit.Contain)
        .width('50%')
        .height('50%')
    }
    .onDisAppear(() => {
      AppStorage.set('netData', undefined);
    })
    .height('100%')
    .width('100%')
  }
}

使用[Launch模板],对优化前后启动性能进行对比分析。分析阶段的起点为启动Ability(即H:void OHOS::AppExecFwk::MainThread::HandleLaunchAbility的开始点),阶段终点为应用接收到网络数据返回后的首帧刷新(即H:ReceiveVsync dataCount:24Bytes now:timestamp expectedEnd:timestamp vsyncId:int的开始点)

图3 优化网络请求时机前
13

图4 优化网络请求时机后
14

对比数据如下:

方案阶段时长(毫秒)
优化网络请求时机前1700
优化网络请求时机后885.3

因此,可以通过提前网络请求的方式减少应用冷启动耗时。

缩短应用进程创建&初始化阶段耗时

该阶段主要是系统完成应用进程的创建以及初始化的过程,包含了启动页图标(startWindowIcon)的解码。使用合适分辨率的图标是影响体验的关键,建议使用不超过256*256分辨率的图片作为启动页面图标,以减少图片解码带来的时延。

设置合适分辨率的startWindowIcon

如果启动页图标分辨率过大,解码耗时会影响应用的启动速度,建议启动页图标分辨率不超过256像素*256像素,如下所示:

{
  "module": {
    ...
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon", // 在这里修改启动页图标,建议不要超过256像素x256像素
        "startWindowBackground": "$color:start_window_background",
        ...
      }
    ]
  }
}

下面使用Launch模板,对使用优化前的启动页图标(4096像素4096像素)及使用优化后的启动页图标(144像素144像素)的启动性能进行对比分析。分析阶段的起点Process Creating,阶段终点为First Frame - Render Phase,优化前后的启动耗时如下图:

图5 优化前使用4096px*4096px启动页图标应用启动耗时
15

图6 优化后使用144px*144px启动页图标应用启动耗时
16

可见优化后应用启动时长缩短了37.2ms,故设置合适分辨率的startWindowIcon对缩短应用进程创建&初始化阶段耗时是有效的。

缩短Application&Ability初始化阶段耗时

该阶段主要是资源加载、虚拟机创建、Application&Ability相关对象的创建与初始化、依赖模块的加载等。

主要耗时点在资源加载阶段,分为以下几个步骤。

  1. 文件加载:查找并解析所有的文件到模块中记录。
  2. 依赖模块解析(实例化):分配内存空间来存放模块所有导出的变量,此时内存中并没有分配变量的值。
  3. 文件执行:运行.ets文件,将内存中之前未分配值的变量赋为真实的值。

本章节将针对这三个阶段可能存在的优化手段进行详细展开说明。

减少import的模块

应用代码执行前,应用程序必须找到并加载import的模块,应用程序加载的每个额外的第三方框架或者模块都会增加启动时间,耗时长短取决于加载的第三方框架或者模块的数量和大小。推荐开发者尽可能使用系统提供的模块,按需加载,来缩短应用程序的启动耗时。

// 优化减少import的模块
/*import { ConfigurationConstant, contextConstant, wantConstant } from '@kit.AbilityKit';
import { GesturePath, GesturePoint } from '@kit.AccessibilityKit';
import { distributedAccount, osAccount } from '@kit.BasicServicesKit';
import { Configuration } from '@kit.ArkUI';
import { atomicService } from '@kit.ScenarioFusionKit';
import { sim } from '@kit.TelephonyKit';*/

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  // ...
}

下面使用Launch模板,对优化import的模块前(模块数量15个)及优化import的模块后(移除不必要的模块剩余5个)的启动性能进行对比分析。分析的trace点为H:SourceTextModule::Evaluate,优化前后的启动耗时如下图:

图7 优化前import 15个模块

17

图8 优化后import 5个模块

18

对比数据如下:

方案阶段时长(微秒)
减少import的模块前6239.5
减少import的模块后119.7

可见减少不必要的模块import,可以缩短应用冷启动时间。此外,如果某些必要的模块导入比较耗时的话,可以采取动态导入的方式。

**减少使用嵌套export /import 的方式

  • 冷启动执行文件减少使用嵌套export *的方式全量导出

    应用冷启动过程中,会在HandleLaunchAbility中执行冷启动相关.ets文件,所有被主页面import的.ets文件均会被执行,包括数据结构、变量、全局函数的初始化等。首页需要用到的变量及函数等可能来源于其他.ets文件,通过export的形式提供给首页使用。

    使用[Launch模板],对优化前(嵌套8层export *)及优化后(不存在嵌套export *,从目标文件中直接import)的启动性能进行对比分析。分析阶段的起点为开始加载abc文件(即H:JSPandaFileExecutor::ExecuteFromAbcFile),阶段终点为abc文件加载完成。

    图9 优化前,存在8层嵌套export *
    19

    图10 优化后,不存在嵌套export *,从目标文件中直接import
    19

    对比数据如下:

    方案阶段时长(微秒)
    (优化前)存在8层嵌套export *492.6
    (优化后)不存在嵌套export *,从目标文件中直接import388.7

    可见阶段时长已缩短。因此减少多层文件的嵌套导出export *可以提升应用冷启动速度。

  • 减少import *的方式全量引用

    应用程序加载过程中,需要使用不同模块中的变量或函数,通常开发者会将相同类型的变量或函数放在同一个工具类文件中,使用时通过import的方式引入对应的模块,当工具类中存在较多暴露函数或变量时,推荐按需引用使用到的变量代替import *的方式,可以减少该阶段中.ets文件执行耗时,即减少文件中所有export变量的初始化过程。

    对优化前(使用import * as nm全量引用2000条数据)和优化后(使用import { One }按需引用)的启动性能进行对比分析。分析阶段的起点为H:void OHOS::AppExecFwk::MainThread::HandleLaunchAbility(const std::shared_ptr &)的开始点,阶段终点为H:void OHOS::AppExecFwk::MainThread::HandleLaunchAbility(const std::shared_ptr &)的结束点。

    图11 优化前,使用import * as nm全量引用2000条数据
    20

    图12 优化后,使用import { One }按需引用
    21

    优化前后的对比数据如下:

    方案阶段时长(毫秒)
    (优化前)使用import * as nm全量引用16.7
    (优化后)使用import { One }按需引用7.1

    可见阶段时长已缩短。因此使用按需引用的方式,可以缩短应用冷启动完成时延。

    说明

    此优化方案仅可将冷启动阶段耗时缩短,但是可能导致其他场景耗时增长,即变量初始化过程从冷启动阶段分摊至其它使用阶段,例:当二级页面使用到Numbers.ets中Two变量,此方案会使二级页面跳转过程对比优化前耗时更长。

合理拆分导出文件,减少冗余文件执行

应用程序加载模块后,需要执行应用侧的.ets文件,对其进行初始化,并执行全局初始化变量、函数。可以将文件分为两类,一类为冷启动强相关文件(如首页展示界面及组件相关文件),一类为非冷启动强相关文件(如跳转后二级页面),在冷启动过程中仅执行冷启动强相关文件,来缩短应用的启动耗时。

【场景示例】

应用存在两个页面,首页Index展示为HAR包中MainPage.ets的Text组件,该文件中不包含耗时操作;首页点击Text跳转至SecondPage,其中引用了HAR包中的SubPage.ets,该文件存在全局函数的耗时操作,会在模块加载时执行。但是HAR包中的导出文件Index.ets同时导出了MainPage.ets和SubPage.ets,而首页直接 import { MainPage } from ‘library/Index’ 的方式会导致应用在冷启动过程中执行了非冷启动强相关文件SubPage.ets,增加了冷启动耗时。

图13 优化前,加载模块时执行了非冷启动相关文件SubPage.ets
22

以下为示例代码:

// entry/src/main/ets/pages/Index.ets
import { MainPage } from 'library/Index'; // 不推荐用法:直接导入了与冷启动非强相关文件SubPage.ets
export struct Index{
  @Provide pathStack: NavPathStack = new NavPathStack();
  build() {
    Navigation(this.pathStack) {
      Row() {
        // 引用HAR的自定义组件
        MainPage()
      }
    }
  }
}

// library/src/main/ets/components/mainpage/MainPage.ets
@Component
export struct MainPage {
  @Consume pathStack: NavPathStack;
  @State message: string = 'HAR MainPage';
  build() {
    Row() {
      Text(this.message)
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
    }.onClick(() => {
      this.pathStack.pushPath({ name: 'SecondPage' });
    })
  }
}

// entry/src/main/ets/pages/SecondPage.ets
import { SubPage } from 'library/Index';
@Builder
export function SecondPageBuilder() {
  SecondPage()
}
@Entry
@Component
struct SecondPage {
  pathStack: NavPathStack = new NavPathStack();
  build() {
    NavDestination() {
      Row() {
        // 引用HAR的自定义组件
        SubPage()
      }
      .height('100%')
    }
    .onReady((context: NavDestinationContext) => {
      this.pathStack = context.pathStack;
    })
  }
}

// library/src/main/ets/components/mainpage/SubPage.ets
// SubPage中的全局耗时函数
const LARGE_NUMBER = 10000000;
function computeTask(): number {
  let count = 0;
  while (count < LARGE_NUMBER) {
    count++;
  }
  return count;
}
let count = computeTask();
// ...

// library/Index.ets
export { MainPage } from './src/main/ets/components/mainpage/MainPage'; // 冷启动强相关文件
export { SubPage } from './src/main/ets/components/mainpage/SubPage'; // 非冷启动强相关文件

【优化方案一】

将HAR包的导出文件Index.ets进行拆分,IndexAppStart.ets文件仅导出首页相关文件,即MainPage.ets。IndexOthers.ets文件导出非首页相关文件,即SubPage.ets。

优点:使用此种方案优化后可以将冷启阶段(加载首页文件)与非冷启阶段(加载非首页文件)需要执行的.ets文件进行完全拆分,类比其他需优化的场景也可以使用本方案进行拆分。

缺点:需保证拆分后IndexAppStart.ets中的导出文件不存在对于IndexOthers.ets中的导出文件的引用。

图14 优化方案一,拆分HAR导出文件
23

示例代码如下:

  1. 将HAR包的导出文件Index.ets进行拆分,IndexAppStart.ets文件仅导出首页相关文件,IndexOthers.ets文件导出非首页相关文件。
// library/IndexAppStart.ets
export { MainPage } from './src/main/ets/components/mainpage/MainPage';
// library/IndexOthers.ets
export { SubPage } from './src/main/ets/components/mainpage/SubPage';
  1. 首页Index从IndexAppStart.ets导入MainPage。
// Index.ets
import { MainPage } from 'library/IndexAppStart';

@Entry
@Component
struct Index {
  @Provide pathStack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.pathStack) {
      Row() {
        // 引用HAR的自定义组件
        MainPage()
      }
    }
    .height('100%')
    .width('100%')
  }
}
  1. 跳转后的页面SecondPage从IndexOthers.ets导入SubPage。
// SecondPage.ets
import { SubPage } from 'library/IndexOthers';

@Builder
export function SecondPageBuilder() {
  SecondPage()
}

@Entry
@Component
struct SecondPage {
  pathStack: NavPathStack = new NavPathStack();

  build() {
    NavDestination() {
      Row() {
        // 引用HAR的自定义组件
        SubPage()
      }
      .height('100%')
    }
    .onReady((context: NavDestinationContext) => {
      this.pathStack = context.pathStack;
    })
  }
}

【优化方案二】

在首页的Index.ets文件中导入MainPage.ets时使用全路径展开。

优点:不需要新增文件来汇总导出所有冷启阶段文件。

缺点:引用时需要对所有冷启阶段文件进行路径展开,增加开发和维护成本。

图15 优化方案二,首页导入冷启动文件时使用全路径展开
24

示例代码如下:

// Index.ets
import { MainPage } from 'library/src/main/ets/components/mainpage/MainPage';

@Entry
@Component
struct Index {
  @Provide pathStack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.pathStack) {
      Row() {
        // 引用HAR的自定义组件
        MainPage()
      }
    }
    .height('100%')
    .width('100%')
  }
}

说明

  1. 上述两种优化方案默认MainPage中不存在对于SubPage中的import。

  2. 当存在MainPage对于SubPage的直接import时,需要使用[动态import]方法来进行优化。

  3. 开发者可自行根据优化方案的优缺点权衡选择合适的优化方案。

下面使用[Launch模板]对优化前后启动性能进行对比分析。阶段起点为UI Ability Launching的开始点,阶段终点为应用首帧即First Frame - App Phase的开始点。

图16 优化前:加载模块时执行了非冷启动相关文件
25

图17 优化方案一:拆分HAR导出文件

26

图18 优化方案二:导入冷启动文件时全路径展开
27

优化前后的对比数据如下:

方案阶段时长(毫秒)
优化前140.1
优化方案一(拆分HAR导出文件)62.9
优化方案二(导入冷启动文件时全路径展开)61.3

可见阶段时长已缩短,因此可以通过拆分HAR包导出的Index.ets文件或导入冷启动文件时路径全展开的方案,减少应用冷启动中.ets文件执行耗时,从而提升应用冷启动速度。

减少多个HAP/HSP对相同HAR的引用

在应用开发的过程中,可以使用[HSP]或[HAR]的共享包方式将同类的模块进行整合,可以实现多个模块或多个工程间共享ArkUI组件、资源等相关代码。同时需要注意避免多个HAP/HSP对相同HAR的引用

优化加载HSP时间过长

对于单窗口应用的APP工程而言,其仅包含一个Entry类型的HAP,那么划分的模块如果没有按需加载的需求,则建议业务组件和公共组件采用HAR的打包方式,最终构建应用HAP包时,这些被依赖的HAR,最终都会被编译进HAP包中。HSP是采用动态加载,在启动过程中,会将依赖的HSP加载进来,增加额外的IO与运行耗时。单HAP场景下,模块推荐使用多HAR,不推荐使用HSP。

以下为示例代码:

import { add } from 'hsp1';
import { add2 } from 'hsp2';
import { add3 } from 'hsp3';
import { add4 } from 'hsp4';
import { add5 } from 'hsp5';
import { add6 } from 'hsp6';
import { add7 } from 'hsp7';
import { add8 } from 'hsp8';
import { add9 } from 'hsp9';
import { add10 } from 'hsp10';
import { add11 } from 'hsp11';
import { add12 } from 'hsp12';
import { add13 } from 'hsp13';
import { add14 } from 'hsp14';
import { add15 } from 'hsp15';
import { add16 } from 'hsp16';
import { add17 } from 'hsp17';
import { add18 } from 'hsp18';
import { add19 } from 'hsp19';
import { add20 } from 'hsp20';

下面使用Launch模板,HAP+20个HSP混合打包及将20个HSP包设计成HAR包的启动性能进行对比分析。

图19 HAP+20个HSP混合打包
28

图20 将20个HSP包设计成HAR包
29

对比数据如下:

方案阶段时长(微秒)
HAP+20个HSP混合打包34643.7
将20个HSP包设计成HAR包36.4

因此,不推荐使用HSP。

缩短AbilityStage生命周期阶段耗时

该阶段主要是AbilityStage的启动生命周期,执行相应的生命周期回调。

避免在AbilityStage生命周期回调接口进行耗时操作

在应用启动流程中,系统会执行AbilityStage的生命周期回调函数。因此,不建议在这些回调函数中执行耗时过长的操作,比如onCreate,耗时操作建议通过异步任务延迟处理或者放到其他线程执行,线程并发方案可以参考:[TaskPool和Worker的对比实践]。在这些生命周期回调里,推荐开发者只做必要的操作,关于AbilityStage可以参考:[AbilityStage组件容器],以下为示例代码:

const LARGE_NUMBER = 100000000;
const DELAYED_TIME = 1000;

export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    // 耗时操作
    // this.computeTask(); 
    this.computeTaskAsync(); // 异步任务
  }

  onAcceptWant(want: Want): string {
    // 仅specified模式下触发
    return 'MyAbilityStage';
  }

  private computeTask(): void {
    let count = 0;
    while (count < LARGE_NUMBER) {
      count++;
    }
  }

  private computeTaskAsync(): void {
    setTimeout(() => { // 这里使用setTimeout来实现异步延迟运行
      this.computeTask();
    }, DELAYED_TIME);
  }
}

下面使用Launch模板,对优化前同步执行耗时操作及优化后异步执行耗时操作的启动性能进行对比分析。分析阶段的起点Process Creating,阶段终点为First Frame - Render Phase,优化前后的启动耗时如下图:

图21 优化前同步执行操作(computeTask),应用冷启动耗时
30

图22 优化前异步执行操作(computeTaskAsync),应用冷启动耗时

31

可见使用异步后,应用冷启动时间速度有了较大的提升,耗时从2.2s减少到了220.9ms。

缩短Ability生命周期阶段耗时

该阶段主要是Ability的启动生命周期,执行相应的生命周期回调。

避免在Ability生命周期回调接口进行耗时操作

在应用启动流程中,系统会执行Ability的生命周期回调函数。因此,不建议在这些回调函数中执行耗时过长的操作,耗时操作建议通过异步任务延迟处理或者放到其他线程执行

在这些生命周期回调里,推荐开发者只做必要的操作,下面以UIAbility为例进行说明。比如在生命周期回调函数onCreate、onWindowStageCreate、onForeground等中执行耗时操作都会导致启动缓慢问题,关于UIAbility组件生命周期的详细说明

图23 UIAbility生命周期状态
32

下面示例代码在UIAbility的回调函数onCreate中分别执行了同步和异步操作:

const LARGE_NUMBER = 100000000;
const DELAYED_TIME = 1000;

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 耗时操作
    // this.computeTask();
    this.computeTaskAsync(); // 异步任务
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err, data) => {
      if (err.code) {
        logger.error('Failed to load the content. Cause: ' + JSON.stringify(err) ?? '');
        return;
      }
      logger.info('Succeeded in loading the content. Data: ' + JSON.stringify(data) ?? '');
    });

    // 耗时操作
    // this.computeTask();
    // this.computeTaskAsync(); // 异步任务
  }

  onForeground(): void {
    // 耗时操作
    // this.computeTask();
    // this.computeTaskAsync(); // 异步任务
  }

  private computeTask(): void {
    let count = 0;
    while (count < LARGE_NUMBER) {
      count++;
    }
  }

  private computeTaskAsync(): void {
    setTimeout(() => { // 这里使用setTimeout来实现异步延迟运行
      this.computeTask();
    }, DELAYED_TIME);
  }
}

下面使用Launch模板,对优化前同步执行耗时操作及优化后异步执行耗时操作的启动性能进行对比分析。分析阶段的起点Process Creating,阶段终点为First Frame - Render Phase,优化前后的启动耗时如下图:

图24 优化前同步执行操作(computeTask),应用冷启动耗时

33

图25 优化后异步执行操作(computeTaskAsync),应用冷启动耗时

34

可见使用延时异步后,应用冷启动时间速度有了较大的提升,耗时从2.1s减少到了220ms。

缩短加载绘制首页阶段耗时

该阶段主要是加载首页内容、测量布局、刷新组件并绘制。同样注意页面生命周期的处理函数,不要进行耗时操作,同时,应优先创建首页需要显示的组件,使用if分支语句,隐藏不需要显示的组件,减少创建过程的耗时。耗时操作建议通过异步任务延迟处理或者放到其他线程执行

自定义组件生命周期回调接口里避免耗时操作

自定义组件的生命周期变更会调用相应的回调函数,aboutToAppear函数会在创建自定义组件实例后,页面绘制之前执行,而onPageShow则是在页面进入前台的时候显示,因此避免在这两个回调函数中执行该耗时操作,不阻塞页面绘制。关于自定义组件生命周期的详细说明

图26 被@Entry装饰的组件(页面)生命周期

35

以下为示例代码在Page的回调函数aboutToAppear中分别执行了同步和异步操作:

const LARGE_NUMBER = 100000000;
const DELAYED_TIME = 1000;

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

  aboutToAppear(): void {
    // 耗时操作
    // this.computeTask();
    this.computeTaskAsync(); // 异步任务
  }

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

  private computeTask(): void {
    let count = 0;
    while (count < LARGE_NUMBER) {
      count++;
    }
  }

  // 运算任务异步处理
  private computeTaskAsync(): void {
    setTimeout(() => { // 这里使用setTimeout来实现异步延迟运行
      this.computeTask();
    }, DELAYED_TIME);
  }
}

下面使用Launch模板,对优化前同步执行耗时操作及优化后异步执行耗时操作的启动性能进行对比分析。分析阶段的起点Process Creating,阶段终点为First Frame - Render Phase。

优化前后的启动耗时如下图:

图27 优化前同步执行操作(computeTask),应用冷启动耗时

36

图28 优化后异步执行操作(computeTaskAsync),应用冷启动耗时

37

可见使用异步后,应用冷启动时间速度有了较大的提升,耗时从2.4s减少到了238.3ms。

使用本地缓存首页数据

在应用启动流程中,大部分应用的首页数据信息需要等待网络请求返回的数据解析结果,因此可以将首页数据通过[数据库]、[Preferences]、[文件]、[AppStorage]等方式进行缓存,再次冷启动时优先展示缓存数据,网络请求后再次刷新首页数据。

图29 使用本地缓存首页数据流程图
38

使用本地缓存优先展示,可以减少首帧展示完成时延,减少用户可见白屏或白块时间,提升用户的冷启动体验。

说明

应用需根据自身对于数据的时效性要求,来决定是否使用缓存数据。例如时效性要求为一天时,一天前保存的缓存数据就不适合进行展示,需从网络获取新数据进行展示,并更新本地缓存数据。

【场景示例】

应用首页需展示一张从网站获取的图片信息,在aboutToAppear()中发起网络请求,待数据返回解析后展示在首页上。之后我们将图片信息缓存至本地应用沙箱内,再次冷启动时首先从沙箱内获取图片信息,若存在即可解析并展示,在网络请求返回时再次更新图片信息。

以下为关键示例代码:

// Index.ets
import { http } from '@kit.NetworkKit';
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { fileIo as fs } from '@kit.CoreFileKit';

const PERMISSIONS: Array<Permissions> = [
  'ohos.permission.READ_MEDIA',
  'ohos.permission.WRITE_MEDIA'
];
AppStorage.link('net_picture');
PersistentStorage.persistProp('net_picture', '');

@Entry
@Component
struct Index {
  @State image: PixelMap | undefined = undefined;
  @State imageBuffer: ArrayBuffer | undefined = undefined; // 图片ArrayBuffer

  /**
   * 通过http的request方法从网络下载图片资源
   */
  async getPicture() {
    http.createHttp()
      .request('https://www.example1.com/POST?e=f&g=h',
        (error: BusinessError, data: http.HttpResponse) => {
          if (error) {
            return;
          }
          // 判断网络获取到的资源是否为ArrayBuffer类型
          if (data.result instanceof ArrayBuffer) {
            this.imageBuffer = data.result as ArrayBuffer;
          }
          this.transcodePixelMap(data);
        }
      )
  }

  /**
   * 使用createPixelMap将ArrayBuffer类型的图片装换为PixelMap类型
   * @param data:网络获取到的资源
   */
  transcodePixelMap(data: http.HttpResponse) {
    if (http.ResponseCode.OK === data.responseCode) {
      const imageData: ArrayBuffer = data.result as ArrayBuffer;
      // 通过ArrayBuffer创建图片源实例。
      const imageSource: image.ImageSource = image.createImageSource(imageData);
      const options: image.InitializationOptions = {
        'alphaType': 0, // 透明度
        'editable': false, // 是否可编辑
        'pixelFormat': 3, // 像素格式
        'scaleMode': 1, // 缩略值
        'size': { height: 100, width: 100 }
      }; // 创建图片大小

      // 通过属性创建PixelMap
      imageSource.createPixelMap(options).then((pixelMap: PixelMap) => {
        this.image = pixelMap;
        setTimeout(() => {
          if (this.imageBuffer !== undefined) {
            this.saveImage(this.imageBuffer);
          }
        }, 0)
      });
    }
  }

  /**
   * 保存ArrayBuffer到沙箱路径
   * @param buffer:图片ArrayBuffer
   * @returns
   */
  async saveImage(buffer: ArrayBuffer | string): Promise<void> {
    const context = getContext(this) as common.UIAbilityContext;
    const filePath: string = context.cacheDir + '/test.jpg';
    AppStorage.set('net_picture', filePath);
    const file = await fs.open(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    await fs.write(file.fd, buffer);
    await fs.close(file.fd);
  }

  async useCachePic(): Promise<void> {
    if (AppStorage.get('net_picture') !== '') {
      // 获取图片的ArrayBuffer
      const imageSource: image.ImageSource = image.createImageSource(AppStorage.get('net_picture'));
      const options: image.InitializationOptions = {
        'alphaType': 0, // 透明度
        'editable': false, // 是否可编辑
        'pixelFormat': 3, // 像素格式
        'scaleMode': 1, // 缩略值
        'size': { height: 100, width: 100 }
      };
      imageSource.createPixelMap(options).then((pixelMap: PixelMap) => {
        this.image = pixelMap;
      });
    }
  }

  async aboutToAppear(): Promise<void> {
    const context = getContext(this) as common.UIAbilityContext;
    const atManager = abilityAccessCtrl.createAtManager();
    await atManager.requestPermissionsFromUser(context, PERMISSIONS);
    this.useCachePic(); // 从本地缓存获取数据
    this.getPicture(); // 从网络端获取数据
  }

  build() {
    Column() {
      Image(this.image)
        .objectFit(ImageFit.Contain)
        .width('50%')
        .height('50%')
    }
  }
}

下面对优化前后启动性能进行对比分析。分析阶段的起点为启动Ability(即H:void OHOS::AppExecFwk::MainThread::HandleLaunchAbility的开始点),阶段终点为应用首次解析Pixelmap(即H:Napi execute, name:CreatePixelMap, traceid:0x0)后的第一个vsync(即H:ReceiveVsync dataCount:24Bytes now:timestamp expectedEnd:timestamp vsyncId:int的开始点)。

图30 优化前未使用本地缓存

39

图31 优化后使用本地缓存

40

对比数据如下:

方案阶段时长(毫秒)
(优化前)未使用本地缓存641.8
(优化后)使用本地缓存68.9

可以看到在使用本地缓存后,应用冷启动时从Ability启动到图片显示的阶段耗时明显减少。

优化首页显示速度

用户感知的启动是从点击应用入口到首页数据显示在屏幕上的过程,也可以看作是响应用户点击事件的一个过程,开发者可以通过

UI优化、并发优化、代码逻辑优化、IPC通信优化等方法来提升首页的响应速度

总结

本文主要介绍了应用冷启动的流程、如何识别和分析冷启动缓慢问题,同时介绍了针对应用冷启动流程各阶段,一些注意事项和优化方法

  • 启动页图标startWindowIcon分辨率建议不超过256px*256px。
  • 在AbilityStage、UIAbility和自定义组件的生命周期回调函数中不建议直接执行耗时任务,比如复杂的计算任务、同步文件读写等耗时任务,建议通过异步任务延迟处理或者放到其他线程执行。
  • import模块按需加载,移除初始化阶段不需要的模块导入,考虑动态加载耗时的模块。
  • 通过使用合理的布局结构、使用懒加载等UI优化方法来减少首帧绘制时间。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值