[Перевод] Не пытайтесь динамически загружать код в ваше Flutter-приложение

Привет, Хабр! Меня зовут Станислав Чернышев, я автор книги «Основы Dart»,  телеграм-канала MADTeacher и доцент кафедры прикладной информатики в Санкт-Петербургском государственном университете аэрокосмического приборостроения.

Исследуя тему плагинной архитектуры для Dart/Flutter-приложений. А точнее, проводя ритуал призыва группы изоляционных Dart-демонов при переносе опыта с предыдущих проектов в образовательный материал, наткнулся на интересную статью с обзором существующих решений и почему команда сервиса AppFlowy пока отказалась двигаться в этом направлении.

Этот перевод лучше всего отнести к разряду вольных, т.е. он не дословный и отбрасывает некоторый авторский текст, сокращая и преобразуя его в тех местах, где это не критично для смысла.

ecf694e81a4ff341da2910f26aee5f64.png

Как обстоят дела сейчас

AppFlowy — проект с открытым исходным кодом, который делает акцент на пользовательской настройке графического интерфейса. В нем предлагается редактор, позволяющий разработчикам вставлять пользовательский код в приложение. В AppFlowy такой код мы называем — плагином.

Основной интерфейс, для внедрения плагинов — AppFlowy Editor . Он позволяет пользователям редактировать форматирование текста для своих заметок.

Если вы Flutter-разработчик, которому интересно узнать, сколько существует велосипедно-костыльных способов организации поддержки маркетплейса с загружаемыми расширениями для приложения, то эта статья для вас! Давайте начнем с рассмотрения примера, демонстрирующего, как разработчик внедряет свои плагины в редактор AppFlowy.

Прежде чем продолжить, пожалуйста, прочтите комментарии.

// this code is currently AOT compiled the app
return AppFlowyEditor(
  editorState: editorState,
  shortcutEvents: const [],
  // this is an API that a developer can use to inject their plugin
  customBuilders: {
  // What we want is to we want to provide this at runtime...
    'local_image': LocalImageNodeWidgetBuilder(),
  },
);

В этом примере код использует интерфейс редактора для рендеринга в нем локальных изображений.

На следующем рисунке показано, как может выглядеть добавленная функциональность (обратите внимание, что локальные изображения и плагины стали доступны в версии 0.2.5).

6a35c93cb5756f1af46b359b93eeb48b.png

Используя API AppFlowyEditor, код легко интегрирует изображение в редактор. Если же разработчик хочет, чтобы разработанный им плагин использовался конечным пользователем, перед ним два стула:

  1. Отправить запрос на исправление в AppFlowy. В случае одобрения, его плагин будет интегрирован в нашу версию приложения.

  2. Создать собственную версию AppFlowy с этим плагином и опубликовать ее для конечного пользователя.

Как справиться с проблемой масштабируемости при плагинной архитектуре

Предположим, добавленный плагин повлиял на редактор таким образом, что он стал медленнее отображать свое содержимое. Или привел к значительному увеличению размера релизного бандла. Тогда команде нужно решить — приносит ли добавленный плагин непоправимую пользу нашей базе пользователей. То есть оправдывают ли привносимые плагином издержки его добавление в следующий релиз или нет.

 Некоторые пользователи могут нуждаться в NetworkImageNodeWidgetBuilder (работа с изображением из интернета), а другие — нет. Чтобы все были довольны, нам, возможно, придется создать одну версию приложения с плагином и одну без него.

Из-за этого мы сталкиваемся с проблемами масштабируемости и стоим перед выбором:

  1. Разработчик плагина должен поддерживать свою собственную версию AppFlowy с плагином.

  2. Команда AppFlowy должна принять этот плагин в текущей версии. Если нет, то нам нужно поддерживать следующую версию приложения с этим плагином (если решили его к нам затаскивать).

