【Dart】Flutter で JSON 設定ファイルを読み書きする

Dart/Flutter

Dart を書くモチベーションは多くの場合「Flutter で GUI アプリケーションを作りたい」、というものになると思います。

GUI アプリケーションを作るとなると、「ユーザー設定ファイルを保存したい」という需要にも繋がってくることが多いです。
そこで、この記事では「ユーザー設定を Dart で扱えるようにしつつ、JSON としてファイルに読み書きできる」サンプル実装を示します。

サンプル実装

まずはサンプル実装を示し、その後に詳しく解説します。
Test クラスが JSON として読み書きしたいデータを保持するクラスです。

Dart プログラム

import 'dart:convert';
import 'dart:io';
import 'package:json_annotation/json_annotation.dart';

part 'test.g.dart';

@JsonSerializable()
class Test {
  static final Test _instance = Test._internal().._setDefault();

  late String hoge;
  late int fuga;

  Test._internal();
  factory Test() => _instance;

  Future load(String jsonPath) async {
    final file = File(jsonPath);
    if (!(await file.exists())) {
      _setDefault();
      return;
    }

    final json = await file.readAsString();
    _$TestFromJson(jsonDecode(json));
  }

  Future save(String jsonPath) async {
    final json = jsonEncode(_$TestToJson(this));
    await File(jsonPath).writeAsString(json);
  }

  void _setDefault() {
    hoge = '';
    fuga = 0;
  }
}
import 'package:test/test.dart';

void main() async {
  const jsonPath = "test.json"; // 好きなパスに変えてください

  // シングルトンを取得
  final t = Test();
  print('hoge: ${t.hoge}, fuga: ${t.fuga}');

  // JSON読み込み
  await t.load(jsonPath);
  print('hoge: ${t.hoge}, fuga: ${t.fuga}');

  // 適当な値を設定
  t.hoge = 'hogehoge';
  t.fuga = 42;
  print('hoge: ${t.hoge}, fuga: ${t.fuga}');

  // JSON書き込み
  await t.save(jsonPath);
}

パッケージ参照の追加

pubspec.yaml に以下のような記述を足してください。

dependencies:
  build_runner: ^2.0.3
  json_serializable: ^4.1.2

自動生成コードの生成

Terminal で flutter packages pub run build_runner build と実行してください。
test.g.dart というファイルが自動生成されます。

詳しい解説

Dart インスタンスを JSON に変換する処理の概要は、以下の通りです。

  1. Test Map<String, dynamic> に変換
  2. Dart の標準ライブラリで Map<String, dynamic>String (JSON) に変換

このうち、1. の処理は Dart 標準でサポートしてくれないので、json_serializable というパッケージを使い、コードを自動生成することで補助します。
自動生成を走らせるコマンドが、flutter packages pub run build_runner build です。
このコマンドの実行に build_runner パッケージが必要になります。

また、アプリを通してアクセスしたい設定ファイルオブジェクトは1つのはずなので、シングルトンパターンを使って同一インスタンスへのアクセスを保証します。
ここはせっかくなので、Dart 独特の機能であるファクトリコンストラクタを利用しています。

test.dart の解説

import 'dart:convert';
import 'dart:io';
import 'package:json_annotation/json_annotation.dart';

part 'test.g.dart';

必要なライブラリをインポートしています。
また、自動生成ファイルである test.g.dart を、このファイルの一部として読み込んでいます。

@JsonSerializable()
class Test {
  static final Test _instance = Test._internal().._setDefault();

  late String hoge;
  late int fuga;

  Test._internal();
  factory Test() => _instance;

@JsonSerializable() は、そのクラスが JSON として扱うための自動生成処理対象であることを示しています。

_instance はシングルトンパターンのインスタンスで、静的かつ再代入不可としています。
_setDefault は void を返すメソッドですが、Cascade を利用しているため Test インスタンスを返せています。

Test クラスで JSON として保存したいのは hogefuga です。
これらは後ほど出てくる _setDefault() メソッドで初期化したいため(詳しくは後ほど解説します)、late キーワードを付加しています。

Test._internal()名前付きコンストラクタです。
また、アンダースコアから始まるので test.dart からのみ参照できます。これによってシングルトンインスタンスの同一性を担保しています。

Test()ファクトリコンストラクタです。
利用者が呼び出すのはこちらになります。ファクトリコンストラクタでは新規インスタンスを生成せず、シングルトンを返しています。

  Future load(String jsonPath) async {
    final file = File(jsonPath);
    if (!(await file.exists())) {
      _setDefault();
      return;
    }

    final json = await file.readAsString();
    _$TestFromJson(jsonDecode(json));
  }

  Future save(String jsonPath) async {
    final json = jsonEncode(_$TestToJson(this));
    await File(jsonPath).writeAsString(json);
  }

  void _setDefault() {
    hoge = '';
    fuga = 0;
  }

load(String) メソッドは与えられた JSON ファイルパスから設定を読み込みます。
非同期処理を含むため、async キーワードが付いています。
挙動としては、指定されたパスにファイルが存在しなかった場合、インスタンスをデフォルト値でリセットします。
ファイルが存在した場合、ファイルから文字列を読み込んで、jsonDecode() で Map<String, dynamic> に変換し、自動生成された _$TestFromJson() メソッドでシングルトンインスタンスに値を設定しています。

ちなみに、自動生成コードからシングルトンインスタンスに値を設定できるのは、ファクトリコンストラクタの存在が大きく関わっています

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'test.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

Test _$TestFromJson(Map<String, dynamic> json) {
  return Test()
    ..hoge = json['hoge'] as String
    ..fuga = json['fuga'] as int;
}

Map<String, dynamic> _$TestToJson(Test instance) => <String, dynamic>{
      'hoge': instance.hoge,
      'fuga': instance.fuga,
    };

こちらは自動生成されるコードですが、_$TestFromJson メソッドを見ると、Test の無引数コンストラクタを呼んでそれに適切な値を設定して返しています。
ここで、Test の無引数コンストラクタはファクトリコンストラクタとして定義しているため、新規インスタンスを作成するのではなく、シングルトンインスタンスに対して設定できているわけです。

さて、test.dart の解説に戻りますが、save(String) メソッドは load(String) メソッドの逆操作なので、詳しい解説は省略します。

最後に setDefault() メソッドですが、ここでデフォルト値を設定しています。
つまり、メンバーが増減するごとにこちらを調整する必要があります
メンバー変数を late として定義している理由はまさにこれで、通常の宣言では必ずメンバ変数を初期化する必要があるため、調整する箇所が増えてしまいバグを埋め込みやすくなります。
基本的に late 宣言は避けるべきですが、今回はこちらの方が妥当な設計だと判断しました。

main.dart の解説

main.dart は利用者側で、実際に Dart で呼び出されるメインメソッドが記述されています。
処理についてはコメントの通りで、特に解説は不要かと思います。

まとめ

ユーザー設定を Dart で扱えるようにしつつ、JSON としてファイルに読み書きできる」サンプル実装を示しました。

意図したわけではないですが、Dart 独特の機能が盛り沢山になった気がします。
ファクトリコンストラクタなどは使い所に気をつける必要がありますが、今回のような例だといい感じに使えるのではないかな?と思います。

コメント

タイトルとURLをコピーしました