首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

HarmonyOS开发实战( Beta5版)Web组件开发性能提升指导

  • 25-03-03 07:02
  • 3636
  • 7078
blog.csdn.net

简介

开发者实现在应用中跳转显示网页需要分为两个方面:使用@ohos.web.webview提供Web控制能力;使用Web组件提供网页显示的能力。在实际应用中往往由于各种原因导致首次跳转Web网页或Web组件内跳转时出现白屏、卡顿等情况。本文介绍提升Web首页加载与Web网页间跳转速度的几种方法。

优化思路

用户在使用Web组件显示网页时往往会经历四个阶段:无反馈-->白屏-->网页渲染-->完全展示,系统会在各个阶段内分别进行WebView初始化、建立网络连接、接受数据与渲染页面等操作,如图一所示是WebView的启动阶段。

图一 Web组件显示页面的阶段

Web组件显示页面的阶段

要优化Web组件的首页加载性能,可以从图例标记的三个阶段来进行优化:

  1. 在WebView的初始化阶段:应用打开WebView的第一步是启动浏览器内核,而这段时间由于WebView还不存在,所有后续的步骤是完全阻塞的。因此可以考虑在应用中预先完成初始化WebView,以及在初始化的同时通过预先加载组件内核、完成网络请求等方法,使得WebView初始化不是完全的阻塞后续步骤,从而减小耗时。
  2. 在建立连接阶段:当开发者提前知道访问的网页地址,我们可以预先建立连接,进行DNS预解析。
  3. 在接收资源数据阶段:当开发者预先知道用户下一页会点击什么页面的时候,可以合理使用缓存和预加载,将该页面的资源提前下载到缓存中。

综上所述,开发者可以通过方法1和2来提升Web首页加载速度,在应用创建Ability的时候,在OnCreate阶段预先初始化内核。随后在onAppear阶段进行预解析DNS、预连接要加载的首页。
在网页跳转的场景,开发者也可以通过方法3,在onPageEnd阶段预加载下一个要访问的页面,提升Web网页间的跳转和显示速度,如图二所示。

图二 Web组件的生命周期回调函数

Web组件的生命周期回调函数

优化方法

提前初始化内核

原理介绍

当应用首次打开时,默认不会初始化浏览器内核,只有当创建WebView实例的时候,才会开始初始化浏览器内核。
为了能提前初始化WebView实例,@ohos.web.webview提供了initializeWebEngine方法。该方法实现在Web组件初始化之前,通过接口加载Web引擎的动态库文件,从而提前进行Web组件动态库的加载和Web内核主进程的初始化,最终以提高启动性能,减少白屏时间。

实践案例

【反例】

在未初始化Web内核前提下,启动加载Web页面

  1. import web_webview from '@ohos.web.webview';
  2. import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';
  3. @Entry
  4. @Component
  5. struct Index {
  6. controller: web_webview.WebviewController = new web_webview.WebviewController();
  7. build() {
  8. Column() {
  9. Web({ src: 'https://www.example.com/example.html', controller: this.controller })
  10. .fileAccess(true)
  11. }
  12. }
  13. }

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

【正例】

在页面开始加载时,调用initializeWebEngine()接口初始化Web内核,具体步骤如下:

  1. 初始化Web内核
  1. // EntryAbility.ets
  2. import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
  3. import { webview } from '@kit.ArkWeb';
  4. export default class EntryAbility extends UIAbility {
  5. onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
  6. webview.WebviewController.initializeWebEngine();
  7. }
  8. }
  1. 加载Web组件
  1. // xxx.ets
  2. import web_webview from '@ohos.web.webview';
  3. import { hiTraceMeter } from '@kit.PerformanceAnalysisKit';
  4. @Entry
  5. @Component
  6. struct Index {
  7. controller: web_webview.WebviewController = new web_webview.WebviewController();
  8. build() {
  9. Column() {
  10. Web({ src: 'https://www.example.com/example.html', controller: this.controller })
  11. .fileAccess(true)
  12. }
  13. }
  14. }

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

总结
页面加载方式耗时(局限不同设备和场景,数据仅供参考)说明
直接加载Web页面1264ms在加载Web组件时才初始化Web内核,增加启动时间
提前初始化Web内核1153ms加载页面时减少了Web内核初始化步骤,提高启动性能

预解析DNS、预连接

WebView在onAppear阶段进行预连接socket, 当Web内核真正发起请求的时候会直接复用预连接的socket,如果当前预解析还没完成,真正发起网络请求进行DNS解析的时候也会复用当前正在执行的DNS解析任务。同理即使预连接的socket还没有连接成功,Web内核也会复用当前正在连接中的socket,进而优化资源的加载过程。
@ohos.web.webview提供了prepareForPageLoad方法实现预连接url,在加载url之前调用此API,对url只进行DNS解析、socket建链操作,并不获取主资源子资源。
参数:

参数名类型说明
urlstring预连接的url。
preconnectableboolean是否进行预连接。如果preconnectable为true,则对url进行dns解析,socket建链预连接;如果preconnectable为false,则不做任何预连接操作。
numSocketsnumber要预连接的socket数。socket数目连接需要大于0,最多允许6个连接。

使用方法如下:

// 开启预连接需要先使用上述方法预加载WebView内核。
webview.WebviewController.initializeWebEngine();
// 启动预连接,连接地址为即将打开的网址。
webview.WebviewController.prepareForPageLoad("https://www.example.com", true, 2); 

预加载下一页

开发者可以在onPageEnd阶段进行预加载,当真正去加载下一个页面的时候,如果预加载已经成功,则相当于直接从缓存中加载页面资源,速度更快。一般来说能够准确预测到用户下一步要访问的页面的时候,可以进行预加载将要访问的页面,比如小说下一页, 浏览器在地址栏输入过程中识别到用户将要访问的页面等。
@ohos.web.webview提供prefetchPage方法实现在预测到将要加载的页面之前调用,提前下载页面所需的资源,包括主资源子资源,但不会执行网页JavaScript代码或呈现网页,以加快加载速度。
参数:

参数名类型说明
urlstring预加载的url。
additionalHeadersArrayurl的附加HTTP请求头。

使用方法如下:

  1. // ../src/main/ets/pages/WebBrowser.ets
  2. import webview from '@ohos.web.webview';
  3. // ...
  4. controller: webview.WebviewController = new webview.WebviewController();
  5. // ...
  6. Web({ src: 'https://www.example.com', controller: this.controller })
  7. .onPageEnd((event) => {
  8. // ...
  9. // 在确定即将跳转的页面时开启预加载
  10. this.controller.prefetchPage('https://www.example.com/nextpage');
  11. })
  12. Button('下一页')
  13. .onClick(() => {
  14. // ...
  15. // 跳转下一页
  16. this.controller.loadUrl('https://www.example.com/nextpage');
  17. })

预渲染优化

原理介绍

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

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

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

图三 预渲染优化原理图

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

