WebAssembly 在 eBay 的实践:速度提升 50 倍

WebAssembly 自诞生以来就震动了整个前端业界。Web 社区很高兴看到 JavaScript 有了竞争者。何况原生 WebAssembly 的速度比 JavaScript 要快得多。我们 eBay 也身处这一浪潮之中,对 WebAssembly 非常欢迎。

我们的工程师们都对 WebAssembly 热情满满,并一直关注着它的规范和演变。当 WebAssembly 1.0 正式发布,并支持所有主流浏览器时,eBay 内部的团队都迫不及待地去体验尝试了。但有一个问题。虽然有许多 用例 和应用可以从 WebAssembly 中受益,但电子商务行业使用的技术依旧比较原始。我们很难找到适合 WebAssembly 的电商场景。我们也想出了一些例子,但这些场景中用 JavaScript 更合适些。

在 eBay,每当我们评估新技术时要问的第一个问题就是“它会给我们的客户增加哪些潜在价值?”只有明确了这个答案我们才会继续下一步。人们很容易对闪闪发光的新事物着迷,却经常会忽略这些新事物其实可能对我们的客户没有任何好处,反而只会让现有的工作流程更加复杂。用户体验永远高于开发者的体验。但 WebAssembly 却与众不同,它的确潜力巨大,我们只是没有合适的用户场景而已。终于,这种情况开始出现了变化。

条形码扫描

eBay 的 iOS 和 Android 原生应用应用程序在出售流程中都有条形码扫描功能。这个功能利用设备相机扫描产品的 UPC 条形码,并根据条形码数据自动填写表单,省去了手工填表的麻烦。这是一个纯原生应用功能。它需要在设备上做一些高负载图像处理工作,检测来自摄像头图像流中的条形码编号。然后将检索到的代码发送到后端服务,再由后端服务填写表单。这意味着设备上的图像处理逻辑必须有很高的效率。

对于原生应用程序,我们将内部构建的 C ++ 扫描程序库编译为 iOS 和 Android 的本机代码,这个库能够以很高的性能从摄像头的图像流中识别产品条形码。我们正在慢慢过渡到 iOS 和 Android 的原生 API 上,但原来这个 C++ 库仍然很可靠。条形码扫描为卖家(测试项目中的页面功能)提供的直观功能,它简化了填写表单的流程。但很遗憾,我们的移动浏览器端用户是用不了这个功能的。我们已经对移动浏览器端优化了商品销售流程(测试项目中的功能),可惜还是没有条形码扫描功能,用户必须手动输入产品 UPC,显然这是很麻烦的体验。

为 Web 端提供条形码扫描功能

之前我们已经在计划过为 Web 端集成条形码扫描功能了。其实两年前我们就利用开源 JavaScript 库 BarcodeReader 做了一个带条形码扫描功能的 Web 版本。 问题是它只有在 20%的时间表现良好。 剩余的 80%的时间,它非常缓慢,用户还以为它卡死在那儿了。大多数情况下它都会超时。这种结果倒是不奇怪,虽说 JavaScript 确实能做到和原生代码一样快,但前提是它处于“热路径”,即由 JIT 编译器做过大量优化。这里的麻烦是 JavaScript 引擎使用大量启发算法来判断代码路径是否为热路径,不能保证每个实例都有经过优化。这种不一致的设计显然会让用户非常失望,结果我们不得不禁用这个功能。但如今情况不一样了:随着 web 平台的快速发展,我们再次产生了这个念头:“我们能否为 Web 端实现具备稳定并优异表现的条码扫描功能?”

有一个备选方案是等待 Shape Detection API 。计划中的这个 Web API 会为 Web 带来许多原生图像检测功能,其中之一就是条形码检测。但它仍处于非常早期的发展阶段,在实现跨浏览器兼容性的路上还有很长的路要走。就算将来它实现了多浏览器兼容,也不能保证在所有平台上都能正常工作。所以我们必须考虑其它选择。

于是我们想到了 WebAssembly。如果我们能使用 WebAssembly 实现条形码扫描功能,毫无疑问它的表现会非常稳定。WebAssembly 字节码的强类型和结构使得编译器始终处于热路径上。最重要的是我们有一个现成的为原生应用服务的 C++ 库。将 C++ 库编译为 WebAssembly 是很方便的。于是乎我们觉得方案已经很清楚了,没什么障碍可言,但实际做起来还是没那么容易的。

