鸿蒙NEXT开发【Web场景性能优化指导】性能

概述

ArkWeb(方舟Web)是一个Web组件平台,旨在为应用程序提供展示Web页面内容的功能,并为开发者提供丰富的能力,包括页面加载、页面交互、页面调试等功能。在这个数字化时代,页面显示的速度直接体现了应用的流畅性,影响着用户对应用的印象和体验。快速加载并展示页面不仅可以吸引用户留在应用上,还能减少他们的等待时间和不耐烦情绪,从而提升用户的满意度。

Web页面的显示过程可以被分为多个阶段,包括DNS解析、建立连接、发送请求、接收响应、解析HTML、下载资源等步骤。在这个过程中,许多因素都会对页面显示速度产生影响,比如网络延迟、服务器响应时间、页面大小、资源压缩等。为了优化页面显示速度,开发者可以在Web页面加载、资源下载和页面渲染等方面进行优化,以提升性能和用户体验。本文将介绍以下常见的优化方式:

  • Web页面加载优化:Web页面加载速度对于用户体验至关重要,提高页面加载的速度可以直接提升应用的流畅性。
  • JSBridge:通过JSBridge通信,可以解决ArkTS环境的冗余切换,避免造成UI阻塞。
  • 同层渲染:通过将页面元素分层渲染,可以减少页面重绘和重排的次数,提高页面渲染效率。

ArkWeb(方舟Web)为开发者提供了优化页面显示速度的方法。通过采取这些优化方式,开发者可以改善应用程序的性能和用户体验,使用户能够更快速、更流畅地浏览Web页面,从而提升用户满意度和留存率。

Web页面加载性能优化指导

Web页面加载流程

如下图所示,Web页面加载流程包括网络连接、资源下载(包括等待网络资源下载)、DOM解析、JavaScript代码编译执行、渲染等。页面加载中,比较关键的节点有网络连接、资源下载和完整的页面渲染,本文将主要对以下关键节点的耗时进行优化:

  • 预解析:预解析指预先对DNS进行解析,可以节省DNS解析的时间,从而优化Web的加载耗时。
  • 预连接:预连接包含预解析的步骤,可以在用户请求页面之前提前进行DNS解析和socket连接建立,这样当用户真正请求页面时,服务器和浏览器之间已经建立好了连接,可以直接传输数据,减少了网络延迟,提升了页面加载速度。
  • 预下载:预下载指在页面加载之前提前下载所需的资源,以避免在页面加载过程中资源下载导致的阻塞和耗时。通过预下载,可以在浏览器加载页面时,提前获取到所需的资源如图片、CSS文件、JavaScript文件等。通过提前下载这些资源,可以避免在页面加载时因为资源未加载完成而导致页面渲染延迟的情况发生。通过合理地使用预下载技术,用户在访问页面时可以更快地看到页面内容,提高整体性能,提升用户体验。
  • 预渲染:预渲染指在后台对需要加载的页面进行预先渲染,提前完成整个页面加载的流程。当用户需要访问该页面时,可以直接切换至前台展示,实现页面“秒开”的效果。预渲染要求在进行DOM解析、JavaScript执行和页面渲染之前,已经完成了所需资源的下载工作,否则可能会导致页面内容不完整或者渲染错误的情况。通过预渲染,可以显著减少用户等待页面加载的时间,特别是对于一些需要加载大量资源或者有复杂交互的页面。
  • 预取POST:预取POST指当即将加载的Web页面中存在POST请求且POST请求耗时较长时,可对POST请求进行预获取,消除等待POST请求数据下载完成的耗时,当用户真正发起POST请求时,进行拦截替换,加快页面加载速度,提高用户体验。
  • 预编译JavaScript生成字节码缓存(Code Cache):该方案会将使用到的JavaScript文件编译成字节码并缓存到本地,在页面首次加载时节省编译时间。
  • 支持自定义协议的JavaScript生成字节码缓存(Code Cache):该方案会将支持自定义协议的JavaScript文件编译成字节码并缓存到本地,在页面首次加载时节省编译时间。
  • 离线资源免拦截注入:在页面加载之前,离线资源免拦截注入会将需要使用的图片、样式表和脚本资源注入到内存缓存中,节省页面首次加载时的网络请求时间。
  • 资源拦截替换加速:在原本的资源拦截替换接口基础上,资源拦截替换加速支持了ArrayBuffer格式的入参,开发者无需在应用侧进行ArrayBuffer到String格式的转换,可直接使用ArrayBuffer格式的数据进行拦截替换。

图1 Web页面加载流程
1

由于所有的关键点都是建立在预处理的思路上,因此如果用户实际并未打开预处理的Web页面,将会造成额外的资源消耗。 各优化方法具体的效果、代价和适用场景的对比如下表所示。

优化方法效果代价适用场景
预解析消除域名解析额外的域名解析中高概率被使用的Web页面
预连接消除域名解析、网络连接耗时额外的网络连接资源中高概率被使用的Web页面
预下载消除网络GET请求下载带来的耗时及阻塞DOM解析、JavaScript执行的耗时额外的网络连接、下载、存储资源高概率被使用的Web页面
预渲染能实现页面“秒开”效果,将页面加载时延降到最低额外的网络连接、下载、存储和渲染消耗超高概率被使用的Web页面
预取POST消除网络POST请求下载带来的耗时及阻塞DOM解析、JavaScript执行的耗时额外的网络连接、下载、存储资源高概率被使用的Web页面
预编译JavaScript生成字节码缓存消除JavaScript编译的耗时额外的存储资源加载HTTP/HTTPS协议JavaScript的Web页面,在第一及第二次优化加载性能
支持自定义协议生成字节码缓存消除JavaScript编译的耗时额外的存储资源加载自定义协议JavaScript的Web页面,在第三次及之后的时机优化加载性能
离线资源免拦截注入消除资源加载到内存的耗时额外的存储资源高概率被使用的资源
资源拦截替换加速节省了转换时间,同时对ArrayBuffer格式的数据传输方式进行了优化-ArrayBuffer格式的数据传输

预解析和预连接优化

原理介绍

如下图所示,在应用启动和UIAbility的onCreate生命周期后,Web组件才能进行初始化和运行。在ArkWeb组件运行阶段,会经过onAppear、load、onPageBegin、onPageEnd步骤。预解析、预连接优化适用于Web页面启动和跳转场景,例如,应用启动时需要加载Web首页。当开发者已经创建一个ArkWeb组件的实例后,可以选择不同时机对当前ArkWeb组件设置URL并进行预解析、预连接:

  • 如下图中a节点所示,如果是应用首页,推荐在ArkWeb组件初始化创建后设置首页的URL,进行预解析、预连接;
  • 如下图中b节点所示,如果是应用内页面,推荐ArkWeb组件onAppear阶段设置当前页面的URL,进行预解析、预连接;
  • 如下图中c节点所示,当前页面完成加载后,可以设置用户下一步可能点击页面的URL,进行预解析、预连接,推荐在onPageEnd及后续时机进行。

图2 预连接优化原理图
2

说明

在设置预连接进行优化时,需要注意:

  • 预连接存在时效性,建议应用在5分钟内复用已建立的连接,超时后已建立的连接会被关闭。
  • 预连接本身存在耗时,建议预加载时间适当提前于页面实际时间,建议预留150ms以上的提前量。
  • 当前页面加载完成后,即onPageEnd回调后,可复用当前ArkWeb组件预连接新的页面或预下载新的页面的资源。

实践案例

案例一:如果要提前对应用的首页进行操作,可以通过initializeBrowserEngine()来提前初始化ArkWeb组件的内核,然后在初始化内核后调用prepareForPageLoad()对即将要加载的页面进行预连接。在prepareForPageLoad中,指定第二个参数为true,代表要进行预连接,如果为false,该接口只会对网址进行DNS预解析。具体代码如下所示。

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

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
    console.log('EntryAbility onCreate');
    webview.WebviewController.initializeWebEngine();
    // 预连接时,需要將'https://www.example.com'替换成真实要访问的网站地址。
    // 指定第二个参数为true,代表要进行预连接,如果为false该接口只会对网址进行DNS预解析
    webview.WebviewController.prepareForPageLoad('https://www.example.com/', true, 2);
    AppStorage.setOrCreate('abilityWant', want);
    console.log('EntryAbility onCreate done');
  }
}

说明

prepareForPageLoad预连接和预解析只和host相关,URL带参数的情况下也能进行预连接、预解析。

案例二:如果要提前对当前页面的Web页面进行连接,可以在Web组件的onAppear方法中对要加载的页面进行预连接,具体代码如下所示。

