FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) の次に整理しておきたいのが、JSON モデルの持ち方です。前記事では fromJson を手書きしましたが、モデルが増えるほどキー名変更やネスト構造の追従が重くなります。この記事では json_serializable と build_runner を使い、ネストした JSON を Dart モデルへ変換し、toJson() で API 送信用の Map に戻すところまでを最小構成で確認します。
1. ゴールと非対象
対象読者
- Flutter の環境構築、Dart 基礎、REST API 通信の入口までは終わっている人
- JSON モデルを毎回手書きしていて、保守の増え方が気になってきた人
- 今後の Riverpod、CRUD、フォーム記事へ進む前に、モデル生成の土台を先にそろえたい人
この記事で到達する状態
json_annotation/json_serializable/build_runnerの役割を説明できる- ネストした JSON を
ShipmentPageResponse.fromJson()で Dart モデルへ変換できる toJson()を使って送信用の Map を作り、jsonEncode()で確認できる- モデルを変更したあとに、どのコマンドを再実行すべきか判断できる
非対象
freezedを使った immutable model 生成- Riverpod や Bloc などの状態管理
- HTTP 通信そのものの説明
- 複数ファイル分割や Repository パターン
今回はモデル生成の入口に絞ります。通信部分は前記事のまま使い、手書きモデルだけを生成コードへ置き換えるつもりで読むとつながりやすくなります。
2. 先に生成の流れを掴む
今回の流れは次の通りです。
flowchart LR
A[JSON文字列 or response.body] --> B[jsonDecodeでMap<String, dynamic>へ変換]
B --> C[ShipmentPageResponse.fromJson]
C --> D[ShipmentSummary / CustomerInfo / PageInfo]
D --> E[UIで表示]
D --> F[toJsonでMapへ戻す]
F --> G[jsonEncodeで送信・確認]
H[JsonSerializable アノテーションをモデルに付ける] --> I[build_runner が main.g.dart を生成]
I --> C
I --> F
3 つのパッケージはそれぞれ役割が異なります。
json_annotation: モデルに付ける注釈を提供するjson_serializable: 注釈を読んでfromJson/toJsonの実装コードを生成するbuild_runner: 生成処理を実行する
開発者が書くのはモデル定義です。実際の変換コードは main.g.dart に出るため、キーを追加するたびに手で Map を書き直す手間を省けます。
3. プロジェクトを作成し、依存を追加する
3-1. 環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create my_json_model_app
cd my_json_model_app
3-3. エミュレーターを起動する
利用可能なエミュレーターを確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. パッケージを追加する
今回は次の 3 つを追加します。
flutter pub add json_annotation
flutter pub add --dev build_runner
flutter pub add --dev json_serializable
役割は次の通りです。
json_annotation: モデルへ@JsonSerializable()や@JsonKey()を付けるための通常依存build_runner: 生成処理を回すための dev dependencyjson_serializable:*.g.dartを生成するための dev dependency
この段階では HTTP パッケージを増やしません。前記事の通信コードに接続する前に、まずモデル生成だけを単独で確認します。
4. 注釈付きの lib/main.dart を作成する
この lib/main.dart は、JSON 文字列をモデルへ変換し、再び toJson() で戻す流れを 1 ファイルで確認するサンプルです。生成コードに任せる境界と、UI 側が何だけを呼べばよいかを見ます。
lib/main.dart は次の内容で作成します。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
part 'main.g.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'json_serializable demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const ShipmentJsonDemoPage(),
);
}
}
class ShipmentJsonDemoPage extends StatefulWidget {
const ShipmentJsonDemoPage({super.key});
@override
State<ShipmentJsonDemoPage> createState() => _ShipmentJsonDemoPageState();
}
class _ShipmentJsonDemoPageState extends State<ShipmentJsonDemoPage> {
ShipmentPageResponse? _response;
String _encodedJson = '';
String? _errorMessage;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadSample();
}
Future<void> _loadSample() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
await Future<void>.delayed(const Duration(milliseconds: 250));
try {
final Map<String, dynamic> json =
jsonDecode(_shipmentPageJson) as Map<String, dynamic>;
final ShipmentPageResponse response = ShipmentPageResponse.fromJson(json);
final String encodedJson =
JsonEncoder.withIndent(' ').convert(response.toJson());
if (!mounted) {
return;
}
setState(() {
_response = response;
_encodedJson = encodedJson;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_response = null;
_encodedJson = '';
_errorMessage = 'JSON 変換に失敗しました。詳細: $error';
});
} finally {
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('json_serializable で JSON モデル化'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'出荷一覧レスポンスを fromJson で読み込み、toJson で戻します。',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'今回は通信ではなくモデル生成が主題なので、JSON サンプル文字列を直接読みます。',
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _isLoading ? null : _loadSample,
icon: const Icon(Icons.sync),
label: const Text('サンプルJSONを再読込'),
),
],
),
),
),
const SizedBox(height: 16),
if (_isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
)
else if (_errorMessage != null)
Card(
color: colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_errorMessage!,
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
)
else if (_response != null) ...<Widget>[
_PageSummaryCard(page: _response!.page, itemCount: _response!.items.length),
const SizedBox(height: 16),
Text(
'変換後のモデル',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._response!.items.map(
(ShipmentSummary shipment) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _ShipmentCard(shipment: shipment),
),
),
const SizedBox(height: 12),
Text(
'toJson の出力確認',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: SelectableText(_encodedJson),
),
],
],
),
);
}
}
class _PageSummaryCard extends StatelessWidget {
const _PageSummaryCard({required this.page, required this.itemCount});
final PageInfo page;
final int itemCount;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_SummaryItem(label: '現在ページ', value: '${page.current}'),
_SummaryItem(label: '表示件数', value: '$itemCount'),
_SummaryItem(label: '総件数', value: '${page.totalCount}'),
],
),
),
);
}
}
class _SummaryItem extends StatelessWidget {
const _SummaryItem({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text(label, style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
Text(value, style: Theme.of(context).textTheme.titleLarge),
],
);
}
}
class _ShipmentCard extends StatelessWidget {
const _ShipmentCard({required this.shipment});
final ShipmentSummary shipment;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
shipment.code,
style: Theme.of(context).textTheme.titleMedium,
),
Chip(label: Text(shipment.status)),
],
),
const SizedBox(height: 8),
Text('取引先: ${shipment.customer.name} (${shipment.customer.code})'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: shipment.tags
.map((String tag) => Chip(label: Text(tag)))
.toList(),
),
],
),
),
);
}
}
@JsonSerializable(explicitToJson: true)
class ShipmentPageResponse {
const ShipmentPageResponse({required this.items, required this.page});
final List<ShipmentSummary> items;
final PageInfo page;
factory ShipmentPageResponse.fromJson(Map<String, dynamic> json) =>
_$ShipmentPageResponseFromJson(json);
Map<String, dynamic> toJson() => _$ShipmentPageResponseToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ShipmentSummary {
const ShipmentSummary({
required this.id,
required this.code,
required this.status,
required this.customer,
required this.tags,
});
final int id;
final String code;
final String status;
final CustomerInfo customer;
final List<String> tags;
factory ShipmentSummary.fromJson(Map<String, dynamic> json) =>
_$ShipmentSummaryFromJson(json);
Map<String, dynamic> toJson() => _$ShipmentSummaryToJson(this);
}
@JsonSerializable()
class CustomerInfo {
const CustomerInfo({required this.code, required this.name});
final String code;
final String name;
factory CustomerInfo.fromJson(Map<String, dynamic> json) =>
_$CustomerInfoFromJson(json);
Map<String, dynamic> toJson() => _$CustomerInfoToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class PageInfo {
const PageInfo({
required this.current,
required this.pageSize,
required this.totalCount,
});
final int current;
final int pageSize;
final int totalCount;
factory PageInfo.fromJson(Map<String, dynamic> json) =>
_$PageInfoFromJson(json);
Map<String, dynamic> toJson() => _$PageInfoToJson(this);
}
const String _shipmentPageJson = '''
{
"items": [
{
"id": 101,
"code": "S-1001",
"status": "ピッキング中",
"customer": {
"code": "C001",
"name": "東京商事"
},
"tags": ["priority", "cool-chain"]
},
{
"id": 102,
"code": "S-1002",
"status": "確認待ち",
"customer": {
"code": "C002",
"name": "大阪物流"
},
"tags": ["fragile"]
},
{
"id": 103,
"code": "S-1003",
"status": "出荷済み",
"customer": {
"code": "C003",
"name": "名古屋販売"
},
"tags": ["export", "morning"]
}
],
"page": {
"current": 1,
"page_size": 3,
"total_count": 12
}
}
''';
コードのポイント
① part 'main.g.dart'; が生成コードの接続点になる
part 'main.g.dart';
この宣言があることで、build_runner が生成した _$ShipmentPageResponseFromJson などを同じライブラリから参照できます。生成コードを直接読む前に、まずここが接続点だと捉えると整理しやすくなります。
② ネストしたモデルを toJson() まで辿るには explicitToJson: true を付ける
@JsonSerializable(explicitToJson: true)
class ShipmentPageResponse {
const ShipmentPageResponse({required this.items, required this.page});
ShipmentPageResponse の中には ShipmentSummary や PageInfo が入っているため、ネスト先も toJson() で変換したい意図を明示しています。親モデルだけでなく、入れ子のオブジェクトも JSON 化したいときの基本設定です。
③ snake_case の JSON キーは fieldRename で Dart 側へ寄せる
@JsonSerializable(fieldRename: FieldRename.snake)
class PageInfo {
JSON の page_size と total_count を、Dart 側では pageSize と totalCount のまま扱えます。API の命名規則を UI 側へ持ち込まずに済むため、モデルの読みやすさを保ちやすくなります。
④ UI 側は fromJson() と toJson() を呼ぶだけでよい
final Map<String, dynamic> json =
jsonDecode(_shipmentPageJson) as Map<String, dynamic>;
final ShipmentPageResponse response = ShipmentPageResponse.fromJson(json);
final String encodedJson =
JsonEncoder.withIndent(' ').convert(response.toJson());
変換の詳細は生成コードに任せ、画面側は入力と出力の呼び出しだけを持っています。これにより、UI は「JSON をモデルにする」「モデルを JSON に戻す」という役割に集中できます。
前記事の response.body を使う場合も、置き換えるのは jsonDecode の入力元だけです。jsonDecode(response.body) の結果を ShipmentPageResponse.fromJson() に渡せば、以降も同じように扱えます。
5. build_runner で main.g.dart を生成する
lib/main.dart を保存したら、プロジェクト直下で次のコマンドを実行します。
dart run build_runner build --delete-conflicting-outputs
生成が終わると lib/main.g.dart が作られます。これは自動生成物のため、手編集は不要です。モデルのフィールドや注釈を変更したら、同じコマンドをもう一度流します。
既存の記事や古い資料では flutter pub run build_runner build --delete-conflicting-outputs と書かれていることがあります。どちらも同じ生成処理を実行します。
頻繁にモデルを触る間だけ、監視実行へ切り替える方法もあります。
dart run build_runner watch --delete-conflicting-outputs
生成が終わったら、次のコマンドでアプリを起動します。
flutter run
fromJson() と toJson() が正しく生成されていれば、起動直後に出荷一覧と toJson の整形結果が表示されます。
6. 自動生成に任せる範囲と運用のコツ
導入直後に迷いやすい点をまとめます。
6-1. ネストした toJson() は explicitToJson: true を意識する
親モデルの toJson() から子モデルの toJson() を明示的に呼びたいときは、親へ explicitToJson: true を付けておくと読みやすくなります。今回なら ShipmentPageResponse と ShipmentSummary がそれに当たります。
6-2. JSON のキー名が snake_case なら、Dart 側は camelCase で持てる
PageInfo のようにクラス単位でそろっているなら fieldRename: FieldRename.snake が素直です。1 つだけ例外がある場合は、@JsonKey(name: '...') をそのフィールドだけに付ける方法も選べます。
6-3. モデルを変えたあとに再生成しないと、エラー位置が分かりにくくなる
フィールド追加や名前変更のあとに main.g.dart を更新しないと、コンパイルエラーが UI 側ではなく生成物側に出ることがあります。モデルを触った直後に build_runner を回す流れへ寄せると、確認箇所がぶれにくくなります。
6-4. 手書きモデルと生成モデルの違い
| 観点 | 手書き fromJson / toJson | json_serializable |
|---|---|---|
| 最初の1本 | 速い | 準備が少し増える |
| フィールド追加 | 全モデルを手で直す | 注釈側を直して再生成する |
| ネスト JSON | 書き漏れが出やすい | 親子関係をクラスで保ちやすい |
| API 契約の変更追従 | キー名の散在が起きやすい | モデル定義へ寄せやすい |
REST API 入門の段階では手書きでも十分です。モデル数が増え始めたところで生成へ切り替えると、保守コストを抑えやすくなります。
7. まとめ
json_serializable と build_runner を入れると、JSON モデルの責務を Dart クラスへ寄せたまま、変換コードだけを自動生成できます。前記事の FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) で手書きしていた fromJson をこの形へ置き換えるだけでも、後続の状態管理や CRUD で追う場所を減らせます。
次にモデル数が増える記事へ進む前に、まずはこのサンプルを手元で動かし、status や page_size のキー名を変えてから build_runner を再実行してみてください。どの差分がモデル定義に集まるかを一度体感しておくと、以降の Flutter 記事でも読み方がそろいます。