初心者向けFlutterコーディング改善集:実際の例と解説
はじめに
こんにちは!
株式会社アンドエーアイでFlutterエンジニアをしているTakuyaです。
本記事では、Flutter初心者が陥りがちなコーディングミスについて、いくつかピックアップしていきます。
背景
自分がFlutterを始めて1年も経っていないので、この記事で自分が今までいただいた指摘をまとめていくことで、いつでも振り返ることができるようにしたいと思いました。
また、Flutter初心者のコーディング力向上に少しでもお役に立てればと思い、本記事でナレッジ共有したいと思います。
ミス集
自分がインターンとして参加した時に、Sandboxという研修を会社で実施しました。初級と中級編に分かれており、初級ではToDoアプリ、中級では本のレビューアプリを作成します。また、勤怠管理アプリ「KANRIL」の開発に参画することになった時にも多くの指摘をいただきました。下記はそれからいくつかピックアップしたものです。
スペルミス
英語は個人的に得意ですが、コードにスペルミスがあったので、VS Codeの拡張機能であるCode Spell Chekcerを入れることを推奨されました。
1つの DateFormat で対応する
修正前と修正後
final now = DateTime.now();
- final formattedNowDate = DateFormat.yMd().format(now);
- final formattedNowTime = DateFormat.jm().format(now);
- final formattedNowWithTime = '$formattedNowDate $formattedNowTime';
+ final dateFormat = DateFormat.yMd()..add_jm(); // 1つのDateFormatで対応
+ final formattedNowDate = dateFormat.format(now);
改善点: 修正後のコードは冗長なフォーマット処理を削除し、より簡潔で可読性の高いものになっています。
Future と try/catch でのエラーハンドリング
修正前
void add(String description) {
final now = DateTime.now();
final formattedNowDate = DateFormat.yMd().format(now);
final formattedNowTime = DateFormat.jm().format(now);
final formattedNowWithTime = '$formattedNowDate $formattedNowTime';
final id = _uuid.v4();
final newTodo =
Todo(id: id, description: description, createdAt: formattedNowWithTime);
state = [
...state,
newTodo,
];
final db = FirebaseFirestore.instance;
final todo = newTodo.toJson();
db.collection('todos').doc(id).set(todo);
修正後
Future<void> add(String description) async {
try {
final id = _uuid.v4();
final newTodo = Todo(
id: id, description: description, createdAt: formattedNowWithTime);
final db = FirebaseFirestore.instance;
final todo = newTodo.toJson();
await db.collection('todos').doc(id).set(todo);
state = [
...state,
newTodo,
];
} catch (error, stackTrace) {
debugPrintStack(label: error.toString(), stackTrace: stackTrace);
rethrow;
}
}
下記は呼び出し側のコードです。
修正前
ref.read(todoListProvider.notifier).add(enteredText);
修正後
try {
await ref.read(todoListProvider.notifier).add(enteredText);
} catch (e) {
// エラーであることをユーザーへ表示する処理
SacffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString()))
);
}
改善点:
1. エラーハンドリング:
修正前: ネットワークエラーなどにより、アプリがクラッシュしたり、予期しない動作を引き起こす可能性があります。
修正後: データベース操作が try/catch ブロックで囲まれており、エラーが発生した場合でもキャッチして処理します。また、debugPrintStack を使ってエラーとスタックトレースをログに残し、rethrow でエラーを上位のハンドラに伝えます。
2. 非同期なデータベース操作:
修正前: db.collection('todos').doc(id).set(todo) が同期的に実行され、データベース操作が非同期的に行われないため、問題を引き起こす可能性があります。
修正後: async と await を使ってデータベース操作を正しく処理し、コードが Firebase 操作の完了を待つようにしています。
3. エラーであることをユーザーへ表示する
修正後: try/catch を使って、await の後でエラーハンドリングを実装しています。これにより、エラーが発生してもアプリがクラッシュせず、SnackBar などを使ってユーザーにエラーを知らせることが可能です。これにより、アプリの安定性が向上します。
build関数内でControllerを初期化している
修正前
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
Widget build(BuildContext context) {
final emailEditingController = TextEditingController();
final passwordEditingController = TextEditingController();
修正後
class _LoginScreenState extends State<ProfileSettingScreen> {
final emailEditingController = TextEditingController();
final passwordEditingController = TextEditingController();
改善点: StatefulWidgetによる状態管理
修正前: StatelessWidget は、「状態」を持たないウィジェットなので、ウィジェットが再描画されるたびに、build関数が呼び出され、 TextEditingController が再作成されます。これにより状態がリセットされてしまいます。
修正後: StatefulWidget に変更することで、状態(例えば、入力中のテキストや、ユーザーがフォームに入力したデータ)を保持し続けることができます。State クラスを使用することで、ウィジェットが再描画された場合でも TextEditingController の状態がリセットされることを防ぎ、入力されたデータを保持できます。
また、State クラスの dispose メソッドが使用できるようになります。これにより、TextEditingController を適切に解放することが可能になり、メモリリークを防ぎます。
関数内の処理を分割させる
修正前
Future<void> submit() async {
final enteredEmail = emailEditingController.text.trim();
final enteredPassword = passwordEditingController.text.trim();
final isValid = formKeys.currentState!.validate();
if (!isValid) {
return;
}
formKeys.currentState!.save();
try {
final userCredential =
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: enteredEmail,
password: enteredPassword,
);
final user = userCredential.user;
if (user == null) {
return;
}
await userCredential.user?.sendEmailVerification();
if (context.mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const EmailVerificationScreen(),
),
);
} else {
return;
}
} on FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
// ...
} else if (e.code == 'email-already-in-use') {
// ...
} else if (e.code == 'user-not-found') {
// ...
} else if (e.code == 'wrong-password') {
// ...
}
}
修正後
Future<void> submit() async {
try {
// ...
} on FirebaseAuthException catch (e) {
await _onFirebaseAuthException(e); // 処理を分割させる
}
}
Future<void> _onFirebaseAuthException(FirebaseAuthException error) async {
if (e.code == 'weak-password') {
} else if (e.code == 'email-already-in-use') {
// ...
} else if (e.code == 'user-not-found') {
// ...
} else if (e.code == 'wrong-password') {
// ...
}
}
改善点:
1. コードの可読性・再利用性の向上
修正前: submit メソッド内に、エラーハンドリングのロジックがすべて含まれているので、メソッドが長くなり、読みづらくなっています。また、submit メソッドに直接エラー処理を書いているため、他の部分で同じエラー処理を行いたい場合、コードを再利用できません。
修正後: エラー処理が _onFirebaseAuthException という別メソッドに切り出されています。これにより、submit メソッドが簡潔になり、エラー処理のロジックが明確に分離されています。また、このエラー処理は他の認証処理でも使い回すことができるため、コードの再利用性が向上しています。
2. 責務の分離
修正前: submit メソッドは、フォームのバリデーション、Firebase へのユーザー登録、そしてエラーハンドリングと複数の責務を担っているため、メソッドが複雑になっています。
修正後: エラーハンドリングが _onFirebaseAuthException に移動されているため、submit メソッドはユーザー登録のロジックに集中できるようになっています。エラーハンドリングは専用のメソッドに分離されているので、どのメソッドが何をするのかが明確になります。
InkWell は Material ウィジェットと併用
InkWellを単体で使用していたため、スプラッシュ効果が表示されない事象が発生していました。
公式ドキュメントにも書かれている通り、InkWellはMaterialウィジェットを祖先として持つ必要があります。これがないと、InkWellがタップされた時に表示されるスプラッシュ効果が表示されません。
The InkWell widget must have a Material widget as an ancestor. The Material widget is where the ink reactions are actually painted. This matches the Material Design premise wherein the Material is what is actually reacting to touches by spreading ink.
ListTile の角を丸くする
ListTile の角がなぜ丸くならないかについても悩んでいました。DecoratedBox の boderRadius で角丸を反映させようと試みましたが、なかなかできず、レビュワーの方に質問したところ、 Material を使用する指摘をいただきました。
公式ドキュメントにも書かれている通り、ListTile は Material デザインに依存しているため、Material ウィジェットで包むことが推奨されます。
This widget requires a Material widget ancestor in the tree to paint itself on, which is typically provided by the app's Scaffold. The tileColor, selectedTileColor, focusColor, and hoverColor are not painted by the ListTile itself but by the Material widget ancestor. In this case, one can wrap a Material widget around the ListTile
また、Material で包み、Clip.antiAlias を指定することで、角丸のデザインにおいてスプラッシュ効果が境界外に流れ出ることなく、見た目が整います。
修正前と修正後:
class RandomWidget extends StatelessWidget {
const RandomWidget({super.key});
Widget build(BuildContext context) {
- return DecoratedBox(
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- ),
+ return Material(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(),
ListTile(),
],
),
);
}
}
EdgeInets.only で左右の Padding を設定
EdgeInsets.symmetric を使用すれば、より簡潔で可動性が向上したコードになります。また、将来的に左右の Padding の値を変更したい時に、horizontal の引数に渡される値のみ変更すれば良いので、メンテナンス性が向上します。
BorderRadius も同様のプロパティが存在するので、左右均等の radius にする場合は、.only のプロパティは使用しないように気をつけましょう。
修正前と修正後:
Padding(
- padding: const EdgeInsets.only(
- left: 16,
- right: 16,
- ),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ )
)
宣伝
アンドエーアイは、事業拡大のため、即戦力となるエンジニアを募集中です。
Flutterトップエンジニアが参画するプロジェクトを複数抱えており、Flutterへの深く体系的な知見とコード資産が社内共有されています。
現在 Azure、AWSの経験があるインフラエンジニアも募集しているので、インフラに関する知識があれば優先的に選考いたします。
Flutterは、常に更新され続け、非常に拡張性の高い技術です。
そのため、常に最新技術にキャッチアップし続け、社内での知見向上に貢献できる人材を求めています。
新しい技術を取り入れる積極性、自走力、ポジティブさは大きく評価されるポイントです。
採用ページ
エンジニア採用ページ
Discussion
👏🏆
TextEditingControllerのところは僕以前ハマりました😅
良い記事を書いてくれてありがとうございます。
コメントありがとうございます!
確かにあるあるですよね。。。笑
油断するとやってしまうという💦