import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();
  build() {
    Column() {
      Button('loadData')
        .onClick(() => {
          if (this.webviewController.accessBackward()) {
            this.webviewController.backward();
          }
        })
      Web({ src: 'https://www.example.com/cn/', controller: this.webviewController})
        .onAppear(() => {
          // 指定第二个参数为true,代表要进行预连接,如果为false该接口只会对网址进行dns预解析
          // 第三个参数为要预连接socket的个数。最多允许6个。
          webview.WebviewController.prepareForPageLoad('https://www.example.com/cn/', true, 2);
        })
    }
  }
}

案例三:当前页面完成显示后,要提前对下一个即将要显示的页进行连接,可以在onPageEnd ()中对即将要加载的页面进行预连接。

import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();
  build() {
    Column() {
      Web({ src: 'https://www.example.com/', controller: this.webviewController})
        .onPageEnd(() => {
          // 预连接https://www.example1.com/。
          webview.WebviewController.prepareForPageLoad('https://www.example.com/', true, 120);
        })
    }
  }
}

预下载优化

原理介绍

如下图所示,ArkWeb组件运行包含onAppear、load、onPageBegin、onPageEnd,开发者可以在onPageEnd设置下一步可能访问的URL,提前下载页面所需的资源。这种方式适用于Web页面启动和跳转场景,例如,在引导流程完成后,提前预下载需要跳转的页面。当开发者已经创建一个ArkWeb组件的实例后,可以在当前页面加载完成后,在当前ArkWeb组件设置URL并进行预下载。本方案能消除资源下载耗时以及资源下载引发的页面DOM解析,JS代码编译执行的阻塞耗时,预估收益可在数百毫秒(依赖当前网络环境)。

图3 预下载优化原理图
3

说明

  • 预下载行为包括连接和资源下载,连接和资源下载耗时可能达到700ms以上(依赖当前网络环境),建议开发者为预下载留出足够的时间。
  • 预下载行为相比于预连接会消耗额外的流量、内存,建议针对高频页面使用。
  • 预下载完成后,当前ArkWeb组件使用的连接将被关闭,如果想要进行下一个页面的预连接,需要显式调用预连接接口。

实践案例

如下示例所示,在onPageEnd阶段,调用[prefetchPage]方法,即可提前下载页面所需的资源,包括主资源子资源,但不会执行网页JavaScript代码或呈现网页,以加快加载速度。

import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();
  build() {
    Column() {
      Web({ src: 'https://www.example.com/', controller: this.webviewController})
        .onPageEnd(() => {
          // 预加载https://www.iana.org/help/example-domains。
          this.webviewController.prefetchPage('https://www.iana.org/help/example-domains');
        })
    }
  }
}

说明

prefetchPage会对下载的资源进行缓存,缓存的时效为5分钟。

预渲染优化

原理介绍

预渲染优化适用于Web页面启动和跳转场景,例如,进入首页后,跳转到其他子页。与预连接、预下载不同的是,预渲染需要开发者额外创建一个新的ArkWeb组件,并在后台对其进行预渲染,此时该组件并不会立刻挂载到组件树上,即不会对用户呈现(组件状态为Hidden和InActive),开发者可以在后续使用中按需动态挂载。

具体原理如下图所示,首先需要定义一个自定义组件封装ArkWeb组件,该ArkWeb组件被离线创建,被包含在一个无状态的节点NodeContainer中,并与相应的NodeController绑定。该ArkWeb组件在后台完成预渲染后,在需要展示该ArkWeb组件时,再通过NodeController将其挂载到ViewTree的NodeContainer中,即通过NodeController绑定到对应的NodeContainer组件。预渲染通用实现的步骤如下:

  1. 创建自定义ArkWeb组件:开发者需要根据实际场景创建封装一个自定义的ArkWeb组件,该ArkWeb组件被离线创建。
  2. 创建并绑定[NodeController]:实现NodeController接口,用于自定义节点的创建、显示、更新等操作的管理。并将对应的NodeController对象放入到容器中,等待调用。
  3. 绑定[NodeContainer]组件:将NodeContainer与NodeController进行绑定,实现动态组件页面显示。

图4 预渲染优化原理图
4

说明

预渲染相比于预下载、预连接方案,会消耗更多的内存、算力,仅建议针对高频页面使用,单应用后台创建的ArkWeb组件要求小于200个。

实践案例

创建载体,并创建ArkWeb组件

// 载体Ability
// EntryAbility.ets
import {createNWeb} from '../pages/common';
onWindowStageCreate(windowStage: window.WindowStage): void {
  windowStage.loadContent('pages/Index', (err, data) => {
    // 创建ArkWeb动态组件(需传入UIContext),loadContent之后的任意时机均可创建
    createNWeb('https://www.example.com', windowStage.getMainWindowSync().getUIContext());
    if (err.code) {
      return;
    }
  });
}

创建NodeContainer和对应的NodeController,渲染后台ArkWeb组件

// 创建NodeController
// common.ets
import { UIContext } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';
import { NodeController, BuilderNode, Size, FrameNode }  from '@kit.ArkUI';
// @Builder中为动态组件的具体组件内容
// Data为入参封装类
class Data{
  url: string = 'https://www.example.com';
  controller: WebviewController = new webview.WebviewController();
}

@Builder
function WebBuilder(data:Data) {
  Column() {
    Web({ src: data.url, controller: data.controller })
      .domStorageAccess(true)
      .zoomAccess(true)
      .fileAccess(true)
      .mixedMode(MixedMode.All)
      .width('100%')
      .height('100%')
  }
}

let wrap = wrapBuilder<Data[]>(WebBuilder);

// 用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用
export class myNodeController extends NodeController {
  private rootnode: BuilderNode<Data[]> | null = null;
  private root: FrameNode | null = null;
  // 必须要重写的方法,用于构建节点数、返回节点挂载在对应NodeContainer中
  // 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新
  makeNode(uiContext: UIContext): FrameNode | null {
    console.log(' uicontext is undifined : '+ (uiContext === undefined));
    if (this.rootnode != null) {
      const parent = this.rootnode.getFrameNode()?.getParent();
      if (parent) {
        console.info(JSON.stringify(parent.getInspectorInfo()));
        parent.removeChild(this.rootnode.getFrameNode());
        this.root = null;
      }
      this.root = new FrameNode(uiContext);
      this.root.appendChild(this.rootnode.getFrameNode());
      // 返回FrameNode节点
      return this.root;
    }
    // 返回null控制动态组件脱离绑定节点
    return null;
  }
  // 当布局大小发生变化时进行回调
  aboutToResize(size: Size) {
    console.log('aboutToResize width : ' + size.width  +  ' height : ' + size.height )
  }

  // 当controller对应的NodeContainer在Appear的时候进行回调
  aboutToAppear() {
    console.log('aboutToAppear')
  }

  // 当controller对应的NodeContainer在Disappear的时候进行回调
  aboutToDisappear() {
    console.log('aboutToDisappear')
  }

  // 此函数为自定义函数,可作为初始化函数使用
  // 通过UIContext初始化BuilderNode,再通过BuilderNode中的build接口初始化@Builder中的内容
  initWeb(url:string, uiContext:UIContext, control:WebviewController) {
    if(this.rootnode != null)
    {
      return;
    }
    // 创建节点,需要uiContext
    this.rootnode = new BuilderNode(uiContext)
    // 创建动态Web组件
    this.rootnode.build(wrap, { url:url, controller:control })
  }
}
// 创建Map保存所需要的NodeController
let NodeMap:Map<string, myNodeController | undefined> = new Map();
// 创建Map保存所需要的WebViewController
let controllerMap:Map<string, WebviewController | undefined> = new Map();

// 初始化需要UIContext 需在Ability获取
export const createNWeb = (url: string, uiContext: UIContext) => {
  // 创建NodeController
  let baseNode = new myNodeController();
  let controller = new webview.WebviewController() ;
  // 初始化自定义web组件
  baseNode.initWeb(url, uiContext, controller);
  controllerMap.set(url, controller)
  NodeMap.set(url, baseNode);
}
// 自定义获取NodeController接口
export const getNWeb = (url : string) : myNodeController | undefined => {
  return NodeMap.get(url);
}

通过NodeContainer使用已经预渲染的页面

// 使用NodeController的Page页
// Index.ets
import {createNWeb, getNWeb} from './common';

@Entry
@Component
struct Index {
  build() {
    Row() {
      Column() {
        // NodeContainer用于与NodeController节点绑定,rebuild会触发makeNode
        // Page页通过NodeContainer接口绑定NodeController,实现动态组件页面显示
        NodeContainer(getNWeb('https://www.example.com'))
          .height('90%')
          .width('100%')
      }
      .width('100%')
    }
    .height('100%')
  }
}

预取POST请求优化

