Appflowy是Notion这款笔记软件的开源替代品,Appflowy用Rust和Flutter打造而成的。有关Appflowy的介绍可以访问其官网www.appflowy.io查看。
本文的目标读者是对Appflowy的技术实现感兴趣的黑客和开发者们。Appflowy可作为人们交流思想和共同构建知识体系的工具。本文主要阐述的是Appflowy的几个大家比较好奇的内容:
- Appflowy的DDD设计
- 采用Flutter来支持跨平台的策略
- Rust在项目中扮演什么角色
- 代码阅读指南
1. 层级架构
1.1 领域驱动设计(DDD)
AppFlowy的前端遵循领域驱动设计范式。它由表示层(presentation)、应用层(application)、领域层(domain)和基础结构层(infrastructure)组成。为了使基础架构层(infrastructure)更具可移植性,我们决定使用Rust来实现这一层,当然我们也很稀罕它的高性能和内存安全特性。除此之外,其他层级我们均使用了Flutter来实现,下面我们会介绍为什么用Flutter。我们将这4个层分拆为UI和数据两个组件,以便开发人员更好地理解。
1.2 层的定义
本小节介绍的层的概念均来自DDD设计,如果您过去已经了解过,您完全可以略过这部分内容。
表示层
负责向用户呈现信息并解释用户命令。
由widget和widget的状态组成。
应用程序层
定义软件应该执行的作业(UI代码或网络代码不在此处)。
协调应用程序活动并将工作委派给领域层。
不包含任何复杂的业务逻辑,而是在将用户输入传递到领域层之前对用户输入进行的基本验证。
领域层
负责表示业务概念。
管理业务状态或委托给基础结构层
自包含,不依赖于任何其他层。领域层应与其他层很好地隔离。
基础结构层
提供通用的技术功能用于支持上层的应用。
处理API、持久性、网络等。
实现存储库接口并隐藏领域层的复杂性。
其他考虑
每一层的抽象和复杂度不同,如下图所示。较高层使用较低层提供的功能,并且每层提供与其上方和下方层不同的抽象。表示层具有高抽象和低复杂性,而基础结构层具有较低的抽象和较高的复杂度。我们应该始终降低复杂度,因为这将导致应用程序中其他位置的许多简化。我们应该注意的另一件事是依赖方向。较高的层依赖于较低的层,但较低的层不得依赖于较高的层。例如,领域层不应依赖于表示层。
1.3 Flutter的价值——跨平台
我们的使命是让任何人都可以创建适合自己需求的应用程序。目标是提供Notion的功能外加数据安全性和跨平台的原生体验。我们通过坚持三个最基本的价值观来实现这一使命:
- 数据隐私第一
- 可靠的原生体验
- 社区驱动
Flutter是Google发布的一个用于创建跨平台、高性能移动应用的框架。要了解更多信息,您可以在其官方网站flutter.dev上查看 。
由于Flutter相对较新,您可能想知道:
如果Flutter在其中一个平台上表现得不够出色,我们要如何应对?
我们同样关心这个问题。AppFlowy对冲这种风险的策略是以最低的成本重写UI组件(表现层、应用层和领域层)。以下说明我们将如何处理它。我们让UI组件尽可能纯净,专注于UI呈现,并将复杂的业务逻辑留给数据组件(基础结构层)。因此,如果UI组件从一个平台切换到另一个平台,则数据组件不必更改,如下图所示。基础设施层将成为在Dart/JS/Swift和Rust中实现的混合基础设施层。
最复杂的层是基础结构层。但是,我们将基础结构层分为两部分:接口和实现。我们创造了一个术语,FlowySDK,它在Dart中定义接口,在Rust中实现。多亏了Dart的FFI,让接口与其实现的绑定变得简单。例如,Dart中的某个接口叫做helloWorld(),对应的在Rust中的实现是hello_world(),它们通过HelloWorldEvent进行映射。当调用到helloWorld()的时候,HelloWorldEvent事件将通过dart_ffi发送,然后传递到FlowySDK内部。在FlowySDK中有一个映射表记录着事件和与之对应的组件。组件在FlowySDK初始化时声明并注册需要监听的事件。
我们将这种模式命名为事件调度。
优点:
- 方便扩展
我们可以轻松添加或删除模块。例如,flowy的用户模块将自身注册到事件调度系统。当相应的事件发生时,将调用该处理程序。此外,我们可以将模块转换为动态库并按需加载,从而提高性能。
- 可移植性强
将FlowySDK集成到不同的平台很容易,因为FFI接口很简单。
- 更精细的控制
我们可以使用不同的CPU/IO资源处理不同类别的事件。例如,在分配CPU资源时,音频处理事件的优先级应高于别的事件。
缺点:
- 性能问题
我们使用protobuf来进行Flutter和Rust间的通信,这会损耗一些性能。序列化和反序列化的时间将随着业务的增加而增长。
- 认知负荷
事件调度有其缺点,实现函数似乎有点太麻烦了。那为什么我们不直接使用CodeGen从Rust的函数生成Dart的函数?就像Flutter Rust Bridge所做的那样呢?原因是在我们写AppFlowy的时候,Flutter在Web和桌面环境中还没有得到很好的支持。如果Flutter Mac桌面的性能不符合我们的需求,我们将不得不在macOS本机上实现桌面。因此,我们还需要开发swift_rust_bridge,这需要额外的工作。鉴于我们目前是一个两人团队,我们选择了一个中间选项,即事件调度。
2. Appflowy前端
2.1. 模块
AppFlowy被分为许多模块,每个模块都有独立的特性和功能。使用模块化架构,使得我们在更改一个模块后不会影响其他模块的功能,开发人员可以根据个人客户需求或偏好定制应用程序。目前,AppFlowy由Core和User模块组成,每个模块都有两个部分,如下所示。在 Flutter 中实现的左侧部分(紫色)遵循 DDD 设计模式,并专注于 UI 呈现。由 Rust crate组成的右侧部分(黄色)侧重于数据处理,我们将在核心模块中探讨关于它的更多细节。
2.2. 核心模块
核心模块为AppFlowy应用程序定义了基础的上下文,同时也作为协调其他各个模块的容器而存在。
每个"enties"都有一个自己的ID,它们是可以被引用的。您可以使用"enties"来表达您的业务。
用户可以拥有多个工作区,每个工作区都包含许多应用。每个应用由多个视图组成。视图是一个独立的对象,并为任何可显示的对象提供抽象。在撰写本文时,我们只定义了Document对象。
我们用flutter_bloc实现每个entity的业务。
下面让我们来看看AppFlowy是如何使用DDD来实现业务规则的。
-
Widget将收到的用户交互信息转换成Bloc事件,这些事件会被发送到特定的Bloc。反之,Bloc也发送消息给widgets,widgets再将UI更新为最新的状态。此处的 Bloc表示DDD中的应用层,该层使用领域层提供的存储库或服务来处理Bloc事件。
-
只需将数据传播到领域层。
-
存储库定义了实现其业务需求的接口和数据模型。我们使用从Rust端生成的protobuf来描述数据模型。例如,proto文件是从rust结构workspace.rs生成的,它将创建workspace.dart和workspace.rs(protobuf生成的文件)。它们表示相同的结构,但以不同的语言实现。使用protobuf可以更轻松地将数据从Flutter端转换为Rust端,反之亦然。但是,序列化和反序列化是有代价的。
通常情况下它运行得很好,但在某些情况下会导致严重的性能问题。例如,处理图像时出现内存问题。有许多方法可以优化这个问题,但在此我们选择不深入研究细节。在此步骤中,dart对象将被包装到请求中并传播到基础结构层。
-
将请求序列化为二进制数据,并通过Dart_ffi将其发送到FlowySDK。
-
请求将由分发器安排。调度程序查找请求的处理程序,然后使用其数据对其进行调用。每个模块声明它可以处理的事件,并将自身注册到调度程序。
-
处理程序提取二进制数据,并根据事件将其反序列化为特定的数据结构,并执行一些业务逻辑。
-
将返回值序列化为二进制数据,并将其发送到调度程序。
-
响应包含状态代码,二进制数据作为返回值传递给调用方。
-
将二进制数据反序列化为特定的dart对象。我们使用CodeGen自动将二进制数据映射到dart对象。您可以查看code_gen.dart以获取更多信息。
-
将protobuf对象传播到上层。
-
Bloc等待future的完成,然后根据状态更新widget。