7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterからFoundation Modelsを呼ぶ最小構成

7
Posted at

はじめに

FlutterでiOSアプリを作っていると、Apple固有の新しいAPIを使いたい場面があります。

Foundation Modelsもその一つです。Apple Intelligenceを支えるオンデバイスモデルへ、SwiftのFoundationModelsフレームワークからアクセスできます。Appleの公式ドキュメントでは、Foundation ModelsはiOS 26.0から利用でき、SystemLanguageModelでモデルの利用可否を確認してからLanguageModelSessionを使う形になっています。

Flutter側にはFoundation ModelsのAPIが直接生えているわけではないので、今回はFlutterのMethodChannelからSwiftを呼び、その先でFoundation Modelsへ接続する構成にしました。

作るものはかなり小さいです。

入力欄へ文章を入れてボタンを押すと、

Flutter
↓
MethodChannel
↓
Swift
↓
Foundation Models
↓
Swift
↓
Flutter

という流れで結果を画面へ返します。

複雑なエージェントやツール呼び出しには進みません。まず「FlutterアプリからAppleのオンデバイスモデルを1回呼べる」ところまでを作ります。

結論

FlutterからFoundation Modelsを使う最小構成は、MethodChannelでSwiftとの薄い橋を作る形にしました。

AI処理はSwift側へ置き、Flutter側にはgenerate(String)という単純なインターフェースだけを見せます。

環境

この記事では、次の前提でコードを書きます。

Flutter: 3.41以降の構成を想定
Dart: Flutterに同梱されるバージョン
Swift: Xcodeに同梱されるバージョン
Xcode: 26系
iOS Deployment Target: 26.0
Foundation Models: iOS標準フレームワーク

Flutter 3.41以降を前提にした理由は、現在のFlutter公式ドキュメントでiOSテンプレートのライフサイクルとしてFlutterImplicitEngineDelegateを使った例が案内されているためです。

Foundation Models自体の利用開始バージョンはiOS 26.0です。

ただし、OSが対応していれば必ずモデルを呼べるわけではありません。

SystemLanguageModel.default.availabilityには、たとえば次の状態があります。

available

unavailable(.deviceNotEligible)

unavailable(.appleIntelligenceNotEnabled)

unavailable(.modelNotReady)

端末がApple Intelligenceに対応していない、Apple Intelligenceが無効になっている、モデルの準備が終わっていない、といった場合があります。

そのため「iOSのバージョン確認」と「モデルのavailability確認」は分けて扱います。

また、AppleはOS更新に伴ってオンデバイスモデルも更新しています。この記事はAPIの最小接続を目的としており、生成結果そのものがOSやモデルの更新後も同一になることは前提にしません。

実装

1. Flutterプロジェクトを作る

まず通常のFlutterプロジェクトを作ります。

flutter create foundation_models_demo
cd foundation_models_demo

今回追加する主要部分は次の2ファイルです。

foundation_models_demo/
├── lib/
│   └── main.dart
└── ios/
    └── Runner/
        └── AppDelegate.swift

Flutter用のFoundation Modelsパッケージを追加するのではなく、iOS標準のSwift APIをプラットフォームチャネル越しに呼びます。

構成図にするとこうなります。

Flutterは入力と表示を担当します。

SwiftはApple固有APIとの接続を担当します。

この境界を決めておくと、Dart側へFoundation Models固有の事情を広げずに済みます。

2. iOSのDeployment Targetを確認する

Foundation ModelsフレームワークはiOS 26.0から利用できます。

今回の最小サンプルではFoundation Modelsを主要機能として扱うため、iOS Deployment Targetを26.0にします。

FlutterプロジェクトのiOS側をXcodeで開きます。

open ios/Runner.xcworkspace

RunnerターゲットのDeployment Targetを確認し、26.0に設定します。

もし既存アプリでiOS 26未満もサポートする必要があるなら、このサンプルのようにアプリ全体のDeployment Targetを上げるのではなく、Foundation Modelsを使うコードをavailability checkで分離する設計が必要です。

今回は最小構成なので、そこまでは広げません。

3. Dart側にMethodChannelを作る

FlutterからSwiftへ渡したい情報は、まず文字列一つだけにします。

lib/main.dartを次のようにします。

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class FoundationModelsApi {
  static const _channel = MethodChannel(
    'example.dev/foundation_models',
  );

  Future<String> generate(String prompt) async {
    final result = await _channel.invokeMethod<String>(
      'generate',
      {
        'prompt': prompt,
      },
    );

    if (result == null) {
      throw StateError('Foundation Models returned no result.');
    }

    return result;
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const FoundationModelsPage(),
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue,
        ),
      ),
    );
  }
}

