鸿蒙HarmonyOS:Web组件初体验与离线包方案探索

1. 引言

在当今数字化的世界中,操作系统的演进不仅仅是技术的进步,更是对用户体验和开发者挑战的不断重新定义。而在这场技术的激流中,最近一年来,鸿蒙HarmonyOS崭露头角,尤其是最近这几个月来,各大主流APP都已经陆续启动鸿蒙化的研发,让鸿蒙HarmonyOS成为备受关注的新一代操作系统,本文将聚焦于HarmonyOS中一个重要的组成部分------Web组件,以及与之息息相关的离线包。Web组件作为移动应用开发中不可或缺的一环,为开发者提供了在应用中嵌入Web内容的强大能力

本文基于 HarmonyOS NEXT版本 API10,实现一个简单的Web容器页和离线包方案 源码地址 github.com/lovexiaobei...

2. Web组件初探

初始化示例代码

js 复制代码
 Web({ src: 'www.example.com', controller: new web_webview.WebviewController();})

HarmonyOS的Web组件构建参数是由srccontroller构成的

js 复制代码
declare interface WebOptions {
    src: string | Resource;
    controller: WebController | WebviewController;
}

src参数可以传递具体的Web链接,可以传递本地的资源文件,controller参数是控制Web组件各种行为,也可以控制Web组件的引擎初始化和开启调试、设置dns等。 Web组件本身是一个WebAttribute,可以使用它来做页面上的操作,比如页面打开回调、标题、网页进度监听等

如果类比Android的话,WebviewController就是Android的WebView本身,可以用来控制加载网页等具体原生操作,Web组件本身就是WebClientWebChromeClientWebSetting的究极缝合怪的组合形式,里面很多方法都能在Web组件上找到类似的,不能说是一摸一样,只能说是十分相像。

3. 简单Web容器搭建实战

WebAbility

首先我们按照最新的Stage模型中的方式,给Web容器页单独新建一个UIAbility组件 其中我们通过want的参数传递一个url过来,然后通过LocalStorage的方式给WebPage 传递过去 代码如下

js 复制代码
export default class WebAbility extends UIAbility {  
  private  urlStorage:LocalStorage = new LocalStorage();  
  
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {  
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');  
    const url = want?.parameters?.url as string;  
    if (url ==undefined || url == null || url == '') {  
      this.context.terminateSelf();  
    }  
    this.urlStorage.setOrCreate('url',url);  
  }  
  
  onWindowStageCreate(windowStage: window.WindowStage): void {  
    windowStage.loadContent('web/page/WebPage',this.urlStorage, (err, data) => { 
    });  
  }  
  
}

在module.json5注册下WebAbility组件 注意一定一定要加上网络权限ohos.permission.INTERNET (和Android也基本一致😄😄)

json 复制代码
"requestPermissions":[  
  {  
    "name" : "ohos.permission.INTERNET",  
    "reason": "$string:module_desc",  
    "usedScene": {  
      "abilities": [
		......
		......
		......
        "WebAbility"  
      ],  
      "when":"inuse"  
    }  
  },],
  ......
  ......
  ......
  "abilities": [
		......
		......
		......
  {  
	  "name": "WebAbility",  
	  "srcEntry": "./ets/web/WebAbility.ets",  
	  "description": "$string:WebAbility_desc",  
	  "icon": "$media:icon",  
	  "label": "$string:WebAbility_label",  
	  "startWindowIcon": "$media:startIcon",  
	  "startWindowBackground": "$color:start_window_background"  
	}
  ]

WebPage

在WebPag上我们简单实现一个带有返回按钮的原生标题栏和Web容器共同组成的一个页面,同时也支持返回键和Web 返回栈的联动

  • url参数接收 url参数是从WebAbility传递过来的
js 复制代码
let storage = LocalStorage.getShared()  
const TAG = 'WebPage';  
  
@Entry(storage)  
@Component  
struct WebPage {  
  @State title: string = '标题';  
  @LocalStorageProp('url') url: string = '';
}
  • Web组件 自定义标题栏实现

定义一个标题的State,在Web组件的onTitleReceive可以返回页面的标题 返回按钮和页面的onBackPress绑定