Это приводит к тому, что обычным пользователям может понадобиться загружать несколько версий AppFlowy, если разработчик X создал AppFlowy с плагином Y, а разработчик A создал AppFlowy с плагином B. Минус такого подхода в том, что количество версий приложения будет расти экспоненциально. При наличии n различных плагинов существует в общей сложности 2^n версий AppFlowy. То есть, если при наличии всего 1 плагина у нас есть одна версия с плагином и одна без него, то 5 плагинов приведет к 32-м различным версиям приложения.

Такой недостаток в масштабируемости приложения требует поиска более эффективного решения.

Цель исследования

Ними была поставлена цель — оптимизировать процесс выпуска приложений путем объединения их в единый релиз, который позволит пользователям загружать плагины по требованию.

При этом мы стремимся сделать возможным использование (выполнение) плагинов сторонних разработчиков внутри нашего приложения, рассматривая его как интегрированный компонент. Другими словами, пользователь должен иметь возможность загружать и включать плагины сторонних разработчиков одним нажатием кнопки из приложения, а не скачивать другую версию приложения. 

Решения

Стремясь достичь желаемой функциональности, наша команда изучила множество потенциальных подходов, которые могли бы обеспечить интеграцию плагинов сторонних разработчиков в AppFlowy.

Оценивая эти варианты, мы стремились определить наиболее подходящий подход, соответствующий нашим целям и техническим возможностям. 

Подход № 1: Over-the-Air Frameworks

Фреймворки обновлений типа Over-the-Air (OTA) облегчают обновление программного обеспечения по беспроводной сети, доставляя с сервера код, которого раньше не было в программе. То есть, они позволяют предоставлять новые функции без сторонних сервисов, таких как App Store.

Как правило, обновления передаются в виде дельта-файлов, которые распаковываются и применяются фреймворком на стороне клиента. Для эффективного управления этим процессом необходим сервер, отвечающий за операции по переносу кода.

Следующий блок кода иллюстрирует, как AppFlowy может использовать фреймворк OTA-обновлений для реализации динамических плагинов.

@override
void initState() {
	super.initState();
  // grab a list of plugins, maybe as a url, to query a server for the plugin code
	plugins = database["plugins"].map((row) => row.plugin_name)); 
}

return AppFlowyEditor(
	  editorState: editorState,
	  shortcutEvents: const [],
		customBuilders: {
      // here we append the default plugins
			...defaultPlugins,
      // suppose that we query the server for the code an load the plugins here asynchronously
			...plugins.map((name) => { name, OTAWidget(url: '')})
		}
);

В этом примере сохраняются настройки плагинов рабочей области. Они будут использоваться в момент запуска приложения для их запроса по сети.

Преимущества:

  • Низкая стоимость реализации в рамках текущей кодовой базы проекта.

  • Плагины не хранятся на машине пользователя.

  • Плагины всегда актуальны.

Недостатки:

  • Требуется размещение сервера OTA-обновлений.

  • Требуется надежная политика версионирования и обратной совместимости.

  • OTA-фреймворки Flutter имеют слабую поддержку. Некоторые из них недоступны для настольных и веб-приложений, в то время как другие требуют использования специфических или старых версий Flutter.

  • Могут быть запрещены в App Store или Google Play Store. Это связано с тем, что приложения, использующие OTA-обновления, обходят процесс утверждения.

Дополнительный геморрой

Реализация OTA-фреймворка, позволяющего создавать индивидуальные сборки релизов, потребует решения следующих задач:

  • Обработка запросов пользователей на добавление плагинов в приложения.

  • Определение того, какие дельты должны быть применены к приложению каждого пользователя.

  • Развертывание обновлений исключительно для указанного приложения, не затрагивая другие.