原理介绍

预取POST请求适用于Web页面启动和跳转场景,当即将加载的Web页面中存在POST请求且POST请求耗时较长时,会导致页面加载时间增加,可以选择不同时机对POST请求进行预获取,消除等待POST请求数据下载完成的耗时,具体有以下两种场景可供参考:

  1. 如果是应用首页,推荐在ArkWeb组件创建后或者提前初始化Web内核后,对首页的POST请求进行预取,如XComponent.onCreate()、自定义组件的生命周期函数aboutToAppear()。
  2. 当前页面完成加载后,可以对用户下一步可能点击页面的POST请求进行预取,推荐在Web组件的生命周期函数onPageEnd()及后继时机进行。

说明

  1. 本方案能消除POST请求下载耗时,预计收益可能在百毫秒(依赖POST请求的数据内容和当前网络环境)。
  2. 预取POST请求行为包括连接和资源下载,连接和资源加载耗时可能达到百毫秒(依赖POST请求的数据内容和当前网络环境),建议开发者为预下载留出足够的时间。
  3. 预取POST请求行为相比于预连接会消耗额外的流量、内存,建议针对高频页面使用。
  4. POST请求具有一定的即时性,预取POST请求需要指定恰当的有效期。
  5. 目前仅支持预取Context-Type为application/x-www-form-urlencoded的POST请求。最多可以预获取6个POST请求。如果要预获取第7个,会自动清除最早预获取的POST缓存。开发者也可以通过clearPrefetchedResource()接口主动清除后续不再使用的预获取资源缓存。
  6. 如果要使用预获取的资源缓存,开发者需要在正式发起的POST请求的请求头中增加键值“ArkWebPostCacheKey”,其内容为对应缓存的cacheKey。

实践案例

案例一:加载包含POST请求的首页

说明

预取POST属于异步行为,不会影响首页加载时间

【不推荐用法】

当首页中包含POST请求,且POST请求耗时较长时,不推荐直接加载Web页面

// xxx.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: 'https://www.example.com/', controller: this.webviewController })
    }
  }
}

【推荐用法】

通过预取POST加载包含POST请求的首页,具体步骤如下:

  1. 通过initializeWebEngine()来提前初始化Web组件的内核,然后在初始化内核后调用prefetchResource()预获取将要加载页面中的POST请求。
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';

export default class EntryAbility extends UIAbility {
  // EntryAbility.ets
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    console.log('EntryAbility onCreate.');
    webview.WebviewController.initializeWebEngine();
    // 预获取时,需要将'https://www.example1.com/POST?e=f&g=h'替换成为真实要访问的网站地址
    webview.WebviewController.prefetchResource(
      {
        url: 'https://www.example.com/POST?e=f&g=h',
        method: 'POST',
        formData: 'a=x&b=y'
      },
      [{
        headerKey: 'c',
        headerValue: 'z'
      }],
      'KeyX', 500
    );
    AppStorage.setOrCreate('abilityWant', want);
    console.log('EntryAbility onCreate done.');
  }
  // ...
}
  1. 通过Web组件,加载包含POST请求的Web页面。
// xxx.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: 'https://www.example.com/', controller: this.webviewController })
        .onPageEnd(() => {
          // 清除后续不再使用的预获取资源缓存
          webview.WebviewController.clearPrefetchedResource(['KeyX']);
        })
    }
  }
}
  1. 在页面将要加载的JavaScript文件中,发起POST请求,设置请求响应头ArkWebPostCacheKey为对应预取时设置的cachekey值’KeyX’。
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://www.example.com/POST?e=f&g=h', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.setRequestHeader('ArkWebPostCacheKey', 'KeyX');
xhr.onload = function () {
  if (xhr.status >= 200 && xhr.status < 300) {
    console.log('成功', xhr.responseText);
  } else {
    console.error('请求失败');
  }
}
const formData = new FormData();
formData.append('a', 'x');
formData.append('b', 'y');
xhr.send(formData);

案例二:加载包含POST请求的下一页

【不推荐用法】

当即将加载的Web页面中包含POST请求,且POST请求耗时较长时,不推荐直接加载Web页面。

// xxx.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
      Button('加载页面')
        .onClick(() => {
          // url请替换为真实地址
          this.controller.loadUrl('https://www.example1.com/');
        })
      Web({ src: 'https://www.example.com/', controller: this.webviewController })
    }
  }
}

【推荐用法】

通过预取POST加载包含POST请求的下一个跳转页面,具体步骤如下:

  1. 当前页面完成显示后,使用onPageEnd()对即将要加载页面中的POST请求进行预获取。
// xxx.ets
import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  webviewController: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
      Button('加载页面')
        .onClick(() => {
          // 性能打点
          hiTraceMeter.startTrace('getMessageData', 1);
          // url请替换为真实地址
          this.controller.loadUrl('https://www.example1.com/');
        })
      Web({ src: 'https://www.example.com/', controller: this.webviewController })
        .onPageEnd(() => {
          // 预获取时,需要将'https://www.example1.com/POST?e=f&g=h'替换成为真实要访问的网站地址
          webview.WebviewController.prefetchResource(
            {
              url: 'https://www.example1.com/POST?e=f&g=h',
              method: 'POST',
              formData: 'a=x&b=y'
            },
            [{
              headerKey: 'c',
              headerValue: 'z'
            }],
            'KeyX', 500
          );
        })
    }
  }
}
  1. 将要加载的页面中,JavaScript正式发起POST请求,设置请求响应头ArkWebPostCacheKey为对应预取时设置的cachekey值’KeyX’。
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://www.example1.com/POST?e=f&g=h', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.setRequestHeader('ArkWebPostCacheKey', 'KeyX');
xhr.onload = function () {
  if (xhr.status >= 200 && xhr.status < 300) {
    console.log('成功', xhr.responseText);
  } else {
    console.error('请求失败');
  }
}
const formData = new FormData();
formData.append('a', 'x');
formData.append('b', 'y');
xhr.send(formData);

预编译JavaScript生成字节码缓存(Code Cache)

原理介绍

预编译JavaScript生成字节码缓存适用于在页面加载之前提前将即将使用到的JavaScript文件编译成字节码并缓存到本地,在页面首次加载时节省编译时间。

开发者需要创建一个无需渲染的离线Web组件,用于进行预编译,在预编译结束后使用其他Web组件加载对应的业务网页。

说明

  1. 仅使用HTTP或HTTPS协议请求的JavaScript文件可以进行预编译操作。
  2. 不支持使用了ES6 Module的语法的JavaScript文件生成预编译字节码缓存。
  3. 通过配置参数中响应头中的E-Tag、Last-Modified对应的值标记JavaScript对应的缓存版本,对应的值发生变动则更新字节码缓存。
  4. 不支持本地JavaScript文件预编译缓存。

实践案例

案例一:在未使用预编译JavaScript前提下,启动加载Web页面

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

@Entry
@Component
struct Index {
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
      Button('加载页面')
        .onClick(() => {
          // 性能打点
          hiTraceMeter.startTrace('unPrecompileJavaScript', 1);
          // url请替换为真实地址
          this.controller.loadUrl('https://www.example.com/b.html');
        })
      Web({ src: 'https://www.example.com/a.html', controller: this.controller })
        .fileAccess(true)
        .onPageBegin((event) => {
          console.log(`load page begin: ${event?.url}`);
        })
        .onPageEnd((event) => {
          // 性能打点
          hiTraceMeter.finishTrace('unPrecompileJavaScript', 1);
          console.log(`load page end: ${event?.url}`);
        })
    }
  }
}

点击“加载页面”按钮,性能打点数据如下,getMessageData进程中的Duration为加载页面开始到结束的耗时:

5

说明

JavaScript的编译耗时受JavaScript文件大小和逻辑复杂度影响,此示例中测试结果为极限场景下,JavaScript文件大小约5.76Mb,共约5.3万行代码编译的耗时

案例二:使用预编译JavaScript生成字节码缓存,具体步骤如下:

  1. 配置预编译的JavaScript文件信息。
import { webview } from '@kit.ArkWeb';
import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';

interface Config {
  url: string,
  localPath: string, // 本地资源路径
  options: webview.CacheOptions
}

@Entry
@Component
struct Index {
  controller: webview.WebviewController = new webview.WebviewController();

