はじめに

WWDC25(2025年6月9日)で発表された Foundation Models framework を使って、アプリ内で「文章を要約する」機能をSwiftUI / UIKit の両方で実装し、簡単に試してみました。

Foundation Models framework は、Appleの公式説明では Apple Intelligence の中核となるオンデバイスの基盤モデルにアプリから直接アクセスするためのフレームワークです。プライバシーに配慮しつつ、端末上でテキスト生成タスク(要約など)を実装できます。

本記事では、オンデバイス要約の最小実装(SwiftUI / UIKit)と、実装時に必要な availability 判定をまとめます。

前提

Foundation Models framework を使うには、まず 対応OSバージョン(iOS 26.0以降 / iPadOS 26.0以降 / macOS 26.0以降 / visionOS 26.0以降) が前提になります。
また、実行端末の状態(Apple Intelligence の対応端末か、Apple Intelligence が有効か、モデルが準備できているか)によっては利用できない場合があります(この判定方法は後述します)。

実際に自分の環境でも、Xcode のバージョン(26.2)は満たしていても macOS が対応バージョンではない状態(macOS Sequoia)では利用できませんでした。そのため、検証前に Mac 側のOSも対応バージョン(macOS Tahoe)へ更新しておく必要があります。
また、Apple Developer Forums でも Xcodeシミュレータで Foundation Models をテストするには、Mac が macOS Tahoe である必要があると案内されています。

作るもの

