株式会社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 / 実装) というサフィックスが付与されることが多いです。また、
extends
とimplements
の2種類あり、extends
は1つのクラスしか継承できないのに対し、implements
は複数のクラスを実装できます。詳しくは公式ドキュメントのimplicit-interfacesをお読みください。
抽象クラスの使い方
抽象クラスを使うことで、実体との依存関係を無くすことができます。実体を隠蔽できるので、利用側は実体が具体的にどうなっているのか知らなくても、その実体が持つ機能を利用することができます。
例えば、LocalDatabaseRepository
というDBを操作する抽象クラスの場合、その実体としてSharedPreferences
やFlutterSecureStorage
または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のoverrides
でSharedPreferencesRepository
をセットします。
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;
});
}
}
CounterController
はref.read(localDatabaseRepositoryProvider)
経由でSharedPreferencesRepository
の機能を利用できます。
これで、CounterController
はLocalDatabaseRepository
の実体が具体的に何であるか知らなくても、セットされた実体の機能を利用することができます。
データソースを別のパッケージに切り替える
ビジネス要件や技術的な理由により、データソース先の切り替えが発生した際は、安全に切り替えることができます。
データソースを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(),
),
);
}
これで、CounterController
はLocalDatabaseRepository
の実体が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
- Webの場合、
- 修正の影響範囲が限定的になる
- 実体を新たに追加して切り替えるので、動いていた従来の実体コードは触らなくて良い
- 切り替え前の実体コードを消さなくても良い
- もしかすると切り替え前に戻すかもしれないという不安を解消
また、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で抽象クラスを使いたくなるケースを解説しました。
- プラットフォーム環境やビジネス要件によって安全にデータソースの切り替えができる
- 抽象クラスを使うことで、パラメータをクラス単位で管理して扱える
- コード量が増えるので、抽象クラスを使う場合はしっかり検討を
今回のコードはこちらのリポジトリにまとめています。
-
GoFではStrategyパターン もしくはTemplate Method パターン ↩
-
Webでしか利用できないIFを参照した場合、ビルドエラーになるので、
dart.library.html
を使ってIFを隠蔽してください(ドキュメント) ↩ ↩2