  // 配置预编译的JavaScript文件信息
  configs: Array<Config> = [
    {
      url: 'https://www.example.com/example.js',
      localPath: 'example.js',
      options: {
        responseHeaders: [
          { headerKey: 'E-Tag', headerValue: 'xxx' },
          { headerKey: 'Last-Modified', headerValue: 'Web, 21 Mar 2024 10:38:41 GMT' }
        ]
      }
    }
  ]
// ...
  1. 读取配置,进行预编译。
Web({ src: 'https://www.example.com/a.html', controller: this.controller })
  .onControllerAttached(async () => {
    // 读取配置,进行预编译
    for (const config of this.configs) {
      let content = await getContext().resourceManager.getRawFileContentSync(config.localPath);

      try {
        this.controller.precompileJavaScript(config.url, content, config.options)
          .then((errCode: number) => {
            console.log('precompile successfully!' );
          }).catch((errCode: number) => {
          console.error('precompile failed.' + errCode);
        })
      } catch (err) {
        console.error('precompile failed!.' + err.code + err.message);
      }
    }
  })

点击“加载页面”按钮,性能打点数据如下,getMessageData进程中的Duration为加载页面开始到结束的耗时:

6

说明

当需要更新本地已经生成的编译字节码时,修改cacheOptions参数中的responseHeaders中的E-Tag或Last-Modified响应头对应的值,再次调用接口即可。

总结

页面加载方式耗时(局限不同设备和场景,数据仅供参考)说明
直接加载Web页面3183ms在触发页面加载时才进行JavaScript编译,增加加载时间
预编译JavaScript生成字节码缓存268ms加载页面前完成预编译JavaScript,节省了跳转页面首次加载的编译时间

支持自定义协议的JavaScript生成字节码缓存(Code Cache)

原理介绍

支持自定义协议的JavaScript生成字节码缓存适用于在页面加载时存在自定义协议的JavaScript文件,支持其生成字节码缓存到本地,在页面非首次加载时节省编译时间。具体操作步骤如下:

  1. 开发者首先需要在Web组件运行前,向Web组件注册自定义协议。
  2. 其次需要拦截自定义协议的JavaScript,设置ResponseData和ResponseDataID,ResponseData为JavaScript内容,ResponseDataID用于区分JavaScript内容是否发生变更。若JavaScript内容变更,ResponseDataID需要一起变更。

实践案例

案例一:调用ArkTS接口, webview.WebviewController.customizeSchemes(schemes: Array): void

【不推荐用法】

直接加载包含自定义协议的JavaScript的Web页面

// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct Index {
  controller: webview.WebviewController = new webview.WebviewController();
  // 创建scheme对象,isCodeCacheSupported为false时不支持自定义协议的JavaScript生成字节码缓存,字段默认为false
  scheme: webview.WebCustomScheme = {
    schemeName: 'scheme',
    isSupportCORS: true,
    isSupportFetch: true,
    isCodeCacheSupported: false
  };
  // 请求数据
  @State jsData: string = 'xxx';

  aboutToAppear(): void {
    try {
      webview.WebviewController.customizeSchemes([this.scheme]);
    } catch (error) {
      const e: BusinessError = error as BusinessError;
      console.error(`ErrorCode: ${e.code}, Message: ${e.message}`);
    }
  }

  build() {
    Column({ space: 10 }) {
      Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }) {
        Web({
          // 需将'https://www.example.com/'替换为真是的包含自定义协议的JavaScript的Web页面地址
          src: 'https://www.example.com/',
          controller: this.controller
        })
          .fileAccess(true)
          .javaScriptAccess(true)
          .onInterceptRequest(event => {
            const responseResource: WebResourceResponse = new WebResourceResponse();
            // 拦截页面请求
            if (event?.request.getRequestUrl() === 'scheme1://www.example.com/test.js') {
              responseResource.setResponseHeader([
                {
                  headerKey: 'ResponseDataId',
                  // 格式:不超过13位的纯数字。JS识别码,JS有更新时必须更新该字段
                  headerValue: '0000000000001'
                }
              ]);
              responseResource.setResponseData(this.jsData);
              responseResource.setResponseEncoding('utf-8');
              responseResource.setResponseMimeType('application/javascript');
              responseResource.setResponseCode(200);
              responseResource.setReasonMessage('OK');
              return responseResource;
            }
            return null;
          })
      }
    }
  }
}

性能打点数据如下,getMessageData进程中的Duration为加载页面开始到结束的耗时:

6

【推荐用法】

支持自定义协议JavaScript生成字节码缓存,具体步骤如下:

  1. 将scheme对象属性isCodeCacheSupported设置为true,支持自定义协议的JavaScript生成字节码缓存。
  scheme1: webview.WebCustomScheme = { schemeName: "scheme1", isSupportCORS: true, isSupportFetch: true, isCodeCacheSupported: true }
  1. 在Web组件运行前,向Web组件注册自定义协议。

    说明

    不得与Web内核内置协议相同。

// xxx.ets
aboutToAppear(): void {
  try {
    webview.WebviewController.customizeSchemes([this.scheme1])
  } catch (error) {
    let e: business_error.BusinessError = error as business_error.BusinessError;
    console.error(`ErrorCode: ${e.code},  Message: ${e.message}`);
  }
}
  1. 拦截自定义协议的JavaScript,设置ResponseData和ResponseDataID。ResponseData为JavaScript内容,ResponseDataID用于区分JavaScript内容是否发生变更。

    说明

    若JavaScript内容变更,ResponseDataID需要一起变更。

// xxx.ets
Web({
  src: $rawfile('index2.html'),
  controller: this.webController
})
  .fileAccess(true)
  .javaScriptAccess(true)
  .width('100%')
  .height('100%')
  .onConsole((event) => {
    console.log('ets onConsole:' + event?.message.getMessage());
    return false
  })
  .onInterceptRequest((event) => {
    let responseResource = new WebResourceResponse()
    // 拦截页面请求
    if (event?.request.getRequestUrl() == 'https://www.intercept.com/test-cc.js') {
      // 构造响应数据
      responseResource.setResponseHeader([
        {
          headerKey: 'ResponseDataID',
          headerValue: '0000000000002'
          // 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
        }]);
      responseResource.setResponseData(this.jsData);
      responseResource.setResponseEncoding('utf-8');
      responseResource.setResponseMimeType('application/javascript');
      responseResource.setResponseCode(200);
      responseResource.setReasonMessage('OK');
      return responseResource;

    }
    if (event?.request.getRequestUrl() == 'scheme1://www.intercept.com/test-cc2.js') {
      // 构造响应数据
      responseResource.setResponseHeader([
        {
          headerKey: 'ResponseDataID',
          headerValue: '0000000000001'
          // 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
        }]);
      responseResource.setResponseData(this.jsData2);
      responseResource.setResponseEncoding('utf-8');
      responseResource.setResponseMimeType('application/javascript');
      responseResource.setResponseCode(200);
      responseResource.setReasonMessage('OK');
      return responseResource;
    }
    return null;
  })

性能打点数据如下,getMessageData进程中的Duration为加载页面开始到结束的耗时:

7

案例二:调用Native接口,int32_t OH_ArkWeb_RegisterCustomSchemes(const char * scheme, int32_t option)

【不推荐用法】

性能打点数据如下,getMessageData进程中的Avg Wall Duration为两次加载页面开始到结束的平均耗时:

8

【推荐用法】

支持将支持自定义协议的JavaScript资源生成Code Cache,具体步骤如下:

  1. 注册三方协议配置时,传入ARKWEB_SCHEME_OPTION_CODE_CACHE_ENABLED参数。
// 注册三方协议的配置,需要在Web内核初始化之前调用,否则会注册失败。
static napi_value RegisterCustomSchemes(napi_env env, napi_callback_info info) {
    OH_LOG_INFO(LOG_APP, "register custom schemes");
    OH_ArkWeb_RegisterCustomSchemes("custom", ARKWEB_SCHEME_OPTION_STANDARD | ARKWEB_SCHEME_OPTION_CORS_ENABLED | ARKWEB_SCHEME_OPTION_CODE_CACHE_ENABLED);
    return nullptr;
}
  1. 设置ResponsesDataId。