实践案例
  1. 创建载体,并创建ArkWeb组件

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

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

    1. // 创建NodeController
    2. // common.ets
    3. import { UIContext } from '@kit.ArkUI';
    4. import { webview } from '@kit.ArkWeb';
    5. import { NodeController, BuilderNode, Size, FrameNode } from '@kit.ArkUI';
    6. // @Builder中为动态组件的具体组件内容
    7. // Data为入参封装类
    8. // 调用onActive,开启渲染
    9. @Builder
    10. function WebBuilder(data:Data) {
    11. Column() {
    12. Web({ src: data.url, controller: data.controller })
    13. .onPageBegin(() => {
    14. data.controller.onActive();
    15. })
    16. .width("100%")
    17. .height("100%")
    18. }
    19. }
    20. let wrap = wrapBuilder<Data[]>(WebBuilder);
    21. // 用于控制和反馈对应的NodeContianer上的节点的行为,需要与NodeContainer一起使用
    22. export class myNodeController extends NodeController {
    23. private rootnode: BuilderNode<Data[]> | null = null;
    24. // 必须要重写的方法,用于构建节点数、返回节点挂载在对应NodeContianer中
    25. // 在对应NodeContianer创建的时候调用、或者通过rebuild方法调用刷新
    26. makeNode(uiContext: UIContext): FrameNode | null {
    27. console.info(" uicontext is undifined : "+ (uiContext === undefined));
    28. if (this.rootnode != null) {
    29. // 返回FrameNode节点
    30. return this.rootnode.getFrameNode();
    31. }
    32. // 返回null控制动态组件脱离绑定节点
    33. return null;
    34. }
    35. // 当布局大小发生变化时进行回调
    36. aboutToResize(size: Size) {
    37. console.info("aboutToResize width : " + size.width + " height : " + size.height )
    38. }
    39. // 当controller对应的NodeContainer在Appear的时候进行回调
    40. aboutToAppear() {
    41. console.info("aboutToAppear")
    42. }
    43. // 当controller对应的NodeContainer在Disappear的时候进行回调
    44. aboutToDisappear() {
    45. console.info("aboutToDisappear")
    46. }
    47. // 此函数为自定义函数,可作为初始化函数使用
    48. // 通过UIContext初始化BuilderNode,再通过BuilderNode中的build接口初始化@Builder中的内容
    49. initWeb(url:string, uiContext:UIContext, control:WebviewController) {
    50. if(this.rootnode != null)
    51. {
    52. return;
    53. }
    54. // 创建节点,需要uiContext
    55. this.rootnode = new BuilderNode(uiContext)
    56. // 创建动态Web组件
    57. this.rootnode.build(wrap, { url:url, controller:control })
    58. }
    59. }
    60. // 创建Map保存所需要的NodeController
    61. let NodeMap:Map<string, myNodeController | undefined> = new Map();
    62. // 创建Map保存所需要的WebViewController
    63. let controllerMap:Map<string, WebviewController | undefined> = new Map();
    64. // 初始化需要UIContext 需在Ability获取
    65. export const createNWeb = (url: string, uiContext: UIContext) => {
    66. // 创建NodeController
    67. let baseNode = new myNodeController();
    68. let controller = new webview.WebviewController() ;
    69. // 初始化自定义Web组件
    70. baseNode.initWeb(url, uiContext, controller);
    71. controllerMap.set(url, controller)
    72. NodeMap.set(url, baseNode);
    73. }
    74. // 自定义获取NodeController接口
    75. export const getNWeb = (url : string) : myNodeController | undefined => {
    76. return NodeMap.get(url);
    77. }

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

    1. // 使用NodeController的Page页
    2. // Index.ets
    3. import {createNWeb, getNWeb} from "./common"
    4. @Entry
    5. @Component
    6. struct Index {
    7. build() {
    8. Row() {
    9. Column() {
    10. // NodeContainer用于与NodeController节点绑定,rebuild会触发makeNode
    11. // Page页通过NodeContainer接口绑定NodeController,实现动态组件页面显示
    12. NodeContainer(getNWeb("https://www.example.com"))
    13. .height("90%")
    14. .width("100%")
    15. }
    16. .width('100%')
    17. }
    18. .height('100%')
    19. }
    20. }

预取POST请求优化

原理介绍

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

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

注意事项:

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

【不推荐用法】

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

  1. // xxx.ets
  2. import { webview } from '@kit.ArkWeb';
  3. @Entry
  4. @Component
  5. struct WebComponent {
  6. webviewController: webview.WebviewController = new webview.WebviewController();
  7. build() {
  8. Column() {
  9. Web({ src: 'https://www.example.com/', controller: this.webviewController })
  10. }
  11. }
  12. }

【推荐用法】

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

  1. 通过initializeWebEngine()来提前初始化Web组件的内核,然后在初始化内核后调用prefetchResource()预获取将要加载页面中的POST请求。
  1. // EntryAbility.ets
  2. import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
  3. import { webview } from '@kit.ArkWeb';
  4. export default class EntryAbility extends UIAbility {
  5. onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  6. console.info('EntryAbility onCreate.');
  7. webview.WebviewController.initializeWebEngine();
  8. // 预获取时,需要将"https://www.example1.com/POST?e=f&g=h"替换成为真实要访问的网站地址
  9. webview.WebviewController.prefetchResource(
  10. {
  11. url: 'https://www.example.com/POST?e=f&g=h',
  12. method: 'POST',
  13. formData: 'a=x&b=y'
  14. },
  15. [{
  16. headerKey: 'c',
  17. headerValue: 'z'
  18. }],
  19. 'KeyX', 500
  20. );
  21. AppStorage.setOrCreate('abilityWant', want);
  22. console.info('EntryAbility onCreate done.');
  23. }
  24. }
  1. 通过Web组件,加载包含POST请求的Web页面
  1. // xxx.ets
  2. import { webview } from '@kit.ArkWeb';
  3. @Entry
  4. @Component
  5. struct WebComponent {
  6. webviewController: webview.WebviewController = new webview.WebviewController();
  7. build() {
  8. Column() {
  9. Web({ src: 'https://www.example.com/', controller: this.webviewController })
  10. .onPageEnd(() => {
  11. // 清除后续不再使用的预获取资源缓存
  12. webview.WebviewController.clearPrefetchedResource(['KeyX']);
  13. })
  14. }
  15. }
  16. }
  1. 在页面将要加载的JavaScript文件中,发起POST请求,设置请求响应头ArkWebPostCacheKey为对应预取时设置的cachekey值'KeyX'
  1. const xhr = new XMLHttpRequest();
  2. xhr.open('POST', 'https://www.example.com/POST?e=f&g=h', true);
  3. xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
  4. xhr.setRequestHeader('ArkWebPostCacheKey', 'KeyX');
  5. xhr.onload = function () {
  6. if (xhr.status >= 200 && xhr.status < 300) {
  7. console.info('成功', xhr.responseText);
  8. } else {
  9. console.error('请求失败');
  10. }
  11. }
  12. const formData = new FormData();
  13. formData.append('a', 'x');
  14. formData.append('b', 'y');
  15. xhr.send(formData);
场景二:加载包含POST请求的下一页

【不推荐用法】

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

  1. // xxx.ets
  2. import { webview } from '@kit.ArkWeb';
  3. @Entry
  4. @Component
  5. struct WebComponent {
  6. webviewController: webview.WebviewController = new webview.WebviewController();
  7. build() {
  8. Column() {
  9. // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
  10. Button('加载页面')
  11. .onClick(() => {
  12. // url请替换为真实地址
  13. this.controller.loadUrl('https://www.example1.com/');
  14. })
  15. Web({ src: 'https://www.example.com/', controller: this.webviewController })
  16. }
  17. }
  18. }

