随时随地使用 Goodnotes

Goodnotes 营销图片显示一位女士在 iPad 上使用该产品。

在过去两年中,Goodnotes 工程团队一直致力于一个项目,将成功的 iPad 记事应用引入其他平台。本案例研究介绍了 2022 年度最佳 iPad 应用如何依托 Web 技术打造成 Web、ChromeOS、Android 和 Windows 应用,以及如何重复使用团队已研究超过十年的相同 Swift 代码。

Goodnotes 徽标。

Goodnotes 为何支持 Web、Android 和 Windows

在 2021 年,Goodnotes 仅是 iOS 和 iPad 版应用。Goodnotes 的工程团队接受了一项巨大的技术挑战:创建新版本的 Goodnotes,但支持额外的操作系统和平台。该产品应与 iOS 应用完全兼容,并呈现与 iOS 应用相同的备注。在 PDF 文件之上所做的任何备注或附加的任何图片都应具有同等效果,并且显示的笔画与 iOS 应用显示的笔画相同。添加的任何描边都应与 iOS 用户可以创建的描边相等,且独立于用户使用的工具(例如钢笔、荧光笔、钢笔、形状或橡皮擦)。

Goodnotes 应用预览版,包含手写记事和草图。

根据相关要求和工程团队的经验,该团队很快得出结论,重复使用 Swift 代码库是最佳的做法,因为 Swift 代码库已经过多年编写和测试。但是,为什么不直接将现有 iOS/iPad 应用移植到其他平台或技术(如 Flutter 或 Compose Multiplatform)呢?若要迁移到新平台,就需要重新编写 Goodnotes。这样做可能会导致已实现的 iOS 应用与要从零新应用构建的新应用之间展开开发竞赛,或者要求在新代码库跟上新代码的同时停止对现有应用进行新的开发。如果 Goodnotes 可以重复使用 Swift 代码,则该团队将从 iOS 团队实现的新功能中受益,而跨平台团队则致力于开发应用基础知识并实现覆盖面功能对等。

该产品已经解决了 iOS 的许多有趣挑战,以添加如下功能:

  • 便笺呈现。
  • 文档和备注同步。
  • 使用无冲突复制数据类型的备注的冲突解决方案。
  • 用于 AI 模型评估的数据分析。
  • 内容搜索和文档索引。
  • 自定义滚动体验和动画。
  • 查看所有界面层的模型实现。

如果工程团队能够让 iOS 代码库适用于 iOS 和 iPad 应用,并将其作为 Goodnotes 可能作为 Windows、Android 或 Web 应用随附的项目的一部分执行,那么在其他平台上实现所有这些代码会容易得多。

Goodnotes 的技术栈

幸运的是,有一种方法可以在网络上重复使用现有的 Swift 代码 - WebAssembly (Wasm)。Goodnotes 使用 Wasm 与开源且由社区维护的项目 SwiftWasm 构建了原型。借助 SwiftWasm,Goodnotes 团队可以使用已实现的所有 Swift 代码生成 Wasm 二进制文件。此二进制文件可以添加到作为适用于 Android、Windows、ChromeOS 和所有其他操作系统的渐进式 Web 应用的网页中。

Goodnotes 的发布顺序是从 Chrome 开始,接着是 Windows,最后是 Android 以及 Linux 等其他平台,最后这些平台都是基于 PWA 的。

其目标是将 Goodnotes 作为 PWA 发布,并出现在每个平台的商店中。除了已用于 iOS 的编程语言 Swift 以及用于在网页上执行 Swift 代码的 WebAssembly,该项目还使用了以下技术:

  • TypeScript::Web 技术最常用的编程语言。
  • React 和 webpack:最常用的 Web 框架和打包器。
  • PWA 和 Service Worker:这是此项目的巨大驱动因素,因为团队可以将我们的应用作为离线应用发布,其运作方式与任何其他 iOS 应用相同,您可以通过商店或浏览器安装该应用。
  • PWABuilder:主项目 Goodnotes 用于将 PWA 封装到原生 Windows 二进制文件中,以便团队可以从 Microsoft Store 分发我们的应用。
  • Trusted Web Activity:公司用来将我们的 PWA 作为原生应用分发的最重要的 Android 技术。