// 在worker线程中读取rawfile,并通过ResourceHandler返回给Web内核。
void RawfileRequest::ReadRawfileDataOnWorkerThread() {
    OH_LOG_INFO(LOG_APP, "read rawfile in worker thread.");
    const struct UrlInfo {
        std::string resource;
        std::string mimeType;
    } urlInfos[] = {{"local.html", "text/html"},
                    {"local_script.js", "text/javascript"},
                    {"test-cc.js", "text/javascript"}
                    };

    if (!resourceManager()) {
        OH_LOG_ERROR(LOG_APP, "read rawfile error, resource manager is nullptr.");
        return;
    }

    RawFile *rawfile = OH_ResourceManager_OpenRawFile(resourceManager(), rawfilePath().c_str());
    if (!rawfile) {
        OH_ArkWebResponse_SetStatus(response(), 404);
    } else {
        OH_ArkWebResponse_SetStatus(response(), 200);
    }

    for (auto &urlInfo : urlInfos) {
        if (urlInfo.resource == rawfilePath()) {
            OH_ArkWebResponse_SetMimeType(response(), urlInfo.mimeType.c_str());
            break;
        }
    }

    if ("test-cc.js" == rawfilePath()) {
        OH_LOG_ERROR(LOG_APP, "OH_ArkWebResponse_SetHeaderByName ResponseDataID");
        OH_ArkWebResponse_SetHeaderByName(response(), "ResponseDataID", "0000000000001", true);
    }
    OH_ArkWebResponse_SetCharset(response(), "UTF-8");
  1. 注册三方协议的配置,设置SchemeHandler。
// EntryAbility.ets
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  // 注册三方协议的配置。
  testNapi.registerCustomSchemes();
  // 初始化Web组件内核,该操作会初始化Browser进程以及创建BrowserContext。
  webview.WebviewController.initializeWebEngine();
  // 设置SchemeHandler。
  testNapi.setSchemeHandler();
}

性能打点数据如下,getMessageData进程中的Avg Wall Duration为两次加载页面开始到结束的平均耗时:

9

总结(以Native接口性能数据举例)

页面加载方式耗时(局限不同设备和场景,数据仅供参考)说明
直接加载Web页面8ms在触发页面加载时才进行JavaScript编译,增加加载时间
自定义协议的JavaScript生成字节码缓存4ms支持自定义协议头的JavaScript文件在第二次加载JavaScript时生成Code Cache,节约了第三次及之后的页面加载或跳转的自定义协议JavaScript文件的编译时间,提升了页面加载和跳转的性能

离线资源免拦截注入

原理介绍

在页面加载之前,离线资源免拦截注入会将需要使用的图片、样式表和脚本资源注入到内存缓存中,节省页面首次加载时的网络请求时间。

说明

  1. 开发者需创建一个无需渲染的离线Web组件,用于将资源注入到内存缓存中,使用其他Web组件加载对应的业务网页。
  2. 仅使用HTTP或HTTPS协议请求的资源可被注入进内存缓存。
  3. 内存缓存中的资源由内核自动管理,当注入的资源过多导致内存压力过大,内核自动释放未使用的资源,应避免注入大量资源到内存缓存中。
  4. 正常情况下,资源的有效期由提供的Cache-Control或Expires响应头控制其有效期,默认的有效期为86400秒,即1天。
  5. 资源的MIMEType通过提供的参数中的Content-Type响应头配置,Content-Type需符合标准,否则无法正常使用,MODULE_JS必须提供有效的MIMEType,其他类型可不提供。
  6. 仅支持通过HTML中的标签加载。
  7. 如果业务网页中的script标签使用了crossorigin属性,则必须在接口的responseHeaders参数中设置Cross-Origin响应头的值为anoymous或use-credentials。
  8. 当调用webview.WebviewController.SetRenderProcessMode(web_webview.RenderProcessMode.MULTIPLE)接口后,应用会启动多渲染进程模式,此方案在此场景下不会生效。
  9. 单次调用最大支持注入30个资源,单个资源最大支持10Mb。

实践案例

案例一:直接加载Web页面

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

@Entry
@Component
struct Index {
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
      Button('加载页面')
        .onClick(() => {
          // 性能打点
          hiTraceMeter.startTrace('getMessageData', 1);
          this.controller.loadUrl('https://www.example.com/b.html');
        })
      Web({ src: 'https://www.example.com/a.html', controller: this.controller })
        .fileAccess(true)
        .onPageEnd(() => {
          // 性能打点
          hiTraceMeter.finishTrace('getMessageData', 1);
        })
    }
  }
}

性能打点数据如下,getMessageData进程中的Duration为加载页面开始到结束的耗时:

10

案例二:使用资源免拦截注入加载Web页面,请参考以下步骤:

  1. 创建资源配置
export interface ResourceConfig {
  urlList: Array<string>,
  type: webview.OfflineResourceType,
  responseHeaders: Array<Header>,
  localPath: string,
}

export interface ExceptionResource {
  console: string,
  urlList: Array<string> | undefined | null;
  type: webview.OfflineResourceType  | undefined | null,
  responseHeaders: Array<Header> | undefined | null,
  resource?: Uint8Array | undefined | null
  localPath?: string,
}

export const baseURL = 'http://localhost:8083/resource/';
export const baseURL1 = 'http://localhost:8083/resource1/";

export const basicResources: Array<ResourceConfig> = [
  {
    localPath: "in_cache_middle.png",
    urlList: [
      baseURL,
      baseURL + "request.png",
      baseURL1 + "request.png",
    ],
    type: webview.OfflineResourceType.IMAGE,
    responseHeaders: []
  },
  {
    localPath: "in_cache.js",
    urlList: [
      baseURL,
      baseURL + "request.js",
      baseURL1 + "request.js"
    ],
    type: webview.OfflineResourceType.CLASSIC_JS,
    responseHeaders: [
      {headerKey: "Content-Type", headerValue: "text/javascript" },
      {headerKey: "Cache-Control", headerValue: "max-age=100000" },
    ]
  },
  {
    localPath: "in_cache_module1.js",
    urlList: [
      baseURL + "request_module1.js",
    ],
    type: webview.OfflineResourceType.MODULE_JS,
    responseHeaders: [
      {headerKey: "Content-Type", headerValue: "application/javascript" },
      {headerKey: "Access-Control-Allow-Origin" , headerValue: "*"},
      {headerKey: "Cache-Control", headerValue: "max-age=100000" },
    ]
  },
  {
    localPath: "in_cache_module2.js",
    urlList: [
      baseURL + "request_module2.js",
    ],
    type: webview.OfflineResourceType.MODULE_JS,
    responseHeaders: [
      {headerKey: "Content-Type", headerValue: "application/javascript" },
      {headerKey: "Access-Control-Allow-Origin" , headerValue: "*"},
      {headerKey: "Cache-Control", headerValue: "max-age=100000" },
    ]
  },
  {
    localPath: "in_cache.css",
    urlList: [
      baseURL,
      baseURL + "request.css",
      baseURL1 + "request.css",
    ],
    type: webview.OfflineResourceType.CSS,
    responseHeaders: [
      {headerKey: "resource-Type", headerValue: "text/css" },
      {headerKey: "Cache-Control", headerValue: "max-age=100000" },
    ]
  },
];
  1. 读取配置,注入资源
// 调用离线资源注入缓存接口
export async function injectOfflineResource(controller: WebviewController, resourceMapArr: Array<webview.OfflineResourceMap>) {
  try {
    controller.injectOfflineResources(resourceMapArr);
  } catch (err) {
    console.error("qqq injectOfflineResource error: " + err.code + " " + err.message);
  }
}

性能打点数据如下,getMessageData进程中的Duration为加载页面开始到结束的耗时:

12

总结

页面加载方式耗时(局限不同设备和场景,数据仅供参考)说明
直接加载Web页面1312ms在触发页面加载时才发起资源请求,增加页面加载时间
使用离线资源免拦截注入加载Web页面74ms将资源预置在内存中,节省了网络请求时间

资源拦截替换加速

原理介绍

资源拦截替换加速在原本的资源拦截替换接口基础上新增支持了ArrayBuffer格式的入参,开发者无需在应用侧进行ArrayBuffer到String格式的转换,可直接使用ArrayBuffer格式的数据进行拦截替换。

说明

本方案与原本的资源拦截替换接口在使用上没有任何区别,开发者仅需在调用WebResourceResponse.setResponseData()接口时传入ArrayBuffer格式的数据即可。

实践案例

案例一:使用字符串格式的数据做拦截替换

import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct Index {
  controller: webview.WebviewController = new webview.WebviewController();
  responseResource: WebResourceResponse = new WebResourceResponse();
  // 这里是string格式数据
  resourceStr: string = 'xxxxxxxxxxxxxxx';

  build() {
    Column() {
      Web({ src: 'https:www.example.com/test.html', controller: this.controller })
        .onInterceptRequest(event => {
          if (event) {
            if (!event.request.getRequestUrl().startsWith('https://www.example.com/')) {
              return null;
            }
          }
          // 使用string格式的数据做拦截替换
          this.responseResource.setResponseData(this.resourceStr);
          this.responseResource.setResponseEncoding('utf-8');
          this.responseResource.setResponseMimeType('text/json');
          this.responseResource.setResponseCode(200);
          this.responseResource.setReasonMessage('OK');
          this.responseResource.setResponseHeader([{ headerKey: 'Access-Control-Allow-Origin', headerValue: '*' }]);
          return this.responseResource;
        })
    }
  }
}

资源替换耗时如图所示,getMessageData … someFunction took后的时间页面加载资源的耗时:

