If you are a developer that is curious about how you can support a marketplace where users can get extensions for your application, this article is for you. Let’s get started!
Background
AppFlowy is an open-source project that emphasizes user customization by offering an editor that allows developers to inject custom code into the AppFlowy application. At AppFlowy we call this injected code a plugin.
The primary interface that allows plugin injection is the AppFlowy Editor. This is a core feature that allows users to edit rich text for their notes.
If you are a developer that is curious about how you can support a marketplace where users can get extensions for your application, this article is for you. Let’s get started!
We’ll start by exploring an example that showcases how a developer injects their plugins into the AppFlowy Editor.
Please read the in-line comments before continuing.
1// this code is currently AOT compiled the app2return AppFlowyEditor(3 editorState: editorState,4 shortcutEvents: const [],5 // this is an API that a developer can use to inject their plugin6 customBuilders: {7 // What we want is to we want to provide this at runtime...8 'local_image': LocalImageNodeWidgetBuilder(),9 },10);
In this example, the code leverages the interface from the editor to render local images in the editor.
The following picture demonstrates what the added functionality may look like (note that this is a local image and this feature was made available in the 0.2.5 release).
By utilizing the
AppFlowyEditor
API, the code seamlessly integrates a Network image into the editor. If a developer wants a plugin they have developed to be used by an end user, they can either:- Submit a pull request to AppFlowy and have this plugin integrated into our version of the application.
- Create their own version of AppFlowy with this plugin and publish it for end user consumption.
How to Handle Scalability Challenges with Plugins
Suppose that the added plugin caused the editor to render its content slower. Or suppose that the added plugin caused the release size to increase significantly. The team would need to consider whether the added plugin was a net benefit to our user base, whether the plugin’s opportunity cost warranted shipping the feature in the next release.
Some users may require the
_NetworkImageNodeWidgetBuilder_
while others do not. To make everyone happy we may need to create one version of the application with the plugin and one without the plugin.Now we are faced with scalability challenges for anyone developing plugins because:
- The plugin developer needs to maintain their own version of AppFlowy with the plugin.
- Or the team needs to accept this plugin in our version. If not, we need to maintain another version with this plugin.
This leads to an issue where non-developers may need to download multiple versions of AppFlowy if developer X has built AppFlowy with plugin Y and developer A has built AppFlowy with plugin B.
This could lead to the number of AppFlowy versions growing exponentially. When there are n distinct plugins, there are a total of 2^n versions of AppFlowy. With just 1 plugin, we have one version with the plugin and one version without. With just 5 plugins we would end up with 32 distinct versions of AppFlowy.
This lack of scalability indicates the need for a more efficient solution.
Objective
The objective now becomes streamlining the release process by consolidating it the application into a single release that allows users to download plugins on demand.
In doing so, we aim to enable the execution of 3rd party plugins within our application, treating it as an integrated component. The user should be able to download and enable 3rd party developer plugins with the click of a button from within the application, not by downloading a different version.
Solutions
In our pursuit of achieving the desired functionality, we explored many potential approaches.
The following sections describe the potential solutions that could enable the integration of 3rd party plugins within our application.
By evaluating these options, we aimed to identify the most suitable approach that aligned with our objectives and technical bandwidth.
Approach #1: Over-the-Air Frameworks
Over-the-Air (OTA) update frameworks facilitate wireless software updates by delivering code, that wasn’t previously in the software, from a server. Furthermore, OTA updates allow new features to be delivered without 3rd party services, like the App Store.
Typically, these updates are transmitted as deltas, which are unpacked and applied by the framework on the client side. Effective management of the update process necessitates a server to handle code push operations.
The following code block illustrates how AppFlowy might use an OTA update framework to implement dynamic plugins.
1@override2void initState() {3 super.initState();4 // grab a list of plugins, maybe as a url, to query a server for the plugin code5 plugins = database["plugins"].map((row) => row.plugin_name));6}7
8return AppFlowyEditor(9 editorState: editorState,10 shortcutEvents: const [],11 customBuilders: {12 // here we append the default plugins13 ...defaultPlugins,14 // suppose that we query the server for the code an load the plugins here asynchronously15 ...plugins.map((name) => { name, OTAWidget(url: '<https://www.example.com/$version/$name>')})16 }17);
In the example, workspace plugin preferences are stored, and on startup the app uses those preferences to request the plugins over the air.
Benefits:
- Low cost implementation in existing codebase.
- Plugins aren’t persisted on a user’s machine.
- Plugins are always up to date.
Consequences:
- Requires hosting an OTA update server.
- Requires a robust versioning and backward compatibility policy.
- Flutter OTA frameworks have poor support.
- Some are unavailable for desktop or web.
- Some require the use of specific or older versions of Flutter.
- Apps that use OTA updates can be banned by the App Store or Google Play Store because they circumvent the approval process.
Introducing New Complexities
Implementing an OTA framework to enable customized release builds, but would entail addressing the following requirements:
- Handling user requests for adding plugins to applications.
- Determining which deltas should be applied to each user’s application.
- Deploying updates exclusively to the designated application without affecting others.
Maintaining a deployment framework would impose considerable technical cost to the team considering:
- Scalability challenges: If AppFlowy has millions of users, the servers will need to scale with the number of requests that are received.
- Security & Reliability challenges: Attackers may submit millions of requests to our plugin server infrastructure as a denial of service attack and may cause an outage. Many users will not have access to their plugins during this time.
- Maintainability challenges: Frameworks that allow code push are not widely supported by the Flutter community at this point, and the team would need to allocate sufficient resources to ensuring that these frameworks are robust enough to handle millions of users.
Due to the potential challenges that the team may face, I decided against pursuing an approach that involved OTA updates and pursued other options.
Just to note, the frameworks that I’m about to mention do their job well, they just didn’t fit our use case! Please do your best to support the following teams that are working on OTA updates in all of their endeavors.
Notable mentions: Shorebird.dev and Flutter Fair
Approach #2: Isolates and Dynamic Libraries
It is also possible to load plugins using dart’s
DynamicLibrary
or by using Isolates
. However, dynamically loaded libraries in dart depend on reflection to analyze code via dart:mirrors
. The following code block shows rough pseudocode demonstrating how the AppFlowy application may load a 3rd party plugin using isolates.1import 'dart:mirrors';2
3void main() {4 // Define the new Dart code as a string5 // this string can be loaded over the air6 String newCode = """7 void main() {8 print("Hello, world!");9 }10 """;11
12 // Use the `compileSource` function to compile the new code13 LibraryMirror library = currentMirrorSystem().isolate.rootLibrary;14 CompilationUnit compilationUnit = parseCompilationUnit(newCode);15 library.define(new CompilationUnitMember(library, compilationUnit));16
17 // Use the `invoke` function to execute the new code18 MethodMirror mainMethod = library.declarations[new Symbol('main')];19 InstanceMirror result = currentMirrorSystem().invoke(mainMethod, []);20}
Unfortunately the
dart:mirrors
library is not available for use in Flutter apps, due to its size and complexity.If I recall correctly, this was a decision made by the Flutter team to improve the performance of released applications.
While there are workarounds, the workarounds would be solely supported by the AppFlowy team and would incur significant cost. Therefore, loading code with isolates and dynamic libraries was not considered as candidate for loading 3rd party plugins in AppFlowy. Use this approach if your application can run using Dart without Flutter!
flutter_eval
and dart_eval
flutter_eval
and dart_eval
are both tools used for evaluating and executing Dart code at runtime.dart_eval
is a bytecode compiler and runtime for Dart which makes it a viable candidate for loading 3rd party plugins in AppFlowy.Let’s walk through an example of how we might use the two frameworks to create a dynamically loaded 3rd party plugin that inserts a double divider when the user types
==
in the editor. Here’s the code for the shortcut event that we want to load into the editor.1ShortcutEvent insertDoubleDivider = ShortcutEvent(2 key: 'insert_double_divider',3 command: 'Equal',4 handler: (editorState, event) {5 int? selection = editorState.service.selectionService.currentSelection;6 if (selection == null) {7 return KeyEventResult.ignored;8 }9
10 final TextNode textNode = textNodes.whereType<TextNode>().first;11 String text = textNode.toPlainText();12 if (text.contains('==')) {13 // insert double divider14 return KeyEventResult.handled;15 }16 return KeyEventResult.ignored;17 },18);
It’s worth mentioning now, that the code above when it is loaded into the application has no meaning since it hasn’t been semantically analyzed. To the application, it’s just a string.
To obtain it’s semantic information, the plugin needs to be analyzed by the
dart_eval
framework while the app is running.dart_eval
can only analyze this string based on what it currently knows about dart. This is a proper separation of concerns and does not imply anything wrong with the package itself.However, since the
ShortcutEvent
object is something that we implemented, dart_eval
needs to know what a ShortcutEvent
is. And for dart_eval
to understand what a ShortcutEvent
, we need to extend the dart_eval
package by creating a interop library.The interop library will provide
dart_eval
with all of the information that it needs to analyze the 3rd party code’s use of our ShortcutEvent
.Building the Interop Library
Our next task is to allow instances of
ShortcutEvent
the plugin code to be analyzed correctly. For that, our interop library needs to know everything about ShortcutEvent
.For example,
- What are its constructors?
- What are its constructor's parameters?
- What are the methods?
- What are the parameters of the methods?
The list goes on for every method, getter, setter, and field in the class.After we implement our interop class, we have about 250+ lines of code.
The point is, that’s tedious. We should probably avoid implementing it by hand.
To make matters worse, those lines of code that we just wrote for our interop library only help us analyze the first line of code from the double divider plugin… we have a long way to go.
For example, consider this statement in our double divider plugin.
1final TextNode textNode = textNodes.whereType<TextNode>().first;
It turns out that the interop for
whereType<TextNode>()
wasn’t implemented in dart_eval
for Iterable
.That’s
dart_eval
's responsibility, rightly so.We should probably implement it in
dart_eval
.Let’s clone
dart_eval
locally, make the change, and pray that our change is accepted into that open-source repository.But now that we depend on our updates to
dart_eval
locally we can’t depend on the public version of dart_eval
anymore. Therefore, we need to clone it and add it as a path dependency in flowy_eval
. So now our fork of dart_eval
becomes our responsibility to maintain.Oh, I also forgot to mention, this line from our plugin, is more of the same!
1return KeyEventResult.handled;
KeyEventResult
is from the Flutter framework. It’s not in dart_eval
it’s in the interop library called flutter_eval
, and it hasn’t been implemented in flutter_eval
yet. So we also clone that and add the change. Same story as dart_eval
now, we need to maintain our fork on our own.The Plugin Developer Experience
At this point, I took a step back to also consider the plugin developer’s experience.
Look at the double divider code once again.
We’ve come a long way.
1import 'package:flutter/material.dart';2import 'package:appflowy_editor/appflowy_editor.dart';3// etc. other imports here that would make the red squiggles go away4
5ShortcutEvent insertDoubleDivider = ShortcutEvent(6 key: 'insert_double_divider',7 command: 'Equal',8 handler: (editorState, event) {9 int? selection = editorState.service.selectionService.currentSelection;10 if (selection == null) {11 return KeyEventResult.ignored;12 }13
14 final TextNode textNode = textNodes.whereType<TextNode>().first;15 String text = textNode.toPlainText();16 if (text.contains('==')) {17 // insert double divider18 return KeyEventResult.handled;19 }20 return KeyEventResult.ignored;21 },22);
The “gotcha” here is that the plugin developer would import
material
, appflowy_editor
, dart:collection
, and assume that there are no plugin compilation errors, because… well… Intellisense says so.However, Intellisense evaluates code from the dart language server, not the
dart_eval
framework.TL;DR - If the necessary interop to evaluate the plugin is missing, it will show up as a runtime error, not a compile time error. It is normally A VERY opaque runtime error.
To solve this, I created a barrel file in my interop library that would only show the classes with interop that was implemented for AppFlowy. This is still prone to PEBKAC errors on the team’s end.
This is what that file looks like.
1/// Available classes from Appflowy Editor and Flowy Infra that can be used2/// to create a plugin for Appflowy.3library flowy_plugin;4
5export 'package:appflowy_editor/appflowy_editor.dart'6 show7 ActionMenuArena,8 ActionMenuArenaMember,9 ActionMenuItem,10 ActionMenuItemWidget,11 BulletedListTextNodeWidget,12 ActionMenuOverlay,13 SelectionGestureDetector,14 SelectionGestureDetectorState,15 ContextMenuItem,16 ContextMenu,17 Keybinding,18 ActionMenuState,19 ActionMenuWidget,20 AppFlowyKeyboard,21 AppFlowyKeyboardService,22 AppFlowyRenderPlugin,23 AppFlowyRenderPluginService,24 AppFlowySelectionService,25 AppFlowySelectionService,26 ApplyOptions,27 BuiltInTextWidget,28 BulletedListPluginStyle,29 BulletedListTextNodeWidget,30 BulletedListTextNodeWidgetBuilder,31 CheckboxNodeWidget,32 CheckboxNodeWidgetBuilder,33 CheckboxPluginStyle,34 ColorOption,35 ColorPicker,36 CursorWidget,37 CursorWidget,38 DeleteOperation,39 Delta,40 Delta,41 Document,42 Document,43 EditorEntryWidgetBuilder,44 EditorNodeWidget,45 EditorState,46 EditorStyle,47 FlowyRichText,48 FlowyService,49 FlowyService,50 FlowyToolbar,51 HeadingPluginStyle,52 HeadingTextNodeWidget,53 HeadingTextNodeWidgetBuilder,54 HistoryItem,55 ImageNodeBuilder,56 ImageNodeWidget,57 ImageNodeWidgetState,58 ImageUploadMenu,59 InsertOperation,60 LinkMenu,61 Node,62 NodeIterator,63 NodeWidgetBuilder,64 NodeWidgetBuilder,65 NodeWidgetContext,66 NodeWidgetContext,67 NumberListPluginStyle,68 NumberListTextNodeWidget,69 NumberListTextNodeWidgetBuilder,70 Operation,71 Position,72 Position,73 QuotedTextNodeWidget,74 QuotedTextNodeWidgetBuilder,75 QuotedTextPluginStyle,76 RichTextNodeWidget,77 RichTExtNodeWidgetBuilder,78 Selection,79 SelectionMenuItem,80 SelectionMenuItem,81 SelectionMenuItem,82 SelectionMenuItem,83 SelectionMenuItemWidget,84 SelectionMenuService,85 SelectionMenuWidget,86 SelectionWidget,87 ShortcutEvent,88 TextDelete,89 TextInsert,90 TextNode,91 TextOperation,92 TextRetain,93 ToolbarItem,94 ToolbarItem,95 ToolbarItemWidget,96 ToolbarWidget,97 Transaction,98 Transaction,99 UndoManager,100 UpdateOperation,101 UpdateTextOperation;102export 'package:flowy_infra/theme.dart' show AppTheme;103export 'package:flowy_infra/colorscheme/colorscheme.dart' show FlowyColorScheme;104export 'src/flowy_plugin.dart' show FlowyPlugin;105export 'src/plugin_service.dart' show FlowyPluginService;
Now, that wasn’t too bad…
We implemented about 2 bridge classes for
flowy_eval
, modified one from dart_eval
and added one to flutter_eval
for our divider shortcut to work. In total, it was 1,000 lines of code. I was still optimistic at this point!Back to the story
At this point, I figured that the boilerplate wasn’t too bad. I would try my best to write everything by hand to get a demo going.
Here’s what I accomplished:
- Dynamically loaded themes for our editor.
- Dynamically loaded selection menu items for our editor.
I was going at a good pace until I reached the custom builders for the
appflowy_editor
.This is an example for the node widget builder for the editor:
1import 'package:flutter/material.dart';2import 'package:flowy_plugin/flowy_plugin.dart';3
4import 'dart:ui' as ui;5
6const DoubleDividerType = 'horizontal_double_rule';7
8class DoubleDividerWidgetBuilder extends NodeWidgetBuilder<Node> {9 @override10 Widget build(NodeWidgetContext<Node> context) {11 return DoubleDividerWidget(12 key: context.node.key,13 node: context.node,14 editorState: context.editorState,15 );16 }17
18 @override19 bool Function(Node) get nodeValidator => (node) {20 return true;21 };22}23
24class DoubleDividerWidget extends StatefulWidget {25 const DoubleDividerWidget({26 Key? key,27 required this.node,28 required this.editorState,29 }) : super(key: key);30
31 final Node node;32 final EditorState editorState;33
34 @override35 State<DoubleDividerWidget> createState() => DoubleDividerWidgetState();36}37
38class DoubleDividerWidgetState extends State<DoubleDividerWidget> {39 @override40 Widget build(BuildContext context) {41 return Container(42 // padding: const EdgeInsets.symmetric(vertical: 5),43 // width: MediaQuery.of(context).size.width,44 height: 25,45 child: CustomPaint(46 painter: DoubleDividerPainter(),47 ),48 );49 }50}51
52class DoubleDividerPainter extends CustomPainter {53 @override54 void paint(Canvas canvas, Size size) {55 var paint = Paint();56 paint.color = Colors.black;57 paint.style = PaintingStyle.stroke;58 paint.strokeWidth = 1.5;59
60 var path = ui.Path();61 path.moveTo(0, size.height-10);62 path.lineTo(size.width, size.height-10);63 path.moveTo(0, size.height-5);64 path.lineTo(size.width, size.height -5);65 canvas.drawPath(path, paint);66 }67
68 @override69 bool shouldRepaint(CustomPainter oldDelegate) {70 return true;71 }72}
This class alone required that I build about 10 different classes, scattered throughout
flowy_eval
, flutter_eval
, and dart_eval
.Not only that, but I started to think about the user experience.
What if a plugin developer wanted to use code from another package. If that were the case, they would have to create a bridge themselves. I thought that this steep barrier to entry may discourage others from capitalizing on our efforts.
Generate the Interop Then
Well that’s what I did (at least tried). I created a simple generator to generate the all of the interop library for any plugin, and the interop for any packages that the plugin depended on. The goal was for the plugin itself to be a package. Here’s what the generator would do.
- Read the
pubspec.yaml
dependencies, and generate the interop for the dependencies. - Generate any required interop for the plugin.
- Load all the interop in the correct order before evaluating the plugin.
In order to do this, I used the following packages:
package_info_plus
- to read all of the dependencies from the source.analyzer
- to analyze all of the package source files that are public.source_gen
- to generate the code usingbuild_runner
which is already a dependency of AppFlowy.
To be honest, this is not how any of these packages were meant to be used. For example,
build_runner
is supposed to have a 1:1 relationship between it’s inputs and outputs. Our input here is the pubspec.yaml
file, and it generates thousands of files.Nonetheless, my experiment almost went according to plan, until I crashed my computer generating the MASSIVE interop library for the double divider. I can’t say exactly how much code needed to be generated (because my computer crashes every time I run the program), but it’s too big, for my computer at least.
Recap
- We need to create an interop library to evaluate a plugin at runtime.
- We can manually implement the bridge, but it’s tedious and very expensive.
- We can automatically implement the bridge, but the bridge will be huge and expensive in memory.
Resolution
With all of the challenges that the team would face if we supported dynamically evaluating dart code from a plugin, we decided to pivot to a much simpler solution that did not require supporting a framework.
Dynamic Themes
The AppFlowy editor offers support for user-specified themes. The editor theme is created from a class called
FlowyColorScheme
which simply lists a few properties that the application can use to resolve the color of a widget.Since the theme is a class in the editor, we can provide its values using a JSON file. The JSON file can be loaded at runtime, de-serialized, and used to instantiate a
FlowyColorScheme
class.To accomplish this, we utilized packages like freezed and json_serializable, which allowed us to effortlessly load a theme instance with values from a JSON file. By doing so, we were able to dynamically update the application's appearance based on the loaded theme.
It took a couple of weeks to fine-tune the functionality and user interface, but the effort required to maintain this infrastructure is significantly lower compared to implementing another framework. This cost-effective solution became the outcome of the investigation and was much simpler to implement and maintain. That being said, aren’t done with our journey to load plugins dynamically, but we found that this was an appropriate solution for the meantime.
Thank you for reading this article. If you enjoyed please kindly take a 1-minute survey and support AppFlowy by downloading our latest release. We look forward to your feedback and are excited for what the future of our application holds.