Skip to main content

AppFlowy 阅读

参考如下链接以及 Appflowy 的源码:

AppFlowy 的架构介绍博文精读(翻译)

来源: https://blog-appflowy.ghost.io/tech-design-flutter-rust/

AppFlowy 是一个开源的 Notion 代替品, 使用 Flutter 和 Rust 构造. 本文主要是针对开发者讲解 AppFlowy 的技术设计的, 包含如下主题:

  • AppFlowy 的 DDD 设计
  • 使用 Flutter 兼容多种平台时候的策略
  • Rust 在工程中扮演的角色
  • 代码库中的一个场景详解

分层架构

领域驱动设计

AppFlowy 的前端使用 DDD 架构. 由: 表示层, 业务逻辑层, 领域层, 基础设施层组成. 为让基础设施层具备可移植性, 且为了更好的性能和内存安全, 故使用 Rust 实现这一层. 其他的层则使用 Flutter 实现. 将这些层分离为两类组件更易理解: UI 组件, 数据组件.

1

分层解释

表示层

表示层负责将信息展示给用户, 并实现用户交互. 这一层主要由 Widget 和 Widget 的状态构成.

应用层

定义应用的工作.(UI 和网络代码不会放到这一层). 这层负责协调应用的活动, 并将工作代理到领域层.

这层不会包含任何复杂的业务逻辑, 仅在用户输入后进行验证, 并将输入数据传递给领域层.

领域层

负责表示业务概念. 管理业务状态, 或将业务状态代理到基础设施层. 这层不依赖任何其他层, 即和其他层有很好的隔离.

基础设施层

提供通用的底层能力以便对上层提供支持. 负责处理 API, 数据持久化, 网络交互等.

这层实现了仓库模式的接口, 将领域层的复杂性进行隐藏.

架构考虑

下图中展示了每一层的抽象, 高层使用低层提供的能力, 各层提供向上和向下的接口抽象. 表示层的抽象最高且复杂性最小, 基础设施层抽象最低且复杂性最高. 应将复杂性尽量下移, 这样可以简化应用内部其他地方. 另外在依赖的方向上, 总是高层依赖低层, 且低层绝不会依赖高层, 比如领域层就不能依赖表示层.

2

使用 Flutter 的必要性

我们要保证提供 Notion 类似的能力, 且实现数据安全和跨平台的一致体验, 因此需要:

  • 保证数据隐私安全
  • 可靠的原生使用体验
  • 社区驱动的可扩展性

因此 Flutter 是目前最佳的选择. 但 Flutter 目前还比较新, 可能会有这样的疑问: 万一 Flutter 无法完美支持某个特定平台, 那应该怎么做?

针对这个疑问, 最好是尽量将重写 Widget 的开销降到最低. 在实践时, 尽量将 UI 组件纯粹化, 即 UI 组件仅负责 UI 渲染, 将业务逻辑下移至数据组件中. 这样当 UI 组件需要改变时, 数据组件不会进行任何改变. 也因此, 基础设施层是混合了 Dart/JS/Swift 和 Rust 的实现, 如下图所示:

3

APP 中最复杂的层也就是基础设施层. 为了减少复杂性, 将基础设施层分离为两个部分: 接口和实现. 为此提供了 FlowySDK.

SDK 包含 Dart 编写的接口定义, 以及 Rust 写的接口实现. 使用 Dart FFI, 可以非常方便地将接口和实现绑定. 比如 Dart 定义了一个 HelloWorld 接口, 对应 rust 中的 hello_world 实现, 二者通过 HelloWorldEvent 进行映射. 当触发事件后, 事件通过 dart_ffi 发送给 FlowySDK. 在 SDK 内部, 实现了事件和对应组件的映射. 每个组件都声明了它自己可以处理的事件, 并在 SDK 初始化时将自己注册到 SDK 中的事件调度器:

4

使用事件调度器的优点有:

  1. 很好的横向扩展能力:

    我们可以非常容易地添加或移除模块, 比如 flowy 的 user 模块将自己注册到了事件调度器, 当对应事件发生时, 事件可以被正确调度给 user 模块处理. 并且模块可以打包为动态库, 这样可以在运行时动态链接, 可选地将它插入到事件调度器中. 下面的代码演示了模块内部的事件-处理函数映射, 并且进行模块的动态创建:

    5

  2. 良好的可移植性和灵活性:

    可以非常简单地将将 FlowySDK 集成到其他的平台, 因为它本身的 FFI 接口非常简单, 只要支持 C 的平台都支持:

    6

  3. 精确的资源控制

    可以简单地为不同事件分配不同的 CPU/IO 资源/优先级等, 比如音频处理事件的 CPU 资源分配优先级就高于其他的事件.