【推荐用法】

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

  1. 当前页面完成显示后,使用onPageEnd()对即将要加载页面中的POST请求进行预获取。
  1. // xxx.ets
  2. import { webview } from '@kit.ArkWeb';
  3. @Entry
  4. @Component
  5. struct WebComponent {
  6. webviewController: webview.WebviewController = new webview.WebviewController();
  7. build() {
  8. Column() {
  9. // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
  10. Button('加载页面')
  11. .onClick(() => {
  12. // url请替换为真实地址
  13. this.controller.loadUrl('https://www.example1.com/');
  14. })
  15. Web({ src: 'https://www.example.com/', controller: this.webviewController })
  16. .onPageEnd(() => {
  17. // 预获取时,需要将"https://www.example1.com/POST?e=f&g=h"替换成为真实要访问的网站地址
  18. webview.WebviewController.prefetchResource(
  19. {
  20. url: 'https://www.example1.com/POST?e=f&g=h',
  21. method: 'POST',
  22. formData: 'a=x&b=y'
  23. },
  24. [{
  25. headerKey: 'c',
  26. headerValue: 'z'
  27. }],
  28. 'KeyX', 500
  29. );
  30. })
  31. }
  32. }
  33. }
  1. 将要加载的页面中,js正式发起POST请求,设置请求响应头ArkWebPostCacheKey为对应预取时设置的cachekey值'KeyX'
  1. const xhr = new XMLHttpRequest();
  2. xhr.open('POST', 'https://www.example1.com/POST?e=f&g=h', true);
  3. xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
  4. xhr.setRequestHeader('ArkWebPostCacheKey', 'KeyX');
  5. xhr.onload = function () {
  6. if (xhr.status >= 200 && xhr.status < 300) {
  7. console.info('成功', xhr.responseText);
  8. } else {
  9. console.error('请求失败');
  10. }
  11. }
  12. const formData = new FormData();
  13. formData.append('a', 'x');
  14. formData.append('b', 'y');
  15. xhr.send(formData);

JSBridge优化

适用场景

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

img.png

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

img.png

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

案例实践

【反例】

使用ArkTS接口实现JSBridge通信。

应用侧代码:

  1. import { webview } from '@kit.ArkWeb';
  2. @Entry
  3. @Component
  4. struct WebComponent {
  5. webviewController: webview.WebviewController = new webview.WebviewController();
  6. aboutToAppear() {
  7. // 配置Web开启调试模式
  8. webview.WebviewController.setWebDebuggingAccess(true);
  9. }
  10. build() {
  11. Column() {
  12. Button('runJavaScript')
  13. .onClick(() => {
  14. console.info('现在时间是:'+new Date().getTime())
  15. // 前端页面函数无参时,将param删除。
  16. this.webviewController.runJavaScript('htmlTest(param)');
  17. })
  18. Button('runJavaScriptCodePassed')
  19. .onClick(() => {
  20. // 传递runJavaScript侧代码方法。
  21. this.webviewController.runJavaScript(`function changeColor(){document.getElementById('text').style.color = 'red'}`);
  22. })
  23. Web({ src: $rawfile('index.html'), controller: this.webviewController })
  24. }
  25. }
  26. }

前端页面代码:

  1. <!DOCTYPE html>
  2. <html>
  3. <body>
  4. <button type="button" onclick="callArkTS()">Click Me!</button>
  5. <h1 id="text">这是一个测试信息,默认字体为黑色,调用runJavaScript方法后字体为绿色,调用runJavaScriptCodePassed方法后字体为红色</h1>
  6. <script>
  7. // 调用有参函数时实现。
  8. var param = "param: JavaScript Hello World!";
  9. function htmlTest(param) {
  10. document.getElementById('text').style.color = 'green';
  11. document.getElementById('text').innerHTML = '现在时间:'+new Date().getTime()
  12. console.info(param);
  13. }
  14. // 调用无参函数时实现。
  15. function htmlTest() {
  16. document.getElementById('text').style.color = 'green';
  17. }
  18. // Click Me!触发前端页面callArkTS()函数执行JavaScript传递的代码。
  19. function callArkTS() {
  20. changeColor();
  21. }
  22. </script>
  23. </body>
  24. </html>

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

img.png

img.png

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

【正例】

使用NDK接口实现JSBridge通信。

应用侧代码:

  1. import testNapi from 'libentry.so';
  2. import { webview } from '@kit.ArkWeb';
  3. class testObj {
  4. test(): string {
  5. console.info('ArkUI Web Component');
  6. return "ArkUI Web Component";
  7. }
  8. toString(): void {
  9. console.info('Web Component toString');
  10. }
  11. }
  12. @Entry
  13. @Component
  14. struct Index {
  15. webTag: string = 'ArkWeb1';
  16. controller: webview.WebviewController = new webview.WebviewController(this.webTag);
  17. @State testObjtest: testObj = new testObj();
  18. aboutToAppear() {
  19. console.info("aboutToAppear")
  20. //初始化web ndk
  21. testNapi.nativeWebInit(this.webTag);
  22. }
  23. build() {
  24. Column() {
  25. Row() {
  26. Button('runJS hello')
  27. .fontSize(12)
  28. .onClick(() => {
  29. console.info('start:---->'+new Date().getTime());
  30. testNapi.runJavaScript(this.webTag, "runJSRetStr(\"" + "hello" + "\")");
  31. })
  32. }.height('20%')
  33. Row() {
  34. Web({ src: $rawfile('runJS.html'), controller: this.controller })
  35. .javaScriptAccess(true)
  36. .fileAccess(true)
  37. .onControllerAttached(() => {
  38. console.info(this.controller.getWebId());
  39. })
  40. }.height('80%')
  41. }
  42. }
  43. }

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

//注册对象及方法,发送脚本到H5执行后的回调,解析存储应用侧传过来的实例等代码逻辑这里不进行展示,开发者根据自身业务场景自行实现。

  1. // 发送JS脚本到H5侧执行
  2. static napi_value RunJavaScript(napi_env env, napi_callback_info info) {
  3. size_t argc = 2;
  4. napi_value args[2] = {nullptr};
  5. napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
  6. // 获取第一个参数 webTag
  7. size_t webTagSize = 0;
  8. napi_get_value_string_utf8(env, args[0], nullptr, 0, &webTagSize);
  9. char *webTagValue = new (std::nothrow) char[webTagSize + 1];
  10. size_t webTagLength = 0;
  11. napi_get_value_string_utf8(env, args[0], webTagValue, webTagSize + 1, &webTagLength);
  12. OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "ndk OH_NativeArkWeb_RunJavaScript webTag:%{public}s",
  13. webTagValue);
  14. // 获取第二个参数 jsCode
  15. size_t bufferSize = 0;
  16. napi_get_value_string_utf8(env, args[1], nullptr, 0, &bufferSize);
  17. char *jsCode = new (std::nothrow) char[bufferSize + 1];
  18. size_t byteLength = 0;
  19. napi_get_value_string_utf8(env, args[1], jsCode, bufferSize + 1, &byteLength);
  20. OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb",
  21. "ndk OH_NativeArkWeb_RunJavaScript jsCode len:%{public}zu", strlen(jsCode));
  22. // 构造runJS执行的结构体
  23. ArkWeb_JavaScriptObject object = {(uint8_t *)jsCode, bufferSize, &JSBridgeObject::StaticRunJavaScriptCallback,
  24. static_cast<void *>(jsbridge_object_ptr->GetWeakPtr())};
  25. controller->runJavaScript(webTagValue, &object);
  26. return nullptr;
  27. }
  28. EXTERN_C_START
  29. static napi_value Init(napi_env env, napi_value exports) {
  30. napi_property_descriptor desc[] = {
  31. {"nativeWebInit", nullptr, NativeWebInit, nullptr, nullptr, nullptr, napi_default, nullptr},
  32. {"runJavaScript", nullptr, RunJavaScript, nullptr, nullptr, nullptr, napi_default, nullptr},
  33. };
  34. napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
  35. return exports;
  36. }
  37. EXTERN_C_END
  38. static napi_module demoModule = {
  39. .nm_version = 1,
  40. .nm_flags = 0,
  41. .nm_filename = nullptr,
  42. .nm_register_func = Init,
  43. .nm_modname = "entry",
  44. .nm_priv = ((void *)0),
  45. .reserved = {0},
  46. };
  47. extern "C" __attribute__((constructor)) void RegisterEntryModule(void) { napi_module_register(&demoModule); }

