[Flutter] Stacを使ってServer-Driven UIでアプリを作る(stac 1.0.0-dev.6版)
Stacとは
Stac (formerly Mirai) is a powerful Server-Driven UI (SDUI) framework for Flutter, enabling you to build beautiful, cross-platform applications dynamically using JSON in real time.
Whether you’re building apps for mobile, web, or desktop, Stac simplifies UI delivery and enhances flexibility without requiring redeployment for every design change.
(翻訳)
Stac(旧Mirai)はFlutterのための強力なサーバ駆動型UI(SDUI)フレームワークで、JSONを使って美しいクロスプラットフォームアプリケーションをリアルタイムで動的に構築することができます。
モバイル、ウェブ、デスクトップアプリのいずれを構築する場合でも、StacはUI配信を簡素化し、デザイン変更のたびに再デプロイすることなく柔軟性を高めます。
(番外編)Flutterで動的にUIを作る
番外編なのですが、実はjson_dynamic_widgetてのがあって、それを使ってみた記事を書いてるのでそれも見てみてください。
カウンターアプリ作ってみる
前置きはこれくらいにして早速カウンターアプリで試してみる。
検証環境
Flutter
Flutter 3.35.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision b896255557 (4 days ago) • 2025-08-13 17:14:08 -0700
Engine • hash 6cd51c08a88e7bbe848a762c20ad3ecb8b063c0e (revision 1e9a811bf8) (3 days ago) • 2025-08-13 23:35:25.000Z
Tools • Dart 3.9.0 • DevTools 2.48.0
Stac
dependencies:
flutter:
sdk: flutter
stac: ^1.0.0-dev.6
今回サンプルアプリを作る上で下記のパッケージも利用してます
- freezed
- freezed_annotation
- build_runner
- json_serializable
- flutter_bloc
使用するJSON
{
"type": "scaffold",
"appBar": {
"type": "appBar",
"title": {
"type": "text",
"data": "Flutter Stac 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関数での定義
Stacの初期化処理をStac.initialize()
を使って行います。
今回は状態を変えるために、カスタマイズしたカウント表示用テキスト(CounterTextParser)と+ボタン押下時のイベントを拾うためのアクション(IncrementActionParser)を作ったので、parsersとactionParsersにそれぞれparserを定義してます。
import 'package:stac/stac.dart';
void main() async {
await Stac.initialize(
parsers: [
CounterTextParser(),
],
actionParsers: [
IncrementActionParser(),
],
);
runApp(const MyApp());
}
MyAppの定義
jsonを読み込む方法はStac.fromNetwork
、Stac.fromAssets
、Stac.fromJson
が用意されていて、今回はgistにjsonを用意したのでfromNetworkを使います。
リポジトリ内にはassetも用意したので、Stac.fromAssets
も試せるようにしてます。
また、今回状態管理をサクッと作りたかったのでBLoCを利用しました。
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Stac Sample',
home: BlocProvider(
create: (context) => CounterCubit(),
child: Builder(
builder: (context) => Stac.fromNetwork(
context: context,
request: StacNetworkRequest(
url:
'https://gist.githubusercontent.com/unbam/0f23488351c8df29625ae7765b6eacbf/raw/50d1bdc219de268b02fd87b3b3f21d7463903e6b/counter.json',
method: Method.get,
),
),
),
// assetsからjsonを読み込む場合はこちら
// child: Stac.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イベントを拾いたい。Stacの中のstac_floating_action_button_parser.dartの読んでみるとonPressedが定義されていれば、Stac.onCallFromJson()
が呼ばれている
onPressed: model.onPressed == null
? null
: () => Stac.onCallFromJson(model.onPressed, context),
Stac.onCallFromJson()
では、actionType
を受け取り、あらかじめStac.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'];
StacActionParser? stacActionParser =
StacRegistry.instance.getActionParser(actionType);
if (stacActionParser != null) {
final model = stacActionParser.getModel(json);
return stacActionParser.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が呼ばれるようになります。
ボタン押下の処理はParser側のonCallに書く必要があるので、ボタンの中の処理までJSONで書くとかはできなさそう。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:stac/stac.dart';
import '../counter_cubit.dart';
import 'increment_action.dart';
class IncrementActionParser implements StacActionParser<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をStac.fromAssets
等で読み取った後は状態を変更する術がないので、カスタムしたParserを用意し、状態を変えれるWidgetを作ります。
まずはカスタム用のjsonは以下のようにしました。今回はcounter_text
という名で作ります。
{
"type": "counter_text",
"data": "0"
}
モデルは以下のようにしました。
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:stac/stac.dart';
part 'counter_text.freezed.dart';
part 'counter_text.g.dart';
abstract class CounterText with _$CounterText {
const factory CounterText({
required String data,
StacTextStyle? 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:stac/stac.dart';
import '../counter_cubit.dart';
import 'counter_text.dart';
class CounterTextParser extends StacParser<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)),
);
}
}
動作
ロードマップ
前回の記事ではMirai Studio Beta
というFlutterFlowのようなUIでアプリが作れるようなロードマップがあったのですが、多分なくなってそう。
その代わり下記の機能がCOMING SOONになってる。
- Dynamic Variables & State Management
- 状態管理に対応できそう?
- Efficient Data Caching
- キャッシュ機能でパフォーマンスを最適化
- Stac Code Convert: Dart to JSON
- DartをJSONに瞬時に変換し、UIを簡単に更新
- DartをJSONに瞬時に変換し、UIを簡単に更新
参考
リポジトリ
この記事では画面全体のjsonを取り込んだ例を作りましたが、feat/part_of_widget
ブランチにて一部分をjsonにして取り込んだサンプルもありますので、見てみてください。
Discussion