Inlayの状態管理

ネイティブコードと1つ以上のFlutterエンジンの間で状態を共有します。

Inlayは、すべてのFlutterエンジンとネイティブコードの間で同期が保たれる共有キーバリューストレージ層を 提供します。ある場所で行った変更は、他のすべての利用側へ自動的にブロードキャストされます。Flutter側では、 生成されたストアを直接使うことも、オプションのInlayCubitヘルパーと組み合わせてリアクティブなUIパターンに することもできます。ネイティブコード(iOS/Android)は、NativeStorageScopeを通じて同じデータに アクセスします。

各Flutterエンジンはそれぞれ独自のDart isolate(独自のメモリ)で動作します。そのため、エンジン間の同期は 共有メモリではなくプラットフォーム層を経由します。isolateモデルについては コアコンセプトを参照してください。

アーキテクチャの概要

すべての読み書きは、プラットフォーム側のインメモリ辞書を経由します。いずれかの利用側が値を書き込むと、 他のすべての利用側に通知が届きます。唯一の例外は書き込んだ本人で、自分自身の書き込みの通知は受け取りません。 これにより更新のループを防いでいます。

ストアの定義

ストアはアノテーション付きの普通のDartクラスです。コードジェネレーターが、getter、setter、 リアクティブなストリーム、スナップショットのサポートを備えた型付きラッパーを生成します。

@InlayStore(key: 'sounds_notifications')
class SoundsNotificationsStore {
  const SoundsNotificationsStore({
    @InlayStoreKey() required this.contactId,
    this.mute = false,
    this.showPreviews = true,
    this.sound = 'Default',
    this.vibration = VibrationLevel.normal,
  });

  final String contactId;
  final bool mute;
  final bool showPreviews;
  final String sound;
  final VibrationLevel vibration;
}

アノテーション

アノテーション用途
@InlayStore(key: 'prefix')クラスをストア定義としてマークします。keyはストレージキーに使うプレフィックスを設定します。
@InlayStoreKey()フィールドをキーセグメントとしてマークします。データのスコープを決めるストレージパスの一部になりますが、それ自体は値として保存されません。

サポートされるフィールドの型

ストアの値フィールドがサポートする型:

  • プリミティブ:bool、int、double、String
  • enum
  • 複合型:List<T>、Map<K,V>、データクラス、およびこれらをネストした組み合わせ

プリミティブはプレーンな文字列として保存されます。enumはどのプラットフォームでも名前ではなく インデックス(0、1、...)で保存されます。そのため、enumの値の順序はスキーマフィンガープリントの 一部になっています。複合型はJSONにエンコードされて1つのキーバリューエントリーに保存され、ルートと同じ シリアライズのパイプラインが再利用されます。

non-nullableな複合型フィールドには、デフォルト値が必須です(例:this.tags = const [])。 nullableな複合型フィールドは、未設定の場合nullになります。

ストレージキー

値は{prefix}/{keyField}/{fieldName}というパターンのキーで保存されます:

sounds_notifications/abc-123/mute
sounds_notifications/abc-123/showPreviews
sounds_notifications/abc-123/sound

つまり、連絡先ごと(あるいはスコープを持つ任意のエンティティごと)に、それぞれ独自のキーのセットを持ちます。

生成される出力

上記のストアに対して、ジェネレーターは次のものを生成します:

  • SoundsNotificationsStore(Dart) - 型付きの非同期getter/setter(例:getMute()、setMute(bool))、 リアクティブなstream、スナップショットのサポートを備えたラッパー。
  • SoundsNotificationsStoreSnapshot(Dart) - すべてのフィールド値を保持するイミュータブルなデータクラス。 copyWithメソッドを持ちます。

さらに、SwiftとKotlin向けにも型付きのストアラッパーが生成されます (ネイティブからの利用を参照)。

Flutterからストアを使う

ストアを直接使う

生成されたストアは直接使えます。Cubitは必要ありません:

final store = SoundsNotificationsStore(
  KeyValueStorage.instance,
  contactId: 'abc-123',
);

// Read
final isMuted = await store.getMute();

// Write
await store.setMute(true);

// Read all fields at once
final snapshot = await store.getSnapshot();
print('Sound: ${snapshot.sound}, Muted: ${snapshot.mute}');

// React to changes from other engines or native code
store.stream.listen((snapshot) {
  print('External change: mute=${snapshot.mute}');
});

InlayCubitと組み合わせる(オプション)

