苦逼前端的心路历程:或许你想在鸿蒙应用中定制一款PDF 阅读器?

425 阅读7分钟

24年2月,正式加入鸿蒙应用框架开发。

24年4月,落地鸿蒙应用的定制化 PDF 阅读器。

24年7月,该阅读器功能模块在业务被初次应用。

24年9月,官方最新版本发布了 PDF 预览模块。

预计24年11月废弃当初费时费力实现的“定制化PDF阅读器”,对接鸿蒙官方的文件预览模块。

背景

还记得当初做文件预览时耗费的心力,调研 pdf.js 注入鸿蒙的可能性并实现,虽然早期并没有很想做(毕竟现在想想都挺复杂),但官方回复要年底才能实现,而项目上当初说这个功能贼急,等不到年底。

最终功能在4月落地,而官方在8月份默默的在我4月份提的 IR 单上回复最新的版本已经支持。截至到目前,功能将被废弃,计划11月对接官方文件预览功能。耗时1个月,寿命不到半年。

该说不说,虽然目前官方已经支持了,但还是觉得当初的实现和思维还是挺通用的,特别是对多年的前端开发者初期转原生应用开发来说。记录一下这个费心费力的经过。

选择什么样的方案?

早期调研考虑多方面:实现成本、性能、扩展性等多方面进行了考虑。最早官方建议直接用 Web 组件直接加载,性能上和实现成本上都相对较小,但看过我这篇文章《如何选择 pdf 方案:PDFObject.js 与 PDF.js 在性能、兼容性、使用场景的探讨》的都知道,直接利用原生 Web 的方式扩展性会相对较差,虽然它实现快,原生渲染性能好,但无法定制。

简易版 PDF 预览方案

前期的鸿蒙应用,介于 HarmonyOS NEXT 还不稳定,因此很多不支持的功能都可以降级,所以我们首个最简 PDF 预览功能就这么快速的落地了。

Web({
  // 可以为http协议的 pdf 地址,也可以是file 协议的 pdf 地址
  src: this.url,
  controller: this.webviewController
})

考虑到 Web 组件加载大体积的 PDF 文件时会半天无响应,并且没有加载进度条(Web 组件加载 PDF 时进度条和 toolbar 绑定的,而当初的 toolbar 上的各种按钮功能官方还未实现的,所以我们关掉了 toolbar )。因此我们选择先下载,后通过 Web 组件打开的方式,给到业务一个下载进度的回调,让业务自行定制加载进度的交互。

按照上面的方式,简易版的 PDF 预览功能就实现了。

复杂版 PDF 预览方案

喜滋滋的提交了 PDF 预览模块,然后被告知 PDF 预览功能有2种,一种是简单的预览,另一种要求按原型稿实现,并且两种都要...... 说好的降级呢,瞬间头秃。

众所周知,利用 Web 直接加载 PDF 文件使用的是系统的内置插件,无法扩展。为了以防万一,还特意去问了鸿蒙的官方,能不能给 Web 组件加载 pdf 文件功能进行扩展,给的回复必然是否定的。最后只能默默的又捡起了之前利用 pdf.js 做的 PDF 阅读器了。

接入 pdf.js

作为一名多年的前端,首先拿着我《基于 webComponent 深度定制 pdf 阅读器组件》的源码,将我们需求的所有交互和设计在源码中先实现了,然后通过 JSBridge 的方式将事件传递到原生,原生接收到事件后做相应的处理

<script language="javascript" src="./pdfview.js"></script>
// 前端页面
<script language="javascript">
  // 由应用动态注入 `setSrc('xxx')`脚本进行执行
  function setSrc(src) {
    let previewDom = document.createElement('pdf-view')
    previewDom.src = src
    previewDom.id = 'viewer'
    document.body.append(previewDom)
    addEvent()
  }
  
  // pdf 组件事件的监听
  function addEvent() {
    const viewer = document.getElementById('viewer')
    try {
        if(pdfWebBridge && pdfWebBridge.exec) {
            viewer.addEventListener('initPdf', (params) => {
              //通过调用应用层注入的 bridge 上的事件,传递数据,实现交互
                pdfWebBridge.exec('inited', params.detail)
            })
            viewer.addEventListener('readFinish', (data) => {
                pdfWebBridge.exec('finish', data.detail)
            })
            viewer.addEventListener('update', (data) => {
                pdfWebBridge.exec('update', data.detail)
            })
            viewer.addEventListener('error', (data) => {
                pdfWebBridge.exec('error', data.detail)
            })
        }
    }catch(err) {}
  }
</script>
原生创建 Web 组件的 js 代理

在 Web 组件应用层上注入与 Web 组件内 html 交互的 JS 代理(bridge),通过以下的方式在应用层注入了一个全局的 pdfWebBridge 桥,该 bridge 上存在 exec 和 log 两个事件,在 html 中可通过调用 pdfWebBridge. execpdfWebBridge.log 来执行 应用层的功能,实现与应用的交互。

Web({
    src: "http://10.234.56.78:8080/index.html",
    controller: this.webviewController})
  .javaScriptProxy({
    object: this.pdfWebBridge,
    name: "pdfWebBridge",
    methodList: ["exec", "log"],
    controller: this.webviewController
  })

本地起了个 http 前端服务,原生和前端两头并行实现,完美。

解决 pdf.js 加载远程资源的跨域问题