Native侧业务代码entry/src/main/cpp/jsbridge_object.h、entry/src/main/cpp/jsbridge_object.cpp 详见应用侧与前端页面的相互调用(C/C++)

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

  1. <!DOCTYPE html>
  2. <html lang="en-gb">
  3. <head>
  4. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  5. <title>run javascript demo</title>
  6. </head>
  7. <body>
  8. <h1>run JavaScript Ext demo</h1>
  9. <p id="webDemo"></p>
  10. <br>
  11. <button type="button" style="height:30px;width:200px" onclick="testNdkProxyObjMethod1()">test ndk method1 ! </button>
  12. <br>
  13. <br>
  14. <button type="button" style="height:30px;width:200px" onclick="testNdkProxyObjMethod2()">test ndk method2 ! </button>
  15. <br>
  16. </body>
  17. <script type="text/javascript">
  18. function testNdkProxyObjMethod1() {
  19. //校验ndk方法是否已经注册到window
  20. if (window.ndkProxy == undefined) {
  21. document.getElementById("webDemo").innerHTML = "ndkProxy undefined"
  22. return "objName undefined"
  23. }
  24. if (window.ndkProxy.method1 == undefined) {
  25. document.getElementById("webDemo").innerHTML = "ndkProxy method1 undefined"
  26. return "objName test undefined"
  27. }
  28. if (window.ndkProxy.method2 == undefined) {
  29. document.getElementById("webDemo").innerHTML = "ndkProxy method2 undefined"
  30. return "objName test undefined"
  31. }
  32. //调用ndk注册到window的method1方法,并将结果回显到p标签
  33. var retStr = window.ndkProxy.method1("hello", "world", [1.2, -3.4, 123.456], ["Saab", "Volvo", "BMW", undefined], 1.23456, 123789, true, false, 0, undefined);
  34. document.getElementById("webDemo").innerHTML = "ndkProxy and method1 is ok, " + retStr;
  35. }
  36. function testNdkProxyObjMethod2() {
  37. //校验ndk方法是否已经注册到window
  38. if (window.ndkProxy == undefined) {
  39. document.getElementById("webDemo").innerHTML = "ndkProxy undefined"
  40. return "objName undefined"
  41. }
  42. if (window.ndkProxy.method1 == undefined) {
  43. document.getElementById("webDemo").innerHTML = "ndkProxy method1 undefined"
  44. return "objName test undefined"
  45. }
  46. if (window.ndkProxy.method2 == undefined) {
  47. document.getElementById("webDemo").innerHTML = "ndkProxy method2 undefined"
  48. return "objName test undefined"
  49. }
  50. var student = {
  51. name:"zhang",
  52. sex:"man",
  53. age:25
  54. };
  55. var cars = [student, 456, false, 4.567];
  56. let params = "[\"{\\\"scope\\\"]";
  57. //调用ndk注册到window的method2方法,并将结果回显到p标签
  58. var retStr = window.ndkProxy.method2("hello", "world", false, cars, params);
  59. document.getElementById("webDemo").innerHTML = "ndkProxy and method2 is ok, " + retStr;
  60. }
  61. function runJSRetStr(data) {
  62. const d = new Date();
  63. let time = d.getTime();
  64. document.getElementById("webDemo").innerHTML = new Date().getTime()
  65. return JSON.stringify(time)
  66. }

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

img.png

img.png

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

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

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

1.应用使用ArkTS语言开发,推荐使用ArkWeb在ArkTS提供的runJavaScriptExt接口实现应用侧至前端页面的通信,同时使用registerJavaScriptProxy实现前端页面至应用侧的通信。

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

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

异步JSBridge调用

原理介绍

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

实践案例

使用ArkTS接口实现JSBridge通信

【案例一】

步骤1.只注册同步函数

  1. import webview from '@ohos.web.webview';
  2. // 定义ETS侧对象及函数
  3. class TestObj {
  4. test(testStr:string): string {
  5. let start = Date.now();
  6. // 模拟耗时操作
  7. for(let i = 0; i < 500000; i++) {}
  8. let end = Date.now();
  9. console.info('objName.test start: ' + start);
  10. return 'objName.test Sync function took ' + (end - start) + 'ms';
  11. }
  12. asyncTestBool(testBol:boolean): Promise<string> {
  13. return new Promise((resolve, reject) => {
  14. let start = Date.now();
  15. // 模拟耗时操作(异步)
  16. setTimeout(() => {
  17. for(let i = 0; i < 500000; i++) {}
  18. let end = Date.now();
  19. console.info('objAsyncName.asyncTestBool start: ' + start);
  20. resolve('objName.asyncTestBool Async function took ' + (end - start) + 'ms');
  21. }, 0); // 使用0毫秒延迟来模拟立即开始的异步操作
  22. });
  23. }
  24. }
  25. class WebObj {
  26. webTest(): string {
  27. let start = Date.now();
  28. // 模拟耗时操作
  29. for(let i = 0; i < 500000; i++) {}
  30. let end = Date.now();
  31. console.info('objTestName.webTest start: ' + start);
  32. return 'objTestName.webTest Sync function took ' + (end - start) + 'ms';
  33. }
  34. webString(): string {
  35. let start = Date.now();
  36. // 模拟耗时操作
  37. for(let i = 0; i < 500000; i++) {}
  38. let end = Date.now();
  39. console.info('objTestName.webString start: ' + start);
  40. return 'objTestName.webString Sync function took ' + (end - start) + 'ms'
  41. }
  42. }
  43. class AsyncObj {
  44. asyncTest(): Promise<string> {
  45. return new Promise((resolve, reject) => {
  46. let start = Date.now();
  47. // 模拟耗时操作(异步)
  48. setTimeout(() => {
  49. for (let i = 0; i < 500000; i++) {
  50. }
  51. let end = Date.now();
  52. console.info('objAsyncName.asyncTest start: ' + start);
  53. resolve('objAsyncName.asyncTest Async function took ' + (end - start) + 'ms');
  54. }, 0); // 使用0毫秒延迟来模拟立即开始的异步操作
  55. });
  56. }
  57. asyncString(testStr:string): Promise<string> {
  58. return new Promise((resolve, reject) => {
  59. let start = Date.now();
  60. // 模拟耗时操作(异步)
  61. setTimeout(() => {
  62. for (let i = 0; i < 500000; i++) {
  63. }
  64. let end = Date.now();
  65. console.info('objAsyncName.asyncString start: ' + start);
  66. resolve('objAsyncName.asyncString Async function took ' + (end - start) + 'ms');
  67. }, 0); // 使用0毫秒延迟来模拟立即开始的异步操作
  68. });
  69. }
  70. }
  71. @Entry
  72. @Component
  73. struct Index {
  74. controller: webview.WebviewController = new webview.WebviewController();
  75. @State testObjtest: TestObj = new TestObj();
  76. @State webTestObj: WebObj = new WebObj();
  77. @State asyncTestObj: AsyncObj = new AsyncObj();
  78. build() {
  79. Column() {
  80. Button('refresh')
  81. .onClick(()=>{
  82. try{
  83. this.controller.refresh();
  84. } catch (error) {
  85. console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`)
  86. }
  87. })
  88. Button('Register JavaScript To Window')
  89. .onClick(()=>{
  90. try {
  91. //只注册同步函数
  92. this.controller.registerJavaScriptProxy(this.webTestObj,"objTestName",["webTest","webString"]);
  93. } catch (error) {
  94. console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`)
  95. }
  96. })
  97. Web({src: $rawfile('index.html'),controller: this.controller}).javaScriptAccess(true)
  98. }
  99. }
  100. }