Поддержание фреймворка развертывания потребовало бы от команды прожать батоны и принесло бы кучу незабываемого геморроя:

  • Проблемы масштабируемости: Если у AppFlowy будут миллионы пользователей, серверы должны будут масштабироваться в зависимости от количества поступающих запросов.

  • Проблемы безопасности и надежности: Злоумышленники могут направить миллионы запросов к нашей инфраструктуре серверов плагинов в качестве атаки типа «отказ в обслуживании», что может привести к сбою в работе. В это время многие пользователи не будут иметь доступа к своим плагинам.

  • Проблемы с поддержкой: Фреймворки, позволяющие «проталкивать» (пушить) код, на данный момент не очень широко поддерживаются сообществом Flutter. Поэтому команде придется выделить много ресурсов, чтобы убедиться, что они достаточно надежны для работы с миллионами пользователей.

Из-за потенциальных проблем, с которыми может столкнуться команда, я решил не использовать этот подход и обратился к другим вариантам.

Хочу заметить, что фреймворки, которые сейчас упомяну, отлично справляются со своей работой! Просто они не подошли для нашего случая использования. Пожалуйста, сделайте все возможное, чтобы поддержать команды Shorebird.dev и Flutter Fair

Подход № 2: Изоляты и динамические библиотеки

Также можно реализовать плагины с использованием динамических библиотек или с помощью изолятов. Однако, динамически загружаемые плагины-библиотеки в dart зависят от рефлексии, а точнее — анализа кода через dart: mirrors. Следующий блок кода показывает примерный псевдокод, демонстрирующий, как приложение AppFlowy может загрузить сторонний плагин с помощью изолятов.

import 'dart:mirrors';

void main() {
  // Define the new Dart code as a string
  // this string can be loaded over the air
  String newCode = """
    void main() {
      print("Hello, world!");
    }
  """;

  // Use the `compileSource` function to compile the new code
  LibraryMirror library = currentMirrorSystem().isolate.rootLibrary;
  CompilationUnit compilationUnit = parseCompilationUnit(newCode);
  library.define(new CompilationUnitMember(library, compilationUnit));

  // Use the `invoke` function to execute the new code
  MethodMirror mainMethod = library.declarations[new Symbol('main')];
  InstanceMirror result = currentMirrorSystem().invoke(mainMethod, []);
}

К сожалению, библиотека dart: mirrors недоступна для использования в приложениях Flutter из-за своего размера и сложности. Если я правильно помню, такое решение было принято командой Flutter для повышения производительности.

Хотя существуют обходные пути, они будут поддерживаться исключительно командой AppFlowy и потребуют значительных затрат. Поэтому загрузка кода с помощью изоляторов и динамических библиотек не рассматривалась в качестве кандидата на загрузку сторонних плагинов в AppFlowy.

Используйте этот подход, если ваше приложение может работать на Dart без Flutter! (Примечание переводчика: и, если разрабатываемое приложение не будет компилироваться с флагом exe и aot-snapshot. Данные варианты сборки не поддерживают dart: mirrors и dart: developer)

flutter_eval и dart_eval

flutter_eval и dart_eval — это инструменты, используемые для оценки и выполнения кода Dart в runtime. dart_eval — компилятор байт-кода и runtime Dart, что делает его подходящим кандидатом для загрузки сторонних плагинов в AppFlowy.

Давайте рассмотрим пример использования этих двух фреймворков для создания динамически загружаемого стороннего плагина, который вставляет двойной разделитель, когда пользователь набирает »==» в редакторе:

ShortcutEvent insertDoubleDivider = ShortcutEvent(
  key: 'insert_double_divider',
  command: 'Equal',
  handler: (editorState, event) {
    int? selection = editorState.service.selectionService.currentSelection;
    if (selection == null) {
      return KeyEventResult.ignored;
    }

    final TextNode textNode = textNodes.whereType().first;
    String text = textNode.toPlainText();
    if (text.contains('==')) {
			// insert double divider
      return KeyEventResult.handled;
    }
    return KeyEventResult.ignored;
  },
);

Стоит отметить, что приведенный код, когда он загружается в приложение, не имеет никакого смысла, поскольку не был семантически проанализирован. Для приложения это просто строка! Чтобы получить семантическую информацию, плагин должен быть проанализирован фреймворком dart_eval во время работы приложения.

