鸿蒙-Web 离线包实践

964 阅读5分钟

概述

大部分为了做web的性能优化,针对 APP 内嵌的 H5 页面,有一种比较常见且有效的方式就是接入离线包,通过把页面加载需要的静态资源js、css、图片(html一般不放)等静态资源提前下载到客户端本地,避免页面加载时静态资源网络请求的开销,从而来提高页面的加载速度。大部分是提高了首屏速度,减少资源从网络下载。

技术实现

Web有个 onInterceptRequest 方法,当Web组件加载url之前触发该回调,用于拦截url并返回响应数据。也就是说,当请求资源的时候,会经过这个方法,我们可以根据某个规则,判断本地是否有离线文件,然后把这个文件返回给h5即可,下面的例子是把h5的图片,全部拦截,替换咱们本地图片,不用去网上下载来,返回本地图片的WebResourceResponse。这个方法如果返回null,那么还会继续从网络加载

.onInterceptRequest((event) => {

  if (event) {
    // 是图片的正则表达式
    const imageRegex = /.(jpg|jpg#|jpeg|png|gif|bmp|webp)(?:?.*)?$/i;
    if (imageRegex.test(event.request.getRequestUrl())) {
      console.log(`onInterceptRequest=> ${event.request.getRequestUrl()}`)
      // 图片,全部替换成本地的一个图片
      let responseWeb: WebResourceResponse = new WebResourceResponse();
      // ResponseDataID
      // 构造响应数据
      // 为自定义的JavaScript请求响应生成 CodeCache:自定义请求响应的资源类型如果是JavaScript脚本,
      // 可以在响应头中添加“ResponseDataID”字段,Web内核读取到该字段后会在为该JS资源生成CodeCache,
      // 加速JS执行,并且ResponseData如果有更新时必须更新该字段。不添加“ResponseDataID”字段的情况下默认不生成CodeCache。
      responseWeb.setResponseHeader([
        {
          // 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
          headerKey: "ResponseDataID",
          headerValue: "0000000000001"
        }]);
      // 1.要设置的资源响应数据。string表示HTML格式的字符串。
      // 2. number表示文件句柄, 此句柄由系统的Web组件负责关闭。
      // 3. Resource表示应用rawfile目录下文件资源。
      // 4. ArrayBuffer表示资源的原始二进制数据。
      responseWeb.setResponseData($rawfile('img1.webp'));
      responseWeb.setResponseMimeType('image/webp');
      responseWeb.setResponseEncoding('utf-8');
      responseWeb.setResponseCode(200);
      responseWeb.setReasonMessage('OK');
      responseWeb.setResponseIsReady(true)
      return responseWeb
    }
  }
  // 返回响应数据则按照响应数据加载,无响应数据则返回null表示按照原来的方式加载
  return null;
})

首先模拟h5打包给到客户端,模拟 打开juejin.cn/

我们第一次打开juejin.cn 然后在onInterceptRequest中去下载css,js,图片等资源下载到本地

// 假设这个都是静态资源的前缀
let PRX = `https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web`

.onInterceptRequest((event) => {
  if (event) {
    let responseWeb = OfflineUtil.instance.getLocalFile(event.request.getRequestUrl());
    return responseWeb
  }
  return null
})
getLocalFile(url: string): WebResourceResponse | null {
  // 第一次需要先下载,模拟
  if (url.includes(PRX)) {
    this.download(url)
  }
}

下面这是下载的

download(url: string) {
  // 每一个httpRequest对应一个HTTP请求任务,不可复用
  let httpRequest = http.createHttp();
  // 用于订阅HTTP响应头事件
  httpRequest.on('headersReceive', (header: Object) => {
    console.info(TAG, 'header: ' + JSON.stringify(header));
  });
  // 用于订阅HTTP流式响应数据接收事件
  let res = new ArrayBuffer(0);
  httpRequest.on('dataReceive', (data: ArrayBuffer) => {
    const newRes = new ArrayBuffer(res.byteLength + data.byteLength);
    const resView = new Uint8Array(newRes);
    resView.set(new Uint8Array(res));
    resView.set(new Uint8Array(data), res.byteLength);
    res = newRes;
  });
  // 用于订阅HTTP流式响应数据接收完毕事件
  httpRequest.on('dataEnd', () => {
    console.info(TAG, '下载完成');
    // 下载完成之后,保存
    this.saveFile(res, url)
  });

  let streamInfo: http.HttpRequestOptions = {
    method: http.RequestMethod.GET, // 可选,默认为http.RequestMethod.GET
  }
  // 填写HTTP请求的URL地址,可以带参数也可以不带参数。URL地址需要开发者自定义。请求的参数可以在extraData中指定
  httpRequest.requestInStream(url, streamInfo).then((data: number) => {
    console.info("requestInStream OK!");
    console.info('ResponseCode :' + JSON.stringify(data));
    // 取消订阅HTTP响应头事件
    httpRequest.off('headersReceive');
    // 取消订阅HTTP流式响应数据接收事件
    httpRequest.off('dataReceive');
    // 取消订阅HTTP流式响应数据接收完毕事件
    httpRequest.off('dataEnd');
    // 当该请求使用完毕时,调用destroy方法主动销毁
    httpRequest.destroy();
  }).catch((err: Error) => {
    console.info("requestInStream ERROR : err = " + JSON.stringify(err));
  });

}

然后保存到本地

saveFile(arrayBuffer: ArrayBuffer, urlReq: string) {
  try {
    let downUrl: url.URL = url.URL.parseURL(urlReq);
    // 得到 文件在url中的名字
    // "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/4ab562f.js"
    // 比如上面得到的就是 4ab562f.js
    let name: string = downUrl.pathname.split('/').pop() as string;
    console.log(TAG, `名字是 ${name}`);
    // 获取真正的路径
    let path = urlReq.replace(PRX, '');
    let pathDown = this.offlineFilePath + path;
    // 打开,如果不存在 直接创建
    console.log(TAG, `下载的路径是 ${pathDown}`);

    // /data/storage/el2/base/haps/entry/cache/offline/static/rc-client-security-web-glue/1.0.0.19/sdk-glue.js
    // 再判断 parent是否存在,不存在创建parent目录 ,否则再openSync会报错
    this.makeParentDir(path)


    let file = fs.openSync(pathDown, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
    let fd = file.fd;
    fs.writeSync(fd, arrayBuffer)
    fs.closeSync(file)
  } catch (e) {
    console.error(JSON.stringify(e) + urlReq)
  }
}

我们看一下下面的图,这就是下载下来的静态资源 image.png

开始从刚才下载的资源去给到h5中

在webonInterceptRequest,

  • 判断是否是前缀的静态资源
  • 判断对应文件是否存在
  • setResponseMimeType 需不需要设置看自己的h5,如果需要,自己实现
getLocalFile(url: string): WebResourceResponse | null {
  // 第一次需要先下载,模拟
  if (url.includes(PRX)) {
    // this.download(url)
  }
  // 表明是在离线包中
  try {
    // 如果包含这个前缀,就去寻找
    if (url.includes(PRX)) {
      // 判断 离线包下的这个文件是否存在
      let path = this.offlineFilePath + url.replace(PRX, '')
      if (fs.accessSync(path)) {
        // 证明存在
        let file = fs.openSync(path)
        let responseWeb: WebResourceResponse = new WebResourceResponse();
        // 加速JS执行,并且ResponseData如果有更新时必须更新该字段。不添加“ResponseDataID”字段的情况下默认不生成CodeCache。
        // 把这个responseDataID存在全局的HashMap中
        let responseDataID = '1'
        if (OfflineUtil.instance.map.hasKey(path)) {
          responseDataID = OfflineUtil.instance.map.get(path)
        } else {
          responseDataID = this.generateUniqueID().toString()
          OfflineUtil.instance.map.set(path, responseDataID)
        }
        responseWeb.setResponseHeader([
          {
            // 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
            headerKey: "ResponseDataID",
            headerValue: responseDataID
          }
        ]);
        // 2. number表示文件句柄, 此句柄由系统的Web组件负责关闭。
        responseWeb.setResponseData(file.fd);
        // 好像这里不用填写MimeType,如果有的网址强校验这个,请根据path来获取真正的mimetype
        // responseWeb.setResponseMimeType();
        responseWeb.setResponseEncoding('utf-8');
        responseWeb.setResponseCode(200);
        responseWeb.setReasonMessage('OK');
        responseWeb.setResponseIsReady(true);
        console.log('给的是本地数据 ' + url)
        return responseWeb;
      }
    }
  } catch (e) {
    console.error(`错误了=> ${JSON.stringify(e)}`)
  }
  return null
}

源码位置github

其实就两个文件

OfflineWebPage

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


@Entry
@Component
struct OfflineWebPage {
  @State message: string = 'Hello World';
  controller: webview.WebviewController = new webview.WebviewController();
  startTime: number = 0

  aboutToAppear(): void {
    OfflineUtil.instance.init()
  }

  build() {
    RelativeContainer() {
      Web({ src: 'https://juejin.cn/', controller: this.controller })
        .fileAccess(true)// 是否可以读存储的html,api11 默认是true ,api12 默认是false $rawfile 不受影响
        .zoomAccess(false)// 设置是否支持手势进行缩放 默认允许,咱们app一般不允许
        .databaseAccess(true)//设置是否开启数据库存储API权限,默认不开启
        .cacheMode(CacheMode.Online)// 设置成只从网络读取,不读取缓存,这样咱们可以看出能减少多少ms
        .onPageBegin(() => {
          this.startTime = new Date().getTime()
        })
        .onPageEnd(() => {
          console.log(`从onPageBegin到onPageEnd 事件是 ${new Date().getTime() - this.startTime}`)
        })
        .onInterceptRequest((event) => {
          if (event) {
            let responseWeb = OfflineUtil.instance.getLocalFile(event.request.getRequestUrl());
            return responseWeb
          }
          return null
        })
        .width('100%')
        .height('100%')
    }
    .height('100%')
    .width('100%')
  }
}

OfflinUtil

import { HashMap, url } from '@kit.ArkTS';
import { http } from '@kit.NetworkKit';
import { fileIo as fs } from '@kit.CoreFileKit';

let TAG = 'OfflineUtil'
let PRX = `https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web`

export class OfflineUtil {
  static instance: OfflineUtil = new OfflineUtil();
  map: HashMap<string, string> = new HashMap()
  // 要把 h5 下载到这里
  offlineFilePath = getContext(this).filesDir + '/offline';

  private constructor() {
  }


  init() {
    // 文件夹是否存在
    let exists = fs.accessSync(this.offlineFilePath)
    if (!exists) {
      // 创建文件夹
      fs.mkdirSync(this.offlineFilePath)
    }
  }

  download(url: string) {
    // 每一个httpRequest对应一个HTTP请求任务,不可复用
    let httpRequest = http.createHttp();
    // 用于订阅HTTP响应头事件
    httpRequest.on('headersReceive', (header: Object) => {
      console.info(TAG, 'header: ' + JSON.stringify(header));
    });
    // 用于订阅HTTP流式响应数据接收事件
    let res = new ArrayBuffer(0);
    httpRequest.on('dataReceive', (data: ArrayBuffer) => {
      const newRes = new ArrayBuffer(res.byteLength + data.byteLength);
      const resView = new Uint8Array(newRes);
      resView.set(new Uint8Array(res));
      resView.set(new Uint8Array(data), res.byteLength);
      res = newRes;
    });
    // 用于订阅HTTP流式响应数据接收完毕事件
    httpRequest.on('dataEnd', () => {
      console.info(TAG, '下载完成');
      // 下载完成之后,保存
      this.saveFile(res, url)
    });

    let streamInfo: http.HttpRequestOptions = {
      method: http.RequestMethod.GET, // 可选,默认为http.RequestMethod.GET
    }
    // 填写HTTP请求的URL地址,可以带参数也可以不带参数。URL地址需要开发者自定义。请求的参数可以在extraData中指定
    httpRequest.requestInStream(url, streamInfo).then((data: number) => {
      console.info("requestInStream OK!");
      console.info('ResponseCode :' + JSON.stringify(data));
      // 取消订阅HTTP响应头事件
      httpRequest.off('headersReceive');
      // 取消订阅HTTP流式响应数据接收事件
      httpRequest.off('dataReceive');
      // 取消订阅HTTP流式响应数据接收完毕事件
      httpRequest.off('dataEnd');
      // 当该请求使用完毕时,调用destroy方法主动销毁
      httpRequest.destroy();
    }).catch((err: Error) => {
      console.info("requestInStream ERROR : err = " + JSON.stringify(err));
    });

  }

  saveFile(arrayBuffer: ArrayBuffer, urlReq: string) {
    try {
      let downUrl: url.URL = url.URL.parseURL(urlReq);
      // 得到 文件在url中的名字
      // "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/4ab562f.js"
      // 比如上面得到的就是 4ab562f.js
      let name: string = downUrl.pathname.split('/').pop() as string;
      console.log(TAG, `名字是 ${name}`);
      // 获取真正的路径
      let path = urlReq.replace(PRX, '');
      let pathDown = this.offlineFilePath + path;
      // 打开,如果不存在 直接创建
      console.log(TAG, `下载的路径是 ${pathDown}`);

      // /data/storage/el2/base/haps/entry/cache/offline/static/rc-client-security-web-glue/1.0.0.19/sdk-glue.js
      // 再判断 parent是否存在,不存在创建parent目录 ,否则再openSync会报错
      this.makeParentDir(path)


      let file = fs.openSync(pathDown, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
      let fd = file.fd;
      fs.writeSync(fd, arrayBuffer)
      fs.closeSync(file)
    } catch (e) {
      console.error(JSON.stringify(e) + urlReq)
    }
  }

  makeParentDir(path: string) {
    // 拿到 parent 路径
    // let parentPath = pathDown.replace(`/${pathDown.split('/').pop() as string}`, "")
    // let exists = fs.access(parentPath)
    // console.log('parent=' + parentPath)
    // if (!exists) {
    //   // 有可能是多级目录
    //   fs.mkdirSync(parentPath, true)
    // }
    try {
      let temp = ''
      let array = path.split('/')
      array.pop()
      array.forEach((value, index, array) => {
        if (value != "") {
          temp = temp + '/' + value
          let exists = fs.accessSync(this.offlineFilePath + temp)
          console.log(`这个是否存在 ${this.offlineFilePath + temp}  + ${exists}`)
          if (!exists) {
            console.log(`创建 ${this.offlineFilePath + temp}`)
            fs.mkdirSync(this.offlineFilePath + temp)
            console.log(`创建 成功`)
          }
        }

      })
    } catch (e) {
      console.error(JSON.stringify(e) + path)
    }
  }

  getLocalFile(url: string): WebResourceResponse | null {
    // 第一次需要先下载,模拟
    if (url.includes(PRX)) {
      // this.download(url)
    }
    // 表明是在离线包中
    try {
      // 如果包含这个前缀,就去寻找
      if (url.includes(PRX)) {
        // 判断 离线包下的这个文件是否存在
        let path = this.offlineFilePath + url.replace(PRX, '')
        if (fs.accessSync(path)) {
          // 证明存在
          let file = fs.openSync(path)
          let responseWeb: WebResourceResponse = new WebResourceResponse();
          // 加速JS执行,并且ResponseData如果有更新时必须更新该字段。不添加“ResponseDataID”字段的情况下默认不生成CodeCache。
          // 把这个responseDataID存在全局的HashMap中
          let responseDataID = '1'
          if (OfflineUtil.instance.map.hasKey(path)) {
            responseDataID = OfflineUtil.instance.map.get(path)
          } else {
            responseDataID = this.generateUniqueID().toString()
            OfflineUtil.instance.map.set(path, responseDataID)
          }
          responseWeb.setResponseHeader([
            {
              // 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
              headerKey: "ResponseDataID",
              headerValue: responseDataID
            }
          ]);
          // 2. number表示文件句柄, 此句柄由系统的Web组件负责关闭。
          responseWeb.setResponseData(file.fd);
          // 好像这里不用填写MimeType,如果有的网址强校验这个,请根据path来获取真正的mimetype
          // responseWeb.setResponseMimeType();
          responseWeb.setResponseEncoding('utf-8');
          responseWeb.setResponseCode(200);
          responseWeb.setReasonMessage('OK');
          responseWeb.setResponseIsReady(true);
          console.log('给的是本地数据 ' + url)
          return responseWeb;
        }
      }
    } catch (e) {
      console.error(`错误了=> ${JSON.stringify(e)}`)
    }
    return null
  }

  generateUniqueID(): string {
    // 获取当前时间戳(毫秒)
    const timestamp = Date.now(); // 当前时间戳
    const randomNum = Math.floor(Math.random() * 1000); // 生成 0 到 999 之间的随机数

    // 将时间戳和随机数组合成一个字符串
    const uniqueID = (timestamp % 1000000000000) + randomNum; // 确保不超过 13 位

    return uniqueID.toString();
  }
}