架构

我们基于 WebAssembly 实现的条形码扫描功能的工程设计非常简单直观。

  • 使用 Emscripten 编译 C++ 库,生成 JavaScript 粘合代码和.wasm 文件。

  • 从主线程创建一个 Worker 子线程。该子线程将导入生成的 JavaScript 粘合代码,然后实例化.wasm 文件。

  • 主线程将从相机捕获的图像流中截取快照发送到 Worker 子线程,子线程将通过粘合代码调用相应的 wasm API。API 的响应会传递给主线程。响应可以是 UPC 字符串(传递给后端),如果没有检测到条形码则为空字符串。

  • 快照是空场景时会重复上述步骤,直到检测到条形码。此循环由可配置的阈值(以秒为单位)计时。达到阈值后,我们会显示一条警告消息“这不是有效的产品代码。请尝试其它条形码或输入文本搜索“。出现这种情况可能是因为用户没有拍到有效的条形码,或者是条形码扫描功能的性能不够。我们会追踪这些超时实例,因为它可以很好地反映条形码扫描的性能表现。

编译

不管是什么 WebAssembly 项目,第一步就是要有一个定义良好的编译管道。Emscripten 已成为编译 WebAssembly 的工具链事实标准,但关键是要有一个一致的环境来输出确定的内容。

我们的前端基于 Node.js,这意味着我们需要一个适用于 npm 流程的解决方案。还好就在那时候 Surma Das 发表了 “Emscripten 和 npm”这篇文章 。文中介绍的基于 Docker 的编译 WebAssembly 的方法非常有用,因为它节省了大量开销。根据该文的建议,我们使用了 trzeci 制作的 Docker Emscripten 镜像。我们还得对自己开发的 C++ 库做一些调整,这样才能顺利编译到 WebAssembly。这个过程其实是一次试错练习。最后我们不仅编译成功了,还在现有的构建流程中建立了一个简洁的 WebAssembly 工作流程。

它很快,但…

我们评测扫描组件性能的方法是分析 API 一秒钟内可以处理的帧数。Wasm API 会接收来自相机图像流的快照像素数据,然后执行计算并返回响应结果。这个过程是连续不断的,直到检测到条形码才结束。我们的指标是大家熟悉的每秒帧数(FPS)。

在我们的测试中,WebAssembly 的平均输出高达 50FPS。问题是在时限内它只有 60%的扫码成功率。就算 FPS 如此之高,它也无法在规定的时间内有效检测出剩余的 40%条形码,结果只会显示失败警告。

作为对比,我们之前试过的 JavaScript 版本绝大多数时间输出只有 1FPS。所以可以肯定的是 WebAssembly 速度更快( 足足 50 倍提升 ),但不知何故,它在近一半的扫描中无法在规定的时限内检测到条形码。还应该提到的是,在某些情况下 JavaScript 表现得非常好,能够立即检测到条形码。一个可行的解决办法是晚点再显示失败提示,但这只会增加用户的挫败感,而且实际上并没有真正解决问题,所以我们没采纳这个主意。

一开始我们也搞不清楚为什么之前做的 C++ 库(在原生应用里表现优异)在 Web 平台上就没法输出相同的结果。经过大量的测试和调试,我们发现扫码成功所需的时间取决于对焦焦距和背景阴影。

那在原生应用中是如何处理图像的呢?原来在原生平台上,我们调用了平台内置的 API 来自动对焦,或让用户选择焦点,对焦到正扫描对象的中心上。这就让原生应用能够持续向扫码组件库发送高质量的图像像素数据(也就是只包含条形码的信息),避免了图像模糊的情况。因此原生平台上的响应延迟是基本不变的。

现在我们明白问题出在哪里了。我们认为在不同的对焦条件下,可能有时其它原生扫码库的表现会更好些。开源条形码扫描组件 ZBar 就非常受欢迎,版本也稳定;更重要的是它更适合模糊和颗粒状的图像场景,那么为什么不试试呢?由于我们已经搭建好了 WebAssembly 工作流程,所以就能无缝将 ZBar 用 WebAssembly 编译并部署成功。然后我们开始评估 ZBar 实现的性能。它的表现不错,输出大约 15 FPS(不如我们自己的 C++ 库)。但在同样的时限内 ZBar 的成功率接近 80%。这比我们自制的 C++ 库提升不少,但仍然无法做到 100%的成功率。

