はじめに

こんにちは、FANTECH本部 Fanbase事業部でモバイルアプリエンジニアをしている成尾 嘉貴(@naruogram)です。私たちのプロダクトでは、ウィジェット機能を新たにリリースしました。
ウィジェット機能とは、アプリを起動しなくてもOSのホーム画面上で情報を表示できる機能のことです。
しかし、Flutter では「ウィジェット機能」を直接サポートしておらず、各OS機能に依存するため、Flutter 単体での実現は困難です。
本記事では、Flutterアプリから各OSのウィジェットを活用するための実践的なノウハウをご紹介します。

目次

  1. Flutterとネイティブウィジェットの設計について
  2. Flutterアプリから各OSのウィジェットへのデータの受け渡しについて
  3. OS別の実装について
  4. ウィジェットの制限事項と対策
  5. home_widgetパッケージの活用
  6. まとめ

Flutterとネイティブウィジェットの設計について

FlutterアプリとiOS/Androidのネイティブウィジェット(WidgetKitおよびGlance)の連携における設計方針を紹介します。

Flow of passing data from Flutter to native widgets

Flutterアプリ側の役割(本体アプリ)

Flutterアプリでは、ウィジェットに必要なデータを管理し、任意タイミングでネイティブウィジェットを更新する役割を担います。

主な責務

  • ユーザーの操作に応じて、ウィジェットに表示するデータを作成・編集・削除
  • ネイティブウィジェットへの更新指示

ネイティブウィジェット側の役割(iOS/Android)

Flutterアプリから共有されたデータをもとに、ウィジェットUIを構築・更新する役割を担います。

主な責務

  • Flutterアプリから提供されたデータをもとに、OSの仕様に沿ってウィジェットUIをネイティブコードで描画・更新

Flutterアプリから各OSのウィジェットへのデータの受け渡し

Flutterで作成したデータを、iOSやAndroidのホーム画面ウィジェット(iOS: WidgetKit、Android:
Glance)へ表示するには、ウィジェット側でそのデータを読み取れる仕組みを実装する必要があります。ただし、各OSには制約があり、注意が必要です。

iOSウィジェットの注意点

  1. ウィジェットからFlutterアプリへの直接通信はできない
  2. App Groups + UserDefaultsを使ったデータ共有が必要

iOSのウィジェット(WidgetKit)はアプリ本体とは別プロセスで動作するため、Flutterアプリとの直接的なデータ受け渡しはできません。

そのため、共有データをApp Groupsを通じて保存し、UserDefaultsを使って両者が読み書きできるようにする必要があります。

Flutter標準のshared_preferencesで保存した値は通常のUserDefaults領域に保存されるため、ウィジェットからは読み取ることができません。MethodChannelを使い、ネイティブコード経由でApp Group対応したUserDefaultsへの保存を行う必要があります。

// UserDefaultsでApp Group領域にアクセスする
UserDefaults(suiteName: "設定したApp GroupsのId")

画像の受け渡しに関する注意点

前提としてUserDefaultsやSharedPreferencesに直接画像を保存するのは、パフォーマンスの低下が懸念されるため、推奨されていません。

画像をウィジェットで表示する場合、画像を保存したファイルパスをローカルに保持しておくことが必要です。
ただし、単にファイルパスを保存するだけでは不十分で、Android・iOSそれぞれのプラットフォームで適切な保存先に画像を保存しておかないと、ネイティブ側で画像が表示されない可能性があります。

Android側

/data/user/0/<パッケージ名>/のようなAndroidのアプリ専用ディレクトリに保存する必要があります。

この保存先パスは、以下のどちらかの方法で取得・利用できます。

  • Flutter側で取得する(path_providerを使用)
    Flutterではpath_providerパッケージを使って、内部ストレージのパスを取得できます。
    一般的な取得方法は次の通りです:

    • getApplicationSupportDirectory()
    • getApplicationDocumentsDirectory()
  • ネイティブ(Kotlin)側で取得する(MethodChannelを使用)
    ネイティブ側では、以下のようにしてアプリの内部ファイルディレクトリを取得できます:

    • context.filesDir.path

iOS側

FlutterからウィジェットなどのiOSネイティブ機能に画像等のファイルを渡す場合、App Group(アプリグループ)を使ってファイルを共有する必要があります。

ただし、Flutterから直接App Groupのディレクトリへアクセスすることはできません。

そのため、MethodChannelを使って、Swift(iOSネイティブ)側からApp Groupの保存パスを取得する必要があります。

Swiftでは、次の方法でApp Groupのパスを取得できます:

FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "設定したApp GroupsのId")

【Android】Flutter×Jetpack Glanceでウィジェットを作成する

Jetpack Glanceとは、アプリウィジェットをJetpack Composeで作成することができるフレームワークです。

FlutterからJetpack Glanceのウィジェットを更新・作成するには下記のステップが必要です。

Flow to update Android widgets from Flutter

1. Flutter側からAndroidウィジェット更新指示を送信

Flutterアプリ側ではユーザー操作等に応じて、MethodChannelを使用し、Androidウィジェットに向けて再描画指示を送信します。この際、対象となるウィジェットのReceiverクラスはAndroidManifestファイルで事前に定義されている必要があります。

val className = call.argument<String>("className")
val ids = AppWidgetManager.getInstance(context)
    .getAppWidgetIds(ComponentName(context, ${context.packageName}.$className))
val intent = Intent(context, widgetClass).apply {
    action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
    putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
}
context.sendBroadcast(intent)

Receiverの実装

class SampleWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget = SampleWidget()
}

2. StateDefinition の設計(DataStore による状態保存・復元)

Glance ウィジェットでは、各ウィジェットインスタンスごとの一意な状態を安全かつ永続的に管理する必要があります。本実装では、DataStore を用いたStateDefinition パターンを採用します。

object SampleWidgetModelStateDefinition : GlanceStateDefinition<SampleWidgetModel> {
    override suspend fun getDataStore(context: Context, fileKey: String): DataStore<SampleWidgetModel> {
        return DataStoreFactory.create(
            serializer = SampleWidgetModelSerializer(),
            produceFile = { context.dataStoreFile(fileKey) }
        )
    }
}

private class SampleWidgetModelSerializer : Serializer<SampleWidgetModel> {
    override val defaultValue = SampleWidgetModel()
    
    override suspend fun readFrom(input: InputStream): SampleWidgetModel {
        return Json.decodeFromString(input.readBytes().decodeToString())
    }
    
    override suspend fun writeTo(t: SampleWidgetModel, output: OutputStream) {
        output.write(Json.encodeToString(t).encodeToByteArray())
    }
}

3. Glance ウィジェット本体の実装

各ウィジェットが画面上に表示すべき内容の取得・描画処理を行います。Flutter 側で更新・保存されたデータを、SharedPreferences等を通じて受信・反映できます。

class SampleWidget : GlanceAppWidget() {
    override val stateDefinition = SampleWidgetModelStateDefinition
    
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            val state = currentState<SampleWidgetModel>()
            val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
            val json = prefs.getString(PREF_KEY_WIDGETS, null)
            
            // JSONから identifier に一致するモデルを取得
            val model = parseAndFindModel(json, state.id)
            
            Text(model?.title ?: "No data")
        }
    }
}

【iOS】Flutter×WidgetKitでウィジェットを作成する

WidgetKitは、iOS 14以降で利用可能なホーム画面ウィジェットを作成するためのフレームワークです。

FlutterからWidgetKitのウィジェットを更新・作成するには下記のステップが必要です。

Flow to update iOS widgets from Flutter

1. Flutter側からiOSウィジェット更新指示を送信

Flutter側からMethodChannel経由でネイティブコードを呼び出し、WidgetCenterを通じてウィジェットを更新します。

// ネイティブ側でWidgetCenterを使用して更新
WidgetCenter.shared.reloadTimelines(ofKind: "HomeWidget")

2. Timeline Provider の設計(タイムラインベースの更新)

WidgetKitの中核となるTimeline Providerパターンは、ウィジェットの更新タイミングとコンテンツを制御します。

下記のコードでは15分に一回更新する予約を行っています。