使用事件调度器的缺点有:

  1. 性能问题

    由于事件在 FFI 传递后, 可能需要还原事件数据结构(不同语言间的转换), 目前使用 Protobuf 实现 Flutter 和 Rust 的沟通, 但 Protobuf 有开销, 当业务规模变大后, 序列化和反序列化数据就成了一个主要的性能瓶颈.

  2. 认知负荷

    事件调度器也有它的缺点, 目前的特性决定了实现一个函数会有非常多的额外工作, 比如为什么不和 flutter rust bridge 做法一样直接使用 CodeGen 来根据 Rust 函数生成 Dart 的函数呢? 原因是 Flutter 在这个工程开始开发时对桌面和 Web 的支持还并不完善, 当时考虑如果后面 Flutter 对 macOS 原生支持不好或者存在性能问题的话, 可以很快地替换为 macOS 的原生来实现 UI. 因此, 我们就还需要一个 swift rust bridge, 但这个工作需要更多的时间, 且由于是两个团队(UI 和 底层), 因此选择了一个折中的方案, 即事件调度器.

    7

AppFlowy 前端

模块划分

AppFlowy 被划分为若干模块, 每个都有自己独立的功能. 通过模块化的架构, 对一个模块的改变不会影响到其他模块, 且用户需求的改变也可以进行隔离处理. 当前 AppFlowy 的架构划分为 Core 和 User 模块, 每个模块又都包含两个组成部分, 如下图所示:

8

左侧部分是由 Flutter 实现的 DDD 架构, 并且关注 UI 渲染. 右侧部分由 Rust 实现, 关注数据处理.

Core 模块

Core 模块定义了基本的上下文边界, 并作为容器协调内部各个模块.

实体的作用是表达业务逻辑, 实体可用被外界引用. Core 模块中最基本的实体和它们间的关系如下图所示:

9

从图中可用看到, User 有若干 Workspace, 每个 Workspace 包含若干 App, 每个 APP 又由若干 View 构成.

View 本身是递归定义的, 即它可以自包含(树结构), View 提供了任意可展示对象的抽象, 而目前只有 Document 一种 View(后续可能继续添加实现其他类型 View):

10

在实现每种 entity 的业务逻辑时, 使用 flutter_bloc 作支持.

下面继续通过图示看 AppFlowy 中是如何利用 DDD 来实现业务规则的:

11

根据图中的内容看:

  1. 总流程是这样的: Widget 接受用户输入, 将交互转换为对应的 Bloc 事件. 事件被 Bloc 收到并计算对应状态改变, 然后将状态变化回传给 Widget 进行更新展示. 这里的 Bloc 对应的是 DDD 中的应用层, Bloc 中依赖 Domain 层提供的仓库接口或其他服务接口.

  2. 细看 Bloc: 仅仅将输入数据传递给 Domain 层.

  3. Domain 层中定义了仓库接口和其他的数据模型, 用以实现业务需求, 且其中包含 protobuf 生成的 Rust 侧数据模型. 比如其中的一个 proto 文件是通过 rust 的 workspace.rs 中各种 struct 定义生成的. 而 proto 文件又会进一步生成 workspace.rs 和 workspace.dart 文件. 他们表示相同的 struct, 但使用不同的语言实现. 使用 protobuf 的一个优点是可以非常简单地沟通 Flutter 侧和 Rust 侧的数据. 但序列化和反序列化有开销. 整个生成过程如下所示:

    12

    针对普通应用场景还好, 但特殊场景的话, 可能就有比较大的性能瓶颈. 比如当处理图片时, 内存问题尤其明显. 不过有若干的方法可以进行优化. 在这个步骤中, dart 对象会被封装到请求, 然后传递给基础设施层.

  4. 序列化请求为二进制数据, 然后通过 dart_ffi 发送给 FlowySDK.

  5. 请求会被事件调度器调度给具体的请求处理句柄进行处理, 句柄会接收请求的内部数据. 每个模块都声明了自己可以处理的事件并将自己注册到事件调度器.

  6. 处理句柄展开二进制数据并进行反序列化, 反序列化后生成对应的结构体, 然后根据数据结构对应进行业务逻辑的处理.

  7. 处理完成后, 响应数据会被再次序列化并发送给事件调度器.

  8. 响应中还包含了状态码, 响应的二进制数据会被传递给请求的发起方.

  9. 响应的二进制数据会再次被反序列化为 dart 对象. 使用 CodeGen 将二进制数据自动映射为 dart 对象. 详见 code_gen.dart

  10. 然后将 protobuf 对象传递给上层.

  11. Bloc 等待 future 完成, 而后根据状态变化决定是否重建 Widget 以更新展示.

跑起来并开始看源码

主要关注 Dart 和 Rust 交互的部分, 代码生成, ffi 的处理.