自作で Dart の Custom Lint パッケージを公開した話

作成日: 2025-03-18 /

Custom Lints パッケージを使って自分で初めて Dart の静的解析のルールを自作したので、その共有。

sangria_lints | Flutter package

sangria_lints | Flutter package

https://pub.dev

sangria_lints | Flutter package

モチベーション

ある日開発している Flutter アプリと連携している Firebase Crashlytics でエラー監視していた。すると、Null check operator used on a null value というメッセージでエラーが複数のウィジェットからあがっていた。すべてのエラーのスタックトレースを読むと共通して StatefulWidgetsetState でエラーが吐かれていた。

調査してみると、どうやら StatefulWidgetsetState を非同期処理で実行しているのが原因だった。ウィジェットがすでに破棄されているにもかかわらず、state を更新できずエラーになっていることがわかった。エラーが発生しないようにするには、setState を実行する前に、mounted の条件分岐でウィジェットの有無を確認することになる。

[Flutter]setStateメソッドの中で非同期処理をやってはいけない – みんプロ式 – 40代からの初心者向けスマホアプリ開発講座(Flutter)

[Flutter]setStateメソッドの中で非同期処理をやってはいけない – みんプロ式 – 40代からの初心者向けスマホアプリ開発講座(Flutter)

https://minpro.net

[Flutter]setStateメソッドの中で非同期処理をやってはいけない – みんプロ式 – 40代からの初心者向けスマホアプリ開発講座(Flutter)

ちなみに、デバッグモードで再現を試みると、setState() called after dispose() とエラーが出ており、本文は下のものになる。Crashlytics に記載されているエラーメッセージは違うが、リリースモードだとメッセージが変更されると記載している issue を見つけた。

setState() called after dispose(): This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree (e.g., whose dispose() method has been called).

mounted の条件分岐を該当箇所に入れて、無事エラーを修正した。しかし、アプリ内で複数の非同期処理で setState が呼ばれており、該当箇所全てを探すのが難しかった。また今度似た記述が追加される可能性を考えると、技術的に再発防止できないかと検討した。

静的解析のルールがないか検索したが、調べた限りだと、有料のものしかなかった。

use-setstate-synchronously | DCM - Code Quality Tool for Flutter Developers

use-setstate-synchronously | DCM - Code Quality Tool for Flutter Developers

https://dcm.dev

use-setstate-synchronously | DCM - Code Quality Tool for Flutter Developers

上のものをお見送りにするよりも、それなら自分も含めて誰もが無料使用できるルールを作りたく、実装にチャレンジした。個人的に今年の年始で AST の勉強をしたのもあり、自分の個人アプリでも使用する可能性があるので、技術的にレベルアップを図ろうと考えた。

作ったルール

作ったルールは上の既存のルールと同じように、set_setstate_synchronously という名前にした。StatefulWidget クラスを継承しているウィジェットの中で、非同期処理で mounted でウィジェットの有無を確認しないでの setState の使用に対して警告をあげるルールになる。

警告対象のコードに対しては下のような出力をしてくれる。

custom_lint_warning.txt
lib/use_setstate_synchronously_rule.dart:30:5 • Avoid calling setState across asynchronous gaps without seeing if the widget is mounted. • use_setstate_synchronously • WARNING

ルールの作成

custom_lint | Dart package

custom_lint | Dart package

https://pub.dev

custom_lint | Dart package

自分で静的解析のルールを作成するには、custom_lint パッケージを使用し、その README.md に倣って開発を進める。 README.md にある Usage のセクションを読んで、必要なパッケージたちをインストールする。

install_needed.sh
$ fvm dart add analyzer analyzer_plugin custom_lint_builder

ディレクトリ構成

この記事の内容がわかりやすくするために、先に自作のパッケージをディレクトリ構成を公開する。 lib ディレクトリにはルールそのものの実装を入れ、example ディレクトリの中でその静的解析ルールが期待通りに動作しているかを確認する。