Goodnotes 技术栈由 Swift、Wasm、React 和 PWA 组成。

下图显示了使用传统 TypeScript 和 React 实现的内容,以及使用 SwiftWasm 和原版 JavaScript、Swift 和 WebAssembly 实现的内容。项目的这一部分使用 JSKit,这是团队使用的一个适用于 Swift 和 WebAssembly 的 JavaScript 互操作性库,用于在需要时通过 Swift 代码处理编辑器屏幕中的 DOM,甚至使用一些特定于浏览器的 API。

移动设备和桌面设备上的应用屏幕截图,显示了 Wasm 驱动的特定绘制区域,以及 React 驱动的界面区域。

为什么要使用 Wasm 和网络?

尽管 Wasm 并非 Apple 提供正式支持,但 Goodnotes 工程团队认为此方法是最佳决策,原因如下:

  • 重复使用了超过 10 万行代码。
  • 能够继续针对核心产品进行开发,同时为跨平台应用做贡献。
  • 利用迭代开发流程尽快覆盖所有平台的强大功能。
  • 能够控制渲染同一文档,而无需复制所有业务逻辑,以及在实现方面引入差异。
  • 受益于在每个平台上同时进行的所有性能改进(以及在每个平台上实现的所有 bug 修复)。

重复使用超过 10 万行代码,以及实现渲染流水线的业务逻辑是重中之重。同时,通过使 Swift 代码与其他工具链兼容,他们可以根据需要在不同平台中重复使用此代码。

迭代产品开发

该团队采用迭代方法,以尽快向用户提供信息。Goodnotes 最初是该产品的只读版本,用户可以从该版本获取任何共享文档,并从任何平台阅读该文档。只需一个链接,他们就可以访问和阅读通过 iPad 撰写的那些记事。编辑功能进入下一阶段,使跨平台版本等效于 iOS 版本。

两张应用屏幕截图,象征从只读发展为全功能产品。

只读产品的第一个版本花了 6 个月时间开发完毕,接下来的 9 个月专门用于介绍首批编辑功能和界面屏幕,您可以在其中查看自己创建的或他人与您共享的所有文档。此外,得益于 SwiftWasm 工具链,iOS 平台的新功能可以轻松移植到跨平台项目。例如,我们创造了一种新型钢笔,并可通过重复使用数千行代码,跨平台轻松实现。

构建这个项目是一次不可思议的体验,Goodnotes 也从中学到了很多。因此,以下部分将重点介绍有关 Web 开发的有趣技术要点,以及 WebAssembly 和 Swift 等语言的用法。

初始障碍

从许多不同的角度来看,处理这个项目极具挑战性。该团队发现的第一个障碍与 SwiftWasm 工具链有关。工具链是该团队的重要驱动因素,但并非所有 iOS 代码都与 Wasm 兼容。例如,与 IO 或界面相关的代码(例如视图、API 客户端或对数据库的访问)的实现是不可重复使用的,因此该团队需要开始重构应用的特定部分,以便能够在跨平台解决方案中重复使用这些部分。该团队创建的大多数 PR 都是通过重构来抽象依赖项,因此该团队稍后可以使用依赖项注入或其他类似策略替换它们。iOS 代码最初混合了可以在 Wasm 中实现的原始业务逻辑与负责输入/输出和界面的代码,而这些代码无法在 Wasm 中实现,因为 Wasm 也不支持这些逻辑。因此,一旦 Swift 业务逻辑准备好在平台之间重复使用,就需要在 TypeScript 中重新实现 IO 和界面代码。

性能问题已解决

