鸿蒙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 对本文代码帮助

相关推荐
y先森12 分钟前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy12 分钟前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu108301891115 分钟前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿1 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡2 小时前
commitlint校验git提交信息
前端
虾球xz3 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇3 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒3 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员3 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐3 小时前
前端图像处理(一)
前端