🌈

[Flutter] Stacを使ってServer-Driven UIでアプリを作る

2025/01/19に公開
2

知ったきっかけ

たまたまXでFlutterのワードで調べてたら、こんな投稿があって知った
https://x.com/buildMirai/status/1866844126463004726

ふむふむ、FlutterでSDUIで作れるフレームワークが出たのか。

Stacとは

https://github.com/Securrency-OSS/mirai

Mirai is a Server-Driven UI (SDUI) library for Flutter. Mirai allows you to build beautiful cross-platform applications with JSON in real time.

2024年の12月にローンチされたSDUIのフレームワーク。めっちゃ最近ですね。

(2025-02-05追記)
2/3にMiraiからStacへプロダクト名が変更になってました。
https://twitter.com/stac_dev/status/1886413979037171981

https://github.com/StacDev/stac

モバイルアプリの開発の大変さ

モバイルアプリの大変さって機能を作ってはビルドし、ストアにアップし審査に出してリリースするといったサイクルの中で速度感のある細かいUI検証やバグの対処をやる中でユーザーに早く提供するのが難しいところだったりしますよね。日々「あ、この対応もリリースに含めりゃよかった」って思うことあります。リリースせずにサーバサイドでUIが提供できれば便利だろうけど、実際のところSDUIを採用してるところどれくらいいるんだろ🤔
開発コスト的に結果上がるのか下がるのか気になる..

(番外編)Flutterで動的にUIを作る

早速番外編なのですが、実はjson_dynamic_widgetてのがあって、それを使ってみた記事を書いてるのでそれもそれも見てみてください。

https://zenn.dev/unbam/articles/82949422fa57a2

カウンターアプリ作ってみる

前置きはこれくらいにして早速カウンターアプリで試してみる。

検証環境

Flutter

Flutter 3.27.2 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 68415ad1d9 (3 days ago)2025-01-13 10:22:03 -0800
Engine • revision e672b006cb
Tools • Dart 3.6.1 • DevTools 2.40.2

Mirai

dependencies:
  flutter:
    sdk: flutter

  mirai: ^0.8.0

(2025-02-05追記)
2/3にMiraiからStacへプロダクト名が変わったことにより、パッケージも変わっています。
stac: ^0.9.2

今回サンプルアプリを作る上で下記のパッケージも利用してます

  • freezed
  • freezed_annotation
  • build_runner
  • json_serializable
  • flutter_bloc

使用するJSON

{
  "type": "scaffold",
  "appBar": {
    "type": "appBar",
    "title": {
      "type": "text",
      "data": "Flutter Mirai Demo",
      "style": {
        "fontSize": 21
      }
    },
    "backgroundColor": "#D1C4E9"
  },
  "body": {
    "type": "center",
    "child": {
       "type": "column",
        "mainAxisAlignment": "center",
        "crossAxisAlignment": "center",
        "children": [
          {
            "type": "text",
            "data": "You have pushed the button this many times:"
          },
          {
            "type": "counter_text",
            "data": "0",
            "style": {
              "fontSize": 34
            }
          }
        ]
    }
  },
  "floatingActionButton": {
    "type": "floatingActionButton",
    "backgroundColor": "#D1C4E9",
    "onPressed": {
       "actionType": "increment"
    },
    "child": {
      "type": "icon",
      "iconType": "material",
      "icon": "add"
    }
  }
}

main関数での定義

Miraiの初期化処理をMirai.initialize()を使って行います。
今回は状態を変えるために、カスタマイズしたカウント表示用テキスト(CounterTextParser)と+ボタン押下時のイベントを拾うためのアクション(IncrementActionParser)を作ったので、parsersとactionParsersにそれぞれparserを定義してます。

void main() async {
  await Mirai.initialize(
    parsers: [
      CounterTextParser(),
    ],
    actionParsers: [
      IncrementActionParser(),
    ],
  );
  runApp(const MyApp());
}

MyAppの定義

MaterialAppに変わるMirai用のWidgetのMiraiAppを定義します。

jsonを読み込む方法はMirai.fromNetworkMirai.fromAssetsMirai.fromJsonが用意されていて、今回はgistにjsonを用意したのでfromNetworkを使います。
リポジトリ内にはassetも用意したので、Mirai.fromAssetsも試せるようにしてます。
また、今回状態管理をサクッと作りたかったのでBLoCを利用しました。

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MiraiApp(
      title: 'Flutter Mirai Demo',
      theme: MiraiTheme(
        colorSchemeSeed: Colors.deepPurple.toString(),
        useMaterial3: true,
      ),
      homeBuilder: (context) => BlocProvider(
        create: (context) => CounterCubit(),
        child: Mirai.fromNetwork(
          context: context,
          request: MiraiNetworkRequest(
            url:
                'https://gist.githubusercontent.com/unbam/0f23488351c8df29625ae7765b6eacbf/raw/a7ee68568e7284416e98ab5629c1a00c9cc09979/counter.json',
            method: Method.get,
          ),
        ),
        // assetsからjsonを読み込む場合はこちら
        // child: Mirai.fromAssets('assets/json/counter.json'),
      ),
    );
  }
}

カウントの状態

import 'package:flutter_bloc/flutter_bloc.dart';

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
}

+ボタンでカウントをインクリメントする

まず、floatingActionButtonのonPressedイベントを拾いたい。Miraiの中のMiraiFloatingActionButtonParser.dartの読んでみるとonPressedが定義されていれば、Mirai.onCallFromJson()が呼ばれている