13

案例二:使用ArrayBuffer格式的数据做拦截替换

import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController()
  scheme1: webview.WebCustomScheme = { schemeName: "imeituan", isSupportCORS: true, isSupportFetch: true }
  responseResource: WebResourceResponse = new WebResourceResponse()
  // 开发者自定义响应数据

  data: string = "";
  buffer: ArrayBuffer = new ArrayBuffer(this.data.length);
  usingLen: number = 1;

  aboutToAppear(): void {
    // 配置Web开启调试模式
    webview.WebviewController.setWebDebuggingAccess(true);

    try {
      webview.WebviewController.customizeSchemes([this.scheme1])
      console.info(`customizeSchemes`)
    } catch (error) {
      console.error(error);
    }

    this.initArrayBufferData(1);
  }

  onPageShow(): void {

  }

  initStringData(size: Number): void {
    switch (size){
      case 1:
        this.usingLen = 10; //10k
        break;
      case 2:
        this.usingLen = 1024; //1M
        break;
      case 3:
        this.usingLen = 1024 * 10; //10M
        break;
      default:
        this.usingLen = 1;
    }

    let str: string = "";
    let str_1k: string = "";
    for (let i = 0 ; i < 1024; i++) {
      str_1k = str_1k.concat("x");
    }
    for (let j = 0; j < this.usingLen; j++) {
      str = str.concat(str_1k);
    }



    this.data = JSON.stringify({
      status: 200,
      result: str,
    });

    console.info("init data length: " + this.data.length);
  }

  initArrayBufferData(size:Number): void {
    this.initStringData(size);

    console.error("target string: " + this.data);

    this.buffer = new ArrayBuffer(this.data.length);
    const uint8Array: Uint8Array = new Uint8Array(this.buffer);
    for (let i = 0; i < this.data.length; i++) {
      uint8Array[i] = this.data.charCodeAt(i);
    }
  }

  build() {
    Column() {
      Button('set to 10K')
        .onClick(() => {
          this.initArrayBufferData(1);
          console.log("datalen set to length "+ this.buffer.byteLength);
        })
      Button('set to 1M')
        .onClick(() => {
          this.initArrayBufferData(2);
          console.log("datalen set to length "+ this.buffer.byteLength);
        })
      Button('set to 10M')
        .onClick(() => {
          this.initArrayBufferData(3);
          console.log("datalen set to length "+ this.buffer.byteLength);
        })
      Web({ src: $rawfile("intercept.html"), controller: this.controller })
        .onConsole((event) => {
          console.error(`ygz ${event?.message?.getMessage()}`);
          return true;
        })
        .onInterceptRequest((event) => {
          if (event) {
            console.error('url:' + event.request.getRequestUrl());
            // 拦截页面请求
            if (!event.request.getRequestUrl().startsWith('http://bridge')) {
              return null;
            }
          }
          // 构造响应数据
          // const str: string = buffer.from(this.buffer).toString();
          console.error("response data length: " + this.data.length);
          this.responseResource.setResponseData(this.buffer);
          this.responseResource.setResponseEncoding('utf-8');
          this.responseResource.setResponseMimeType('text/json');
          this.responseResource.setResponseCode(200);
          this.responseResource.setReasonMessage('OK');
          this.responseResource.setResponseHeader([{ headerKey: 'Access-Control-Allow-Origin', headerValue: '*' }]);
          console.info(`ygz return reponse`);
          return this.responseResource;
        })
    }
  }
}

资源替换耗时如图所示,getMessageData william someFunction took后的时间页面加载资源的耗时:

16

总结

页面加载方式耗时(局限不同设备和场景,数据仅供参考)说明
使用string格式的数据做拦截替换34msWeb组件内部数据传输仍需要转换为ArrayBuffer,增加数据处理步骤,增加启动耗时
使用ArrayBuffer格式的数据做拦截替换13ms接口直接支持ArrayBuffer格式,节省了转换时间,同时对ArrayBuffer格式的数据传输方式进行了优化,进一步减少耗时

JSBridge

JSBridge优化解决方案

适用场景

应用使用ArkTS、C++语言混合开发,或本身应用架构较贴近于小程序架构,自带C++侧环境, 推荐使用ArkWeb在Native侧提供的ArkWeb_ControllerAPI、ArkWeb_ComponentAPI实现JSBridge功能。

17

上图为具有普适性的小程序一般架构,其中逻辑层需要应用自带JavaScript运行时,本身已存在C++环境,通过Native接口可直接在C++环境中完成与视图层(ArkWeb作为渲染器)的通信,无需再返回ArkTS环境调用JSBridge相关接口。

16

Native JSBridge方案可以解决ArkTS环境的冗余切换,同时允许回调在非UI线程上报,避免造成UI阻塞。

实践案例

案例一:使用ArkTS接口实现JSBridge通信。

应用侧代码:

import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();

  aboutToAppear() {
    // 配置Web开启调试模式
    webview.WebviewController.setWebDebuggingAccess(true);
  }

  build() {
    Column() {
      Button('runJavaScript')
        .onClick(() => {
          console.info('现在时间是:' + new Date().getTime());
          // 前端页面函数无参时,将param删除。
          this.webviewController.runJavaScript('htmlTest(param)');
        })
      Button('runJavaScriptCodePassed')
        .onClick(() => {
          // 传递runJavaScript侧代码方法。
          this.webviewController.runJavaScript(`function changeColor(){document.getElementById('text').style.color = 'red'}`);
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}

前端页面代码:

<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<h1 id="text">这是一个测试信息,默认字体为黑色,调用runJavaScript方法后字体为绿色,调用runJavaScriptCodePassed方法后字体为红色</h1>
<script>
    // 调用有参函数时实现。
    var param = "param: JavaScript Hello World!";
    function htmlTest(param) {
      document.getElementById('text').style.color = 'green';
      document.getElementById('text').innerHTML = '现在时间:'+new Date().getTime()
      console.log(param);
    }
    // 调用无参函数时实现。
    function htmlTest() {
      document.getElementById('text').style.color = 'green';
      document.getElementById('text').innerHTML = '现在时间:'+new Date().getTime();
    }
    // Click Me!触发前端页面callArkTS()函数执行JavaScript传递的代码。
    function callArkTS() {
      changeColor();
    }
</script>
</body>
</html>

点击runJavaScript按钮后触发h5页面htmlTest方法,使得页面内容变更为当前时间戳,如下图所示:

17

经过多轮测试,可以得出从点击原生button到h5触发htmlTest方法,耗时约7ms~9ms。

案例二:使用NDK接口实现JSBridge通信。

应用侧代码:

import testNapi from 'libentry.so';
import { webview } from '@kit.ArkWeb';

class testObj {
  constructor() {
  }

  test(): string {
    console.log('ArkUI Web Component');
    return "ArkUI Web Component";
  }

  toString(): void {
    console.log('Web Component toString');
  }
}

@Entry
@Component
struct Index {
  webTag: string = 'ArkWeb1';
  controller: webview.WebviewController = new webview.WebviewController(this.webTag);
  @State testObjtest: testObj = new testObj();

  aboutToAppear() {
    console.info("aboutToAppear");
    //初始化web ndk
    testNapi.nativeWebInit(this.webTag);
  }

  build() {
    Column() {
      Row() {
        Button('runJS hello')
          .fontSize(12)
          .onClick(() => {
            console.log('start:---->'+new Date().getTime());
            testNapi.runJavaScript(this.webTag, "runJSRetStr(\"" + "hello" + "\")");
          })
      }.height('20%')

      Row() {
        Web({ src: $rawfile('runJS.html'), controller: this.controller })
          .javaScriptAccess(true)
          .fileAccess(true)
          .onControllerAttached(() => {
            console.error("ndk onControllerAttached webId: " + this.controller.getWebId());
          })
      }.height('80%')
    }
  }
}

hello.cpp作为应用C++侧业务逻辑代码:

// 注册对象及方法,发送脚本到H5执行后的回调,解析存储应用侧传过来的实例等代码逻辑这里不进行展示,开发者根据自身业务场景自行实现。
// 发送JS脚本到H5侧执行
static napi_value RunJavaScript(napi_env env, napi_callback_info info) {
    size_t argc = 2;
    napi_value args[2] = {nullptr};
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    // 获取第一个参数 webTag
    size_t webTagSize = 0;
    napi_get_value_string_utf8(env, args[0], nullptr, 0, &webTagSize);
    char *webTagValue = new (std::nothrow) char[webTagSize + 1];
    size_t webTagLength = 0;
    napi_get_value_string_utf8(env, args[0], webTagValue, webTagSize + 1, &webTagLength);
    OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "ndk OH_NativeArkWeb_RunJavaScript webTag:%{public}s",
                 webTagValue);

    // 获取第二个参数 jsCode
    size_t bufferSize = 0;
    napi_get_value_string_utf8(env, args[1], nullptr, 0, &bufferSize);
    char *jsCode = new (std::nothrow) char[bufferSize + 1];
    size_t byteLength = 0;
    napi_get_value_string_utf8(env, args[1], jsCode, bufferSize + 1, &byteLength);

    OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb",
                 "ndk OH_NativeArkWeb_RunJavaScript jsCode len:%{public}zu", strlen(jsCode));

    // 构造runJS执行的结构体
    ArkWeb_JavaScriptObject object = {(uint8_t *)jsCode, bufferSize, &JSBridgeObject::StaticRunJavaScriptCallback,
                                     static_cast<void *>(jsbridge_object_ptr->GetWeakPtr())};
    controller->runJavaScript(webTagValue, &object);
    return nullptr;
}

EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        {"nativeWebInit", nullptr, NativeWebInit, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"runJavaScript", nullptr, RunJavaScript, nullptr, nullptr, nullptr, napi_default, nullptr}
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = ((void *)0),
    .reserved = {0}
};

