Flutterからプログラミングを学び始めた人が、オブジェクト指向プログラミング(OOP)の考え方をさらっと学習するために本記事を執筆します。
間違いや分かりずらい点ありましたらご指摘お願いします
サンプルプロジェクトの簡単な説明
Flutterプロジェクト作成時の初期アプリであるカウントアップアプリに機能追加したものになります。
カウンターの種類を変えることができ、0,1,2,・・・と進んでいき値が12になったらまた0に戻るHourCounterと単純に0から無限にカウントアップしていくSimpleCounterを左上のスイッチで切り替えることが出来ます。
ついでに、スイッチの状態は一度アプリを切っても保持されます(shared_preferencesを使用)
カウンターのクラス図は以下のようになっています。
※きちんとしたUMLの記法で書いていません。あくまでイメージとしてとらえてください。
以下にコードを置いています
※レクチャーの綴り間違えました...作り直すの面倒くさいのでそのまま...
オブジェクト指向基礎
手続き型言語のコード量の多さや、保守性の悪さを解決する、プログラミング言語のアーキテクチャの一つ。
データと操作をまとめて一つのオブジェクトとしてとらえる。
オブジェクト指向の3大要素として「カプセル化、継承、ポリモーフィズム」がある。
クラスとメンバ
クラスとはオブジェクトを生成するためにデータと処理を定義したもの。
データのことをフィールドといい、処理のことをメソッドという。
フィールドとメソッドのことをメンバという。メンバと言われたら、フィールドかメソッドのこと。
クラスを実際にオブジェクトにして使うことをインスタンス化と言い、生成されたものをインスタンスと呼ぶ。
Dartではnewをつけてもつけなくても良い。
class Hoge {
// フィールド
String value;
// メソッド
void fuga() {
value = 'piyo';
}
}
アクセス修飾子
メンバにはアクセス制限をつけることが出来る。Javaのようにpublic、protected、privateのようなキーワードはなく、
Dartでは名前の頭に_(アンダースコア)をつけるとprivateとなる。
privateなメンバはクラス外からアクセスできない。(可視性の制御)
class CounterModel extends ChangeNotifier {
...
// このcount自体はpublicなので外部から見える
int get count => _counter?.counter;
bool isChangedHourCounter = true;
// _counterと_counterTypeは_がついているのでprivate
CounterBase _counter;
CounterType _counterType;
...
その他の修飾子
var
通常の変数指定。型を指定しない場合につける。指定しない場合でもDartの場合型推論を行う。
個人的には基本使用せず、型を指定すべきかと思う。(型が決まっていない場合はdynamicを使うようにする)
final
再代入不可となるが、参照先の内容は書き換えることが出来る。
final String hoge = 'hoge';
hoge = 'fuga'; // NG
final List<String> piyo = ['piyo','piyopiyo'];
piyo[0] = 'piyopiyopiyo'; // OK 内容は書き換えられる
const
finalと似ているが、コンパイル時に値が決まっていることを保証する。
また、finalと違って参照先の内容を書き換えることも出来ない。
const String hoge = 'fuga';
print('hoge is ${hoge}');
// ↓コンパイル時に以下に変換されるイメージ
print('hoge is fuga');
static
指定したクラス内のフィールドがインスタンスごとに保持されるのではなく、クラスで1つの実体を持つことを宣言する。
コンストラクタを用いたインスタンス化を行わなくても、ダイレクトに参照できるようになる。
class Hoge {
static String fuga = 'piyo';
}
main() {
print(Hoge.fuga); // piyo
}
参考:Dartの変数定義時の修飾static/final/const、そしてconst constructorについて - Qiita
カプセル化
アクセス修飾子を使って外部から見えるメンバを制限すること。
内部で何が行われているかを隠蔽し、使いやすくする。
コンストラクタ
インスタンス化するときに呼ばれるメソッド。クラス内に定義しない場合、自動的に作成されたデフォルトコンストラクタが呼ばれる。
Dartでは他の言語のようにオーバーロード(同名で、受け取る値が異なるコンストラクタを複数定義し、インスタンス化するときに渡す変数を変えることで、生成されたオブジェクトを変化させる機能)は出来ないが、名前付きコンストラクタという機能で同じようなことが出来る。
class CounterModel extends ChangeNotifier {
// クラス名と同じ名前でコンストラクタを作る
// メソッド全般の話だが、{}の中に引数を書くことで、使う時に変数名を参照できる
// 下の例だと使う時、CounterModel(storageRepository: hoge)のようになる
CounterModel({@required StorageRepositoryBase storageRepository})
: _storageRepository = storageRepository;
// クラス内のフィールドをfinal(以降変更できない)とするときはコンストラクタ内で初期化するか
// 宣言と同時に初期化する必要がある
final StorageRepositoryBase _storageRepository;
以下は名前付きコンストラクタの例だが、_で始まっているのでprivateとなる
class PersistenceStorageProvider {
PersistenceStorageProvider._();
static final PersistenceStorageProvider instance =
PersistenceStorageProvider._();
抽象クラスとインターフェース
いきなりDartでの仕様を学ぼうとすると混乱するので、一般的な話をする。
抽象クラスとはabstractというキーワードをclassキーワードの前につけたクラスのことで、継承されることを前提として設計される。
継承とはすでに存在しているクラスに、メンバを追加したり元々のメソッドを上書きしたりして新しいクラスを作ること。
継承元のクラスをスーパークラスと言い、継承して出来たクラスをサブクラスという。
抽象クラスは処理の再利用をしたいときに使う。
一方インターフェースは使えるメソッドを定義するためのもの。インターフェースを実装したクラスは、インターフェース内で定義されているメソッドがオーバーライドされていることが保証される。
参考:【詳解】抽象クラスとインタフェースを使いこなそう!! - Qiita
Dartにおける抽象クラスと暗黙的インターフェース
Dartではinterfaceといったキーワードは使えないが、代わりに全てのクラスは暗黙的に自動でinterfaceが定義される。
継承したいときはextendsを使い、実装したいとき(インターフェースとして使いたいとき)はimplementsを使用する。
abstractクラスをextendsした場合でもimplementsした場合でもスーパークラスで定義されているメソッドはすべてオーバーライドする必要がある。
違いとしては、extendsの場合、abstractクラスで定義したフィールドの利用が可能であり、メソッドの実装までabstract側でやっておいて、superとして処理を呼べる。(処理の共通化)
/// Counterの基本となる抽象クラス
/// フィールドにはcounterを持ち
/// incrementというcounterをインクリメントするメソッドの
/// 実装を強制する
abstract class CounterBase {
int counter = 0;
void increment();
}
以下が継承したサブクラス
import 'package:flutter_oop_recture/domain/counter_base.dart';
class SimpleCounter extends CounterBase {
// @override のアノテーションをつけてメソッドをオーバーライドする
@override
void increment() {
// SimpleCounter自体のフィールドにcounterというものはないが
// スーパークラスのフィールドを使用できる
super.counter++;
}
}
一方implementsの場合、変数を定義できず、superとして処理も呼べないため、必ずメソッドの実装をオーバーライドして書き換える必要がある。
// abstract として定義しているが、目的はinterfaceとしての利用
abstract class StorageRepositoryBase {
Future<void> savePersistenceStorage(String key, String value);
Future<String> loadPersistenceStorage(String key);
Future<bool> isExistKey(String key);
Future<void> remove(String key);
}
以下が実装クラス
class StorageRepository implements StorageRepositoryBase {
final PersistenceStorageProvider _instance =
PersistenceStorageProvider.instance;
@override
Future<void> savePersistenceStorage(String key, String value) async {
final SharedPreferences pref = await _instance.prefs;
await pref.setString(key, value);
}
// 他すべてのメソッドをオーバーライド
...
参考:【Dart】abstract,mixin,extends,implements,with等の使い方基礎 - Zenn
ポリモーフィズム
ポリモーフィズム(多態性)とはクラスを継承してメソッドをオーバーライドしたことで、同名のメソッドを呼んだ時に挙動が変わることです。
先ほども紹介したCounterBaseの抽象クラスですが、incrementというメソッドを持っています。
abstract class CounterBase {
int counter = 0;
void increment();
}
SimpleCounterでは単純にインクリメントを行うだけに対し
class SimpleCounter extends CounterBase {
@override
void increment() {
super.counter++;
}
}
HourCounterではincrementメソッドの中でresetメソッドを呼び、もし指定したmodular(法)の数になったら、値を0にリセットする処理を、スーパークラスのModularCounterで実装しています。
abstract class ModularCounter extends CounterBase {
ModularCounter(this.modular);
final int modular;
/// ModularCounterにはリセット機能を実装するよう強制する
/// スーパークラスのcounterを用いて実装しておく
void reset() {
// 明示的にsuper.counterと書かなくとも、スーパークラスのフィールドが使える
if (counter >= modular) {
counter = 0;
}
}
}
このModularCounterを継承することで、resetメソッドを呼び出すことが出来ます。
class HourCounter extends ModularCounter {
HourCounter() : super(12);
@override
void increment() {
super.counter++;
reset();
}
@override
void reset() {
// reset機能はスーパークラスの実装をそのまま使うため
// 内部でsuper.reset()を呼ぶだけにしておく
super.reset();
}
}
依存関係逆転の原則
このようにextendsやimplementsを行って作ったクラスを使う時に注意すべきなのが依存関係逆転の原則です。
CounterBaseを継承して作られたSimpleCounterを使いたい場合に、SimpleCounterをそのまま宣言してしまうとSimpleCounterに依存してしまうことになります。
そうではなく、もっと抽象的な存在であるCounterBaseに依存させることで、後からHourCounterに差し替えることが可能になります。
class CounterModel extends ChangeNotifier {
...
CounterBase _counter;
CounterType _counterType;
...
/// _counterをCounterBaseとして定義しているので
/// 後からSimpleCounterやHourCounterに差し替えることが出来る
Future<void> switchCounter() async {
if (isChangedHourCounter) {
_counter = SimpleCounter();
isChangedHourCounter = false;
await _storageRepository.savePersistenceStorage(
key_counter, CounterType.simpleCounter.value);
} else {
_counter = HourCounter();
isChangedHourCounter = true;
await _storageRepository.savePersistenceStorage(
key_counter, CounterType.hourCounter.value);
}
notifyListeners();
}
/// CounterBaseにはincrementメソッドを定義しているので、気にせずincrementを呼ぶことが出来る
/// 実際に行われる処理は具体的に実装されたSimpleCounterやHourCounterで実装したincrementメソッド
void increment() {
_counter.increment();
notifyListeners();
}
}
上記のケースでは単にカウンターの切替を行うだけですが、結局CounterModelがSimpleCounterとHourCounterに依存してしまっています。
そこで、このような依存関係をなくすために、外部から実装クラスを受け取るように実装したくなる時があります。
そのような手法を**DI(Dependency Injection)**と言います。
CounterModelではコンストラクタからStorageRepositoryBase(インターフェース)を受け取っており、内部ではどのような実装が行われているかは意識しません。
class CounterModel extends ChangeNotifier {
CounterModel({@required StorageRepositoryBase storageRepository})
: _storageRepository = storageRepository;
final StorageRepositoryBase _storageRepository;
これを外から代入する方法は様々ですが、FlutterではProviderパッケージを使って代入を行うことが出来ます。
void main() {
runApp(
MultiProvider(
providers: [
// 後から読み込む場合の型はStorageRepositoryBase(インターフェース)
Provider<StorageRepositoryBase>(
// 実際に生成しているのはStorageRepository(StorageRepositoryのサブクラス)
create: (BuildContext context) => StorageRepository(),
),
ChangeNotifierProvider<CounterModel>(
create: (BuildContext context) => CounterModel(
// 上記で生成したStorageRepositoryBaseを型に持つ実体をCounterModelのコンストラクタへ代入
storageRepository: context.read<StorageRepositoryBase>())
..init(),
),
],
child: MyApp(),
),
);
}
参考:オブジェクト指向 依存関係逆転の原則の「逆転」とは - Qiita
シングルトン
生成したインスタンスが単一であることを保証するもの。
状態を保持したいときや、何度も同じ処理を行いたくないときに使う。
サンプルプロジェクトではshared_preferencesのインスタンスを提供するクラスをシングルトンで定義している。
class PersistenceStorageProvider {
PersistenceStorageProvider._();
// staticとすることでインスタンスごとに保持されるのではなく、クラスで1つの実体を持つことを宣言する
// instanceという変数への参照に自分自身を代入している
static final PersistenceStorageProvider instance =
PersistenceStorageProvider._();
// 内部で保持しておきたいインスタンス
SharedPreferences _prefs;
Future<SharedPreferences> get prefs async {
// ??=を使ってもし_prefがnullならば右辺の結果を代入し、そうでなければそのまま_prefを返却する
return _prefs ??= await initSharedPreferences();
}
// 実際にSharedPreferencesのインスタンスを取得する処理
Future<SharedPreferences> initSharedPreferences() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs;
}
}
参考:
- Dartの変数定義時の修飾static/final/const、そしてconst constructorについて - Qiita
- 【Flutter】Dartでシングルトンパターンを実装する - taketiyo.log
おまけ Unit Test
依存関係逆転の法則を用いてCounterModelからSharedPreferencesの依存を排除したことによって、単体テストが書きやすくなる。単体テストの書き方については過去の記事を参照してください。
void main() {
// テスト用のメソッドを使いたいので、型はテスト用repositoryの方を指定する
final StorageMemRepository storageRepository = StorageMemRepository();
group('init', () {
test('shared_preferencesに値がない場合', () async {
storageRepository.clear();
// ここでCounterModelを生成しているが、渡しているのは初めに定義したテスト用のインスタンス
final model = CounterModel(storageRepository: storageRepository);
await model.init();
// isChangeHourCounterは初期状態のtrueのままであること
expect(model.isChangedHourCounter, true);
model.dispose();
});
test('shared_preferencesにSimpleCounterが設定してある場合', () async {
storageRepository.clear();
// あらかじめsimpleCounterの方をセットしておく
await storageRepository.savePersistenceStorage(
key_counter, CounterType.simpleCounter.value);
final model = CounterModel(storageRepository: storageRepository);
await model.init();
// isChangeHourCounterはfalseであること
expect(model.isChangedHourCounter, false);
model.dispose();
});
});
...
テスト用のレポジトリは以下のようにインメモリで動作するように記載している。
StorageRepositoryBaseをimplementsしているので、CounterModelのコンストラクタで代入出来る。
class StorageMemRepository implements StorageRepositoryBase {
// テスト用のレポジトリ内のデータの実態はただのMap
final Map<String, String> _data = <String, String>{};
// データを初期化できるようにテスト用リポジトリだけclearメソッドを追加
void clear() {
_data.clear();
}
// その他のメソッドは実際にSharedPreferencesにアクセスせずに
// 単に_dataを操作するように書く
@override
Future<bool> isExistKey(String key) {
return Future<bool>.value(_data[key] != null);
}
...
}
Next Action
オブジェクト指向が何となくわかってきたら、アーキテクチャについて学ぶと良いと思います。
FlutterではMVVM + RepositoryやDDDがよく使われます。
- ProviderでMVVM + Repositoryを使った解説はこちら
- Riverpodを使ったDDDについてはこちら
まとめ
書く中で色々調べなおして整理したりしたので自分自身の勉強にもなりました。
プログラミング初心者に向けてのつもりで書き始めたけど、初心者を抜け出して中級者になろうとしている人や中級者が読むといいのかもしれないと思いました。
参考
- tokku5552/flutter_oop_recture - GitHub
- 【詳解】抽象クラスとインタフェースを使いこなそう!! - Qiita
- 【Dart】abstract,mixin,extends,implements,with等の使い方基礎 - Zenn
- オブジェクト指向 依存関係逆転の原則の「逆転」とは - Qiita
- Dartの変数定義時の修飾static/final/const、そしてconst constructorについて - Qiita
- 【Flutter】Dartでシングルトンパターンを実装する - taketiyo.log
- 【Flutter】Providerで最低限のDIを行ってテスタブルなコードにリファクタリングする - Qiita
- Flutterで単体テストを書く - Qiita
- Dartの変数定義時の修飾static/final/const、そしてconst constructorについて - Qiita
- Flutterに関する記事まとめ | インフラエンジニアがもがくブログ