1. 引言

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

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

2. Web组件初探

初始化示例代码

1
Web({ src: 'www.example.com', controller: new web_webview.WebviewController();})

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

1
2
3
4
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 传递过去
代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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也基本一致😄😄)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
"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传递过来的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let storage = LocalStorage.getShared()  
    const TAG = 'WebPage';

    @Entry(storage)
    @Component
    struct WebPage {
    @State title: string = '标题';
    @LocalStorageProp('url') url: string = '';
    }
    • Web组件 自定义标题栏实现
      定义一个标题的State,在Web组件的onTitleReceive可以返回页面的标题
      返回按钮和页面的onBackPress绑定
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      @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 返回栈的联动

    1
    2
    3
    4
    5
    6
    7
    8
    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方法源码如下

1
2
3
/**  
* 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的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
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数据的,那么基于拦截请求的离线包方案呼之欲出,我们直接看代码

离线包方案

离线包加载流程
离线包加载流程

  • 加载本地离线包
    我们先判断本地文件夹有没有,如果有了,那就简单认为已经下载好了,没有就去下载离线包压缩包

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    loadOfflinePackage() {  
    //判断本地离线包是否存在
    let context = getContext(this) as common.UIAbilityContext;
    let filesDir = context.filesDir + '/offlineWeb';
    if (fs.accessSync(filesDir)) {
    //如果离线包存在直接初始化
    console.info('offline package is exist');
    initOffLine(filesDir);
    } else {
    //不存在就去下载离线包
    console.info('offline package is not exist');
    this.downloadOfflinePackage(filesDir);
    }
    }
  • 下载离线包并解压到本地文件夹
    这里我们利用系统提供的request请求工具和zlib解压缩工具对压缩包进行下载和解压

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    request.downloadFile(context, {  
    url: 'https://chenshengyu.cn/public.zip',
    filePath: zipDir,
    }).then((downloadTask: request.DownloadTask) => {
    downloadTask.on('progress', (receivedSize: number, totalSize: number) => {
    console.info(`download progress: ${receivedSize}/${totalSize}`);
    })
    downloadTask.on('complete', () => {
    //解压缩
    zlib.decompressFile(zipDir, path).then(() => {
    console.info('decompressFile success');
    initOffLine(path);
    }).catch((err: BusinessError) => {
    console.error(`Invoke decompressFile failed, code is ${err.code}, message is ${err.message}`);
    });
    })
    }).catch((err: BusinessError) => {
    console.error(`Invoke downloadTask failed, code is ${err.code}, message is ${err.message}`);
    });
  • 把离线包加载到本地内存里
    在这里通过本地一个HashMap 来保存离线包数据 遍历离线包文件夹,并将所有的文件通过文件系统读取成text ,然后创建一个WebResourceResponse来存储,用文件的路径做为key方便读取的时候匹配文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    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,否则就走系统的网络正常加载,这样做的好处是,离线包没加载或者离线包没有资源的时候,也能正常加载网页,网络和离线包走同一个方案,随时切换离线包和在线方式
代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.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;
})

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

网络关闭后网页可以正常加载,图片因为编码问题未展示出来,由此可见基于onInterceptRequest拦截请求的离线包方式总体可行的

总结

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

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

鸣谢

感谢ChatGPT 对本文的写作帮助
感谢Github Copilot 对本文代码帮助