class FoundationModelsPage extends StatefulWidget {
  const FoundationModelsPage({super.key});

  @override
  State<FoundationModelsPage> createState() =>
      _FoundationModelsPageState();
}

class _FoundationModelsPageState
    extends State<FoundationModelsPage> {
  final _api = FoundationModelsApi();
  final _controller = TextEditingController();

  String _result = '';
  bool _loading = false;

  Future<void> _generate() async {
    final prompt = _controller.text.trim();

    if (prompt.isEmpty || _loading) {
      return;
    }

    setState(() {
      _loading = true;
      _result = '';
    });

    try {
      final result = await _api.generate(prompt);

      if (!mounted) return;

      setState(() {
        _result = result;
      });
    } on PlatformException catch (e) {
      if (!mounted) return;

      setState(() {
        _result = '${e.code}: ${e.message ?? 'Unknown error'}';
      });
    } finally {
      if (mounted) {
        setState(() {
          _loading = false;
        });
      }
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Foundation Models'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              controller: _controller,
              minLines: 3,
              maxLines: 6,
              decoration: const InputDecoration(
                border: OutlineInputBorder(),
                hintText: '文章を入力',
              ),
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: _loading ? null : _generate,
              child: Text(
                _loading ? 'Generating...' : 'Generate',
              ),
            ),
            const SizedBox(height: 24),
            SelectableText(_result),
          ],
        ),
      ),
    );
  }
}

Flutter側から見えているAI用APIは、

Future<String> generate(String prompt)

だけです。

この形にしたのは意図的です。

Foundation ModelsのSystemLanguageModelやLanguageModelSessionをFlutter側で再現し始めると、Apple固有APIの設計がDart側まで入り込んできます。

最初は薄い境界にしておきます。

4. Swift側でFoundation Modelsを呼ぶ

次にios/Runner/AppDelegate.swiftを編集します。

現在のFlutter公式ドキュメントにあるFlutterImplicitEngineDelegateを使った構成に合わせます。

import Flutter
import UIKit
import FoundationModels

@main
@objc class AppDelegate:
    FlutterAppDelegate,
    FlutterImplicitEngineDelegate {

    private let model = SystemLanguageModel.default

    func didInitializeImplicitFlutterEngine(
        _ engineBridge: FlutterImplicitEngineBridge
    ) {
        GeneratedPluginRegistrant.register(
            with: engineBridge.pluginRegistry
        )

        let channel = FlutterMethodChannel(
            name: "example.dev/foundation_models",
            binaryMessenger:
                engineBridge.applicationRegistrar.messenger()
        )

        channel.setMethodCallHandler {
            [weak self] call, result in

            guard call.method == "generate" else {
                result(FlutterMethodNotImplemented)
                return
            }

            guard
                let arguments =
                    call.arguments as? [String: Any],
                let prompt = arguments["prompt"] as? String
            else {
                result(
                    FlutterError(
                        code: "INVALID_ARGUMENT",
                        message: "prompt is required.",
                        details: nil
                    )
                )
                return
            }

            Task {
                await self?.generate(
                    prompt: prompt,
                    result: result
                )
            }
        }
    }

    private func generate(
        prompt: String,
        result: @escaping FlutterResult
    ) async {
        switch model.availability {
        case .available:
            break

        case .unavailable(.deviceNotEligible):
            result(
                FlutterError(
                    code: "DEVICE_NOT_ELIGIBLE",
                    message:
                        "This device doesn't support the model.",
                    details: nil
                )
            )
            return

        case .unavailable(.appleIntelligenceNotEnabled):
            result(
                FlutterError(
                    code: "APPLE_INTELLIGENCE_DISABLED",
                    message:
                        "Apple Intelligence is not enabled.",
                    details: nil
                )
            )
            return

        case .unavailable(.modelNotReady):
            result(
                FlutterError(
                    code: "MODEL_NOT_READY",
                    message:
                        "The model is not ready yet.",
                    details: nil
                )
            )
            return

        case .unavailable:
            result(
                FlutterError(
                    code: "MODEL_UNAVAILABLE",
                    message:
                        "The model is unavailable.",
                    details: nil
                )
            )
            return
        }

        do {
            let session = LanguageModelSession(
                model: model,
                instructions: """
                Answer the user's request concisely.
                Do not invent facts that are not provided.
                """
            )

            let response = try await session.respond(
                to: prompt
            )

            result(response.content)
        } catch {
            result(
                FlutterError(
                    code: "GENERATION_FAILED",
                    message: error.localizedDescription,
                    details: nil
                )
            )
        }
    }
}

これが今回の中心部分です。

Swift側では、

let model = SystemLanguageModel.default

でオンデバイスモデルへの参照を作ります。