InlayCubitは、blocライブラリの上に構築された、オプションの便利なレイヤーです。 次の処理によって、ストアをリアクティブなUIパターンにつなげます:

  1. ストアから初期スナップショットを自動的に読み込みます。
  2. エンジン間の同期のため、ストアのstreamを購読します。
  3. 状態が変わるたびに、変更をストアに永続化します。プリミティブとenumのフィールドは変更があった場合にのみ 書き込まれ、複合型のフィールド(リスト、マップ、データクラス)は変更のたびに書き直されます。

インスタンスを作成した後、init()を呼び出して初期スナップショットを読み込み、エンジン間の更新の監視を 開始します。Dartのカスケード演算子を使ってMyCubit(store)..init()と書くのが慣用的です。

チームがすでにFlutterでbloc/Cubitを使っているなら、自然に馴染みます。そうでなければ、これは完全に 飛ばしてストアを直接使ってください。ストア層とストレージ層は独立して動作します。

まずCubitを定義します:

class SoundsNotificationsCubit
    extends InlayCubit<SoundsNotificationsStoreSnapshot> {
  SoundsNotificationsCubit(super.store);

  void toggleMute() {
    if (state case InlayStateReady(data: final current)) {
      emit(InlayStateReady(current.copyWith(mute: !current.mute)));
    }
  }

  void changeSound(String sound) {
    if (state case InlayStateReady(data: final current)) {
      emit(InlayStateReady(current.copyWith(sound: sound)));
    }
  }
}

次に、それをウィジェットで使います:

class SoundsNotificationsScreen extends StatelessWidget {
  const SoundsNotificationsScreen({super.key, required this.contactId});
  final String contactId;

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => SoundsNotificationsCubit(
        SoundsNotificationsStore(
          KeyValueStorage.instance,
          contactId: contactId,
        ),
      )..init(),
      child: BlocBuilder<SoundsNotificationsCubit,
          InlayState<SoundsNotificationsStoreSnapshot>>(
        builder: (context, state) => switch (state) {
          InlayStateLoading() =>
            const Center(child: CircularProgressIndicator()),
          InlayStateReady(:final data) => ListView(
            children: [
              SwitchListTile(
                title: const Text('Mute'),
                value: data.mute,
                onChanged: (_) =>
                    context.read<SoundsNotificationsCubit>().toggleMute(),
              ),
            ],
          ),
        },
      ),
    );
  }
}

InlayStateは2つのバリアントを持つsealed型です:

バリアント説明
InlayStateLoading初期スナップショットをストアから読み込んでいる途中です。
InlayStateReady(data)スナップショットの読み込みが完了し、利用可能です。

1つのCubitで複数のストアを監視する必要がある場合は、InlayStoreObserver mixinを使ってください。

ネイティブコードからストアを使う

ネイティブコードは、NativeStorageScopeを通じて同じストレージにアクセスします。スコープを作成し、値を 読み書きし、必要に応じて変更を監視します。ジェネレーターはSwiftとKotlinの両方に向けて、型付きプロパティと、 オブザーバーのコールバックを絞り込むためのcontainsChangesヘルパーを備えた型付きストアラッパーを生成します。 そのため、キー文字列をハードコードする必要は一切ありません。

let scope = KeyValueStorageImpl.shared.createScope()
let store = SoundsNotificationsStore(storage: scope, contactId: "abc-123")

// Read (typed)
let isMuted = store.mute

// Write (automatically broadcast to Flutter engines and other scopes)
store.mute = true

// Observe changes made by Flutter or other native scopes
scope.startObserving { entries in
    if store.containsChanges(in: entries) {
        print("mute = \(store.mute), sound = \(store.sound)")
    }
}

// Stop observing and clean up
scope.stopObserving()
scope.dispose()

KeyValueStorage(低レベルのDart API)

KeyValueStorageは、生成されたストアの下で動いているDartのシングルトンです。ストアをきちんと定義するほどでは ないアドホックな値には、これを直接使ってください。runAppの前に、エンジンごとに一度だけ初期化してください:

WidgetsFlutterBinding.ensureInitialized();
await KeyValueStorage.instance.init();

API

// String
await KeyValueStorage.instance.putString('key', 'value');
final value = await KeyValueStorage.instance.getString('key');

// Bool
await KeyValueStorage.instance.putBool('key', value: true);
final flag = await KeyValueStorage.instance.getBool('key');

// JSON
await KeyValueStorage.instance.putJson('key', {'nested': 'data'});
final json = await KeyValueStorage.instance.getJson('key');

