はじめに
Flutter開発を始めて半年。最初は「Widgetを組み合わせれば、それっぽいUIが作れる!」と感動する毎日でした。しかし、プロジェクト業務を行なっていくにつれて、いくつかの壁にぶつかりました。
1. 「動けばいい」からの脱却
最初は機能を実現することで精一杯でしたが、ふと自分の作ったアプリを触ってみると、画面遷移で一瞬止まったり、リストのスクロールが微妙にカクついたりすることに気づきました。Flutterは毎秒60フレーム(あるいは120フレーム)約16ミリ秒以内に準備を整えなければなりません。
2. 「速いコード = 読みづらい」という勘違い
「パフォーマンスを極限まで追求すると、コードが複雑になって読みづらくなる(保守性が下がる)のでは?」という不安がありました。実際、公式リファレンスにある「ウィジェット階層を少なくする」という教えを愚直に守ろうとすると、逆にクラスが細かくなりすぎて管理が大変になることもあります。
しかし、詳しく学んでみると 「パフォーマンスに寄与しつつ、同時に保守性も高まる実装法」 がいくつもあることを知りました。
3. 半年経った今だからこそ伝えたい
「保守性を第一に考える」という原則を大切にしつつ、 「良い構造のコードを書けば、結果としてアプリも速くなる」 という驚きを、同じような悩みを持つ新人エンジニアの方々と共有したいと思い、この記事を執筆しました!
対象読者
この記事は、以下のような方をイメージして書いています!
- Flutterの基本(Widgetの配置や画面遷移)はマスターした
- 「とりあえず動く」の次のステップ(効率的な書き方)を知りたい
-
constをつけるべき場所や、ウィジェットをクラスに切り出す基準に迷っている - コードが肥大化してきて、「保守性の高い書き方」 を模索している
1. パフォーマンスと保守性のバランス
まず大切な考え方として、 「保守性を第一に考える」 のが基本です。FlutterにはElementの再利用といった最適化の仕組みがあるため、普通に書いていて致命的な低速化が起こることは稀だからです。
ただし、「良い構造は、結果としてパフォーマンスにも寄与する」 というケースが多くあります。
2. buildメソッドを「クリーン」に保つ
buildメソッドは描画のたびに何度も呼ばれるため、ここで重い処理(計算)をしてはいけません。
① Navigatorの取得タイミング
画面遷移に使う Navigator.of(context) は、ウィジェットの階層によっては計算量が多くなる場合があります。
-
NG:
Navigator.of(context)を冒頭で定義する
これをやると、再描画のたびにウィジェットツリーを検索してNavigatorStateを探しに行くため、無駄な計算コストがかかります。-
対策: 実際に使う
onPressedの中など、「使う直前」で取得するのが好ましいです
-
対策: 実際に使う
// ❌ 良くない例:buildのたびに検索が走る
@override
Widget build(BuildContext context) {
final navigator = Navigator.of(context); // ここで取得しない
return ElevatedButton(
onPressed: () => navigator.push(...),
child: const Text('Go'),
);
}
// ✅ 良い例:使う直前に取得する
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
final navigator = Navigator.of(context); // タップ時のみ実行
navigator.push(...);
},
child: const Text('Go'),
);
}
② リストのフィルタリング
表示するデータの加工を build 内で行うと、再描画のたびに計算が走ってしまいます。
-
NG:
build内でのデータ加工(filter/mapなど)
リストを表示する際に、items.where(...).toList()といった処理をbuild内で直接書くと、スクロールやアニメーションのたびにフィルタリング計算が走り、カクつきの原因になります。- 対策: 加工済みのリストをコンストラクタで受け取るか、事前に計算を済ませておきましょう
// ❌ 良くない例:build内で毎回フィルタリングする
@override
Widget build(BuildContext context) {
final filteredItems = items.where((item) => item.isValid).toList();
return ListView.builder(itemCount: filteredItems.length, ...);
}
// ✅ 良い例:あらかじめ加工済みのリストを渡す
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.filteredItems});
final List filteredItems; // フィルタリング済みを受け取る
@override
Widget build(BuildContext context) {
return ListView.builder(itemCount: filteredItems.length, ...);
}
}
3. ウィジェットツリーを「浅く」保つ
ツリーが深いと、Flutterが再構築の判定を行うコストが増えます。
適切なウィジェットを選ぶだけで、階層を劇的に減らせます。
例:右下に配置したい場合
慣れているRowやColumnだけで何とかしようとして、階層が深くなってしまうことがあります。Row と Column を組み合わせるよりも、 Align 1つの方がシンプルです。
-
NG:右下配置のために
Row+Columnを使う
たった一つのボタンを右下に置くために、水平方向(Row)と垂直方向(Column)を入れ子にするのは冗長です。ウィジェットツリーが深くなると、Flutterの再構築コストが増大します。-
対策:
Alignウィジェット1つで代用できないか検討しましょう。適切なウィジェット選びが、ツリーを浅くし、可読性を高めます
-
対策:
// ❌ 階層が深く、コードも読みづらい
body: Row(
mainAxisAlignment: MainAxisAlignment.end, // ❶ 水平方向の右寄せ
children: [
Column(
mainAxisAlignment: MainAxisAlignment.end, // ❷ 垂直方向の下寄せ
children: [
ElevatedButton(
onPressed: () { /* 遷移処理 */ },
child: const Text('HelloWorld'),
),
],
),
],
),
// ✅ Alignを使えば1階層で済み、意図も伝わりやすい
body: Align(
alignment: Alignment.bottomRight, // ❶ 右下を指定するだけ
child: ElevatedButton(
onPressed: () { /* 遷移処理 */ },
child: const Text('HelloWorld'),
),
),
4. const 修飾子と「クラス化」の威力
constを付ける理由
const が付いたウィジェットはコンパイル時定数となり、親が再構築されても自分自身は再構築されません。
ヘルパーメソッドより「ウィジェットクラス」
コードを共通化する際、関数(ヘルパーメソッド)にしてしまいがちですが、これはお勧めできません。関数だと const が使えないため、再構築を抑える効果がないからです。
-
NG:ウィジェットを「関数」として切り出す
関数として切り出すと、const修飾子が使えません。その結果、親ウィジェットが再構築されるたびに、その関数で定義したウィジェットも強制的に再構築されてしまいます。-
対策: 面倒でも独自の
StatelessWidgetクラスを作成し、constantコンストラクタを実装しましょう。これにより、祖先の再構築の影響を受けない「堅牢なパーツ」になります。
-
対策: 面倒でも独自の
// ❌ お勧めしない:ヘルパーメソッド(再構築を防げない)
Widget _buildColoredText() {
return ColoredBox(color: Colors.red, child: Text('Hello'));
}
// ✅ 良い例:独自のウィジェットクラス+constantコンストラクタ
class ColoredText extends StatelessWidget {
const ColoredText({super.key}); // constantコンストラクタ
@override
Widget build(BuildContext context) {
return const ColoredBox(...);
}
}
5. 状態(State)を「末端」に追いやる
「画面全体で使うデータだから」と、画面のトップレベル(Scaffoldの直上など)で setState や Riverpodの ref.watch を行いがちです。
画面全体で setState を呼ぶと、更新の必要がないパーツ(AppBarなど)まで再構築されてしまいます。
状態を持つウィジェットをできるだけ末端に移動させることで、影響範囲を最小限に抑えられます。
-
NG:画面全体の
setStateで小さなパーツを更新する
ボタン一つの数字を変えるために画面全体でsetStateを呼ぶと、更新の必要がないAppBarや背景まで全て再構築されてしまいます。-
対策: 状態(State)を「末端」のウィジェットに押し込めるのが鉄則です。Riverpodを使う場合も、
ref.watchは画面全体ではなく、表示更新が必要な最小単位のウィジェットで行うようにしましょう。
-
対策: 状態(State)を「末端」のウィジェットに押し込めるのが鉄則です。Riverpodを使う場合も、
❌ 良くない例:親ウィジェットで状態を管理する
ボタンの数字を変えるためだけに、画面全体(HomeScreen)を StatefulWidget にして setState を呼んでしまう例です。
// ❌ 状態を上に置きすぎている例
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _counter = 0; // 画面全体の状態として管理
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home Screen'), // 更新不要なAppBarも...
),
body: Center(
child: ElevatedButton(
child: Text('count = $_counter'),
onPressed: () {
// ここでsetStateを呼ぶと、Scaffold全体が再構築される
setState(() => _counter++);
},
),
),
);
}
}
// ✅ 状態を持つボタンだけを切り出す例
class CountButton extends StatefulWidget {
const CountButton({super.key});
@override
State<CountButton> createState() => _CountButtonState();
}
class _CountButtonState extends State<CountButton> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return ElevatedButton(
child: Text('count = $_counter'),
onPressed: () => setState(() => _counter++), // このボタンだけが再構築される
);
}
}
まとめ
本記事では、アプリのパフォーマンスとプログラムの保守性、どちらも両立させるポイントに絞って紹介しました。
Flutterアプリを開発する際は、本記事を頭の片隅に置いて設計を行なってみてください。
「保守性の高いコードは、結果として速い」
この半年で学んだ一番の教訓です!
どんなにコードを工夫しても、シミュレータやDebugビルドでは正確なパフォーマンスは測れません。
パフォーマンスを確認する際は、必ず実機で、かつReleaseビルドに近いProfileモードで確認するようにしましょう。