【鸿蒙开发】第四十六章 ArkWeb(方舟Web)

目录

1 ArkWeb简介

1.1 使用场景

1.2 能力范围

1.3 约束与限制

2 ArkWeb进程 

3 Web组件的生命周期

3.1 概述

3.2 Web组件网页加载的状态说明

3.3 Web组件网页加载的性能指标

4 设置基本属性和事件

4.1 UserAgent开发指导 

4.1.1 默认UserAgent结构

4.1.2 自定义UserAgent结构

4.1.3 常见问题

1、如何通过UserAgent来识别HarmonyOS操作系统中不同设备

 2、如何模拟HarmonyOS操作系统的UserAgent进行前端调试

4.2 管理Cookie及数据存储

4.2.1 Cookie管理

4.2.2 缓存与存储管理

1、Cache

2、Dom Storage

4.3 设置深色模式

4.4 在新窗口中打开页面

4.5 管理位置权限

4.6 使用隐私模式

 4.7 使用运动和方向传感器监测设备状态

4.7.1 概述

4.7.2 需要权限

4.7.3 开发步骤

5 Web渲染和布局

5.1 Web组件渲染模式

5.1.1  规格与约束

5.1.2  使用场景

5.2 Web组件大小自适应页面内容布局

5.2.1 规格与约束

5.2.2 使用场景

5.3 优化跳转至新Web组件过程中的页面闪烁现象

5.3.1 闪烁原因

5.3.2 优化方法

6 在应用中使用前端页面JavaScript

6.1 应用侧调用前端页面函数

6.2 前端页面调用应用侧函数

6.2.1 复杂类型使用方法

6.3 建立应用侧与前端页面数据通道 

7 管理网页交互

7.1 Web组件嵌套滚动

7.2 Web页面显示内容滚动

7.3 Web组件对接软键盘

7.3.1 Web页面输入框输入与软键盘交互的W3C标准支持

7.3.2 设置软键盘避让模式

7.3.3 拦截系统软键盘与自定义软键盘输入

7.4 Web组件焦点管理

7.4.1 基础概念

7.4.2 Web组件走焦规范

1、主动走焦

2、被动走焦

7.4.3 Web组件与ArkUI组件焦点控制

Web组件内H5元素焦点控制

8 管理Web组件的网络安全与隐私

8.1 解决Web组件本地资源跨域问题

8.1.1 拦截本地资源跨域

8.2 使用智能防跟踪功能

8.3 使用Web组件的广告过滤功能

8.3.1 常用easylist语法规则

8.3.2 约束与限制

8.3.3 使用场景

1、开启广告过滤

2、关闭特定域名页面的广告过滤

3、收集广告过滤的信息

8.4 坚盾守护模式

8.4.1 ArkWeb限制的HTML5特性

8.4.2 评估对应用的影响

9 管理网页加载与浏览记录

9.1 使用Web组件加载页面

9.1.1 加载网络页面

9.1.2 加载本地页面

9.1.3 加载HTML格式的文本数据

9.2 管理页面跳转及浏览记录导航

9.2.1 历史记录导航

9.2.2 页面跳转

9.2.3 跨应用跳转

9.3 拦截Web组件发起的网络请求

9.3.1 为Web组件设置网络拦截器

9.3.2 设置自定义scheme需要遵循的规则

9.3.3 获取被拦截请求的请求信息

9.3.4 为被拦截的请求提供自定义的响应体

9.4 自定义页面请求响应

9.5 加速Web页面的访问

9.5.1 预解析和预连接

9.5.2 预加载

9.5.3 预获取post请求

9.5.4 预编译生成编译缓存

9.5.5 离线资源免拦截注入

9.6 设置Web组件前进后退缓存

9.6.1 Web组件开启BFCache

9.6.2 设置缓存的页面数量和页面留存的时间

9.7 Web组件在不同的窗口间迁移

10 管理网页文件上传与下载

10.1 上传文件

10.2 使用Web组件的下载能力

10.2.1 监听页面触发的下载

10.2.2 使用Web组件发起一个下载任务

10.2.3 使用Web组件恢复进程退出时未下载完成的任务

11 使用网页多媒体

11.1 使用WebRTC进行Web视频会议

11.2 托管网页中的媒体播放

11.2.1 使用场景

11.2.2 实现原理

1、ArkWeb内核播放媒体的框架

2、ArkWeb内核与应用的交互

11.2.3 开发指导

1、开启接管网页媒体播放

2、创建本地播放器(NativeMediaPlayer)

3、绘制本地播放器组件

11.2.4 执行ArkWeb内核发送给本地播放器的播控指令

11.2.5 将本地播放器的状态信息通知给ArkWeb内核

12 处理网页内容

12.1 使用Web组件打印前端页面

12.1.1  使用W3C标准协议接口拉起打印

12.1.2  通过调用应用侧接口拉起打印。

12.2 使用Web组件的PDF文档预览能力

12.3 网页中安全区域计算和避让适配

12.3.1 概述

12.3.2 实现场景

1、开启Web组件沉浸式效果

2、设置网页在可视窗口中的布局方式

3、对网页元素进行避让

13 同层渲染

13.1 使用场景

13.1.1 Web网页

13.1.2 三方UI框架

13.2 整体架构

13.3 规格约束

13.3.1 可被同层渲染的ArkUI组件

13.3.2 Web网页的同层渲染标签

13.4 Web页面中同层渲染输入框

14 使用离线Web组件

14.1 整体架构

14.2 创建离线Web组件

14.3 预启动渲染进程

14.4 预渲染Web页面

15 Web调试维测

15.1 使用DevTools工具调试前端页面

15.2 调试步骤

15.2.1 应用代码开启Web调试开关

15.2.3 将设备连接至电脑

15.2.4 端口转发

15.2.5 在Chrome浏览器上打开调试工具页面

15.2.5 等待发现被调试页面

1、开始网页调试

15.3 使用crashpad收集Web组件崩溃信息


1 ArkWeb简介

1.1 使用场景

ArkWeb(方舟Web)提供了Web组件,用于在应用程序中显示Web页面内容。常见使用场景包括:

  • 应用集成Web页面:应用可以在页面中使用Web组件,嵌入Web页面内容,以降低开发成本,提升开发、运营效率。
  • 浏览器网页浏览场景:浏览器类应用可以使用Web组件,打开三方网页,使用无痕模式浏览Web页面,设置广告拦截等。
  • 小程序:小程序类宿主应用可以使用Web组件,渲染小程序的页面。

1.2 能力范围

Web组件为开发者提供了丰富的控制Web页面能力。包括:

  • Web页面加载:声明式加载Web页面和离屏加载Web页面等。
  • 生命周期管理:组件生命周期状态变化,通知Web页面的加载状态变化等。
  • 常用属性与事件:UserAgent管理、Cookie与存储管理、字体与深色模式管理、权限管理等。
  • 与应用界面交互:自定义文本选择菜单、上下文菜单、文件上传界面等与应用界面交互能力。
  • App通过JavaScriptProxy,与Web页面进行JavaScript交互。
  • 安全与隐私:无痕浏览模式、广告拦截、坚盾守护模式等。
  • 维测能力:DevTools工具调试能力,使用crashpad收集Web组件崩溃信息。
  • 其他高阶能力:与原生组件同层渲染、Web组件的网络托管、Web组件的媒体播放托管、Web组件输入框拉起自定义输入法、网页接入密码保险箱等。

1.3 约束与限制

  • Web内核版本:ArkWeb基于谷歌Chromium内核开发,使用的Chromium版本为M114。

2 ArkWeb进程 

ArkWeb是多进程模型,分为应用进程、Web渲染进程、Web GPU进程、Web孵化进程和Foundation进程。

说明

Web内核没有明确的内存大小申请约束,理论上可以无限大,直到被资源管理释放。

图1 ArkWeb进程模型图

  • 应用进程中Web相关线程(应用唯一)
  • 应用进程为主进程。包含网络线程、Video线程、Audio线程和IO线程等。
  • 负责Web组件的北向接口与回调处理,网络请求、媒体服务等需要与其他系统服务交互的功能。
  • Foundation进程(系统唯一)
  • 负责接收应用进程进行孵化进程的请求,管理应用进程和Web渲染进程的绑定关系。
  • Web孵化进程(系统唯一)
  • 负责接收Foundation进程的请求,执行孵化Web渲染进程与Web GPU进程。
  • 执行孵化后处理安全沙箱降权、预加载动态库,以提升性能。
  • Web渲染进程(应用可指定多Web实例间共享或独立进程)
  • 负责运行Web渲染进程引擎(HTML解析、排版、绘制、渲染)。
  • 负责运行ArkWeb执行引擎(JavaScript、Web Assembly)。
  • 提供接口供应用选择多Web实例间是否共享渲染进程,满足不同场景对安全性、稳定性、内存占用的诉求。
  • 默认策略:移动设备上共享渲染进程以节省内存,2in1设备上独立渲染进程提升安全与稳定性。
  • Web GPU进程(应用唯一)
  • 负责光栅化、合成送显等与GPU、RenderService交互功能。提升应用进程稳定性、安全性。

1. 可通过setRenderProcessMode设置渲染子进程的模式,从而控制渲染过程的单进程或多进程状态。

移动设备默认为单进程渲染,而2in1设备则默认采用多进程渲染。通过调用getRenderProcessMode可查询当前的渲染子进程模式,其中枚举值0表示单进程模式,枚举值1对应多进程模式。若获取的值超出RenderProcessMode枚举范围,系统将自动采用多进程渲染模式作为默认设置。

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

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

  build() {
    Column() {
      Button('getRenderProcessMode')
        .onClick(() => {
          let mode = webview.WebviewController.getRenderProcessMode();
          console.log("getRenderProcessMode: " + mode);
        })
      Button('setRenderProcessMode')
        .onClick(() => {
          try {
            webview.WebviewController.setRenderProcessMode(webview.RenderProcessMode.MULTIPLE);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as     BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

2. 可通过terminateRenderProcess来主动关闭渲染进程。若渲染进程尚未启动或已销毁,此操作将不会产生任何影响。此外,销毁渲染进程将同时影响所有与之关联的其他实例。

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

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

  build() {
    Column() {
      Button('terminateRenderProcess')
      .onClick(() => {
        let result = this.controller.terminateRenderProcess();
        console.log("terminateRenderProcess result: " + result);
      })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

3. 可通过onRenderExited来监听渲染进程的退出事件,从而获知退出的具体原因(如内存OOM、crash或正常退出等)。由于多个Web组件可能共用同一个渲染进程,因此,每当渲染进程退出时,每个受此影响的Web组件均会触发相应的回调。

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

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

  build() {
    Column() {
      Web({ src: 'chrome://crash/', controller: this.controller })
        .onRenderExited((event) => {
          if (event) {
            console.log('reason:' + event.renderExitReason);
          }
        })
    }
  }
}

4. 可通过onRenderProcessNotRespondingonRenderProcessResponding来监听渲染进程的无响应状态。

当Web组件无法处理输入事件,或未能在预期时间内导航至新URL时,系统会判定网页进程为无响应状态,并触发onRenderProcessNotResponding回调。在网页进程持续无响应期间,该回调可能反复触发,直至进程恢复至正常运行状态,此时将触发onRenderProcessResponding回调。

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

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

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .onRenderProcessNotResponding((data) => {
          console.log("onRenderProcessNotResponding: [jsStack]= " + data.jsStack +
            ", [process]=" + data.pid + ", [reason]=" + data.reason);
        })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';

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

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .onRenderProcessResponding(() => {
          console.log("onRenderProcessResponding again");
        })
    }
  }
}

5. Web组件创建参数涵盖了多进程模型的运用。其中,sharedRenderProcessToken标识了当前Web组件所指定的共享渲染进程的token。在多渲染进程模式下,拥有相同token的Web组件将优先尝试重用与该token绑定的渲染进程。token与渲染进程的绑定关系,在渲染进程的初始化阶段形成。一旦渲染进程不再关联任何Web组件,它与token的绑定关系将被解除。

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

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

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller, sharedRenderProcessToken: "111" })
      Web({ src: 'www.w3.org', controller: this.controller, sharedRenderProcessToken: "111" })
    }
  }
}

3 Web组件的生命周期

3.1 概述

使用Web组件加载本地或者在线网页。

Web组件的状态主要包括:Controller绑定到Web组件、网页加载开始、网页加载进度、网页加载结束、页面即将可见等。

图1 Web组件网页正常加载过程中的回调事件

3.2 Web组件网页加载的状态说明

  • aboutToAppear函数:在创建自定义组件的新实例后,在执行其build函数前执行。一般建议在此设置WebDebug调试模式setWebDebuggingAccess、设置Web内核自定义协议URL的跨域请求与fetch请求的权限customizeSchemes、设置Cookie(configCookie)等。
  • onControllerAttached事件:当Controller成功绑定到Web组件时触发该回调,且禁止在该事件回调前调用Web组件相关的接口,否则会抛出js-error异常。推荐在此事件中注入JS对象registerJavaScriptProxy、设置自定义用户代理setCustomUserAgent,可以在回调中使用loadUrlgetWebId等操作网页不相关的接口。但因该回调调用时网页还未加载,因此无法在回调中使用有关操作网页的接口,例如zoomInzoomOut等。
  • onLoadIntercept事件:当Web组件加载url之前触发该回调,用于判断是否阻止此次访问。默认允许加载。
  • onOverrideUrlLoading事件:当URL将要加载到当前Web中时,让宿主应用程序有机会获得控制权,回调函数返回true将导致当前Web中止加载URL,而返回false则会导致Web继续照常加载URL。onLoadIntercept接口和onOverrideUrlLoading接口行为不一致,触发时机也不同,所以在应用场景上存在一定区别。主要是在LoadUrl和iframe(HTML标签,表示HTML内联框架元素,用于将另一个页面嵌入到当前页面中)加载时,onLoadIntercept事件会正常回调到,但onOverrideUrlLoading事件在LoadUrl加载时不会触发,在iframe加载HTTP(s)协议或about:blank时也不会触发。详细介绍请见onLoadInterceptonOverrideUrlLoading的说明。
  • onInterceptRequest事件:当Web组件加载url之前触发该回调,用于拦截url并返回响应数据。
  • onPageBegin事件:网页开始加载时触发该回调,且只在主frame(表示一个HTML元素,用于展示HTML页面的HTML元素)触发。如果是iframe或者frameset(用于包含frame的HTML标签)的内容加载时则不会触发此回调。多frame页面有可能同时开始加载,即使主frame已经加载结束,子frame也有可能才开始或者继续加载中。同一页面导航(片段、历史状态等)或者在提交前失败、被取消的导航等也不会触发该回调。
  • onProgressChange事件:告知开发者当前页面加载的进度。多frame页面或者子frame有可能还在继续加载而主frame可能已经加载结束,所以在onPageEnd事件后依然有可能收到该事件。
  • onPageEnd事件:网页加载完成时触发该回调,且只在主frame触发。多frame页面有可能同时开始加载,即使主frame已经加载结束,子frame也有可能才开始或者继续加载中。同一页面导航(片段、历史状态等)或者在提交前失败、被取消的导航等也不会触发该回调。推荐在此回调中执行JavaScript脚本loadUrl等。需要注意的是收到该回调并不能保证Web绘制的下一帧将反映此时DOM的状态。
  • onPageVisible事件:Web回调事件。渲染流程中当HTTP响应的主体开始加载,新页面即将可见时触发该回调。此时文档加载还处于早期,因此链接的资源比如在线CSS、在线图片等可能尚不可用。
  • onRenderExited事件:应用渲染进程异常退出时触发该回调,可以在此回调中进行系统资源的释放、数据的保存等操作。如果应用希望异常恢复,需要调用loadUrl接口重新加载页面。
  • onDisAppear事件:组件卸载消失时触发此回调。该事件为通用事件,指组件从组件树上卸载时触发的事件。

应用侧代码

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

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  responseWeb: WebResourceResponse = new WebResourceResponse();
  heads: Header[] = new Array();
  @State webData: string = "<!DOCTYPE html>\n" +
    "<html>\n" +
    "<head>\n" +
    "<title>intercept test</title>\n" +
    "</head>\n" +
    "<body>\n" +
    "<h1>intercept test</h1>\n" +
    "</body>\n" +
    "</html>";

  aboutToAppear(): void {
    try {
      webview.WebviewController.setWebDebuggingAccess(true);
    } catch (error) {
      console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
    }
  }

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .onControllerAttached(() => {
          // 推荐在此loadUrl、设置自定义用户代理、注入JS对象等
          console.log('onControllerAttached execute')
        })
        .onLoadIntercept((event) => {
          if (event) {
            console.log('onLoadIntercept url:' + event.data.getRequestUrl())
            console.log('url:' + event.data.getRequestUrl())
            console.log('isMainFrame:' + event.data.isMainFrame())
            console.log('isRedirect:' + event.data.isRedirect())
            console.log('isRequestGesture:' + event.data.isRequestGesture())
          }
          // 返回true表示阻止此次加载,否则允许此次加载
          return false;
        })
        .onOverrideUrlLoading((webResourceRequest: WebResourceRequest) => {
          if (webResourceRequest && webResourceRequest.getRequestUrl() == "about:blank") {
            return true;
          }
          return false;
        })
        .onInterceptRequest((event) => {
          if (event) {
            console.log('url:' + event.request.getRequestUrl());
          }
          let head1: Header = {
            headerKey: "Connection",
            headerValue: "keep-alive"
          }
          let head2: Header = {
            headerKey: "Cache-Control",
            headerValue: "no-cache"
          }
          let length = this.heads.push(head1);
          length = this.heads.push(head2);
          this.responseWeb.setResponseHeader(this.heads);
          this.responseWeb.setResponseData(this.webData);
          this.responseWeb.setResponseEncoding('utf-8');
          this.responseWeb.setResponseMimeType('text/html');
          this.responseWeb.setResponseCode(200);
          this.responseWeb.setReasonMessage('OK');
          // 返回响应数据则按照响应数据加载,无响应数据则返回null表示按照原来的方式加载
          return this.responseWeb;
        })
        .onPageBegin((event) => {
          if (event) {
            console.log('onPageBegin url:' + event.url);
          }
        })
        .onFirstContentfulPaint(event => {
          if (event) {
            console.log("onFirstContentfulPaint:" + "[navigationStartTick]:" +
            event.navigationStartTick + ", [firstContentfulPaintMs]:" +
            event.firstContentfulPaintMs);
          }
        })
        .onProgressChange((event) => {
          if (event) {
            console.log('newProgress:' + event.newProgress);
          }
        })
        .onPageEnd((event) => {
          // 推荐在此事件中执行JavaScript脚本
          if (event) {
            console.log('onPageEnd url:' + event.url);
          }
        })
        .onPageVisible((event) => {
          console.log('onPageVisible url:' + event.url);
        })
        .onRenderExited((event) => {
          if (event) {
            console.log('onRenderExited reason:' + event.renderExitReason);
          }
        })
        .onDisAppear(() => {
          promptAction.showToast({
            message: 'The web is hidden',
            duration: 2000
          })
        })
    }
  }
}

 前端index.html:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<h1>Hello, ArkWeb</h1>
</body>
</html>

3.3 Web组件网页加载的性能指标

网页加载过程中需要关注一些重要的性能指标。例如,FCP(First Contentful Paint)首次内容绘制,FMP(First Meaningful Paint)首次有效绘制,LCP(Largest Contentful Paint)最大内容绘制等。Web组件提供了如下接口来通知开发者。

  • onFirstContentfulPaint事件:网页首次内容绘制的回调函数。首次绘制文本、图像、非空白Canvas或者SVG的时间点。
  • onFirstMeaningfulPaint事件:网页首次有效绘制的回调函数。首次绘制页面主要内容的时间点。
  • onLargestContentfulPaint事件:网页绘制页面最大内容的回调函数。可视区域内容最大的可见元素开始出现在页面上的时间点。

4 设置基本属性和事件

4.1 UserAgent开发指导 

UserAgent(简称UA)是一个特殊的字符串,它包含了设备类型、操作系统及版本等关键信息。如果页面无法正确识别UA,可能会导致一系列异常情况,例如页面布局错误、渲染问题以及逻辑错误等。

4.1.1 默认UserAgent结构

  • 默认UserAgent定义
Mozilla/5.0 ({DeviceType}; {OSName} {OSVersion}; {DistributionOSName} {DistributionOSVersion}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{ChromeCompatibleVersion}.0.0.0 Safari/537.36 ArkWeb/{ArkWeb VersionCode} {DeviceCompat} {扩展区}
  • 举例说明 
Mozilla/5.0 (Phone; OpenHarmony 5.0; HarmonyOS 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 ArkWeb/4.1.6.1 Mobile
  •  字段说明

字段

含义

DeviceType

当前的设备类型。

取值范围:

- Phone:手机设备

- Tablet:平板设备

- PC:2in1设备

OSName

基础操作系统名称

默认取值:OpenHarmony

OSVersion

基础操作系统版本,两位数字,M.S。

通过系统参数const.ohos.fullname解析版本号得到,取版本号部分M.S前两位。

默认取值:例如5.0

DistributionOSName

发行版操作系统名称

默认取值:HarmonyOS

DistributionOSVersion

发行版操作系统版本,两位数字,M.S。

通过系统参数const.product.os.dist.apiname解析版本号得到,如果该参数值为空,通过系统参数const.product.os.dist.version解析版本号得到,取M.S前两位。

默认取值:例如5.0

ChromeCompatibleVersion

兼容Chrome主版本的版本号,从114版本开始演进。

默认取值:114

ArkWeb

HarmonyOS版本Web内核名称。

默认取值:ArkWeb

ArkWeb VersionCode

ArkWeb版本号,格式a.b.c.d。

默认取值:例如4.1.6.1

DeviceCompat

前向兼容字段。

默认取值:Mobile

扩展区

三方应用可以扩展的字段。

三方应用使用ArkWeb组件时,可以做UA扩展,例如加入应用相关信息标识。

说明

  • 当前通过UserAgent中是否含有"Mobile"字段来判断是否开启前端HTML页面中meta标签的viewport属性。当UserAgent中不含有"Mobile"字段时,meta标签中viewport属性默认关闭,此时可通过显性设置metaViewport属性为true来覆盖关闭状态。
  • 建议通过OpenHarmony关键字识别是否是HarmonyOS设备,同时可以通过DeviceType识别设备类型用于不同设备上的页面显示(ArkWeb关键字表示设备使用的web内核,OpenHarmony关键字表示设备使用的操作系统,因此推荐通过OpenHarmony关键字识别是否是HarmonyOS设备)。

4.1.2 自定义UserAgent结构

在下面的示例中,通过getUserAgent()接口获取当前默认用户代理,支持开发者基于默认的UserAgent去定制UserAgent。

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

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

  build() {
    Column() {
      Button('getUserAgent')
        .onClick(() => {
          try {
            let userAgent = this.controller.getUserAgent();
            console.log("userAgent: " + userAgent);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

以下示例通过setCustomUserAgent()接口设置自定义用户代理,会覆盖系统的用户代理。建议将扩展字段追加在默认用户代理的末尾。

当Web组件src设置了url时,建议在onControllerAttached回调事件中设置UserAgent,设置方式请参考示例。如果未在onControllerAttached回调事件中设置UserAgent,再调用setCustomUserAgent方法时,可能会出现加载的页面与实际设置UserAgent不符的异常现象。

当Web组件src设置为空字符串时,建议先调用setCustomUserAgent方法设置UserAgent,再通过loadUrl加载具体页面。

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

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  // 三方应用相关信息标识
  @State customUserAgent: string = ' DemoApp';

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
      .onControllerAttached(() => {
        console.log("onControllerAttached");
        try {
          let userAgent = this.controller.getUserAgent() + this.customUserAgent;
          this.controller.setCustomUserAgent(userAgent);
        } catch (error) {
          console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
        }
      })
    }
  }
}

在下面的示例中,通过getCustomUserAgent()接口获取自定义用户代理。

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

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  @State userAgent: string = '';

  build() {
    Column() {
      Button('getCustomUserAgent')
        .onClick(() => {
          try {
            this.userAgent = this.controller.getCustomUserAgent();
            console.log("userAgent: " + this.userAgent);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

4.1.3 常见问题

1、如何通过UserAgent来识别HarmonyOS操作系统中不同设备

HarmonyOS设备的识别主要通过UserAgent中的系统、系统版本和设备类型三个维度来判断。建议同时检查系统、系统版本和设备类型,以确保更准确的设备识别。

1. 系统识别

通过UserAgent中的{OSName}字段识别HarmonyOS系统。

const isHarmonyOS = () => /OpenHarmony/i.test(navigator.userAgent);

2. 系统版本识别

通过UserAgent中的{OSName}和{OSVersion}字段识别HarmonyOS系统。格式为:OpenHarmony + 版本号。

const matches = navigator.userAgent.match(/OpenHarmony (\d+\.?\d*)/);
matches?.length && Number(matches[1]) >= 5;

3. 设备类型识别

通过DeviceType字段来识别不同设备类型。

// 检测是否为手机设备  
const isPhone = () => /Phone/i.test(navigator.userAgent);  

// 检测是否为平板设备  
const isTablet = () => /Tablet/i.test(navigator.userAgent);  

// 检测是否为2in1设备  
const is2in1 = () => /PC/i.test(navigator.userAgent); 
 2、如何模拟HarmonyOS操作系统的UserAgent进行前端调试

在Windows/Mac/Linux等操作系统中,可以通过Chrome/Edge/Firefox等浏览器DevTools提供的UserAgent复写能力,模拟HarmonyOS UserAgent。

4.2 管理Cookie及数据存储

4.2.1 Cookie管理

Cookie是网络访问过程中,由服务端发送给客户端的一小段数据。客户端可持有该数据,并在后续访问该服务端时,方便服务端快速对客户端身份、状态等进行识别。

当Cookie SameSite属性未指定时,默认值为SameSite=Lax,只在用户导航到cookie的源站点时发送cookie,不会在跨站请求中被发送。

Web组件提供了WebCookieManager类,用于管理Web组件的Cookie信息。Cookie信息保存在应用沙箱路径下/proc/{pid}/root/data/storage/el2/base/cache/web/Cookiesd的文件中。

下面以configCookieSync()接口举例,为“www.example.com”设置单个Cookie的值“value=test”。其他Cookie的相关功能及使用,请参考WebCookieManager()接口文档。

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

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

  build() {
    Column() {
      Button('configCookieSync')
        .onClick(() => {
          try {
            webview.WebCookieManager.configCookieSync('https://www.example.com', 'value=test');
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

4.2.2 缓存与存储管理

在访问网站时,网络资源请求是相对比较耗时的。开发者可以通过Cache、Dom Storage等手段将资源保存到本地,以提升访问同一网站的速度。

1、Cache

使用cacheMode()配置页面资源的缓存模式,Web组件为开发者提供四种缓存模式,分别为:

  • Default : 优先使用未过期的缓存,如果缓存不存在,则从网络获取。
  • None : 加载资源使用cache,如果cache中无该资源则从网络中获取。
  • Online : 加载资源不使用cache,全部从网络中获取。
  • Only :只从cache中加载资源。

在下面的示例中,选用缓存设置为None模式。

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

@Entry
@Component
struct WebComponent {
  @State mode: CacheMode = CacheMode.None;
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .cacheMode(this.mode)
    }
  }
}

同时,为了获取最新资源,开发者可以通过removeCache()接口清除已经缓存的资源,示例代码如下:

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

@Entry
@Component
struct WebComponent {
  @State mode: CacheMode = CacheMode.None;
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Button('removeCache')
        .onClick(() => {
          try {
            // 设置为true时同时清除rom和ram中的缓存,设置为false时只清除ram中的缓存
            this.controller.removeCache(true);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
        .cacheMode(this.mode)
    }
  }
}
2、Dom Storage

Dom Storage包含了Session Storage和Local Storage两类。前者为临时数据,其存储与释放跟随会话生命周期;后者为可持久化数据,落盘在应用目录下。两者的数据均通过Key-Value的形式存储,通常在访问需要客户端存储的页面时使用。开发者可以通过Web组件的属性接口domStorageAccess()进行使能配置,示例如下:

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

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

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .domStorageAccess(true)
    }
  }
}

4.3 设置深色模式

Web组件支持对前端页面进行深色模式配置。

  • 通过darkMode()接口可以配置不同的深色模式,默认关闭。当深色模式开启时,Web将启用媒体查询prefers-color-scheme中网页所定义的深色样式,若网页未定义深色样式,则保持原状。如需开启强制深色模式,建议配合forceDarkAccess()使用。WebDarkMode.Off模式表示关闭深色模式。WebDarkMode.On表示开启深色模式,并且深色模式跟随前端页面。WebDarkMode.Auto表示开启深色模式,并且深色模式跟随系统。在下面的示例中,通过darkMode()接口将页面深色模式配置为跟随系统。
// xxx.ets
import { webview } from '@kit.ArkWeb';
  
@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  @State mode: WebDarkMode = WebDarkMode.Auto;

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .darkMode(this.mode)
    }
  }
}
  • 通过forceDarkAccess()接口可将前端页面强制配置深色模式,强制深色模式无法保证所有颜色转换符合预期,且深色模式不跟随前端页面和系统。配置该模式时候,需要将深色模式配置成WebDarkMode.On。在下面的示例中,通过forceDarkAccess()接口将页面强制配置为深色模式。
// xxx.ets
import { webview } from '@kit.ArkWeb';

@Entry        
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  @State mode: WebDarkMode = WebDarkMode.On;
  @State access: boolean = true;

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .darkMode(this.mode)
        .forceDarkAccess(this.access)
    }
  }
}
  • index.html页面代码。
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width,
                                    initial-scale=1.0,
                                    maximum-scale=1.0,
                                    user-scalable=no">
    <style type="text/css">
        @media (prefers-color-scheme: dark) {
            .contentCss{ background:  #000000; color: white; }
            .hrefCss{ color: #317AF7; }
        }
    </style>
</head>
<body class="contentCss">
<div style="text-align:center">
    <p>Dark mode debug page</p>
</div>
</body>
</html>

4.4 在新窗口中打开页面

Web组件提供了在新窗口打开页面的能力,可以通过multiWindowAccess()接口来设置是否允许网页在新窗口打开。当有新窗口打开时,应用侧会在onWindowNew()接口中收到Web组件新窗口事件,需要在此接口事件中,新建窗口来处理Web组件窗口请求。

说明

如下面的本地示例,当用户点击“新窗口中打开网页”按钮时,应用侧会在onWindowNew()接口中收到Web组件新窗口事件。

应用侧代码:

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

// 在同一page页有两个Web组件。在WebComponent新开窗口时,会跳转到NewWebViewComp。
@CustomDialog
struct NewWebViewComp {
  controller?: CustomDialogController;
  webviewController1: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: "", controller: this.webviewController1 })
        .javaScriptAccess(true)
        .multiWindowAccess(false)
        .onWindowExit(() => {
          console.info("NewWebViewComp onWindowExit");
          if (this.controller) {
            this.controller.close();
          }
        })
    }
  }
}

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  dialogController: CustomDialogController | null = null;

  build() {
    Column() {
      Web({ src: $rawfile("window.html"), controller: this.controller })
        .javaScriptAccess(true)
          // 需要使能multiWindowAccess
        .multiWindowAccess(true)
        .allowWindowOpenMethod(true)
        .onWindowNew((event) => {
          if (this.dialogController) {
            this.dialogController.close()
          }
          let popController: webview.WebviewController = new webview.WebviewController();
          this.dialogController = new CustomDialogController({
            builder: NewWebViewComp({ webviewController1: popController })
          })
          this.dialogController.open();
          // 将新窗口对应WebviewController返回给Web内核。
          // 若不调用event.handler.setWebController接口,会造成render进程阻塞。
          // 如果没有创建新窗口,调用event.handler.setWebController接口时设置成null,通知Web没有创建新窗口。
          event.handler.setWebController(popController);
        })
    }
  }
}

window.html页面代码:

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width"/>
    <title>WindowEvent</title>
</head>
<body>
<input type="button" value="新窗口中打开网页" onclick="OpenNewWindow()">
<script type="text/javascript">
    function OpenNewWindow()
    {
        var txt = '打开的窗口';
        let openedWindow = window.open("about:blank", "", "location=no,status=no,scrollvars=no");
        openedWindow.document.write("<p>" + "<br><br>" + txt.fontsize(10) + "</p>");
        openedWindow.focus();
    }
</script>
</body>
</html>

图1 新窗口中打开页面效果图

4.5 管理位置权限

Web组件提供位置权限管理能力。可以通过onGeolocationShow()接口对某个网站进行位置权限管理。Web组件根据接口响应结果,决定是否赋予前端页面权限。

"requestPermissions":[
   {
     "name" : "ohos.permission.LOCATION"
   },
   {
     "name" : "ohos.permission.APPROXIMATELY_LOCATION"
   },
   {
     "name" : "ohos.permission.LOCATION_IN_BACKGROUND"
   }
 ]

在下面的示例中,用户点击前端页面"获取位置"按钮,Web组件通过弹窗的形式通知应用侧位置权限请求消息。

前端页面代码:

<!DOCTYPE html>
<html>
<body>
<p id="locationInfo">位置信息</p>
<button onclick="getLocation()">获取位置</button>
<script>
var locationInfo=document.getElementById("locationInfo");
function getLocation(){
  if (navigator.geolocation) {
    <!-- 前端页面访问设备地理位置 -->
    navigator.geolocation.getCurrentPosition(showPosition);
  }
}
function showPosition(position){
  locationInfo.innerHTML="Latitude: " + position.coords.latitude + "<br />Longitude: " + position.coords.longitude;
}
</script>
</body>
</html>

 应用代码:

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

let context = getContext(this) as common.UIAbilityContext;
let atManager = abilityAccessCtrl.createAtManager();

// 向用户请求位置权限设置。
atManager.requestPermissionsFromUser(context, ["ohos.permission.APPROXIMATELY_LOCATION"]).then((data) => {
  console.info('data:' + JSON.stringify(data));
  console.info('data permissions:' + data.permissions);
  console.info('data authResults:' + data.authResults);
}).catch((error: BusinessError) => {
  console.error(`Failed to request permissions from user. Code is ${error.code}, message is ${error.message}`);
})

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

  build() {
    Column() {
      Web({ src: $rawfile('getLocation.html'), controller: this.controller })
        .geolocationAccess(true)
        .onGeolocationShow((event) => { // 地理位置权限申请通知
          AlertDialog.show({
            title: '位置权限请求',
            message: '是否允许获取位置信息',
            primaryButton: {
              value: 'cancel',
              action: () => {
                if (event) {
                  event.geolocation.invoke(event.origin, false, false); // 不允许此站点地理位置权限请求
                }
              }
            },
            secondaryButton: {
              value: 'ok',
              action: () => {
                if (event) {
                  event.geolocation.invoke(event.origin, true, false); // 允许此站点地理位置权限请求
                }
              }
            },
            cancel: () => {
              if (event) {
                event.geolocation.invoke(event.origin, false, false); // 不允许此站点地理位置权限请求
              }
            }
          })
        })
    }
  }
}