Goodnotes 开始开发编辑器后,该团队发现编辑体验存在一些问题,具有挑战性的技术限制被纳入我们的路线图。第一个问题与性能有关。JavaScript 是一种单线程语言。这意味着它有一个调用堆栈和一个内存堆。它按顺序执行代码,并且必须先执行一段代码,然后才能继续执行下段代码。同步是同步的,但有时可能有害。例如,如果某个函数需要一段时间执行或必须等待,则在此期间它会冻结所有内容。而这正是工程师们必须解决的问题评估代码库中与渲染层或其他复杂算法相关的某些特定路径对团队来说是一个问题,因为这些算法是同步的,执行它们会阻塞主线程。Goodnotes 团队重新编写了它们以提高速度,并重构了部分代码以使其异步。他们还引入了收益策略,以便应用可以停止算法的执行并在稍后继续执行,以便浏览器更新界面并避免丢帧。对于 iOS 应用来说,这并不是问题,因为它可以在 iOS 主线程更新界面时在后台使用线程并评估这些算法。

工程团队必须解决的另一个解决方案是,将基于附加到 DOM 的 HTML 元素的界面迁移到基于全屏画布的文档界面。该项目开始将与文档相关的所有笔记和内容作为 DOM 结构的一部分,像任何其他网页一样使用 HTML 元素显示,但之后在某个时候迁移到了全屏画布,以通过减少浏览器进行 DOM 更新的时间来提高低端设备上的性能。

工程团队发现以下更改可以减少一些问题,如果他们在项目开始时这样做,就可以减少。

  • 为大量算法频繁使用 Web 工作器,从而更多地分流主线程。
  • 从一开始就使用导出的函数导入的函数,而不是 JS-Swift 互操作库,以便降低离开 Wasm 上下文对性能的影响。此 JavaScript 互操作库有助于访问 DOM 或浏览器,但它比原生 Wasm 导出的函数慢。
  • 确保代码允许在后台使用 OffscreenCanvas,以便应用可以分流主线程,并将 Canvas API 的所有使用行为移至 Web 工作器,从而最大限度地提升应用在写入备注时的性能。
  • 将所有与 Wasm 相关的执行移至 Web 工作器甚至 Web 工作器池,以便应用可以减少主线程工作负载。

文本编辑器

另一个有趣的问题与一种特定工具(即文本编辑器)有关。此工具的 iOS 实现基于 NSAttributedString,这是一个在后台使用 RTF 的小型工具集。不过,这种实现与 SwiftWasm 不兼容,因此跨平台团队不得不先创建基于 RTF 语法的自定义解析器,然后再通过将 RTF 转换为 HTML 实现修改体验,反之亦然。同时,iOS 团队开始开发此工具的新实现,以替换对 RTF 的使用,使其成为一个自定义模型,以便该应用能够以友好的方式针对共享相同 Swift 代码的所有平台表示样式化文本。

Goodnotes 文本编辑器。

这一挑战是项目路线图中最有趣的要点之一,因为它是根据用户的需求以迭代方式解决的。这是一个工程问题,采用以用户为中心的方法得以解决,即团队需要重写部分代码才能渲染文本,以便在第二个版本中实现文本编辑。

迭代版本

该项目在过去两年中取得了令人难以置信的演变。该团队开始开发该项目的只读版本,几个月后发布了包含众多修改功能的全新版本。为了频繁地发布生产环境中的代码更改,该团队决定广泛使用功能标志。对于每个版本,该团队都可以启用新功能,还可以发布代码更改以实现用户在几周后看到的新功能。不过,团队认为还有一些地方可以改进!他们认为引入动态功能标志系统有助于加快速度,因为这样就无需重新部署来更改标志值。这将提高 Goodnotes 的灵活性,并加快新功能的部署速度,因为 Goodnotes 不需要将项目部署关联到产品版本。

离线工作

离线支持是其团队开发的主要功能之一。能够编辑和修改文档是此类应用应该具备的一项功能。但是,这并非易事,因为 Goodnotes 支持协作。这意味着,不同用户在不同设备上所做的所有更改最终应该应用到所有设备上,而无需要求用户解决任何冲突。Goodnotes 很久以前就通过在后台使用 CRDT 解决了这个问题。得益于这些无冲突的复制数据类型,Goodnotes 能够合并任何用户对任何文档所做的所有更改并合并这些更改,而不会出现任何合并冲突。IndexedDB 的使用和适用于网络浏览器的存储空间是网络上协作离线体验的巨大驱动因素。