考虑到跨域问题,不能直接将传入的 文件路径直接吐给前端服务进行渲染,毕竟众所周知,在前端项目中,pdf.js 由于其加载远程文件时,使用的是网络请求获取文件 Arraybuffer 的方式,是无法读取到跨域资源的内容的。

因此我们在上一步,加入了原生下载资源,将文件的 ArrayBuffer 直接吐给 Web 组件内,同样是利用了 JSBridge 代理的模式,通过 pdf.js 能直接渲染 ArrayBuffer 的特性,来实现类原生(给出去的模块谁又知道是前端方案还是原生方案)的定制化 pdf 文件预览器。这里就又花了时间去调研了《跨端技术下的鸿蒙应用:文件资源访问策略与实践》,结合了原生 ArrayBuffer 通信和前端 pdf.js 方案,实现了支持跨域文件的“类原生” PDF 阅读器。

// 注入到 web 侧监听端口注册及端口监听消息接收的js脚本
const POSTPDFWEBPORT_JS: string = "var h5Port;\n" +
  "window.addEventListener('message', function (event) {\n" +
  "            if (event.data == '__init_pdf_port__') {\n" +
  "                if (event.ports[0] != null) {\n" +
  "                    h5Port = event.ports[0]\n" +
  "                    h5Port.onmessage = function (event) {\n" +
  "                        var result = event.data;\n" +
  "                        if (typeof(result) == "object") {\n" +
  "                           if (result instanceof ArrayBuffer) {\n" +
                                 // 将获取到的 arraybuffer 给到定制的pdf阅读器进行渲染
  "                               setArrayBuffer(result)\n" +
  "                               h5Port.close()\n" +
  "                            } else {\n" +
  "                                pdfWebBridge.log("source not support");\n" +
  "                            }\n" +
  "                        } else {\n" +
  "                            pdfWebBridge.log("source not support");\n" +
  "                        }\n" +
  "                    }\n" +
  "                }\n" +
  "            }\n" +
  "        })";

// 组件部分 ------------------------------------
if(this.readerUrl) {
  Web({
  // 临时的远程前端服务,利于调试
    src: "http://10.234.56.78:8080/index.html",
    controller: this.webviewController})
    // 在页面加载前执行端口通道的创建
  .onPageBegin(() => {
    // 在创建端口前执行监听端口消息接口
    this.webviewController.runJavaScript(POSTPDFWEBPORT_JS);
    // 创建端口
    this.creatWebPorts()
  })
  .onPageEnd(() => {
    // 在页面加载完成后在应用侧向web侧端口发送 ArrayBuffer
    this.postMessage()
  })
}

// 事件部分 ---------------------------------------
// 创建web侧与应用侧的通道通信
creatWebPorts() {
    // 1、创建两个消息端口。
  this.ports = this.webviewController.createWebMessagePorts();
  // 2. 保存端口1到本地
  this.nativePort = this.ports[1];
  console.info(`reader nativePort ${this.nativePort}`);
  // 3、在应用侧的消息端口(如端口1)上注册回调事件。
  this.nativePort.onMessageEvent((result: web_webview.WebMessage) => {
    let msg = 'Got msg from HTML:';
  })
  // 3、将另一个消息端口(如端口0)发送到HTML侧,由HTML侧保存并使用。
  this.webviewController.postMessage('__init_pdf_port__', [this.ports[0]], '*');
}

// 向 web 测发送文件的 arraybuffer 消息
postMessage() {
  if (this.nativePort && this.message) {
    this.nativePort.postMessageEvent(this.message);
  } else {
    console.error(`reader ports is null or message is undefined`);
  }
}

html 中接收 ArrayBuffer 信息

function setArrayBuffer(data) {
  let previewDom = document.createElement('pdf-view')
  previewDom.data = data
  previewDom.id = 'viewer'
  document.body.append(previewDom)
  addEvent()
}
解决 ArrayBuffer 方案的限制

我以为我完成了,然后当我渲染一个 50M 的 PDF 文件时,这方案在 demo 中崩溃了,然后我只能将ArrayBuffer 的方案又替换成了《跨端技术下的鸿蒙应用:文件资源访问策略与实践》中的通过 web 组件的 onInterceptRequest 拦截请求的方式

终于,整个功能完整了。但是原生模块被使用肯定不能用远程的方案,所以需要将整个前端功能打包到原生,通过原生 Web 组件加载本地 resfile 内的 html 文件来实现。

解决鸿蒙 Web 组件 file 协议访问上下文跨域的限制

然后发现 Web 组件内接收不到下载后的 pdf 文件了,咨询了下官方,被告知鸿蒙的 ArkWeb 内核不允许 file 协议或者 resource 协议访问URL上下文中来自跨域的请求。 这一句话我理解了很久...... 才知道 html 文件是 file 协议的路径(file://xxx1.html), PDF 资源也是 file 协议(file://xxx2.pdf),两个 file 协议的路径不一样,也属于跨域场景。

解决方案正如《跨端技术下的鸿蒙应用:文件资源访问策略与实践》 中详细介绍的,创建一个同域环境来解决。

最后,最终方案如下:

总结

这个功能属实耗费了太多的心力,虽然可能在 11月份被下架,但从这个经历中获取到的知识和思维感觉在后续原生开发中或许还是能用到的。

最后,有经验的前端开发者一眼就能看出来,这是一个常规的混合开发模式。从这也能看出,在鸿蒙应用开发上,除了语言上的便利外,混合开发模式也为前端工程师带来了一定的便利。