4.6 使用隐私模式

在创建Web组件时,可以将可选参数incognitoMode设置为true,来开启Web组件的隐私模式。 当使用隐私模式时,浏览网页时的Cookie、 Cache Data等数据不会保存在本地的持久化文件,当隐私模式的Web组件被销毁时,Cookie、 Cache Data等数据将不被记录下来。 

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

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

 build() {
   Column() {
     Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
   }
 }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      Button('isIncognitoMode')
        .onClick(() => {
          try {
            let result = this.controller.isIncognitoMode();
            console.log('isIncognitoMode' + result);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

隐私模式提供了一系列接口,用于操作地理位置、Cookie以及Cache Data。

  • 通过allowGeolocation设置隐私模式下的Web组件允许指定来源使用地理位置。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  origin: string = "file:///";

  build() {
    Column() {
      Button('allowGeolocation')
        .onClick(() => {
          try {
            // allowGeolocation第二个参数表示隐私模式(true)或非隐私模式(false)下,允许指定来源使用地理位置。
            webview.GeolocationPermissions.allowGeolocation(this.origin, true);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
    }
  }
}
  •  通过deleteGeolocation清除隐私模式下指定来源的地理位置权限状态。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  origin: string = "file:///";

  build() {
    Column() {
      Button('deleteGeolocation')
        .onClick(() => {
          try {
            // deleteGeolocation第二个参数表示隐私模式(true)或非隐私模式(false)下,清除指定来源的地理位置权限状态。
            webview.GeolocationPermissions.deleteGeolocation(this.origin, true);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  origin: string = "file:///";

  build() {
    Column() {
      Button('getAccessibleGeolocation')
        .onClick(() => {
          try {
            // getAccessibleGeolocation第三个参数表示隐私模式(true)或非隐私模式(false)下,以回调方式异步获取指定源的地理位置权限状态。
            webview.GeolocationPermissions.getAccessibleGeolocation(this.origin, (error, result) => {
              if (error) {
                console.log('getAccessibleGeolocationAsync error: ' + JSON.stringify(error));
                return;
              }
              console.log('getAccessibleGeolocationAsync result: ' + result);
            }, true);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
    }
  }
}
  •  通过deleteAllData清除隐私模式下Web SQL当前使用的所有存储。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      Button('deleteAllData')
        .onClick(() => {
          try {
            // deleteAllData参数表示删除所有隐私模式(true)或非隐私模式(false)下,内存中的web数据。
            webview.WebStorage.deleteAllData(true);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.controller, incognitoMode: true })
        .databaseAccess(true)
    }
  }
}

加载的html文件:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
 <meta charset="UTF-8">
 <title>test</title>
 <script type="text/javascript">
   var db = openDatabase('mydb','1.0','Test DB',2 * 1024 * 1024);
   var msg;
   db.transaction(function(tx){
     tx.executeSql('INSERT INTO LOGS (id,log) VALUES(1,"test1")');
     tx.executeSql('INSERT INTO LOGS (id,log) VALUES(2,"test2")');
     msg = '<p>数据表已创建,且插入了两条数据。</p>';
     document.querySelector('#status').innerHTML = msg;
   });
   db.transaction(function(tx){
     tx.executeSql('SELECT * FROM LOGS', [], function (tx, results) {
       var len = results.rows.length,i;
       msg = "<p>查询记录条数:" + len + "</p>";
       document.querySelector('#status').innerHTML += msg;
           for(i = 0; i < len; i++){
             msg = "<p><b>" + results.rows.item(i).log + "</b></p>";
       document.querySelector('#status').innerHTML += msg;
       }
     },null);
   });
   </script>
</head>
<body>
<div id="status" name="status">状态信息</div>
</body>
</html>
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      Button('fetchCookieSync')
        .onClick(() => {
          try {
            // fetchCookieSync第二个参数表示获取隐私模式(true)或非隐私模式(false)下,webview的内存cookies。
            let value = webview.WebCookieManager.fetchCookieSync('https://www.example.com', true);
            console.log("fetchCookieSync cookie = " + value);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      Button('configCookieSync')
        .onClick(() => {
          try {
            // configCookieSync第三个参数表示获取隐私模式(true)或非隐私模式(false)下,对应url的cookies。
            webview.WebCookieManager.configCookieSync('https://www.example.com', 'a=b', true);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
    }
  }
}
  • 通过existCookie查询隐私模式下是否存在cookie。 
// xxx.ets
import { webview } from '@kit.ArkWeb';

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

  build() {
    Column() {
      Button('existCookie')
        .onClick(() => {
          // existCookie参数表示隐私模式(true)或非隐私模式(false)下,查询是否存在cookies。
          let result = webview.WebCookieManager.existCookie(true);
          console.log("result: " + result);
        })
      Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';

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

  build() {
    Column() {
      Button('clearAllCookiesSync')
        .onClick(() => {
          // clearAllCookiesSync参数表示清除隐私模式(true)或非隐私模式(false)下,webview的所有内存cookies。
          webview.WebCookieManager.clearAllCookiesSync(true);
        })
      Web({ src: 'www.example.com', controller: this.controller, incognitoMode: true })
    }
  }
}

 4.7 使用运动和方向传感器监测设备状态

4.7.1 概述

运动和方向传感器,如加速度计、陀螺仪等,能够监测设备的运动状态和方向变化,例如设备的旋转、倾斜等。

通过W3C标准协议接口,Web组件能够访问这些传感器的数据,进而实现更加丰富的用户交互功能。例如,开发者在网页应用中可以利用加速度计识别运动模式,指导用户进行健身运动,利用陀螺仪捕获玩家手中设备的倾斜和旋转动作,实现无按钮操控的游戏体验。

通过在JavaScript中调用以下支持的W3C标准协议接口,可以访问运动和方向传感器。

接口名称说明
Accelerometer加速度可获取设备X、Y、Z轴方向的加速度数据。
Gyroscope陀螺仪可获取设备X、Y、Z轴方向的角速度数据。
AbsoluteOrientationSensor绝对定位可获取表示设备绝对定位方向的四元数,包含X、Y、Z和W分量。
RelativeOrientationSensor相对定位可获取表示设备相对定位方向的四元数,包含X、Y、Z和W分量。
DeviceMotionEvent设备运动事件通过监听该事件,可获取设备在X、Y、Z轴方向上的加速度数据,设备在X、Y、Z轴方向上包含重力的加速度数据,以及设备在alpha、beta、gamma轴方向上旋转的速率数据。
DeviceOrientationEvent设备方向事件通过监听该事件,可获取设备绕X、Y、Z轴的角度。

4.7.2 需要权限

使用加速度、陀螺仪及设备运动事件接口时,需在配置文件module.json5中声明相应的传感器权限。具体配置方法请参考在配置文件中声明权限

    "requestPermissions":[
      {
        "name" : "ohos.permission.ACCELEROMETER" // 加速度权限
      },
      {
        "name" : "ohos.permission.GYROSCOPE"     // 陀螺仪权限
      }
    ]

Web组件在对接运动和方向传感器时,需配置onPermissionRequest接口,通过该接口接收权限请求通知。

4.7.3 开发步骤

1. 应用侧代码中,Web组件配置onPermissionRequest接口,可通过PermissionRequestgetAccessibleResource接口获取请求权限的资源类型,当资源类型为TYPE_SENSOR时,进行传感器授权处理。

import { webview } from '@kit.ArkWeb';
import { abilityAccessCtrl, PermissionRequestResult } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController()
  aboutToAppear() {
    // 配置Web开启调试模式
    webview.WebviewController.setWebDebuggingAccess(true);
    // 访问控制管理, 获取访问控制模块对象。
    let atManager = abilityAccessCtrl.createAtManager();
    try {
      atManager.requestPermissionsFromUser(getContext(this), ['ohos.permission.ACCELEROMETER', 'ohos.permission.GYROSCOPE']
        , (err: BusinessError, data: PermissionRequestResult) => {
        console.info('data:' + JSON.stringify(data));
        console.info('data permissions:' + data.permissions);
        console.info('data authResults:' + data.authResults);
      })
    } catch (error) {
      console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
    }
  }

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .onPermissionRequest((event) => {
          if (event) {
            AlertDialog.show({
              title: 'title',
              message: 'text',
              primaryButton: {
                value: 'deny',
                action: () => {
                  event.request.deny();
                }
              },
              secondaryButton: {
                value: 'onConfirm',
                action: () => {
                  event.request.grant(event.request.getAccessibleResource());
                }
              },
              autoCancel: false,
              cancel: () => {
                event.request.deny();
              }
            })
          }
        })
    }
  }
}

2. 在前端页面代码中,利用JavaScript调用传感器相关的W3C标准协议接口。

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <meta name="misapplication-tap-highlight" content="no" />
    <meta name="HandheldFriendly" content="true" />
    <meta name="MobileOptimized" content="320" />
    <title>运动和方向传感器</title>
    <meta charset="UTF-8">
    <style>
        body {
            font-family: Arial, sans-serif;
        }
    </style>
    <script type="text/javascript">
        // 访问设备的加速度计传感器,并获取其数据。
        function getAccelerometer() {
            var acc = new Accelerometer({frequency: 60});
            acc.addEventListener('activate', () => console.log('Ready to measure.'));
            acc.addEventListener('error', error => console.log('Error type: ' + error.type + ', error: ' + error.error ));
            acc.addEventListener('reading', () => {
                console.log(`Accelerometer ${acc.timestamp}, ${acc.x}, ${acc.y}, ${acc.z}.`);
            });
            acc.start();
        }
        // 访问设备的陀螺仪传感器,并获取其数据。
        function getGyroscope() {
            var gyr = new Gyroscope({frequency: 60});
            gyr.addEventListener('activate', () => console.log('Ready to measure.'));
            gyr.addEventListener('error', error => console.log('Error type: ' + error.type + ', error: ' + error.error ));
            gyr.addEventListener('reading', () => {
                console.log(`Gyroscope ${gyr.timestamp}, ${gyr.x}, ${gyr.y}, ${gyr.z}.`);
            });
            gyr.start();
        }
        // 访问设备的方向传感器,并获取其数据。
        function getAbsoluteOrientationSensor() {
            var aos = new AbsoluteOrientationSensor({frequency: 60});
            aos.addEventListener('activate', () => console.log('Ready to measure.'));
            aos.addEventListener('error', error => console.log('Error type: ' + error.type + ', error: ' + error.error ));
            aos.addEventListener('reading', () => {
                console.log(`AbsoluteOrientationSensor data: ${aos.timestamp}, ${aos.quaternion}`);
            });
            aos.start();
        }
        // 监听设备的运动事件,并执行相应的处理逻辑。
        function listenDeviceMotionEvent() {
            removeDeviceMotionEvent();
            if ('DeviceMotionEvent' in window) {
                window.addEventListener('devicemotion', handleMotionEvent, false);
            } else {
              console.log('不支持DeviceMotionEvent');
            }
        }
        // 移除之前添加的设备运动事件监听器。
        function removeDeviceMotionEvent() {
            if ('DeviceMotionEvent' in window) {
              window.removeEventListener('devicemotion', handleMotionEvent, false);
            } else {
              console.log('不支持DeviceOrientationEvent');
            }
        }
        // 处理运动事件。
        function handleMotionEvent(event) {
            const x = event.accelerationIncludingGravity.x;
            const y = event.accelerationIncludingGravity.y;
            const z = event.accelerationIncludingGravity.z;
            console.log(`DeviceMotionEvent data: ${event.timeStamp}, ${x}, ${y}, ${z}`);
        }
        // 监听设备方向的变化,并执行相应的处理逻辑。
        function listenDeviceOrientationEvent() {
            removeDeviceOrientationEvent();
            if ('DeviceOrientationEvent' in window) {
                window.addEventListener('deviceorientation', handleOrientationEvent, false);
            } else {
                console.log('不支持DeviceOrientationEvent');
            }
        }
        // 移除之前添加的设备方向事件监听器。
        function removeDeviceOrientationEvent() {
            if ('DeviceOrientationEvent' in window) {
              window.removeEventListener('deviceorientation', handleOrientationEvent, false);
            } else {
              console.log('不支持DeviceOrientationEvent');
            }
        }
        // 监听设备方向的变化,并执行相应的处理逻辑。
        function listenDeviceOrientationEvent2() {
            removeDeviceOrientationEvent2();
            if ('DeviceOrientationEvent' in window) {
                window.addEventListener('deviceorientationabsolute', handleOrientationEvent, false);
            } else {
                console.log('不支持DeviceOrientationEvent');
            }
        }
        // 移除之前添加的设备方向事件监听器。
        function removeDeviceOrientationEvent2() {
            if ('DeviceOrientationEvent' in window) {
              window.removeEventListener('deviceorientationabsolute', handleOrientationEvent, false);
            } else {
              console.log('不支持DeviceOrientationEvent');
            }
        }
        // 处理方向事件。
        function handleOrientationEvent(event) {
            console.log(`DeviceOrientationEvent data: ${event.timeStamp}, ${event.absolute}, ${event.alpha}, ${event.beta}, ${event.gamma}`);
        }
    </script>
</head>
<body>
<div id="dcontent" class="dcontent">
    <h3>运动和方向:</h3>
    <ul class="dlist">
        <li><button type="button" onclick="getAccelerometer()">加速度</button></li>
        <li><button type="button" onclick="getGyroscope()">陀螺仪</button></li>
        <li><button type="button" onclick="getAbsoluteOrientationSensor()">设备方向(绝对定位)</button></li>
        <li><button type="button" onclick="listenDeviceMotionEvent()">设备运动事件</button></li>
        <li><button type="button" onclick="listenDeviceOrientationEvent()">设备方向事件</button></li>
        <li><button type="button" onclick="listenDeviceOrientationEvent2()">设备方向事件(绝对定位)</button></li>
    </ul>
</div>
</body>
</html>

5 Web渲染和布局

5.1 Web组件渲染模式

Web组件支持两种渲染模式。

异步渲染模式(默认)

  • renderMode: RenderMode.ASYNC_RENDER
  • 异步渲染模式下,Web组件作为图形surface节点,独立送显。建议在仅由Web组件构成的应用页面中使用此模式,有更好的性能和更低的功耗表现。

同步渲染模式

  • renderMode: RenderMode.SYNC_RENDER
  • 同步渲染模式下,Web组件作为图形canvas节点,Web渲染跟随系统组件一起送显。可以渲染更长Web组件内容,但会消耗更多的性能资源。

5.1.1  规格与约束

异步渲染模式

  • Web组件的宽高最大规格不超过7,680px(物理像素),超过则会导致白屏。
  • 不支持动态切换模式。

同步渲染模式

  • Web组件的宽高最大规格不超过500,000px(物理像素),超过则会导致白屏。
  • 不支持DSS合成。
  • 不支持动态切换模式。

5.1.2  使用场景

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

@Entry
@Component
struct WebHeightPage {
  private webviewController: WebviewController = new webview.WebviewController()

  build() {
     Column() {
         Web({
             src: "https://www.example.com/",
             controller: this.webviewController,
             renderMode: RenderMode.ASYNC_RENDER // 设置渲染模式
         })
     }
  }
}

5.2 Web组件大小自适应页面内容布局

使用Web组件大小自适应页面内容布局模式layoutMode(WebLayoutMode.FIT_CONTENT)时,能使Web组件的大小根据页面内容自适应变化。

适用于Web组件需要根据网页高度撑开,与其他原生组件一起滚动的场景,如:

  • 浏览长文章。Web组件同一布局层级有其他原生组件,如评论区、工具栏等。
  • 长页面首页。Web组件同一布局层级有其他原生组件,如宫格菜单。

5.2.1 规格与约束

  1. 建议配置渲染模式为同步渲染模式,避免因为组件大小超出限制导致异常场景(白屏,布局错误)。
  2. 建议配置过滚动模式为关闭状态。当过滚动模式开启时,当用户在Web界面上滑动到边缘时,Web会通过弹性动画弹回界面,会与Scroll组件的回弹相互冲突,影响体验。
  3. 键盘避让属性配置为RESIZE_CONTENT时,该避让模式不生效。
  4. 不支持对页面进行缩放。
  5. 不支持通过Web组件的height属性修改组件高度。
  6. 仅支持根据页面内容自适应组件高度,不支持自适应宽度。

5.2.2 使用场景

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

@Entry
@Component
struct WebHeightPage {
  private webviewController: WebviewController = new webview.WebviewController()
  private scroller: Scroller = new Scroller()

  build() {
    Navigation() {
      Column() {
        Scroll(this.scroller) {
          Column() {
            Web({
              src: $rawfile("fit_content.html"),
              controller: this.webviewController,
              renderMode: RenderMode.SYNC_RENDER // 设置为同步渲染模式
            })
              .layoutMode(WebLayoutMode.FIT_CONTENT) // 设置为Web组件大小自适应页面内容
              .overScrollMode(OverScrollMode.NEVER) // 设置过滚动模式为关闭状态
            Text("评论区")
              .fontSize(28)
              .fontColor("#FF0F0F")
              .height(100)
              .width("100%")
              .backgroundColor("#f89f0f")
          }
        }

      }
    }
    .title("标题栏")
  }
}
<!--fit_content.html-->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    <title>Fit-Content</title>
</head>
<body>
<div>
    <div><h2 id="使用场景">使用场景</h2>
        <p>ArkWeb(方舟Web)提供了Web组件,用于在应用程序中显示Web页面内容。常见使用场景包括:</p>
        <ul>
            <li><p>
                应用集成Web页面:应用可以在页面中使用Web组件,嵌入Web页面内容,以降低开发成本,提升开发、运营效率。</p>
            </li>
            <li><p>
                浏览器网页浏览场景:浏览器类应用可以使用Web组件,打开三方网页,使用无痕模式浏览Web页面,设置广告拦截等。</p>
            </li>
            <li><p>小程序:小程序类宿主应用可以使用Web组件,渲染小程序的页面。</p></li>
        </ul>
    </div>
    <div><h2 id="能力范围">能力范围</h2>
        <p>Web组件为开发者提供了丰富的控制Web页面能力。包括:</p>
        <ul>
            <li><p>Web页面加载:声明式加载Web页面和离屏加载Web页面等。</p></li>
            <li><p>生命周期管理:组件生命周期状态变化,通知Web页面的加载状态变化等。</p></li>
            <li><p>常用属性与事件:UserAgent管理、Cookie与存储管理、字体与深色模式管理、权限管理等。</p>
            </li>
            <li><p>
                与应用界面交互:自定义文本选择菜单、上下文菜单、文件上传界面等与应用界面交互能力。</p>
            </li>
            <li><p>App通过JavaScriptProxy,与Web页面进行JavaScript交互。</p></li>
            <li><p>安全与隐私:无痕浏览模式、广告拦截、坚盾守护模式等。</p></li>
            <li><p>维测能力:DevTools工具调试能力,使用crashpad收集Web组件崩溃信息。
            </p></li>
            <li><p>
                其他高阶能力:与原生组件同层渲染、Web组件的网络托管、Web组件的媒体播放托管、Web组件输入框拉起自定义输入法、等。</p>
            </li>
        </ul>
    </div>
    <div><h2 id="约束与限制">约束与限制</h2>
        <ul>
            <li>Web内核版本:ArkWeb基于谷歌Chromium内核开发,使用的Chromium版本为M114。</li>
        </ul>
    </div>
</div>
</body>
</html>

5.3 优化跳转至新Web组件过程中的页面闪烁现象

当应用采用Navigation等路由策略导航至Web组件页面时,在网页加载过程中,页面底部可能会出现闪烁现象,这有损用户体验。

5.3.1 闪烁原因

当应用采用Navigation等路由策略导航至Web组件页面时,通常会依据网页端的回调通知来判断是否应隐藏应用侧的原生导航栏。若决定隐藏原生导航栏,Web组件的布局将随即进行调整。这一布局调整过程可简化为如下四个阶段:

图中四个状态的说明(从左至右)。

  1. 将应用路由至Web页面,页面顶部为原生导航栏,底部则是Web组件,在此情况下,网页能够正常加载。
  2. 在网页加载过程中,系统会回调通知应用侧隐藏原生导航栏,以便切换至网页端的导航栏。此时,原生导航栏被隐藏,Web组件的布局随即进行调整。页面底部最初会显露出Web组件的背景色,假设这一颜色为灰色。
  3. 网页继续根据新的尺寸加载并显示,首先呈现的是HTML网页的背景色,此处假设为白色。
  4. 最后,网页内容完全加载,展现出完整的HTML网页内容。

在以上流程中,如果Web组件的背景色与网页的背景色有差异,在这种跳转过程中就有概率闪烁,闪烁产生的概率大小取决于网页的复杂程度与网络条件。

5.3.2 优化方法

应用可以通过设置Web组件的背景颜色,使其与网页背景颜色保持一致,从而避免视觉闪烁,提升用户体验(如上图示例,可将Web组件的背景色设置为白色)。

在其他类似情况下,例如Web组件默认背景为白色,若网页背景为灰色,则在导航至新的Web页面时可能会出现白色闪烁,将Web组件背景色设置为灰色即可解决此问题。

以下为设置Web组件背景色的接口示例(示例中将Web组件背景色设置为灰色,若不设置,Web组件背景色默认为白色):

 Web({ src: $rawfile('xxx.html'),  controller: this.webController})
      .backgroundColor(Color.Gray)

6 在应用中使用前端页面JavaScript

6.1 应用侧调用前端页面函数

应用侧可以通过runJavaScript()runJavaScriptExt()方法调用前端页面的JavaScript相关函数。

runJavaScript()runJavaScriptExt()在参数类型上有些差异。runJavaScriptExt()入参类型不仅支持string还支持ArrayBuffer(从文件中获取JavaScript脚本数据),另外可以通过AsyncCallback的方式获取执行结果。

在下面的示例中,点击应用侧的“runJavaScript”按钮时,来触发前端页面的htmlTest()方法。

前端页面代码:

<!-- index.html -->
<!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';
        console.log(param);
    }
    // 调用无参函数时实现。
    function htmlTest() {
        document.getElementById('text').style.color = 'green';
    }
    // Click Me!触发前端页面callArkTS()函数执行JavaScript传递的代码。
    function callArkTS() {
        changeColor();
    }
</script>
</body>
</html>

应用侧代码:

// xxx.ets
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(() => {
          // 前端页面函数无参时,将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 })
    }
  }
}

6.2 前端页面调用应用侧函数

使用Web组件将应用侧代码注册到前端页面中,注册完成之后,前端页面中使用注册的对象名称就可以调用应用侧的函数,实现在前端页面中调用应用侧方法。

注册应用侧代码有两种方式,一种在Web组件初始化调用,使用javaScriptProxy()接口。另外一种在Web组件初始化完成后调用,使用registerJavaScriptProxy()接口, 需要和deleteJavaScriptRegister接口配合使用,防止内存泄漏。

在下面的示例中,将test()方法注册在前端页面中, 该函数可以在前端页面触发运行。

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

class testClass {
  constructor() {
  }

  test(): string {
    return 'ArkTS Hello World!';
  }
}

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();
  // 声明需要注册的对象
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      // Web组件加载本地index.html页面
      Web({ src: $rawfile('index.html'), controller: this.webviewController})
        // 将对象注入到web端
        .javaScriptProxy({
          object: this.testObj,
          name: "testObjName",
          methodList: ["test"],
          controller: this.webviewController,
          // 可选参数
          asyncMethodList: [],
          permission: '{"javascriptProxyPermission":{"urlPermissionList":[{"scheme":"resource","host":"rawfile","port":"","path":""},' +
                      '{"scheme":"e","host":"f","port":"g","path":"h"}],"methodList":[{"methodName":"test","urlPermissionList":' +
                      '[{"scheme":"https","host":"xxx.com","port":"","path":""},{"scheme":"resource","host":"rawfile","port":"","path":""}]},' +
                      '{"methodName":"test11","urlPermissionList":[{"scheme":"q","host":"r","port":"","path":"t"},' +
                      '{"scheme":"u","host":"v","port":"","path":""}]}]}}'
        })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

class testClass {
  constructor() {
  }

  test(): string {
    return "ArkUI Web Component";
  }

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

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"],
                    // 可选参数, asyncMethodList
                    [],
                    // 可选参数, permission
                    '{"javascriptProxyPermission":{"urlPermissionList":[{"scheme":"resource","host":"rawfile","port":"","path":""},' +
                    '{"scheme":"e","host":"f","port":"g","path":"h"}],"methodList":[{"methodName":"test","urlPermissionList":' +
                    '[{"scheme":"https","host":"xxx.com","port":"","path":""},{"scheme":"resource","host":"rawfile","port":"","path":""}]},' +
                    '{"methodName":"test11","urlPermissionList":[{"scheme":"q","host":"r","port":"","path":"t"},' +
                    '{"scheme":"u","host":"v","port":"","path":""}]}]}}'
            );
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}

 说明

  • 可选参数permission是一个json字符串,示例如下:
{
  "javascriptProxyPermission": {
    "urlPermissionList": [       // Object级权限,如果匹配,所有Method都授权
      {
        "scheme": "resource",    // 精确匹配,不能为空
        "host": "rawfile",       // 精确匹配,不能为空
        "port": "",              // 精确匹配,为空不检查
        "path": ""               // 前缀匹配,为空不检查
      },
      {
        "scheme": "https",       // 精确匹配,不能为空
        "host": "xxx.com",       // 精确匹配,不能为空
        "port": "8080",          // 精确匹配,为空不检查
        "path": "a/b/c"          // 前缀匹配,为空不检查
      }
    ],
    "methodList": [
      {
        "methodName": "test",
        "urlPermissionList": [   // Method级权限
          {
            "scheme": "https",   // 精确匹配,不能为空
            "host": "xxx.com",   // 精确匹配,不能为空
            "port": "",          // 精确匹配,为空不检查
            "path": ""           // 前缀匹配,为空不检查
          },
          {
            "scheme": "resource",// 精确匹配,不能为空
            "host": "rawfile",   // 精确匹配,不能为空
            "port": "",          // 精确匹配,为空不检查
            "path": ""           // 前缀匹配,为空不检查
          }
        ]
      },
      {
        "methodName": "test11",
        "urlPermissionList": [   // Method级权限
          {
            "scheme": "q",       // 精确匹配,不能为空
            "host": "r",         // 精确匹配,不能为空
            "port": "",          // 精确匹配,为空不检查
            "path": "t"          // 前缀匹配,为空不检查
          },
          {
            "scheme": "u",       // 精确匹配,不能为空
            "host": "v",         // 精确匹配,不能为空
            "port": "",          // 精确匹配,为空不检查
            "path": ""           // 前缀匹配,为空不检查
          }
        ]
      }
    ]
  }
}
  • index.html前端页面触发应用侧代码。 
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    function callArkTS() {
        let str = testObjName.test();
        document.getElementById("demo").innerHTML = str;
        console.info('ArkTS Hello World! :' + str);
    }
</script>
</body>
</html>

6.2.1 复杂类型使用方法

  • 应用侧和前端页面之间传递Array。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

class testClass {
  constructor() {
  }

  test(): Array<Number> {
    return [1, 2, 3, 4]
  }

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

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"]);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    function callArkTS() {
        testObjName.toString(testObjName.test());
    }
</script>
</body>
</html>
  •  应用侧和前端页面之间传递基础类型,非Function等复杂类型。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

class student {
  name: string = '';
  age: string = '';
}

class testClass {
  constructor() {
  }

  // 传递的基础类型name:"jeck", age:"12"。
  test(): student {
    let st: student = { name: "jeck", age: "12" };
    return st;
  }

  toString(param: ESObject): void {
    console.log('Web Component toString' + param["name"]);
  }
}

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"]);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    function callArkTS() {
        testObjName.toString(testObjName.test());
    }
</script>
</body>
</html>
  • 应用侧调用前端页面的Callback。 
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

class testClass {
  constructor() {
  }

  test(param: Function): void {
    param("call callback");
  }

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

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"]);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    function callArkTS() {
        testObjName.test(function(param){testObjName.toString(param)});
    }
</script>
</body>
</html>
  •  应用侧调用前端页面Object里的Function。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

class testClass {
  constructor() {
  }

  test(param: ESObject): void {
    param.hello("call obj func");
  }

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

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"]);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}

<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    // 写法1
    class Student {
        constructor(nameList) {
            this.methodNameListForJsProxy = nameList;
        }
        hello(param) {
            testObjName.toString(param)
        }
    }
    var st = new Student(["hello"])
    // 写法2
    //创建一个构造器,构造函数首字母大写
    function Obj1(){
        this.methodNameListForJsProxy=["hello"];
        this.hello=function(param){
            testObjName.toString(param)
        };
    }
    //利用构造器,通过new关键字生成对象
    var st1 = new Obj1();
    function callArkTS() {
        testObjName.test(st);
        testObjName.test(st1);
    }
</script>
</body>
</html>
  • 前端页面调用应用侧Object里的Function。 
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

class ObjOther {
  methodNameListForJsProxy: string[]

  constructor(list: string[]) {
    this.methodNameListForJsProxy = list
  }

  testOther(json: string): void {
    console.info(json)
  }
}

class testClass {
  ObjReturn: ObjOther

  constructor() {
    this.ObjReturn = new ObjOther(["testOther"]);
  }

  test(): ESObject {
    return this.ObjReturn
  }

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

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"]);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    function callArkTS() {
      testObjName.test().testOther("call other object func");
    }
</script>
</body>
</html>
  • Promise场景。

第一种使用方法,在应用侧new Promise。

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

class testClass {
  constructor() {
  }

  test(): Promise<string> {
    let p: Promise<string> = new Promise((resolve, reject) => {
      setTimeout(() => {
        console.log('执行完成');
        reject('fail');
      }, 10000);
    });
    return p;
  }

  toString(param: String): void {
    console.log(" " + param);
  }
}

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"]);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    function callArkTS() {
      testObjName.test().then((param)=>{testObjName.toString(param)}).catch((param)=>{testObjName.toString(param)})
    }
</script>
</body>
</html>

第二种使用方法,在前端页面new Promise。

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

class testClass {
  constructor() {
  }

  test(param:Function): void {
    setTimeout( () => { param("suc") }, 10000)
  }

  toString(param:String): void {
    console.log(" " + param);
  }
}

@Entry
@Component
struct Index {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State testObj: testClass = new testClass();

  build() {
    Column() {
      Button('refresh')
        .onClick(() => {
          try {
            this.webviewController.refresh();
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('Register JavaScript To Window')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(this.testObj, "testObjName", ["test", "toString"]);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('deleteJavaScriptRegister')
        .onClick(() => {
          try {
            this.webviewController.deleteJavaScriptRegister("testObjName");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<button type="button" onclick="callArkTS()">Click Me!</button>
<p id="demo"></p>
<script>
    function callArkTS() {
      let funpromise
      var p = new Promise(function(resolve, reject){funpromise=(param)=>{resolve(param)}})
      testObjName.test(funpromise)
      p.then((param)=>{testObjName.toString(param)})
    }
</script>
</body>
</html>

6.3 建立应用侧与前端页面数据通道 

前端页面和应用侧之间可以用createWebMessagePorts()接口创建消息端口来实现两端的通信。

在下面的示例中,应用侧页面中通过createWebMessagePorts方法创建消息端口,再把其中一个端口通过postMessage()接口发送到前端页面,便可以在前端页面和应用侧之间互相发送消息。

应用侧代码:

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

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  ports: webview.WebMessagePort[] = [];
  @State sendFromEts: string = 'Send this message from ets to HTML';
  @State receivedFromHtml: string = 'Display received message send from HTML';

  build() {
    Column() {
      // 展示接收到的来自HTML的内容
      Text(this.receivedFromHtml)
      // 输入框的内容发送到HTML
      TextInput({ placeholder: 'Send this message from ets to HTML' })
        .onChange((value: string) => {
          this.sendFromEts = value;
        })

      // 该内容可以放在onPageEnd生命周期中调用。
      Button('postMessage')
        .onClick(() => {
          try {
            // 1、创建两个消息端口。
            this.ports = this.controller.createWebMessagePorts();
            // 2、在应用侧的消息端口(如端口1)上注册回调事件。
            this.ports[1].onMessageEvent((result: webview.WebMessage) => {
              let msg = 'Got msg from HTML:';
              if (typeof (result) === 'string') {
                console.info(`received string message from html5, string is: ${result}`);
                msg = msg + result;
              } else if (typeof (result) === 'object') {
                if (result instanceof ArrayBuffer) {
                  console.info(`received arraybuffer from html5, length is: ${result.byteLength}`);
                  msg = msg + 'length is ' + result.byteLength;
                } else {
                  console.info('not support');
                }
              } else {
                console.info('not support');
              }
              this.receivedFromHtml = msg;
            })
            // 3、将另一个消息端口(如端口0)发送到HTML侧,由HTML侧保存并使用。
            this.controller.postMessage('__init_port__', [this.ports[0]], '*');
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })

      // 4、使用应用侧的端口给另一个已经发送到html的端口发送消息。
      Button('SendDataToHTML')
        .onClick(() => {
          try {
            if (this.ports && this.ports[1]) {
              this.ports[1].postMessageEvent(this.sendFromEts);
            } else {
              console.error(`ports is null, Please initialize first`);
            }
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.controller })
    }
  }
}

前端页面代码:

<!--index.html-->
<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebView Message Port Demo</title>
</head>
<body>
    <h1>WebView Message Port Demo</h1>
    <div>
        <input type="button" value="SendToEts" onclick="PostMsgToEts(msgFromJS.value);"/><br/>
        <input id="msgFromJS" type="text" value="send this message from HTML to ets"/><br/>
    </div>
    <p class="output">display received message send from ets</p>
</body>
<script>
var h5Port;
var output = document.querySelector('.output');
window.addEventListener('message', function (event) {
    if (event.data === '__init_port__') {
        if (event.ports[0] !== null) {
            h5Port = event.ports[0]; // 1. 保存从应用侧发送过来的端口。
            h5Port.onmessage = function (event) {
              // 2. 接收ets侧发送过来的消息。
              var msg = 'Got message from ets:';
              var result = event.data;
              if (typeof(result) === 'string') {
                console.info(`received string message from html5, string is: ${result}`);
                msg = msg + result;
              } else if (typeof(result) === 'object') {
                if (result instanceof ArrayBuffer) {
                  console.info(`received arraybuffer from html5, length is: ${result.byteLength}`);
                  msg = msg + 'length is ' + result.byteLength;
                } else {
                  console.info('not support');
                }
              } else {
                console.info('not support');
              }
              output.innerHTML = msg;
            }
        }
    }
})
// 3. 使用h5Port向应用侧发送消息。
function PostMsgToEts(data) {
    if (h5Port) {
      h5Port.postMessage(data);
    } else {
      console.error('h5Port is null, Please initialize first');
    }
}
</script>
</html>

7 管理网页交互

7.1 Web组件嵌套滚动

Web组件嵌套滚动的典型应用场景为,在一个页面中,有多个独立的区域需要进行滚动,当用户滚动Web区域内容时,可带动其他滚动区域进行滚动,以达到上下滑动页面的用户体验。

内嵌在可滚动容器(ScrollList...)中的Web组件,接收到滑动手势事件,需要对接ArkUI框架的NestedScrollMode枚举类型,使得Web组件可以嵌套ArkUI可滚动容器,进行嵌套滚动。开发者可以在Web组件创建时,使用nestedScroll属性接口指定默认的嵌套滚动模式,也允许在过程中动态改变嵌套滚动的模式。

nestedScroll入参为一个NestedScrollOptions对象,该对象具有两个属性,分别为scrollForward和scrollBackward,每一个属性都为一个NestedScrollMode枚举类型。

当Web组件被多个可滚动容器组件嵌套时,未被Web组件消费的与父组件方向一致的偏移量、速度值将被传递给距Web组件最近且方向一致的父组件,使得父组件可以继续滚动。一次手势滑动只能沿X轴或Y轴一个方向嵌套滚动,当手势斜向滑动时,滚动方向为偏移量或速度在X轴、Y轴绝对值较大的方向;当偏移量或速度绝对值在X轴、Y轴绝对值相同时,滚动方向为距Web组件最近的可滚动组件的方向。

说明

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

@Entry
@Component
struct NestedScroll {
  private scrollerForScroll: Scroller = new Scroller();
  controller: webview.WebviewController = new webview.WebviewController();
  controller2: webview.WebviewController = new webview.WebviewController();
  // NestedScrollMode设置成SELF_ONLY时,Web网页滚动到页面边缘后,不与父组件联动,父组件仍无法滚动。
  @State NestedScrollMode0: NestedScrollMode = NestedScrollMode.SELF_ONLY;
  // NestedScrollMode设置成SELF_FIRST时,Web网页滚动到页面边缘后,父组件继续滚动。
  @State NestedScrollMode1: NestedScrollMode = NestedScrollMode.SELF_FIRST;
  // NestedScrollMode设置为PARENT_FIRST时,父组件先滚动,滚动至边缘后通知Web继续滚动。
  @State NestedScrollMode2: NestedScrollMode = NestedScrollMode.PARENT_FIRST;
  // NestedScrollMode设置为PARALLEL时,父组件与Web同时滚动。
  @State NestedScrollMode3: NestedScrollMode = NestedScrollMode.PARALLEL;
  @State NestedScrollModeF: NestedScrollMode = NestedScrollMode.SELF_FIRST;
  @State NestedScrollModeB: NestedScrollMode = NestedScrollMode.SELF_FIRST;
  // scroll竖向的滚动
  @State ScrollDirection: ScrollDirection = ScrollDirection.Vertical;

  build() {
    Flex() {
      Scroll(this.scrollerForScroll) {
        Column({ space: 5 }) {
          Row({}) {
            Text('切换前滚动模式').fontSize(5)
            Button('SELF_ONLY').onClick((event: ClickEvent) => {
              this.NestedScrollModeF = this.NestedScrollMode0
            }).fontSize(5)
            Button('SELF_FIRST').onClick((event: ClickEvent) => {
              this.NestedScrollModeF = this.NestedScrollMode1
            }).fontSize(5)
            Button('PARENT_FIRST').onClick((event: ClickEvent) => {
              this.NestedScrollModeF = this.NestedScrollMode2
            }).fontSize(5)
            Button('PARALLEL').onClick((event: ClickEvent) => {
              this.NestedScrollModeF = this.NestedScrollMode3
            }).fontSize(5)
          }

          Row({}) {
            Text('切换后滚动模式').fontSize(5)
            Button('SELF_ONLY').onClick((event: ClickEvent) => {
              this.NestedScrollModeB = this.NestedScrollMode0
            }).fontSize(5)
            Button('SELF_FIRST').onClick((event: ClickEvent) => {
              this.NestedScrollModeB = this.NestedScrollMode1
            }).fontSize(5)
            Button('PARENT_FIRST').onClick((event: ClickEvent) => {
              this.NestedScrollModeB = this.NestedScrollMode2
            }).fontSize(5)
            Button('PARALLEL').onClick((event: ClickEvent) => {
              this.NestedScrollModeB = this.NestedScrollMode3
            }).fontSize(5)
          }

          Text('当前内嵌前滚动模式 scrollForward ---' + `${this.NestedScrollModeF}`).fontSize(10)
          Text('当前内嵌后滚动模式  scrollBackward ---' + `${this.NestedScrollModeB}`).fontSize(10)

          Text("Scroll Area")
            .width("100%")
            .height("10%")
            .backgroundColor(0X330000FF)
            .fontSize(16)
            .textAlign(TextAlign.Center)
          Text("Scroll Area")
            .width("100%")
            .height("10%")
            .backgroundColor(0X330000FF)
            .fontSize(16)
            .textAlign(TextAlign.Center)
          Text("Scroll Area")
            .width("100%")
            .height("10%")
            .backgroundColor(0X330000FF)
            .fontSize(16)
            .textAlign(TextAlign.Center)

          Web({ src: "www.example.com", controller: this.controller })
            .nestedScroll({
              scrollForward: this.NestedScrollModeF,
              scrollBackward: this.NestedScrollModeB,
            })
            .height("40%")
            .width("100%")

          Text("Scroll Area")
            .width("100%")
            .height("20%")
            .backgroundColor(0X330000FF)
            .fontSize(16)
            .textAlign(TextAlign.Center)

          Text("Scroll Area")
            .width("100%")
            .height("20%")
            .backgroundColor(0X330000FF)
            .fontSize(16)
            .textAlign(TextAlign.Center)

          Web({ src: "www.example.com", controller: this.controller2 })
            .nestedScroll({
              scrollForward: this.NestedScrollModeF,
              scrollBackward: this.NestedScrollModeB,
            })
            .height("40%")
            .width("90%")

          Text("Scroll Area")
            .width("100%")
            .height("20%")
            .backgroundColor(0X330000FF)
            .fontSize(16)
            .textAlign(TextAlign.Center)

        }.width("95%").border({ width: 5 })
      }
      .width("100%").height("120%").border({ width: 5 }).scrollable(this.ScrollDirection)
    }.width('100%').height('100%').backgroundColor(0xDCDCDC).padding(20)
  }
}

7.2 Web页面显示内容滚动

ArkWeb中的Webview.WebviewController提供scrollTo和scrollBy接口。

当Web显示的内容大小远远大于组件大小时,用户可以通过scrollTo和scrollBy对Web页面中显示的内容进行滚动来显示没有显示的部分,且可以生成动画滚动效果。目前动画效果不支持手势打断,可以通过再次执行一个时间约为0的动画进行强制打断。

说明

  • 支持滚动的条件: Web页面的长度或宽度大于显示区域的长度或宽度。

7.3 Web组件对接软键盘

开发者能够通过Web组件对接软键盘,来处理系统软键盘的显示与交互问题,同时实现软键盘的自定义功能。主要有以下场景。

  • 拉起系统软键盘输入文字:用户在点击网页输入框时,会在屏幕下方弹出系统默认的软键盘(输入法),用户可以通过软键盘输入文字,输入的内容会显示在输入框中。
  • 自定义系统软键盘的回车键类型:应用指定网页输入框拉起不同类型的软键盘回车键。例如:确认、下一个、提交等。
  • 软键盘避让:在移动设备上,由于输入法通常固定在屏幕下半段,应用可设置不同的Web页面软键盘避让模式。例如:平移、调整大小、不避让等。
  • 自定义软键盘输入:在移动设备上,应用可以使用自绘制输入法在Web页面输入,以此替代系统软键盘。

7.3.1 Web页面输入框输入与软键盘交互的W3C标准支持

为支持Web页面与系统软键盘、自定义软键盘等的良好交互,ArkWeb遵循并实现了W3C规范中的以下输入控制属性:

  • type属性

type属性定义了input元素的类型,影响输入的验证、显示方式和键盘类型。常见的type值包括:

type值描述
text默认值。普通文本输入
number数字输入
email电子邮件地址输入
password密码输入
tel电话号码输入
urlURL输入
date日期选择器
time时间选择器
checkbox复选框
radio单选按钮
file文件上传
submit提交按钮
reset重置按钮
button普通按钮
  • inputmode属性

inputmode属性用于配置输入法类型。

inputmode描述
decimal只显示数字键盘,通常还有一个逗号键
email文本键盘,键通常用于电子邮件地址,如[@]。
none不应出现键盘
numeric只显示数字键盘
search文本键盘,[enter]键通常显示为[go]
tel只显示数字键盘,通常还有[+]、[*]和[#]键。
text默认。文本键盘
url文本键盘,键通常用于网址,如[.]和[/],以及特殊的[.com]键,或者其他通常用于本地设置的域名结束符。
  • enterkeyhint属性

enterkeyhint属性用于指定移动设备虚拟键盘上回车键的显示方式。

enterkeyhint值描述
enter显示默认的回车键
done表示输入完成
go表示跳转或执行
next进入下一个输入字段
previous返回上一个输入字段
search执行搜索
send发送信息

说明

用户在点击网页输入框时,会在屏幕下方弹出系统默认的软键盘(输入法),并可进行文字输入上屏。

type属性更广泛,不仅影响键盘显示,还会影响输入验证和元素的外观。

inputmode主要用于优化移动设备上的键盘输入体验,不会改变input的基本行为或验证。

7.3.2 设置软键盘避让模式

在移动设备上,支持设置Web页面的软键盘避让模式。

  1. 在应用代码中设置UIContext的软键盘避让模式setKeyboardAvoidMode()情况下,ArkWeb组件可支持Resize和Offset两种模式。
  • Resize模式下,应用窗口高度可缩小避开软键盘,ArkWeb组件跟随ArkUI重新布局。
  • Offset模式下(以及默认模式),应用窗口高度不变,ArkWeb组件根据自身的避让模式进行避让。

(1)在应用代码中设置UIContext的软键盘避让模式。

// EntryAbility.ets
import { KeyboardAvoidMode } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

onWindowStageCreate(windowStage: window.WindowStage) {
  hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

  windowStage.loadContent('pages/Index', (err, data) => {
    let keyboardAvoidMode = windowStage.getMainWindowSync().getUIContext().getKeyboardAvoidMode();
    // 设置虚拟键盘抬起时压缩页面大小为减去键盘的高度
  windowStage.getMainWindowSync().getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
    if (err.code) {
      hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
      return;
    }
    hilog.info(0x0000, 'testTag', 'Succeeded in loading the content. Data: %{public}s', JSON.stringify(data) ?? '');
  });
}

(2)再在Web组件中拉起软键盘。

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>测试网页</title>
  </head>
  <body>
    <h1>DEMO</h1>
    <input type="text" id="input_a">
  </body>
</html>
//Index.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct KeyboardAvoidExample {
  controller: webview.WebviewController = new webview.WebviewController();
  build() {
    Column() {
      Row().height("50%").width("100%").backgroundColor(Color.Gray)
      Web({ src: $rawfile("index.html"),controller: this.controller})
      Text("I can see the bottom of the page").width("100%").textAlign(TextAlign.Center).backgroundColor(Color.Pink).layoutWeight(1)
    }.width('100%').height("100%")
  }
}

此时ArkWeb组件跟随ArkUI重新布局,效果如图1、图2所示。

图1 Web组件网页默认软键盘避让模式

图2 Web组件网页跟随Arkui软键盘避让模式

2.在UIContext的键盘避让模式为Offset模式情况下,应用可通过WebKeyboardAvoidMode()设置ArkWeb组件的键盘避让模式。Web组件的WebKeyboardAvoidMode()接口优先级高于W3C侧virtualKeyboard.overlayContens。

  • RESIZE_VISUAL:仅调整可视视口的大小,而不调整布局视口的大小。
  • RESIZE_CONTENT:调整视觉视口和布局视口的大小。
  • OVERLAYS_CONTENT:不调整任何视口的大小,获焦input元素没有滚动到可识区域的行为。

说明

可视视口指用户正在看到的网站的区域,该区域的宽度等于移动设备的浏览器窗口的宽度。

布局视口指网页本身的宽度。

(1)在应用代码中设置ArkWeb的软键盘避让模式。

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

@Entry
@Component
struct KeyboardAvoidExample {
  controller: webview.WebviewController = new webview.WebviewController();
  build() {
    Column() {
      Row().height("50%").width("100%").backgroundColor(Color.Gray)
      Web({ src: $rawfile("index.html"),controller: this.controller})
        .keyboardAvoidMode(WebKeyboardAvoidMode.OVERLAYS_CONTENT) //此时ArkWeb组件不会调整任何视口的大小大小
      Text("I can see the bottom of the page").width("100%").textAlign(TextAlign.Center).backgroundColor(Color.Pink).layoutWeight(1)
    }.width('100%').height("100%")
  }
}

此时ArkWeb组件根据自身的避让模式进行避让,效果如图3所示。

图3 Web组件网页自身软键盘避让模式

与其他Web组件行为的交叉场景:

交叉场景规格
同层渲染同层Web:软键盘避让行为与普通场景行为一致 同层原生组件:由ArkUI负责软键盘避让模式。
离屏创建组件默认使用与非离屏创建一致的软键盘避让模式 在上树前设置其他避让模式可需生效。
customDialogcustomDialog自身避让。
折叠屏软键盘避让行为与普通场景行为一致 软件键盘需跟随屏幕开合状态进展开合变化。
软键盘托管软键盘避让行为与普通场景行为一致。
Web嵌套滚动嵌套滚动场景下不推荐使用Web软键盘避让,包括RESIZE_VISUAL与RESIZE_CONTENT。

7.3.3 拦截系统软键盘与自定义软键盘输入

应用能够通过调用onInterceptKeyboardAttach来拦截系统软键盘的弹出。在网页中,当可编辑元素如input标签即将触发软键盘显示时,onInterceptKeyboardAttach会被回调。应用可利用此接口来控制软键盘的显示,包括使用系统默认软键盘、定制带有特定Enter键的软键盘,或是完全自定义软键盘。借助这一功能,开发者能够实现对软键盘的灵活管理。

  • 使用系统默认软键盘
  • 使用定制Enter键的系统软键盘
  • 使用完全由应用自定义的软键盘
  // Index.ets
  import { webview } from '@kit.ArkWeb';
  import { inputMethodEngine } from '@kit.IMEKit';

  @Entry
  @Component
  struct WebComponent {
    controller: webview.WebviewController = new webview.WebviewController();
    webKeyboardController: WebKeyboardController = new WebKeyboardController()
    inputAttributeMap: Map<string, number> = new Map([
        ['UNSPECIFIED', inputMethodEngine.ENTER_KEY_TYPE_UNSPECIFIED],
        ['GO', inputMethodEngine.ENTER_KEY_TYPE_GO],
        ['SEARCH', inputMethodEngine.ENTER_KEY_TYPE_SEARCH],
        ['SEND', inputMethodEngine.ENTER_KEY_TYPE_SEND],
        ['NEXT', inputMethodEngine.ENTER_KEY_TYPE_NEXT],
        ['DONE', inputMethodEngine.ENTER_KEY_TYPE_DONE],
        ['PREVIOUS', inputMethodEngine.ENTER_KEY_TYPE_PREVIOUS]
      ])

      /**
       * 自定义键盘组件Builder
       */
      @Builder
      customKeyboardBuilder() {
          // 这里实现自定义键盘组件,对接WebKeyboardController实现输入、删除、关闭等操作。
        Row() {
          Text("完成")
            .fontSize(20)
            .fontColor(Color.Blue)
            .onClick(() => {
              this.webKeyboardController.close();
            })
          // 插入字符。
          Button("insertText").onClick(() => {
            this.webKeyboardController.insertText('insert ');
          }).margin({
            bottom: 200,
          })
          // 从后往前删除length参数指定长度的字符。
          Button("deleteForward").onClick(() => {
            this.webKeyboardController.deleteForward(1);
          }).margin({
            bottom: 200,
          })
          // 从前往后删除length参数指定长度的字符。
          Button("deleteBackward").onClick(() => {
            this.webKeyboardController.deleteBackward(1);
          }).margin({
            left: -220,
          })
          // 插入功能按键。
          Button("sendFunctionKey").onClick(() => {
            this.webKeyboardController.sendFunctionKey(6);
          })
        }
      }

    build() {
      Column() {
        Web({ src: $rawfile('index.html'), controller: this.controller })
        .onInterceptKeyboardAttach((KeyboardCallbackInfo) => {
          // option初始化,默认使用系统默认键盘
          let option: WebKeyboardOptions = {
            useSystemKeyboard: true,
          };
          if (!KeyboardCallbackInfo) {
            return option;
          }

          // 保存WebKeyboardController,使用自定义键盘时候,需要使用该handler控制输入、删除、软键盘关闭等行为
          this.webKeyboardController = KeyboardCallbackInfo.controller
          let attributes: Record<string, string> = KeyboardCallbackInfo.attributes
          // 遍历attributes
          let attributeKeys = Object.keys(attributes)
          for (let i = 0; i < attributeKeys.length; i++) {
            console.log('WebCustomKeyboard key = ' + attributeKeys[i] + ', value = ' + attributes[attributeKeys[i]])
          }

          if (attributes) {
            if (attributes['data-keyboard'] == 'customKeyboard') {
              // 根据html可编辑元素的属性,判断使用不同的软键盘,例如这里如果属性包含有data-keyboard,且值为customKeyboard,则使用自定义键盘
              console.log('WebCustomKeyboard use custom keyboard')
              option.useSystemKeyboard = false;
              // 设置自定义键盘builder
              option.customKeyboard = () => {
                this.customKeyboardBuilder()
              }
              return option;
            }

            if (attributes['keyboard-return'] != undefined) {
              // 根据html可编辑元素的属性,判断使用不同的软键盘,例如这里如果属性包含有keyboard-return,使用系统键盘,并且指定系统软键盘enterKey类型
              option.useSystemKeyboard = true;
              let enterKeyType: number | undefined = this.inputAttributeMap.get(attributes['keyboard-return'])
              if (enterKeyType != undefined) {
                option.enterKeyType = enterKeyType
              }
              return option;
            }
          }

          return option;
        })
      }
    }
  }
<!-- index.html -->
    <!DOCTYPE html>
    <html>

    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0">
    </head>

    <body>

    <p style="font-size:12px">input标签,原有默认行为:</p>
    <input type="text" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,系统键盘自定义enterKeyType属性 enter key UNSPECIFIED:</p>
    <input type="text" keyboard-return="UNSPECIFIED" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,系统键盘自定义enterKeyType属性 enter key GO:</p>
    <input type="text" keyboard-return="GO" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,系统键盘自定义enterKeyType属性 enter key SEARCH:</p>
    <input type="text" keyboard-return="SEARCH" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,系统键盘自定义enterKeyType属性 enter key SEND:</p>
    <input type="text" keyboard-return="SEND" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,系统键盘自定义enterKeyType属性 enter key NEXT:</p>
    <input type="text" keyboard-return="NEXT" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,系统键盘自定义enterKeyType属性 enter key DONE:</p>
    <input type="text" keyboard-return="DONE" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,系统键盘自定义enterKeyType属性 enter key PREVIOUS:</p>
    <input type="text" keyboard-return="PREVIOUS" style="width: 300px; height: 20px"><br>
    <hr style="height:2px;border-width:0;color:gray;background-color:gray">

    <p style="font-size:12px">input标签,应用自定义键盘:</p>
    <input type="text" data-keyboard="customKeyboard" style="width: 300px; height: 20px"><br>

    </body>

    </html>

ArkWeb自定义键盘示例效果如图4、图5、图6所示。

图4 ArkWeb自定义键盘数字键盘

图5 ArkWeb自定义键盘字母键盘

图6 ArkWeb自定义键盘符号键盘

7.4 Web组件焦点管理

利用Web组件的焦点管理功能,有效管理Web组件的聚焦与失焦,同时利用H5侧的W3C标准接口,管理网页界面上唯一可交互的元素聚焦与失焦。

  • Web组件与ArkUI组件焦点控制的常用接口及其使用场景
  1. 通过requestFocus主动请求Web组件获焦:当应用内有多个组件时,开发者可通过Web组件的requestFocus接口主动将焦点转移到Web组件上。
  2. 根据焦点情况更改Web组件样式:组件监听焦点事件上报,为组件修改样式,例如边框、背景色等,以提供视觉和交互反馈。
  • Web组件内H5元素焦点控制的常用接口及其使用场景
  1. 通过tabindex属性管理元素焦点:定义Web组件内元素的焦点顺序。可以通过将元素的tabindex设置为"-1",使其能够通过脚本进行聚焦,同时在CSS中对元素的可见性进行控制。
  2. 键盘事件更新焦点位置:监听键盘事件,例如Tab键,依据用户的操作更新Web内元素焦点位置。
  3. 根据焦点情况更改Web组件内元素样式:为焦点元素添加样式,例如边框、背景色等,以提供视觉和交互反馈。

7.4.1 基础概念

Web组件焦点、焦点链和走焦的详情说明请参考ArkUI焦点基础概念

  • 焦点: 
    • 组件焦点:指当前应用界面上唯一的一个可交互元素。
    • 网页内元素焦点:指当前网页界面上唯一的一个可交互元素。
    • 当用户使用键盘、遥控器、遥杆等非指向性输入设备与应用程序进行间接交互时,基于焦点的导航和交互是重要的输入手段。
  • 走焦: 
    • 组件走焦:指焦点在应用内的组件之间转移的行为。这一过程对用户透明,开发者可以通过监听onFocus(焦点获取)和onBlur(焦点失去)事件来捕捉这些变化。
    • 网页内元素走焦:指焦点在网页内的元素之间转移的行为。该行为遵循W3C标准,开发者可以通过监听focus(在元素获取焦点时触发)和blur(在元素失去焦点时触发)事件来捕捉这些变化。

7.4.2 Web组件走焦规范

根据走焦的触发方式,可以分为主动走焦和被动走焦,Web组件走焦规范详情参考ArkUI走焦规范

1、主动走焦

指开发者或用户主观行为导致的焦点移动。包括:使用requestFocus申请焦点、外接键盘的按键走焦(TAB键/Shift+TAB键)、点击申请焦点(手势/鼠标/触摸板)等导致的焦点转移。

  • requestFocus

详见Web组件与ArkUI组件焦点控制,可以主动将焦点转移到Web组件上。

  • 按键走焦
  • 支持ArkWeb与其他组件通过TAB键、Shift+TAB键走焦。
  • 支持ArkWeb内部网页元素通过TAB键、Shift+TAB键走焦,网页元素走焦完成后,抛回ArkUI继续框架侧走焦。
  • 点击申请获焦

用户可通过手势、鼠标或触摸板点击Web组件,使其主动获得焦点。当具体点击到Web组件内的某个元素时,该元素能够获得焦点,例如,点击网页内的输入框,可使其从不可编辑状态转变为可编辑状态,并激活输入法。

2、被动走焦

被动走焦指焦点因系统获其他操作而转移,无需直接干预,是焦点系统的默认行为。

目前会被动走焦的场景有:

  • 组件删除:当焦点所在的Web组件被移除时,系统会按照先向后再向前的原则,将焦点转移至相邻的同级组件。倘若所有同级组件均无法获取焦点,则焦点将被释放,并提示其父级组件接管焦点处理。
  • 属性变更:如果将处于焦点状态的组件的focusable或enabled属性设置为false,或者将visibility属性设置为不可见,系统会自动将焦点转移到其他可获得焦点的组件上,转移方式同组件删除。
  • Web组件不可见:ArkWeb获焦后,应用前后台切换、页面切换、Navigation导航等场景,ArkWeb会失焦再获焦。
  • Web组件加载网页:ArkWeb通过src、loadUrl、loadData加载网页,默认会获取焦点,但如果此时web组件为不可获焦状态则会获焦失败(常见的不可获焦状态原因有:过场动画过程中父组件不可获焦、应用侧设置了web组件或其父组件不可获焦属性等),应用侧可以调用主动请求焦点接口requestFocus再次尝试使web组件获焦。当获焦成功后,应用侧onFocus、w3c focus事件均会上报。
  • autofocus样式:设置了autofocus样式的元素网页完成加载时默认获焦。若该元素支持文字输入,则输入框会有光标闪烁,但不拉起软键盘。
  • 菜单弹出:ArkUI的overlay属性类型组件默认抢焦,在与此类组件结合的ArkWeb场景中(menudatepickertimepicker、下拉框、弹窗等),ArkWeb均会失焦。

7.4.3 Web组件与ArkUI组件焦点控制

  • 应用侧通用获焦回调接口onFocus,获焦事件回调,绑定该接口的组件获焦时,回调响应。
  • 应用侧通用失焦回调接口onBlur,失焦事件回调,绑定该接口的组件失焦时,回调响应。
  • 应用侧主动请求焦点接口requestFocus,组件主动请求焦点。

示例:

  1. requestFocus能够让应用开发者主动选择控制组件走焦到Web组件上。
  2. onFocus和onBlur两个接口通常成对使用,来监听组件的焦点变化。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  controller2: webview.WebviewController = new webview.WebviewController();
  @State webborderColor: Color = Color.Red;
  @State webborderColor2: Color = Color.Red;

  build() {
    Column() {
      Row() {
        Button('web1 requestFocus')
          .onClick(() => {
            try {
              this.controller.requestFocus();
            } catch (error) {
              console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
            }
          });
        Button('web2 requestFocus')
          .onClick(() => {
            try {
              this.controller2.requestFocus();
            } catch (error) {
              console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
            }
          });
      }
      Web({ src: 'www.example.com', controller: this.controller })
        .onFocus(() => {
          this.webborderColor = Color.Green;
        })
        .onBlur(() => {
          this.webborderColor = Color.Red;
        })
        .margin(3)
        .borderWidth(10)
        .borderColor(this.webborderColor)
        .height("45%")

      Web({ src: 'www.example.com', controller: this.controller2 })
        .onFocus(() => {
          this.webborderColor2 = Color.Green;
        })
        .onBlur(() => {
          this.webborderColor2 = Color.Red;
        })
        .margin(3)
        .borderWidth(10)
        .borderColor(this.webborderColor2)
        .height("45%")
    }
  }
}

示例图1 组件焦点获焦/失焦事件

通过requestfocus接口主动请求获焦,并监听通用接口onFocus、onBlur事件,改变Web组件边框颜色。

Web组件内H5元素焦点控制

  • W3C标准事件focus,前端感知网页获焦
addEventListener("focus", (event) => {});

onfocus = (event) => {};
  • W3C标准事件blur,前端感知网页失焦
addEventListener("blur", (event) => {});

onblur = (event) => {};
  • W3C autofocus,表示元素应在页面加载时或其所属的 dialog 显示时被聚焦
<input name="q" autofocus />

在文档或对话框中,最多只能有一个元素具有 autofocus 属性。如果应用于多个元素,第一个元素将获得焦点。

示例:

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

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

  build() {
    Column() {
      Web({ src: $rawfile("test.html"), controller: this.controller })
    }
  }
}

// test.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>test</title>
</head>
<body>
  <form id="form">
    <input type="text" placeholder="text input" />
    <input type="password" placeholder="password" />
  </form>
</body>
<script>
const form = document.getElementById("form");
form.addEventListener(
  "focus",
  (event) => {
    event.target.style.background = "pink";
  },
  true,
);
form.addEventListener(
  "blur",
  (event) => {
    event.target.style.background = "";
  },
  true,
);
</script>
</html>

示例图2 Web组件内元素焦点获焦/失焦事件

通过监听W3C接口focus、blur事件,改变输入背景色。

8 管理Web组件的网络安全与隐私

8.1 解决Web组件本地资源跨域问题

8.1.1 拦截本地资源跨域

为了提高安全性,ArkWeb内核不允许file协议或者resource协议访问URL上下文中来自跨域的请求。因此,在使用Web组件加载本地离线资源的时候,Web组件会拦截file协议和resource协议的跨域访问。可以通过方法二设置一个路径列表,再使用file协议访问该路径列表中的资源,允许跨域访问本地文件。当Web组件无法访问本地跨域资源时,开发者可以在DevTools控制台中看到类似以下报错信息:

Access to script at 'xxx' from origin 'xxx' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, arkweb, data, chrome-extension, chrome, https, chrome-untrusted.
// main/ets/pages/Index.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';
  webviewController: webview.WebviewController = new webview.WebviewController();
  // 构造域名和本地文件的映射表
  schemeMap = new Map([
    ["https://www.example.com/index.html", "index.html"],
    ["https://www.example.com/js/script.js", "js/script.js"],
  ])
  // 构造本地文件和构造返回的格式mimeType
  mimeTypeMap = new Map([
    ["index.html", 'text/html'],
    ["js/script.js", "text/javascript"]
  ])

  build() {
    Row() {
      Column() {
        // 针对本地index.html,使用http或者https协议代替file协议或者resource协议,并且构造一个属于自己的域名。
        // 本例中构造www.example.com为例。
        Web({ src: "https://www.example.com/index.html", controller: this.webviewController })
          .javaScriptAccess(true)
          .fileAccess(true)
          .domStorageAccess(true)
          .geolocationAccess(true)
          .width("100%")
          .height("100%")
          .onInterceptRequest((event) => {
            if (!event) {
              return;
            }
            // 此处匹配自己想要加载的本地离线资源,进行资源拦截替换,绕过跨域
            if (this.schemeMap.has(event.request.getRequestUrl())) {
              let rawfileName: string = this.schemeMap.get(event.request.getRequestUrl())!;
              let mimeType = this.mimeTypeMap.get(rawfileName);
              if (typeof mimeType === 'string') {
                let response = new WebResourceResponse();
                // 构造响应数据,如果本地文件在rawfile下,可以通过如下方式设置
                response.setResponseData($rawfile(rawfileName));
                response.setResponseEncoding('utf-8');
                response.setResponseMimeType(mimeType);
                response.setResponseCode(200);
                response.setReasonMessage('OK');
                response.setResponseIsReady(true);
                return response;
              }
            }
            return null;
          })
      }
      .width('100%')
    }
    .height('100%')
  }
}
<!-- main/resources/rawfile/index.html -->
<html>
<head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body>
<script crossorigin src="./js/script.js"></script>
</body>
</html>
// main/resources/rawfile/js/script.js
const body = document.body;
const element = document.createElement('div');
element.textContent = 'success';
body.appendChild(element);

方法二

通过setPathAllowingUniversalAccess设置一个路径列表。当使用file协议访问该列表中的资源时,允许进行跨域访问本地文件。此外,一旦设置了路径列表,file协议将仅限于访问列表内的资源(此时,fileAccess的行为将会被此接口行为覆盖)。路径列表中的路径必须符合以下任一路径格式:

1.应用文件目录通过Context.filesDir获取,其子目录示例如下:

  • /data/storage/el2/base/files/example
  • /data/storage/el2/base/haps/entry/files/example

2.应用资源目录通过Context.resourceDir获取,其子目录示例如下:

  • /data/storage/el1/bundle/entry/resource/resfile
  • /data/storage/el1/bundle/entry/resource/resfile/example

当路径列表中的任一路径不满足上述条件时,系统将抛出异常码401,并判定路径列表设置失败。若设置的路径列表为空,file协议的可访问范围将遵循fileAccess的规则,具体示例如下。

// main/ets/pages/Index.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Row() {
      Web({ src: "", controller: this.controller })
        .onControllerAttached(() => {
          try {
            // 设置允许可以跨域访问的路径列表
            this.controller.setPathAllowingUniversalAccess([
              getContext().resourceDir,
              getContext().filesDir + "/example"
            ])
            this.controller.loadUrl("file://" + getContext().resourceDir + "/index.html")
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as   BusinessError).message}`);
          }
        })
        .javaScriptAccess(true)
        .fileAccess(true)
        .domStorageAccess(true)
    }
  }
}
<!-- main/resource/rawfile/index.html -->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>Demo</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no,   viewport-fit=cover">
    <script>
        function getFile() {
            var file = "file:///data/storage/el1/bundle/entry/resources/resfile/js/script.js";
      // 使用file协议通过XMLHttpRequest跨域访问本地js文件。
            var xmlHttpReq = new XMLHttpRequest();
            xmlHttpReq.onreadystatechange = function(){
                console.log("readyState:" + xmlHttpReq.readyState);
                console.log("status:" + xmlHttpReq.status);
                if(xmlHttpReq.readyState == 4){
                    if (xmlHttpReq.status == 200) {
                // 如果ets侧正确设置路径列表,则此处能正常获取资源
                        const element = document.getElementById('text');
                        element.textContent = "load " + file + " success";
                    } else {
                // 如果ets侧不设置路径列表,则此处会触发CORS跨域检查错误
                        const element = document.getElementById('text');
                        element.textContent = "load " + file + " failed";
                    }
                }
            }
            xmlHttpReq.open("GET", file);
            xmlHttpReq.send(null);
        }
    </script>
</head>

<body>
<div class="page">
    <button id="example" onclick="getFile()">stealFile</button>
</div>
<div id="text"></div>
</body>

</html>
// main/resources/rawfile/js/script.js
const body = document.body;
const element = document.createElement('div');
element.textContent = 'success';
body.appendChild(element);

8.2 使用智能防跟踪功能

Web组件支持智能防跟踪功能,即跟踪型网站作为三方插入别的网页时,其发送的网络请求禁止携带cookie。

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

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

  build() {
    Column() {
      Button('enableIntelligentTrackingPrevention')
        .onClick(() => {
          try {
            this.controller.enableIntelligentTrackingPrevention(true);
            console.log("enableIntelligentTrackingPrevention: true");
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      Button('isIntelligentTrackingPreventionEnabled')
        .onClick(() => {
          try {
            let result = this.controller.isIntelligentTrackingPreventionEnabled();
            console.log("result: " + result);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      // 需要打开智能防跟踪功能,才会触发onIntelligentTrackingPreventionResult回调
      Button('enableIntelligentTrackingPrevention')
        .onClick(() => {
          try {
            this.controller.enableIntelligentTrackingPrevention(true);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
        .onIntelligentTrackingPreventionResult((details) => {
          console.log("onIntelligentTrackingPreventionResult: [websiteHost]= " + details.host +
            ", [trackerHost]=" + details.trackerHost);
        })
    }
  }
}

同时,智能防跟踪功能提供了一组接口,用于设置需要绕过智能防跟踪功能的域名列表。这些接口设置的域名列表是整个应用生效,而非某个Web组件。

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

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

  build() {
    Column() {
      Button('addIntelligentTrackingPreventionBypassingList')
        .onClick(() => {
          try {
            let hostList = ["www.test1.com", "www.test2.com", "www.test3.com"];
            webview.WebviewController.addIntelligentTrackingPreventionBypassingList(hostList);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      Button('removeIntelligentTrackingPreventionBypassingList')
        .onClick(() => {
          try {
            let hostList = [ "www.test1.com", "www.test2.com" ];
            webview.WebviewController.removeIntelligentTrackingPreventionBypassingList(hostList);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';

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

  build() {
    Column() {
      Button('clearIntelligentTrackingPreventionBypassingList')
        .onClick(() => {
          webview.WebviewController.clearIntelligentTrackingPreventionBypassingList();
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

8.3 使用Web组件的广告过滤功能

ArkWeb为应用提供广告过滤功能,支持通过云端推送默认的easylist规则,或允许应用通过接口设定自定义规则文件。它在网络层拦截广告资源的下载,或在网页中注入CSS规则以隐藏特定的广告元素。

当前配置文件格式为easylist语法规则。

8.3.1 常用easylist语法规则

规则类别说明示例
URL拦截规则拦截所有网站中url能匹配"example.com/js/*_tv.js"的子资源请求。用于定义域名过滤规则,用于匹配特定的域名及其所有子域名。||example.com/js/*_tv.js
URL拦截规则拦截非alimama.com、非taobao.com域名网站中的url匹配"alimama.cn"的第三方资源。$third_party是一种options语法,表示匹配第三方资源;域名前使用'~'表示不包括该域名。||alimama.cn^$third-party,domain=alimama.com|\taobao.com
例外规则关闭example.com网页内的广告过滤。@@是例外规则的语法关键字,表示不过滤。@@||example.com^$document
例外规则在域名为litv.tv的网页中,不过滤能匹配上".adserver."的子资源。@@.adserver.$domain=litv.tv
元素隐藏规则隐藏myabandonware.com和myware.com域名中所有class="i528"的元素。##用于表示元素隐藏。myabandonware.com, myware.com##.i528
元素隐藏例外规则不隐藏sdf-event.sakura.ne.jp网站中id="ad_1"的元素。sdf-event.sakura.ne.jp#@##ad_1

例外规则,通常是配合普通规则一起使用的,使普通规则在某些场景下不起作用,单独应用例外规则没有意义。

例如先配置了一条过滤所有网站的拦截规则,||abc.com/js/123.js,但是某些网站中出现了误拦截或者不能拦截的场景,就可以针对这些网站配置新的例外规则。

8.3.2 约束与限制

8.3.3 使用场景

1、开启广告过滤

应用可以通过AdsBlockManager提供的setAdsBlockRules()接口设置自定义的easylist过滤规则,并通过Web组件的enableAdsBlock()接口使能广告过滤特性。

在下面的示例中,演示了一个应用通过文件选择器选择easylist规则文件,并开启广告过滤功能。

// xxx.ets
import { webview } from '@kit.ArkWeb';
import { picker, fileUri } from '@kit.CoreFileKit';

// 演示点击按钮,通过filepicker打开一个easylist规则文件并设置到Web组件中
@Entry
@Component
struct WebComponent {
  main_url: string = 'https://www.example.com';
  controller: webview.WebviewController = new webview.WebviewController();

  @State input_text: string = 'https://www.example.com';

  build() {
    Column() {
      Row() {
        Flex() {
          Button({type: ButtonType.Capsule}) {
            Text("setAdsBlockRules")
          }
          .onClick(() => {
            try {
              let documentSelectionOptions: ESObject = new picker.DocumentSelectOptions();
              let documentPicker: ESObject = new picker.DocumentViewPicker();
              documentPicker.select(documentSelectionOptions).then((documentSelectResult: ESObject) => {
                if (documentSelectResult && documentSelectResult.length > 0) {
                  let fileRealPath = new fileUri.FileUri(documentSelectResult[0]);
                  console.info('DocumentViewPicker.select successfully, uri: ' + fileRealPath);
                  webview.AdsBlockManager.setAdsBlockRules(fileRealPath.path, true);
                }
              })
            } catch (err) {
              console.error('DocumentViewPicker.select failed with err:' + err);
            }
          })
        }
      }
      Web({ src: this.main_url, controller: this.controller })
        .onControllerAttached(()=>{
          this.controller.enableAdsBlock(true);
        })
    }
  }
}

如果存在内置的easylist规则文件,setAdsBlockRules()接口的replace参数可用于设置规则文件的使用策略,replace为true表示不使用内置的easylist规则文件,replace为false表示自定义规则和内置的规则将会同时工作,如果发现内置规则与自定义规则冲突,可使用replace=true禁用内置规则效果。

设置的自定义规则文件将在应用进程内对所有的Web组件生效,是一个应用级全局配置文件,并将持久化,应用重启后可继续工作。

2、关闭特定域名页面的广告过滤

在Web组件的广告过滤开关开启后,应用有时候会期望关闭一些特定页面的广告过滤功能,除了可以使用自定义的easylist规则,AdsBlockManager还提供了addAdsBlockDisallowedList()接口完成此功能。

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

// 演示通过一个按钮的点击向Web组件设置广告过滤的域名策略
@Entry
@Component
struct WebComponent {
  main_url: string = 'https://www.example.com';
  text_input_controller: TextInputController = new TextInputController();
  controller: webview.WebviewController = new webview.WebviewController();

  @State input_text: string = 'https://www.example.com';

  build() {
    Column() {
      Row() {
        Flex() {
          TextInput({ text: this.input_text, placeholder: this.main_url, controller: this.text_input_controller})
            .id("input_url")
            .height(40)
            .margin(5)
            .borderColor(Color.Blue)
            .onChange((value: string) => {
              this.input_text = value;
            })

          Button({type: ButtonType.Capsule}) { Text("Go") }
          .onClick(() => {
            this.controller.loadUrl(this.input_text);
          })

          Button({type: ButtonType.Capsule}) { Text("addAdsBlockDisallowedList") }
          .onClick(() => {
            let arrDomainSuffixes = new Array<string>();
            arrDomainSuffixes.push('example.com');
            arrDomainSuffixes.push('abcdefg.cn');
            webview.AdsBlockManager.addAdsBlockDisallowedList(arrDomainSuffixes);
          })
        }
      }
      Web({ src: this.main_url, controller: this.controller })
        .onControllerAttached(()=>{
          this.controller.enableAdsBlock(true);
        })
    }
  }
}

addAdsBlockDisallowedList()接口将域名设置到AdsBlockManager的DisallowedList中,下次页面加载时会使用网页url和DisallowedList中的域名进行后缀匹配,匹配成功则不会对此页面进行广告过滤。此外,还提供了addAdsBlockAllowedList()接口配合DisallowedList进行域名设置,控制是否开启广告过滤。

AdsBlockManager中缓存有2组域名列表,分别为DisallowedList和AllowList,其中DisallowedList用于禁用网页的广告过滤,而AllowList用于重新开启被DisallowedList关闭的广告过滤开关,其中AllowList优先级更高。页面加载时会先使用网页url和AllowList进行匹配,匹配成功的网页广告过滤将保持开启,否则将会继续使用DisallowedList进行匹配,匹配成功将关闭网页的广告过滤。如果访问的网页不在AllowList和DisallowedList中,那么默认网页的广告过滤会保持开启状态。

例如,应用想要开启域名为'news.example.com'和'sport.example.com'的广告过滤,但需要关闭'example.com'的其他域名下网页的广告过滤,就可以先使用addAdsBlockDisallowedList()接口添加'example.com'域名到DisallowedList,再使用addAdsBlockDisallowedList()接口添加'news.example.com'和'sport.example.com'域名。

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

// 演示addAdsBlockAllowedList和addAdsBlockAllowedList配套使用,设置网页级的广告过滤开关。
@Entry
@Component
struct WebComponent {
  main_url: string = 'https://www.example.com';
  text_input_controller: TextInputController = new TextInputController();
  controller: webview.WebviewController = new webview.WebviewController();

  @State input_text: string = 'https://www.example.com';

  build() {
    Column() {
      Row() {
        Flex() {
          TextInput({ text: this.input_text, placeholder: this.main_url, controller: this.text_input_controller})
            .id("input_url")
            .height(40)
            .margin(5)
            .borderColor(Color.Blue)
            .onChange((value: string) => {
              this.input_text = value;
            })

          Button({type: ButtonType.Capsule}) { Text("Go") }
          .onClick(() => {
            this.controller.loadUrl(this.input_text);
          })

          Button({type: ButtonType.Capsule}) { Text("addAdsBlockAllowedList") }
          .onClick(() => {
            let arrDisallowDomainSuffixes = new Array<string>();
            arrDisallowDomainSuffixes.push('example.com');
            webview.AdsBlockManager.addAdsBlockDisallowedList(arrDisallowDomainSuffixes);

            let arrAllowedDomainSuffixes = new Array<string>();
            arrAllowedDomainSuffixes.push('news.example.com');
            arrAllowedDomainSuffixes.push('sport.example.com');
            webview.AdsBlockManager.addAdsBlockAllowedList(arrAllowedDomainSuffixes);
          })
        }
      }
      Web({ src: this.main_url, controller: this.controller })
        .onControllerAttached(()=>{
          this.controller.enableAdsBlock(true);
        })
    }
  }
}

需要注意的是,AdsBlockManager的DisallowedList和AllowedList列表不会持久化,因此重启应用后会重置为空。

如果Web组件未通过enableAdsBlock()接口开启广告过滤功能,上述接口设置在此Web组件中将不起作用。

3、收集广告过滤的信息

在Web组件的广告过滤开关开启后,访问的网页如果发生了广告过滤,会通过Web组件的onAdsBlocked()回调接口通知到应用,应用可根据需要进行过滤信息的收集和统计。

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

@Entry
@Component
struct WebComponent {
  @State totalAdsBlockCounts: number = 0;
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: 'https://www.example.com', controller: this.controller })
        .onAdsBlocked((details: AdsBlockedDetails) => {
          if (details) {
            console.log(' Blocked ' + details.adsBlocked.length + ' in ' + details.url);
            let adList: Array<string> = Array.from(new Set(details.adsBlocked));
            this.totalAdsBlockCounts += adList.length;
            console.log('Total blocked counts :' + this.totalAdsBlockCounts);
          }
        })
    }
  }
}

由于页面可能随时发生变化并不断产生网络请求,为了减少通知频次、降低对页面加载过程的影响,仅在页面加载完成时进行首次通知,此后发生的过滤将间隔1秒钟上报,无广告过滤则无通知。

8.4 坚盾守护模式

8.4.1 ArkWeb限制的HTML5特性

坚盾守护模式开启时,ArkWeb通过限制以下HTML5特性减少攻击面。

  • 禁止使用WebAssembly能力。
  • 禁止使用WebGL、WebGL2能力。
  • 禁止使用PDF Viewer预览功能。
  • 禁止使用MathML能力。
  • 禁止使用Web Speech API语音识别能力。
  • 禁止使用RTCDataChannel接口。
  • 禁止使用MediaDevices.getUserMedia接口提示用户允许访问媒体输入设备。
  • 禁止使用Service Worker能力。
  • 禁止使用非代理UDP流量,防止WebRTC泄露真实源ip。
  • 禁止即时编译(JIT)能力。

8.4.2 评估对应用的影响

如果要评估应用在坚盾守护模式下的受影响程度及兼容性,可前往“设置 > 隐私和安全 > 坚盾守护模式”开启该模式。

说明

如果需要评估调试版本(未上架应用市场)应用的兼容性,需要先开启开发者选项后再开启坚盾守护模式。

运行应用的相应功能后,可通过以下方式确认是否受影响。

  • 排查前端代码是否存在WebAssembly相关接口调用,WebAssembly提供了一种在Web上运行C/C++等低级语言编译目标的能力,通常用于游戏、编解码等高性能场景。坚盾守护模式下,无法调用WebAssembly。
  • 排查前端代码是否存在WebGL相关接口调用,WebGL提供了3D图形绘制能力,坚盾守护模式下,相关接口无法调用。
  • 排查是否有在线显示PDF的功能场景,坚盾守护模式开启下无法在线显示PDF,例如通过loadUrl接口加载pdf链接等场景。
  • 排查HTML页面是否存在<math>标签嵌入的MathML语法,坚盾守护模式下,MathML语法不能正常解析,导致显示异常。
  • 排查前端代码是否存在SpeechRecognition(语言识别)、SpeechSynthesis(语音合成)等接口调用,坚盾守护模式下,相关接口无法调用。
  • 排查前端代码是否存在RTCDataChannel/createDataChannel等接口调用,该类接口是WebRTC API的特性,可以建立一条双向数据通道的连接,实现WebRTC 中对等端之间的实时数据交换,坚盾守护模式下,相关接口无法调用。
  • 排查前端代码是否存在MediaDevices.getUserMedia接口调用,该接口用于向用户请求访问流媒体设备(例如:摄像头、麦克风),坚盾守护模式下,相关接口调用会抛出异常信息“can't use getUserMedia on advancedSecurityMode!”。
  • 排查前端代码是否存在ServiceWorker相关接口调用,该机制用于实现离线缓存、网络请求拦截和推送通知等功能,坚盾守护模式下无法创建成功。
  • 坚盾守护模式下WebRTC禁止使用非代理UDP传输,涉及到网络连通性,应用需要验证评估WebRTC场景下的网络功能和性能表现。
  • JIT涉及性能优化,坚盾守护模式下应用需要评估JS性能表现。

9 管理网页加载与浏览记录

9.1 使用Web组件加载页面

页面加载是Web组件的基本功能。根据页面加载数据来源可以分为三种常用场景,包括加载网络页面、加载本地页面、加载HTML格式的富文本数据。

页面加载过程中,若涉及网络资源获取,请在module.json5中配置网络访问权限,添加方法请参考在配置文件中声明权限

"requestPermissions":[
    {
      "name" : "ohos.permission.INTERNET"
    }
  ]

9.1.1 加载网络页面

开发者可以在Web组件创建时,指定默认加载的网络页面 。在默认页面加载完成后,如果开发者需要变更此Web组件显示的网络页面,可以通过调用loadUrl()接口加载指定的网页。Web组件的第一个参数变量src不能通过状态变量(例如:@State)动态更改地址,如需更改,请通过loadUrl()重新加载。

在下面的示例中,在Web组件加载完“www.example.com”页面后,开发者可通过loadUrl接口将此Web组件显示页面变更为“www.example1.com”。

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

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

  build() {
    Column() {
      Button('loadUrl')
        .onClick(() => {
          try {
            // 点击按钮时,通过loadUrl,跳转到www.example1.com
            this.controller.loadUrl('www.example1.com');
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      // 组件创建时,加载www.example.com
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

9.1.2 加载本地页面

在下面的示例中展示加载本地页面文件的方法:

将本地页面文件放在应用的rawfile目录下,开发者可以在Web组件创建的时候指定默认加载的本地页面 ,并且加载完成后可通过调用loadUrl()接口变更当前Web组件的页面。

加载本地html文件时引用本地css样式文件可以通过下面方法实现。

<link rel="stylesheet" href="resource://rawfile/xxx.css">
<link rel="stylesheet" href="file:///data/storage/el2/base/haps/entry/cache/xxx.css">// 加载沙箱路径下的本地css文件。
  • 将资源文件放置在应用的resources/rawfile目录下

图1 资源文件路径

  • 应用侧代码。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

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

  build() {
    Column() {
      Button('loadUrl')
        .onClick(() => {
          try {
            // 点击按钮时,通过loadUrl,跳转到local1.html
            this.controller.loadUrl($rawfile("local1.html"));
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      // 组件创建时,通过$rawfile加载本地文件local.html
      Web({ src: $rawfile("local.html"), controller: this.controller })
    }
  }
}
  • local.html页面代码
<!-- local.html -->
<!DOCTYPE html>
<html>
  <body>
    <p>Hello World</p>
  </body>
</html>
  • local1.html页面代码。 
<!-- local1.html -->
<!DOCTYPE html>
<html>
  <body>
    <p>This is local1 page</p>
  </body>
</html>

 加载沙箱路径下的本地页面文件

1. 通过构造的单例对象GlobalContext获取沙箱路径,需要开启应用中文件系统的访问fileAccess权限。

// GlobalContext.ets
export class GlobalContext {
  private constructor() {}
  private static instance: GlobalContext;
  private _objects = new Map<string, Object>();

  public static getContext(): GlobalContext {
    if (!GlobalContext.instance) {
      GlobalContext.instance = new GlobalContext();
    }
    return GlobalContext.instance;
  }

  getObject(value: string): Object | undefined {
    return this._objects.get(value);
  }

  setObject(key: string, objectClass: Object): void {
    this._objects.set(key, objectClass);
  }
}
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { GlobalContext } from '../GlobalContext';

let url = 'file://' + GlobalContext.getContext().getObject("filesDir") + '/index.html';

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

  build() {
    Column() {
      // 加载沙箱路径文件。
      Web({ src: url, controller: this.controller })
      .fileAccess(true)
    }
  }
}

2. 修改EntryAbility.ets。

以filesDir为例,获取沙箱路径。若想获取其他路径,请参考应用文件路径

// xxx.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { webview } from '@kit.ArkWeb';
import { GlobalContext } from '../GlobalContext';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
    // 通过在GlobalContext对象上绑定filesDir,可以实现UIAbility组件与UI之间的数据同步。
    GlobalContext.getContext().setObject("filesDir", this.context.filesDir);
    console.log("Sandbox path is " + GlobalContext.getContext().getObject("filesDir"));
  }
}

加载的html文件。

<!-- index.html -->
<!DOCTYPE html>
<html>
    <body>
        <p>Hello World</p>
    </body>
</html>

9.1.3 加载HTML格式的文本数据

Web组件可以通过loadData()接口实现加载HTML格式的文本数据。当开发者不需要加载整个页面,只需要显示一些页面片段时,可通过此功能来快速加载页面,当加载大量html文件时,需设置第四个参数baseUrl为"data"。

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

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

  build() {
    Column() {
      Button('loadData')
        .onClick(() => {
          try {
            // 点击按钮时,通过loadData,加载HTML格式的文本数据
            this.controller.loadData(
              "<html><body bgcolor=\"white\">Source:<pre>source</pre></body></html>",
              "text/html",
              "UTF-8"
            );
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      // 组件创建时,加载www.example.com
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

Web组件可以通过data url方式直接加载HTML字符串。

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

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  htmlStr: string = "data:text/html, <html><body bgcolor=\"white\">Source:<pre>source</pre></body></html>";

  build() {
    Column() {
      // 组件创建时,加载htmlStr
      Web({ src: this.htmlStr, controller: this.controller })
    }
  }
}

9.2 管理页面跳转及浏览记录导航

9.2.1 历史记录导航

在前端页面点击网页中的链接时,Web组件默认会自动打开并加载目标网址。当前端页面替换为新的加载链接时,会自动记录已经访问的网页地址。可以通过forward()backward()接口向前/向后浏览上一个/下一个历史记录。

页面加载过程中,若涉及网络资源获取,需要在module.json5中配置网络访问权限,添加方法请参考在配置文件中声明权限

"requestPermissions":[
    {
      "name" : "ohos.permission.INTERNET"
    }
  ]

在下面的示例中,点击应用的按钮来触发前端页面的后退操作。

// xxx.ets
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 })
    }
  }
}

如果存在历史记录,accessBackward()接口会返回true。同样,您可以使用accessForward()接口检查是否存在前进的历史记录。如果您不执行检查,那么当用户浏览到历史记录的末尾时,调用forward()backward()接口时将不执行任何操作。

9.2.2 页面跳转

当点击网页中的链接需要跳转到应用内其他页面时,可以通过使用Web组件的onLoadIntercept()接口来实现。

在下面的示例中,应用首页Index.ets加载前端页面route.html,在前端route.html页面点击超链接,可跳转到应用的ProfilePage.ets页面。

  • 应用首页Index.ets页面代码。
// index.ets
import { webview } from '@kit.ArkWeb';
import { router } from '@kit.ArkUI';

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

  build() {
    Column() {
      // 资源文件route.html存放路径src/main/resources/rawfile
      Web({ src: $rawfile('route.html'), controller: this.webviewController })
        .onLoadIntercept((event) => {
          if (event) {
            let url: string = event.data.getRequestUrl();
            if (url.indexOf('native://') === 0) {
              // 跳转其他界面
              router.pushUrl({ url: url.substring(9) });
              return true;
            }
          }
          return false;
        })
    }
  }
}
  • route.html前端页面代码。
<!-- route.html -->
<!DOCTYPE html>
<html>
<body>
  <div>
      <a href="native://pages/ProfilePage">个人中心</a>
   </div>
</body>
</html>
  • 跳转页面ProfilePage.ets代码。
@Entry
@Component
struct ProfilePage {
  @State message: string = 'Hello World';

  build() {
    Column() {
      Text(this.message)
        .fontSize(20)
    }
  }
}

9.2.3 跨应用跳转

Web组件可以实现点击前端页面超链接跳转到其他应用。

在下面的示例中,点击call.html前端页面中的超链接,跳转到电话应用的拨号界面。

  • 应用侧代码。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { call } from '@kit.TelephonyKit';

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

  build() {
    Column() {
      Web({ src: $rawfile('call.html'), controller: this.webviewController })
        .onLoadIntercept((event) => {
          if (event) {
            let url: string = event.data.getRequestUrl();
            // 判断链接是否为拨号链接
            if (url.indexOf('tel://') === 0) {
              // 跳转拨号界面
              call.makeCall(url.substring(6), (err) => {
                if (!err) {
                  console.info('make call succeeded.');
                } else {
                  console.info('make call fail, err is:' + JSON.stringify(err));
                }
              });
              return true;
            }
          }
          return false;
        })
    }
  }
}
  • 前端页面call.html代码。
<!-- call.html -->
<!DOCTYPE html>
<html>
<body>
  <div>
    <a href="tel://xxx xxxx xxx">拨打电话</a>
  </div>
</body>
</html>

9.3 拦截Web组件发起的网络请求

通过网络拦截接口(arkweb_scheme_handler.h)对Web组件发出的请求进行拦截,并可以为被拦截的请求提供自定义的响应头以及响应体。

9.3.1 为Web组件设置网络拦截器

为指定的Web组件或者ServiceWorker设置ArkWeb_SchemeHandler,当Web内核发出相应scheme请求的时候,会触发ArkWeb_SchemeHandler的回调。需要在Web组件初始化之后设置网络拦截器。

当请求开始的时候会回调ArkWeb_OnRequestStart,请求结束的时候会回调ArkWeb_OnRequestStop。

如果想要拦截Web组件发出的第一个请求,可以通过initializeWebEngine对Web组件提前进行初始化,然后设置拦截器进行拦截。

  // 创建一个ArkWeb_SchemeHandler对象。
  ArkWeb_SchemeHandler *schemeHandler;
  OH_ArkWeb_CreateSchemeHandler(&schemeHandler);

  // 为ArkWeb_SchemeHandler设置ArkWeb_OnRequestStart与ArkWeb_OnRequestStop回调。
  OH_ArkWebSchemeHandler_SetOnRequestStart(schemeHandler, OnURLRequestStart);
  OH_ArkWebSchemeHandler_SetOnRequestStop(schemeHandler, OnURLRequestStop);

  // 拦截webTag为“scheme-handler”的Web组件发出的scheme为“https”的请求。
  OH_ArkWeb_SetSchemeHandler("https", "scheme-handler", schemeHandler);
  OH_ArkWebServiceWorker_SetSchemeHandler("https", schemeHandler);

也可以拦截非Web组件内置scheme的请求。

  // 创建一个ArkWeb_SchemeHandler对象。
  ArkWeb_SchemeHandler *schemeHandler;
  OH_ArkWeb_CreateSchemeHandler(&schemeHandler);

  // 为ArkWeb_SchemeHandler设置ArkWeb_OnRequestStart与ArkWeb_OnRequestStop回调。
  OH_ArkWebSchemeHandler_SetOnRequestStart(schemeHandler, OnURLRequestStart);
  OH_ArkWebSchemeHandler_SetOnRequestStop(schemeHandler, OnURLRequestStop);

  // 拦截webTag为“scheme-handler”的Web组件发出的scheme为“custom”的请求。
  OH_ArkWeb_SetSchemeHandler("custom", "scheme-handler", schemeHandler);
  OH_ArkWebServiceWorker_SetSchemeHandler("custom", schemeHandler);

9.3.2 设置自定义scheme需要遵循的规则

如果要拦截自定义scheme的请求,需要提前将自定义scheme注册到Web内核。需要在Web组件初始化之前进行注册,Web组件初始化后再注册会失败。

  // 注册“custom“ scheme到Web组件,并指定该scheme需要遵循标准的scheme规则,允许该scheme发出跨域请求。
  OH_ArkWeb_RegisterCustomSchemes("custom", ARKWEB_SCHEME_OPTION_STANDARD | ARKWEB_SCHEME_OPTION_CORS_ENABLED);
  // 注册“custom-local” scheme到Web组件,并指定该scheme需要遵循与“file” scheme一样的规则。
  OH_ArkWeb_RegisterCustomSchemes("custom-local", ARKWEB_SCHEME_OPTION_LOCAL);
  // 注册“custom-csp-bypassing”到Web组件,并指定该scheme需要遵循标准的scheme规则,允许忽略CSP检查。
  OH_ArkWeb_RegisterCustomSchemes("custom-csp-bypassing", ARKWEB_SCHEME_OPTION_CSP_BYPASSING | ARKWEB_SCHEME_OPTION_STANDARD);
  // 注册“custom-isolated”到Web组件,并指定该scheme的请求必须从相同scheme加载的网页中发起。
  OH_ArkWeb_RegisterCustomSchemes("custom-isolated", ARKWEB_SCHEME_OPTION_DISPLAY_ISOLATED);

由于注册scheme需要在Web组件初始化之前进行注册,而网络拦截器需要在Web组件初始化之后设置,建议在EntryAbility的onCreate中调用c++接口注册scheme。

scheme注册完毕后,通过initializeWebEngine对Web组件进行初始化,初始化完成后再设置网络拦截器。

  export default class EntryAbility extends UIAbility {
      onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
          // 注册scheme的配置。
          testNapi.registerCustomSchemes();
          // 初始化Web组件内核,该操作会初始化Browser进程以及创建BrowserContext。
          webview.WebviewController.initializeWebEngine();
          // 创建并设置ArkWeb_SchemeHandler。
          testNapi.setSchemeHandler();
      }
      ...
  };

9.3.3 获取被拦截请求的请求信息

通过OH_ArkWebResourceRequest_*接口获取被拦截请求的信息。可以获取url、method、referrer、headers、resourceType等信息。

  char* url;
  OH_ArkWebResourceRequest_GetUrl(resourceRequest_, &url);
  OH_ArkWeb_ReleaseString(url);

  char* method;
  OH_ArkWebResourceRequest_GetMethod(resourceRequest_, &method);
  OH_ArkWeb_ReleaseString(method);

  int32_t resourceType = OH_ArkWebResourceRequest_GetResourceType(resourceRequest_);

  char* frameUrl;
  OH_ArkWebResourceRequest_GetFrameUrl(resourceRequest_, &frameUrl);
  OH_ArkWeb_ReleaseString(frameUrl);
  ...

支持获取PUT/POST类请求的上传数据。数据类型支持BYTES、FILE、BLOB和CHUNKED。

  // 获取被拦截请求的上传数据。
  OH_ArkWebResourceRequest_GetHttpBodyStream(resourceRequest(), &stream_);
  // 设置读取上传数据的读回调。
  OH_ArkWebHttpBodyStream_SetReadCallback(stream_, ReadCallback);
  // 初始化ArkWeb_HttpBodyStream,其它OH_ArkWebHttpBodyStream*函数需要在初始化进行调用。
  OH_ArkWebHttpBodyStream_Init(stream_, InitCallback);

9.3.4 为被拦截的请求提供自定义的响应体

Web组件的网络拦截支持在worker线程以流的方式为被拦截的请求提供自定义的响应体。也可以以特定的网络错误码(arkweb_net_error_list.h)结束当前被拦截的请求。

  // 为被拦截的请求创建一个响应头。
  ArkWeb_Response *response;
  OH_ArkWeb_CreateResponse(&response);

  // 设置HTTP状态码为200。
  OH_ArkWebResponse_SetStatus(response, 200);
  // 设置响应体的编码格式。
  OH_ArkWebResponse_SetCharset(response, "UTF-8");
  // 设置响应体的大小。
  OH_ArkWebResponse_SetHeaderByName(response, "content-length", "1024", false);
  // 将为被拦截的请求创建的响应头传递给Web组件。
  OH_ArkWebResourceHandler_DidReceiveResponse(resourceHandler, response);

  // 该函数可以调用多次,数据可以分多份来传递给Web组件。
  OH_ArkWebResourceHandler_DidReceiveData(resourceHandler, buffer, bufLen);

  // 读取响应体结束,当然如果希望该请求失败的话也可以通过调用OH_ArkWebResourceHandler_DidFailWithError(resourceHandler_, errorCode);
  // 传递给Web组件一个错误码并结束该请求。
  OH_ArkWebResourceHandler_DidFinish(resourceHandler);

9.4 自定义页面请求响应

Web组件支持在应用拦截到页面请求后自定义响应请求能力。开发者通过onInterceptRequest()接口来实现自定义资源请求响应 。自定义请求能力可以用于开发者自定义Web页面响应、自定义文件资源响应等场景。

Web网页上发起资源加载请求,应用层收到资源请求消息。应用层构造本地资源响应消息发送给Web内核。Web内核解析应用层响应信息,根据此响应信息进行页面资源加载。

在下面的示例中,Web组件通过拦截页面请求“https://www.example.com/test.html”, 在应用侧代码构建响应资源,实现自定义页面响应场景。

  • 前端页面index.html代码。
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body>
<!-- 页面资源请求 -->
<a href="https://www.example.com/test.html">intercept test!</a>
</body>
</html>
  • 应用侧代码。
// xxx.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  responseResource: WebResourceResponse = new WebResourceResponse();
  // 开发者自定义响应数据
  @State webData: string = '<!DOCTYPE html>\n' +
    '<html>\n' +
    '<head>\n' +
    '<title>intercept test</title>\n' +
    '</head>\n' +
    '<body>\n' +
    '<h1>intercept ok</h1>\n' +
    '</body>\n' +
    '</html>'

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .onInterceptRequest((event) => {
          if (event) {
            console.info('url:' + event.request.getRequestUrl());
            // 拦截页面请求
            if (event.request.getRequestUrl() !== 'https://www.example.com/test.html') {
              return null;
            }
          }
          // 构造响应数据
          this.responseResource.setResponseData(this.webData);
          this.responseResource.setResponseEncoding('utf-8');
          this.responseResource.setResponseMimeType('text/html');
          this.responseResource.setResponseCode(200);
          this.responseResource.setReasonMessage('OK');
          return this.responseResource;
        })
    }
  }
}

为自定义的JavaScript请求响应生成 CodeCache:自定义请求响应的资源类型如果是JavaScript脚本,可以在响应头中添加“ResponseDataID”字段,Web内核读取到该字段后会在为该JS资源生成CodeCache,加速JS执行,并且ResponseData如果有更新时必须更新该字段。不添加“ResponseDataID”字段的情况下默认不生成CodeCache。

在下面的示例中,Web组件通过拦截页面请求“https://www.example.com/test.js”, 应用侧代码构建响应资源,在响应头中添加“ResponseDataID”字段,开启生成CodeCache的功能。

  • 前端页面index.html代码。
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body>

<div id="div-1">this is a test div</div>
<div id="div-2">this is a test div</div>
<div id="div-3">this is a test div</div>
<div id="div-4">this is a test div</div>
<div id="div-5">this is a test div</div>
<div id="div-6">this is a test div</div>
<div id="div-7">this is a test div</div>
<div id="div-8">this is a test div</div>
<div id="div-9">this is a test div</div>
<div id="div-10">this is a test div</div>
<div id="div-11">this is a test div</div>

<script src="https://www.example.com/test.js"></script>
</body>
</html>
  • 应用侧代码。
// xxx.ets
import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  responseResource: WebResourceResponse = new WebResourceResponse();
  // 开发者自定义响应数据(响应数据长度需大于等于1024才会生成codecache)
  @State jsData: string = 'let text_msg = "the modified content:version 0000000000001";\n' +
    'let element1 = window.document.getElementById("div-1");\n' +
    'let element2 = window.document.getElementById("div-2");\n' +
    'let element3 = window.document.getElementById("div-3");\n' +
    'let element4 = window.document.getElementById("div-4");\n' +
    'let element5 = window.document.getElementById("div-5");\n' +
    'let element6 = window.document.getElementById("div-6");\n' +
    'let element7 = window.document.getElementById("div-7");\n' +
    'let element8 = window.document.getElementById("div-8");\n' +
    'let element9 = window.document.getElementById("div-9");\n' +
    'let element10 = window.document.getElementById("div-10");\n' +
    'let element11 = window.document.getElementById("div-11");\n' +
    'element1.innerHTML = text_msg;\n' +
    'element2.innerHTML = text_msg;\n' +
    'element3.innerHTML = text_msg;\n' +
    'element4.innerHTML = text_msg;\n' +
    'element5.innerHTML = text_msg;\n' +
    'element6.innerHTML = text_msg;\n' +
    'element7.innerHTML = text_msg;\n' +
    'element8.innerHTML = text_msg;\n' +
    'element9.innerHTML = text_msg;\n' +
    'element10.innerHTML = text_msg;\n' +
    'element11.innerHTML = text_msg;\n';

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .onInterceptRequest((event) => {
          // 拦截页面请求
          if (event?.request.getRequestUrl() == 'https://www.example.com/test.js') {
            // 构造响应数据
            this.responseResource.setResponseHeader([
              {
                // 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
                headerKey: "ResponseDataID",
                headerValue: "0000000000001"
              }]);
            this.responseResource.setResponseData(this.jsData);
            this.responseResource.setResponseEncoding('utf-8');
            this.responseResource.setResponseMimeType('application/javascript');
            this.responseResource.setResponseCode(200);
            this.responseResource.setReasonMessage('OK');
            return this.responseResource;
          }
          return null;
        })
    }
  }
}

9.5 加速Web页面的访问

当Web页面加载缓慢时,可以使用预连接、预加载和预获取post请求的能力加速Web页面的访问。

9.5.1 预解析和预连接

可以通过prepareForPageLoad()来预解析或者预连接将要加载的页面。

在下面的示例中,在Web组件的onAppear中对要加载的页面进行预连接。

// xxx.ets
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/', controller: this.webviewController })
        .onAppear(() => {
          // 指定第二个参数为true,代表要进行预连接,如果为false该接口只会对网址进行dns预解析
          // 第三个参数为要预连接socket的个数。最多允许6个。
          webview.WebviewController.prepareForPageLoad('https://www.example.com/', true, 2);
        })
    }
  }
}

也可以通过initializeBrowserEngine()来提前初始化内核,然后在初始化内核后调用

prepareForPageLoad()对即将要加载的页面进行预解析、预连接。这种方式适合提前对首页进行

预解析、预连接。

在下面的示例中,Ability的onCreate中提前初始化Web内核并对首页进行预连接。

// xxx.ets
import { webview } from '@kit.ArkWeb';
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
    console.log("EntryAbility onCreate");
    webview.WebviewController.initializeWebEngine();
    // 预连接时,需要將'https://www.example.com'替换成真实要访问的网站地址。
    webview.WebviewController.prepareForPageLoad("https://www.example.com/", true, 2);
    AppStorage.setOrCreate("abilityWant", want);
    console.log("EntryAbility onCreate done");
  }
}

9.5.2 预加载

如果能够预测到Web组件将要加载的页面或者即将要跳转的页面。可以通过prefetchPage()来预加载即将要加载页面。

预加载会提前下载页面所需的资源,包括主资源子资源,但不会执行网页JavaScript代码。预加载是WebviewController的实例方法,需要一个已经关联好Web组件的WebviewController实例。

在下面的示例中,在onPageEnd的时候触发下一个要访问的页面的预加载。

// 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(() => {
          // 预加载https://www.iana.org/help/example-domains。
          this.webviewController.prefetchPage('https://www.iana.org/help/example-domains');
        })
    }
  }
}

9.5.3 预获取post请求

可以通过prefetchResource()预获取将要加载页面中的post请求。在页面加载结束时,可以通过clearPrefetchedResource()清除后续不再使用的预获取资源缓存。

以下示例,在Web组件onAppear中,对要加载页面中的post请求进行预获取。在onPageEnd中,可以清除预获取的post请求缓存。

// 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 })
        .onAppear(() => {
          // 预获取时,需要將"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);
        })
        .onPageEnd(() => {
          // 清除后续不再使用的预获取资源缓存。
          webview.WebviewController.clearPrefetchedResource(["KeyX",]);
        })
    }
  }
}

如果能够预测到Web组件将要加载页面或者即将要跳转页面中的post请求。可以通过prefetchResource()预获取即将要加载页面的post请求。

以下示例,在onPageEnd中,触发预获取一个要访问页面的post请求。

// 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(() => {
          // 预获取时,需要將"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);
        })
    }
  }
}

也可以通过initializeBrowserEngine()提前初始化内核,然后在初始化内核后调用prefetchResource()预获取将要加载页面中的post请求。这种方式适合提前预获取首页的post请求。

以下示例,在Ability的onCreate中,提前初始化Web内核并预获取首页的post请求。

// xxx.ets
import { webview } from '@kit.ArkWeb';
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
    console.log("EntryAbility onCreate");
    webview.WebviewController.initializeWebEngine();
    // 预获取时,需要將"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);
    AppStorage.setOrCreate("abilityWant", want);
    console.log("EntryAbility onCreate done");
  }
}

9.5.4 预编译生成编译缓存

可以通过precompileJavaScript()在页面加载前提前生成脚本文件的编译缓存。

推荐配合动态组件使用,使用离线的Web组件用于生成字节码缓存,并在适当的时机加载业务用Web组件使用这些字节码缓存。下方是代码示例:

1. 首先,在EntryAbility中将UIContext存到localStorage中。

// EntryAbility.ets
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';

const localStorage: LocalStorage = new LocalStorage('uiContext');

export default class EntryAbility extends UIAbility {
  storage: LocalStorage = localStorage;

  onWindowStageCreate(windowStage: window.WindowStage) {
    windowStage.loadContent('pages/Index', this.storage, (err, data) => {
      if (err.code) {
        return;
      }

      this.storage.setOrCreate<UIContext>("uiContext", windowStage.getMainWindowSync().getUIContext());
    });
  }
}

2. 编写动态组件所需基础代码。

// DynamicComponent.ets
import { NodeController, BuilderNode, FrameNode, UIContext } from '@kit.ArkUI';

export interface BuilderData {
  url: string;
  controller: WebviewController;
}

const storage = LocalStorage.getShared();

export class NodeControllerImpl extends NodeController {
  private rootNode: BuilderNode<BuilderData[]> | null = null;
  private wrappedBuilder: WrappedBuilder<BuilderData[]> | null = null;

  constructor(wrappedBuilder: WrappedBuilder<BuilderData[]>) {
    super();
    this.wrappedBuilder = wrappedBuilder;
  }

  makeNode(): FrameNode | null {
    if (this.rootNode != null) {
      return this.rootNode.getFrameNode();
    }
    return null;
  }

  initWeb(url: string, controller: WebviewController) {
    if(this.rootNode != null) {
      return;
    }

    const uiContext: UIContext = storage.get<UIContext>("uiContext") as UIContext;
    if (!uiContext) {
      return;
    }
    this.rootNode = new BuilderNode(uiContext);
    this.rootNode.build(this.wrappedBuilder, { url: url, controller: controller });
  }
}

export const createNode = (wrappedBuilder: WrappedBuilder<BuilderData[]>, data: BuilderData) => {
  const baseNode = new NodeControllerImpl(wrappedBuilder);
  baseNode.initWeb(data.url, data.controller);
  return baseNode;
}

3. 编写用于生成字节码缓存的组件,本例中的本地Javascript资源内容通过文件读取接口读取rawfile目录下的本地文件。

// PrecompileWebview.ets
import { BuilderData } from "./DynamicComponent";
import { Config, configs } from "./PrecompileConfig";

@Builder
function WebBuilder(data: BuilderData) {
  Web({ src: data.url, controller: data.controller })
    .onControllerAttached(() => {
      precompile(data.controller, configs);
    })
    .fileAccess(true)
}

export const precompileWebview = wrapBuilder<BuilderData[]>(WebBuilder);

export const precompile = async (controller: WebviewController, configs: Array<Config>) => {
  for (const config of configs) {
    let content = await readRawFile(config.localPath);

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

async function readRawFile(path: string) {
  try {
    return await getContext().resourceManager.getRawFileContent(path);;
  } catch (err) {
    return new Uint8Array(0);
  }
}

JavaScript资源的获取方式也可通过网络请求的方式获取,但此方法获取到的http响应头非标准HTTP响应头格式,需额外将响应头转换成标准HTTP响应头格式后使用。如通过网络请求获取到的响应头是e-tag,则需要将其转换成E-Tag后使用。

1. 编写业务用组件代码。

// BusinessWebview.ets
import { BuilderData } from "./DynamicComponent";

@Builder
function WebBuilder(data: BuilderData) {
  // 此处组件可根据业务需要自行扩展
  Web({ src: data.url, controller: data.controller })
    .cacheMode(CacheMode.Default)
}

export const businessWebview = wrapBuilder<BuilderData[]>(WebBuilder);

2. 编写资源配置信息。

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

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

export let configs: Array<Config> = [
  {
    url: "https://www.example.com/example.js",
    localPath: "example.js",
    options: {
      responseHeaders: [
        { headerKey: "E-Tag", headerValue: "aWO42N9P9dG/5xqYQCxsx+vDOoU="},
        { headerKey: "Last-Modified", headerValue: "Wed, 21 Mar 2024 10:38:41 GMT"}
      ]
    }
  }
]

3. 在页面中使用。

// Index.ets
import { webview } from '@kit.ArkWeb';
import { NodeController } from '@kit.ArkUI';
import { createNode } from "./DynamicComponent"
import { precompileWebview } from "./PrecompileWebview"
import { businessWebview } from "./BusinessWebview"

@Entry
@Component
struct Index {
  @State precompileNode: NodeController | undefined = undefined;
  precompileController: webview.WebviewController = new webview.WebviewController();

  @State businessNode: NodeController | undefined = undefined;
  businessController: webview.WebviewController = new webview.WebviewController();

  aboutToAppear(): void {
    // 初始化用于注入本地资源的Web组件
    this.precompileNode = createNode(precompileWebview,
      { url: "https://www.example.com/empty.html", controller: this.precompileController});
  }

  build() {
    Column() {
      // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
      Button("加载页面")
        .onClick(() => {
          this.businessNode = createNode(businessWebview, {
            url:  "https://www.example.com/business.html",
            controller: this.businessController
          });
        })
      // 用于业务的Web组件
      NodeContainer(this.businessNode);
    }
  }
}

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

9.5.5 离线资源免拦截注入

可以通过injectOfflineResources()在页面加载前提前将图片、样式表或脚本资源注入到应用的内存缓存中。

推荐配合动态组件使用,使用离线的Web组件用于将资源注入到内核的内存缓存中,并在适当的时机加载业务用Web组件使用这些资源。下方是代码示例:

1. 首先,在EntryAbility中将UIContext存到localStorage中。

// EntryAbility.ets
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';

const localStorage: LocalStorage = new LocalStorage('uiContext');

export default class EntryAbility extends UIAbility {
  storage: LocalStorage = localStorage;

  onWindowStageCreate(windowStage: window.WindowStage) {
    windowStage.loadContent('pages/Index', this.storage, (err, data) => {
      if (err.code) {
        return;
      }

      this.storage.setOrCreate<UIContext>("uiContext", windowStage.getMainWindowSync().getUIContext());
    });
  }
}

2. 编写动态组件所需基础代码。

// DynamicComponent.ets
import { NodeController, BuilderNode, FrameNode, UIContext } from '@kit.ArkUI';

export interface BuilderData {
  url: string;
  controller: WebviewController;
}

const storage = LocalStorage.getShared();

export class NodeControllerImpl extends NodeController {
  private rootNode: BuilderNode<BuilderData[]> | null = null;
  private wrappedBuilder: WrappedBuilder<BuilderData[]> | null = null;

  constructor(wrappedBuilder: WrappedBuilder<BuilderData[]>) {
    super();
    this.wrappedBuilder = wrappedBuilder;
  }

  makeNode(): FrameNode | null {
    if (this.rootNode != null) {
      return this.rootNode.getFrameNode();
    }
    return null;
  }

  initWeb(url: string, controller: WebviewController) {
    if(this.rootNode != null) {
      return;
    }

    const uiContext: UIContext = storage.get<UIContext>("uiContext") as UIContext;
    if (!uiContext) {
      return;
    }
    this.rootNode = new BuilderNode(uiContext);
    this.rootNode.build(this.wrappedBuilder, { url: url, controller: controller });
  }
}

export const createNode = (wrappedBuilder: WrappedBuilder<BuilderData[]>, data: BuilderData) => {
  const baseNode = new NodeControllerImpl(wrappedBuilder);
  baseNode.initWeb(data.url, data.controller);
  return baseNode;
}

3. 编写用于注入资源的组件代码,本例中的本地资源内容通过文件读取接口读取rawfile目录下的本地文件。

// InjectWebview.ets
import { webview } from '@kit.ArkWeb';
import { resourceConfigs } from "./Resource";
import { BuilderData } from "./DynamicComponent";

@Builder
function WebBuilder(data: BuilderData) {
  Web({ src: data.url, controller: data.controller })
    .onControllerAttached(async () => {
      try {
        data.controller.injectOfflineResources(await getData ());
      } catch (err) {
        console.error("error: " + err.code + " " + err.message);
      }
    })
    .fileAccess(true)
}

export const injectWebview = wrapBuilder<BuilderData[]>(WebBuilder);

export async function getData() {
  const resourceMapArr: Array<webview.OfflineResourceMap> = [];

  // 读取配置,从rawfile目录中读取文件内容
  for (let config of resourceConfigs) {
    let buf: Uint8Array = new Uint8Array(0);
    if (config.localPath) {
      buf = await readRawFile(config.localPath);
    }

    resourceMapArr.push({
      urlList: config.urlList,
      resource: buf,
      responseHeaders: config.responseHeaders,
      type: config.type,
    })
  }

  return resourceMapArr;
}

export async function readRawFile(url: string) {
  try {
    return await getContext().resourceManager.getRawFileContent(url);
  } catch (err) {
    return new Uint8Array(0);
  }
}

4. 编写业务用组件代码。

// BusinessWebview.ets
import { BuilderData } from "./DynamicComponent";

@Builder
function WebBuilder(data: BuilderData) {
  // 此处组件可根据业务需要自行扩展
  Web({ src: data.url, controller: data.controller })
    .cacheMode(CacheMode.Default)
}

export const businessWebview = wrapBuilder<BuilderData[]>(WebBuilder);

5. 编写资源配置信息。

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

export interface ResourceConfig {
  urlList: Array<string>,
  type: webview.OfflineResourceType,
  responseHeaders: Array<Header>,
  localPath: string, // 本地资源存放在rawfile目录下的路径
}

export const resourceConfigs: Array<ResourceConfig> = [
  {
    localPath: "example.png",
    urlList: [
      "https://www.example.com/",
      "https://www.example.com/path1/example.png",
      "https://www.example.com/path2/example.png",
    ],
    type: webview.OfflineResourceType.IMAGE,
    responseHeaders: [
      { headerKey: "Cache-Control", headerValue: "max-age=1000" },
      { headerKey: "Content-Type", headerValue: "image/png" },
    ]
  },
  {
    localPath: "example.js",
    urlList: [ // 仅提供一个url,这个url既作为资源的源,也作为资源的网络请求地址
      "https://www.example.com/example.js",
    ],
    type: webview.OfflineResourceType.CLASSIC_JS,
    responseHeaders: [
      // 以<script crossorigin="anoymous" />方式使用,提供额外的响应头
      { headerKey: "Cross-Origin", headerValue:"anonymous" }
    ]
  },
];

6. 在页面中使用。

// Index.ets
import { webview } from '@kit.ArkWeb';
import { NodeController } from '@kit.ArkUI';
import { createNode } from "./DynamicComponent"
import { injectWebview } from "./InjectWebview"
import { businessWebview } from "./BusinessWebview"

@Entry
@Component
struct Index {
  @State injectNode: NodeController | undefined = undefined;
  injectController: webview.WebviewController = new webview.WebviewController();

  @State businessNode: NodeController | undefined = undefined;
  businessController: webview.WebviewController = new webview.WebviewController();

  aboutToAppear(): void {
    // 初始化用于注入本地资源的Web组件, 提供一个空的html页面作为url即可
    this.injectNode = createNode(injectWebview,
        { url: "https://www.example.com/empty.html", controller: this.injectController});
  }

  build() {
    Column() {
      // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
      Button("加载页面")
        .onClick(() => {
          this.businessNode = createNode(businessWebview, {
            url: "https://www.example.com/business.html",
            controller: this.businessController
          });
        })
      // 用于业务的Web组件
      NodeContainer(this.businessNode);
    }
  }
}

7. 加载的HTML网页示例。

<!DOCTYPE html>
<html lang="en">
<head></head>
<body>
  <img src="https://www.example.com/path1/request.png" />
  <img src="https://www.example.com/path2/request.png" />
  <script src="https://www.example.com/example.js" crossorigin="anonymous"></script>
</body>
</html>

9.6 设置Web组件前进后退缓存

Web组件为开发者提供了启用和配置前进后退缓存(以下简称BFCache)的功能。启用此功能后,能够显著提升用户返回至先前浏览网页的速度,尤其对于网络条件不佳的用户,可提供更为流畅的浏览体验。

BFCache功能启用后,Web组件会在用户离开当前页面时在内存中保存该页面的快照。当用户在短期内通过Web组件的前进或后退功能重新访问同一页面时,能够迅速恢复页面状态,避免重复发起HTTP请求。

9.6.1 Web组件开启BFCache

开发者需要在调用initializeWebEngine()初始化ArkWeb内核之前调用enableBackForwardCache()来开启BFCache。enableBackForwardCache可以接收一个BackForwardCacheSupportedFeatures参数,用于控制是否允许具备同层渲染特性和视频托管特性的页面进入BFCache。

// EntryAbility.ets
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) {
        let features = new webview.BackForwardCacheSupportedFeatures();
        features.nativeEmbed = true;
        features.mediaTakeOver = true;
        webview.WebviewController.enableBackForwardCache(features);
        webview.WebviewController.initializeWebEngine();
        AppStorage.setOrCreate("abilityWant", want);
    }
}

9.6.2 设置缓存的页面数量和页面留存的时间

启用BFCache后仅能存储一个页面,Web组件默认进入BFCache的页面可保持存活状态600秒。开发者可通过调用setBackForwardCacheOptions()设置每个Web实例的前进后退缓存策略。包括调整缓存中页面的最大数量,使BFCache能够容纳更多页面,从而在用户连续进行前进后退操作时,提供更快的加载速度。同时,开发者还能修改每个页面在缓存中的停留时间,延长页面在BFCache中的驻留期限,进而优化用户的浏览体验。

在下面的示例中,设置Web组件可以缓存的最大数量为10,每个页面在缓存中停留300s。

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

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

  build() {
    Column() {
      Row() {
        Button("Add options").onClick((event: ClickEvent) => {
          let options = new webview.BackForwardCacheOptions();
          options.size = 10;
          options.timeToLive = 300;
          this.controller.setBackForwardCacheOptions(options);
        })
        Button("Backward").onClick((event: ClickEvent) => {
          this.controller.backward();
        })
        Button("Forward").onClick((event: ClickEvent) => {
          this.controller.forward();
        })
      }
      Web({ src: "https://www.example.com", controller: this.controller })
    }
    .height('100%')
    .width('100%')
  }
}

9.7 Web组件在不同的窗口间迁移

Web组件能够实现在不同窗口的组件树上进行挂载或移除操作,这一能力使得开发者可以将同一个Web组件在不同窗口间迁移。例如,将浏览器的Tab页拖出成独立窗口,或拖入浏览器的另一个窗口。

Web组件在不同窗口间迁移,是基于自定义节点能力实现的。实现的基本原理是:通过BuilderNode,开发者可创建Web组件的离线节点,并结合自定义占位节点控制Web节点的挂载与移除。当从一个窗口上移除Web节点,并挂载到另一个窗口中,即完成Web组件在窗口间的迁移。

在以下示例中,主窗Ability启动时,通过命令式的方式创建了一个Web组件。开发者可以利用common.ets中提供的方法和类,实现Web组件的挂载和移除。Index.ets则提供了一种挂载和移除Web组件的实现方法。通过这种方式,开发者能够实现Web组件在不同窗口中页面的挂载与移除,即实现了Web组件在不同窗口间的迁移。下图是展示了这一迁移过程的示意图。

说明

不要将一个Web组件同时挂载在两个父节点下,这会导致非预期行为。

// 主窗Ability
// EntryAbility.ets
import { createNWeb, defaultUrl } from '../pages/common'

// ...

  onWindowStageCreate(windowStage: window.WindowStage): void {
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
        return;
      }
      // 创建Web动态组件(需传入UIContext),loadContent之后的任意时机均可创建,应用仅创建一个Web组件
      createNWeb(defaultUrl, windowStage.getMainWindowSync().getUIContext());
      hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
    });
  }

// ...
// 提供动态挂载Web组件能力
// pages/common.ets
import { UIContext, NodeController, BuilderNode, FrameNode } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';
import { hilog } from '@kit.PerformanceAnalysisKit';

export const defaultUrl : string = 'https://www.example.com';

// Data为入参封装类
class Data{
  url: string = '';
  webController: webview.WebviewController | null = null;

  constructor(url: string, webController: webview.WebviewController) {
    this.url = url;
    this.webController = webController;
  }
}

// @Builder中为动态组件的具体组件内容
@Builder
function WebBuilder(data:Data) {
  Web({ src: data.url, controller: data.webController })
    .width("100%")
    .height("100%")
    .borderStyle(BorderStyle.Dashed)
    .borderWidth(2)
}

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

// 用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用
export class MyNodeController extends NodeController {
  private builderNode: BuilderNode<[Data]> | null | undefined = null;
  private webController : webview.WebviewController | null | undefined = null;
  private rootNode : FrameNode | null = null;

  constructor(builderNode : BuilderNode<[Data]> | undefined, webController : webview.WebviewController | undefined) {
    super();
    this.builderNode = builderNode;
    this.webController = webController;
  }

  // 必须要重写的方法,用于构建节点数、返回节点挂载在对应NodeContainer中
  // 在对应NodeContainer创建的时候调用或者通过rebuild方法调用刷新
  makeNode(uiContext: UIContext): FrameNode | null {
    // 该节点会被挂载在NodeContainer的父节点下
    return this.rootNode;
  }

  // 挂载Webview
  attachWeb() : void {
    if (this.builderNode) {
      let frameNode : FrameNode | null = this.builderNode.getFrameNode();
      if (frameNode?.getParent() != null) {
        // 挂载自定义节点前判断该节点是否已经被挂载
        hilog.error(0x0000, 'testTag', '%{public}s', 'The frameNode is already attached');
        return;
      }
      this.rootNode = this.builderNode.getFrameNode();
    }
  }

  // 卸载Webview
  detachWeb() : void {
    this.rootNode = null;
  }

  getWebController() : webview.WebviewController | null | undefined {
    return this.webController;
  }
}

// 创建Map保存所需要的BuilderNode
let builderNodeMap : Map<string, BuilderNode<[Data]> | undefined> = new Map();
// 创建Map保存所需要的webview.WebviewController
let webControllerMap : Map<string, webview.WebviewController | undefined> = new Map();

// 初始化需要UIContext对象,UIContext对象可通过窗口或自定义组件的getUIContext方法获取
export const createNWeb = (url: string, uiContext: UIContext) => {
  // 创建WebviewController
  let webController = new webview.WebviewController() ;
  // 创建BuilderNode
  let builderNode : BuilderNode<[Data]> = new BuilderNode(uiContext);
  // 创建动态Web组件
  builderNode.build(wrap, new Data(url, webController));

  // 保存BuilderNode
  builderNodeMap.set(url, builderNode);
  // 保存WebviewController
  webControllerMap.set(url, webController);
}

// 自定义获取BuilderNode的接口
export const getBuilderNode = (url : string) : BuilderNode<[Data]> | undefined => {
  return builderNodeMap.get(url);
}
// 自定义获取WebviewController的接口
export const getWebviewController = (url : string) : webview.WebviewController | undefined => {
  return webControllerMap.get(url);
}
// 使用NodeController的Page页
// pages/Index.ets
import { getBuilderNode, MyNodeController, defaultUrl, getWebviewController } from "./common"

@Entry
@Component
struct Index {
  private nodeController : MyNodeController =
    new MyNodeController(getBuilderNode(defaultUrl), getWebviewController(defaultUrl));

  build() {
    Row() {
      Column() {
        Button("Attach Webview")
          .onClick(() => {
            // 注意不要将同一个节点同时挂载在不同的页面上!
            this.nodeController.attachWeb();
            this.nodeController.rebuild();
          })
        Button("Detach Webview")
          .onClick(() => {
            this.nodeController.detachWeb();
            this.nodeController.rebuild();
          })
        // NodeContainer用于与NodeController节点绑定,rebuild会触发makeNode
        // Page页通过NodeContainer接口绑定NodeController,实现动态组件页面显示
        NodeContainer(this.nodeController)
          .height("80%")
          .width("80%")
      }
      .width('100%')
    }
    .height('100%')
  }
}

10 管理网页文件上传与下载

10.1 上传文件

Web组件支持前端页面选择文件上传功能,应用开发者可以使用onShowFileSelector()接口来处理前端页面文件上传的请求,如果应用开发者不做任何处理,Web会提供默认行为来处理前端页面文件上传的请求。

下面的示例中,当用户在前端页面点击文件上传按钮,应用侧在onShowFileSelector()接口中收到文件上传请求,在此接口中开发者将上传的本地文件路径设置给前端页面。

  • 应用侧代码。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';
import { picker } from '@kit.CoreFileKit';

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

  build() {
    Column() {
      Web({ src: $rawfile('local.html'), controller: this.controller })
        .onShowFileSelector((event) => {
          console.log('MyFileUploader onShowFileSelector invoked');
          const documentSelectOptions = new picker.DocumentSelectOptions();
          let uri: string | null = null;
          const documentViewPicker = new picker.DocumentViewPicker();
          documentViewPicker.select(documentSelectOptions).then((documentSelectResult) => {
            uri = documentSelectResult[0];
            console.info('documentViewPicker.select to file succeed and uri is:' + uri);
            if (event) {
              event.result.handleFileList([uri]);
            }
          }).catch((err: BusinessError) => {
            console.error(`Invoke documentViewPicker.select failed, code is ${err.code}, message is ${err.message}`);
          })
          return true;
        })
    }
  }
}
  • local.html页面代码
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Document</title>
</head>

<body>
<!-- 点击上传文件按钮 -->
<input type="file" value="file"></br>
<meta name="viewport" content="width=device-width" />
</body>
</html>

10.2 使用Web组件的下载能力

说明

Web组件的下载功能要求应用通过调用WebDownloadItem.start来指定下载文件的保存路径。值得注意的是,WebDownloadItem.start并非启动下载,下载过程实际上在用户点击页面链接时即已开始。WebDownloadItem.start的作用是将已经下载到临时文件的部分移动到指定目标路径,后续未完成的下载的内容将直接保存到指定目标路径,临时目录位于/data/storage/el2/base/cache/web/Temp/。如果决定取消当前下载,应调用WebDownloadItem.cancel,此时临时文件将被删除。

如果不希望在WebDownloadItem.start之前将文件下载到临时目录,可以通过WebDownloadItem.cancel中断下载,后续可通过WebDownloadManager.resumeDownload恢复中断的下载。

10.2.1 监听页面触发的下载

通过setDownloadDelegate()向Web组件注册一个DownloadDelegate来监听页面触发的下载任务。资源由Web组件来下载,Web组件会通过DownloadDelegate将下载的进度通知给应用。

下面的示例中,在应用的rawfile中创建index.html以及download.html。应用启动后会创建一个Web组件并加载index.html,点击setDownloadDelegate按钮向Web组件注册一个DownloadDelegate,点击页面里的下载按钮的时候会触发一个下载任务,在DownloadDelegate中可以监听到下载的进度。

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

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

  build() {
    Column() {
      Button('setDownloadDelegate')
        .onClick(() => {
          try {
            this.delegate.onBeforeDownload((webDownloadItem: webview.WebDownloadItem) => {
              console.log("will start a download.");
              // 传入一个下载路径,并开始下载。
              // 如果传入一个不存在的路径,则会下载到默认/data/storage/el2/base/cache/web/目录。
              webDownloadItem.start("/data/storage/el2/base/cache/web/" + webDownloadItem.getSuggestedFileName());
            })
            this.delegate.onDownloadUpdated((webDownloadItem: webview.WebDownloadItem) => {
              // 下载任务的唯一标识。
              console.log("download update guid: " + webDownloadItem.getGuid());
              // 下载的进度。
              console.log("download update guid: " + webDownloadItem.getPercentComplete());
              // 当前的下载速度。
              console.log("download update speed: " + webDownloadItem.getCurrentSpeed())
            })
            this.delegate.onDownloadFailed((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download failed guid: " + webDownloadItem.getGuid());
              // 下载任务失败的错误码。
              console.log("download failed guid: " + webDownloadItem.getLastErrorCode());
            })
            this.delegate.onDownloadFinish((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download finish guid: " + webDownloadItem.getGuid());
            })
            this.controller.setDownloadDelegate(this.delegate);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: $rawfile('index.html'), controller: this.controller })
    }
  }
}

加载的html文件。

<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
// 点击视频右下方菜单的下载按钮会触发下载任务。
<video controls="controls" width="800px" height="580px"
       src="http://vjs.zencdn.net/v/oceans.mp4"
       type="video/mp4">
</video>
<a href='data:text/html,%3Ch1%3EHello%2C%20World%21%3C%2Fh1%3E' download='download.html'>下载download.html</a>
</body>
</html>

待下载的html文件。

<!-- download.html -->
<!DOCTYPE html>
<html>
<body>
<h1>download test</h1>
</body>
</html>

10.2.2 使用Web组件发起一个下载任务

使用startDownload()接口发起一个下载。

Web组件发起的下载会根据当前显示的url以及Web组件默认的Referrer Policy来计算referrer。

在下面的示例中,先点击setDownloadDelegate按钮向Web注册一个监听类,然后点击startDownload主动发起了一个下载,

该下载任务也会通过设置的DownloadDelegate来通知app下载的进度。

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

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

  build() {
    Column() {
      Button('setDownloadDelegate')
        .onClick(() => {
          try {
            this.delegate.onBeforeDownload((webDownloadItem: webview.WebDownloadItem) => {
              console.log("will start a download.");
              // 传入一个下载路径,并开始下载。
              webDownloadItem.start("/data/storage/el2/base/cache/web/" + webDownloadItem.getSuggestedFileName());
            })
            this.delegate.onDownloadUpdated((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download update guid: " + webDownloadItem.getGuid());
            })
            this.delegate.onDownloadFailed((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download failed guid: " + webDownloadItem.getGuid());
            })
            this.delegate.onDownloadFinish((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download finish guid: " + webDownloadItem.getGuid());
            })
            this.controller.setDownloadDelegate(this.delegate);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('startDownload')
        .onClick(() => {
          try {
            // 这里指定下载地址为 https://www.example.com/,Web组件会发起一个下载任务将该页面下载下来。
            // 开发者需要替换为自己想要下载的内容的地址。
            this.controller.startDownload('https://www.example.com/');
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

10.2.3 使用Web组件恢复进程退出时未下载完成的任务

在Web组件启动时,可通过resumeDownload()接口恢复未完成的下载任务。

在以下示例中,通过“record”按钮将当前下载任务保存至持久化文件中,应用重启后,可借助“recovery”按钮恢复持久化的下载任务。示例代码实现了将当前下载任务持久化保存至文件的功能,若需保存多个下载任务,应用可根据需求调整持久化的时机与方式。

// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';
import { downloadUtil, fileName, filePath } from './downloadUtil'; // downloadUtil.ets 见下文

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  delegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
  download: webview.WebDownloadItem = new webview.WebDownloadItem();
  // 用于记录失败的下载任务。
  failedData: Uint8Array = new Uint8Array();

  build() {
    Column() {
      Button('setDownloadDelegate')
        .onClick(() => {
          try {
            this.delegate.onBeforeDownload((webDownloadItem: webview.WebDownloadItem) => {
              console.log("will start a download.");
              // 传入一个下载路径,并开始下载。
              webDownloadItem.start("/data/storage/el2/base/cache/web/" + webDownloadItem.getSuggestedFileName());
            })
            this.delegate.onDownloadUpdated((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download update percent complete: " + webDownloadItem.getPercentComplete());
              this.download = webDownloadItem;
            })
            this.delegate.onDownloadFailed((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download failed guid: " + webDownloadItem.getGuid());
              // 序列化失败的下载任务到一个字节数组。
              this.failedData = webDownloadItem.serialize();
            })
            this.delegate.onDownloadFinish((webDownloadItem: webview.WebDownloadItem) => {
              console.log("download finish guid: " + webDownloadItem.getGuid());
            })
            this.controller.setDownloadDelegate(this.delegate);
            webview.WebDownloadManager.setDownloadDelegate(this.delegate);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Button('startDownload')
        .onClick(() => {
          try {
            // 这里指定下载地址为 https://www.example.com/,Web组件会发起一个下载任务将该页面下载下来。
            // 开发者需要替换为自己想要下载的内容的地址。
            this.controller.startDownload('https://www.example.com/');
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      // 将当前的下载任务信息序列化保存,用于后续恢复下载任务。
      // 当前用例仅展示下载一个任务的场景,多任务场景请按需扩展。
      Button('record')
        .onClick(() => {
          try {
            // 保存当前下载数据到持久化文档中。
            downloadUtil.saveDownloadInfo(downloadUtil.uint8ArrayToStr(this.download.serialize()));
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      // 从序列化的下载任务信息中,恢复下载任务。
      // 按钮触发时必须保证WebDownloadManager.setDownloadDelegate设置完成。
      Button('recovery')
        .onClick(() => {
          try {
            // 当前默认持久化文件存在,用户根据实际情况增加判断。
            let webDownloadItem =
              webview.WebDownloadItem.deserialize(downloadUtil.strToUint8Array(downloadUtil.readFileSync(filePath, fileName)));
            webview.WebDownloadManager.resumeDownload(webDownloadItem);
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })

      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

下载任务信息持久化工具类文件。

// downloadUtil.ets
import { util } from '@kit.ArkTS';
import fileStream from '@ohos.file.fs';

const helper = new util.Base64Helper();

export const filePath = getContext().filesDir;
export const fileName = 'demoFile.txt';
export namespace  downloadUtil {

  export function uint8ArrayToStr(uint8Array: Uint8Array): string {
    return helper.encodeToStringSync(uint8Array);
  }

  export function strToUint8Array(str: string): Uint8Array {
    return helper.decodeSync(str);
  }

  export function saveDownloadInfo(downloadInfo: string): void {
    if (!fileExists(filePath)) {
      mkDirectorySync(filePath);
    }

    writeToFileSync(filePath, fileName, downloadInfo);
  }

  export function fileExists(filePath: string): boolean {
    try {
      return fileStream.accessSync(filePath);
    } catch (error) {
      return false;
    }
  }

  export function mkDirectorySync(directoryPath: string, recursion?: boolean): void {
    try {
      fileStream.mkdirSync(directoryPath, recursion ?? false);
    } catch (error) {
      console.error(`mk dir error. err message: ${error.message}, err code: ${error.code}`);
    }
  }

  export function writeToFileSync(dir: string, fileName: string, msg: string): void {
    let file = fileStream.openSync(dir + '/' + fileName, fileStream.OpenMode.WRITE_ONLY | fileStream.OpenMode.CREATE);
    fileStream.writeSync(file.fd, msg);
  }

  export function readFileSync(dir: string, fileName: string): string {
    return fileStream.readTextSync(dir + '/' + fileName);
  }

}

11 使用网页多媒体

11.1 使用WebRTC进行Web视频会议

Web组件可以通过W3C标准协议接口拉起摄像头和麦克风,通过onPermissionRequest接口接收权限请求通知,需在配置文件中声明相应的音频权限。

 // src/main/resources/base/element/string.json
 {
   "name": "reason_for_camera",
   "value": "reason_for_camera"
 },
 {
   "name": "reason_for_microphone",
   "value": "reason_for_microphone"
 }
  // src/main/module.json5
  "requestPermissions":[
    {
      "name" : "ohos.permission.CAMERA",
      "reason": "$string:reason_for_camera",
      "usedScene": {
        "abilities": [
          "EntryAbility"
        ],
        "when":"inuse"
      }
    },
    {
      "name" : "ohos.permission.MICROPHONE",
      "reason": "$string:reason_for_microphone",
      "usedScene": {
        "abilities": [
          "EntryAbility"
        ],
        "when":"inuse"
      }
    }
  ],

通过在JavaScript中调用W3C标准协议接口navigator.mediaDevices.getUserMedia(),该接口用于拉起摄像头和麦克风。constraints参数是一个包含了video和audio两个成员的MediaStreamConstraints对象,用于说明请求的媒体类型。

在下面的示例中,点击前端页面中的开起摄像头按钮再点击onConfirm,打开摄像头和麦克风。

  • 应用侧代码。
// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';
import { abilityAccessCtrl } from '@kit.AbilityKit';

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

  aboutToAppear() {
    // 配置Web开启调试模式
    webview.WebviewController.setWebDebuggingAccess(true);
    // 获取权限请求通知,点击onConfirm按钮后,拉起摄像头和麦克风。
    let atManager = abilityAccessCtrl.createAtManager();
    atManager.requestPermissionsFromUser(getContext(this), ['ohos.permission.CAMERA', 'ohos.permission.MICROPHONE'])
      .then((data) => {
        console.info('data:' + JSON.stringify(data));
        console.info('data permissions:' + data.permissions);
        console.info('data authResults:' + data.authResults);
      }).catch((error: BusinessError) => {
      console.error(`Failed to request permissions from user. Code is ${error.code}, message is ${error.message}`);
    })
  }

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.controller })
        .onPermissionRequest((event) => {
          if (event) {
            AlertDialog.show({
              title: 'title',
              message: 'text',
              primaryButton: {
                value: 'deny',
                action: () => {
                  event.request.deny();
                }
              },
              secondaryButton: {
                value: 'onConfirm',
                action: () => {
                  event.request.grant(event.request.getAccessibleResource());
                }
              },
              cancel: () => {
                event.request.deny();
              }
            })
          }
        })
    }
  }
}
  • 前端页面index.html代码。
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<video id="video" width="500px" height="500px" autoplay="autoplay"></video>
<canvas id="canvas" width="500px" height="500px"></canvas>
<br>
<input type="button" title="HTML5摄像头" value="开启摄像头" onclick="getMedia()"/>
<script>
  function getMedia()
  {
    let constraints = {
      video: {width: 500, height: 500},
      audio: true
    };
    // 获取video摄像头区域
    let video = document.getElementById("video");
    // 返回的Promise对象
    let promise = navigator.mediaDevices.getUserMedia(constraints);
    // then()异步,调用MediaStream对象作为参数
    promise.then(function (MediaStream) {
      video.srcObject = MediaStream;
      video.play();
    });
  }
</script>
</body>
</html>

11.2 托管网页中的媒体播放

Web组件提供了应用接管网页中媒体播放的能力,用来支持应用增强网页的媒体播放,如画质增强等。

11.2.1 使用场景

网页播放媒体时,存在着网页视频不够清晰、网页的播放器界面简陋功能少、一些视频不能播放的问题。

此时,应用开发者可以使用该功能,通过自己或者第三方的播放器,接管网页媒体播放来改善网页的媒体播放体验。

11.2.2 实现原理

1、ArkWeb内核播放媒体的框架

不开启该功能时,ArkWeb内核的播放架构如下所示:

说明

  • 上图中1表示ArkWeb内核创建WebMediaPlayer来播放网页中的媒体资源。
  • 上图中2表示WebMediaPlayer使用系统解码器来渲染媒体数据。

开启该功能后,ArkWeb内核的播放架构如下:

说明

  • 上图中1表示ArkWeb内核创建WebMediaPlayer来播放网页中的媒体资源。
  • 上图中2表示WebMediaPlayer使用应用提供的本地播放器(NativeMediaPlayer)来渲染媒体数据。
2、ArkWeb内核与应用的交互

说明

11.2.3 开发指导

1、开启接管网页媒体播放

需要先通过enableNativeMediaPlayer接口,开启接管网页媒体播放的功能。

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

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

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .enableNativeMediaPlayer({ enable: true, shouldOverlay: false })
    }
  }
}
2、创建本地播放器(NativeMediaPlayer)

该功能开启后,每当网页中有媒体需要播放时,ArkWeb内核会触发由onCreateNativeMediaPlayer注册的回调函数。

开发者则需要调用 onCreateNativeMediaPlayer 来注册一个创建本地播放器的回调函数。

该回调函数需要根据媒体信息来判断是否要创建一个本地播放器来接管当前的网页媒体资源。

  • 如果应用不接管当前的为网页媒体资源, 需要在回调函数里返回 null 。
  • 如果应用接管当前的为网页媒体资源, 需要在回调函数里返回一个本地播放器实例。

本地播放器需要实现NativeMediaPlayerBridge接口,以便ArkWeb内核对本地播放器进行播控操作。

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

// 实现 webview.NativeMediaPlayerBridge 接口。
// ArkWeb 内核调用该类的方法来对 NativeMediaPlayer 进行播控。
class NativeMediaPlayerImpl implements webview.NativeMediaPlayerBridge {
  // ... 实现 NativeMediaPlayerBridge 里的接口方法 ...
  constructor(handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) {}
  updateRect(x: number, y: number, width: number, height: number) {}
  play() {}
  pause() {}
  seek(targetTime: number) {}
  release() {}
  setVolume(volume: number) {}
  setMuted(muted: boolean) {}
  setPlaybackRate(playbackRate: number) {}
  enterFullscreen() {}
  exitFullscreen() {}
}

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

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .enableNativeMediaPlayer({ enable: true, shouldOverlay: false })
        .onPageBegin((event) => {
          this.controller.onCreateNativeMediaPlayer((handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) => {
            // 判断需不需要接管当前的媒体。
            if (!shouldHandle(mediaInfo)) {
              // 本地播放器不接管该媒体。
              // 返回 null 。ArkWeb 内核将用自己的播放器来播放该媒体。
              return null;
            }
            // 接管当前的媒体。
            // 返回一个本地播放器实例给 ArkWeb 内核。
            let nativePlayer: webview.NativeMediaPlayerBridge = new NativeMediaPlayerImpl(handler, mediaInfo);
            return nativePlayer;
          });
        })
    }
  }
}

// stub
function shouldHandle(mediaInfo: webview.MediaInfo) {
  return true;
}
3、绘制本地播放器组件

应用接管网页的媒体后,开发者需要将本地播放器组件及视频画面绘制到ArkWeb内核提供的Surface上。ArkWeb内核再将Surface与网页进行合成,最后上屏显示。

该流程与同层渲染绘制相同。

1、在应用启动阶段,应保存UIContext,以便后续的同层渲染绘制流程能够使用该UIContext。

// xxxAbility.ets

import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err, data) => {
      if (err.code) {
        return;
      }
      // 保存UIContext, 在后续的同层渲染绘制中使用。
      AppStorage.setOrCreate<UIContext>("UIContext", windowStage.getMainWindowSync().getUIContext());
    });
  }

  // ... 其他需要重写的方法 ...
}

2、使用ArkWeb内核创建的Surface进行同层渲染绘制。

// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BuilderNode, FrameNode, NodeController, NodeRenderType } from '@kit.ArkUI';

interface ComponentParams {}

class MyNodeController extends NodeController {
  private rootNode: BuilderNode<[ComponentParams]> | undefined;

  constructor(surfaceId: string, renderType: NodeRenderType) {
    super();

    // 获取之前保存的 UIContext 。
    let uiContext = AppStorage.get<UIContext>("UIContext");
    this.rootNode = new BuilderNode(uiContext as UIContext, { surfaceId: surfaceId, type: renderType });
  }

  makeNode(uiContext: UIContext): FrameNode | null {
    if (this.rootNode) {
      return this.rootNode.getFrameNode() as FrameNode;
    }
    return null;
  }

  build() {
    // 构造本地播放器组件
  }
}

@Entry
@Component
struct WebComponent {
  node_controller?: MyNodeController;
  controller: webview.WebviewController = new webview.WebviewController();
  @State show_native_media_player: boolean = false;

  build() {
    Column() {
      Stack({ alignContent: Alignment.TopStart }) {
        if (this.show_native_media_player) {
          NodeContainer(this.node_controller)
            .width(300)
            .height(150)
            .backgroundColor(Color.Transparent)
            .border({ width: 2, color: Color.Orange })
        }
        Web({ src: 'www.example.com', controller: this.controller })
          .enableNativeMediaPlayer({ enable: true, shouldOverlay: false })
          .onPageBegin((event) => {
            this.controller.onCreateNativeMediaPlayer((handler: webview.NativeMediaPlayerHandler, mediaInfo:    webview.MediaInfo) => {
              // 接管当前的媒体。

              // 使用同层渲染流程提供的 surface 来构造一个本地播放器组件。
              this.node_controller = new MyNodeController(mediaInfo.surfaceInfo.id, NodeRenderType.RENDER_TYPE_TEXTURE);
              this.node_controller.build();

              // 展示本地播放器组件。
              this.show_native_media_player = true;

              // 返回一个本地播放器实例给 ArkWeb 内核。
              return null;
            });
          })
      }
    }
  }
}

动态创建组件并绘制到Surface上的详细介绍见同层渲染绘制 。

11.2.4 执行ArkWeb内核发送给本地播放器的播控指令

为了方便ArkWeb内核对本地播放器进行播控操作,应用开发者需要令本地播放器实现NativeMediaPlayerBridge接口,并根据每个接口方法的功能对本地播放器进行相应操作。

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

class ActualNativeMediaPlayerListener {
  constructor(handler: webview.NativeMediaPlayerHandler) {}
}

class NativeMediaPlayerImpl implements webview.NativeMediaPlayerBridge {
  constructor(handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) {
    // 1. 创建一个本地播放器的状态监听。
    let listener: ActualNativeMediaPlayerListener = new ActualNativeMediaPlayerListener(handler);
    // 2. 创建一个本地播放器。
    // 3. 监听该本地播放器。
    // ...
  }

  updateRect(x: number, y: number, width: number, height: number) {
    // <video> 标签的位置和大小发生了变化。
    // 根据该信息变化,作出相应的改变。
  }

  play() {
    // 启动本地播放器播放。
  }

  pause() {
    // 暂停本地播放器播放。
  }

  seek(targetTime: number) {
    // 本地播放器跳转到指定的时间点。
  }

  release() {
    // 销毁本地播放器。
  }

  setVolume(volume: number) {
    // ArkWeb 内核要求调整本地播放器的音量。
    // 设置本地播放器的音量。
  }

  setMuted(muted: boolean) {
    // 将本地播放器静音或取消静音。
  }

  setPlaybackRate(playbackRate: number) {
    // 调整本地播放器的播放速度。
  }

  enterFullscreen() {
    // 将本地播放器设置为全屏播放。
  }

  exitFullscreen() {
    // 将本地播放器退出全屏播放。
  }
}

11.2.5 将本地播放器的状态信息通知给ArkWeb内核

ArkWeb内核需要本地播放器的状态信息来更新到网页(例如:视频的宽高、播放时间、缓存时间等),因此,应用开发者需要将本地播放器的状态信息通知给ArkWeb内核。

onCreateNativeMediaPlayer接口中, ArkWeb内核传递给应用一个NativeMediaPlayerHandler对象。应用开发者需要通过该对象,将本地播放器的最新状态信息通知给ArkWeb内核。

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

class ActualNativeMediaPlayerListener {
  handler: webview.NativeMediaPlayerHandler;

  constructor(handler: webview.NativeMediaPlayerHandler) {
    this.handler = handler;
  }

  onPlaying() {
    // 本地播放器开始播放。
    this.handler.handleStatusChanged(webview.PlaybackStatus.PLAYING);
  }
  onPaused() {
    // 本地播放器暂停播放。
    this.handler.handleStatusChanged(webview.PlaybackStatus.PAUSED);
  }
  onSeeking() {
    // 本地播放器开始执行跳转到目标时间点。
    this.handler.handleSeeking();
  }
  onSeekDone() {
    // 本地播放器 seek 完成。
    this.handler.handleSeekFinished();
  }
  onEnded() {
    // 本地播放器播放完成。
    this.handler.handleEnded();
  }
  onVolumeChanged() {
    // 获取本地播放器的音量。
    let volume: number = getVolume();
    this.handler.handleVolumeChanged(volume);
  }
  onCurrentPlayingTimeUpdate() {
    // 更新播放时间。
    let currentTime: number = getCurrentPlayingTime();
    // 将时间单位换算成秒。
    let currentTimeInSeconds = convertToSeconds(currentTime);
    this.handler.handleTimeUpdate(currentTimeInSeconds);
  }
  onBufferedChanged() {
    // 缓存发生了变化。
    // 获取本地播放器的缓存时长。
    let bufferedEndTime: number = getCurrentBufferedTime();
    // 将时间单位换算成秒。
    let bufferedEndTimeInSeconds = convertToSeconds(bufferedEndTime);
    this.handler.handleBufferedEndTimeChanged(bufferedEndTimeInSeconds);

    // 检查缓存状态。
    // 如果缓存状态发生了变化,则向 ArkWeb 内核通知缓存状态。
    let lastReadyState: webview.ReadyState = getLastReadyState();
    let currentReadyState: webview.ReadyState = getCurrentReadyState();
    if (lastReadyState != currentReadyState) {
      this.handler.handleReadyStateChanged(currentReadyState);
    }
  }
  onEnterFullscreen() {
    // 本地播放器进入了全屏状态。
    let isFullscreen: boolean = true;
    this.handler.handleFullscreenChanged(isFullscreen);
  }
  onExitFullscreen() {
    // 本地播放器退出了全屏状态。
    let isFullscreen: boolean = false;
    this.handler.handleFullscreenChanged(isFullscreen);
  }
  onUpdateVideoSize(width: number, height: number) {
    // 当本地播放器解析出视频宽高时, 通知 ArkWeb 内核。
    this.handler.handleVideoSizeChanged(width, height);
  }
  onDurationChanged(duration: number) {
    // 本地播放器解析到了新的媒体时长, 通知 ArkWeb 内核。
    this.handler.handleDurationChanged(duration);
  }
  onError(error: webview.MediaError, errorMessage: string) {
    // 本地播放器出错了,通知 ArkWeb 内核。
    this.handler.handleError(error, errorMessage);
  }
  onNetworkStateChanged(state: webview.NetworkState) {
    // 本地播放器的网络状态发生了变化, 通知 ArkWeb 内核。
    this.handler.handleNetworkStateChanged(state);
  }
  onPlaybackRateChanged(playbackRate: number) {
    // 本地播放器的播放速率发生了变化, 通知 ArkWeb 内核。
    this.handler.handlePlaybackRateChanged(playbackRate);
  }
  onMutedChanged(muted: boolean) {
    // 本地播放器的静音状态发生了变化, 通知 ArkWeb 内核。
    this.handler.handleMutedChanged(muted);
  }

  // ... 监听本地播放器其他的状态 ...
}
@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  @State show_native_media_player: boolean = false;

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .enableNativeMediaPlayer({enable: true, shouldOverlay: false})
        .onPageBegin((event) => {
          this.controller.onCreateNativeMediaPlayer((handler: webview.NativeMediaPlayerHandler, mediaInfo: webview.MediaInfo) => {
            // 接管当前的媒体。

            // 创建一个本地播放器实例。
            // let nativePlayer: NativeMediaPlayerImpl = new NativeMediaPlayerImpl(handler, mediaInfo);

            // 创建一个本地播放器状态监听对象。
            let nativeMediaPlayerListener: ActualNativeMediaPlayerListener = new ActualNativeMediaPlayerListener(handler);
            // 监听本地播放器状态。
            // nativePlayer.setListener(nativeMediaPlayerListener);

            // 返回这个本地播放器实例给 ArkWeb 内核。
            return null;
          });
        })
    }
  }
}

// stub
function getVolume() {
  return 1;
}
function getCurrentPlayingTime() {
  return 1;
}
function getCurrentBufferedTime() {
  return 1;
}
function convertToSeconds(input: number) {
  return input;
}
function getLastReadyState() {
  return webview.ReadyState.HAVE_NOTHING;
}
function getCurrentReadyState() {
  return webview.ReadyState.HAVE_NOTHING;
}

12 处理网页内容

12.1 使用Web组件打印前端页面

Web组件打印html页面时可通过W3C标准协议接口和应用接口两种方式实现。

使用打印功能前,请在module.json5中配置相关权限,添加方法请参考在配置文件中声明权限

"requestPermissions":[
    {
      "name" : "ohos.permission.PRINT"
    }
  ]

12.1.1  使用W3C标准协议接口拉起打印

通过创建打印适配器,拉起打印应用,并对当前Web页面内容进行渲染,渲染后生成的PDF文件信息通过fd传递给打印框架。W3C标准协议接口window.print()方法用于打印当前页面或弹出打印对话框。该方法没有任何参数,只需要在JavaScript中调用即可。

可通过前端css样式控制是否打印,例如@media print。再通过web加载该html页面的方式运行。

  • print.html页面代码。
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>printTest</title>
    <style>
        @media print {
            h1 {
                display: none;
            }
        }
    </style>
</head>

<body>
    <div>
        <h1><b>
                <center>This is a test page for printing</center>
            </b>
            <hr color=#00cc00 width=95%>
        </h1>
        <button class="Button Button--outline" onclick="window.print();">Print</button>
        <p> content content content </p>
        <div id="printableTable">
            <table>
                <thead>
                    <tr>
                        <td>Thing</td>
                        <td>Chairs</td>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td>1</td>
                        <td>blue</td>
                    </tr>
                    <tr>
                        <td>2</td>
                        <td>green</td>
                    </tr>
                </tbody>
            </table>
        </div>
        <p> content content content </p>
        <p> content content content </p>
    </div>
</body>
  • 应用侧代码。
import { webview } from '@kit.ArkWeb';

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

  build() {
    Row() {
      Column() {
        Web({ src: $rawfile("print.html"), controller: this.controller })
          .javaScriptAccess(true)
      }
      .width('100%')
    }
    .height('100%')
  }
}

12.1.2  通过调用应用侧接口拉起打印。

应用侧通过调用createWebPrintDocumentAdapter创建打印适配器,通过将适配器传入打印的print接口调起打印。

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

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

  build() {
    Column() {
      Button('createWebPrintDocumentAdapter')
        .onClick(() => {
          try {
            let webPrintDocadapter = this.controller.createWebPrintDocumentAdapter('example.pdf');
            print.print('example_jobid', webPrintDocadapter, null, getContext());
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
      Web({ src: 'www.example.com', controller: this.controller })
    }
  }
}

12.2 使用Web组件的PDF文档预览能力

Web组件提供了在网页中预览PDF的能力。应用可以通过Web组件的src参数和loadUrl()接口中传入PDF文件,来加载PDF文档。根据PDF文档来源不同,可以分为三种常用场景:加载网络PDF文档、加载本地PDF文档、加载应用内resource资源PDF文档。

PDF文档预览加载过程中,若涉及网络文档获取,请在module.json5中配置网络访问权限,添加方法请参考在配置文件中声明权限

"requestPermissions":[
    {
      "name" : "ohos.permission.INTERNET"
    }
  ]

在下面的示例中,Web组件创建时指定默认加载的网络PDF文档 www.example.com/test.pdf,该地址为示例,使用时需替换为真实可访问地址:

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

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

  build() {
    Column() {
      Web({ 
          src: 
          "https://www.example.com/test.pdf",                     // 方式一 加载网络PDF文档
          // getContext(this).filesDir + "/test.pdf", // 方式二 加载本地应用沙箱内PDF文档
          // "resource://rawfile/test.pdf",                         // 方式三 应用内resource资源PDF文档
          // $rawfile('test.pdf'),                                 // 方式四 应用内resource资源PDF文档
          controller: this.controller 
      })
        .domStorageAccess(true)
    }
  }
}

上述示例中,由于PDF预览页面对于侧边导航栏是否展开会根据用户操作使用window.localStorage进行持久化记录,所以需开启文档对象模型存储domStorageAccess权限:

Web().domStorageAccess(true)

在Web组件创建时,指定默认加载的PDF文档。在默认PDF文档加载完成后,如果需要变更此Web组件显示的PDF文档,可以通过调用loadUrl()接口加载指定的PDF文档。Web组件的第一个参数变量src不能通过状态变量(例如:@State)动态更改地址,如需更改,请通过loadUrl()重新加载。

同时包含三种PDF文档加载预览场景:

  • 预览加载网络PDF文件。
Web({ 
  src: "https://www.example.com/test.pdf",
  controller: this.controller 
})
  .domStorageAccess(true)
  • 预览加载应用沙箱内PDF文件,需要开启应用中文件系统的访问fileAccess权限。
Web({ 
  src: getContext(this).filesDir + "/test.pdf",
  controller: this.controller 
})
  .domStorageAccess(true)
  .fileAccess(true)
  • 预览加载应用内PDF资源文件,有两种使用形式。$rawfile('test.pdf')形式无法指定下面介绍的预览参数。
Web({ 
  src: "resource://rawfile/test.pdf", // 或 $rawfile('test.pdf')
  controller: this.controller 
})
  .domStorageAccess(true)

此外,通过配置PDF文件预览参数,可以控制打开预览时页面状态。

当前支持如下参数:

语法描述
nameddest=destination指定PDF文档中的命名目标。
page=pagenum使用整数指定文档中的页码,文档第一页的pagenum值为1。
zoom=scale zoom=scale,left,top使用浮点或整数值设置缩放和滚动系数。 例如:缩放值100表示缩放值为100%。 向左和向上滚动值位于坐标系中,0,0 表示可见页面的左上角,无论文档如何旋转。
toolbar=1 | 0打开或关闭顶部工具栏。
navpanes=1 | 0打开或关闭侧边导航窗格。

URL示例:

https://example.com/test.pdf#Chapter6  
https://example.com/test.pdf#page=3  
https://example.com/test.pdf#zoom=50  
https://example.com/test.pdf#page=3&zoom=200,250,100  
https://example.com/test.pdf#toolbar=0  
https://example.com/test.pdf#navpanes=0  

12.3 网页中安全区域计算和避让适配

12.3.1 概述

安全区域定义为页面的显示区域,其默认不与系统设置的非安全区域(如状态栏、导航栏)重叠,以确保开发者设计的界面均布局于安全区域内。然而,当Web组件启用沉浸式模式时,网页元素可能会出现与状态栏或导航栏重叠的问题。具体示例如图1所示,红色虚线框划定的区域即为安全区域,而顶部状态栏、屏幕挖孔区域和底部导航条则被界定为非安全区域,Web组件开启沉浸式效果时,网页内底部元素与导航条发生重叠。

图1 Web组件开启沉浸式效果时网页内底部元素与导航条发生重叠

Web组件提供了利用W3C CSS进行安全区域计算并避让适配的能力,用来支持异形屏幕设备在沉浸式效果下页面的正常显示,网页开发者可以使用该能力对重叠元素进行避让。ArkWeb内核将持续监测Web组件及系统安全区域的位置与尺寸,依据两者的重叠部分,计算出当前Web组件的安全区域,以及在各个方向上所需避让的具体距离。

12.3.2 实现场景

1、开启Web组件沉浸式效果

可以通过expandSafeArea来开启沉浸式效果。

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

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

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        .width('100%').height('100%')
        .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
    }
  }
}
2、设置网页在可视窗口中的布局方式

viewport-fit用于限制网页在安全区域内的展示形态。默认为auto,与contain表现一致,表示可视窗口完全包含网页内容,即网页全部内容展示于安全区域内。而cover则表示网页内容完全覆盖可视窗口,即网页内容不仅展示于安全区域,还包含非安全区域,即可能与状态栏和导航栏发生重叠,只有这种场景下网页需要进行避让适配,设置方式如下:

<meta name='viewport' content='viewport-fit=cover'>
3、对网页元素进行避让

网页元素的避让适配主要依赖于env() CSS函数,该函数用于在CSS中插入由用户代码定义的变量。这使得开发人员能够将内容置于可视窗口(viewport)的安全区域内。在规范中定义的safe-area-inset-*值,确保了即使在非矩形视区中,内容也能得到完全显示。其语法如下:

/* safe-area-inset-*可设置上、右、下、左,四个方向上的避让值 */
env(safe-area-inset-top);
env(safe-area-inset-right);
env(safe-area-inset-bottom);
env(safe-area-inset-left);

/* 基于fallback,使用safe-area-inset-*设置四个方向上的避让值 */
/* 下述长度单位参见:https://developer.mozilla.org/zh-CN/docs/Web/CSS/length */
env(safe-area-inset-top, 20px);
env(safe-area-inset-right, 1em);
env(safe-area-inset-bottom, 0.5vh);
env(safe-area-inset-left, 1.4rem);

说明

safe-area-inset-*由四个环境变量组成,分别定义了可视窗口边缘内矩形的top、right、bottom和left,确保内容可以安全地放置,避免被非矩形显示区域切断。在矩形视口(如普通2in1设备的显示器)中,这些值等于零。而对于非矩形显示器(例如圆形表盘、移动设备屏幕等),所有内容都将在用户代理设定的四个值所形成的矩形区域内可见。

不同于其他的CSS属性,用户代理定义的属性名字对大小写敏感。同时,需要注意env()必须配合viewport-fit=cover使用。

对于一些购物网站,首页网页底部为Tab形式的绝对布局元素,在沉浸式状态下这些绝对布局元素就需要进行底部避让,以防止绝对布局元素与系统导航条发生重叠遮挡,避让效果见图2:

.tab-bottom {
    padding-bottom: env(safe-area-inset-bottom);
}

同时,上述env()使用还能基于部分数学计算函数calc(),min(),max()组合计算,如:

.tab-bottom {
    padding-bottom: max(env(safe-area-inset-bottom), 30px);
}

图2 Web组件开启沉浸式效果时网页内底部元素避让导航条区域

13 同层渲染

在系统中,应用可以使用Web组件加载Web网页。在非原生框架的UI组件功能或性能不如原生组件时,可使用同层渲染,使用ArkUI组件渲染这些组件(简称为同层组件)。

13.1 使用场景

13.1.1 Web网页

小程序的地图组件,可以使用ArkUI的XComponent组件渲染来提升性能。小程序的输入框组件,可以使用ArkUI的TextInput组件渲染,达到与原生应用一致的输入体验。

  • 在网页侧,应用开发者可将<embed>、<object>的网页UI组件(简称为同层标签),按一定规则进行同层渲染,详细规格见同层渲染规格小节。
  • 在应用侧,应用开发者可以通过Web组件的同层渲染事件上报接口,感知到H5同层标签的生命周期以及输入事件,进行同层渲染组件的相应业务逻辑处理。
  • 在应用侧,应用开发者可以使用ArkUI的NodeContainer等接口,构建H5同层标签对应的同层渲染组件。可支持同层渲染的ArkUI常用组件包括:TextInputXComponentCanvasVideoWeb

13.1.2 三方UI框架

Flutter提供了PlatformView与Texture抽象组件,这些组件可使用原生组件渲染,用来支持Flutter组件功能不足的部分。Weex2.0框架的Camera、Video、Canvas组件。

  • 在三方框架页面侧,由于Flutter、Weex等三方框架不在操作系统范围,本文不列举可被同层渲染的三方框架UI组件的范围与使用方式。
  • 在应用侧,应用开发者可以使用ArkUI的NodeContainer等接口,构建三方框架同层标签对应的同层渲染组件。可支持同层渲染的ArkUI常用组件包括:TextInputXComponentCanvasVideoWeb

13.2 整体架构

ArkWeb同层渲染特性主要提供两种能力:同层标签生命周期和事件命中转发处理。

同层标签生命周期主要关联前端标签(<embed>/<object>),同时命中到同层标签的事件会被上报到开发者侧,由开发者分发到对应组件树。整体框架如下图所示:

图1 同层渲染整体架构

13.3 规格约束

13.3.1 可被同层渲染的ArkUI组件

以下规格对Web网页和三方框架场景均生效。

支持的组件范围:

支持的组件通用属性与事件:

13.3.2 Web网页的同层渲染标签

此规格仅针对Web网页,不适用于三方框架场景。

如果应用需要在Web组件加载的网页中使用同层渲染,需要按照以下规格将网页中的<embed>、<object>标签指定为同层渲染组件。

支持的产品形态:

  • 当前仅支持移动设备和平板形态。

支持的H5标签:

  • 支持<embed>标签:在开启同层渲染后,仅支持type类型为native前缀的标签识别为同层组件,不支持自定义属性。
  • 支持<object>标签:在开启同层渲染后,支持将非标准MIME type的object标签识别为同层组件,支持通过param/value的自定义属性解析。
  • 不支持W3C规范标准标签(如<input>、<video>)定义为同层标签。
  • 不支持同时配置<object>标签和<embed>标签作为同层标签。
  • 标签类型只支持英文字符,不区分大小写。

同层标签的属性支持范围:

  • 支持满足W3C标准的CSS样式属性。

同层标签的生命周期管理:

当Embed标签生命周期变化时触发onNativeEmbedLifecycleChange()回调。

  • 支持创建、销毁、位置宽高变化、不支持可见状态变化。
  • 支持同层组件所在Web页面进入前进后退缓存。

同层标签的输入事件分发处理:

约束限制:

  • Web页面内不建议超过5个同层标签。超过5个后,渲染性能将会下降。
  • 受GPU限制,同层标签最大高度不超过8000px,最大纹理大小为8000px。
  • 开启同层渲染后,Web组件打开的所有Web页面将不支持同步渲染模式RenderMode
  • Video组件:在非全屏Video变为全屏时,Video组件变为非纹理导出模式,视频播放状态保持延续;恢复为非全屏时,变为纹理导出模式,视频播放状态保持延续。
  • Web组件:仅支持一层同层渲染嵌套,不支持多层同层渲染嵌套。输入事件只支持滑动、点击、缩放、长按 ,不支持拖拽、旋转。
  • 涉及界面交互的ArkUI组件(如TextInput等):建议在页面布局中使用Stack包裹同层组件容器与BuilderNode,并使两者位置一致,NodeContainer要与<embed>/<object>标签对齐,以保障组件正常交互。如两者位置不一致,可能出现的问题有:TextInput/TextArea等附属的文本选择框位置错位(如下图)、LoadingProgress/Marquee等组件的动画启停与组件可见状态不匹配。

图2 未使用Stack包裹,TextInput的位置错位

图3 使用Stack包裹,TextInput的位置正常

13.4 Web页面中同层渲染输入框

在Web页面中,可以使用ArkUI原生的TextInput组件进行同层渲染。此处利用同层渲染展示三个输入框,渲染效果图如下:

图4 同层渲染输入框

1. 在Web页面中标记需要同层渲染的HTML标签。

同层渲染支持<embed>/<object>两种标签。type类型可任意指定,两个字符串参数均不区分大小写,ArkWeb内核将会统一转换为小写。其中,tag字符串使用全字符串匹配,type使用字符串前缀匹配。

若开发者不使用该接口或该接口接收的为非法字符串(空字符串)时,ArkWeb内核将使用默认设置,即"embed" + "native/"前缀模式。若指定类型与w3c定义的object或embed标准类型重合,如registerNativeEmbedRule("object", "application/pdf"),ArkWeb将遵循w3c标准行为,不会将其识别为同层标签。

  • 采用<embed>标签
<!--HAP's src/main/resources/rawfile/text.html-->
<!DOCTYPE html>
<html>
<head>
    <title>同层渲染测试html</title>
    <meta name="viewport">
</head>

<body style="background:white">

<embed id = "input1" type="native/view" style="width: 100%; height: 100px; margin: 30px; margin-top: 600px"/>

<embed id = "input2" type="native/view2" style="width: 100%; height: 100px; margin: 30px; margin-top: 50px"/>

<embed id = "input3" type="native/view3" style="width: 100%; height: 100px; margin: 30px; margin-top: 50px"/>

</body>
</html>
  •  采用<object>标签

需要使用registerNativeEmbedRule注册object标签。

// ...
Web({src: $rawfile("text.html"), controller: this.browserTabController})
  // 注册同层标签为"object",类型为"test"前缀
  .registerNativeEmbedRule("object", "test")
  // ...

 与registerNativeEmbedRule相对应的前端页面代码,类型可使用"test"及以"test"为前缀的字串。

<!--HAP's src/main/resources/rawfile/text.html-->
<!DOCTYPE html>
<html>
<head>
    <title>同层渲染测试html</title>
    <meta name="viewport">
</head>

<body style="background:white">

<object id = "input1" type="test/input" style="width: 100%; height: 100px; margin: 30px; margin-top: 600px"></object>

<object id = "input2" type="test/input" style="width: 100%; height: 100px; margin: 30px; margin-top: 50px"></object>

<object id = "input3" type="test/input" style="width: 100%; height: 100px; margin: 30px; margin-top: 50px"></object>

</body>
</html>

2. 在应用侧开启同层渲染功能。

同层渲染功能默认不开启,如果要使用同层渲染的功能,可通过enableNativeEmbedMode来开启。

// xxx.ets
import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();

  build() {
    Column() {
      Web({ src: 'www.example.com', controller: this.controller })
        // 配置同层渲染开关开启。
        .enableNativeEmbedMode(true)
    }
  }
}

3. 创建自定义组件。

同层渲染功能开启后,展示在对应区域的原生组件。

@Component
struct TextInputComponent {
  @Prop params: Params
  @State bkColor: Color = Color.White

  build() {
    Column() {
      TextInput({text: '', placeholder: 'please input your word...'})
        .placeholderColor(Color.Gray)
        .id(this.params?.elementId)
        .placeholderFont({size: 13, weight: 400})
        .caretColor(Color.Gray)
        .width(this.params?.width)
        .height(this.params?.height)
        .fontSize(14)
        .fontColor(Color.Black)
    }
    //自定义组件中的最外层容器组件宽高应该为同层标签的宽高
    .width(this.params.width)
    .height(this.params.height)
  }
}

@Builder
function TextInputBuilder(params:Params) {
  TextInputComponent({params: params})
    .width(params.width)
    .height(params.height)
    .backgroundColor(Color.White)
}

4. 创建节点控制器。

用于控制和反馈对应NodeContainer上的节点行为。

class MyNodeController extends NodeController {
  private rootNode: BuilderNode<[Params]> | undefined | null;
  private embedId_: string = "";
  private surfaceId_: string = "";
  private renderType_: NodeRenderType = NodeRenderType.RENDER_TYPE_DISPLAY;
  private width_: number = 0;
  private height_: number = 0;
  private type_: string = "";
  private isDestroy_: boolean = false;

  setRenderOption(params: NodeControllerParams) {
    this.surfaceId_ = params.surfaceId;
    this.renderType_ = params.renderType;
    this.embedId_ = params.embedId;
    this.width_ = params.width;
    this.height_ = params.height;
    this.type_ = params.type;
  }

  // 必须要重写的方法,用于构建节点数、返回节点数挂载在对应NodeContainer中。
  // 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新。
  makeNode(uiContext: UIContext): FrameNode | null {
    if (this.isDestroy_) { // rootNode为null
      return null;
    }
    if (!this.rootNode) {// rootNode 为undefined时
      this.rootNode = new BuilderNode(uiContext, { surfaceId: this.surfaceId_, type: this.renderType_ });
      if(this.rootNode) {
        this.rootNode.build(wrapBuilder(TextInputBuilder), {  textOne: "myTextInput", width: this.width_, height: this.height_  })
        return this.rootNode.getFrameNode();
      }else{
        return null;
      }
    }
    // 返回FrameNode节点。
    return this.rootNode.getFrameNode();
  }

  setBuilderNode(rootNode: BuilderNode<Params[]> | null): void {
    this.rootNode = rootNode;
  }

  getBuilderNode(): BuilderNode<[Params]> | undefined | null {
    return this.rootNode;
  }

  updateNode(arg: Object): void {
    this.rootNode?.update(arg);
  }

  getEmbedId(): string {
    return this.embedId_;
  }
  
  setDestroy(isDestroy: boolean): void {
    this.isDestroy_ = isDestroy;
    if (this.isDestroy_) {
      this.rootNode = null;
    }
  }
 
  postEvent(event: TouchEvent | undefined): boolean {
    return this.rootNode?.postTouchEvent(event) as boolean
  }
}

5. 监听同层渲染的生命周期变化。

开启该功能后,每当网页中存在同层渲染支持的标签时,ArkWeb内核会触发由onNativeEmbedLifecycleChange注册的回调函数。

需要调用onNativeEmbedLifecycleChange来监听同层渲染标签的生命周期变化。

build() {
  Row() {
    Column() {
      Stack() {
        ForEach(this.componentIdArr, (componentId: string) => {
          NodeContainer(this.nodeControllerMap.get(componentId))
            .position(this.positionMap.get(componentId))
            .width(this.widthMap.get(componentId))
            .height(this.heightMap.get(componentId))
        }, (embedId: string) => embedId)
        // Web组件加载本地text.html页面
        Web({src: $rawfile("text.html"), controller: this.browserTabController})
          // 配置同层渲染开关开启
          .enableNativeEmbedMode(true)
            // 注册同层标签为"object",类型为"test"前缀
          .registerNativeEmbedRule("object", "test")
            // 获取embed标签的生命周期变化数据
          .onNativeEmbedLifecycleChange((embed) => {
            console.log("NativeEmbed surfaceId" + embed.surfaceId);
            // 如果使用embed.info.id作为映射nodeController的key,请在h5页面显式指定id
            const componentId = embed.info?.id?.toString() as string
            if (embed.status == NativeEmbedStatus.CREATE) {
              console.log("NativeEmbed create" + JSON.stringify(embed.info));
              // 创建节点控制器、设置参数并rebuild
              let nodeController = new MyNodeController()
              // embed.info.width和embed.info.height单位是px格式,需要转换成ets侧的默认单位vp
              nodeController.setRenderOption({surfaceId : embed.surfaceId as string,
                type : embed.info?.type as string,
                renderType : NodeRenderType.RENDER_TYPE_TEXTURE,
                embedId : embed.embedId as string,
                width : px2vp(embed.info?.width),
                height : px2vp(embed.info?.height)})
              this.edges = {left: `${embed.info?.position?.x as number}px`, top: `${embed.info?.position?.y as number}px`}
              nodeController.setDestroy(false);
              //根据web传入的embed的id属性作为key,将nodeController存入Map
              this.nodeControllerMap.set(componentId, nodeController);
              this.widthMap.set(componentId, px2vp(embed.info?.width));
              this.heightMap.set(componentId, px2vp(embed.info?.height));
              this.positionMap.set(componentId, this.edges);
              // 将web传入的embed的id属性存入@State状态数组变量中,用于动态创建nodeContainer节点容器,需要将push动作放在set之后
              this.componentIdArr.push(componentId)
            } else if (embed.status == NativeEmbedStatus.UPDATE) {
              let nodeController = this.nodeControllerMap.get(componentId);
              console.log("NativeEmbed update" + JSON.stringify(embed));
              this.edges = {left: `${embed.info?.position?.x as number}px`, top: `${embed.info?.position?.y as number}px`}
              this.positionMap.set(componentId, this.edges);
              this.widthMap.set(componentId, px2vp(embed.info?.width));
              this.heightMap.set(componentId, px2vp(embed.info?.height));
              nodeController?.updateNode({textOne: 'update', width: px2vp(embed.info?.width), height: px2vp(embed.info?.height)} as ESObject)
            } else if (embed.status == NativeEmbedStatus.DESTROY) {
              console.log("NativeEmbed destroy" + JSON.stringify(embed));
              let nodeController = this.nodeControllerMap.get(componentId);
              nodeController?.setDestroy(true)
              this.nodeControllerMap.clear();
              this.positionMap.delete(componentId);
              this.widthMap.delete(componentId);
              this.heightMap.delete(componentId);
              this.componentIdArr.filter((value: string) => value != componentId)
            } else {
              console.log("NativeEmbed status" + embed.status);
            }
          })
      }.height("80%")
    }
  }
}

6. 同层渲染手势事件。

开启该功能后,每当在同层渲染的区域进行触摸操作时,ArkWeb内核会触发onNativeEmbedGestureEvent注册的回调函数。

需要调用onNativeEmbedGestureEvent来监听同层渲染同层渲染区域的手势事件。

build() {
  Row() {
    Column() {
      Stack() {
        ForEach(this.componentIdArr, (componentId: string) => {
          NodeContainer(this.nodeControllerMap.get(componentId))
            .position(this.positionMap.get(componentId))
            .width(this.widthMap.get(componentId))
            .height(this.heightMap.get(componentId))
        }, (embedId: string) => embedId)
        // Web组件加载本地text.html页面。
        Web({src: $rawfile("text.html"), controller: this.browserTabController})
          // 配置同层渲染开关开启。
          .enableNativeEmbedMode(true)
            // 获取embed标签的生命周期变化数据。
          .onNativeEmbedLifecycleChange((embed) => {
            // 生命周期变化实现
          })
          .onNativeEmbedGestureEvent((touch) => {
            console.log("NativeEmbed onNativeEmbedGestureEvent" + JSON.stringify(touch.touchEvent));
            this.componentIdArr.forEach((componentId: string) => {
              let nodeController = this.nodeControllerMap.get(componentId);
              // 将获取到的同层区域的事件发送到该区域embedId对应的nodeController上
              if(nodeController?.getEmbedId() == touch.embedId) {
                let ret = nodeController?.postEvent(touch.touchEvent)
                if(ret) {
                  console.log("onNativeEmbedGestureEvent success " + componentId);
                } else {
                  console.log("onNativeEmbedGestureEvent fail " + componentId);
                }
                if(touch.result) {
                  // 通知Web组件手势事件消费结果
                  touch.result.setGestureEventResult(ret);
                }
              }
            })
          })
      }
    }
  }
}

14 使用离线Web组件

Web组件能够实现在不同窗口的组件树上进行挂载或移除操作,这一能力使得开发者可以预先创建Web组件,从而实现性能优化。例如,当Tab页为Web组件时,页面可以预先渲染,以便于即时显示。

创建离线Web组件,是基于自定义占位组件NodeContainer实现的。其基本原理为:构建支持命令式创建的Web组件,此类组件创建后不会立即挂载到组件树中,因此不会立即对用户呈现(其组件状态为Hidden和InActive)。开发者可以在后续使用中按需动态挂载这些组件,以实现更灵活的使用方式。

使用离线Web组件可以优化预启动渲染进程和预渲染Web页面。

  • 预启动渲染进程:在未进入Web页面前,提前创建一个空的Web组件,从而启动Web的渲染进程,为后续的Web页面使用做好准备。
  • 预渲染Web页面:在Web页面启动或跳转的场景下,预先在后台创建Web组件,加载数据并完成渲染,从而在跳转至Web页面时实现快速显示上屏。

14.1 整体架构

如下图所示,在需要离屏创建Web组件时,定义一个自定义组件以封装Web组件,此Web组件在离线状态下被创建,封装于无状态的NodeContainer节点中,并与相应的NodeController组件绑定。Web组件在后台预渲染完毕后,当需要展示时,通过NodeController将其挂载到ViewTree的NodeContainer中,即与对应的NodeContainer组件绑定,即可挂载上树并显示。

14.2 创建离线Web组件

本示例展示了如何预先创建离线Web组件,并在需要的时候进行挂载和显示。在后续内容中,预启动渲染进程和预渲染Web页面作为性能优化措施,均是利用离线Web组件实现的。

说明

创建Web组件将占用内存(每个Web组件大约200MB)和计算资源,建议避免一次性创建过多的离线Web组件,以减少资源消耗。

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

// @Builder中为动态组件的具体组件内容
// Data为入参封装类
class Data{
  url: ResourceStr = "https://www.example.com";
  controller: WebviewController = new webview.WebviewController();
}

@Builder
function WebBuilder(data:Data) {
  Column() {
    Web({ src: data.url, controller: data.controller })
      .width("100%")
      .height("100%")
  }
}

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

// 用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用
export class myNodeController extends NodeController {
  private rootnode: BuilderNode<Data[]> | null = null;
  // 必须要重写的方法,用于构建节点数、返回节点挂载在对应NodeContainer中
  // 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新
  makeNode(uiContext: UIContext): FrameNode | null {
    console.log(" uicontext is undefined : "+ (uiContext === undefined));
    if (this.rootnode != null) {
      // 返回FrameNode节点
      return this.rootnode.getFrameNode();
    }
    // 返回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:ResourceStr, 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<ResourceStr, myNodeController | undefined> = new Map();
// 创建Map保存所需要的WebViewController
let controllerMap:Map<ResourceStr, WebviewController | undefined> = new Map();

// 初始化需要UIContext,需在Ability获取
export const createNWeb = (url: ResourceStr, 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: ResourceStr) : myNodeController | undefined => {
  return NodeMap.get(url);
}
// 使用NodeController的Page页
// Index.ets
import { 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%')
  }
}

14.3 预启动渲染进程

在后台预先创建一个Web组件,以启动用于渲染的Web渲染进程,这样可以节省后续Web组件加载时启动Web渲染进程所需的时间。

说明

仅在采用单渲染进程模式的应用中,即全局共享一个Web渲染进程时,优化效果显著。Web渲染进程仅在所有Web组件均被销毁后才会终止,因此建议应用至少保持一个Web组件处于活动状态。

该示例在onWindowStageCreate时期预创建了一个Web组件加载blank页面,从而提前启动了Render进程,从index跳转到index2时,优化了Web的Render进程启动和初始化的耗时。

由于创建额外的Web组件会产生内存开销,建议在此方案的基础上复用该Web组件。

// 载体Ability
// EntryAbility.ets
import { createNWeb } from "../pages/common"
onWindowStageCreate(windowStage: window.WindowStage): void {
  windowStage.loadContent('pages/Index', (err, data) => {
    // 创建空的Web动态组件(需传入UIContext),loadContent之后的任意时机均可创建
    createNWeb("about:blank", windowStage.getMainWindowSync().getUIContext());
    if (err.code) {
      return;
    }
  });
}
// 创建NodeController
// common.ets
import { UIContext, NodeController, BuilderNode, Size, FrameNode } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';

// @Builder中为动态组件的具体组件内容
// Data为入参封装类
class Data{
  url: ResourceStr = "https://www.example.com";
  controller: WebviewController = new webview.WebviewController();
}

@Builder
function WebBuilder(data:Data) {
  Column() {
    Web({ src: data.url, controller: data.controller })
      .width("100%")
      .height("100%")
  }
}

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

// 用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用
export class myNodeController extends NodeController {
  private rootnode: BuilderNode<Data[]> | null = null;
  // 必须要重写的方法,用于构建节点数、返回节点挂载在对应NodeContainer中
  // 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新
  makeNode(uiContext: UIContext): FrameNode | null {
    console.log(" uicontext is undefined : "+ (uiContext === undefined));
    if (this.rootnode != null) {
      // 返回FrameNode节点
      return this.rootnode.getFrameNode();
    }
    // 返回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:ResourceStr, 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<ResourceStr, myNodeController | undefined> = new Map();
// 创建Map保存所需要的WebViewController
let controllerMap:Map<ResourceStr, WebviewController | undefined> = new Map();

// 初始化需要UIContext 需在Ability获取
export const createNWeb = (url: ResourceStr, 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: ResourceStr) : myNodeController | undefined => {
  return NodeMap.get(url);
}
// 创建NodeController
// common.ets
import { UIContext, NodeController, BuilderNode, Size, FrameNode } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';

// @Builder中为动态组件的具体组件内容
// Data为入参封装类
class Data{
  url: ResourceStr = "https://www.example.com";
  controller: WebviewController = new webview.WebviewController();
}

@Builder
function WebBuilder(data:Data) {
  Column() {
    Web({ src: data.url, controller: data.controller })
      .width("100%")
      .height("100%")
  }
}

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

// 用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用
export class myNodeController extends NodeController {
  private rootnode: BuilderNode<Data[]> | null = null;
  // 必须要重写的方法,用于构建节点数、返回节点挂载在对应NodeContainer中
  // 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新
  makeNode(uiContext: UIContext): FrameNode | null {
    console.log(" uicontext is undefined : "+ (uiContext === undefined));
    if (this.rootnode != null) {
      // 返回FrameNode节点
      return this.rootnode.getFrameNode();
    }
    // 返回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:ResourceStr, 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<ResourceStr, myNodeController | undefined> = new Map();
// 创建Map保存所需要的WebViewController
let controllerMap:Map<ResourceStr, WebviewController | undefined> = new Map();

// 初始化需要UIContext 需在Ability获取
export const createNWeb = (url: ResourceStr, 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: ResourceStr) : myNodeController | undefined => {
  return NodeMap.get(url);
}
import web_webview from '@ohos.web.webview'
@Entry
@Component
struct index2 {
  WebviewController: webview.WebviewController = new webview.WebviewController();
  
  build() {
    Row() {
      Column() {
        Web({src: 'https://www.example.com', controller: this.webviewController})
          .width('100%')
          .height('100%')
      }
      .width('100%')
    }
    .height('100%')
  }
}

14.4 预渲染Web页面

预渲染Web页面优化方案适用于Web页面启动和跳转场景,例如,进入首页后,跳转到其他子页。建议在高命中率的页面使用该方案。

预渲染Web页面的实现方案是提前创建离线Web组件,并设置Web为Active状态来开启渲染引擎,进行后台渲染。

说明

  1. 预渲染的Web页面需要确定加载的资源。
  2. 由于该方案会将激活不可见的后台Web(即设置为Active状态),建议不要对存在自动播放音视频的页面进行预渲染。应用侧请自行检查和管理页面的行为。
  3. 在后台,预渲染的网页会持续进行渲染,为了防止发热和功耗问题,建议在预渲染完成后立即停止渲染过程。可以参考以下示例,使用 onFirstMeaningfulPaint 来确定预渲染的停止时机,该接口适用于http和https的在线网页。
// 载体Ability
// EntryAbility.ets
import {createNWeb} from "../pages/common";
import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  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;
      }
    });
  }
}

// 创建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();
}
// 通过布尔变量shouldInactive控制网页在后台完成预渲染后停止渲染
let shouldInactive: boolean = true;
@Builder
function WebBuilder(data:Data) {
  Column() {
    Web({ src: data.url, controller: data.controller })
      .onPageBegin(() => {
        // 调用onActive,开启渲染
        data.controller.onActive();
      })
      .onFirstMeaningfulPaint(() =>{
        if (!shouldInactive) {
          return;
        }
        // 在预渲染完成时触发,停止渲染
        data.controller.onInactive();
        shouldInactive = false;
      })
      .width("100%")
      .height("100%")
  }
}
let wrap = wrapBuilder<Data[]>(WebBuilder);
// 用于控制和反馈对应的NodeContianer上的节点的行为,需要与NodeContainer一起使用
export class myNodeController extends NodeController {
  private rootnode: BuilderNode<Data[]> | null = null;
  // 必须要重写的方法,用于构建节点数、返回节点挂载在对应NodeContianer中
  // 在对应NodeContianer创建的时候调用、或者通过rebuild方法调用刷新
  makeNode(uiContext: UIContext): FrameNode | null {
    console.info(" uicontext is undifined : "+ (uiContext === undefined));
    if (this.rootnode != null) {
      // 返回FrameNode节点
      return this.rootnode.getFrameNode();
    }
    // 返回null控制动态组件脱离绑定节点
    return null;
  }
  // 当布局大小发生变化时进行回调
  aboutToResize(size: Size) {
    console.info("aboutToResize width : " + size.width  +  " height : " + size.height )
  }
  // 当controller对应的NodeContainer在Appear的时候进行回调
  aboutToAppear() {
    console.info("aboutToAppear")
    // 切换到前台后,不需要停止渲染
    shouldInactive = false;
  }
  // 当controller对应的NodeContainer在Disappear的时候进行回调
  aboutToDisappear() {
    console.info("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);
}
// 使用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%')
  }
}

15 Web调试维测

15.1 使用DevTools工具调试前端页面

Web组件支持使用DevTools工具调试前端页面。DevTools是一个 Web前端开发调试工具,提供了电脑上调试移动设备前端页面的能力。开发者通过setWebDebuggingAccess()接口开启Web组件前端页面调试能力,利用DevTools工具可以在电脑上调试移动设备上的前端网页,设备需为4.1.0及以上版本。

15.2 调试步骤

15.2.1 应用代码开启Web调试开关

调试网页前,需要应用侧代码调用setWebDebuggingAccess()接口开启Web调试开关。

如果没有开启Web调试开关,则DevTools无法发现被调试的网页。

1. 在应用代码中开启Web调试开关,具体如下:

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

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

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

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

2. 开启调试功能需要在DevEco Studio应用工程hap模块的module.json5文件中增加如下权限,添加方法请参考在配置文件中声明权限

"requestPermissions":[
   {
     "name" : "ohos.permission.INTERNET"
   }
 ]

15.2.3 将设备连接至电脑

请将设备连接至电脑,随后开启开发者模式,为后续的端口转发操作做好准备。

1. 请开启设备上的开发者模式,并启用USB调试功能。

  1. (1) 终端系统查看“设置 > 系统”中是否有“开发者选项”,如果不存在,可在“设置 > 关于本机”连续七次单击“版本号”,直到提示“开启开发者模式”,点击“确认开启”后输入PIN码(如果已设置),设备将自动重启。
  2. (2) USB数据线连接终端和电脑,在“设置 > 系统 > 开发者选项”中,打开“USB调试”开关,弹出的“允许USB调试”的弹框,点击“允许”。

2. 使用hdc命令连接上设备。

打开命令行执行如下命令,查看hdc能否发现设备。

hdc list targets
  •  如果命令有返回设备的ID,则说明hdc已连接上设备。

  • 如果命令返回 [Empty],则说明hdc还没有发现设备。 

3. 进入hdc shell。

当hdc命令连接上设备后,执行如下命令,进入hdc shell。

hdc shell

15.2.4 端口转发

当应用代码调用setWebDebuggingAccess接口开启Web调试开关后,ArkWeb内核将启动一个domain socket的监听,以此实现DevTools对网页的调试功能。

但是Chrome浏览器无法直接访问到设备上的domain socket, 所以需要将设备上的domain socket转发到电脑上。

1. 先在hdc shell里执行如下命令,查询ArkWeb在设备里创建的domain socket。

cat /proc/net/unix | grep devtools
  • 如果前几步操作无误,该命令的执行结果将显示用于查询的domain socket端口。

  • 如果没有查询到结果, 请再次确认。

(1) 应用开启了Web调试开关。

(2) 应用使用Web组件加载了网页。

2. 将查询到的domain socket转发至电脑的TCP 9222端口。

  • 执行exit退出hdc shell。
exit
  • 在命令行里执行如下命令转发端口。
hdc fport tcp:9222 localabstract:webview_devtools_remote_38532

说明

"webview_devtools_remote_" 后面的数字,代表ArkWeb所在应用的进程号, 该数字不是固定的。请将数字改为自己查询到的值。

如果应用的进程号发生变化(例如,应用重新启动),则需要重新进行端口转发。

  • 命令执行成功示意图:

3. 在命令行里执行如下命令,检查端口是否转发成功。

hdc fport ls
  •  如果有返回端口转发的任务,则说明端口转发成功。

  • 如果返回 [Empty], 则说明端口转发失败。 

15.2.5 在Chrome浏览器上打开调试工具页面

1. 在电脑端Chrome浏览器地址栏中输入调试工具地址 chrome://inspect/#devices 并打开该页面。

  • 修改Chrome调试工具的配置。

2. 需要从本地的TCP 9222端口发现被调试网页,所以请确保已勾选 "Discover network targets"。然后再进行网络配置。

  • (1) 点击 "Configure" 按钮。
  • (2) 在 "Target discovery settings" 中添加要监听的本地端口localhost:9222。

3. 为了同时调试多个应用,请在Chrome浏览器的调试工具网页内,于“Devices”选项中的“configure”部分添加多个端口号。

15.2.5 等待发现被调试页面

如果前面的步骤执行成功,稍后,Chrome的调试页面将显示待调试的网页。

1、开始网页调试

15.3 使用crashpad收集Web组件崩溃信息

Web组件支持使用crashpad记录进程崩溃信息。crashpad是chromium内核提供的进程崩溃信息处理工具,在应用使用Web组件导致的进程崩溃出现后(包括应用主进程与Web渲染进程),crashpad会在应用主进程沙箱目录写入minidump文件。该文件为二进制格式,后缀为dmp,其记录了进程崩溃的原因、线程信息、寄存器信息等,应用可以使用该文件分析Web组件相关进程崩溃问题。

使用步骤如下:

1. 在应用使用Web组件导致的进程崩溃出现后,会在应用主进程沙箱目录下产生对应的dmp文件,对应的沙箱路径如下:

/data/storage/el2/log/crashpad

 2. 应用可以访问该路径拿到目录下的dmp文件,然后进行解析,具体步骤如下:

  • 通过minidump_stackwalk工具解析dmp文件,可以得到上述dmp文件对应的进程崩溃信息(崩溃的原因、线程信息、寄存器信息等),示例如下(Linux环境):
./minidump_stackwalk b678e0b5-894b-4794-9ab3-fb5d6dda06a3.dmp > parsed_stacktrace.txt
  • minidump_stackwalk由breakpad项目源码编译得到,编译方法见项目仓库:breakpad仓库地址

  • 查看解析后的文件,以下示例列出部分内容:
Crash reason:  SIGSEGV /SEGV_MAPERR    表示导致进程crash的信号,此处示例为段错误
Crash address: 0x0
Process uptime: 12 seconds

Thread 0 (crashed)                     表示Thread 0发生crash
 0  libweb_engine.so + 0x2e0b340       0层调用栈,0x2e0b340为so偏移地址,可用来反编译解析crash源码(依赖unstripped so)
     x0 = 0x00000006a5719ff8    x1 = 0x000000019a5a28c0
     x2 = 0x0000000000020441    x3 = 0x00000000000001b6
     x4 = 0x0000000000000018    x5 = 0x0000000000008065
     x6 = 0x0000000000008065    x7 = 0x63ff686067666d60
     x8 = 0x0000000000000000    x9 = 0x5f129cf9e7bf008c
    x10 = 0x0000000000000001   x11 = 0x0000000000000000
    x12 = 0x000000069bfcc6d8   x13 = 0x0000000009a1746e
    x14 = 0x0000000000000000   x15 = 0x0000000000000000
    x16 = 0x0000000690df4850   x17 = 0x000000010c0d47f8
    x18 = 0x0000000000000000   x19 = 0x0000005eea827db8
    x20 = 0x0000005eea827c38   x21 = 0x00000006a56b1000
    x22 = 0x00000006a8b85020   x23 = 0x00000020002103c0
    x24 = 0x00000006a56b8a70   x25 = 0x0000000000000000
    x26 = 0x00000006a8b84e00   x27 = 0x0000000000000001
    x28 = 0x0000000000000000    fp = 0x0000005eea827c10
     lr = 0x000000069fa4b33c    sp = 0x0000005eea827c10
     pc = 0x000000069fa4b340
    Found by: given as instruction pointer in context
 1  libweb_engine.so + 0x2e0b338
     fp = 0x0000005eea827d80    lr = 0x000000069fa48d44
     sp = 0x0000005eea827c20    pc = 0x000000069fa4b33c
    Found by: previous frame's frame pointer
 2  libweb_engine.so + 0x2e08d40
     fp = 0x0000005eea827e50    lr = 0x00000006a385cef8
     sp = 0x0000005eea827d90    pc = 0x000000069fa48d44
    Found by: previous frame's frame pointer
 3  libweb_engine.so + 0x6c1cef4
     fp = 0x0000005eea828260    lr = 0x00000006a0f11298
     sp = 0x0000005eea827e60    pc = 0x00000006a385cef8
 ......
  • 使用llvm工具链解析crash源码位置,示例如下(Linux环境):
./llvm-addr2line -Cfpie libweb_engine.so 0x2e0b340

llvm-addr2line工具链位于sdk中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值