今回作るのは、入力した文章をオンデバイスで要約して表示するだけの最小サンプルです。UIは SwiftUI / UIKit の両方で用意します。後述のコードを使用することで、画像のような要約機能をオンデバイスで実装できました。

  • 入力:文章(SwiftUI は TextEditor、UIKit は UITextView
  • 出力:日本語の箇条書き要約(重要ポイント3つ)
  • 操作:ボタンを押す → 要約を実行 → 結果を画面に表示

実装

まず最初に、Foundation Models が この端末・この設定で利用できるかを判定します。端末や設定状態によっては利用できないため、実装の入口で SystemLanguageModel.default.availability を確認し、利用できない場合は理由に応じた案内を表示します。

その上で、要約処理は OnDeviceSummarizer に集約し、SwiftUI / UIKit の両方から共通で呼べるようにします。画面側は責務を絞り、次の3ステップだけにします。

  1. 入力テキストを取得する
  2. 非同期で要約処理を呼び出す
  3. 結果(またはエラー)を画面に表示する
共通のFoundation Models関連のコード
enum OnDeviceSummarizeError: LocalizedError {
    case unavailable(String)

    var errorDescription: String? {
        switch self {
        case .unavailable(let message): return message
        }
    }
}

final class OnDeviceSummarizer {
    private let model = SystemLanguageModel.default
    private let session = LanguageModelSession(instructions: """
    あなたは要約機能です。
    出力は日本語。
    300字以内で出力してください。
    """)

    func summarize(_ text: String) async throws -> String {
        try ensureAvailable()

        let prompt = """
        次の文章を要約してください。
        ---
        \(text)
        ---
        """

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

    private func ensureAvailable() throws {
        switch model.availability {
        case .available:
            return
        case .unavailable(let reason):
            throw OnDeviceSummarizeError.unavailable("この端末では利用できません(\(reason))。")
        }
    }
}
SwiftUI
import SwiftUI

struct ContentView: View {
    @State private var inputText: String = ""
    @State private var outputText: String = ""
    @State private var errorText: String?
    @State private var isLoading: Bool = false

    private let summarizer = OnDeviceSummarizer()

    var body: some View {
        VStack(spacing: 12) {
            Text("オンデバイス要約(Foundation Models)")
                .font(.headline)

            TextEditor(text: $inputText)
                .frame(height: 180)
                .overlay(RoundedRectangle(cornerRadius: 8).stroke(.secondary.opacity(0.3)))

            Button {
                Task {
                    await summarize()
                }
            } label: {
                if isLoading {
                    ProgressView()
                } else {
                    Text("要約する")
                }
            }
            .disabled(isLoading || inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)

            if let errorText {
                Text(errorText)
                    .foregroundStyle(.red)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }

            ScrollView {
                Text(outputText)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
            .frame(maxHeight: .infinity)
            .overlay(RoundedRectangle(cornerRadius: 8).stroke(.secondary.opacity(0.3)))
        }
        .padding()
    }

    private func summarize() async {
        isLoading = true
        errorText = nil
        outputText = ""

        do {
            let result = try await summarizer.summarize(inputText)
            outputText = result
        } catch {
            errorText = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
        }

        isLoading = false
    }
}
UIKit
import UIKit

final class ViewController: UIViewController {

    private let inputTextView = UITextView()
    private let outputTextView = UITextView()
    private let summarizeButton = UIButton(type: .system)

    private let summarizer = OnDeviceSummarizer()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        setupUI()
    }

    private func setupUI() {
        inputTextView.font = .systemFont(ofSize: 16)
        inputTextView.layer.borderWidth = 1
        inputTextView.layer.borderColor = UIColor.secondaryLabel.withAlphaComponent(0.3).cgColor
        inputTextView.layer.cornerRadius = 8

        outputTextView.font = .systemFont(ofSize: 16)
        outputTextView.isEditable = false
        outputTextView.layer.borderWidth = 1
        outputTextView.layer.borderColor = UIColor.secondaryLabel.withAlphaComponent(0.3).cgColor
        outputTextView.layer.cornerRadius = 8

        summarizeButton.setTitle("要約する", for: .normal)
        summarizeButton.addTarget(self, action: #selector(didTapSummarize), for: .touchUpInside)

        let stack = UIStackView(arrangedSubviews: [inputTextView, summarizeButton, outputTextView])
        stack.axis = .vertical
        stack.spacing = 12
        stack.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(stack)

        NSLayoutConstraint.activate([
            stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            stack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),

            inputTextView.heightAnchor.constraint(equalToConstant: 180),
            outputTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 180),
        ])
    }

    @objc private func didTapSummarize() {
        let text = inputTextView.text ?? ""
        if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return }

        setLoading(true)
        outputTextView.text = ""

        Task {
            do {
                let result = try await summarizer.summarize(text)
                await MainActor.run {
                    self.outputTextView.text = result
                    self.setLoading(false)
                }
            } catch {
                let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
                await MainActor.run {
                    self.outputTextView.text = message
                    self.setLoading(false)
                }
            }
        }
    }

    private func setLoading(_ isLoading: Bool) {
        summarizeButton.isEnabled = !isLoading
        if isLoading {
            summarizeButton.setTitle("要約中…", for: .disabled)
        } else {
            summarizeButton.setTitle("要約する", for: .normal)
        }
    }
}

つまづきポイント

availability(利用可否)

Foundation Models は、端末や設定状態によって利用できない場合があります。実装としては、要約処理を呼ぶ前に SystemLanguageModel.default.availability を確認し、unavailable の場合は理由を含めてユーザーに案内する必要があります。
本記事のサンプルでは OnDeviceSummarizer.ensureAvailable() に判定を集約し、利用できない場合は例外として返し、画面側でメッセージ表示しています。

MainActor(UIKitのUI更新)

SwiftUI は @State を更新すれば画面に反映されますが、UIKit は非同期処理の結果をそのままUIに反映するとスレッド違反になる可能性があります。
そのため、Task {} 内で取得した結果を UILabelUITextView に反映する際は await MainActor.run { ... } でメインスレッドに戻して更新します。

まとめ

今回のサンプルでは、Foundation Models を使ったオンデバイス要約を SwiftUI / UIKit の両方で実装し、利用可否の判定と最小の呼び出し方法を整理しました。オンデバイスで要約ができると、ネットワーク状態に依存しない形でテキスト機能を追加できます。まずは availability を考慮した最小実装から入れ、実務のユースケースに合わせて入力UIと出力形式を調整していくのが現実的です。次は @Generable を使って出力を構造化し、画面表示や後処理を安定させる方向を試す予定です。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる