[Flutter] json_dynamic_widgetを使って動的にWidgetを差し替える

2023/12/14に公開

この記事は2023年のFlutter Advent Calendar(カレンダー1)の14日目の記事です。


やりたいこと

すでにリリースしたFlutterアプリを外部から動的にWidgetを差し替えて、アプリをリリースせずにたとえばFirestoreやRemoteConfig等を利用してjson等を利用して値の変更だけで画面を変更するということをやりたい。
A画面からB画面にごっそり入れ替えたいとか。とある画面の一部分のWidgetを差し替えたいとか。
今回はこのパッケージの使い方だけに絞りたいのでassetsに事前に用意したjsonを利用したサンプルで一部のWidgetを紹介します。
全部は紹介できないので、親の顔よりも見たFlutterプロジェクト生成時にできるカウンターアプリを作ろうと思います。

実現方法

json_dynamic_widgetというパッケージを利用してjson形式でWidgetを記載して動的に差し替えるということをやります。
https://pub.dev/packages/json_dynamic_widget

記事記載時のバージョン

Flutter 3.16.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision db7ef5bf9f (9 days ago)2023-11-15 11:25:44 -0800
Engine • revision 74d16627b9
Tools • Dart 3.2.0 • DevTools 2.28.2
json_dynamic_widget: ^7.0.5+1

実装

  1. JSONを読み込んでからどうWidgetに変換する基礎の部分
  2. Scaffold
  3. ButtonとonPressedイベント
  4. Icon

1.JSONを読み込んでからどうWidgetに変換する基礎の部分

// JsonWidgetRegistryのインスタンスを生成
final registry = JsonWidgetRegistry.instance;

// JSONをMapに変換
final widgetJson = jsonDecode(snapshot.data!);

// JSONをWidgetに変換できるようなJsonWidgetDataオブジェクトに変換
final jsonWidgetData = JsonWidgetData.fromDynamic(
  widgetJson,
  registry: registry,
);

// JsonWidgetDataオブジェクトをWidgetに変換(buildメソッドでWidgetが返却)
return jsonWidgetData.build(
  context: context,
);

JsonWidgetRegistry.instanceJsonWidgetDataを利用してJSONを読み込みWidget化します。
JsonWidgetRegistryは変換対象を管理していくオブジェクトです。
プラグインを利用する場合は、そのRegistryに組み込んでいくと、標準パッケージ以外でのWidgetの変換が可能になります。その方法は後述のIconで記載します。
JsonWidgetDataを利用して、実際にJSONをWidgetに変換していきます。

2.Scaffold

json_dynamic_widgetでScaffoldをjsonで表現すると、下記のようになります。

{
  "type": "scaffold",
  "args": {
    "appBar": {
      "type": "app_bar",
      "args": {
        "title": {
          "type": "text",
          "args": {
            "text": "AppBar"
          }
        }
      }
    },
    "body": {
      "type": "center",
      "args": {
        "child": {
          "type": "text",
          "args": {
            "text": "Hello World",
	    "style": {
              "fontSize": 30,
	      "color": "#ff0000"
            }
          }
        }
      }
    }
  }
}

まずは以下の2つを押さえておく。

  • Widgetはtypeで表現する。(スネークケースで記述)
  • childやstyle、イベント系はargsの中で表現する。

3.ButtonとonPressedイベント

json_dynamic_widgetでButtonをjsonで表現すると、下記のようになります。
今回はFloatingActionButtonの例を挙げます。

"floatingActionButton": {
  "type": "floating_action_button",
  "args": {
    "child": {
      "type": "icon",
        "args": {
          "icon": {
            "codePoint": 57415,
            "fontFamily": "MaterialIcons"
          }
       }
    },
    "onPressed": "${print()}"
  }
}

iconは後述で説明します。
Button系のonPressedなどのイベントもargs内に記載します。
関数は${関数()}という記述方法でJsonWidgetDataが読み取ります。
json_dynamic_widgetでは標準で数種類の関数(Built functions)が準備されてます。

独自の関数はJsonWidgetRegistryに関数を事前に登録する必要があります。
registerFunctionsに全ての関数を登録していくのでここが結構肥大化するのが懸念..

// JsonWidgetRegistryに関数を登録
registry.registerFunctions({
  'print': ({args, required registry}) => () {
    print('sample');
  },
});

argsList<dynamic>?になっているので引数を渡したい場合はjson側は下記のように書けます。

"onPressed": "${関数('引数1', '引数2')}"

仕組み的にはJSONの中に書いたlisten部分を抽出し、JsonWidgetRegistry側でStreamで監視対象として追加、JsonWidgetBuilder側でそのStreamをlistenして変更を検知してsetStateしている流れになります。

値の変更を行う

値の変更を検知し描画するにはkeyvalueを用いて以下のように記述します。
JSON側で検知したい値のキーをlistenし、textなどの表示したい部分に${key}と書くことでキーと対応した値が変更されるとWidgetがリビルドされる仕組みになってます。
変更の仕方はシンプルでFlutter側でregistry.setValueで値をセットします。
(※試してないですが、listenは配列になってるので複数の値を検知することが可能のようです)

  • JSON側
"type": "text",
"listen": ["key"],
"args": {
  "text": "${key}",
  "style": {
    "fontSize": 30
  }
}
  • Flutter側
registry.setValue('key', value);

4.Icon

json_dynamic_widgetでIconをjsonで表現すると、下記のようになります。

"icon": {
  "type": "icon",
  "args": {
    "icon": {
      "codePoint": 57415,
      "fontFamily": "MaterialIcons"
    }
  }
},

codePointについて

FlutterでMaterial Iconsを利用する場合、基本的にはIconの引数のIconDataにはIcons.~といった形でアイコン名を指定することが多いと思いますが、

Icon(Icons.add)

実はcodePoint = HexCode(16進数)を指定しても表現できます。

 Icon(IconData(0xe047, fontFamily: 'MaterialIcons'))

Flutterの公式ドキュメントから確認できます

json_dynamic_widgetでのcodePointの表現はこの16進数を10進数に変換した値を指定する必要があります。

e04757415

とはいえ、めんどくさいです。
なので、First Party Pluginで提供されてるjson_dynamic_widget_plugin_material_iconsを利用します。(4.0.0からDart3.2.0にする必要があります)

① まずは下記のようにregistryに対してプラグインを登録。

+ import 'package:json_dynamic_widget_plugin_material_icons/json_dynamic_widget_plugin_material_icons.dart';

...

final registry = JsonWidgetRegistry.instance;

+ // MaterialIconsのプラグインを登録
+ JsonMaterialIconsPluginRegistrar.registerDefaults(registry: registry);

② jsonファイルは下記のような書き方に変更できます
こちらの方が馴染みがありますね。

"icon": {
  "type": "material_icon",
  "args": {
    "icon": "add"
  }
},

カウンターアプリの全体像

上記を踏まえて、カウントターアプリを作ってみます。

JSON側

{
  "type": "scaffold",
  "args": {
    "appBar": {
      "type": "app_bar",
      "args": {
        "title": {
          "type": "text",
          "args": {
            "text": "Flutter Demo Home Page"
          }
        }
      }
    },
    "body": {
      "type": "center",
      "args": {
        "child": {
          "type": "column",
          "args": {
            "mainAxisAlignment": "center",
            "children": [
              {
                "type": "text",
                "args": {
                  "text": "You have pushed the button this many times:"
                }
              },
              {
                "type": "text",
                "listen": ["count"],
                "args": {
                  "text": "${count}",
                  "style": {
                    "fontSize": 30
                  }
                }
              }
            ]
          }
        }
      }
    },
    "floatingActionButton": {
      "type": "floating_action_button",
      "args": {
        "child": {
          "type": "material_icon",
          "args": {
            "icon": "add"
          }
        },
        "onPressed": "${incrementCounter()}"
      }
    }
  }
}

Flutter側

import 'dart:convert';

import 'package:json_dynamic_widget/json_dynamic_widget.dart';
import 'package:json_dynamic_widget_plugin_material_icons/json_dynamic_widget_plugin_material_icons.dart';

const homeJsonPath = 'assets/json/home.json';

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _counter = 0;
  JsonWidgetRegistry? _registry;

  Future<String> _getWidget() async {
    return await rootBundle.loadString(homeJsonPath);
  }

  
  void initState() {
    super.initState();

    // JsonWidgetRegistryのインスタンスを生成
    _registry = JsonWidgetRegistry.instance;
    _registry!.setValue('count', _counter);

    // JsonWidgetRegistryに関数を登録
    _registry!.registerFunctions({
      'incrementCounter': ({args, required registry}) => () {
            _counter++;
            registry.setValue('count', _counter);
          },
    });

    // MaterialIconsのプラグインを登録
    JsonMaterialIconsPluginRegistrar.registerDefaults(registry: _registry!);
  }

  
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _getWidget(),
      builder: (context, AsyncSnapshot<String> snapshot) {
        if (snapshot.hasData) {
          // JSONをMapに変換
          final widgetJson = jsonDecode(snapshot.data!);

          // JSONをWidgetに変換できるようなJsonWidgetDataオブジェクトに変換
          final jsonWidgetData = JsonWidgetData.fromDynamic(
            widgetJson,
            registry: _registry,
          );
          // JsonWidgetDataオブジェクトをWidgetに変換
          return jsonWidgetData.build(
            context: context,
          );
        } else {
          return const Scaffold(
            body: Center(
              child: CircularProgressIndicator(),
            ),
          );
        }
      },
    );
  }
}

デモ

最後に

画期的ではあるものの、めちゃくちゃコーディング体験が悪くて挫折しました。
何がしんどかったかというと、READMEに書かれてるbuilt-in-widgetsの各Widgetのリンクが全て404で飛べないw
プラグインのREADMEも更新されてなく苦戦しました。。
今回のサンプルは画面まるっとJSONでやってますが、もちろんWidgetなので、画面の一部分のパーツのみをJSONにして動的に差し替えるとかも可能です。
そういった要件がもし出てきた時には一度トライしてみてはいかがでしょうか。

このパッケージのサンプルがWeb上で確認できるのでこちらを確認してみてください。
https://peiffer-innovations.github.io/json_dynamic_widget/web/index.html

Discussion