ただし、そのままLanguageModelSessionを呼ぶのではなく、

switch model.availability

で状態を確認しています。

Appleの公式ドキュメントでも、モデルを利用する前にavailabilityを確認し、利用できない場合の代替UIを用意することが推奨されています。

5. LanguageModelSessionはSwift側に閉じ込める

生成部分だけを見るとかなり短いです。

let session = LanguageModelSession(
    model: model,
    instructions: """
    Answer the user's request concisely.
    Do not invent facts that are not provided.
    """
)

let response = try await session.respond(
    to: prompt
)

result(response.content)

LanguageModelSessionはモデルとの対話コンテキストを管理するクラスです。

今回はボタンを押すたびに新しいsessionを作っています。

そのため、このサンプルはチャット履歴を維持しません。

1回目の入力
↓
Session A
↓
終了

2回目の入力
↓
Session B
↓
終了

という構成です。

最小構成としては、こちらのほうが挙動を追いやすいと考えました。

会話型UIを作るならsessionを保持する設計も考えられますが、Foundation Modelsではsession内に会話履歴が蓄積されます。Appleのドキュメントでも、コンテキストサイズを超えるとエラーになることが説明されています。

まず単発生成を動かしてから、必要ならsession管理を追加するほうが切り分けやすいです。

6. Flutterへエラーコードを返す

モデルが利用できない状態を、Swift側だけで握りつぶさないようにしました。

たとえばApple Intelligenceが無効なら、

FlutterError(
    code: "APPLE_INTELLIGENCE_DISABLED",
    message: "Apple Intelligence is not enabled.",
    details: nil
)

を返します。

Dart側ではPlatformExceptionとして受け取れます。

try {
  final result = await _api.generate(prompt);
} on PlatformException catch (e) {
  debugPrint(e.code);
  debugPrint(e.message);
}

実際のアプリでは、このエラーコードをそのままユーザーへ表示するより、

DEVICE_NOT_ELIGIBLE
→ この端末では利用できない機能として別UIを出す

APPLE_INTELLIGENCE_DISABLED
→ 利用できない理由を説明する

MODEL_NOT_READY
→ 後でもう一度試せる状態として扱う

のようにUIへ変換するほうが扱いやすいと思います。

availabilityは例外処理ではなく、画面設計に関わる状態として扱うのがポイントです。

7. 呼び出しの流れを確認する

ここまでを整理すると、ボタンを押した後は次の順番で動きます。

Flutterから見ると、通常の非同期メソッドを呼んでいるだけです。

final result = await _api.generate(prompt);

iOS固有処理をSwift側へ閉じ込めたことで、Widget側がFoundation Modelsの細かなAPIを知る必要はありません。

Android側で同じ画面を提供する場合も、この境界を使って別実装へ差し替えられます。

8. 最初は文字列だけを橋にする

Foundation Modelsには、単純な文字列生成以外にも構造化された出力やtool callingなどがあります。

ただ、Flutterとの接続確認をする段階では、私はいきなりそこまで広げません。

最初の境界は、

String
↓
String

で十分です。

MethodChannelを通したデータ変換、Swiftのasync処理、Foundation Modelsのavailability、Flutter側の例外処理を一度に確認できます。

構造化された結果が必要になったら、その次に、

Swiftの生成結果
↓
Dictionary
↓
MethodChannel
↓
Map<String, dynamic>

へ広げられます。

先に複雑なデータ型を作ると、問題がFoundation Models側なのか、MethodChannelの変換なのか分かりにくくなります。

ハマりどころ

端末条件を満たさないと呼べない

Foundation Modelsのオンデバイスモデルは、すべてのiPhoneで利用できるわけではありません。

Appleの公式ドキュメントでは、利用可否は端末がApple Intelligenceに対応しているか、Apple Intelligenceが有効か、モデルが準備できているかなどに依存します。

そのため、

SystemLanguageModel.default.isAvailable

だけでUIを二分するより、今回のようにavailabilityの理由まで見るほうが実用的です。

モデルが準備中なのか、そもそも端末が非対応なのかでは、ユーザーへ案内する内容が変わります。

Simulatorだけで判断しない

Foundation Modelsを試すときは、Simulatorの扱いにも注意が必要です。

Apple Developer Forumsでは、Simulator上のFoundation ModelsはMac側のモデルを利用するため、MacとSimulatorのOSバージョンが合っていない場合に問題が起こり得ることがAppleから説明されています。

コードがおかしいと決めつける前に、実機での確認も含めて切り分けたほうがよいです。

同じsessionへ同時にリクエストしない

Appleの公式ドキュメントでは、LanguageModelSessionは一度に一つのリクエストを処理し、前の生成が終わる前に同じsessionへ次の要求を送ると問題になることが明記されています。

