Flutter製ノーコードツール、MagicInstructionsのアーキテクチャを大公開するっピ
TL;DR
NappsTechnologiesの代表の榎本です。普段はMagicInstructions(https://magicinstructions.app/#/)というFlutterで作られたノーコードツールを開発しています。どういうアーキテクチャでなぜノーコードが可能になったのかを今日は大公開しちゃいます
宣言的プログラミング
MagicInstructionsでは、Flutterでアプリを制作しています。Flutterの特徴を一言で言うと、全てはWidgetという部品で構成され、Widgetをプログラミングコードで組み合わせることでアプリを制作できます。
class SampleScreen extends StatelessWidget {
  Widget build() => Text('テスト')
}
この例では画面に「テスト」という文字列が表示されます。

次のコードでは2つの文字列を横並びで表示しています。
class SampleScreen extends StatelessWidget {
  Widget build() => Row(children:[
    Text('テスト'),
    Text('テスト2'),
    
}

データを元にWidgetを表示する
このコードを次のように、文字列の配列をmapでWidgetに変換するコードに書き換えるとどうでしょうか?
class SampleScreen extends StatelessWidget {
  
  Widget build(BuildContext context) => Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: ['テスト', 'テスト2'].map((e) => Text(e)).toList());
}
先程の例と同様の表示になります。

宣言的プログラミングを採用するFlutterでは、データを元にして動的表示を組み替えることが可能です。もう少し発展させてみましょう。下記のような色とラベルの文字を保持するエンティティを用意します
class TextEntity {
  final String label;
  final Color color;
  TextEntity({required this.label, required this.color});
}
サンプルスクリーンのコードは下記のようにエンティティを使ってデータを表示するように書き換えます。
class SampleScreen extends StatelessWidget {
  final datas = [
    TextEntity(label: 'テスト', color: Colors.red),
    TextEntity(label: 'テスト2', color: Colors.blue),
  ];
  
  Widget build(BuildContext context) => Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: datas
          .map((e) => Text(e.label, style: TextStyle(color: e.color)))
          .toList());
}
表示はこのように、左の文字が赤色、右の文字が青色になります。
データの指示に従って画面の表示を変更することができました。

エンティティに従って画面を描画するのがMagicInstructionsのアーキテクチャです。
エンティティの項目を増やすことで文字の大きさや位置を変更することも可能です。
汎用的に様々部品が置けるようにする
次に下記のようなテキストとボタンが並んでいる画面を作る方法を考えてみます

直接コードを書くと下記のようになります
class SampleScreen extends StatelessWidget {
  static Widget withDependencies() => ChangeNotifierProvider(
      create: (_) => SampleScreenState(), child: SampleScreen());
  
  Widget build(BuildContext context) =>
      Column(mainAxisAlignment: MainAxisAlignment.center, children: [
        Text('テスト',
            style: TextStyle(color: context.watch<SampleScreenState>().color)),
        ElevatedButton(
          onPressed: () {},
          child: Text('テスト',
              style:
                  TextStyle(color: context.watch<SampleScreenState>().color)),
        ),
      ]);
}
これをデータを使って変更できるようにしてみます。Flutterにはリフレクションがないため下記のようにStringをキー、Widgetを返すFunctionを値とするMapを作成し、Widgetのインスタンスを作成するクラスを作成します。
class WidgetBuilder {
  Map<String, Widget Function(Map map)> builders = {
    'Text': (Map map) {
      final entity = TextEntity.fromMap(map);
      return Text(entity.label, style: TextStyle(color: entity.color));
    },
    'Button': (Map map) {
      final entity = ButtonEntity.fromMap(map);
      return ElevatedButton(
        child: Text(entity.label),
        onPressed: () {},
      );
    }
  };
  Widget build(String key, Map map) {
    final builder = builders[key];
    if (builder == null) return Container();
    return builder(map);
  }
}
次にこのWidgetBuilderを使って描画できるようにスクリーンクラスを変更します
class SampleScreen extends StatelessWidget {
  final datas = [
    WidgetEntity(
        key: 'Text', data: TextEntity(label: 'テスト', color: Colors.red).toMap()),
    WidgetEntity(key: 'Button', data: ButtonEntity(label: 'ボタン').toMap()),
  ];
  
  Widget build(BuildContext context) => Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children:
          datas.map((e) => WidgetBuilder().build(e.key, e.data)).toList());
}
エンティティクラスは下記ように用意しています
class WidgetEntity {
  final String key;
  final Map data;
  WidgetEntity({required this.key, required this.data});
}
実際の表示は下記のようになり、データを変更することで画面の表示内容を変更することができます

データを変更し下記のようすると表示も切り替わります
class SampleScreen extends StatelessWidget {
  final datas = [
    WidgetEntity(
        key: 'Text', data: TextEntity(label: 'テスト', color: Colors.red).toMap()),
    WidgetEntity(key: 'Button', data: ButtonEntity(label: 'ボタン').toMap()),
    WidgetEntity(
        key: 'Text', data: TextEntity(label: 'テスト', color: Colors.red).toMap()),
  ];
  
  Widget build(BuildContext context) => Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children:
          datas.map((e) => WidgetBuilder().build(e.key, e.data)).toList());
}

実際のMagicInstructionsではエンティティの内容をfirestoreに永続化し、実行時にエンティティの内容を元に表示することで画面の描画を行っています。さらにエンティティの内容をFigmaのAPIから取得したJSONを元に作成することで、下記のように複雑なUIを自動的に作成しています。

ロジックの実行方法
ここまでの流れでアプリのUIをノーコードで実現する方法を説明しましたので、次はボタンを押した時のロジックについて説明します。ボタンを押したら文字の色が赤色になるアプリを考えてみます。

色を保持するクラスを作成します
class SampleScreenState with ChangeNotifier {
  Color color = Colors.black;
  void setColor(Color color) {
    this.color = color;
    notifyListeners();
  }
}
サンプルスクリーンのコードは下記のようになり、色を保持するクラスから文字の色を取得し、ボタンが押されたときに色をSampleScreenStateに保持しています
class SampleScreen extends StatelessWidget {
  static Widget withDependencies() => ChangeNotifierProvider(
      create: (_) => SampleScreenState(), child: SampleScreen());
  
  Widget build(BuildContext context) =>
      Column(mainAxisAlignment: MainAxisAlignment.center, children: [
        Text('テスト',
            style: TextStyle(color: context.watch<SampleScreenState>().color)),
        ElevatedButton(
          child: Text('テスト'),
          onPressed: () {
            context.read<SampleScreenState>().setColor(Colors.red);
          },
        )
      ]);
}
このようにデータを元に描画する、ロジックではデータを変更するように作ることで、ロジックの結果をUIに反映することができます。ロジックをリフレクションで実行することで様々なロジックを実行できますと書きたいところですが、Flutterではリフレクションはできません。MagicInstructionsでは、Stringをキー、Function()を値とするMapを構築しロジックを動的に実行させています。
class LogicExecutor {
  final executors = <String, Function(BuildContext)>{
    'changeColor': (context) =>
        context.read<SampleScreenState>().setColor(Colors.red)
  };
  void execute(BuildContext context, String key) {
    final executor = executors[key];
    if (executor == null) return;
    executor(context);
  }
}
MagicInstructionsでは下記のように汎用的なロジックを用意し、複数のアクションを実行できることで、さまざまなユースケースに対応しています

まとめ
MagicInstructionsではFlutterの宣言的プログラミングを活用してノーコードツールを作成しています、NappsTechnologiesではMagicInstructionsの開発に興味あるエンジニアを募集しています。
Discussion