步骤2.H5侧调用JSBridge函数

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Document</title>
  7. </head>
  8. <body>
  9. <button type="button" onclick="htmlTest()"> Click Me!</button>
  10. <p id="demo"></p>
  11. <p id="webDemo"></p>
  12. <p id="asyncDemo"></p>
  13. </body>
  14. <script type="text/javascript">
  15. async function htmlTest() {
  16. document.getElementById("demo").innerHTML = '测试开始:' + new Date().getTime() + '\n';
  17. const time1 = new Date().getTime()
  18. objTestName.webString();
  19. const time2 = new Date().getTime()
  20. objAsyncName.asyncString()
  21. const time3 = new Date().getTime()
  22. objName.asyncTestBool()
  23. const time4 = new Date().getTime()
  24. objName.test();
  25. const time5 = new Date().getTime()
  26. objTestName.webTest();
  27. const time6 = new Date().getTime()
  28. objAsyncName.asyncTest()
  29. const time7 = new Date().getTime()
  30. const result = [
  31. 'objTestName.webString()耗时:'+ (time2 - time1),
  32. 'objAsyncName.asyncString()耗时:'+ (time3 - time2),
  33. 'objName.asyncTestBool()耗时:'+ (time4 - time3),
  34. 'objName.test()耗时:'+ (time5 - time4),
  35. 'objTestName.webTest()耗时:'+ (time6 - time5),
  36. 'objAsyncName.asyncTest()耗时:'+ (time7 - time6),
  37. ]
  38. document.getElementById("demo").innerHTML = document.getElementById("demo").innerHTML + '\n' + result.join('\n')
  39. }
  40. </script>
  41. </html>

【案例二】