extern "C" __attribute__((constructor)) void RegisterEntryModule(void) { napi_module_register(&demoModule); }

runJS.html作为应用前端页面:

<!DOCTYPE html>
<html lang="en-gb">
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>run javascript demo</title>
</head>
<body>
<h1>run JavaScript Ext demo</h1>
<p id="webDemo"></p>
<br>
<button type="button" style="height:30px;width:200px" onclick="testNdkProxyObjMethod1()">test ndk method1 ! </button>
<br>
<br>
<button type="button" style="height:30px;width:200px" onclick="testNdkProxyObjMethod2()">test ndk method2 ! </button>
<br>
</body>
<script type="text/javascript">
  function testNdkProxyObjMethod1() {
    //校验ndk方法是否已经注册到window
    if (window.ndkProxy == undefined) {
      document.getElementById("webDemo").innerHTML = "ndkProxy undefined";
      return "objName undefined";
    }
    if (window.ndkProxy.method1 == undefined) {
      document.getElementById("webDemo").innerHTML = "ndkProxy method1 undefined";
      return "objName  test undefined";
    }
    if (window.ndkProxy.method2 == undefined) {
      document.getElementById("webDemo").innerHTML = "ndkProxy method2 undefined";
      return "objName  test undefined";
    }
    //调用ndk注册到window的method1方法,并将结果回显到p标签
    var retStr = window.ndkProxy.method1("hello", "world", [1.2, -3.4, 123.456], ["Saab", "Volvo", "BMW", undefined], 1.23456, 123789, true, false, 0,  undefined);
    document.getElementById("webDemo").innerHTML  = "ndkProxy and method1 is ok, " + retStr;
  }
  function testNdkProxyObjMethod2() {
    //校验ndk方法是否已经注册到window
    if (window.ndkProxy == undefined) {
      document.getElementById("webDemo").innerHTML = "ndkProxy undefined";
      return "objName undefined";
    }
    if (window.ndkProxy.method1 == undefined) {
      document.getElementById("webDemo").innerHTML = "ndkProxy method1 undefined";
      return "objName  test undefined";
    }
    if (window.ndkProxy.method2 == undefined) {
      document.getElementById("webDemo").innerHTML = "ndkProxy method2 undefined";
      return "objName  test undefined";
    }
    var student = {
      name:"zhang",
      sex:"man",
      age:25
    };
    var cars = [student, 456, false, 4.567];
    let params = "[\"{\\\"scope\\\"]";
    //调用ndk注册到window的method2方法,并将结果回显到p标签
    var retStr = window.ndkProxy.method2("hello", "world", false, cars, params);
    document.getElementById("webDemo").innerHTML  = "ndkProxy and method2 is ok, " + retStr;
  }
  function runJSRetStr(data) {
    const d = new Date();
    let time = d.getTime();
    document.getElementById("webDemo").innerHTML = new Date().getTime();
    return JSON.stringify(time);
  }
</script>
</html>

点击runJS hello按钮后触发h5页面runJSRetStr方法,使得页面内容变更为当前时间戳。

18

经过多轮测试,可以得出从点击原生button到h5触发runJSRetStr方法,耗时约2ms~6ms。

总结

通信方式耗时(局限不同设备和场景,数据仅供参考)说明
ArkWeb实现与前端页面通信7ms~9msArkTS环境冗余切换,耗时较长
ArkWeb、c++实现与前端页面通信2ms~6ms避免ArkTS环境冗余切换,耗时短

JSBridge优化方案适用于ArkWeb应用侧与前端网页通信场景,开发者可根据应用架构选择合适的业务通信机制:

  1. 应用使用ArkTS语言开发,推荐使用ArkWeb在ArkTS提供的runJavaScriptExt接口实现应用侧至前端页面的通信,同时使用registerJavaScriptProxy实现前端页面至应用侧的通信。
  2. 应用使用ArkTS、C++语言混合开发,或本身应用结构较贴近于小程序架构,自带C++侧环境,推荐使用ArkWeb在NDK侧提供的OH_NativeArkWeb_RunJavaScript及OH_NativeArkWeb_RegisterJavaScriptProxy接口实现JSBridge功能。

说明

开发者需根据当前业务区分是否存在C++侧环境(较为显著标志点为当前应用是否使用了Node API技术进行开发,若是则该应用具备C++侧环境)。 具备C++侧环境的应用开发,可使用ArkWeb提供的NDK侧JSBridge接口。 不具备C++侧环境的应用开发,可使用ArkWeb侧JSBridge接口。

异步JSBridge调用

原理介绍

异步JSBridge调用适用于H5侧调用原生或C++侧注册的JSBridge函数场景下,将用户指定的JSBridge接口的调用抛出后,不等待执行结果, 以避免在ArkUI主线程负载重时JSBridge同步调用可能导致Web线程等待IPC时间过长,从而造成阻塞的问题。

实践案例

案例一:使用ArkTS接口实现JSBridge通信,具体步骤如下:

  1. 只注册同步函数
import { webview } from '@kit.ArkWeb';
// 定义ETS侧对象及函数
class TestObj {
  constructor() {}
  test(testStr:string): string {
    let start = Date.now();
    // 模拟耗时操作
    for(let i = 0; i < 500000; i++) {}
    let end = Date.now();
    console.log('objName.test start: ' + start);
    return 'objName.test Sync function took ' + (end - start) + 'ms';
  }
  asyncTestBool(testBol:boolean): Promise<string> {
    return new Promise((resolve, reject) => {
      let start = Date.now();
      // 模拟耗时操作(异步)
      setTimeout(() => {
        for(let i = 0; i < 500000; i++) {}
        let end = Date.now();
        console.log('objAsyncName.asyncTestBool start: ' + start);
        resolve('objName.asyncTestBool Async function took ' + (end - start) + 'ms');
      }, 0); // 使用0毫秒延迟来模拟立即开始的异步操作
    });
  }
}

class WebObj {
  constructor() {}
  webTest(): string {
    let start = Date.now();
    // 模拟耗时操作
    for(let i = 0; i < 500000; i++) {}
    let end = Date.now();
    console.log('objTestName.webTest start: ' + start);
    return 'objTestName.webTest Sync function took ' + (end - start) + 'ms';
  }
  webString(): string {
    let start = Date.now();
    // 模拟耗时操作
    for(let i = 0; i < 500000; i++) {}
    let end = Date.now();
    console.log('objTestName.webString start: ' + start);
    return 'objTestName.webString Sync function took ' + (end - start) + 'ms';
  }
}

class AsyncObj {
  constructor() {
  }

  asyncTest(): Promise<string> {
    return new Promise((resolve, reject) => {
      let start = Date.now();
      // 模拟耗时操作(异步)
      setTimeout(() => {
        for (let i = 0; i < 500000; i++) {
        }
        let end = Date.now();
        console.log('objAsyncName.asyncTest start: ' + start);
        resolve('objAsyncName.asyncTest Async function took ' + (end - start) + 'ms');
      }, 0); // 使用0毫秒延迟来模拟立即开始的异步操作
    });
  }

  asyncString(testStr:string): Promise<string> {
    return new Promise((resolve, reject) => {
      let start = Date.now();
      // 模拟耗时操作(异步)
      setTimeout(() => {
        for (let i = 0; i < 500000; i++) {
        }
        let end = Date.now();
        console.log('objAsyncName.asyncString start: ' + start);
        resolve('objAsyncName.asyncString Async function took ' + (end - start) + 'ms');
      }, 0); // 使用0毫秒延迟来模拟立即开始的异步操作
    });
  }
}

