[Flutter] Stacを使ってServer-Driven UIでアプリを作る
知ったきっかけ
たまたまXでFlutterのワードで調べてたら、こんな投稿があって知った
ふむふむ、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へプロダクト名が変更になってました。
モバイルアプリの開発の大変さ
モバイルアプリの大変さって機能を作ってはビルドし、ストアにアップし審査に出してリリースするといったサイクルの中で速度感のある細かいUI検証やバグの対処をやる中でユーザーに早く提供するのが難しいところだったりしますよね。日々「あ、この対応もリリースに含めりゃよかった」って思うことあります。リリースせずにサーバサイドでUIが提供できれば便利だろうけど、実際のところSDUIを採用してるところどれくらいいるんだろ🤔
開発コスト的に結果上がるのか下がるのか気になる..
(番外編)Flutterで動的にUIを作る
早速番外編なのですが、実はjson_dynamic_widgetてのがあって、それを使ってみた記事を書いてるのでそれもそれも見てみてください。
カウンターアプリ作ってみる
前置きはこれくらいにして早速カウンターアプリで試してみる。
検証環境
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.fromNetwork
、Mirai.fromAssets
、Mirai.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()
が呼ばれている
onPressed: model.onPressed == null
? null
: () => Mirai.onCallFromJson(model.onPressed, context),
Mirai.onCallFromJson()
では、actionType
を受け取り、あらかじめMirai.initialize内で定義したactionParsersから該当のactionを取得し、onCall
メソッドを呼んでいる。
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を追えてないので、なんとも言えないですが、どういった差別化になるんだろ。
参考
リポジトリ
Discussion
ちょうどSDUIについて調べていて、先日記事を書いたのですが、
Miraiというフレームワークがあったことは知らず、とても参考になりました!
ありがとうございます!
コメントありがとうございます!
私もこの前知ったばかりでまだどんなことができるかちゃんと見れてないので是非、公式見てみてください!