Goodnotes 应用可离线运行。

最重要的是,由于 Wasm 二进制文件的大小,打开 Goodnotes Web 应用会产生大约 40 MB 的初始下载成本。最初,Goodnotes 团队完全依靠 app bundle 本身及其使用的大多数 API 端点的常规浏览器缓存,但后来就能从更可靠的 Cache API 和 Service Worker 中获益。该团队最初是认为这项任务比较复杂,因此回避这项任务,但最后,他们意识到 Workbox 并没有那么可怕。

在 Web 上使用 Swift 时的建议

如果您的 iOS 应用包含大量想要重复使用的代码,请做好准备,因为您即将开启一段不可思议的旅程。在开始之前,您可能会发现一些有趣的提示。

  • 检查您想要重复使用的代码。如果您应用的业务逻辑是在服务器端实现的,您可能会希望重复使用界面代码,Wasm 在这里不会为您提供帮助。该团队简要了解了 Tokamak,这是一个与 SwiftUI 兼容的框架,用于使用 WebAssembly 构建浏览器应用,但其成熟度还不足以满足应用需求。但是,如果您的应用在客户端代码中实现了强大的业务逻辑或算法,Wasm 将是您最好的伙伴。
  • 确保您的 Swift 代码库已准备就绪。界面层的软件设计模式或特定架构的软件设计模式可在界面逻辑与业务逻辑之间建立强有力的分离,非常方便,因为您无法重复使用界面层实现。简洁的架构或六边形架构原则也是基础,因为您必须为所有与 IO 相关的代码注入并提供依赖项。如果您遵循这些实现细节定义为抽象且大量使用依赖项反转原则的架构,就会更容易实现。
  • Wasm 不提供界面代码。因此,请确定要用于 Web 的界面框架。
  • JSKit 可帮助您将 Swift 代码与 JavaScript 集成,但请注意,如果您有热路径,则跨 JS-Swift 桥接的成本可能很高,因此您需要将其替换为导出的函数。如需详细了解 JSKit 的幕后运作方式,请参阅官方文档Swift 中的动态成员查找!这篇博文。
  • 能否重复使用您的架构将取决于您的应用所遵循的架构以及您所使用的异步代码执行机制库。MVVP 或可组合架构等模式有助于您重复使用视图模型和部分界面逻辑,而无需将实现与无法与 Wasm 一起使用的 UIKit 依赖项相结合。RXSwift 和其他库可能与 Wasm 不兼容,因此请谨记这一点,因为您必须在 Goodnotes 的 Swift 代码中使用 OpenCombine、async/await 和 stream。
  • 使用 gzip 或 brotli,压缩 Wasm 二进制文件。请记住,对于传统 Web 应用,二进制文件的大小会非常大。
  • 即使您可以在没有 PWA 的情况下使用 Wasm,也请确保至少添加一个 Service Worker,即使您的 Web 应用没有清单或者您不希望用户安装它。Service Worker 将免费保存并提供 Wasm 二进制文件以及所有应用资源,因此用户无需在每次打开您的项目时都下载这些资源。
  • 请注意,招聘可能比预期要难。您可能需要聘请有一定 Web 经验的高级 Web 开发者,或聘请有一定 Web 经验的高级 Swift 开发者。如果您能找到对这两个平台都有所了解的通才工程师,那就太棒了

总结

在开发充满挑战的产品的同时,使用复杂的技术栈构建 Web 项目将带来不可思议的体验。这将会困难重重 但绝对值得在为 iOS 应用开发新功能时,如果没有使用此方法,Goodnotes 永远无法发布适用于 Windows、Android、ChromeOS 和 Web 的版本。得益于此技术栈和 Goodnotes 的工程团队,Goodnotes 现在无处不在,并且该团队已准备好继续处理下一个挑战!如果您想详细了解此项目,可以观看 Goodnotes 团队在 2023 年 NSSpain 上发布的演讲。请务必试用 Goodnotes 网页版