dart_eval может анализировать текстовое представление кода, основываясь только на том, что он знает о dart на данный момент. Это правильное разделение интересов, и оно не подразумевает, что с самим пакетом что-то не так. Однако, поскольку объект ShortcutEvent — это то, что мы реализовали, dart_eval должен знать, что такое ShortcutEvent. А для этого нам нужно расширить пакет, создав библиотеку interop, которая предоставит dart_eval всю информацию, необходимую для анализа использования нашего ShortcutEvent сторонним кодом. 

Создание библиотеки Interop

Наша следующая задача — позволить экземплярам ShortcutEvent корректно анализировать код плагина. А для этого разрабатываемая библиотека должна знать все о ShortcutEvent.

Например,

  • Какие у него конструкторы?

  • Каковы параметры конструктора?

  • Какие есть методы?

  • Каковы параметры методов?

И так далее для каждого метода, геттера, сеттера и поля класса. Реализация такого interop-класса в среднем занимает 250+ строк кода. Дело в том, что это утомительно. Возможно, нам следует избегать его реализации вручную.

Еще хуже то, что те строки кода, которые мы только что написали для interop-библиотеки, помогают проанализировать только первую строку кода из плагина двойного разделителя… нам предстоит проделать еще долгий путь.

Например, рассмотрим это выражение в плагине двойного разделителя.

final TextNode textNode = textNodes.whereType().first;

Оказалось, что метод whereType() не был реализован в dart_eval для Iterable. Ну, что ж… клонируем dart_eval локально, вносим изменения и молимся, чтобы они были приняты в исходном репозиторий проекта. А пока берем поруки еще и форк dart_eval.

О, я также забыл упомянуть, что эта строка из плагина — аналогична предыдущей!

return KeyEventResult.handled;

KeyEventResult — это элемент фреймворка Flutter. Он находится не в dart_eval, а в interop-библиотеке flutter_eval. А если быть точнее — должен, но на самом деле — еще не был реализован. Поэтому мы также клонируем flutter_eval и добавляем изменения. Таким образом поддержка еще одного форка полностью ложится на наши плечи.

Опыт разработчика плагинов

В этот момент я сделал шаг назад, чтобы учесть предыдущий опыт разработчика плагина…

Посмотрите еще раз на код двойного разделителя.

Мы прошли долгий путь.

import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
// etc. other imports here that would make the red squiggles go away

ShortcutEvent insertDoubleDivider = ShortcutEvent(
  key: 'insert_double_divider',
  command: 'Equal',
  handler: (editorState, event) {
    int? selection = editorState.service.selectionService.currentSelection;
    if (selection == null) {
      return KeyEventResult.ignored;
    }

    final TextNode textNode = textNodes.whereType().first;
    String text = textNode.toPlainText();
    if (text.contains('==')) {
			// insert double divider
      return KeyEventResult.handled;
    }
    return KeyEventResult.ignored;
  },
);

«Загвоздка» здесь в том, что разработчик плагина импортирует material, appflowy_editor, dart: collection и полагает, что ошибок компиляции плагина нет, потому что… ну… Intellisense так говорит. Однако, Intellisense оценивает код из сервера языка dart, а не из фреймворка dart_eval!

TL; DR — Отсутствие необходимого interop для оценки плагина приведет к ошибке времени выполнения, а не компиляции. Обычно это ОЧЕНЬ непрозрачная ошибка времени выполнения.

Чтобы решить эту проблему, я создал файл «barrel» в моей библиотеке interop. Он будет показывать только классы с interop, реализованными для AppFlowy. Это все еще чревато ошибками PEBKAC (problem exists between keyboard and chair, проблема между клавиатурой и креслом) со стороны команды.

Вот как выглядит файл «barrel» :

/// Available classes from Appflowy Editor and Flowy Infra that can be used
/// to create a plugin for Appflowy.
library flowy_plugin;

