Appflowy is an open source alternative to The Notion note-taking software, built with Rust and Flutter. An introduction to Appflowy can be found at www.appflowy.io.
This article is aimed at hackers and developers interested in the technical implementation of Appflowy. Appflowy serves as a tool for people to exchange ideas and build knowledge together. This article focuses on a few things about Appflowy that you are curious about:
- Appflowy DDD design
- Use Flutter to support a cross-platform strategy
- What is Rust’s role in the project
- Code Reading Guide
1. Hierarchical architecture
1.1 Domain-Driven Design (DDD)
The front end of AppFlowy follows the domain-driven design paradigm. It consists of presentation layer, application layer, Domain layer and infrastructure layer. In order to make the infrastructure layer more portable, we decided to use Rust to implement it, and of course we appreciated its high performance and memory security features. In addition to this, all other levels are implemented using Flutter. We will explain why Flutter is used below. We split these four layers into UI and data components for developers to better understand.
1.2 Definition of layers
The layer concepts introduced in this section are derived from DDD design, and you can skip this section if you’ve already seen it.
The presentation layer
Responsible for presenting information to the user and interpreting user commands.
It consists of widgets and the status of widgets.
Application layer
Define the jobs the software should perform (UI code or network code is not here).
Coordinate application activities and delegate work to the domain layer.
Does not contain any complex business logic, but is a basic validation of user input before it is passed to the domain layer.
Domain layer
Responsible for presenting business concepts.
Manage business state or delegate to the infrastructure layer
Self-contained, independent of any other layers. The domain layer should be well isolated from other layers.
Infrastructure layer
Provides common technical functionality to support upper-level applications.
Handles apis, persistence, networking, and more.
Implement the repository interface and hide the complexity of the domain layer.
Other consideration
Each layer is different in abstraction and complexity, as shown in the figure below. The higher layers use the functionality provided by the lower layers, and each layer provides a different abstraction from the layers above and below it. The presentation layer has high abstraction and low complexity, while the infrastructure layer has low abstraction and high complexity. We should always reduce complexity, as this leads to a lot of simplification elsewhere in the application. Another thing we should pay attention to is dependence on direction. The higher layer depends on the lower layer, but the lower layer must not depend on the higher layer. For example, the domain layer should not depend on the presentation layer.
1.3 Value of Flutter — Cross-platform
Our mission is to make it possible for anyone to create an application that fits their needs. The goal is to provide Notion functionality plus data security and a cross-platform native experience. We do this by upholding three fundamental values:
- Data Privacy first
- Solid native experience
- community-driven
Flutter is a framework released by Google for creating cross-platform, high-performance mobile applications. To learn more, you can check out its official website, flutter. Dev.
Since Flutter is relatively new, you may be wondering:
If Flutter doesn’t perform well on one of these platforms, how do we respond?
We are equally concerned about this issue. AppFlowy’s strategy for hedging this risk is to rewrite the UI components (the presentation, application, and domain layers) at minimal cost. Here’s how we’ll handle it. We kept the UI component as pure as possible, focused on UI rendering, and left the complex business logic to the data component (infrastructure layer). Therefore, if the UI component switches from one platform to another, the data component does not have to change, as shown in the figure below. The infrastructure layer will be a hybrid infrastructure layer implemented in Dart/JS/Swift and Rust.
The most complex layer is the infrastructure layer. However, we split the infrastructure layer into two parts: interfaces and implementations. We coined a term, FlowySDK, that defines the interface in Dart and implements it in Rust. Thanks to Dart’s FFI, binding the interface to its implementation is easy. For example, an interface in Dart is called helloWorld(), and the corresponding implementation in Rust is hello_world(), which is mapped through HelloWorldEvent. When helloWorld() is called, the HelloWorldEvent event is sent via dart_ffi and then passed inside FlowySDK. There is a mapping table in FlowySDK that records events and their corresponding components. The component declares and registers the events to listen for when FlowySDK is initialized.
We named this pattern event scheduling.
Advantages:
- Convenient extension
We can easily add or remove modules. For example, flowy’s user module registers itself with the event scheduling system. This handler is called when the corresponding event occurs. In addition, we can improve performance by converting modules into dynamic libraries and loading them on demand.
- Portability is strong
Integrating FlowySDK into different platforms is easy because of the simplicity of the FFI interface.
- Finer controls
We can use different CPU/IO resources to handle different types of events. For example, when allocating CPU resources, audio processing events should be prioritized over other events.
Disadvantages:
- Performance issues
We use Protobuf to communicate between the Flutter and Rust, which degrades some performance. The serialization and deserialization time will increase as the business increases.
- The cognitive load
Event scheduling has its drawbacks, and implementing functions can seem a bit too cumbersome. So why don’t we just use CodeGen to generate the Dart function from Rust’s function? Just like the Flutter Rust Bridge did? The reason for this is that Flutter was not well supported in Web and desktop environments at the time we wrote AppFlowy. If the Performance of the Flutter Mac desktop does not meet our requirements, we will have to implement the desktop on macOS native machines. Therefore, we also need to develop the Swift_RUst_Bridge, which requires additional work. Since we are currently a two-person team, we chose the middle option, event scheduling.
2. Appflowy front end
2.1. The module
AppFlowy is divided into many modules, each with independent features and functions. With a modular architecture, we can change one module without affecting the functionality of other modules, and developers can customize applications based on individual customer needs or preferences. Currently, AppFlowy consists of Core and User modules, each of which has two parts, as shown below. The left part (purple) implemented in Flutter follows the DDD design pattern and focuses on UI presentation. The right-hand section (yellow), made up of Rust Crate, focuses on data processing, which we’ll explore in more detail in the core module.
2.2. Core modules
The core module defines the underlying context for the AppFlowy application and also serves as a container for coordinating various other modules.
Each “enties” has its own ID, and they can be referenced. You can use “enties” to express your business.
Users can have multiple workspaces, each containing many applications. Each application consists of multiple views. A view is a stand-alone object and provides an abstraction for any displayable object. At the time of writing this article, we defined only the Document object.
We use Flutter_bloc for each entity’s business.
Let’s take a look at how AppFlowy uses DDD to implement business rules.
-
The Widget converts the received user interaction into Bloc events, which are sent to a particular Bloc. Bloc, in turn, sends a message to the widgets, which then updates the UI to the latest status. Bloc here represents the application layer in DDD that processes Bloc events using repositories or services provided by the domain layer.
-
Just propagate the data to the domain layer.
-
A repository defines the interfaces and data models that implement its business requirements. We use a Protobuf generated from Rust to describe the data model. For example, the proto file is generated from the rust structure workspace. Rs, which creates workspace. Dart and workspace. They represent the same structure, but are implemented in different languages. Using Protobuf makes it easier to convert data from the Flutter side to the Rust side and vice versa. However, serialization and deserialization come at a cost.
It works fine in general, but can cause serious performance problems in some cases. For example, memory problems occur when processing images. There are many ways to optimize this problem, but we choose not to delve into the details here. In this step, the DART object is wrapped into the request and propagated to the infrastructure layer.
-
Serialize the request into binary data and send it to FlowySDK via Dart_ffi.
-
Requests will be arranged by the distributor. The scheduler finds the requested handler and then invokes it with its data. Each module declares events it can handle and registers itself with the scheduler.
-
Handlers extract binary data, deserialize it to a specific data structure based on events, and perform some business logic.
-
Serialize the return value into binary data and send it to the scheduler.
-
The response contains the status code, and the binary data is passed to the caller as the return value.
-
Deserialize binary data into a specific DART object. We used CodeGen to automatically map binary data to dart objects. You can check out code_gen.dart for more information.
-
Propagates a Protobuf object to the upper layer.
-
Bloc waits for the Future to complete and then updates the widget based on the status.