directory-structure.txt
➜  sangria git:(develop) tree
.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── analysis_options.yaml
├── example
│   ├── analysis_options.yaml
│   ├── lib
│   │   └── use_setstate_synchronously_rule.dart
│   ├── main.dart
│   ├── pubspec.lock
│   └── pubspec.yaml
├── lib
│   ├── sangria_lints.dart
│   └── src
│       └── rules
│           └── use_setstate_synchronously
│               ├── use_setstate_synchronously_rule.dart
│               └── use_setstate_synchronously_visitor.dart
├── pubspec.lock
└── pubspec.yaml

8 directories, 20 files

ルールの定義

パッケージ内にエントリーポイントを用意し、getLintRulesDartLintRule を継承した独自の Lint ルールを定義したクラスを追加する。

custom_lint は、デフォルトの挙動で利用側がパッケージから提供されるルールを analysis_options.yml に記載することで制御できる。その挙動を明記するためにあえてリストをフィルタリングするコードを書いている。

lib/sangria_lints.dart
import 'package:custom_lint_builder/custom_lint_builder.dart';
import 'package:sangria_lints/src/rules/use_setstate_synchronously/use_setstate_synchronously_rule.dart';

// Lint ルールのエントリポイント。
PluginBase createPlugin() => _SangriaLints();

class _SangriaLints extends PluginBase {
  static final _lints = [UseSetStateSynchronouslyLintRule()];

  @override
  List<LintRule> getLintRules(CustomLintConfigs configs) {
    return _lints.where((lint) => lint.isEnabled(configs)).toList();
  }
}

ここからは、StatefulWidget の state を mounted で確認せずに、非同期で更新してるメソッドと関数 に対して警告をあげるルールを書いていく。そのノードを探すため、run メソッドの中でその条件を満たしたコードを検知する実装を書く。

静的解析結果を監視する LintRuleNodeRegistry を取得し、そこからさらに対象のノードを監視するメソッドにコールバックを登録してルールを作成していく。メソッドと関数を監視するのは addMethodDeclarationaddFunctionExpression になるので、それぞれに同じコールバックを登録する。各 node には、それぞれの AST が渡され、子ノードの中身を見て、警告をあげるルールにする。

lib/use_setstate_synchronously/use_setstate_synchronously_rule.dart
class UseSetStateSynchronouslyLintRule extends DartLintRule {
  UseSetStateSynchronouslyLintRule()
    : super(
        code: LintCode(
          name: 'use_setstate_synchronously',
          problemMessage:
              'Avoid calling setState across asynchronous gaps without seeing if the widget is mounted.',
          errorSeverity: ErrorSeverity.WARNING,
        ),
      );

  @override
  void run(
    CustomLintResolver resolver,
    ErrorReporter reporter,
    CustomLintContext context,
  ) {
    // クラスメソッドやインスタンスメソッドの中身を解析・監視するコード
    context.registry.addMethodDeclaration((node) {
      // ...
    });

    // ウィジェットに渡すコールバックの中身を解析・監視するコード
    context.registry.addFunctionExpression((node) {
      //...
    });
  }
}

この addMethodDeclarationaddFunctionExpression については、custom_lint_visitor から対象のノードを監視するメソッドを選択できる。例えば、switch 文を解析したい場合は visitSwitchExpression を、try 文を解析したい場合は visitTryStatement にコールバックを登録する。

参考にできる Lints パッケージが多々ある

いきなり静的解析のルールを実装しようと上の LintRuleNodeRegistry からメソッドを探すのは敷居が高いので。すでに公開されているルールの中身を読むのをおすすめする。pub.dev の中を検索すると、自分たちでルールを開発している企業たちが公開しているので、非常に参考になった。

Leobit / Internal Projects / Flutter Public / Leobit Lints · GitLab

Leobit / Internal Projects / Flutter Public / Leobit Lints · GitLab

https://gitlab.com

Leobit / Internal Projects / Flutter Public / Leobit Lints · GitLab

altive_lints | Dart package

altive_lints | Dart package

https://pub.dev

altive_lints | Dart package

例えば、今回の set_setstate_synchronously についてだが、if 文の括弧内の処理を再起的にみていかないといけないため、visitor を用意する必要があった。なぜ再起的に見ていく必要があるかというと、if 文の中でさらにネストされた記述を監視するためになる。

lib/src/set_setstate_synchronously_visitor.dart
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';