今回のサンプルではDart側で、

if (prompt.isEmpty || _loading) {
  return;
}

として、生成中のボタン操作を止めています。

さらに毎回新しいsessionを作っているため、最小サンプルでは並行呼び出しを考えやすくしています。

会話履歴を維持するためにsessionを共有する場合は、生成中状態の管理をより明確にする必要があります。

「オンデバイスだから何でも向いている」わけではない

Foundation Modelsは、テキストの要約、抽出、分類、編集などに使えます。

一方、Appleはオンデバイスモデルについて、基本的な計算、コード生成、論理推論などを適さない用途の例として挙げています。

そのため、クラウドの大規模モデルでやっていた処理を、そのまますべてFoundation Modelsへ移せるとは考えないほうがよいです。

今回のサンプルも「Flutterから呼べること」を確認するものであり、特定の業務処理に対する品質を保証するものではありません。

OS更新後の出力を固定値だと思わない

もう一つ見落としやすいのがモデル更新です。

AppleはOS更新に合わせてSystemLanguageModelを更新しており、公式ドキュメントでも新しいモデルバージョンに対してプロンプトを再テストすることを案内しています。

通常のAPIなら、

同じ入力
→ 同じ仕様の処理

を期待しやすいですが、生成AIではそう単純ではありません。

業務ロジックに組み込む場合は、プロンプトもアプリのロジックの一部として管理し、OS更新時の確認対象へ入れる必要があります。

もう一段実用寄りにするなら

今回のコードを実際のアプリ機能へ発展させるなら、次に考えたいのはSwiftコードの分離です。

今は説明を短くするためAppDelegate.swiftへFoundation Modelsの処理を置いています。

機能が増えるなら、

AppDelegate.swift
    ↓
FoundationModelsBridge.swift
    ↓
FoundationModelsService.swift
    ↓
FoundationModels

のように分離したほうが扱いやすくなります。

AppDelegateにはMethodChannelの登録だけを残し、Foundation Models固有の処理はサービスクラスへ移します。

Flutter側も同様です。

Widget
↓
FoundationModelsApi
↓
MethodChannel

という境界を保っておけば、Widgetから直接MethodChannel.invokeMethod()を呼び続けずに済みます。

私はFlutter、React Native、Swiftを案件に応じて使い分けていますが、クロスプラットフォームだからネイティブコードを書かない、とは考えていません。

OS固有の機能を使う部分だけSwiftへ寄せ、アプリの大部分はFlutterで共有する。その境界を小さく保つほうが、今回のようなAPIは扱いやすいと思います。

動作確認チェックリスト

最後に、うまく動かないときに確認する項目をまとめます。

[ ] iOS Deployment TargetがFoundation Modelsの要件を満たしている
[ ] Xcode側でFoundationModelsをimportできている
[ ] FlutterとSwiftのMethodChannel名が一致している
[ ] Flutterから渡すmethod名がgenerateになっている
[ ] promptがStringとしてSwiftへ届いている
[ ] SystemLanguageModelのavailabilityを確認している
[ ] Apple Intelligence対応端末で試している
[ ] Apple Intelligenceが有効になっている
[ ] modelNotReadyになっていない
[ ] Simulatorだけでなく必要に応じて実機でも確認している
[ ] 生成中の連続呼び出しを防いでいる
[ ] SwiftのエラーをFlutterErrorとして返している
[ ] Dart側でPlatformExceptionを処理している

Foundation Models側の問題とMethodChannel側の問題を一度に追うと切り分けが難しくなります。

まずSwiftだけでFoundation Modelsが呼べることを確認し、その次にFlutterとの橋をつなぐ順番でもよいと思います。

まとめ

FlutterからAppleのFoundation Modelsを呼ぶために、今回はMethodChannelを使いました。

構成としては、

Flutter
↓
MethodChannel
↓
Swift
↓
Foundation Models

だけです。

Flutter側では、

final result = await _api.generate(prompt);

という小さなAPIだけを使います。

Swift側ではSystemLanguageModel.defaultのavailabilityを確認し、利用できる場合だけLanguageModelSessionからrespond(to:)を呼びます。

この構成で大切なのは、Foundation Modelsの全機能をDartへ移植することではありません。

Apple固有の処理をSwift側へ閉じ込め、Flutterとの境界を小さく保つことです。

まず文字列を渡して文字列を返すところまで動かす。その後、必要なら構造化出力、sessionの維持、ストリーミング、tool callingなどへ広げる。

オンデバイスAIをアプリへ組み込む場合も、最初から大きな仕組みにせず、小さな接続から始めるほうが問題を切り分けやすいと考えています。

参考文献

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?