js 复制代码
@State title: string = '标题';
............
Column() {  
  Row() {
  //back  
  Button({ type: ButtonType.Circle, stateEffect: true }) {  
    Image($r('app.media.back')).width(30).height(30)  
  }.width(30)  
  .height(30)  
  .margin({ left: 10 })  
  .backgroundColor(0xFFFFFF)  
  .onClick(() => {  
   this.onBackPress();  
  })  
  Text(this.title) {  
  }.layoutWeight(1).height(30).width("100%").textAlign(TextAlign.Center).maxLines(1).margin({right:40})  
}.height(50).width("100%").justifyContent(FlexAlign.Start)


Web({src:this.url,controller:this.webviewController})
        .layoutWeight(1)
        .width("100%")
        .onPageBegin((event) => {
        })
        .onTitleReceive((event) => {
          hilog.info(0x0000, TAG, 'onTitleReceive %{public}s', event?.title??"");
          if (event?.title){
            this.title = event.title;
          }
        })
        ;  
}.width("100%").height("100%")
  • 返回事件判断和Web 返回栈的联动
js 复制代码
onBackPress() {  
  if (this.webviewController.accessBackward()) {  
    this.webviewController.backward();  
    return true;  
  }  
  router.back()  
  return false;  
}

我们启动下这个试试

4离线包方案探索

在前面介绍Web组件的时候有说过 Web组件初始化或者webviewController 在loadUrl的时候是可以传递Resource资源的,但这种只能做到包体静态离线包,没办法做到动态离线包,loadUrl 也没有像Android WebView 一样 支持file协议加载本地文件的离线包方法。那么动态离线包怎么做呢?

在WebAttribute我们看到了熟悉的 onInterceptRequest方法源码如下

js 复制代码
/**  
 * Triggered when the resources loading is intercepted. * * @param { function } callback The triggered callback when the resources loading is intercepted. * @returns { WebAttribute } If the response value is null, the Web will continue to load the resources. Otherwise, the response value will be used * @syscap SystemCapability.Web.Webview.Core * @since 9 */
 onInterceptRequest(callback: (event?: {request: WebResourceRequest;}) => WebResourceResponse): WebAttribute;

这边的callback只需要返回一个WebResourceResponse即可,注释里也写到,如果返回为null Web 将继续使用系统的继续加载,我们只需要实现这个 WebResourceResponse 是不是等于说可使用本地的资源了?看看 WebResourceResponse的参数

js 复制代码
    setResponseData(data: string | number | Resource);
   
    setResponseEncoding(encoding: string);
    
    setResponseMimeType(mimeType: string);
    
    setReasonMessage(reason: string);
    
    setResponseHeader(header: Array<Header>);
    
    setResponseCode(code: number);
    
    setResponseIsReady(IsReady: boolean);

可以看到 setResponseData是支持string的data数据的,那么基于拦截请求的离线包方案呼之欲出,我们直接看代码

离线包方案

离线包加载流程

  • 加载本地离线包 我们先判断本地文件夹有没有,如果有了,那就简单认为已经下载好了,没有就去下载离线包压缩包
js 复制代码
let path = context.filesDir + localPathSuffix;
//判断是否存在public目录
if (fs.accessSync(path)) {
  //存在public目录,初始化离线资源
  console.info("initOffLine 存在public目录");
  initOffLine(context.filesDir);
  return;
}
//不存在public目录,判断是否存在public.zip
path = context.filesDir + downloadFilePathSuffix;
if (fs.accessSync(path)) {
  //存在public.zip,解压public.zip
  console.info("initOffLine 存在public.zip");
  unzip(path, context.filesDir);
  return;
}
  • 下载离线包并解压到本地文件夹 这里我们利用系统提供的request请求工具和zlib解压缩工具对压缩包进行下载和解压
js 复制代码
  //不存在public目录和public.zip,下载public.zip
  try {
    request.downloadFile(context, {
      url: 'https://chenshengyu.cn/public.zip',
      filePath: path,
    }).then((downloadTask: request.DownloadTask) => {
      downloadTask.on('progress', (receivedSize: number, totalSize: number) => {
        console.info(`download progress: ${receivedSize}/${totalSize}`);
      })
      downloadTask.on('complete', () => {
        console.info('download complete');
        unzip(path, context.filesDir);
      })
    }).catch((err: BusinessError) => {
      console.error(`Invoke downloadTask failed, code is ${err.code}, message is ${err.message}`);
    });
  } catch (error) {
    let err: BusinessError = error as BusinessError;
    console.error(`Invoke downloadFile failed, code is ${err.code}, message is ${err.message}`);
  }
}

