先日、弊社が開発を担当したスマートフォンアプリ「シラス」がリリースされました。
Flutterでのモバイルアプリ開発は社内でも初めてのプロジェクトでしたが、Flutterの特性が存分に発揮され、iOS / Android特有の落とし穴にハマることもほとんどなく、無事リリースに至ることができました。
今回は開発中に実際に遭遇したバッテリーパフォーマンスの問題について紹介したいと思います。
発生した問題と経緯
開発も大詰めに入り、実機にアプリを配布して内部テストを始めたところ、iOSで「バッテリー消費が大きい」「動画を再生していると本体が発熱する」という報告が上がってきました。
開発中はエミュレータでアプリを動かしているため、MacBookのファンが激しく回っていても「エミュレータが重いんだろう」と思ってアプリのパフォーマンスは気にかけていませんでしたが、発熱するレベルとなるとそのままではリリースできません。
急遽パフォーマンス計測とバッテリー消費改善をすることになりました。
その1:無駄なsetState
上記の通り、これまでパフォーマンス計測などは行ったことはありませんでした。そのため、どこから手をつけるか悩んだのですが、まずは計測ツールを使う前に『動画の描画がトリガーになってリビルドが発生していないか』を確認することにしました。
確認方法は初歩的なprintデバッグです。視聴画面の build メソッドに debugPrint を仕込み、動画を再生している最中に build が呼び出されまくっていないかを確認しました。
その結果、なんと秒間2〜3回の頻度で画面全体のリビルドが発生していたことが判明しました。

原因は以下のコードでした。
<code>final videoPlayerController = VideoPlayerController.networkUrl(videoUrl)<br>..addListener(() => setState(() {}));</code>addListener により、再生位置を含む VideoPlayer の状態が変化するたびに setState が呼び出され、画面が丸ごとリビルドされてしまっていました。
なぜこのようなコードになっていたのか……については、開発初期の手探りでFlutter開発をおこなっていた時期に、「 videoPlayerController の初期化が終わったら setState を呼んでViewに通知する必要がある」「 addListenter で VideoPlayerController の状態変更にフックした処理が書ける」が組み合わさって「controllerの状態変化を setState でViewに伝える必要があるのでは?」という勘違いから生まれたものだったことがわかりました。
負荷がかかる以外は正常に動作していたため、勘違いに気付くタイミングがなく、ここまで生き残ってしまっていたわけです。
肝心の性能改善ですが、コードをたった1行消しただけで、バッテリー消費量に驚くほどの差が出ました。
| 0min | 30min | 60min | 90min | |
| 削除前 | 100% | 86% | 70% | 53% | 
| 削除後 | 100% | 96% | 90% | 84% | 
なんと3倍も長持ちするようになりました!発熱具合も「カイロくらい熱くなる」だったのが「気持ちほんのりあったかいかも」程度まで改善されました!
その2:画面外でも動いていたアニメーション
一番クリティカルだった視聴画面でのバッテリー消費は改善しましたが、念のため全ての画面でプロファイルを取ることにしました。
FlutterのDevToolsにもプロファイラはありますが、今回はXcodeに付属しているプロファイラを使用しました。Xcodeから実機実行したあと、ナビゲーターのスプレーアイコンを選択するとプロファイラが開きます。

CPU使用率のほかに、バッテリーへの影響なども計測してくれます。

ほとんどの画面では、表示した際にAPI通信のために一時的な負荷はかかるものの、その後はCPU使用率4〜5%に落ち着いていました。しかし、一部の画面では常時CPU使用率が高くなっていることが判明しました。

CPU使用率が高くなっていたのはどれも動画一覧を表示している画面でした。動画一覧がタブの中に表示される画面では、そのタブを開いたときだけCPU使用率が高くなっていました。
しかし、サーバーから動画一覧を取得して表示しているだけで、特に複雑な処理を行なっているわけではない画面です。気になって調査を進めたところ、リストの一番下までスクロールすればCPU使用率が他の画面と同じ水準まで落ち着くことがわかりました。
原因は、以下の LastIndicator でした。
import 'package:flutter/material.dart';
    import 'package:visibility_detector/visibility_detector.dart';
    
    class LastIndicator extends StatelessWidget {
      const LastIndicator(this.onVisible, {this.threshold = 0.1});
    
      final VoidCallback onVisible;
      final double threshold;
    
      @override
      Widget build(BuildContext context) {
        return VisibilityDetector(
          key: const Key('LastIndicator'),
          onVisibilityChanged: (info) {
            // Indicatorの上から {threshold} %が見えたらイベントを発火する
            if (info.visibleFraction > threshold) {
              onVisible();
            }
          },
          child: const Center(
            child: SizedBox(
              width: 30,
              height: 30,
              child: CircularProgressIndicator(),
            ),
          ),
        );
      }
    }これは無限スクロールを実装するためのWidgetです1。データに残りがある場合は末尾に LastIndicator を配置し、スクロールされて画面内に表示されたら残りを取得する処理が発火する仕組みです。
利用側は以下のような実装になっていました。
SingleChildScrollView(
   child: Column(
     children: [
       ...itemList.map((item) => ItemCard(item)),
       if (hasNext) LastIndicator(fetchMore),
     ]
   )
)リスト形式でデータを表示する場合は ListView.builder を使った方がメモリ効率や build 時のパフォーマンスが良いことは知っていましたが、要素がそれほど多くなることは稀で、なおかつ見た目上は目立った処理落ちもなかったため、indexに依存したコードを書かずに済む Column + SingleChildScrollView で実装していました。
しかし、上記のコードの問題点は「画面外の要素の描画に時間やメモリを消費すること」だけではなく、「画面外の要素も描画されつづける」ことにありました。LastIndicator が持っている CircularProgressIndicator (読み込み中のぐるぐる)はアニメーションを行うWidgetです。画面を表示した瞬間からずっと画面外でぐるぐるが回り続けていたため、負荷がかかってしまっていました。
以下のような実装に修正したら、常時負荷が高くなる現象は解消されました。
ListView.builder(
  itemBuilder: (context, index) {
    if (index == itemList.length) {
      return LastIndicator(fetchMore);
    }
	
    return ListItem(itemList[index]);
  },
  itemCount: hasNext 
      ? itemList.length + 1 
      : itemList.length,
 ),
最後に
Flutter開発で実際に遭遇したパフォーマンス問題とその改善について紹介しました。
どちらもFlutter開発に慣れていれば踏み抜かない類の問題かもしれませんが、新しい技術を導入するにあたって、どうしても慣れるまでに時間を要する問題はありますね。このあたりは継続的なリファクタリングなどで改善していくほかないのかもしれません。
