🤖

[Flutter] コード自動生成ツールmasonを使い倒してみる [実例]

2024/05/06に公開

概要

Flutterアプリ開発において、画面のボイラープレートを作成するのは面倒な作業ですが、masonを使えば、ViewModel、State、そして画面のテンプレートを一瞬で生成することができます。この記事では、masonのインストールから、カスタムテンプレートの作成、そしてhookを使った柔軟なコード生成まで、実践的な使い方を段階的に紹介します。

masonを導入することで、毎回同じようなコードを書く手間が省け、開発スピードが飛躍的に向上します。ぜひ、masonを活用してFlutterアプリ開発を効率化しましょう!

要件

  • 画面のボイラープレートと、それに付随するViewModelとStateを生成する。
  • 生成した瞬間からビルドできる状態になっている。

masonのインストール

残念ながら、 asdf にはmasonのプラグインが見当たりませんでした。そのため、今回は Homebrew を使ってインストールします。

brew tap felangel/mason
brew install mason

masonは内部でdartコマンドを使用しているため、dartのバージョンが古い場合は、先に最新版にアップデートしておくことをおすすめします。

mason init

プロジェクトのルートディレクトリで以下のコマンドを実行すると、 mason.yaml ファイルが生成されます。

mason init

mason new

次に、テンプレートを作成していきます。今回は ${projectRoot}/mason ディレクトリ以下にテンプレートを配置することにします。
例として、view_model というテンプレートを作成してみましょう。 mason new コマンドを実行すると、${projectRoot}/mason/view_model (以下、テンプレートディレクトリと呼称) 配下にテンプレート一式が生成されます。
--hooks オプションを使用すると、自動でhooks(後述)を追加できますが、後から追加することも可能です。なお、グローバル(自分のローカル環境)にテンプレートを作成して使用することもできるようです。

$ mkdir mason
$ cd mason
$ mason new view_model
$ cd view_model
$ tree
 ├── CHANGELOG.md
 ├── LICENSE
 ├── README.md
 ├── __brick__
 │   └── HELLO.md
 └── brick.yaml

登録する

mason make コマンドを使ってテンプレートから実際のコードを生成するには、事前に mason add コマンドでテンプレートを登録する必要があります。

mason add view_model --path ./mason/view_model

生成する

以下のコマンドを実行することで、テンプレートからコードを生成できます。

mason make view_model --output-dir ./lib/{出力先}

テンプレートをカスタマイズする

基本的な説明は省略しますが、変数の文字列中の / がエスケープされてしまう問題があります。これを回避するには、 {{{}}} で変数を囲むと良いでしょう。
__brick__ ディレクトリ以下の内容が、そのまま --output-dir で指定したディレクトリに展開されるイメージです。

import 'package:.../service/logger/logger_provider.dart';
{{{viewStateImport}}}

import 'package:riverpod_annotation/riverpod_annotation.dart';

part '{{name}}_view_model.g.dart';

@Riverpod(keepAlive: false, dependencies: [logger])
class {{name.pascalCase()}}ViewModel extends _${{name.pascalCase()}}ViewModel {

  @override
  Future<{{name.pascalCase()}}ViewState> build() async {
    return {{name.pascalCase()}}ViewState.initial();
  }
}

├── CHANGELOG.md
├── LICENSE
├── README.md
├── __brick__
│   ├── view_model
│   │   ├── {{name}}_view_model.dart
│   │   └── {{name}}_view_state.dart
│   └── {{name}}_screen.dart
├── brick.yaml
└── hooks (...)

テンプレートの入力項目の設定

brick.yaml を編集することで、テンプレートの入力項目をカスタマイズできます。詳細は割愛しますが、今回は .vars.name を編集しました。

name: view_model
description: A new brick created with the Mason CLI.

version: 0.1.0+1

environment:
  mason: ">=0.1.0-dev.53 <0.1.0"

vars:
  name:
    type: string
    description: ViewModel name
    default: Dash
    prompt: What is ViewModel name?

hook

pre_gen.dart の hook を使うことで、コード生成前に任意の処理を実行させることができます。 post_gen の hook も用意されているようです。
hook を有効にするには、テンプレートディレクトリ以下に hooks ディレクトリ(dartプロジェクト)を作成し、その中に pre_gen.dart を配置します。 pubspec.yamlpre_gen.dart を用意したら、 dart pub get を実行しておきましょう。

name: hooks

environment:
  sdk: "^3.0.0"

dependencies:
  mason: ^0.1.0-dev
  path: ^1.9.0
import 'dart:io';

import 'package:mason/mason.dart';

void run(HookContext context) {
  final name = context.vars['name'] as String;
}

.
(...)
├── __brick__ (...)
└── hooks
    ├── build (...)
    ├── pre_gen.dart
    ├── pubspec.lock
    └── pubspec.yaml

pre_genで入力を加工する

今回のケースでは、生成したファイル間で相互にimportが必要になるため、それを自前で生成してあげないと、生成直後にビルドエラーになってしまいます。そこで、 viewStateImport という変数にimport文を詰めて対応しています。8割方、関数名だけ書いてCopilot君に生成してもらったコードなので、マサカリはCopilot君に投げましょう。

ポイントとしては、 masonのパッケージがStringの拡張関数群をexportしているので、 .snakeCase で文字列をスネークケース化できたりします。あとは、logger.err を呼んでもエラーにならないという難点を抱えています(詳細、意図などは未調査です)。

import 'dart:io';

import 'package:mason/mason.dart';
import "package:path/path.dart" show dirname, join, relative;

void run(HookContext context) {
  final name = context.vars['name'] as String;
  context.vars['name'] = name.snakeCase;
  final packagePath =
      getPackagePathOf("view_model/${name.snakeCase}_view_state.dart");
  final viewStateImport =
      "import 'package:${getPackageName()}/${packagePath}';";
  context.vars['viewStateImport'] = viewStateImport;
}

String getPackagePathOf(String relativePath) {
  final packageRoot = relative(Directory.current.path, from: getProjectRoot())
      .replaceAll('lib/', '');
  return join(packageRoot, relativePath);
}

String getProjectRoot() {
  return getProjectRootRecursively(Directory.current.path);
}

String getProjectRootRecursively(String current) {
  if (isProjectRoot(current)) {
    return current;
  }
  return getProjectRootRecursively(dirname(current));
}

bool isProjectRoot(String path) {
  final pubspec = File(join(path, 'pubspec.yaml'));
  if (pubspec.existsSync()) {
    return true;
  }
  return false;
}

String getPackageName() {
  final pubspec = File(join(getProjectRoot(), 'pubspec.yaml'));
  final content = pubspec.readAsStringSync();
  final packageName = RegExp(r'name: (.+)').firstMatch(content)?.group(1);
  if (packageName == null) {
    throw Exception('Could not find package name in pubspec.yaml');
  }
  return packageName;
}

懺悔

  • mason以外に良いツールがあるのかは調べていないです。
  • 概要だけClaude君に書いてもらいました。
  • GitHubのイシューやソースコードは一部確認しましたが、READMEは読み込んでいないので、このユースケースが適切かどうかは自信がありません。

Discussion