.....
//解压缩代码
const unzip = (zipPath: string, path: string) => {
  zlib.decompressFile(zipPath, path).then(() => {
    console.info('decompressFile success');
    initOffLine(path);
  }).catch((err: BusinessError) => {
    console.error(`Invoke decompressFile failed, code is ${err.code}, message is ${err.message}`);
  });
}
  • 把离线包加载到本地内存里 在这里通过本地一个HashMap 来保存离线包数据 遍历离线包文件夹,并将所有的文件通过文件系统读取成text ,然后创建一个WebResourceResponse来存储,用文件的路径做为key方便读取的时候匹配文件
js 复制代码
const  offLineMap :HashMap<string, WebResourceResponse> = new HashMap<string, WebResourceResponse>()  
const  initOffLine = (path:string)=>{  
  offLineMap.clear();  
  initOffLineList(path,path+"/public");  
}  
  
const initOffLineList = (path:string,suffix:string)=>{  
  let files = fs.listFileSync(path);  
  files.forEach((file)=>{  
    let pathDir = path+"/"+file;  
    fs.stat(pathDir, (err: BusinessError, stat: fs.Stat) => {  
      if (err) {  
        console.info("get file info failed with error message: " + err.message + ", error code: " + err.code);  
      } else {  
        if(stat.isDirectory()){  
          initOffLineList(pathDir,suffix)  
        }else if(stat.isFile()){  
          initOffLineResponse(pathDir,suffix)  
        }  
      }  
     });  
  })  
}  
const initOffLineResponse = (path:string,suffix:string)=>{  
  fs.readText(path).then((data)=>{  
    let response = new WebResourceResponse();  
    response.setResponseData(data);  
    response.setResponseEncoding("utf-8");  
    response.setResponseMimeType(path2MimeType(path));  
    response.setResponseCode(200);  
    response.setReasonMessage('OK');  
    let key = path.replace(suffix,"");  
    console.info("key:"+key);  
    offLineMap.set(key,response);  
  })  
}
  • 离线包资源拦截流程

这里我们拦截下 onInterceptRequest 请求,如果匹配到离线包资源就可以返回给Web组件我们自己构建的WebResourceResponse,否则就走系统的网络正常加载,这样做的好处是,离线包没加载或者离线包没有资源的时候,也能正常加载网页,网络和离线包走同一个方案,随时切换离线包和在线方式 代码如下

js 复制代码
.onInterceptRequest((event) => {  
  //获取请求地址  
  const requestUrl = event?.request?.getRequestUrl();  
  let key ="";  
  //判断是不是静态资源  
  if (requestUrl?.startsWith(this.url)){  
    key = requestUrl.substring(this.url.length);  
    // 对/结尾的资源特殊处理 追加上index.html  
    if (key.endsWith("/")) {  
      key += "index.html"  
    }  
    //判断本地离线包是否命中  
    let response = offLineMap.get(key)  
    if ( response != null) {  
      //命中离线包资源,使用本地离线包数据  
      return response  
    }  
  }  //没有命中离线包,正常请求  
 return;  
})

这样一个简单的离线包方案就好了,我们运行下看下效果

网络关闭后网页可以正常加载,图片因为是CDN链接没有打到离线资源导致图片未显示,由此可见基于onInterceptRequest拦截请求的离线包方式总体可行的

总结

本文只是简单验证下离线包方案,离线包技术远远没有这么简单,对于离线包的加载时机和加载流程、离线包管理、安全验证、方案降级等还有众多需要完善的地方,我们后面接着探索

我们使用了HarmonyOS的下载、压缩、文件管理等多个API,基本上没有费劲就完成了简单的方案验证,由此可见 HarmonyOS对于开发者来说是一个很完善的方案了,也提供了大量的开发者工具,让我们开发者可以快速上手鸿蒙开发,总之鸿蒙,未来可期。

鸣谢

感谢ChatGPT 对本文的写作帮助

感谢Github Copilot 对本文代码帮助

相关推荐
GIS程序媛—椰子18 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00125 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端28 分钟前
Content Security Policy (CSP)
前端·javascript·面试
木舟100931 分钟前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤439142 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落1 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt