私のFlutterのアプリ開発ポリシー
Flutter でのアプリ開発におけるポリシーをメモしてみます。
静的解析ではチェックできず、人の目でチェックする必要がある内容を対象としています。
UI 設計
デザインシステム
1 つ以上の要素を囲むウィジェットのサイズは、内部ウィジェットのマージンや親ウィジェット内の相対関係により決定されるようにする
固定値での指定は可能な限り避ける
理由
各端末の画面サイズや、端末の文字サイズ設定、データ長によるレイアウト崩れを避けるため
コードサンプル
BAD
return Scaffold(
appBar: AppBar(),
body: const SizedBox(
width: 320, // BAD
child: ListTile(
title: Text('Alice'),
),
),
);
GOOD
return Scaffold(
appBar: AppBar(),
body: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), // GOOD
child: ListTile(
title: Text('Alice'),
),
),
);
Theme
に定義されているものを利用する
色は 理由
- デザインシステムで定義された色のみが適用されるようにするため
- ダークモードのように実行時に動的に色が変わる挙動に対応するため
コードサンプル
BAD
final colorContainer = Container(
color: Colors.blue, // BAD
);
GOOD
final colorContainer = Container(
color: Theme.of(context).primaryColor, // GOOD
);
Theme
に定義されているものを利用する
テキストスタイルは 理由
デザインシステムで定義された Typography のスタイルのみが適用されるようにするため
コードサンプル
BAD
const nameText = Text(
'Alice',
style: TextStyle(fontSize: 14), // BAD
);
GOOD
final nameText = Text(
'Alice',
style: Theme.of(context).textTheme.caption, // GOOD
);
*.arb
ファイルに定義して利用する
画面に表示する文字列は - 変数する含む文字列も
*.arb
ファイルに定義して利用することを優先する - 部分的に太字などフォーマットが異なる場合は、分けて定義するしかない
理由
- 表記ゆれに気付きやすくするため
- 多言語対応するため
コードサンプル
BAD
const appInfoText = Text('アプリ情報'); // BAD
final nameText = Text('名前: $name'); // BAD
GOOD
"appInfo": "アプリ情報",
"nameFormat": "名前: {name}",
"@nameFormat": {
"placeholders": {
"name": {
"type": "String"
}
}
},
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
final appInfoText = Text(S.of(context)!.appInfo); // GOOD
final nameText = Text(S.of(context)!.nameFormat(name)); // GOOD
画面の構築
データ数が多いものやデータ長が長いもの、複数のパターンが存在するデータの全てに対して、レイアウト崩れが発生しないことを確認する
以下に注意する
- enum クラスの全てのケース
- Union class の全てのサブクラス
理由
画面設計時の考慮もれに気づくため
複数のウィジェットをグルーピングしてファイル内で切り出す際は、プライベートウィジェットとして切り出す
理由
Flutter のリビルドを必要最小限に抑える仕組みの恩恵を最大限受けるため
コードサンプル
BAD
class MemberScreen extends StatelessWidget {
const MemberScreen({
Key? key,
required this.member,
}) : super(key: key);
final String member;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: [
_headerTile(context), // BAD
Container(
alignment: Alignment.center,
child: Text(member),
)
],
),
);
}
// BAD
Widget _headerTile(BuildContext context) {
return ListTile(
title: const Text('Header'),
onTap: () => Navigator.push(context, MemberScreen.route()),
);
}
}
GOOD
class MemberScreen extends StatelessWidget {
const MemberScreen({
Key? key,
required this.member,
}) : super(key: key);
final String member;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: [
const _HeaderTile(), // GOOD
Container(
alignment: Alignment.center,
child: Text(member),
)
],
),
);
}
}
// GOOD
class _HeaderTile extends StatelessWidget {
const _HeaderTile({
Key? key,
}) : super(key: key);
Widget build(BuildContext context) {
return ListTile(
title: const Text('Header'),
onTap: () => Navigator.push(context, MemberScreen.route()),
);
}
}
複雑な画面では、ウィジェットのローカル変数への格納 → 余白をつけて組み立て、の 2 ステップで処理を分ける
理由
処理のかたまりごとに一種類の処理のみを記述することで、可読性を向上させるため
コードサンプル
BAD
final body = Column(
children: const [
Text('1st'),
Padding(
padding: EdgeInsets.only(top: 16),
child: Text('2nd'),
),
],
);
GOOD
// ウィジェットのローカル変数への格納
const firstText = Text('1st');
const secondText = Text('2nd');
// 余白をつけて組み立て
final body = Column(
children: const [
firstText,
Padding(
padding: EdgeInsets.only(top: 16),
child: secondText,
),
],
);
ローカル変数名は、格納されるウィジェットの型を接尾語にする
理由
中身を想像しやすくし、可読性を向上させるため
コードサンプル
BAD
const nameLabel = Text('Alice');
GOOD
const nameText = Text('Alice');
ウィジェット間の余白は、ウィジェットの上または左に付与する
理由
- 上から順に読んでいく際に構造が理解しやすくなり、可読性の向上が見込めるため
- 余白の付与する位置を統一することで、可読性の向上が見込めるため
コードサンプル
BAD
const firstText = Text('1st');
const secondText = Text('2nd');
const thirdText = Text('3rd');
final body = Column(
children: [
const Padding(
padding: EdgeInsets.only(bottom: 16), // BAD
child: firstText,
),
Row(
children: const [
Padding(
padding: EdgeInsets.only(right: 16), // BAD
child: secondText,
),
thirdText,
],
),
],
);
GOOD
const firstText = Text('1st');
const secondText = Text('2nd');
const thirdText = Text('3rd');
final body = Column(
children: [
firstText,
Padding(
padding: const EdgeInsets.only(top: 16), // GOOD
child: Row(
children: const [
secondText,
Padding(
padding: EdgeInsets.only(left: 16), // GOOD
child: thirdText,
),
],
),
),
],
);
スクロール可能なウィジェットではセーフエリア外までスクロール内容が表示されるようにする
この際、スクロールウィジェットの内側の下部にセーフエリア外の下部領域と一致する領域を余白としてとる。スクロールを最下部までスクロールした際にスクロール内部のウィジェットがセーフエリア外のホームバーなどと被ってしまうのを防ぐため
理由
画面サイズ最大までスクロール領域を広げてユーザビリティを高めるため
コードサンプル
BAD
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: SafeArea( // BAD
child: SingleChildScrollView(
child: Column(
children: List.generate(
20,
(index) => Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: OutlinedButton(
onPressed: () {},
child: Text('Button ${index + 1}'),
),
),
),
).toList(),
),
),
),
);
}
GOOD
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: SafeArea(
bottom: false, // GOOD
child: SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewPadding.bottom, // GOOD
),
child: Column(
children: List.generate(
20,
(index) => Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: OutlinedButton(
onPressed: () {},
child: Text('Button ${index + 1}'),
),
),
),
).toList(),
),
),
),
);
}
StatefulWidget
または ConsumerStatefulWidget
内では、プライベートメソッドに context
を渡さないようにする
理由
プライベートゲッターで context
を取得できるため
コードサンプル
BAD
class MyScreen extends StatefulWidget {
// ...
Future<void> _launchSettingsScreen(
BuildContext context, // BAD
) async {
await Navigator.push<void>(
context,
SettingsScreen.route(),
);
}
GOOD
class MyScreen extends StatefulWidget {
// ...
Future<void> _launchSettingsScreen() async {
await Navigator.push<void>(
context, // GOOD
SettingsScreen.route(),
);
}
state
とする
State データのローカル変数を作成する際の名前は、 理由
中身を想像しやすくし、可読性を向上させるため
コードサンプル
BAD
class MyScreen extends ConsumerWidget {
// ...
final AutoDisposeStateNotifierProvider<MyViewModel, MyState> _viewModel;
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.watch(_viewModel); // BAD
GOOD
class MyScreen extends ConsumerWidget {
// ...
final AutoDisposeStateNotifierProvider<MyViewModel, MyState> _viewModel;
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(_viewModel); // GOOD
コンポーネント
外側の余白を持たせない
理由
外側の余白は利用側に委ねることで、再利用性を高めるため
コードサンプル
BAD
class MyButton extends StatelessWidget {
// ...
Widget build(BuildContext context) {
return Padding( // BAD
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ElevatedButton(
onPressed: onPressed,
child: child,
),
);
}
}
GOOD
class MyButton extends StatelessWidget {
// ...
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
child: child,
);
}
}
// ...
// コンポーネントの利用箇所
Center(
child = Padding( // GOOD
padding: const EdgeInsets.symmetric(horizontal: 16),
child: MyButton(
onPressed: () => Navigator.pop(context),
child: const Text('pop'),
),
),
);
UI 表示のみを実装し、画面遷移などのイベントは外部から渡せるようにする
理由
画面遷移のイベントは利用側に委ねることで、再利用性を高めるため
画面と状態管理
State は画面生成に必要最小限のデータを持つようにする
理由
State を見るだけで画面のパターンを想像できるようにして、可読性を高めるため
State と ViewModel のメンバー変数は重複がないようにする
理由
変数の二重管理の発生により処理が不必要に複雑になるのを防ぐため
一般
命名
メソッド名から容易に想像がつく引数以外は、名前付きにしておく
理由
- 可読性を高めるため
- 利用者が引数の内容を明確に認知し、利用ミスを防ぐため
_
で無効化する
無名関数における利用していない引数は 理由
利用しないことを最初に明記し、可読性を高めるため
“info” という単語は利用は避け、より具体的な語彙を利用する
理由
全てのクラスやメンバー変数は何かしらの情報を持つという前提があり、”info”という単語がその中身を説明することに寄与しないため
コメント
コメントはコードを見ても分からないことを書き、分かることは書かないようにする
- 背景や意図をマストで優先的に書く
- 補足となる説明事項は書いてもいい
- コードを日本語訳しただけのようなすぐに分かることは書かない
理由
コードの読みやすさの向上を図りつつ、コメントのメンテナンスコストを不必要に上げないため
その他
メソッドやメンバー変数の公開範囲や可変性、null 許容性は最大限縛る
- プライベートの方がパブリックより望ましい
-
final
→late final
→var
(変数)の順で望ましい - not-null の方が nullable より望ましい
理由
参照される範囲や可変性を狭めた前提でコードを読むことができ、可読性の向上が図れるため
インデントが深くならない書き方を優先する
理由
インデントの視認性が低くならないようにして可読性を高めるため
デバッグ用のコードやコメントアウトしたコードは残さない
PR レビューはこれらのコードは削除しておく
理由
動作していないコードは、読む際ノイズとなり、削除しておくことで可読性が高まるため
Discussion