Testabilityを保ちつつproviderを使ってreduxアプリのパフォーマンスを改善する
はじめに
僕がここ2年弱携わってるFlutterアプリのアーキテクチャはreduxです。最近はBLoCやprovider+ChangeNotifierが主流なのかな?と感じつつもreduxも悪くはないなと個人的には思ってます。
とはいえ開発を開始したのが2年弱前、Flutterのバージョンはv0.何だっけな?という状態で、当時はドキュメントが少ない中手探りで開発してたのもあり、パフォーマンスの観点では良い設計とは言えず、少し改善する必要があるなと感じていました。
僕が所属するチームでは、flutter_reduxパッケージのStoreConnector widgetを使ってStateからUI描画に必要な値を取り出し、ユーザーアクション処理と一緒にViewModelに突っ込み、そのViewModelをWidgetに渡すという設計パターンを軸にしていました。以下のコードはFlutter Projectを作ったときに自動で生成されるカウンターアプリに上記の設計パターンを当てたものです。
シンプルなので見通しも良く、MyHomePageContentをテストするときはmock化したViewModelを渡せばいいのでテストもしやすいです。しかしパフォーマンスにやや問題があります。StoreConnectorはconverterで返すViewModelが変わった場合builderが走るようになっています。つまり、Incrementボタンがタップされ、Stateのcountが増える度にbuilderが走ります。builderで返しているのはMyHomePageContentであり、そのbuildメソッドにはAppBarなどcountに関係ないWidgetたちも含まれているため、これらもリビルドの対象になってしまっているのです。
この課題に対して、「Testabilityは保ちつつパフォーマンスを向上させる」ということを観点にアーキテクチャの改善を行いました。この記事では、上記のカウンターアプリを例に具体的にどのように改善していったのかを書きます。
StoreConnectorを局所使用に変更
まずやったのはStoreConnectorを値が必要なUIの親に移動することです。パフォーマンスの観点での問題点は、上記のカウンターアプリを例にすると、「countに関係ないものまでリビルドされてしまう」ことでした。なのでシンプルに、「countに関係あるものだけリビルド対象にする」というのがStoreConnectorを局所使用することの目的です。これを上記のカウンターアプリのコードに適用すると、MyHomePageとMyHomePageContentを以下のように変更することになります。
・カウント表示部分のText WidgetをStoreConnectorでラップ。必要なのはcountだけなのでconverterで返すオブジェクトはAppState.countに変更
・ViewModelは不要になったので削除
・ViewModelがなくなったので、FloatingActionButtonのonPressedで直接increment処理をするように変更
以上が変更ポイントです。AppBarなど不要にリビルドしていた部分がごっそりなくなりました。
パフォーマンスの観点では改善されたわけですが、Testabilityの観点で課題が残ってしまっています。FloatingActionButtonのonPressedでincrement処理をベタ書きしている部分です。僕が所属しているプロダクトではタップのようなユーザーアクション処理も部分的にテストを書いています。最初の方にも書きましたが、mock化したViewModelをWidgetに渡してテストしていて、この改善だけでは外部からタップ処理を注入できないのですでに書かれているユーザーアクション処理のテストには対応できません。
コールバックでユーザーアクション処理を注入
そこでまず考えられるのが、classにユーザーアクション処理のコールバックを持たせて外部から注入することです。
こうすることで「外部からユーザーアクション処理を注入できない」という問題は解決することができます。しかし、コールバックはconstにできないため、必然的にMyHomePageもconstで呼ぶことができなくなります。constにできるかどうかで、親WidgetのリビルドにそのWidgetも巻き込まれるかどうかが決まります(今回のカウンターアプリの場合はMyHomePageの親がMaterialAppなので少し分かりづらいかもですが)。パフォーマンスを考えれば可能な限りconstでMyHomePageを使いたいです。
providerでユーザーアクション処理を注入
ここで使ったのがproviderです。
providerは簡単に言うとInheritedWidgetのラッパーです。InheritedWidgetを直接使うと少し面倒な書き方になってしまう部分を、providerを使用すると簡単に書けるようになります。逆に言うとInheritedWidgetを直接使ってもこれからやることは実現できます。InheritedWidgetについては、公式ドキュメントと併用して以下のmonoさんの記事を読むといいかと思います。とても分かりやすいです。
providerの具体的な使い方はkabochapoさんの以下の記事がとっつきやすくていいと思います。
今回はこのproviderをDIの用途として使います。providerを使ってMyHomePageはconstで使用しつつ、タップ処理を外部から注入できるようにします。
・Handlerクラスを追加してそれにincrement処理を持たせる
・Providerを使ってHandlerをMyHomePageに伝搬
・MyHomePageではProvider.ofでHandlerを取得するのでconst使用が可能になる
以上がポイントです。Handlerクラスはカウンターアプリだと1つしかコールバックを持っていませんが、実際のプロダクトだと複数のコールバックを持っています。これでMyHomePageはconstで使用しつつ、タップ処理を外部から注入できるようになりました。テストを書くときはMyHomePageの親にProviderを置き、mock化したHandlerをProviderに渡せばOKです。
まとめ
今回は以下の2つをすることで、Testabilityは保ちつつパフォーマンスを向上させました。
・状態変化によるリビルドは影響範囲を最小限にする
・provider(InheritedWidget)を使って、Widgetをconstにしつつ外部から値を注入可能にする
この記事ではreduxプロダクトでのパフォーマンス改善についての内容でしたが、上記の2つのポイントはreduxに関係なく使えるパフォーマンス改善やTestableな設計にするための小技として使えると思うので参考にしていただけると幸いです。
Twitterはこちら https://twitter.com/kitoko552