株式会社NeverのFlutterコーディング規約5選

[株式会社Never](https://neverjp.com/)のshoheiです。

株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。

https://neverjp.com/service/

# 概要

弊社のコーディング規約を紹介します。弊社はNotionで管理しています。
参考元: [実装方針 & コードレビューのやり方](https://www.notion.so/5476bf87f9e74f38a4da1300ffa20d23)

具体例を交えて全て紹介するとかなりのボリュームになるので、その中で大事な5選を紹介します。

# コーディング規約の目的

- ソフトウェア品質を良くする
    - 不具合の発生を抑え、パフォーマンスを良くするため
- ソースコードの可読性を良くする
    - コードレビューの負担を下げ、レビューの生産性を高めるため
- 実装に迷った時の判断材料にする
    - 規約に沿う事で、実装者の心理的安全性が保たれ、自信を持って実装できるため

# コーディグ規約５選

弊社が定めるFlutterアプリ開発のコーディング規約５選を紹介します。

## ソースコードが命名規則通りに統一されている事

命名規則を統一することでソースコードの可読性が良くなり、コードレビューの生産性が高くなります。命名規則は以下の通りです。[^1]

|項目  |規則  | 具体例|
|---|---|---|
|ディレクトリ名 | スネークケース| `home_page` |
|ファイル名 | スネークケース| `home_page.dart` |
|クラス名 | アッパーキャメルケース| `class HomePage` |
|変数名| キャメルケース | `var userName` |
|定数名| キャメルケース | `final userName` |

基本的には`形容詞 or 名詞`始まりの構成で命名します。理由は複数形を考慮するためです。操作系は`動詞`始まりの構成で命名します。[^2]

### 画面、コンポーネント

|項目  |命名例  | 説明|
|---|---|---|
|画面| `ArticlePage` `ArticleScreen` <br> `ArticlesPage` `ArticlesScreen` <br> `ArticleListPage` `ArticleListScreen` | `名詞 + Page` `名詞 + Screen`で構成する。一目見てどの画面かわかる名前、suffixに画面を示す単語を命名。 |
|コンポーネント| `ArticleTile` `ArticleCard` `ArticleListView`  | `名詞 + Widgetの種類名` 例えば`Card`をカプセル化した場合、`ArticleCard`と命名する。注意として、`ArticleWidget`のようなsuffixに`Widget`を付けるのは避ける、何のWidgetで構成されているのか分からないため。|

### データクラス

|項目  |命名例  | 説明|
|---|---|---|
| 記事 | `Article` `LatestArticle` `PrivateArticle` | `名詞` `形容詞 + 名詞` `名詞 + 名詞` クラス内のプロパティを持つ者として相応しい名前を命名する。 |

### データソースを扱う層

|項目  |命名例  | 説明|
|---|---|---|
| APIクライアント | `GitHubClient` | `名詞 + Client`で構成する。| 
| データソース先をカプセル化したクラス | `ArticleRepository` `ArticleDataSource` | `名詞 + Repository` `名詞 + DataSource`で構成する。| 

### ビジネスロジック

#### １クラス = １ロジックを担うクラス

`動詞 + 名詞`の構成でクラス名を命名します。 

|項目  |命名例  |
|---|---|
| 記事を作成する | `CreateArticle` `UploadArticle` `SaveArticle`|
| 記事を取得する | `FetchArticle` `GetArticle` |
| 記事を更新する | `UpdateArticle` |
| 記事を削除する | `DeleteArticle` `RemoveArticle` |

関数名は`call`を使います。Dartでは`call`を定義すると、そのクラスオブジェクトは関数として扱われ[^3]、1クラス = 1ロジックの粒度として扱いやすくなるためです。関数として扱われるので、`call`を省略して利用できます。

```dart
final createArticle = CreateArticle();

createArticle.call();

createArticle(); // callを省略できる
```

また、取得系において個人的には`Fetch`がオススメです。 `Fetch`は遠く（データソース）から取得し、`Get`は近く（メモリキャッシュ）から取得するイメージを持つためです（`get`は`getter`の要素が強く感じる）

#### １状態 = 1管理者（操作関数を持つクラス）

`名詞 + 名詞`の構成でクラス名を命名します。

|項目  |命名例  | 説明|
|---|---|---|
| 記事の状態管理 + CRUD操作を提供する | `ArticleController` | `名詞 + Controller`で構成する。Flutterでは`xxxController`で命名されたものが多いため合わせる。|

関数は `動詞 + 名詞`の構成で命名します。

|項目  |命名例  |
|---|---|
| 記事を作成する | `createArticle` `uploadArticle` `saveArticle` |


### boolean
`is + 形容詞` `has + 過去分詞` `三単現動詞 + 名詞` `助動詞 + 動詞` で構成し、YES or Noで判断できるよう命名します。[^4]

|項目  |命名例  | 説明|
|---|---|---|
| 読み込み状態 | `isLoading`  | `is + 形容詞` |
| 有効状態 | `isEnabled`  | `is + 形容詞` |
| 表示状態 | `isVisible`  | `is + 形容詞` |
| 保持状態 | `hasArticled`  | `has + 過去分詞` |
| 存在有無 | `existsArticle` | `三単現動詞 + 名詞` |
| 可否状態 | `canUpload`  | `助動詞 + 動詞` |

### その他

|項目  |命名例  | 由来|
|---|---|---|
|拡張関数 | `StringExtension` `StringExt`  | `拡張元 + Extension` `拡張元 + Ext`で構成する。suffixは拡張関数を示す単語で構成する。 |
|変換クラス | `TimestampDateConverter` | `名詞 + Converter`で構成する。 どのような状態を変換できるのかわかるよう命名する。 |
|ストリームクラス | `ArticleStream`| `名詞 + Stream`で構成する。購読すると、どのような状態がストリーム経由で得られるのかわかるよう命名する。 |
|便利クラス | `Validation` `ValidationUtil` `ValidationHelper` | かっこよく`名詞`のみで完結したい。`xxxUtil` `xxxHelper`の命名は最終手段。 |
| Exceptionクラス | `GitHubException` |  `名詞 + Exception`で構成する。Exceptionの発生元から具体的な名前を命名する。 |

抽象化を採用する場合、抽象クラスを実装したクラス名のsuffixに`Impl`（`Implementation`の略称）を付けます。`Repository`の場合はその実装部として`DataSource`でも良いです。[^5]

|抽象クラス例  |実装クラス例|
|---|---|
|`ArticleRepository`|`ArticleRepositoryImpl` `ArticleDataSource`|
|`CreateArticle`|`CreateArticleImpl`|

## 静的解析で指摘事項がない事
静的解析のパッケージを導入することで、規約に合わない実装をワーニング / エラーとして指摘してくれます。解説ドキュメントは[こちら](https://dart.dev/tools/analysis)

Flutterプロジェクトを作成すると [flutter_lints](https://pub.dev/packages/flutter_lints) が標準で導入されていますが、ルールは比較的甘めなので、より厳しくする場合はカスタマイズが必要です。

カスタマイズがややめんどうなので、より厳しいルールを定めた外部パッケージを使うことをお勧めします。

オススメのパッケージは以下の通りです。

- [pedantic_mono](https://pub.dev/packages/pedantic_mono)
- [very_good_analysis](https://pub.dev/packages/very_good_analysis)

必要に応じて外部パッケージ専用のlint（[riverpod_lint](https://pub.dev/packages/riverpod_lint)など）を導入しても良いです。

導入後、ビルド前にIDEの静的解析が実行されるので、実行後の結果でワーニングやエラーがあれば修正します。修正方法は指摘内容にリンクが記載されているので確認して修正します。

![スクリーンショット 2023-12-31 16.47.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/cf6f09b8-fcca-4730-ec4a-8b354bcd91c2.png)

静的解析の指摘を解消することで、不具合の混入リスクが軽減し、ソフトウェアの品質が上がります。

## リスト表示で遅延処理が正しく実装されている事

APIやDBから取得したデータをリスト表示する場合、遅延処理が実施されるWidgetを使います。これを怠ると、全てのリストデータがメモリ上に保持され続け、OOM（Out Of Memory）発生のリスクが高くなります。

良くある事例として、重たい画像を含んだ多くのデータをリスト表示したらアプリが落ちるといった現象です。

代表的な遅延処理があるリスト系Widgetは以下の通りです。

- リスト
    - [ListView](https://api.flutter.dev/flutter/widgets/ListView-class.html)
    - [CustomScrollView + SliverList](https://api.flutter.dev/flutter/widgets/SliverList-class.html)
- グリッド
    - [GridView](https://api.flutter.dev/flutter/widgets/GridView-class.html)
    - [CustomScrollView + SliverGird](https://api.flutter.dev/flutter/widgets/SliverGrid-class.html)
- 外部パッケージ
    - [pull_to_refresh_flutter3](https://pub.dev/packages/pull_to_refresh_flutter3)
    - [very_good_infinite_list](https://pub.dev/packages/very_good_infinite_list)
    - [infinite_scroll_pagination](https://pub.dev/packages/infinite_scroll_pagination)

実装してみて遅延処理がされているか不安な場合は、スクロールするたびにリスト内の描画対象となるStateインスタンスが生成と破棄を繰り返していることをログで確認します。

```dart
// リスト表示するWidget
class Tile extends StatefulWidget {
  const Tile(this.index, {super.key});

  final int index;

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

class TileState extends State<Tile> {

  @override
  void dispose() {
    debugPrint('dispose: ${widget.index}'); // 追加
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(
        widget.index.toString(),
      ),
    );
  }
}
```

例として、1,000件のリスト表示を３通りの方法で検証してみます。

```dart
// 1️⃣ ✅ ListView.builder 👉 破棄される、仕込んだdisposeログが表示
class SamplePage1 extends StatelessWidget {
  const SamplePage1({super.key});

  @override
  Widget build(BuildContext context) {
    final items = List.generate(1000, (index) => index);
    return Scaffold(
      appBar: AppBar(),
      body: ListView.builder(
        itemBuilder: (context, item) => Tile(item),
        itemCount: items.length,
      ),
    );
  }
}

// 2️⃣ ✅ ListView 👉 破棄される、仕込んだdisposeログが表示
class SamplePage2 extends StatelessWidget {
  const SamplePage2({super.key});

  @override
  Widget build(BuildContext context) {
    final items = List.generate(1000, (index) => index);
    return Scaffold(
      appBar: AppBar(),
      body: ListView(
        children: items.map(Tile.new).toList(),
      ),
    );
  }
}

// 3️⃣ ❌　SingleChildScrollView + Column 👉 破棄されない、仕込んだdisposeログは表示されない
class SamplePage3 extends StatelessWidget {
  const SamplePage3({super.key});

  @override
  Widget build(BuildContext context) {
    final items = List.generate(1000, (index) => index);
    return Scaffold(
      appBar: AppBar(),
      body: SingleChildScrollView(
        child: Column(
          children: items.map(Tile.new).toList(),
        ),
      ),
    );
  }
}
```


Dev Toolsでメモリ割り当て状況を確認します。


1️⃣ ✅ ListView.builder 👉 WidgetとStateは画面範囲 + cacheExtentの領域のみ保持
![スクリーンショット 2023-12-31 17.20.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/e85aa7c1-893a-d6d1-7e65-7d08cc9dccbd.png)

2️⃣ ✅ ListView 👉 Widgetは全て保持されるが、Stateは画面範囲 + cacheExtentの領域のみ保持
![スクリーンショット 2023-12-31 17.18.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/78fc6b35-5ef0-ff6e-896c-0cfb579b25c3.png)

3️⃣ ❌ SingleChildScrollView + Column 👉 WidgetもStateも全て保持される
![スクリーンショット 2023-12-31 17.24.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/a065c611-4632-d73f-9ddc-f98474e07f9b.png)

## 例外ケースでも耐えれる実装ができている事

### Nullableデータを正しく扱えている事

データが確定しない状態がある（確保されたメモリ上にデータが無い場合がある）ことを`Nullable`として扱います。

Nullableデータは必ず`NULLチェック`をして扱います。

```dart
int? fetchData() {
  ...
}

// ❌ NG dataがnullの場合、null強制参照エラーが発生する。
final data = fetchData();
print(data!.toString());

// ✅ OK
final data = fetchData();
if (data != null) {
  print(data.toString()); 
} else {
  print('no data');
}
```

null強制参照をすると静的解析が教えてくれます。強制参照のシンボルである`!`は避けるよう実装することがベターです。

また、`?`とnull合体演算子`??`の組み合わせを使う事で、Nullableデータを安全に扱うことができます。

```dart
final data = fetchData();
print(data?.toString() ?? 'no data');
```

### 文字数が多い場合に画面が崩れない事
`Layout overflow`🚧になっていないか確認します。特に、`Row`や`Column`で子Widgetのサイズが大きくなることを考慮していないと発生します。

また、端末の設定よりフォントサイズを大きくすると、通常時は問題なくても、大きくなった場合に`Layout overflow`🚧になります。

フォントサイズは以下の手順で変更できます。

- iOS
    - 設定 → アクセシビリティ → 画面表示とテキストサイズ → さらに大きな文字
- Android
    - 設定 → ディスプレイ → 表示サイズとテキスト → フォントサイズ


![Screenshot_20231231_174101.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/afd39b3a-3f67-6f7c-9c3e-fdf29def98f0.png)


対応として子Widgetのサイズの指定を実施しますが、子Widgetのサイズが拡張した際にどのようなデザインであるべきかで対応が変わります。

例としてはTextのWidgetに`Flexible`をセットします。

```dart
class SamplePage1 extends StatelessWidget {
  const SamplePage1({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            // Flexibleで拡張時の比率を設定
            Flexible( 
              child: Text('タイトル' * 6),
            ),
            FilledButton(
              onPressed: () {},
              child: const Text('ボタン'),
            ),
          ],
        ),
      ),
    );
  }
}
```

![Screenshot_20231231_174144.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/91a75906-7614-77a9-0965-d9563d324b0d.png)



ここは実現したいデザインと相談しながら実装しましょう。


### Exceptionを適切に扱えている事
`Exception`が`throw`された時の例外処理が考慮できているか確認します。

主にAPIクライアントや外部パッケージのエラーレスポンスの仕様を確認して、`try - catch`でエラーハンドリングするものか確認します。

エラーが`throw`される仕様の場合は、`try - catch`でエラーを捕捉できるよう実装します。[^6]

```dart
void post() {
  ... // throw Exception(); or throw FormatException();
}

try {
  post();
} on Exception catch (e) {
  // 全てのExceptionを捕捉できる
}

try {
  post();
} on FormatException catch (e) {
  // FormatExceptionを捕捉できる
} on Exception catch (e) {
  // FormatException以外のExceptionを捕捉できる
}
```

catch節ではエラー発生時の正しい処理を実装しましょう。無理やりcatch節で`Exception`を丸め込んでしまった場合、ログから`Exception`の原因が特定できず、不具合調査が難航する場合があるので注意しましょう。

なお、`Exception`以外に`Error`型がありますが、こちらはプログラム修正すべきものなので、catchはせずにエラーを発生させて、プログラムの修正を強制します。[^7][^8]

## 非同期処理のローディング中や機内モード中のエラーハンドリングの実装ができている事

ネットワーク通信など時間がかかる処理を実行した際に、ユーザーに処理中を伝えるためと、ユーザーからの多重実行を防ぐための考慮ができているか確認します。


- ローディング状態のフラグで制御
    - `isLoading`等のstateを使い、処理が開始したら`isLoading`を`true`, 終了したら`false`、処理中にリクエストがあっても`isLoading`が`true`であれば早期`return`をする。
    - ローディング状態に合わせて、[CircularProgressIndicator](https://api.flutter.dev/flutter/material/CircularProgressIndicator-class.html)や[CupertinoActivityIndicator](https://api.flutter.dev/flutter/cupertino/CupertinoActivityIndicator-class.html)を表示する
- `Dialog`を表示して処理中を伝え、ユーザーからの操作を制御する
    - [showGeneralDialog](https://api.flutter.dev/flutter/widgets/showGeneralDialog.html)
- AsyncCacheで多重実行を制御[^9]
    - [AsyncCache.ephemeral()](https://api.flutter.dev/flutter/async/AsyncCache/AsyncCache.ephemeral.html)で非同期処理が終了するまでリクエストがあっても処理しない

また、**端末の機内モード**を使い、ネットワーク接続できない状態でも問題なく動作するか確認します。前述のExceptionの例外処理ができているか確認します。

# 最後に

コーディング規約をチーム内で共有し、チームで楽しく良いアプリを作っていきましょう👍


[^1]: [Effective Dart: Style](https://dart.dev/effective-dart/style)
[^2]: [モデルやメソッドに名前を付けるときは英語の品詞に気をつけよう](https://qiita.com/jnchito/items/459d58ba652bf4763820)
[^3]: [Callable objects](https://dart.dev/language/callable-objects)
[^4]: [booleanメソッドの命名規則](https://qiita.com/GinGinDako/items/6e8b696c4734b8e92d2b)
[^5]: [データレイヤ - このガイドにおける命名規則](https://developer.android.com/jetpack/guide/data-layer?hl=ja#naming-conventions)
[^6]: [Error handling](https://dart.dev/language/error-handling)
[^7]: [Error class](https://api.dart.dev/stable/3.2.4/dart-core/Error-class.html)
[^8]: [【Dart】Exception（例外）とError（間違い）の使い方](https://zenn.dev/iwaku/articles/2020-12-19-iwaku)
[^9]: [AsyncCacheのススメ(非同期処理の多重実行防止のための個人的ベタープラクティス)](https://zenn.dev/8rine23/articles/5772f7d81144bd)