booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

コミックROLLYアプリ速度改善のためにやったことについて

自己紹介

株式会社ブックリスタのプロダクト開発部でエンジニアをしている城と申します。 コミックアプリ「コミック ROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」のアプリ開発を担当しています。


はじめに

コミックROLLYアプリはFlutterを用いて開発され、リリースから1周年を迎えました。 機能やUIの充実が進む一方で、「画面表示がもう少し早ければ...」という声が増えてきました。

幸いにも、エンジニア間でこの課題が議論されており、具体的な改善案があったため迅速に対応できました。 本記事では、コミックROLLYが実施したパフォーマンス改善の具体的な内容を共有します。


アプリの基本構成

コミックROLLYでは、以下の技術スタックを採用しています。

  • 状態管理: Riverpod
    UIとロジックの責務を分離し、再利用性と保守性を向上
  • 主な構成要素:
    1. Widget
      • 継承元: HookConsumerWidget
      • 画面を表すUIクラス
      • UIの状態管理はRiverpod経由で実現
    2. State
      • 画面の状態やデータを保持するクラス
    3. Notifier
      • 継承元: StateNotifier
      • ビジネスロジックを実装するクラス
    4. Provider
      • StateNotifierとUIを結びつける役割

パフォーマンス改善内容

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を使用しています。 これでも一見期待通りの動作は得られますが、watchWidgetの再構成をトリガーするためのリスナーです。 ダイアログを表示したい事と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(...);
    });
  }
}

解説

改善前: episodesrecommendsがセットされた時、それぞれWidget全体が再構成されます。

改善後: episodesがセットされた時はScopeAのみ、recommendsがセットされた時はScopeBのみ再構成されます。 改善前はbuildWidgetRefwatchしていましたが、改善後はConsumerWidgetRefwatchしているのがミソです。 再構成が必要な箇所だけ独立できるので効率的になりました。


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%前後も短縮するなどの効果がありました。

紹介した内容の他に非同期処理の並列化など、現在もパフォーマンス改善に取り組んでいます。 またナレッジが溜まったらご紹介させていただきたいと思っていますし、より良いアプローチや別のナレッジがあればぜひ教えて頂きたいです。

この記事の内容が、アプリ開発をされている方の何かしらのヒントになれば幸いです。