@Entry
@Component
struct Index {
  controller: webview.WebviewController = new webview.WebviewController();
  @State testObjtest: TestObj = new TestObj();
  @State webTestObj: WebObj = new WebObj();
  @State asyncTestObj: AsyncObj = new AsyncObj();
  build() {
    Column() {
      Button('refresh')
        .onClick(()=>{
          try{
            this.controller.refresh();
          } catch (error) {
            console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(()=>{
          try {
            //只注册同步函数
            this.controller.registerJavaScriptProxy(this.webTestObj,"objTestName",["webTest","webString"]);
          } catch (error) {
            console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`);
          }
        })
      Web({src: $rawfile('index.html'),controller: this.controller}).javaScriptAccess(true)
    }
  }
}
  1. H5侧调用JSBridge函数
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
<button type="button" onclick="htmlTest()"> Click Me!</button>
<p id="demo"></p>
<p id="webDemo"></p>
<p id="asyncDemo"></p>
</body>
<script type="text/javascript">
  async function htmlTest() {
    document.getElementById("demo").innerHTML = '测试开始:' + new Date().getTime() + '\n';
    const time1 = new Date().getTime();
    objTestName.webString();
    const time2 = new Date().getTime();
    objAsyncName.asyncString();
    const time3 = new Date().getTime();
    objName.asyncTestBool();
    const time4 = new Date().getTime();
    objName.test();
    const time5 = new Date().getTime();
    objTestName.webTest();
    const time6 = new Date().getTime();
    objAsyncName.asyncTest();
    const time7 = new Date().getTime();
    const result = [
      'objTestName.webString()耗时:'+ (time2 - time1),
      'objAsyncName.asyncString()耗时:'+ (time3 - time2),
      'objName.asyncTestBool()耗时:'+ (time4 - time3),
      'objName.test()耗时:'+ (time5 - time4),
      'objTestName.webTest()耗时:'+ (time6 - time5),
      'objAsyncName.asyncTest()耗时:'+ (time7 - time6)
    ]
    document.getElementById("demo").innerHTML = document.getElementById("demo").innerHTML + '\n' + result.join('\n');
  }
</script>
</html>

案例二:使用registerJavaScriptProxy或javaScriptProxy注册异步函数或异步同步共存,H5侧调用JSBridge函数与不推荐用法一致。

// registerJavaScriptProxy方式注册
Button('refresh')
  .onClick(()=>{
    try{
      this.controller.refresh();
    } catch (error) {
      console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`)
    }
  })
Button('Register JavaScript To Window')
  .onClick(()=>{
    try {
      // 调用注册接口对象及成员函数,其中同步函数列表必填,空白则需要用[]占位;异步函数列表非必填
      // 同步、异步函数都注册
      this.controller.registerJavaScriptProxy(this.testObjtest,"objName",["test"],["asyncTestBool"]);
      // 只注册异步函数,同步函数列表处留空
      this.controller.registerJavaScriptProxy(this.asyncTestObj,"objAsyncName",[],["asyncTest","asyncString"]);
    } catch (error) {
      console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`);
    }
  })
Web({src: $rawfile('index.html'),controller: this.controller}).javaScriptAccess(true)

// javaScriptProxy方式注册
// javaScriptProxy只支持注册一个对象,若需要注册多个对象请使用registerJavaScriptProxy
Web({src: $rawfile('index.html'),controller: this.controller})
  .javaScriptAccess(true)
  .javaScriptProxy({
    object: this.testObjtest,
    name:"objName",
    methodList: ["test","toString"],
    //指定异步函数列表
    asyncMethodList: ["test","toString"],
    controller: this.controller
  })

总结

数据运行结果如下:

注册方法类型耗时(局限不同设备和场景,数据仅供参考)说明
同步方法1398ms,2707ms,2705ms同步函数调用会阻塞JavaScript线程
异步方法2ms,2ms,4ms异步函数调用不阻塞JavaScript线程

通过运行数据可看到async的异步方法不需要等待结果,所以在JavaScript单线程任务队列中不会长时间占用,同步任务需要等待原生主线程同步执行后返回结果。

说明

JSBridge接口在注册时,即会根据注册调用的接口决定其调用方式(同步/异步)。开发者需根据当前业务区分, 是否将其注册为异步函数。

  • 同步函数调用将会阻塞JavaScript的执行,等待调用的JSBridge函数执行结束,适用于需要返回值,或者有时序问题等场景。
  • 异步函数调用时不会等待JSBridge函数执行结束,后续JavaScript可在短时间后继续执行。但JSBridge函数无法直接返回值。
  • 注册在ETS侧的JSBridge函数调用时需要在主线程上执行;NDK侧注册的函数将在其他线程中执行。
  • 异步JSBridge接口与同步接口在JavaScript侧的调用方式一致,仅注册方式不同,本部分调用方式仅作简要示范。

附NDK接口实现JSBridge通信(C++侧注册异步函数):

// 定义JSBridge函数
static void ProxyMethod1(const char* webTag, void* userData) {
    OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "Method1 webTag :%{public}s",webTag);
}

static void ProxyMethod2(const char* webTag, void* userData) {
    OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "Method2 webTag :%{public}s",webTag);
}

static void ProxyMethod3(const char* webTag, void* userData) {
    OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "Method3 webTag :%{public}s",webTag);
}

void RegisterCallback(const char *webTag) {
    int myUserData = 100;
    //创建函数方法结构体
    ArkWeb_ProxyMethod m1 = {
        .methodName = "method1",
        .callback = ProxyMethod1,
        .userData = (void *)&myUserData
    };
    ArkWeb_ProxyMethod m2 = {
        .methodName = "method2",
        .callback = ProxyMethod2,
        .userData = (void *)&myUserData
    };
    ArkWeb_ProxyMethod m3 = {
        .methodName = "method3",
        .callback = ProxyMethod3,
        .userData = (void *)&myUserData
    };
    ArkWeb_ProxyMethod methodList[2] = {m1,m2};

    //创建JSBridge对象结构体
    ArkWeb_ProxyObject obj = {
        .objName = "ndkProxy",
        .methodList = methodList,
        .size = 2
    };
    // 获取ArkWeb_Controller API结构体
    ArkWeb_AnyNativeAPI* apis = OH_ArkWeb_GetNativeAPI(ArkWeb_NativeAPIVariantKind::ARKWEB_NATIVE_CONTROLLER);
    ArkWeb_ControllerAPI* ctrlApi = reinterpret_cast<ArkWeb_ControllerAPI*>(apis);

        // 调用注册接口,注册函数
        ctrlApi->registerJavaScriptProxy(webTag, &obj);

    ArkWeb_ProxyMethod asyncMethodList[1] = {m3};
    ArkWeb_ProxyObject obj2 = {
        .objName = "ndkProxy",
    .methodList = asyncMethodList,
    .size = 1
    };
    ctrlApi->registerAsyncJavaScriptProxy(webTag, &obj2);
}

同层渲染

同层渲染是一种优化技术,用于提高Web页面的渲染性能。同层渲染会将位于同一个图层的元素一起渲染,以减少重绘和重排的次数,从而提高页面的渲染效率。

总结

本文深入探讨了Web页面加载的原理和优化方法,为开发者提供了重要的指导和思路。在当今互联网时代,用户对网页加载速度和体验要求越来越高,因此页面加载优化成为开发者必须重视的一环。通过理解Web页面加载的原理,开发者可以更好地处理页面加载与优化的相关问题,提升应用的整体质量。

文中提供了预连接、预下载、预渲染、预取POST、预编译等多种常见的优化方法,指导开发者优化Web页面的加载速度。这些方法可以有效提高应用流畅度、提升用户体验。但是,这几种方法都是基于预处理的方式进行优化的,所以存在一定的优化代价。

在实际的开发场景中,开发者应该根据实际的情况进行权衡利弊,决定对应的方案与策略。此外,还提供了JSBridge与资源加速的优化方案,帮助开发者进一步提高Web加载性能。除了以上提到的优化方法,开发者还可以通过其他方式进一步优化页面加载速度。例如,压缩资源可以减小文件大小,减少加载时间;减少HTTP请求可以减少网络延迟,加快页面加载速度,提升用户体验。

综上所述,Web页面加载优化对于提升用户体验、提高网站性能、增加页面浏览量和提高转化率具有重要意义。开发者应该重视页面加载优化,不断探索和实践各种优化方法,以提升用户体验,实现商业目标。通过文章介绍的几种优化方法,开发者可以改善页面加载速度,提升用户体验,增加页面浏览量,提高应用的活跃度和用户粘性。只有不断优化页面加载速度,才能更好地满足用户需求,提升应用价值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值