Flutterで抽象クラスの活用事例を紹介

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

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

https://neverjp.com/service/

# 概要

社内で抽象クラスについて説明する機会があったので、いっそのこと記事にまとめました。

本記事では、抽象クラスの活用事例を紹介します。

:::note warn
Dart3から沢山の`class modifiers`が登場しており、活用事例で紹介する実装で`abstract class`ではない方が良いではないかと執筆後に感じています。[Class modifiers reference](https://dart.dev/language/modifier-reference)で紹介されている内容を調査した上で、活用事例に適した`class modifier`を当てようと思います。本記事を非公開にするか悩んでおりますが、`abstract class`を使った事例として扱わせていただきます。誤解を招く様な内容があれば一旦非公開いたします。
:::

# 抽象クラスとは？

抽象クラスとは、具体的な振る舞いを持たないクラスのことです。メソッドやメンバの定義はできますが、インスタンス化できないため、そのままでは扱うことはできません。

Dartの抽象クラスの定義は `abstract class` を使います。

>Dart3から`abstract interface class`が使えるようになりました。`implements`を強制させることができるので、純粋なインターフェースクラスとして扱うことができます。`abstract interface class`以外にも`class modifiers`が登場しています。
[Class modifiers reference](https://dart.dev/language/modifier-reference)

```dart
// 抽象クラス
abstract class LocalDatabaseRepository {
  Future<void> saveInt(DatabaseKey key, int value);
  Future<int?> fetchInt(DatabaseKey key);
}

// ❌インスタンス化できない
final localDatabaseRepository = LocalDatabaseRepository();
```

抽象クラスを使う場合は、それの実体化が必要です。

```dart
final class LocalDatabaseRepositoryImpl extends LocalDatabaseRepository {
  @override
  Future<int?> fetchInt(DatabaseKey key) {
    // TODO: implement fetchInt
    throw UnimplementedError();
  }

  @override
  Future<void> saveInt(DatabaseKey key, int value) {
    // TODO: implement saveInt
    throw UnimplementedError();
  }
}

// ⭕️インスタンス化できる
final localDatabaseRepository = LocalDatabaseRepositoryImpl();
```

>実体化されたクラス名には Impl （Implementation / 実装） というサフィックスが付与されることが多いです。また、`extends`と`implements`の2種類あり、`extends`は1つのクラスしか継承できないのに対し、`implements`は複数のクラスを実装できます。詳しくは公式ドキュメントの[implicit-interfaces](https://dart.dev/language/classes#implicit-interfaces)をお読みください。

# 抽象クラスの使い方

抽象クラスを使うことで、実体との依存関係を無くすことができます。実体を隠蔽できるので、利用側は実体が具体的にどうなっているのか知らなくても、その実体が持つ機能を利用することができます。

例えば、`LocalDatabaseRepository` というDBを操作する抽象クラスの場合、その実体として`SharedPreferences`や`FlutterSecureStorage`または`Mock`が使われたロジックをセットすれば、その機能を利用できます。利用者はセットされた実体を知らなくても、`LocalDatabaseRepository`を使うことでDBを操作する機能を利用できます。[^1]


では、以下の`LocalDatabaseRepository`を使って説明します。

```dart
abstract class LocalDatabaseRepository {
  Future<void> saveInt(DatabaseKey key, int value);
  Future<int?> fetchInt(DatabaseKey key);
}
```

解説には、[Riverpod](https://riverpod.dev/)を使っていますが、Riverpodを使わなくてもコンストラクタ注入や他のService Locatorでも実装可能です

## データソースを切り替えたいケース

データソースとは、実際に利用するデータの提供元のことです。

まず、データソースに`SharedPreferences`を使った例を考えます。`SharedPreferences`のインスタンスは`_ref.read(sharedPreferencesProvider)`で取得できるようにします。

```dart
final sharedPreferencesProvider = Provider<SharedPreferences>(
  (_) => throw UnimplementedError(), // 👈 後でインスタンスをセットする
);
```

```dart
final class SharedPreferencesRepository extends LocalDatabaseRepository {
  SharedPreferencesRepository(this._ref);

  final Ref _ref;

  @override
  Future<void> saveInt(DatabaseKey key, int value) async {
    final db = _ref.read(sharedPreferencesProvider);
    await db.setInt(key.name, value);
  }

  @override
  Future<int?> fetchInt(DatabaseKey key) async {
    final db = _ref.read(sharedPreferencesProvider);
    return db.getInt(key.name);
  }
}
```

`LocalDatabaseRepository`を継承した`SharedPreferencesRepository`を実装します。そして、その実体をセットします。

```dart
final localDatabaseRepositoryProvider = Provider<LocalDatabaseRepository>(
  (_) => throw UnimplementedError(),
);
```

```dart
void main() async {
  ...
  
  /// DataSource
  final sharedPreferences = await SharedPreferences.getInstance();

  /// DI
  runApp(
    ProviderScope(
      overrides: [
        sharedPreferencesProvider.overrideWithValue(sharedPreferences),
        localDatabaseRepositoryProvider
            .overrideWith(SharedPreferencesRepository.new), // 👈 ココ
      ],
      child: const App(),
    ),
  );
}
```

`localDatabaseRepositoryProvider`に対して、`ProviderScopeのoverrides`で`SharedPreferencesRepository`をセットします。

`LocalDatabaseRepository`を利用する`CounterController`の実装は以下の通りです。

```dart
final counterControllerProvider =
    AsyncNotifierProvider.autoDispose<CounterController, int>(
  CounterController.new,
);

final class CounterController extends AutoDisposeAsyncNotifier<int> {
  static const _key = DatabaseKey.counter;

  @override
  FutureOr<int> build() async {
    final value =
        await ref.watch(localDatabaseRepositoryProvider).fetchInt(_key);
    return value ?? 0;
  }

  Future<void> increment() async {
    state = await AsyncValue.guard<int>(() async {
      final value = (state.asData?.value ?? 0) + 1;
      await ref.read(localDatabaseRepositoryProvider).saveInt(_key, value);
      return value;
    });
  }
}
```

`CounterController`は`ref.read(localDatabaseRepositoryProvider)`経由で`SharedPreferencesRepository`の機能を利用できます。

これで、`CounterController`は`LocalDatabaseRepository`の実体が具体的に何であるか知らなくても、セットされた実体の機能を利用することができます。

### データソースを別のパッケージに切り替える

ビジネス要件や技術的な理由により、データソース先の切り替えが発生した際は、安全に切り替えることができます。

データソースを`SharedPreferences`から`FlutterSecureStorage`に切り替えを考えます。

`FlutterSecureStorage`のインスタンスは`_ref.read(flutterSecureStorageProvider)`で取得できるようにします。

```dart
final flutterSecureStorageProvider = Provider<FlutterSecureStorage>(
  (_) => throw UnimplementedError(), // 👈 後でインスタンスをセットする
);
```

以下のように、`LocalDatabaseRepository`を継承した`FlutterSecureStorageRepository`を実装します。

```dart
final class FlutterSecureStorageRepository extends LocalDatabaseRepository {
  FlutterSecureStorageRepository(this._ref);

  final Ref _ref;

  Future<void> saveInt(DatabaseKey key, int value) async {
    final db = _ref.read(flutterSecureStorageProvider);
    await db.write(key: key.name, value: value.toString());
  }

  Future<int?> fetchInt(DatabaseKey key) async {
    final db = _ref.read(flutterSecureStorageProvider);
    final value = await db.read(key: key.name);
    if (value == null) {
      return null;
    }
    return int.parse(value);
  }
}
```

次に`CounterController`の修正ですが、`localDatabaseRepositoryProvider`経由で`LocalDatabaseRepository`の実体を利用できるよう既に実装されているので、修正は必要ありません。

修正する箇所は実体をセットしている箇所のみです。

```dart
void main() {
  ...
  
  /// DataSource
  const flutterSecureStorage = FlutterSecureStorage();

  /// DI
  runApp(
    ProviderScope(
      overrides: [
        flutterSecureStorageProvider.overrideWithValue(flutterSecureStorage),
        localDatabaseRepositoryProvider
            .overrideWith(FlutterSecureStorageRepository.new), // 👈 ココ
      ],
      child: const App(),
    ),
  );
}
```

これで、`CounterController`は`LocalDatabaseRepository`の実体が`FlutterSecureStorageRepository`に切り替わった状態で利用できます。

### データソースをプラットフォーム毎に切り替える

Webは`FlutterSecureStorage`、iOS/Androidは`SharedPreferences`を使いたい場合にも対応できます。

```dart
void main() async {
  ...

  /// DataSource
  final dataSource = await Future(() {
    if (kIsWeb) {
      return const FlutterSecureStorage();
    } else {
      return SharedPreferences.getInstance();
    }
  });

  /// DI
  runApp(
    ProviderScope(
      overrides: [
        // 👇 Webなら FlutterSecureStorageRepository を使う
        if (kIsWeb) ...[
          flutterSecureStorageProvider
              .overrideWithValue(dataSource as FlutterSecureStorage),
          localDatabaseRepositoryProvider
              .overrideWith(FlutterSecureStorageRepository.new),
        ] else ...[
          sharedPreferencesProvider
              .overrideWithValue(dataSource as SharedPreferences),
          localDatabaseRepositoryProvider
              .overrideWith(SharedPreferencesRepository.new),
        ],
      ],
      child: const App(),
    ),
  );
}
```

環境によって`LocalDatabaseRepository`の実体を切り替えています。[^2]

これで、`CounterController`は、Webなら`FlutterSecureStorageRepository`、iOS/Androidなら`SharedPreferencesRepository`を使うようになります。

### データソースをMockに切り替える

テストやWidgetbookを使う際に、データソースをMockに切り替えて確認できます。

以下、ユニットテストの例です。

`LocalDatabaseRepository`を継承した`LocalDatabaseRepositoryMock`を実装します。

```dart
final class LocalDatabaseRepositoryMock extends LocalDatabaseRepository {
  /// Mockデータを設定するためのハンドラー
  Future<int?> Function(DatabaseKey key)? fetchIntHandler;
  Future<void> Function(DatabaseKey key, int value)? saveIntHandler;

  /// 関数が呼ばれるごとにカウントする
  int fetchIntCallCount = 0;
  int saveIntCallCount = 0;

  @override
  Future<int?> fetchInt(DatabaseKey key) async {
    fetchIntCallCount += 1;
    return fetchIntHandler?.call(key);
  }

  @override
  Future<void> saveInt(DatabaseKey key, int value) async {
    saveIntCallCount += 1;
    return saveIntHandler?.call(key, value);
  }

  /// ハンドラーをリセットする
  void resetHandler() {
    fetchIntHandler = null;
    saveIntHandler = null;
    fetchIntCallCount = 0;
    saveIntCallCount = 0;
  }
}
```

検証用にいくつかメソッドやメンバを定義し、overrideメソッド内はテスト用の振る舞いにしました。

このMockを使った`CounterController`のテストコードは以下の通りです。

```dart
void main() {
  group('CounterController テスト（自前のMockを使う）', () {
    late final LocalDatabaseRepositoryMock localDatabaseRepositoryMock;

    setUpAll(() {
      localDatabaseRepositoryMock = LocalDatabaseRepositoryMock();
    });

    tearDown(() {
      localDatabaseRepositoryMock.resetHandler();
    });

    test(
      'カウントアップできること',
      () async {
        /// Mockにデータをセットする
        localDatabaseRepositoryMock
          ..fetchIntHandler = (DatabaseKey key) {
            expect(key, DatabaseKey.counter);
            return Future.value(0);
          }
          ..saveIntHandler = (DatabaseKey key, int value) {
            expect(key, DatabaseKey.counter);
            return Future.value();
          };

        /// MockをProviderにセットし、テスト実施
        final container = ProviderContainer(
          overrides: [
            localDatabaseRepositoryProvider.overrideWithValue(
              localDatabaseRepositoryMock,
            ),
          ],
        );
        addTearDown(container.dispose);

        /// テスト実施
        await container.read(counterControllerProvider.future);

        expect(container.exists(counterControllerProvider), isTrue);
        expect(container.read(counterControllerProvider).value, 0);

        await container.read(counterControllerProvider.notifier).increment();
        expect(container.read(counterControllerProvider).value, 1);

        await container.read(counterControllerProvider.notifier).increment();
        expect(container.read(counterControllerProvider).value, 2);

        expect(localDatabaseRepositoryMock.fetchIntCallCount, 1);
        expect(localDatabaseRepositoryMock.saveIntCallCount, 2);

        /// Providerが再構築されるまで待ち、CounterControllerが破棄されるか確認
        await container.pump();
        expect(container.exists(counterControllerProvider), isFalse);
      },
    );
  });
}
```

なお、[Mockito](https://pub.dev/packages/mockito)を使えば、自前にMockを実装しなくても、Mock用のコードを自動生成してくれます。


### メリットとデメリット

抽象クラスを使わなくても、`LocalDatabaseRepository`そのものを実体化できるようclassにして、その中のロジックを書き換えれば別にいいんじゃないのと感じる方もいると思います。

抽象クラスとその実体を分けるメリットとデメリットを考えてみました。

#### メリット

- 利用側は実体との依存関係がないので、プラットフォーム毎に実体を切り替えたい場合に有効
    - Webの場合、`dart.library.html`で実体を隠蔽すれば、ビルドエラーの影響を受けずに済む[^2]
- 修正の影響範囲が限定的になる
    - 実体を新たに追加して切り替えるので、動いていた従来の実体コードは触らなくて良い
- 切り替え前の実体コードを消さなくても良い
    - もしかすると切り替え前に戻すかもしれないという不安を解消

また、`override`を使いたいだけであれば、抽象クラスにせずとも普通の`class`でも可能ですが、以下の理由により抽象クラスを利用する方が良いと考えています。

- IF制約（メソッドやメンバの定義制約）の用途のみで利用したい
- 他の実装者が意図せずに、スーパークラスのインスタンス生成を防ぐため

#### デメリット

- コード量が増える
- コードジャンプが面倒（IDEやPluginsによって解決されているかもしれませんが）
    - 愚直にジャンプすると抽象クラスにジャンプするので実体のロジックが見つけにくい
- 抽象クラスの使い方に戸惑う（人による）

#### 所感

抽象クラスを作るメリットはありますが、コード量が増えてしまうので、実装コストは大きくなります。

また、Riverpodを使えば、Provider内でロジックを実装でき、`overrides`を使うことでそのロジックを切り替えることができます。Providerの皮だけ用意して、適宜実体をセットして扱えば、抽象クラスを使うシーンはそこまでないかもしれません。
※こちら関して[コメント](https://qiita.com/hukusuke1007/items/8fd3411d2ef3c0d18429#comment-4c21f10a0b3c67709695)をいただいております

PJの予算や要件に応じて、どの機能のどのロジックで抽象クラスを利用するのか、じっくり検討した方が良いと思います。 

>クリーンアーキテクチャのような各レイヤーへアクセスする際は実体に対する依存関係をなくすためにインターフェースを経由して実装する設計がありますが、この記事では言及しません。[^3]

## その他のケース

その他で抽象クラスを使うと便利なケースを紹介します。

抽象クラスを使うことで、パラメータをクラス単位で管理して扱うことができます。

### リクエスト情報をクラスで管理して扱う

APIリクエストをする際に、URLやパラメータ情報をクラスで管理したい場合に活用できます。

```dart
abstract class ReqBase {
  String get baseUrl;
  String get path;
}
```

ReqBaseを継承して、APIリクエストの情報をクラスで管理します。

```dart
/// ユーザーリスト情報を取得する
final class GetGithubUsersReq extends ReqBase {
  @override
  String get baseUrl => 'api.github.com';

  @override
  String get path => 'users';
}

/// usernameのユーザー情報を取得する
final class GetGithubUserReq extends ReqBase {
  GetGithubUserReq({
    required this.username,
  });

  final String username;

  @override
  String get baseUrl => 'api.github.com';

  @override
  String get path => 'users/$username';
}
```

それぞれのRequestクラスを使ってAPIリクエストを実施できます。

```dart
/// apiメソッド
Future<http.Response> api(ReqBase req) async {
  final res = await http.get(
    Uri.https(req.baseUrl, req.path),
  );
  return res;
}

/// 実行
final res1 = await api(GetGithubUsersReq());
print(res1.body); // 👈 ユーザーリストを取得

final res2 = await api(GetGithubUserReq(username: 'hukusuke1007'));
print(res2.body); // 👈 usernameの情報を取得
```

### 独自のExceptionを利用する

`Exception`を実装することで、カスタマイズした`Exception`を作ることができます。

Flutterが提供する`Exception`の定義は以下の通りです。

```dart
@pragma('flutter:keep-to-string-in-subtypes')
abstract interface class Exception {
  factory Exception([var message]) => _Exception(message);
}
```

Dart3から`Exception`がインターフェースクラスになったようなので、これを実装して独自の`AppException`を作ります。

```dart
final class AppException implements Exception {
  AppException({
    this.title,
    this.detail,
  });

  factory AppException.error(String title) => AppException(title: title);

  final String? title;
  final String? detail;

  @override
  String toString() => '$title, $detail';
}
```

`Exception`は発生したら、`AppException`でthrowするように実装します

```dart
Future<http.Response> api(ReqBase req) async {
  try {
    final res = await http.get(
      Uri.https(req.baseUrl, req.path),
    );
    return res;
  } on Exception catch (e) {
    throw AppException.error('エラーが発生しました: $e');
  }
}

final data = GetGithubUsers();
final res = await api(data); // 👈 エラーが発生したら AppException がthrowされる
```

データソースからthrowされる`Exception`を全て`AppException`でラップすれば、アプリ内では`AppException`だけを意識したコードが書けるので便利です。

# まとめ

Flutterで抽象クラスを使いたくなるケースを解説しました。

- プラットフォーム環境やビジネス要件によって安全にデータソースの切り替えができる
- 抽象クラスを使うことで、パラメータをクラス単位で管理して扱える
- コード量が増えるので、抽象クラスを使う場合はしっかり検討を

今回のコードはこちらの[リポジトリ](https://github.com/never-inc/flutter_experiment)にまとめています。

[^1]: GoFでは[Strategyパターン](https://ja.wikipedia.org/wiki/Strategy_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3) もしくは[Template Method パターン](https://ja.wikipedia.org/wiki/Template_Method_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3)
[^2]: Webでしか利用できないIFを参照した場合、ビルドエラーになるので、`dart.library.html`を使ってIFを隠蔽してください（[ドキュメント](https://dart.dev/guides/libraries/create-packages#conditionally-importing-and-exporting-library-files)）
[^3]: [依存性逆転の原則](https://ja.wikipedia.org/wiki/%E4%BE%9D%E5%AD%98%E6%80%A7%E9%80%86%E8%BB%A2%E3%81%AE%E5%8E%9F%E5%89%87)