步骤1.使用registerJavaScriptProxy或javaScriptProxy注册异步函数或异步同步共存

  1. //registerJavaScriptProxy方式注册
  2. Button('refresh')
  3. .onClick(()=>{
  4. try{
  5. this.controller.refresh();
  6. } catch (error) {
  7. console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`)
  8. }
  9. })
  10. Button('Register JavaScript To Window')
  11. .onClick(()=>{
  12. try {
  13. //调用注册接口对象及成员函数,其中同步函数列表必填,空白则需要用[]占位;异步函数列表非必填
  14. //同步、异步函数都注册
  15. this.controller.registerJavaScriptProxy(this.testObjtest,"objName",["test"],["asyncTestBool"]);
  16. //只注册异步函数,同步函数列表处留空
  17. this.controller.registerJavaScriptProxy(this.asyncTestObj,"objAsyncName",[],["asyncTest","asyncString"]);
  18. } catch (error) {
  19. console.error(`ErrorCode:${(error as BusinessError).code},Message:${(error as BusinessError).message}`)
  20. }
  21. })
  22. Web({src: $rawfile('index.html'),controller: this.controller}).javaScriptAccess(true)
  23. //javaScriptProxy方式注册
  24. //javaScriptProxy只支持注册一个对象,若需要注册多个对象请使用registerJavaScriptProxy
  25. Web({src: $rawfile('index.html'),controller: this.controller})
  26. .javaScriptAccess(true)
  27. .javaScriptProxy({
  28. object: this.testObjtest,
  29. name:"objName",
  30. methodList: ["test","toString"],
  31. //指定异步函数列表
  32. asyncMethodList: ["test","toString"],
  33. controller: this.controller
  34. })

步骤2.H5侧调用JSBridge函数与反例中一致

总结

img.png

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

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

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

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

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

  1. // 定义JSBridge函数
  2. static void ProxyMethod1(const char* webTag, void* userData) {
  3. OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "Method1 webTag :%{public}s",webTag);
  4. }
  5. static void ProxyMethod2(const char* webTag, void* userData) {
  6. OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "Method2 webTag :%{public}s",webTag);
  7. }
  8. static void ProxyMethod3(const char* webTag, void* userData) {
  9. OH_LOG_Print(LOG_APP, LOG_INFO, LOG_PRINT_DOMAIN, "ArkWeb", "Method3 webTag :%{public}s",webTag);
  10. }
  11. void RegisterCallback(const char *webTag) {
  12. int myUserData = 100;
  13. //创建函数方法结构体
  14. ArkWeb_ProxyMethod m1 = {
  15. .methodName = "method1",
  16. .callback = ProxyMethod1,
  17. .userData = (void *)&myUserData
  18. };
  19. ArkWeb_ProxyMethod m2 = {
  20. .methodName = "method2",
  21. .callback = ProxyMethod2,
  22. .userData = (void *)&myUserData
  23. };
  24. ArkWeb_ProxyMethod m3 = {
  25. .methodName = "method3",
  26. .callback = ProxyMethod3,
  27. .userData = (void *)&myUserData
  28. };
  29. ArkWeb_ProxyMethod methodList[2] = {m1,m2};
  30. //创建JSBridge对象结构体
  31. ArkWeb_ProxyObject obj = {
  32. .objName = "ndkProxy",
  33. .methodList = methodList,
  34. .size = 2,
  35. };
  36. // 获取ArkWeb_Controller API结构体
  37. ArkWeb_AnyNativeAPI* apis = OH_ArkWeb_GetNativeAPI(ArkWeb_NativeAPIVariantKind::ARKWEB_NATIVE_CONTROLLER);
  38. ArkWeb_ControllerAPI* ctrlApi = reinterpret_cast<ArkWeb_ControllerAPI*>(apis);
  39. // 调用注册接口,注册函数
  40. ctrlApi->registerJavaScriptProxy(webTag, &obj);
  41. ArkWeb_ProxyMethod asyncMethodList[1] = {m3};
  42. ArkWeb_ProxyObject obj2 = {
  43. .objName = "ndkProxy",
  44. .methodList = asyncMethodList,
  45. .size = 1,
  46. };
  47. ctrlApi->registerAsyncJavaScriptProxy(webTag, &obj2)
  48. }

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

原理介绍

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

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

注意事项:

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

【不推荐用法】

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

  1. import web_webview from '@ohos.web.webview';
  2. @Entry
  3. @Component
  4. struct Index {
  5. controller: web_webview.WebviewController = new web_webview.WebviewController();
  6. build() {
  7. Column() {
  8. // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
  9. Button('加载页面')
  10. .onClick(() => {
  11. // url请替换为真实地址
  12. this.controller.loadUrl('https://www.example.com/b.html');
  13. })
  14. Web({ src: 'https://www.example.com/a.html', controller: this.controller })
  15. .fileAccess(true)
  16. .onPageBegin((event) => {
  17. console.info(`load page begin: ${event?.url}`);
  18. })
  19. .onPageEnd((event) => {
  20. console.info(`load page end: ${event?.url}`);
  21. })
  22. }
  23. }
  24. }

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

【推荐用法】

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

  1. 配置预编译的JavaScript文件信息
  1. import { webview } from '@kit.ArkWeb';
  2. interface Config {
  3. url: string,
  4. localPath: string, // 本地资源路径
  5. options: webview.CacheOptions
  6. }
  7. @Entry
  8. @Component
  9. struct Index {
  10. // 配置预编译的JavaScript文件信息
  11. configs: Array<Config> = [
  12. {
  13. url: 'https://www/example.com/example.js',
  14. localPath: 'example.js',
  15. options: {
  16. responseHeaders: [
  17. { headerKey: 'E-Tag', headerValue: 'aWO42N9P9dG/5xqYQCxsx+vDOoU=' },
  18. { headerKey: 'Last-Modified', headerValue: 'Web, 21 Mar 2024 10:38:41 GMT' }
  19. ]
  20. }
  21. }
  22. ]
  23. // ...
  24. }
  1. 读取配置,进行预编译
  1. Web({ src: 'https://www.example.com/a.html', controller: this.controller })
  2. .onControllerAttached(async () => {
  3. // 读取配置,进行预编译
  4. for (const config of this.configs) {
  5. let content = await getContext().resourceManager.getRawFileContentSync(config.localPath);
  6. try {
  7. this.controller.precompileJavaScript(config.url, content, config.options)
  8. .then((errCode: number) => {
  9. console.info('precompile successfully!' );
  10. }).catch((errCode: number) => {
  11. console.error('precompile failed.' + errCode);
  12. })
  13. } catch (err) {
  14. console.error('precompile failed!.' + err.code + err.message);
  15. }
  16. }
  17. })

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

说明

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

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

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

原理介绍

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

  1. 开发者首先需要在Web组件运行前,向Web组件注册自定义协议。

  2. 其次需要拦截自定义协议的JavaScript,设置ResponseData和ResponseDataID,ResponseData为JavaScript内容,ResponseDataID用于区分JavaScript内容是否发生变更。若JavaScript内容变更,ResponseDataID需要一起变更。

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

【不推荐用法】

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

  1. // xxx.ets
  2. import { webview } from '@kit.ArkWeb';
  3. import { BusinessError } from '@kit.BasicServicesKit';
  4. @Entry
  5. @Component
  6. struct Index {
  7. controller: webview.WebviewController = new webview.WebviewController();
  8. // 创建scheme对象,isCodeCacheSupported为false时不支持自定义协议的JavaScript生成字节码缓存
  9. scheme: webview.WebCustomScheme = { schemeName: 'scheme', isSupportCORS: true, isSupportFetch: true, isCodeCacheSupported: false };
  10. // 请求数据
  11. @State jsData: string = 'xxx';
  12. aboutToAppear(): void {
  13. try {
  14. webview.WebviewController.customizeSchemes([this.scheme]);
  15. } catch (error) {
  16. const e: BusinessError = error as BusinessError;
  17. console.error(`ErrorCode: ${e.code}, Message: ${e.message}`);
  18. }
  19. }
  20. build() {
  21. Column({ space: 10 }) {
  22. Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }) {
  23. Web({
  24. // 需将'https://www.example.com/'替换为真是的包含自定义协议的JavaScript的Web页面地址
  25. src: 'https://www.example.com/',
  26. controller: this.controller
  27. })
  28. .fileAccess(true)
  29. .javaScriptAccess(true)
  30. .onInterceptRequest(event => {
  31. const responseResource: WebResourceResponse = new WebResourceResponse();
  32. // 拦截页面请求
  33. if (event?.request.getRequestUrl() === 'scheme1://www.example.com/test.js') {
  34. responseResource.setResponseHeader([
  35. {
  36. headerKey: 'ResponseDataId',
  37. // 格式:不超过13位的纯数字。JS识别码,JS有更新时必须更新该字段
  38. headerValue: '0000000000001'
  39. }
  40. ]);
  41. responseResource.setResponseData(this.jsData);
  42. responseResource.setResponseEncoding('utf-8');
  43. responseResource.setResponseMimeType('application/javascript');
  44. responseResource.setResponseCode(200);
  45. responseResource.setReasonMessage('OK');
  46. return responseResource;
  47. }
  48. return null;
  49. })
  50. }
  51. }
  52. }
  53. }

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

【推荐用法】

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

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

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

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

说明 若JS内容变更,ResponseDataID需要一起变更。

  1. // xxx.ets
  2. Web({
  3. // 需将'https://www.example.com/'替换为真是的包含自定义协议的JavaScript的Web页面地址
  4. src: 'https://www.example.com/',
  5. controller: this.controller
  6. })
  7. .fileAccess(true)
  8. .javaScriptAccess(true)
  9. .onInterceptRequest(event => {
  10. const responseResource: WebResourceResponse = new WebResourceResponse();
  11. // 拦截页面请求
  12. if (event?.request.getRequestUrl() === 'scheme1://www.example.com/test.js') {
  13. responseResource.setResponseHeader([
  14. {
  15. headerKey: 'ResponseDataId',
  16. // 格式:不超过13位的纯数字。JS识别码,JS有更新时必须更新该字段
  17. headerValue: '0000000000001'
  18. }
  19. ]);
  20. responseResource.setResponseData(this.jsData2);
  21. responseResource.setResponseEncoding('utf-8');
  22. responseResource.setResponseMimeType('application/javascript');
  23. responseResource.setResponseCode(200);
  24. responseResource.setReasonMessage('OK');
  25. return responseResource;
  26. }
  27. return null;
  28. })

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

场景二 调用Native接口,int32_t OH_ArkWeb_RegisterCustomSchemes(const char * scheme, int32_t option)

【不推荐用法】

通过网络拦截接口对Web组件发出的请求进行拦截,Demo工程构建请参考拦截Web组件发起的网络请求

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

【推荐用法】

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

  1. 注册三方协议配置时,传入ARKWEB_SCHEME_OPTION_CODE_CACHE_ENABLED参数。
  1. // 注册三方协议的配置,需要在Web内核初始化之前调用,否则会注册失败。
  2. static napi_value RegisterCustomSchemes(napi_env env, napi_callback_info info)
  3. {
  4. OH_LOG_INFO(LOG_APP, "register custom schemes");
  5. OH_ArkWeb_RegisterCustomSchemes("custom", ARKWEB_SCHEME_OPTION_STANDARD | ARKWEB_SCHEME_OPTION_CORS_ENABLED | ARKWEB_SCHEME_OPTION_CODE_CACHE_ENABLED);
  6. return nullptr;
  7. }
  1. 设置ResponsesDataId。
  1. // 在worker线程中读取rawfile,并通过ResourceHandler返回给Web内核
  2. void RawfileRequest::ReadRawfileDataOnWorkerThread() {
  3. // ...
  4. if ('test-cc.js' == rawfilePath()) {
  5. OH_ArkWebResponse_SetHeaderByName(response(), "ResponseDataID", "0000000000001", true);
  6. }
  7. OH_ArkWebResponse_SetCharset(response(), "UTF-8");
  8. }
  1. 注册三方协议的配置,设置SchemeHandler。
  1. // EntryAbility.ets
  2. import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
  3. import { webview } from '@kit.ArkWeb';
  4. import { window } from '@kit.ArkUI';
  5. import testNapi from 'libentry.so';
  6. export default class EntryAbility extends UIAbility {
  7. onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  8. // 注册三方协议的配置
  9. testNapi.registerCustomSchemes();
  10. // 初始化Web组件内核,该操作会初始化Brownser进程以及创建BrownserContext
  11. webview.WebviewController.initializeWebEngine();
  12. // 设置SchemeHandler
  13. testNapi.setSchemeHandler();
  14. }
  15. // ...
  16. }

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

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

离线资源免拦截注入

原理介绍

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

注意事项:

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

【不推荐用法】

直接加载Web页面

  1. import webview from '@ohos.web.webview';
  2. @Entry
  3. @Component
  4. struct Index {
  5. controller: webview.WebviewController = new webview.WebviewController();
  6. build() {
  7. Column() {
  8. // 在适当的时机加载业务用Web组件,本例以Button点击触发为例
  9. Button('加载页面')
  10. .onClick(() => {
  11. this.controller.loadUrl('https://www.example.com/b.html');
  12. })
  13. Web({ src: 'https://www.example.com/a.html', controller: this.controller })
  14. .fileAccess(true)
  15. }
  16. }
  17. }

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

【推荐用法】

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

  1. 创建资源配置
  1. interface ResourceConfig {
  2. urlList: Array<string>;
  3. type: webview.OfflineResourceType;
  4. responseHeaders: Array<Header>;
  5. localPath: string; // 本地资源存放在rawfile目录下的路径
  6. }
  7. const configs: Array<ResourceConfig> = [
  8. {
  9. localPath: 'example.png',
  10. urlList: [
  11. // 多url场景,第一个url作为资源的源
  12. 'https://www.example.com/',
  13. 'https://www.example.com/path1/example.png',
  14. 'https://www.example.com/path2/example.png'
  15. ],
  16. type: webview.OfflineResourceType.IMAGE,
  17. responseHeaders: [
  18. { headerKey: 'Cache-Control', headerValue: 'max-age=1000' },
  19. { headerKey: 'Content-Type', headerValue: 'image/png' }
  20. ]
  21. },
  22. {
  23. localPath: 'example.js',
  24. urlList: [
  25. // 仅提供一个url,这个url既作为资源的源,也作为资源的网络请求地址
  26. 'https://www.example.com/example.js'
  27. ],
  28. type: webview.OfflineResourceType.CLASSIC_JS,
  29. responseHeaders: [
  30. // 以<script crossorigin='anonymous'/>方式使用,提供额外的响应头
  31. { headerKey: 'Cross-Origin', headerValue: 'anonymous' }
  32. ]
  33. }
  34. ];
  1. 读取配置,注入资源
  1. Web({ src: 'https://www.example.com/a.html', controller: this.controller })
  2. .onControllerAttached(async () => {
  3. try {
  4. const resourceMapArr: Array<webview.OfflineResourceMap> = [];
  5. // 读取配置,从rawfile目录中读取文件内容
  6. for (const config of this.configs) {
  7. const buf: Uint8Array = await getContext().resourceManager.getRawFileContentSync(config.localPath);
  8. resourceMapArr.push({
  9. urlList: config.urlList,
  10. resource: buf,
  11. responseHeaders: config.responseHeaders,
  12. type: config.type
  13. });
  14. }
  15. // 注入资源
  16. this.controller.injectOfflineResources(resourceMapArr);
  17. } catch (err) {
  18. console.error('error: ' + err.code + ' ' + err.message);
  19. }
  20. })

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

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

资源拦截替换加速

原理介绍

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

说明

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

实践案例

【不推荐用法】

使用字符串格式的数据做拦截替换

  1. import webview from '@ohos.web.webview';
  2. @Entry
  3. @Component
  4. struct Index {
  5. controller: webview.WebviewController = new webview.WebviewController();
  6. responseResource: WebResourceResponse = new WebResourceResponse();
  7. // 这里是string格式数据
  8. resourceStr: string = 'xxxxxxxxxxxxxxx';
  9. build() {
  10. Column() {
  11. Web({ src: 'https:www.example.com/test.html', controller: this.controller })
  12. .onInterceptRequest(event => {
  13. if (event) {
  14. if (!event.request.getRequestUrl().startsWith('https://www.example.com/')) {
  15. return null;
  16. }
  17. }
  18. // 使用string格式的数据做拦截替换
  19. this.responseResource.setResponseData(this.resourceStr);
  20. this.responseResource.setResponseEncoding('utf-8');
  21. this.responseResource.setResponseMimeType('text/json');
  22. this.responseResource.setResponseCode(200);
  23. this.responseResource.setReasonMessage('OK');
  24. this.responseResource.setResponseHeader([{ headerKey: 'Access-Control-Allow-Origin', headerValue: '*' }]);
  25. return this.responseResource;
  26. })
  27. }
  28. }
  29. }

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

【推荐用法】

使用ArrayBuffer格式的数据做拦截替换

  1. import webview from '@ohos.web.webview';
  2. @Entry
  3. @Component
  4. struct Index {
  5. controller: webview.WebviewController = new webview.WebviewController();
  6. responseResource: WebResourceResponse = new WebResourceResponse();
  7. // 这里是ArrayBuffer格式数据
  8. buffer: ArrayBuffer = new ArrayBuffer(10);
  9. build() {
  10. Column() {
  11. Web({ src: 'https:www.example.com/test.html', controller: this.controller })
  12. .onInterceptRequest(event => {
  13. if (event) {
  14. if (!event.request.getRequestUrl().startsWith('https://www.example.com/')) {
  15. return null;
  16. }
  17. }
  18. // 使用ArrayBuffer格式的数据做拦截替换
  19. this.responseResource.setResponseData(this.buffer);
  20. this.responseResource.setResponseEncoding('utf-8');
  21. this.responseResource.setResponseMimeType('text/json');
  22. this.responseResource.setResponseCode(200);
  23. this.responseResource.setReasonMessage('OK');
  24. this.responseResource.setResponseHeader([{ headerKey: 'Access-Control-Allow-Origin', headerValue: '*' }]);
  25. return this.responseResource;
  26. })
  27. }
  28. }
  29. }

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

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

预加载优化滑动白块

应用在加载图片资源时,需要先发起请求,然后解析渲染到屏幕上。在列表滑动过程中,如果等屏幕可视区域出现新图片时才开始发起请求,会因上述加载资源的步骤出现时间差,导致列表中图片出现白块问题,在网络情况不良或应用渲染图片阻塞时,这种情况会更加严重。本文使用预加载策略,在滑动前预先加载可视区域外的图片资源,解决可视区域白块问题,提高用户使用体验。

原理介绍

滑动白块的产生主要来源于页面滑动场景组件可见和组件上屏刷新之间的时间差,在这两个时间点间,由于网络图片未加载完成,该区域显示的是默认图片即图片白块。图片组件从可见到上屏刷新之间的耗时主要是由图片资源网络请求和解码渲染两部分组成,在这段时间内页面滑动距离是滑动速度(px/ms)*(下载耗时+解码耗时)(ms),因此只要设置预加载的高度大于滑动距离,就可以保证页面基本无白块。开发者可根据预加载高度(px)>滑动速度(px/ms)*(下载耗时+解码耗时)(ms)这一计算公式对应用进行调整,计算出Web页面在设备视窗外需要预加载的图片个数,即可视窗口根元素超过屏幕的高度。

实践案例

【不推荐用法】

常规案例使用懒加载的逻辑加载图片,图片组件进入可视区域后再执行加载,滑动过程中列表有大量图片未加载完成产生的白块。

img

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <title>Image List</title>
  5. </head>
  6. <body>
  7. <ul>
  8. <li><img src="default.jpg" data-src="photo1.jpg" alt="Photo 1"></li>
  9. <li><img src="default.jpg" data-src="photo2.jpg" alt="Photo 2"></li>
  10. <li><img src="default.jpg" data-src="photo3.jpg" alt="Photo 3"></li>
  11. <li><img src="default.jpg" data-src="photo4.jpg" alt="Photo 4"></li>
  12. <li><img src="default.jpg" data-src="photo5.jpg" alt="Photo 5"></li>
  13. <!-- 添加更多的图片只需要复制并修改src和alt属性即可 -->
  14. </ul>
  15. </body>
  16. <script>
  17. window.onload = function(){
  18. // 可视窗口作为根元素,不进行扩展
  19. const options = {root:document,rootMargin:'0% 0% 0% 0%'}
  20. // 创建一个IntersectionObserver实例
  21. const observer = new IntersectionObserver(function(entries,observer){
  22. entries.forEach(function(entry){
  23. // 检查图片是否进入可视区域
  24. if(entry.isIntersecting){
  25. const image = entry.target;
  26. // 将数据源的src赋值给img的src
  27. image.src = image.dataset.src;
  28. // 停止观察该图片
  29. observer.unobserve(image);
  30. }
  31. })
  32. },options);
  33. document.querySelectorAll('img').forEach(img => { observer.observe(img) });
  34. }
  35. </script>
  36. </html>

img

【推荐用法】

根据上方公式,优化案例设定在400mm/s的速度滑动屏幕,此时可计算应用需预加载0.5个屏幕高度的图片。将可视区域作为根元素,使用rootMargin设置可视窗口向下拓展50%,即0.5个屏幕高度。当图片元素进入可视窗口时,会将标签的data-src属性中保存的图片地址赋值给src属性,从而实现图片的预加载。应用会查询页面上所有具有data-src属性的标签,并开始观察这些图片。当某张图片进入已拓展高度的可视窗口时,就会执行相应的加载操作,实现页面预渲染更多图片,解决滑动白块问题。

  1. // html结构与上方常规案例相同
  2. // 可视区域作为根元素,向下扩展50%的margin长度
  3. const options = {root:document,rootMargin:'0% 0% 50% 0%'};
  4. // 创建IntersectionObserver实例
  5. const observer = new IntersectionObserver(function(entries,observer){
  6. // ...
  7. },options);
  8. document.querySelectorAll('img').forEach(img => {observer.observe(img)});

img

img

总结
图片加载方式说明
常规加载(不推荐用法)常规案例在列表滑动过程中,由于图片加载未及时导致出现大量白块,影响用户体验。
预加载(推荐用法)优化案例在拓展0.5个屏幕高度的可视窗口后,滑动时无明显白块,用户体验提升。

开发者可使用公式,根据设备屏幕高度和设置滑动屏幕速度预估值,计算出视窗根元素需要扩展的高度,解决滑动白块问题。

性能分析

场景示例

构建通过点击按钮跳转Web网页和在网页内跳转页面的场景,在点击按钮触发跳转事件、Web组件触发OnPageEnd事件处使用Hilog打点记录时间戳。

反例

入口页通过router实现跳转

  1. // ../src/main/ets/pages/WebUninitialized.ets
  2. // ...
  3. Button('进入网页')
  4. .onClick(() => {
  5. hilog.info(0x0001, "WebPerformance", "UnInitializedWeb");
  6. router.pushUrl({ url: 'pages/WebBrowser' });
  7. })
  8. Web页使用Web组件加载指定网页
  9. // ../src/main/ets/pages/WebBrowser.ets
  10. // ...
  11. Web({ src: 'https://www.example.com', controller: this.controller })
  12. .domStorageAccess(true)
  13. .onPageEnd((event) => {
  14. if (event) {
  15. hilog.info(0x0001, "WebPerformance", "WebPageOpenEnd");
  16. }
  17. })
正例

入口页提前进行Web组件的初始化和预连接

  1. // ../src/main/ets/pages/WebInitialized.ets
  2. import webview from '@ohos.web.webview';
  3. // ...
  4. Button('进入网页')
  5. .onClick(() => {
  6. hilog.info(0x0001, "WebPerformance", "InitializedWeb");
  7. router.pushUrl({ url: 'pages/WebBrowser' });
  8. })
  9. // ...
  10. aboutToAppear() {
  11. webview.WebviewController.initializeWebEngine();
  12. webview.WebviewController.prepareForPageLoad("https://www.example.com", true, 2);
  13. }

Web页加载的同时使用prefetchPage预加载下一页

  1. // ../src/main/ets/pages/WebBrowser.ets
  2. import webview from '@ohos.web.webview';
  3. // ...
  4. controller: webview.WebviewController = new webview.WebviewController();
  5. // ...
  6. Web({ src: 'https://www.example.com', controller: this.controller })
  7. .domStorageAccess(true)
  8. .onPageEnd((event) => {
  9. if (event) {
  10. hilog.info(0x0001, "WebPerformance", "WebPageOpenEnd");
  11. this.controller.prefetchPage('https://www.example.com/nextpage');
  12. }
  13. })

数据对比

通过分别抓取正反示例的trace数据后使用SmartPerf Host工具分析可以得出以下结论:

hilog

从点击按钮进入Web首页到Web组件触发OnPageEnd事件,表示首页加载完成。对比优化前后时延可以得出,使用提前初始化内核和预解析、预连接可以减少平均100ms左右的加载时间。

首页完成时延

从Web首页内点击跳转下一页按钮到Web组件触发OnPageEnd事件,表示页面间跳转完成。对比优化前后时延可以得出,使用预加载下一页方法可以减少平均40~50ms左右的跳转时间。

跳转完成时延

最后

小编在之前的鸿蒙系统扫盲中,有很多朋友给我留言,不同的角度的问了一些问题,我明显感觉到一点,那就是许多人参与鸿蒙开发,但是又不知道从哪里下手,因为资料太多,太杂,教授的人也多,无从选择。有很多小伙伴不知道学习哪些鸿蒙开发技术?不知道需要重点掌握哪些鸿蒙应用开发知识点?而且学习时频繁踩坑,最终浪费大量时间。所以有一份实用的鸿蒙(HarmonyOS NEXT)文档用来跟着学习是非常有必要的。 


希望这一份鸿蒙学习文档能够给大家带来帮助~

文章知识点与官方知识档案匹配,可进一步学习相关知识
CS入门技能树Linux入门初识Linux42834 人正在系统学习中
鸿蒙NEXT全套学习资料
微信名片
注:本文转载自blog.csdn.net的让开,我要吃人了的文章"https://blog.csdn.net/weixin_55362248/article/details/141718343"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2492) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

101
推荐
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2024 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top