class UseSetstateSynchronouslyVisitor extends RecursiveAstVisitor<void> {
  UseSetstateSynchronouslyVisitor({
    required this.reporter,
    required this.lintCode,
  });

  final ErrorReporter reporter;
  final LintCode lintCode;

  @override
  void visitIfStatement(node) {
    final expressionStr = node.expression.toString();

    if (expressionStr.contains('!mounted')) {
      // ... !mounted で早期 return している場合警告をあげないなどのロジック
    } else if (expressionStr.contains('mounted')) {
      // ... mounted の条件でラップしている場合警告をあげないなどのロジック
    } else {
      super.visitIfStatement(node);
    }
  }

  @override
  void visitMethodInvocation(MethodInvocation node) {
    // メソッドの名前が setState かつ、早期 return されず、mounted の条件分岐でラップされていない場合、警告をあげる。
    // reporter.atNode(node, lintCode);

    super.visitMethodInvocation(node);
  }
}

watch モードでのデバッグ

一般公開する前に、作成した静的解析が実際のコードで無事に警告やエラーをあげてくれるかを確認する必要がある。custom_lint には watch モードがあり、Hot-Reload でデバッグが可能となる。

$ fvm dart run custom_lint --watch
➜  example git:(develop) ✗ fvm dart run custom_lint --watch
The Dart VM service is listening on http://127.0.0.1:60785/-GnvsQVXzng=/
The Dart DevTools debugger and profiler is available at: http://127.0.0.1:60785/-GnvsQVXzng=/devtools/?uri=ws://127.0.0.1:60785/-GnvsQVXzng=/ws
Analyzing...                           0.0s

No issues found!

Custom lint runner commands:
r: Force re-lint
q: Quit

example ディレクトリに動作確認のファイルたちを入れていき、example から lib ディレクトリに書いたルールを参照するように書く。

example/pubspec.yml
dev_dependencies:
  sangria_lints:
    path: ../
  custom_lint: 0.7.5

watch モードを ON にするため、analysis_option.ymldebug: true を書くのを忘れずに。

example/analysis_option.yml
include: package:flutter_lints/flutter.yaml

analyzer:
  plugins:
    - custom_lint

custom_lint:
  debug: true

そして、警告があがることを期待するコードの上にコメントで expect_lint: <rule_name> を書き、動作確認する。上の custom_lint コマンドを実行して、何も警告があがらなければ無事期待通り動作していることになる。VSCode のエディタで警告の波線が表示されるには、一度 built して初めて反映されるので、なかなか警告が表示されないのは注意。

example/set_setstate_synchronously.dart
import 'package:flutter/material.dart';

class UseSetstateSynchronouslyRuleWidget extends StatefulWidget {
  const UseSetstateSynchronouslyRuleWidget({super.key});

  @override
  _UseSetstateSynchronouslyRuleWidgetState createState() =>
      _UseSetstateSynchronouslyRuleWidgetState();
}

class _UseSetstateSynchronouslyRuleWidgetState
    extends State<UseSetstateSynchronouslyRuleWidget> {
  String displayText = '';

  @override
  void initState() {
    super.initState();
  }

  Future<void> setStateAsynchronously() async {
    // expect_lint: use_setstate_synchronously <- これ
    setState(() {
      displayText = 'random string';
    });

    if (!mounted) {
      // expect_lint: use_setstate_synchronously
      setState(() {
        displayText = 'random string'
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        GestureDetector(
          onTap: () async => await setStateAsynchronously(),
          child: Text('addMethodDeclaration'),
        ),
        GestureDetector(
          onTap: () async {
            // expect_lint: use_setstate_synchronously
            setState(() {
              displayText = 'random string'
            });

            if (!mounted) {
              // expect_lint: use_setstate_synchronously
              setState(() {
                displayText = 'random string'
              });
            }
          },
          child: Text('addFunctionExpression'),
        ),
      ],
    );
  }
}

ちなみにルールのほうに print でデバッグ文を書くと、下のように出力される。

watch.txt
[use_setstate_synchronously] run
[use_setstate_synchronously] run

まとめ

1 つしかルールを含んでいないパッケージになってしまったが、これからまた開発して既存の analyzer になかったものがあれば、自分で開発して作っていく予定だ。ぜひインストールまでしてもらえると大変幸いに感じる。