30
14

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

Last updated at Posted at 2023-07-17

株式会社Neverのshoheiです。

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

概要

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

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

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

抽象クラスとは?

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

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

Dart3からabstract interface classが使えるようになりました。implementsを強制させることができるので、純粋なインターフェースクラスとして扱うことができます。abstract interface class以外にもclass modifiersが登場しています。
Class modifiers reference

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

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

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

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 / 実装) というサフィックスが付与されることが多いです。また、extendsimplementsの2種類あり、extendsは1つのクラスしか継承できないのに対し、implementsは複数のクラスを実装できます。詳しくは公式ドキュメントのimplicit-interfacesをお読みください。

抽象クラスの使い方

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

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

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

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

解説には、Riverpodを使っていますが、Riverpodを使わなくてもコンストラクタ注入や他のService Locatorでも実装可能です

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

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

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

final sharedPreferencesProvider = Provider<SharedPreferences>(
  (_) => throw UnimplementedError(), // 👈 後でインスタンスをセットする
);
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を実装します。そして、その実体をセットします。

final localDatabaseRepositoryProvider = Provider<LocalDatabaseRepository>(
  (_) => throw UnimplementedError(),
);
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のoverridesSharedPreferencesRepositoryをセットします。

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

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;
    });
  }
}

CounterControllerref.read(localDatabaseRepositoryProvider)経由でSharedPreferencesRepositoryの機能を利用できます。

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

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

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

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

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

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

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

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の実体を利用できるよう既に実装されているので、修正は必要ありません。

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

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

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

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

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

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

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を実装します。

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のテストコードは以下の通りです。

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を使えば、自前にMockを実装しなくても、Mock用のコードを自動生成してくれます。

メリットとデメリット

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

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

メリット

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

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

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

デメリット

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

所感

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

また、Riverpodを使えば、Provider内でロジックを実装でき、overridesを使うことでそのロジックを切り替えることができます。Providerの皮だけ用意して、適宜実体をセットして扱えば、抽象クラスを使うシーンはそこまでないかもしれません。
※こちら関してコメントをいただいております

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

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

その他のケース

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

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

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

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

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

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

/// ユーザーリスト情報を取得する
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リクエストを実施できます。

/// 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の定義は以下の通りです。

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

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

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するように実装します

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で抽象クラスを使いたくなるケースを解説しました。

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

今回のコードはこちらのリポジトリにまとめています。

  1. GoFではStrategyパターン もしくはTemplate Method パターン

  2. Webでしか利用できないIFを参照した場合、ビルドエラーになるので、dart.library.htmlを使ってIFを隠蔽してください(ドキュメント 2

  3. 依存性逆転の原則

30
14
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
14