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 的处理.