mirai_floating_action_button_parser.dart
onPressed: model.onPressed == null
  ? null
  : () => Mirai.onCallFromJson(model.onPressed, context),

Mirai.onCallFromJson()では、actionTypeを受け取り、あらかじめMirai.initialize内で定義したactionParsersから該当のactionを取得し、onCallメソッドを呼んでいる。

mirai.dart
static FutureOr<dynamic> onCallFromJson(
    Map<String, dynamic>? json,
    BuildContext context,
  ) {
    try {
      if (json != null && json['actionType'] != null) {
        String actionType = json['actionType'];
        MiraiActionParser? miraiActionParser =
            MiraiRegistry.instance.getActionParser(actionType);
        if (miraiActionParser != null) {
          final model = miraiActionParser.getModel(json);
          return miraiActionParser.onCall(context, model);
        } else {
          Log.w('Action type [$actionType] not supported');
        }
      }
    } catch (e) {
      Log.e(e);
    }
    return null;

なので改めて今回用意したcounter.jsonのfloatingActionButtonのonPressedに"actionType": "increment"を定義してみる

"floatingActionButton": {
    "type": "floatingActionButton",
    "backgroundColor": "#D1C4E9",
    "onPressed": {
       "actionType": "increment"
    },
    "child": {
      "type": "icon",
      "iconType": "material",
      "icon": "add"
    }
}

今回やりたいのはCounterCubitのincrementを呼ぶだけなので、アクション用のモデルの定義は空っぽで作りました。

import 'package:freezed_annotation/freezed_annotation.dart';

part 'increment_action.freezed.dart';
part 'increment_action.g.dart';


class IncrementAction with _$IncrementAction {
  const factory IncrementAction() = _IncrementAction;

  factory IncrementAction.fromJson(Map<String, dynamic> json) =>
      _$IncrementActionFromJson(json);
}

IncrementActionをParseするためのIncrementActionParserを用意します。
onCallメソッドでCounterCubitのインクリメント処理を呼びます。これにより、FloatingActionButtonのonPressedでIncrementActionParserのonCallが呼ばれるようになります。

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mirai/mirai.dart';

import '../counter_cubit.dart';
import 'increment_action.dart';

class IncrementActionParser implements MiraiActionParser<IncrementAction> {
  
  String get actionType => 'increment';

  
  IncrementAction getModel(Map<String, dynamic> json) =>
      IncrementAction.fromJson(json);

  
  FutureOr onCall(BuildContext context, IncrementAction model) {
    context.read<CounterCubit>().increment();
  }
}

カウントのテキストの状態を変える

最後に、カウントのテキストを作ります。
状態の変える必要のないTextは以下のようなjsonになります。

 {
    "type": "text",
    "data": "0"
},

ただ、このjsonをMirai.fromAssets等で読み取った後は状態を変更する術がないので、カスタムしたParserを用意し、状態を変えれるWidgetを作ります。
まずはカスタム用のjsonは以下のようにしました。今回はcounter_textという名で作ります。

{
    "type": "counter_text",
    "data": "0"
}

モデルは以下のようにしました。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mirai/mirai.dart';

part 'counter_text.freezed.dart';
part 'counter_text.g.dart';


class CounterText with _$CounterText {
  const factory CounterText({
    required String data,
    MiraiTextStyle? style,
  }) = _CounterText;

  factory CounterText.fromJson(Map<String, dynamic> json) =>
      _$CounterTextFromJson(json);
}

続いて、CounterTextをParseするためのCounterTextParserを用意します。
このParserにてBlocProviderを使ってCounterCubitの状態を監視し、FloatingActionButtonで定義したincrementのアクションにて値がインクリメントされることにより再描画が走る仕組みになります。

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mirai/mirai.dart';

import '../counter_cubit.dart';
import 'counter_text.dart';

class CounterTextParser extends MiraiParser<CounterText> {
  const CounterTextParser();

  
  String get type => 'counter_text';

  
  CounterText getModel(Map<String, dynamic> json) => CounterText.fromJson(json);

  
  Widget parse(BuildContext context, model) {
    return BlocBuilder(
      bloc: BlocProvider.of<CounterCubit>(context),
      builder: (context, state) => Text(
        state.toString(),
        style: model.style?.parse(context),
      ),
    );
  }
}

動作

ロードマップ

今年の9月にはMirai Studio Betaが予定されていて、一気通貫でアプリが作れるようになるのかも。今のFlutterFlowを追えてないので、なんとも言えないですが、どういった差別化になるんだろ。

参考

https://stac.dev/
https://buildmirai.dev/
https://docs.buildmirai.dev/concepts/parsers
https://medium.com/flutter-uae/creating-dynamic-and-scalable-ui-with-server-driven-ui-in-flutter-with-mirai-5875146d8a3b

リポジトリ

https://github.com/unbam/flutter_mirai_sample

Discussion

はがくん@元薬剤師のFlutter/Goエンジニアはがくん@元薬剤師のFlutter/Goエンジニア

ちょうどSDUIについて調べていて、先日記事を書いたのですが、
Miraiというフレームワークがあったことは知らず、とても参考になりました!
ありがとうございます!

TakeTake

コメントありがとうございます!
私もこの前知ったばかりでまだどんなことができるかちゃんと見れてないので是非、公式見てみてください!