export 'package:appflowy_editor/appflowy_editor.dart'
    show
        ActionMenuArena,
        ActionMenuArenaMember,
        ActionMenuItem,
        ActionMenuItemWidget,
        BulletedListTextNodeWidget,
        ActionMenuOverlay,
        SelectionGestureDetector,
        SelectionGestureDetectorState,
        ContextMenuItem,
        ContextMenu,
        Keybinding,
        ActionMenuState,
        ActionMenuWidget,
        AppFlowyKeyboard,
        AppFlowyKeyboardService,
        AppFlowyRenderPlugin,
        AppFlowyRenderPluginService,
        AppFlowySelectionService,
        AppFlowySelectionService,
        ApplyOptions,
        BuiltInTextWidget,
        BulletedListPluginStyle,
        BulletedListTextNodeWidget,
        BulletedListTextNodeWidgetBuilder,
        CheckboxNodeWidget,
        CheckboxNodeWidgetBuilder,
        CheckboxPluginStyle,
        ColorOption,
        ColorPicker,
        CursorWidget,
        CursorWidget,
        DeleteOperation,
        Delta,
        Delta,
        Document,
        Document,
        EditorEntryWidgetBuilder,
        EditorNodeWidget,
        EditorState,
        EditorStyle,
        FlowyRichText,
        FlowyService,
        FlowyService,
        FlowyToolbar,
        HeadingPluginStyle,
        HeadingTextNodeWidget,
        HeadingTextNodeWidgetBuilder,
        HistoryItem,
        ImageNodeBuilder,
        ImageNodeWidget,
        ImageNodeWidgetState,
        ImageUploadMenu,
        InsertOperation,
        LinkMenu,
        Node,
        NodeIterator,
        NodeWidgetBuilder,
        NodeWidgetBuilder,
        NodeWidgetContext,
        NodeWidgetContext,
        NumberListPluginStyle,
        NumberListTextNodeWidget,
        NumberListTextNodeWidgetBuilder,
        Operation,
        Position,
        Position,
        QuotedTextNodeWidget,
        QuotedTextNodeWidgetBuilder,
        QuotedTextPluginStyle,
        RichTextNodeWidget,
        RichTExtNodeWidgetBuilder,
        Selection,
        SelectionMenuItem,
        SelectionMenuItem,
        SelectionMenuItem,
        SelectionMenuItem,
        SelectionMenuItemWidget,
        SelectionMenuService,
        SelectionMenuWidget,
        SelectionWidget,
        ShortcutEvent,
        TextDelete,
        TextInsert,
        TextNode,
        TextOperation,
        TextRetain,
        ToolbarItem,
        ToolbarItem,
        ToolbarItemWidget,
        ToolbarWidget,
        Transaction,
        Transaction,
        UndoManager,
        UpdateOperation,
        UpdateTextOperation;
export 'package:flowy_infra/theme.dart' show AppTheme;
export 'package:flowy_infra/colorscheme/colorscheme.dart' show FlowyColorScheme;
export 'src/flowy_plugin.dart' show FlowyPlugin;
export 'src/plugin_service.dart' show FlowyPluginService;

Итак, это было не так уж и плохо…

Мы реализовали около 2-х bridge-классов для flowy_eval, модифицировали один из dart_eval и добавили один в flutter_eval, чтобы работал shortcut разделителя. В общей сложности это была 1 000 строк кода. Я все еще был оптимистом на этом этапе!

Возвращаемся к истории

На этом этапе я решил, что шаблонный код не так уж плох и постараюсь написать все вручную, чтобы получить демо-версию.

Вот что у меня получилось написать для редактора:

  • Динамически загружаемые темы (работают на основе присылаемого JSON).

  • Динамически загружаемые пункты меню выбора (была надежда, что все получится).

Я шел в хорошем темпе, пока не дошел до пользовательских конструкторов для appflowy_editor.

Это пример создания виджета узла для редактора:

import 'package:flutter/material.dart';
import 'package:flowy_plugin/flowy_plugin.dart';

import 'dart:ui' as ui;

const DoubleDividerType = 'horizontal_double_rule';