我们对这个结果还是不满意,但却注意到了一些意想不到的情况。在 ZBar 没能成功的情况下,自制的 C++ 库却能够非常快速地完成工作。这是一个令人心动的惊喜。很显然,两个库各自适合不同质量的图像快照。于是我们萌生了一个想法。

多线程和扫码竞赛

你可能猜到了:为什么不创建两个 Web worker 线程呢,一个用于 ZBar,一个用于自制 C++ 库,然后让它们互相竞争就行了。获胜的响应(也就是第一个发送有效条形码的响应)会被发送到主线程,同时所有 worker 线程都终止运行。我们做出了这套方案,然后开始内部场景测试并模拟尽可能多的场景。结果我们的成功率提升到了 95%,比以前好多了——但仍然不足 100%。

有人提了一个奇特的建议,就是把原来的 JavaScipt 库也加进来。这样方案就变成了三个线程并行。老实说我们并不认为这会解决问题,但我们尝试一下是很容易的,因为我们标准化了工作者界面。又一次让完全出乎我们意料的是,三条线程相互竞争后整体成功率确实接近了 100%。正如之前所提到的那样,JavaScript 在某些情况下表现非常好,而正是这个因素提升了整体成功率。所以“总要试试 JavaScript”这句话真没错啊!开个玩笑。下面的图表展示了我们最终实现的架构。

下图是高层流程图:

关于资产加载的说明

主页面呈现完毕之后就会预加载条形码扫描组件所需的资源。这是为了确保售货页面(测试页面)能快速加载,准备好交互流程。我们使用 XMLHttpRequest 在页面的加载事件之后预取并缓存 WebAssembly 资源(wasm 文件和相应的粘合代码脚本)和 JavaScript 扫码库。这里要注意的是它们只是预加载,并不会执行。这是为了让主线程专注于用户交互任务。只有当用户点击条形码图标时它们才会开始执行。如果用户在资源加载完毕之前就点击了条形码图标,我们会根据需要加载它们并立即执行。条形码事件处理程序和 worker 控制器绑定在一起,都是初始页面加载的内容,但它们的尺寸都很小。

成果

经过全面测试和内部测试,这个功能进入了 A/B 测试流程。实验中的“测试”页面会显示条形码扫描图标(如下面的屏幕截图所示),“Control”页面是没有的。

用于评估这个 A/B 测试成功与否的指标称为“草稿完成率”。它是一份表单从草稿状态成功完成并提交的比例。草稿完成率是一个很好的指标,可以条形码扫描组件是不是真的通过提升表单完成率来减少了用户摩擦并优化了卖货流程(测试的页面功能)。我们做了几周的测试,结果回来时确实非常令人满意。它与我们最初的假设完全一致。在启用条形码扫描组件的情况下, 填单流程的草稿完成率提高了 30%

我们还加入了性能评估,来分析各种类型的扫描组件获胜的比例。结果与预期一致,ZBar 占到扫描成功结果的 53%,其次是自制的 C++ 库,占 34%;最后是 JavaScript 库,占 13%。

结论

这趟 WebAssembly 旅程对我们来说是一次很棒的学习经历。工程师们都热衷于新技术,都想立即尝试一下。如果这些新技术能对以客户为中心的指标产生积极影响,那就是两全其美了。回到本文一开始谈到的观点:技术发展非常迅速,每天都有新事物出现,但只有少数会给客户带来积极影响,而 WebAssembly 就是其中之一。这就是我们从这次实践中学到的最有价值结论:

要对 99 件事情说“不”,而对客户真正重要的那一件事,我们的答案就应该是“Yes”。

至于将来的发展,我们正在考虑将条形码扫描组件扩展到移动 Web 平台的买货流程里,rang 买家可以扫描物品来搜索和购物。我们还将研究使用形状检测 API 等浏览器内置的摄像头功能来做改进。同时,我们很高兴 eBay 团队为 WebAssembly 找到了合适的用例,并将这项技术引入了电子商务业。

特别感谢 Surma Das 和 Lin Clark 为 WebAssembly 撰写的诸多文章,帮助我们跨越了许多障碍。

英文原文: https://www.ebayinc.com/stories/blogs/tech/webassembly-at-ebay-a-real-world-use-case/