自己紹介
株式会社ブックリスタのプロダクト開発部でエンジニアをしている城と申します。 コミックアプリ「コミック ROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」のアプリ開発を担当しています。
はじめに
コミックROLLYアプリはFlutterを用いて開発され、リリースから1周年を迎えました。 機能やUIの充実が進む一方で、「画面表示がもう少し早ければ...」という声が増えてきました。
幸いにも、エンジニア間でこの課題が議論されており、具体的な改善案があったため迅速に対応できました。 本記事では、コミックROLLYが実施したパフォーマンス改善の具体的な内容を共有します。
アプリの基本構成
コミックROLLYでは、以下の技術スタックを採用しています。
- 状態管理: Riverpod
UIとロジックの責務を分離し、再利用性と保守性を向上 - 主な構成要素:
- Widget
- 継承元:
HookConsumerWidget
- 画面を表すUIクラス
- UIの状態管理はRiverpod経由で実現
- 継承元:
- State
- 画面の状態やデータを保持するクラス
- Notifier
- 継承元:
StateNotifier
- ビジネスロジックを実装するクラス
- 継承元:
- Provider
StateNotifier
とUIを結びつける役割
- Widget
パフォーマンス改善内容
State監視方法の見直し
WidgetRef.watch編
下記のStateからepisodesを監視して、値が変更されたらWidgetの再構成をトリガーしたい場合を例とします。
@freezed class WorkPageState with _$WorkPageState { const factory WorkPageState({ @Default([]) List<EpisodeEntity> episodes, @Default([]) List<RecommendEntity> recommends, }) = _WorkPageState; }
改善前
final episodes = ref.watch(workPageProvider).episodes;
改善後
final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
解説
改善前: WorkPageState
クラス自体を監視対象とし、そのメンバー変数episodes
の参照を代入しているに過ぎません。そのためepisodes
以外のrecommends
を変更した場合でもWidget
の再構成がトリガーされます。
改善後: 監視対象のepisodes
を変更した場合だけWidget
の再構成がトリガーされます。
WidgetRef.listen編
State
にエラーメッセージerrorMessage
が設定されたらダイアログでエラーメッセージを表示したい場合を例とします。
@freezed class WorkPageState with _$WorkPageState { const factory WorkPageState({ @Default("") String errorMessage, }) = _WorkPageState; }
改善前
class WorkPage extends HookConsumerWidget { const WorkPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final errorMessage = ref.watch(workPageProvider.select((state) => state.errorMessage)); // エラーメッセージがあればダイアログを表示する if (errorMessage.isNotEmpty) { showOkAlertDialog(context: context, message: errorMessage); } return Container(...); } }
改善後
class WorkPage extends HookConsumerWidget { const WorkPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { ref.listen(workPageProvider.select((state) => state.errorMessage), (prev, next) { if (next.isNotEmpty()) { showOkAlertDialog(context: context, message: next); } }); return Container(...); } }
解説
改善前: ダイアログを表示する目的でwatch
を使用しています。
これでも一見期待通りの動作は得られますが、watch
はWidget
の再構成をトリガーするためのリスナーです。
ダイアログを表示したい事とWidget
の再構成は無関係なのにもかかわらずトリガーされ、ダイアログ表示を含むパフォーマンスに影響を及ぼします。
改善後: Widget
の再構成が必要ないState
の監視にlisten
を使用しています。
これによりlisten
の第二引数の関数が呼び出されWidget
の再構成はトリガーされません。
再構成の独立編
下記のStateからepisodesとrecommendsを監視して、それぞれ値が変更されたらWidgetの再構成をトリガーしたい場合を例とします。
@freezed class WorkPageState with _$WorkPageState { const factory WorkPageState({ @Default([]) List<EpisodeEntity> episodes, @Default([]) List<RecommendEntity> recommends, }) = _WorkPageState; }
改善前
class WorkPage extends HookConsumerWidget { const WorkPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return Column( children: [ _buildEpisodes(ref), _buildRecommends(ref), ], ); } Widget _buildEpisodes(WidgetRef ref) { final episodes = ref.watch(workPageProvider.select((state) => state.episodes)); return Container(...); } Widget _buildRecommends(WidgetRef ref) { final recommends = ref.watch(workPageProvider.select((state) => state.recommends)); return Container(...); } }
改善後
class WorkPage extends HookConsumerWidget { const WorkPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return Column( children: [ _buildEpisodes(), _buildRecommends(), ], ); } Widget _buildEpisodes() { return Consumer(builder: (context, ref, _) { // ScopeA final episodes = ref.watch(workPageProvider.select((state) => state.episodes)); return Container(...); }); } Widget _buildRecommends() { return Consumer(builder: (context, ref, _) { // ScopeB final recommends = ref.watch(workPageProvider.select((state) => state.recommends)); return Container(...); }); } }
解説
改善前: episodes
とrecommends
がセットされた時、それぞれWidget
全体が再構成されます。
改善後: episodes
がセットされた時はScopeA
のみ、recommends
がセットされた時はScopeB
のみ再構成されます。
改善前はbuild
のWidgetRef
でwatch
していましたが、改善後はConsumer
のWidgetRef
でwatch
しているのがミソです。
再構成が必要な箇所だけ独立できるので効率的になりました。
State設定の見直し
ここまでState
監視時に気をつけたいポイントは記載しました。
次からはState
設定時に気をつけたいポイントです。
下記のように画面の操作をビジネスロジック側でハンドリングさせたい場合を例にします。
class WorkPage extends HookConsumerWidget { const WorkPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final episodes = ref.watch(workPageProvider.select((state) => state.episodes)); final ignorePointer = ref.watch(workPageProvider.select((state) => state.ignorePointer)); return IgnorePointer( ignoring: ignorePointer, child: Container(...), ); } }
改善前
class WorkPageStateNotifier extends StateNotifier<WorkTopState> { WorkPageStateNotifier(super.state) { fetch(); } Future<void> fetch() async { final episodesResponse = await episodesRepository.get(); state = state.copyWith(episodes: episodesResponse); final ignorePointer = await checkResponse(episodesResponse); state = state.copyWith(ignorePointer: ignorePointer); } }
改善後
class WorkPageStateNotifier extends StateNotifier<WorkTopState> { WorkPageStateNotifier(super.state) { fetch(); } Future<void> fetch() async { final episodesResponse = await episodesRepository.get(); final ignorePointer = await checkResponse(episodesResponse); state = state.copyWith( episodes: episodesResponse ignorePointer: ignorePointer, ); } }
解説
改善前: 分かれていたState
の変更を1つにまとめています。
State
変更処理の間に非同期処理があるため、例で記載したウィジェットでは再構成が2度トリガーされてパフォーマンスが低下します。
改善後: 非同期処理の完了後にまとめてState
設定することで再構成を最小限に抑えられました。
補足:State
変更処理の間が同期処理のみであればウィジェットの再構成は1度に集約されますが、listen
の場合はそれでも2度に分かれます。
ListViewの見直し
ListView.builderの改善
ListView.builder
を使用すると必要な要素だけを動的に作成することでListView
を高速に表示できます。
しかしListView.builder
を使用していても全要素を一度に生成してしまうケースがありました。
改善前
class WorkPage extends HookConsumerWidget { const WorkPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final episodes = ref.watch(workPageProvider.select((state) => state.episodes)); return SingleChildScrollView( child: Column( children: [ _buildSearchHeader(), ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: episodes.length, itemBuilder: (context, index) { return _buildEpisode(episodes[index]); }, ), ], ), ); } }
改善後
class WorkPage extends HookConsumerWidget { const WorkPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final episodes = ref.watch(workPageProvider.select((state) => state.episodes)); return Column( children: [ _buildSearchHeader(), Expanded( child: ListView.builder( shrinkWrap: true, itemCount: filteredItems.length, itemBuilder: (context, index) { return _buildEpisode(episodes[index]); }, ), ), ], ); } }
解説
改善前: SingleChildScrollView
がスクロール可能なビュー全体をメモリ上に展開しようとするためListView.builder
のすべての要素分builderが走ります。
要素数が多くなるほど画面表示まで時間がかかることになりListView.builder
の恩恵を受けられませんでした。
改善前: 必要な要素だけを動的に作成するようになりました。
今回の対応では_buildSearchHeader
をスクロールに含めないようにUI変更を伴いました。
ListViewのスクロール時にヘッダーもスクロールさせたい場合はCustomScrollView
を使用することで対応可能です。
itemExtentの勧め
結論から言うとListView
の要素の高さが一定である場合itemExiten
を指定するとパフォーマンスが向上します。
ListView.builder( itemExtent: 100, // 各アイテムの高さを固定 itemCount: 2000, itemBuilder: (context, index) { return ListTile(title: Text('Item $index')); }, );
解説
デバイスのディスプレイをスクロールした時、どれだけ画面をスクロールするかはスクロールの速さや距離が影響します。シークバーなら選んだ位置によってスクロール先が決まります。
このような方法でListView
を下方向に1000pxスクロールさせる必要があると仮定しましょう。
ListView
を1000px移動した先に表示するアイテムのindexはいくつでしょう。
それを求めるには順番にListViewのアイテムを生成して高さを求めないと答えが出なさそうです。
ではitemExtent
であらかじめすべてのアイテムは100pxと決められていたらどうでしょう。
アイテムを生成せずとも即座に答えが出せます。パフォーマンスとメモリ使用量の削減ができました。
最後に
今回の記事ではレイアウトに関する内容をメインで紹介させていただきました。 コミックROLLYでは今回の記事で紹介した改善することで、チカチカして見えたのが解消する、画面起動からコンテンツが表示されるまでの時間を25%前後も短縮するなどの効果がありました。
紹介した内容の他に非同期処理の並列化など、現在もパフォーマンス改善に取り組んでいます。 またナレッジが溜まったらご紹介させていただきたいと思っていますし、より良いアプローチや別のナレッジがあればぜひ教えて頂きたいです。
この記事の内容が、アプリ開発をされている方の何かしらのヒントになれば幸いです。