はじめに
こんにちは。Flutterアプリ開発をしていました。
学校のチーム開発の授業でテーマ自由という機会をもらい、リーダーとして5人でいわゆるスマートリモコンアプリを開発してApp Storeにリリースしました。Androidでも動作確認はできていましたが、学校内の作品展示会までにリリースが間に合わず、現時点ではiOSのみの公開となっています。
このアプリは自作のIoTデバイス(M5Stack AtomS3 Lite)とFlutterアプリをつなぎ、スマートフォン一台で家中の家電(テレビ・エアコン・照明など)を操作できるスマートホームシステムです。
開発期間はざっくり1年弱、コミット数は924件。チームでゼロから作り上げる中でハマりまくった出来事を、技術的な観点で振り返ります。
同じような構成(Flutter + AWS Amplify + IoT + BLE)を考えている方の参考になれば幸いです。
プロジェクト概要
このアプリについて
スマートホーム環境においてリモコン制御を一元管理・自動化するシステムです。Flutterアプリ側と自作ファームウェアを書いたM5Stack AtomS3 Liteで構成されています。
| 項目 | 内容 |
|---|---|
| プラットフォーム | iOS |
| Flutterバージョン | 3.9.0 |
| 技術スタック | Flutter / Riverpod / sqflite / AWS Amplify(Cognito) / AWS IoT Core(MQTT) / BLE / mDNS |
| IoTデバイス | M5Stack AtomS3 Lite(自作ファームウェア) |
| チーム規模 | 5人(Flutter担当・マイコン担当・AWS担当・ドキュメント担当) |
システムを構成する3つのレイヤー
このアプリはFlutterアプリ・クラウド(AWS)・マイコンの3層で成り立っています。
┌───────────────────────────────────┐
│ Flutter App (iOS) │
│ Riverpod / sqflite / Amplify │
└────────────────┬──────────────────┘
│ BLE(初回登録時)
│ HTTP over mDNS(同一LAN・低遅延)
│ MQTT over WebSocket(外出先・AWS経由)
┌────────────────▼──────────────────┐
│ M5Stack AtomS3 Lite │
│ 自作ファームウェア(C++) │
│ BLE / Wi-Fi / MQTT / 赤外線送受信│
└────────────────┬──────────────────┘
│ 赤外線信号
┌────────────────▼──────────────────┐
│ 家電製品(TV・エアコン・照明など)│
└───────────────────────────────────┘
マイコン(M5Stack AtomS3 Lite)は4つのモードを持っています:
| モード | 説明 |
|---|---|
| Bluetoothモード | BLE接続待機・赤外線学習受信・IRテスト送信 |
| Wi-Fiモード | Wi-Fi接続・WebサーバーによるHTTP IRコマンド処理 |
| 赤外線学習モード | 赤外線受信・JSON変換・BLEでアプリに通知 |
| AWS IoT Coreモード | MQTT接続・コマンド受信・IR送信実行 |
主な機能
- 家・部屋・リモコンの階層管理(SQLiteで永続化)
- カスタムリモコン作成:ボタンをドラッグで自由配置、色・形・アイコン・サイズを変更可
- 赤外線学習・送信(NEC/Sony/Samsung/RAW対応)
- シーン機能:複数コマンドをまとめてスケジュール・パッケージトリガーで自動実行
- エアコン専用UI:温度・モード・風量をステートフルに管理
- ハイブリッド通信:Wi-Fi環境ではmDNS+HTTPで低遅延、外出先ではAWS IoT Core+MQTTで制御
- 左利きモード:ドロワー位置・スワイプバック方向が全画面で反転
データベース設計(SQLite)
ローカルに sqflite でデータを永続化しています。主要なテーブル関係は以下の通りです。
HOMES(家)
└─ 1:N → ROOMS(部屋) ※ thing_name(AWS IoT Thing名)を保持
└─ 1:N → REMOTES(リモコン)
└─ 1:N → REMOTEWIDGETS(ボタン) ※ IRコードをJSON保持
└─ N:M → ROOMS(ROOM_REMOTE_ASSOCIATIONS 中間テーブル)
└─ 1:N → SCENES(シーン) ※ trigger_conditions / actions をJSON保持
└─ 1:1 → SETTINGS(設定) ※ 利き手・テーマ色・テーマモード
AC_WIDGETS / AC_CYCLE_BUTTON_STATES / AC_TEMPERATURE_CURRENT_STATES
(エアコン状態をリモコンとは別テーブル群で管理)
アーキテクチャ全体像
┌─────────────────────────────────────────────────┐
│ Flutter App (iOS / Android) │
│ │
│ ┌──────────┐ ┌───────────┐ ┌─────────────┐ │
│ │ Riverpod │ │ sqflite │ │ AWS Amplify │ │
│ │ 状態管理 │ │ローカルDB │ │ Cognito認証 │ │
│ └──────────┘ └───────────┘ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ CommunicationManager │ │
│ │ Wi-Fi? → mDNS検索 → HTTP(直接) │ │
│ │ → 未検出 → MQTT(AWS) │ │
│ │ Mobile → MQTT(AWS IoT Core) │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────┬──────────────────────────┘
│ BLE(デバイス登録時)
│ HTTP over mDNS(同一LAN)
│ MQTT over WebSocket(外出先)
┌────────▼────────┐
│ M5Stack │
│ AtomS3 Lite │
│ (自作ファーム) │
│ 赤外線送受信 │
└─────────────────┘
ハマりポイント①:AWS Amplify Authenticator のテーマが死んでいた
問題
AWS Amplify の Authenticator ウィジェットは便利なのですが、アプリのテーマを一切継承しないという仕様に最初は気づきませんでした。
アプリがダークモード+白テーマという設定のとき、Authenticatorが真っ白なボタンを白い背景に描画し、ボタンが完全に見えなくなるという状態になりました。
コミット履歴がその苦労を物語っています:
70b6ad0 feat(auth): Authenticator にアプリの theme/darkTheme/themeMode を継承させる
7b6c9f1 fix(auth): Authenticator のボタン/リンクテーマを上書きして視認性を向上
原因
amplify_authenticator は内部で独自の MaterialApp を持つような構造になっており、外側の ThemeData がそのまま伝わりません。さらに Authenticator 自体が ElevatedButton・TextButton・FilledButton・OutlinedButton のどれを使っているかが一貫していなく、一種類だけ上書きしても他のボタンが素のスタイルのまま残ります。
解決策
Authenticator をフルスクリーンダイアログとして表示する専用ウィジェット内で、アプリの設定値から ThemeData を再構築して渡すことで解決しました。
// auth_navigation_setting.dart(_AuthFlowScreen 内)
return Authenticator(
stringResolver: localizedAuthStringResolver,
child: MaterialApp(
theme: lightTheme, // アプリのthemeColorから生成
darkTheme: darkTheme, // アプリのthemeModeから決定
themeMode: appSettings.themeMode == AppThemeMode.auto
? ThemeMode.system
: ...,
builder: (context, child) {
// builder の中で ElevatedButton / TextButton /
// FilledButton / OutlinedButton を全部上書き
final themeWithOverrides = Theme.of(context).copyWith(
elevatedButtonTheme: elevatedOverride,
textButtonTheme: textButtonOverride,
outlinedButtonTheme: ...,
filledButtonTheme: ...,
);
return Scaffold(
appBar: AppBar(title: const Text('ログイン / アカウント作成')),
body: Theme(
data: themeWithOverrides,
child: Builder(
builder: (innerCtx) => Authenticator.builder()(innerCtx, child),
),
),
);
},
...
),
);
白テーマのときは ColorScheme.fromSeed では白→白になってしまうため、primary: Colors.white, onPrimary: Colors.black を明示的に指定する特例処理が必要でした。
学び
Authenticatorはbuilderの外のテーマを見ない。builderの中でTheme.of(context)を取得して.copyWithで上書きするのが正解。4種類のボタンテーマをまとめて上書きすること。
ハマりポイント②:ログイン後にMQTT接続が完了する前に画面が閉じる
問題
ログイン成功 → Authenticatorが閉じる → MQTT接続が間に合っていない → リモコン操作が即座に使えない、という UX 問題が発生しました。
32cecb0 fix(auth): root navigator での認証フロー表示とログイン後の初期化待機を追加
解決策
Amplify.Hub の AuthHubEventType.signedIn を受信した後、MQTT接続が完了するまでポーリングで待機してから画面を閉じるようにしました。
// ログイン成功を検知
_sub = Amplify.Hub.listen(HubChannel.Auth, (event) async {
if (event.type == AuthHubEventType.signedIn) {
// ローディングオーバーレイを表示
showDialog(context: context, barrierDismissible: false,
builder: (_) => const _FullScreenLoading());
// MQTT接続完了まで最大10秒待機
final ready = await _waitForMqttReady(mqtt, const Duration(seconds: 10));
Navigator.of(context, rootNavigator: true).pop(); // loading を閉じる
if (ready) Navigator.of(context, rootNavigator: true).pop(); // 認証画面を閉じる
}
});
Future<bool> _waitForMqttReady(MqttService mqtt, Duration timeout) async {
final end = DateTime.now().add(timeout);
if (mqtt.isConnected) return true;
while (DateTime.now().isBefore(end)) {
if (!mounted) return false;
if (mqtt.isConnected) return true;
await Future.delayed(const Duration(milliseconds: 200));
}
return false;
}
学び
認証後に非同期初期化が必要なサービス(MQTTなど)がある場合、
signedInイベントをトリガーにポーリングで待ってから画面遷移するのが安全。ローディングUIも忘れずに。
ハマりポイント③:AndroidでAWS CRTライブラリがクラッシュする
問題
Androidでのリリースビルドで、AWS IoT CoreへのMQTT接続時にアプリが突然クラッシュしていました。ログには aws_fatal_assert というメッセージが。
5aa4d38 kotlin側によるaws接続時にアプリがクラッシュする問題
(aws_fatal_assertクラッシュは、古いバージョンのCRTライブラリのバグだった)を解消
原因
AWS CRT(Common Runtime)ライブラリの古いバージョンには、EventLoopGroup の初期化前にMQTT接続を試みるとクラッシュするバグがありました。また、Kotlin側のネイティブコードで CRT の初期化保証が不足していました。
解決策
- AWS CRT ライブラリのバージョンを最新に更新
-
companion objectに CRT初期化保証関数を追加 -
connectMqttでEventLoopGroup作成前に初期化を呼び出すよう修正 - ProGuardルールに AWS CRT 関連クラスの保持ルールを追加
// AndroidのKotlinコード(抜粋)
companion object {
private var crtInitialized = false
fun ensureCrtInitialized(context: Context) {
if (!crtInitialized) {
// CRTの初期化を保証
CrtResource.waitForNoResources()
crtInitialized = true
}
}
}
# proguard-rules.pro に追加
-keep class software.amazon.awssdk.crt.** { *; }
-keep class software.amazon.awssdk.iot.** { *; }
学び
AWS系のネイティブSDKをAndroidで使う場合、R8(ProGuard)の圧縮でクラス参照が消えることがある。クラッシュログに
aws_fatal_assertが出たら、まずCRTライブラリのバージョンとProGuardルールを疑う。
ハマりポイント④:iOSコンパイルエラー(fd_set → poll)
問題
iOSビルド時に特定のネットワークライブラリが fd_set というC APIを使っており、iOS SDKのバージョンアップでコンパイルエラーになりました。
09ed320 fd_setをpollに変更(iosコンパイルエラー)
解決策
fd_set を POSIX 標準の poll() に置き換えることで解決。iOS では fd_set のいくつかの使い方が制限されているため、poll() の方がクロスプラットフォーム的に安全です。
学び
iOSでCのネットワークAPIを使うとき、
fd_set系の関数は避けてpoll()を使う方が安全。新しいiOS SDKへのアップデートでサイレントに壊れることがある。
ハマりポイント⑤:通信経路の自動切り替え(mDNS vs MQTT)
設計の背景
スマートホームアプリの宿命として、ユーザーが自宅のWi-Fi内にいる場合と外出先(モバイル回線)からアクセスする場合の両方に対応する必要がありました。
実装したハイブリッド通信
// communication_manager.dart
Future<bool> sendIrSignal(
String hostname,
String? thingname,
String irData,
) async {
final results = await _connectivity.checkConnectivity();
if (results.contains(ConnectivityResult.wifi)) {
// Wi-Fi接続中:まずmDNSでデバイスをローカル探索
final isLocal = await MdnsService.testWifiConnection(hostname);
if (isLocal) {
// 同一LAN内 → HTTP直接送信(低遅延)
return await MdnsService.sendIrData(hostname, irData);
} else {
// フリーWi-Fi等でローカル不可 → MQTT経由
return await _mqttService.publish('$thingname/ir-command', irData);
}
} else {
// モバイル回線 → MQTT一択
return await _mqttService.publish('$thingname/ir-command', irData);
}
}
この設計のハマりポイントは、connectivity_plus の checkConnectivity() が List を返すようになったことです(古いAPIは単一の結果を返していた)。Wi-FiとモバイルデータがListの中に混在して返ってくる場合があり、Wi-Fiを優先する処理を明示的に書く必要がありました。
// Wi-Fiを優先して選択する
ConnectivityResult? connectivityResult;
if (connectivityResults.contains(ConnectivityResult.wifi)) {
connectivityResult = ConnectivityResult.wifi; // Wi-Fi優先
} else if (connectivityResults.contains(ConnectivityResult.mobile)) {
connectivityResult = ConnectivityResult.mobile;
}
学び
connectivity_plusv7以降はcheckConnectivity()がListを返す。複数の接続が同時にある場合(Wi-Fi + モバイルのデュアル接続)を考慮して、明示的に優先順位を決める必要がある。
ハマりポイント⑥:左利きモードとスワイプバックの競合
問題
左利きユーザーのために、ドロワーを左側に配置し、スワイプバックの方向も逆転させる機能を実装しました。しかし、縦スクロールリストのある画面で左端からスワイプすると、スクロールとスワイプバックが競合してバウンドが発生していました。
5a36b56 右利きではスワイプバックがうまくいった。
4c5d05f 左利きでもちゃんとスパイプバックができた
e3f572e fix: 左利きスライドバックの縦スクロール競合とバウンド問題を修正
解決策
カスタム PageRoute(HandedPageRoute)を実装し、利き手設定に応じてスライド方向を制御。ジェスチャーの競合は GestureDetector の behavior と HitTestBehavior を細かく調整することで解決しました。
// handed_page_route.dart(概要)
class HandedPageRoute<T> extends PageRouteBuilder<T> {
final Handedness handedness;
HandedPageRoute({required this.handedness, required Widget child})
: super(
pageBuilder: (_, __, ___) => child,
transitionsBuilder: (_, animation, __, child) {
final begin = handedness == Handedness.left
? const Offset(-1.0, 0.0) // 左から右
: const Offset(1.0, 0.0); // 右から左(標準)
return SlideTransition(
position: animation.drive(
Tween(begin: begin, end: Offset.zero)
.chain(CurveTween(curve: Curves.easeInOut)),
),
child: child,
);
},
);
}
学び
スワイプバックをカスタムする場合、縦スクロールと横スワイプの競合が起きやすい。
DragStartDetailsの速度ベクトル(velocity.pixelsPerSecond)を見て縦方向優勢なら横スワイプを無視する処理が必要。
ハマりポイント⑦:Riverpodの状態整合性チェック
問題
「家」→「部屋」→「リモコン」という階層構造があるため、例えば選択中の家を削除したとき、選択状態が宙に浮いてアプリがバグる問題がありました。
解決策
appStateValidatorProvider という「検査官」プロバイダを専用で作り、ホーム一覧・部屋一覧・選択状態の整合性を常に監視するようにしました。
// main_app.dart(抜粋)
final appStateValidatorProvider = Provider.autoDispose((ref) {
final homes = ref.watch(homeDataProvider);
final selection = ref.watch(appSelectionManagerProvider);
final notifier = ref.read(appSelectionManagerProvider.notifier);
// 家が削除されたら選択をリセット
final isHomeValid = homes.any((h) => h.id == selection.selectedHomeId);
if (!isHomeValid) {
final newHomeId = homes.isNotEmpty ? homes.first.id : null;
Future.microtask(() => notifier.selectHome(newHomeId));
return;
}
// 部屋が削除されたら選択をリセット
final homeId = selection.selectedHomeId!;
final rooms = ref.watch(roomDataProvider(homeId));
final isRoomValid = rooms.any((r) => r.id == selection.selectedRoomId);
if (!isRoomValid) {
final newRoomId = rooms.isNotEmpty ? rooms.first.id : null;
Future.microtask(() => notifier.selectRoom(newRoomId));
}
});
Future.microtask を使っているのは、ウィジェットのビルド中に状態を変更するとエラーになるためです。
学び
階層的なデータ構造では、上位のデータが削除されたとき下位の選択状態が無効になる。Riverpodで「整合性監視プロバイダ」を1つ作って、
Consumerウィジェットの近くで常にwatchしておくと安全。状態更新はFuture.microtask越しに行う。
ハマりポイント⑧:AWS IoT Core のセッション期限切れ(30日)
問題
Cognito のリフレッシュトークンはデフォルトで 30日で期限切れになります。操作中にトークンが失効すると、MQTTの接続が静かに切れてリモコン操作が届かなくなっていました。
f567547 操作中に更新トークンが切れた場合の処理追加(30日毎)
b32a929 wssの自動更新を実装
解決策
setupSecureIoTConnection に onSessionExpired コールバックを追加し、セッション切れ検出時に再ログインダイアログを表示するようにしました。
// main_app.dart(抜粋)
mqttService.setupSecureIoTConnection(
onSessionExpired: () {
final context = navigatorKey.currentContext;
if (context != null) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: const Text('セッション期限切れ'),
content: const Text('再度ログインしてください。'),
actions: [
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await Amplify.Auth.signOut();
},
child: const Text('ログイン画面へ'),
),
],
),
);
}
},
);
また WebSocket(WSS)の署名付きURL更新も自動化し、接続が切れたときに自動再接続する仕組みを実装しました。
学び
AWS IoT Core の WebSocket接続は Cognito の署名付きURL(SigV4)を使うが、この URL には有効期限がある。長時間起動しっぱなしのIoTアプリでは、定期的な WSS URL 更新 と トークン期限切れ検知が必須。
ハマりポイント⑨:IRコマンドのクラッシュ地獄
問題
赤外線の学習・送信まわりは最もトラブルが多い部分でした。コミット履歴を見るだけで当時の苦労が伝わります:
82dce14 クラッシュがなかなかしなくなった。
50cba6e 学習時以外の赤点滅を消したが、白くなるまま。他の部分でクラッシュしている
d0be715 LEDと赤外線は前のやつに戻したから動いた。
原因は色々とあると思うけど、無理に制御しようとフラグを多くしすぎたこと。
コアを分けて制御しているからもっとシンプルでよかった。
主な問題と解決
-
LED制御と赤外線制御を同じコアで処理していた
→ それぞれ別コアに分離し、タスク間通信を整理 -
mDNS送信とMQTT送信でIRのJSONパスが違っていた
88c732e リソースパス修正(/sendACを/sendIRに統一、マイコンにあわせる)→
/sendACと/sendIRが混在していたのを/sendIRに統一 -
エアコンのAC判定が通信経路によって変わっていた
5afbc6e AC判定をモジュール化、mqtt経由でもAC判定を行う→ AC判定ロジックをモジュール化して、mDNS/MQTT両経路で同じ処理に
IRコマンドのJSONフォーマット
{
"commands": [
{
"protocol": 1,
"data": "0xFFE01F",
"bits": 32,
"raw": [9000, 4500, 560, 1690, ...],
"len": 67
}
]
}
標準プロトコル(NEC/Sony等)は data フィールドで、独自プロトコルは raw 配列で送るデュアル構造にしています。
ハマりポイント⑩:AppBarのスクロールエフェクトが邪魔
問題
Material 3 ではスクロールすると AppBar の背景色が変化するデフォルト動作があります。テーマカラーによってはこれが意図しない色になり、見た目が崩れていました。
9a3d42d fix: スクロール時にAppBarの色が変わる問題を修正
解決策
AppBarTheme(
elevation: 0,
scrolledUnderElevation: 0, // スクロール時のelevation変化を無効
surfaceTintColor: Colors.transparent, // tintカラーを透明に
)
scrolledUnderElevation: 0 と surfaceTintColor: Colors.transparent の両方を設定することで完全に無効化できます。
状態管理設計:Riverpod でどう整理したか
プロバイダの責務分け
appSelectionManagerProvider ← 家・部屋の「選択」状態のみ
↑ watch
appStateValidatorProvider ← 整合性チェック(家/部屋削除後の自動リセット)
homeDataProvider ← 家一覧(DBから読み込み)
homeListProvider ← 家一覧のCRUD操作
roomDataProvider(homeId) ← 指定した家の部屋一覧
remoteListProvider ← 指定した部屋のリモコン一覧
appSettingsProvider ← テーマ色・テーマモード・利き手設定
communicationManagerProvider ← CommunicationManager(MqttServiceを注入)
mqttServiceProvider ← MqttService(シングルトン)
MqttService をシングルトンにして communicationManagerProvider に注入しているのが重要なポイントです。MQTTは接続を使い回さないとオーバーヘッドが大きいため、アプリ全体で1つのインスタンスを共有しています。
リリースにあたって気づいたこと
1. Androidのリリースビルドは別物
flutter run でのデバッグビルドと flutter build apk --release の挙動は全然違います。特にR8による難読化・圧縮でAWS系のクラス参照が消え、クラッシュが出ました。必ずリリースビルドで動作確認を。
2. iOSのBLE権限は説明文が必要
Info.plist に NSBluetoothAlwaysUsageDescription を書かないとリジェクトされます。BLEを使う理由を明確に英語で書く必要があります。
3. Amplify の設定ファイルは .gitignore に
amplify_configuration.dart には Cognito の Pool ID などが含まれます。うっかりコミットしないよう .gitignore に追加を。本プロジェクトでは envied パッケージで環境変数を安全に管理しています。
// env.dart
@Envied(path: '.env')
abstract class Env {
@EnviedField(varName: 'API_ENDPOINT', obfuscate: true)
static final String apiEndpoint = _Env.apiEndpoint;
}
4. mDNS はデバイスによって検出できないことがある
一部のルーター(特に企業向け)は mDNS パケットをブロックします。必ずMQTTフォールバックを実装しておくこと。
5. スケジュール機能はiOSとAndroidで完全に別実装
iOSは flutter_local_notifications のバックグラウンド起動機能、AndroidはAlarmManager経由のHTTP送信と、実装が全く異なります。Flutterで「一度書けば全プラットフォーム」は嘘です。
まとめ
924コミット、1年弱の開発を振り返って一番の学びは**「IoTは通信が全て複雑になる」**ということです。
通信経路の自動切り替え・認証セッション管理・MQTTの接続維持・BLEのデバイス登録フロー、それぞれが独立した難所でした。
Flutterは確かにクロスプラットフォームですが、ネイティブ側(Kotlin/Swift)の知識がある程度ないと、AWS CRTのクラッシュやiOSのコンパイルエラーには対処できませんでした。
それでも、Flutter + Riverpod の組み合わせはこれだけ複雑な状態管理を持つアプリでもきれいに整理できるので、IoTアプリにも十分おすすめできます。