class DoubleDividerWidgetBuilder extends NodeWidgetBuilder {
  @override
  Widget build(NodeWidgetContext context) {
    return DoubleDividerWidget(
      key: context.node.key,
      node: context.node,
      editorState: context.editorState,
    );
  }

  @override
  bool Function(Node) get nodeValidator => (node) {
    return true;
  };
}

class DoubleDividerWidget extends StatefulWidget {
  const DoubleDividerWidget({
    Key? key,
    required this.node,
    required this.editorState,
  }) : super(key: key);

  final Node node;
  final EditorState editorState;

  @override
  State createState() => DoubleDividerWidgetState();
}

class DoubleDividerWidgetState extends State {
  @override
  Widget build(BuildContext context) {
    return Container(
      // padding: const EdgeInsets.symmetric(vertical: 5),
      // width: MediaQuery.of(context).size.width,
      height: 25,
      child: CustomPaint(
        painter: DoubleDividerPainter(),
      ),
    );
  }
}

class DoubleDividerPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint();
    paint.color = Colors.black;
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = 1.5;

    var path = ui.Path();
    path.moveTo(0, size.height-10);
    path.lineTo(size.width, size.height-10);
    path.moveTo(0, size.height-5);
    path.lineTo(size.width, size.height -5);
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

Только для этого класса мне пришлось создать около 10 различных классов, разбросанных по flowy_eval, flutter_eval и dart_eval.

Мало того, я начал задумываться о пользовательском опыте.

Что, если разработчик плагина захочет использовать код из другого пакета? В этом случае им пришлось бы самим создавать bridge. Но ведь такой высокий барьер для входа может отбить у других желание использовать наши усилия!

Сгенерировать Interop, а затем…

Вот что я сделал (по крайней мере, попытался). Я создал простой кодогенератор для генерации всей interop-библиотеки для любого плагина, а также interop для всех пакетов, от которых зависит плагин. Цель заключалась в том, чтобы сам плагин был пакетом. В итоге реализованный кодогенератор:

  1. Считывает зависимости из pubspec.yaml и генерирует interop для этих зависимостей.

  2. Генерирует все необходимые интерфейсы для плагина.

  3. Загружает все interop в правильном порядке перед оценкой плагина.

Для этого использовались следующие пакеты:

  • package_info_plus — для чтения всех зависимостей в коде проекта.

  • analyzer — для анализа всех открытых исходных файлов пакета.

  • source_gen — для генерации кода с помощью build_runner, который уже является зависимостью AppFlowy.

Честно говоря, ни один из этих пакетов не предназначен для использования именно таким образом. Например, build_runner должен иметь соотношение 1:1 между своими входами и выходами. Нашим входом здесь является файл pubspec.yaml, а он генерирует тысячи файлов!

Тем не менее, мой эксперимент почти прошел по плану, пока я не разбил свой компьютер, генерируя МАССИВНУЮ interop-библиотеку для двойного разделителя. Я не могу точно сказать, сколько кода нужно было сгенерировать (потому что мой компьютер падал каждый раз, когда запускал программу), но его слишком много! По крайней мере, для моего компьютера.

Неутешительное резюме

  • Нам нужно создать interop-библиотеку для оценки плагина во время выполнения.

  • Мы можем реализовать мост вручную, но это утомительно и очень затратно.

  • Мы можем для воспользоваться кодогенерацией для автоматической реализации моста, но он будет огромным и занимать много памяти.

Как итог

Учитывая все проблемы, с которыми столкнулась бы команда, если бы поддерживала динамическую оценку кода Dart из плагина, мы решили перейти к гораздо более простому решению (см. начало статьи).

Спасибо за прочтение этой статьи. Если вам понравилось, пожалуйста, пройдите 1-минутный опрос и поддержите AppFlowy, загрузив наш последний релиз. Мы с нетерпением ждем ваших отзывов!

Перевод подготовил затраханный за лето препод, ведущий телеграм-канал MADTeacher,  посвященный Dart/Flutter и безумству нашей системы высшего образования

© Habrahabr.ru