struct SampleTimelineProvider: AppIntentTimelineProvider {
    func timeline(for configuration: WidgetIntent, in context: Context) async -> Timeline<WidgetEntry> {
        // App Groupsから共有データを取得
        let data = UserDefaults(suiteName: "APP_GROUP_ID")?.data(forKey: "widget_data")
        let entry = WidgetEntry(date: Date(), data: data)

        // 更新ポリシーの設定
        let updatePolicy: TimelineReloadPolicy = .after(Date().addingTimeInterval(15 * 60))
        return Timeline(entries: [entry], policy: updatePolicy)
    }
}

Timeline Providerでは以下の更新ポリシーが選択可能:

  • .never : 更新しない
  • .after(date) : 指定時刻以降に更新
  • .atEnd : タイムラインの最後のエントリ表示後に更新

3. WidgetKit 実装の構造

@main
struct SampleWidget: Widget {
    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: "SampleWidget",
            provider: SampleTimelineProvider()
        ) { entry in
            // SwiftUIでウィジェットUIを構築
            Text(entry.data?.title ?? "No data")
        }
    }
}

ウィジェットの制限事項と対策

iOS WidgetKitのメモリ制限

iOSのウィジェットには30MBのメモリ制限があり、この制限を超えるとウィジェットがJetsam(メモリ不足によるプロセス終了)されます

画像を使用する場合は特に注意が必要で、ウィジェットで画像を表示する際、元の画像サイズをそのまま使用すると簡単に30MBの制限を超えてしまいます。

そのため、Flutter側で画像を事前にリサイズすることが必要になるケースがあります。

OS による更新頻度の制限

iOS の更新制限

Appleの公式ドキュメントによると、WidgetKitには「バジェット」と呼ばれる更新回数の制限があります。

バジェットは24時間単位で管理されますが、ユーザーの使用パターンに合わせて調整されたり、システムがバッテリー状況やユーザーの行動に基づいて自動的に最適化されます。

過度な更新は制限され、適切なウィジェット表示ができない場合があり、最新情報(15分に1回を超える頻度)を常に監視するようなウィジェットは作成するのが困難です。

Android の更新制限

Androidの公式ドキュメントによると、updatePeriodMillisは30分(1800000ミリ秒)未満の値に制限されているため、30分に1回の更新しかできません。

そのため、より頻繁な更新が必要な場合は、updatePeriodMillisを0に設定し、WorkManagerを使用する必要があります。

home_widgetパッケージの活用

home_widgetパッケージについて

home_widgetパッケージを使用すると、前述のネイティブコードの多くを自動化できます。

このパッケージが提供する機能:

  • iOS: App Groups経由でのUserDefaultsへのデータ保存とWidgetCenterの更新
  • Android: SharedPreferencesへのデータ保存とAppWidgetManagerを使った更新通知

基本的な使用例:

// 初期設定
await HomeWidget.setAppGroupId('group.com.example.app');

// データ保存
await HomeWidget.saveWidgetData('key', 'value');

// ウィジェット更新
await HomeWidget.updateWidget(
  name: 'AndroidWidgetName',
  iOSName: 'iOSWidgetName',
);

このパッケージを使用することで、MethodChannelの実装やプラットフォーム別の処理を意識することなく、シンプルなAPIでウィジェット機能を実装できます。

まとめ

本記事では、FlutterアプリによるGlanceとWidgetKitで実現する両OS対応ウィジェット機能実装について解説しました。

  1. ウィジェットは別プロセスで動作する
    • Flutterアプリとは独立して動作するため、直接的なデータ共有はできません
    • 各OSの仕組み(iOS: App Groups、Android: SharedPreferences)を利用する必要があります
  2. home_widgetパッケージの活用
    • ネイティブコードの実装を大幅に削減できます
    • プラットフォーム差異を吸収し、統一したAPIで実装可能です
  3. 画像の取り扱いに注意
    • UserDefaults/SharedPreferencesに画像を直接保存しない
    • 各OSの適切なディレクトリにファイルとして保存し、パスを共有する
  4. 更新タイミングの制御
    • iOS: Timeline Providerによる計画的な更新
    • Android: Broadcastによる明示的な更新
    • 頻繁な更新はバッテリー消費につながるため、適切な間隔で更新する

参考リンク