// Bulk operations
await KeyValueStorage.instance.putAll([
  StorageEntry(key: 'a', value: '1'),
  StorageEntry(key: 'b', value: '2'),
]);
final entries = await KeyValueStorage.instance.getByPrefix('sounds_');

// Reactive stream - emits when OTHER writers change values, plus one
// full-store event when the app returns to the foreground
KeyValueStorage.instance.stream.listen((entries) {
  // handle external changes
});

エンジン間の同期

値が変更されたときの流れは次のとおりです:

  1. 利用側(Flutterのストア、Cubit、またはネイティブのスコープ)が値を書き込みます。書き込みは Pigeonチャネルを介してプラットフォームを経由します。
  2. プラットフォーム側のインメモリストアが更新され、書き込んだ本人を除く登録済みのすべての利用側に通知します。
  3. 他の各FlutterエンジンのKeyValueStorage.streamが変更をemitします。生成されたストアを使っている場合は、 そのstreamが関係するキーに絞り込んで新しいスナップショットをemitします。InlayCubitを使っている場合は、 Cubitが自動的に状態を更新します。
  4. 登録済みのNativeStorageScopeのオブザーバーが、コールバックを通じて変更を受け取ります。

スレッドセーフティ

懸念点解決策
プラットフォームのストア(iOS)バリア書き込みを使った並行DispatchQueue
プラットフォームのストア(Android)読み書きロックにより、バッチ書き込みがスナップショットの読み取りに対してアトミックになる(iOSと同じ挙動)。オブザーバーのレジストリは同期化
Dart isolate各エンジンはそれぞれ独立したisolate。更新はストリームを介して非同期に配信される
自己通知各書き込み元にIDが付与され、通知は発信元をスキップする
競合最後の書き込みが優先(last-write-wins)。writeSnapshotはプリミティブとenumのフィールドを前の状態と比較し、変更されたものだけを書き込む(複合型のフィールドは常に書き込まれる)
アプリのバックグラウンド化KeyValueStorageはAppLifecycleState.resumedで完全な同期を行い、エンジンが非アクティブだった間の変更を取り込む

テーマとロケールの伝播

ネイティブコンテナごとにエンジンを持つということは、各コンテナがまっさらな状態で起動するということです。 ネイティブからpushされたFlutter画面は、ダークモードやアプリ内の言語設定といったアプリレベルの設定を、 別のコンテナのウィジェットの状態から引き継げません。InlayAppearanceはこの両方を前述のストレージ層に 保持するため、すべてのエンジン(後から作成されるものも含む)が起動時に現在の値を読み取り、変更にも リアルタイムに追従します。

ネイティブホストは、型付きのAPIで設定を操作します:

InlayAppearance.shared.themeMode = .dark
InlayAppearance.shared.localeLanguageTag = "pl-PL"
InlayAppearance.shared.localeLanguageTag = nil // follow the system again

Dartのエントリーポイントでは、ストレージの後にこれを初期化し、変更があるとアプリを再ビルドします:

await KeyValueStorage.instance.init();
await InlayAppearance.instance.init();

runApp(
  ListenableBuilder(
    listenable: InlayAppearance.instance,
    builder: (context, _) => MaterialApp.router(
      themeMode: InlayAppearance.instance.themeMode,
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      locale: InlayAppearance.instance.locale,
      // ...
    ),
  ),
);

Flutterのコードから設定を変更することもできます(InlayAppearance.instance.setThemeMode(...)、 setLocale(...))。その変更も同じ仕組みで、他のすべてのエンジンとネイティブのスコープに届きます。 runInlayDialogで起動したダイアログは、明示的にthemeModeを渡さない限り、自動的にInlayAppearanceに 従います。

システムのダークモードは、もともとFlutterに自動で伝わります。エンジンがOSの設定に従うためです。 InlayAppearanceは、アプリ内で上書きするケース、つまりホストアプリが独自のテーマ切り替えや言語選択を持ち、 すべてのFlutter画面がそれに従う必要がある場合のためのものです。

使い分け

アプローチ型安全性エンジン間の同期最適な用途
@InlayStore + InlayCubit完全(型付きフィールド、スナップショット)自動ストレージと結びついたリアクティブな状態が必要なFlutter画面
@InlayStore(直接利用)完全手動(streamを監視)blocを使わないFlutterコード
KeyValueStorage(生のAPI)手動(文字列のキー/値)手動(streamを監視)アドホックな値、単発の読み書き
NativeStorageScope + 生成されたストア完全(型付きプロパティ)startObserving + containsChanges経由ネイティブコード(iOS/Android)

プライバシーポリシー

このページの内容