Flutter が終わり、OS が始まる場所
カメラも通知も生体認証も、Dart だけでは書けない。plugin が何を肩代わりし、その下で何が起きているのか。境界を越える仕組みと、越えた先で必要になる設定・権限・ライフサイクルの扱いを整理する。連載 II の最終回。
ここまでの 7 回で扱ってきたもの——レイアウト、入力、Hooks、ナビゲーション、テーマと多言語化、 モデル、ローカル保存——は、すべて Dart の世界の中で完結していました。書いたコードがそのまま どのプラットフォームでも動く領域です。
ところが、カメラで撮る、通知を出す、生体認証で守る、ホーム画面にウィジェットを置く——このあたり から様子が変わります。Dart だけでは書けない。そこには境界があり、向こう側には iOS と Android それぞれの OS がいます。連載の最後は、この境界の話です。
§ 01PACKAGEpackage と plugin は何が違うか
pub.dev で配られているものは、大きく二種類あります。
package は Dart だけで書かれたライブラリです。intl や freezed のように、計算や変換や
データ構造を提供します。Dart が動く場所ならどこでも動きます。
plugin は、Dart の API の裏に各プラットフォームのネイティブ実装を同梱したものです。
image_picker なら、iOS 側に Swift の実装、Android 側に Kotlin の実装が入っています。あなたが
呼ぶのは Dart の関数ですが、実際に写真を選ぶ画面を出しているのは OS です。
この違いは pubspec.yaml の中身に現れます。plugin 側の pubspec.yaml には、どのプラットフォーム
にどの実装クラスが対応するかを書いた flutter: plugin: の節があります。使う側が意識することは
あまりありませんが、「これは plugin だから、Web やデスクトップでは動かないかもしれない」と
気づけるかどうかは実務で効きます1大きな plugin は「federated plugin」として、インターフェースを定める package と、プラットフォームごとの実装 package に分かれていることが多い。image_picker_ios のような名前のパッケージが依存に現れるのはそのため。対応プラットフォームは pub.dev の各ページで確認できる。。
§ 02CHANNEL境界を越える仕組み
では、Dart からネイティブの実装をどう呼んでいるのか。仕組みは MethodChannel です。
const channel = MethodChannel('com.example.app/battery');final level = await channel.invokeMethod<int>('getBatteryLevel');
やっていることは単純で、チャンネル名とメソッド名と引数を、ネイティブ側へ送っているだけです。 向こう側では、同じチャンネル名で待ち受けているコードが呼ばれ、結果が返ってきます。
ここから三つの性質が出てきます。
必ず非同期です。 境界を越える往復なので、await が要ります。同期的に値を取ることはできません。
渡せる値が限られます。 数値・文字列・真偽値・リスト・マップといった、シリアライズできる
ものだけです。Dart のオブジェクトをそのまま渡すことはできないので、Map に詰め替えます——
前回の Freezed の toJson が、ここでも使えます。
失敗が二種類あります。 ネイティブ側が投げたエラーは PlatformException として届きます。
一方、チャンネルの受け手がいない場合は MissingPluginException です。後者は「plugin を
追加したのにホットリスタートしかしていない」ときの定番で、アプリを完全に再起動すれば直ります。
なお、連続して届くもの(センサーの値、接続状態の変化)には EventChannel があり、Dart 側では
Stream として受け取ります。
そして普段、この章のコードを自分で書くことはありません。 plugin がこの往復を丸ごと隠して
くれているからです。知っておく価値があるのは、隠されているものが何かを知っていれば、
MissingPluginException も「なぜ非同期なのか」も謎ではなくなるからです。
§ 03SETUPDart だけでは終わらない設定
plugin を使うときに最初につまずくのが、pub add しただけでは動かないことです。境界の向こう側
は OS の領分なので、OS の作法に従った設定が要ります。
iOS なら Info.plist に、なぜその機能が要るのかをユーザーへ説明する文言を書きます。これが
無いと、権限を要求した瞬間にアプリが落ちます。
<key>NSCameraUsageDescription</key><string>商品の写真を登録するためにカメラを使用します</string>
Android なら AndroidManifest.xml に権限を宣言します。
<uses-permission android:name="android.permission.CAMERA" />
ほかにも、plugin が要求する最低 OS バージョン(minSdkVersion や iOS Deployment Target)の
引き上げを求められることがあります。plugin の README にプラットフォームごとの設定手順が
書いてあるのは、このためです。Dart 側のコードが正しくても、ここが抜けていると動きません。
§ 04INIT初期化の順序
連載 I の初回で WidgetsFlutterBinding.ensureInitialized() を扱いました。あれが必要になる理由が、
まさにここです。
plugin との通信路(チャンネル)は、Flutter とネイティブをつなぐ足場が用意されて初めて使え
ます。だから runApp() より前にプラグインへ触れるなら、その前に一度だけ足場を立てておく必要が
あります。
Future<void> main() async {WidgetsFlutterBinding.ensureInitialized(); // これが無いと plugin を呼べないawait Firebase.initializeApp();runApp(const App());}
前回触れたデスクトップ向けの sqflite_common_ffi の初期化も、同じ場所に置く処理でした。
「起動時にプラットフォームの都合を片付けておく」のが main() の役割の一つです。
ただし、ここに置いた初期化はすべて最初のフレームを遅らせます。連載 I で書いたとおり、 起動時に本当に必要なものだけを待つ、という判断はここでも効きます。
§ 05PERMISSION権限は拒否されうる
境界の向こう側で、もう一つ Dart の世界と勝手が違うのが権限です。カメラも通知も位置情報も、 ユーザーが許可しなければ使えません。
final status = await Permission.camera.request();if (status.isGranted) {// 使える} else if (status.isPermanentlyDenied) {// 二度と聞けない。設定アプリへ誘導するしかないawait openAppSettings();}
設計上の要点は一つです。拒否は異常系ではなく、正常系の一つとして扱うこと。
ユーザーには断る権利があり、一度断られたら(permanentlyDenied)アプリからは二度と聞けません。
残された道は設定アプリへ誘導することだけです。したがって画面は、「権限がある前提」で作って例外で
落とすのではなく、権限が無い状態でも意味のある表示になるように作ります。
さらに、iOS と Android で挙動が違います。要求できる回数、初回と二回目の扱い、通知権限の要不要 ——ここは plugin が吸収しきれない部分なので、両方の実機で確かめるしかありません。
§ 06LIFECYCLEアプリが背面に回るとき
境界の話の最後は、アプリ自身の状態です。ユーザーはいつでもホームボタンを押し、別のアプリへ 移り、また戻ってきます。OS はそれを状態変化として通知します。
resumed… 前面にあり、操作を受け付けているinactive… 前面だが操作を受け付けない(通話着信、通知センターを開いた等)hidden/paused… 背面に回ったdetached… Flutter エンジンから切り離された
受け取り方は AppLifecycleListener が簡潔です2従来は WidgetsBindingObserver を State に mixin し、didChangeAppLifecycleState を実装する形だった。AppLifecycleListener(Flutter 3.13 以降)は登録と解除が対になっていて扱いやすく、onResume などの用途別コールバックを直接渡せる。。
final listener = useMemoized(() => AppLifecycleListener(onResume: () => ref.read(permissionProvider.notifier).refresh(),),const [],);useEffect(() => listener.dispose, const []);
なぜこれが権限とセットで出てくるか。 さきほどの openAppSettings() でユーザーを設定アプリへ
送った場合、許可されたかどうかをアプリは知りません。戻ってきた瞬間(onResume)に確認し直す
——これが定番の組み合わせです。
同じ理由で、背面に回るタイミングは保存の機会でもあります。編集途中の内容を書き出しておけば、 そのまま OS にプロセスを終了されても失われません。
§ 07BOUNDARY境界をコードのどこに引くか
最後に構造の話を、前回と同じ形で。プラットフォームの都合を、画面に漏らさないことです。
abstract interface class ImageSource {Future<Uint8List?> pickPhoto();}
画面はこの抽象だけを知り、image_picker も権限も Info.plist も知りません。利点は前回と同じ
三つです。実装を差し替えられる、テストで偽物を注入できる(実機もカメラも要らない)、
そしてプラットフォーム差を実装の内側に閉じ込められる。
null を返しうることを型に出しているのも意図的です。ユーザーが選択をキャンセルした、権限が
無い——境界の向こうでは、失敗と拒否が日常です。それを戻り値の形で呼び出し側に伝えておくと、
画面側が自然と「取れなかったとき」を書くことになります。
§ 08SUMMARY境界を読む地図
- package は Dart だけ、plugin はネイティブ実装を同梱したもの
- 境界を越えるのは MethodChannel。非同期で、渡せるのはシリアライズできる値だけ
MissingPluginExceptionは受け手がいない合図。多くは完全な再起動で直るpub addでは終わらない。Info.plistとAndroidManifest.xmlが要る- plugin を呼ぶ前に
ensureInitialized()。ただし起動を遅らせる自覚を持って - 拒否は正常系。
permanentlyDeniedの先は設定アプリへの誘導しかない - 設定から戻った時(
onResume)に権限を確認し直す。背面に回る時は保存の機会 - 境界は抽象の裏に隠す。差し替えとテストのしやすさは、前回の Repository と同じ理屈
§ 09SERIES連載 II を終えて
これで 8 回が一周しました。制約とレイアウト → 入力とダイアログ → Hooks → ナビゲーション → テーマと多言語化 → モデル → ローカル保存 → プラットフォーム境界。
連載 I が「画面のコードを読めるようになる」ための道具だったのに対して、II は「一つのアプリを 作りきる」ために要るものを並べました。この 15 回で、Flutter アプリのソースを開いたときに、 どのファイルが何を担っているかは見当がつくはずです。
ここから先は、基礎の順序立てではなく、個別のテーマを掘り下げていきます。実際のアプリで設計判断が 必要になった箇所——同期、課金、ウィジェット連携、テスト戦略——を、一つずつ扱っていく予定です。
- [1] 大きな plugin は「federated plugin」として、インターフェースを定める package と、プラットフォームごとの実装 package に分かれていることが多い。
image_picker_iosのような名前のパッケージが依存に現れるのはそのため。対応プラットフォームは pub.dev の各ページで確認できる。 ↩ - [2] 従来は
WidgetsBindingObserverをStateに mixin し、didChangeAppLifecycleStateを実装する形だった。AppLifecycleListener(Flutter 3.13 以降)は登録と解除が対になっていて扱いやすく、onResumeなどの用途別コールバックを直接渡せる。 ↩