随时随地使用 Goodnotes

Goodnotes 营销图片,显示一名女性在 iPad 上使用该产品。

过去两年,Goodnotes 工程团队一直在致力于一个项目,希望将这款广受欢迎的 iPad 记事应用推广到其他平台。本案例研究介绍了 2022 年 iPad 年度最佳应用如何通过 Web 技术和 WebAssembly 跨平台发布到 Web、ChromeOS、Android 和 Windows 平台,并重复使用该团队已经使用了十多年的 Swift 代码。

Goodnotes 徽标。

Goodnotes 为何推出网页版、Android 版和 Windows 版

2021 年,Goodnotes 仅以 iOS 和 iPad 应用的形式提供。Goodnotes 的工程团队接受了一项巨大的技术挑战:为其他操作系统和平台创建新版 Goodnotes。该产品应与 iOS 应用完全兼容,并呈现相同的记事。在 PDF 上记下的任何备注或附加的任何图片都应与 iOS 应用显示的笔触相同。添加的任何笔触都应与 iOS 用户可以创建的笔触相同,不受用户使用的工具(例如钢笔、荧光笔、钢笔、形状或橡皮擦)的影响。

显示手写笔记和草图的 Goodnotes 应用预览。

根据这些要求和工程团队的经验,该团队很快得出结论,由于 Swift 代码库已经过多年的编写和充分测试,因此重复使用该代码库是最佳做法。但为什么不直接将现有的 iOS/iPad 应用移植到其他平台或技术(例如 Flutter 或 Compose Multiplatform)?迁移到新平台需要重写 Goodnotes。这样做可能会导致已实现的 iOS 应用与从零开始构建的新应用之间展开开发竞赛,或者需要在新的代码库赶上进度时停止对现有应用的新开发。如果 Goodnotes 可以重复使用 Swift 代码,那么在跨平台团队致力于完善应用基础并实现功能一致性时,该团队就可以受益于 iOS 团队实现的新功能。

该产品已经解决了 iOS 在添加以下功能时遇到的许多有趣挑战:

  • 记事呈现。
  • 文档和备注同步。
  • 使用无冲突的复制数据类型对记事进行冲突解决。
  • 用于 AI 模型评估的数据分析。
  • 内容搜索和文档索引编制。
  • 自定义滚动体验和动画。
  • 所有界面层的视图模型实现。

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

Goodnotes 的技术栈

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

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

目标是将 Goodnotes 作为 PWA 发布,并能够在每个平台的应用商店中上架。除了已用于 iOS 的编程语言 Swift 和用于在 Web 上执行 Swift 代码的 WebAssembly 之外,该项目还使用了以下技术:

  • TypeScript:Web 技术中最常用的编程语言。
  • React 和 webpack:最受欢迎的 Web 框架和捆绑器。
  • PWA 和服务工件:是此项目的强大助手,因为团队可以将我们的应用作为离线应用发布,该应用的运作方式与任何其他 iOS 应用一样,您可以从商店或浏览器本身安装该应用。
  • PWABuilder:Goodnotes 用来将 PWA 封装到原生 Windows 二进制文件中的主要项目,以便团队能够通过 Microsoft 商店分发我们的应用。
  • 可信 Web Activity:该公司使用这项最重要的 Android 技术,在后台将我们的 PWA 分发为原生应用。

Goodnotes 技术栈,包括 Swift、Wasm、React 和 PWA。

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

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

为什么要使用 Wasm 和 Web?

虽然 Apple 尚未正式支持 Wasm,但 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 元素的界面迁移到基于全屏画布的文档界面。该项目一开始就使用 HTML 元素(就像任何其他网页一样)在 DOM 结构中显示与文档相关的所有备注和内容,但在某个时间点迁移到了全屏画布,以减少浏览器处理 DOM 更新的时间,从而提升低端设备上的性能。

工程团队发现,如果在项目一开始就做出以下更改,就可能减少遇到的一些问题。

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

文本编辑器

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

Goodnotes 文本编辑器。

这一挑战是项目路线图中最有趣的部分之一,因为它是根据用户的需求迭代解决的。这是一个工程问题,团队采用以用户为中心的方法加以解决,他们需要重写部分代码才能呈现文本,因此在第二个版本中启用了文本编辑功能。

迭代发布

该项目在过去两年间的演变令人惊叹。该团队开始着手开发项目的只读版本,几个月后就发布了一个具有大量编辑功能的全新版本。为了能够频繁将代码更改发布到生产环境,该团队决定广泛使用功能标志。对于每个版本,该团队都可以启用新功能,还可以发布实现新功能的代码更改,用户会在几周后看到这些新功能。不过,团队认为他们还有一些方面可以改进!他们认为,引入动态功能标志系统有助于加快速度,因为这样就不必重新部署即可更改标志值。这样一来,Goodnotes 将获得更大的灵活性,并且新功能的部署速度也会加快,因为 Goodnotes 无需将项目部署与产品版本相关联。

离线工作

该团队开发的一项主要功能是离线支持。能够修改文档是您对此类应用的预期功能之一。不过,这并不是一项简单的功能,因为 Goodnotes 支持协作。这意味着,不同用户在不同设备上所做的所有更改都应最终反映在每部设备上,而无需用户解决任何冲突。Goodnotes 早就通过在后台使用 CRDT 解决了这个问题。得益于这些无冲突的复制数据类型,Goodnotes 能够合并任何用户对任何文档所做的所有更改,并在不发生任何合并冲突的情况下合并这些更改。使用 IndexedDB 和可供网络浏览器使用的存储空间,是实现 Web 上协作式离线体验的重要因素。

离线使用的 Goodnotes 应用。

此外,由于 Wasm 二进制文件大小,打开 Goodnotes Web 应用会导致初始预下载费用约为 40MB。最初,Goodnotes 团队完全依赖于应用软件包本身和他们使用的大多数 API 端点的常规浏览器缓存,但事后看来,他们本可以更早地利用更可靠的 Cache API 和服务工件。该团队最初因其复杂性而回避此任务,但最终发现,Workbox 让此任务变得简单多了。

在 Web 上使用 Swift 时的建议

如果您的 iOS 应用包含大量您想重复使用的代码,请做好准备,因为您即将开启一段精彩的旅程。在开始之前,您可以先了解一些提示。

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

总结

使用复杂的技术栈构建 Web 项目,同时开发充满挑战的产品,这是一种难得的体验。这将会很难,但绝对值得。如果不采用这种方法,Goodnotes 就无法在为 iOS 应用开发新功能的同时,发布适用于 Windows、Android、ChromeOS 和网页的版本。得益于这一技术栈和 Goodnotes 的工程团队,Goodnotes 现已全面支持多平台,并且该团队也已准备好继续应对后续的挑战!如果您想详细了解此项目,可以观看 Goodnotes 团队在 2023 年 NSSpain 大会上发表的演讲。请务必试用 Goodnotes 网页版