Zenn
🌭

AppIntentでインタラクティブなアプリにしてみる

2025/03/11に公開
1

workhub事業部でiOSエンジニアをしているyochidrosです。Apple Intelligenceの公式公開が近づいていますが、皆さんの対応はいかがでしょうか? 弊社でもApple Intelligenceと自社アプリの統合を進めるために日々、導入の可能性を模索しています。この記事では、その前段階としてApple Intelligenceで利用されているコア機能AppIntentを導入してみた話と苦労した話などを紹介していきます。


社内事例

私たちが開発しているアプリでは、iOSが提供している機能を活用することでアプリを開くことなく、価値のある機能を提供することが可能だと思っています。その中でApple Intelligenceに一番近く、AppIntentを最大限活用する既存の機能はWidgetだと考え、今回、Widgetにインタラクティブな機能を追加する挑戦をしてみました。

最初からApple Intelligence対応の機能を提供しようと考えましたが、現時点ではAppleが提供しているAssistantDomainに適切なスキーマがないため、実現は困難だと判断しました。しかし、将来的に対応可能なスキーマが追加された際に、再実装せずに対応できる状態にするための方法を模索しました。その結果、WidgetとAppIntentを組み合わせることで、私たちが目指す形に最も近い機能を実現できることがわかり、実装に取り組みました。

WidgetとAppIntentの活用

WidgetはiOS 14から導入され、多くの便利な機能が提供されていますが、アクションを実行する際にはアプリを開く必要がある場合がほとんどでした。iOS 17からは、AppIntentを用いたインタラクションが可能になりましたが、まだ対応しているアプリは少ないのが現状です。

私たちは、Widgetのみで予約を作成する機能を実現しました。

iOS 17以前では、インタラクティブなボタンをWidgetで実装するためのAPIが提供されておらず、例えば Button(action: {}, label: ...) を定義しても反応しないため、実現が困難でした。しかし、iOS 17から Button(intent: AppIntent, label: ...) が導入され、Widget内でのボタンアクションが可能になりました。

参考: Adding interactivity to widgets and Live Activities

これはAppIntentを通じてアプリの機能を利用できるようにするものですが、Widgetならではの課題も存在します。特に、以下の2点に苦労しました。

  • アプリ本体にあるデータ・機能の利用
  • インタラクション後のフィードバックUI

アプリ本体にあるデータ・機能の利用

アプリの機能自体はSwift Package Managerなどを利用してappやwidget間で共有可能ですが、実データの一部はappのランタイム上でのみ扱えるため、完全にwidget内で完結させることは困難でした。特に、Firebaseを活用している場合、app内のデータをどのように扱うかを考慮する必要がありました。

通常、Widgetとアプリは別々のプロセスとして動作するため、アプリで認証したユーザー情報をそのままWidgetで利用することはできません。しかし、App Groups を利用することで、データの共有が可能になります。

try Auth.auth().useUserAccessGroup("TEAMID.com.example.group1")

この設定により、Firebase Authの情報をアプリとWidget間で共有できます。

詳細: 共有 Apple キーチェーンを使ったアプリ間認証を有効にする

弊社アプリではユーザーの切り替え機能を提供しているため、それに応じてWidgetも更新する必要があります。しかし、Widget起動時にFirebaseの初期化を実行してしまうと、前回のログインユーザー情報のまま更新が続いてしまうため、適切なユーザー情報が保持されているかを確認する処理を追加しました。

if FirebaseApp.isDefaultAppConfigured() {
    let cacheUser = loadCache()
    if cacheUser.uid == Auth.auth().currentUser.uid {
        return
    } else {
        await FirebaseApp.app()?.delete()
    }
}
FirebaseApp.configure()

この処理により、Firebaseの認証情報が適切でない場合はリセットし、最新の状態で初期化することが可能になりました。

App内の機能を利用する方法

AppIntentがアプリ(App)とウィジェット(Widget)の両方に同じidentifierを持つ場合、どちらが実行されるのか気になるところです。

実際には、起動しているプロセスによってどのAppIntentが実行されるかが決まります。

App Widget 実行
起動中 停止中 App内にあるAppIntent
停止中 起動中 Widget内にあるAppIntent
起動中 起動中 AppからAppIntentを呼び出す→App内にあるAppIntent
WidgetからAppIntentを呼び出す→Widget内にあるAppIntent

そもそも、AppIntentはシステムが生成したメタデータを基に実行されます。

AppIntentのメタデータ構造

実際にAppIntentを定義しビルドすると、以下のようなメタデータ(extract.actionsdata)が生成されることを確認できます。

{
    "entities": {
        ...
    },
    "queries": {
        ...
    },
    "version": 1,
    "generator": {
        "version": "16C5032a",
        "name": "xcode-tools"
    },
    "actions": {
        "RefreshIntent": {
            "mangledTypeName": "6widget13RefreshIntentV",
            "assistantDefinedSchemas": [],
            "mangledTypeNameByBundleIdentifierV2": {},
            "assistantDefinedSchemaTraits": [],
            "fullyQualifiedTypeName": "widget.RefreshIntent",
            "systemProtocols": [],
            ...
        }
    },
    "enums": [
       ...
    ]
}

このactions内に定義されたAppIntentの情報が格納されており、mangledTypeNameを見ると、システムが特定の型を識別していることが分かります。

基本的に、アクティブなアプリのメタデータを参照し、特定のアクションが見つかればそれを実行します。見つからない場合は、他のアプリの検索を試みます。

バックグラウンドでのAppIntent実行

アプリが起動していない状態でも、特定のプロトコルに準拠させることで、AppIntentをアプリのプロセス内で実行可能にできます。

対応プロトコル:

  • ForegroundContinuableIntent
    • バックグラウンドで動作するが、フォアグラウンドで処理を継続したい場合に利用
  • LiveActivityIntent
    • ライブアクティビティの開始・停止などを変更したい場合に利用
  • AudioPlaybackIntent
    • オーディオの再生・停止状態などを変更したい場合に利用

これらに準拠することで、ウィジェットなどからのインタラクションでも、アプリのプロセス内でIntentを処理できます。

アプリが未起動の場合でも、これらのプロトコルに準拠したAppIntentはバックグラウンドでアプリのプロセスを起動し、初期化完了後に実行されます。AppDelegateを使用している場合、application(_:didFinishLaunchingWithOptions:)の後にIntentが実行されますが、この際UIの表示はシステム側で処理されません。

@Dependencyを利用する場合

AppIntentsフレームワークの@Dependencyを使用する場合、application(_:didFinishLaunchingWithOptions:)のタイミングで依存関係の初期化を行うことで、AppIntent内で利用可能になります。

このアプローチにより、ウィジェット内にロジックを直接記述する必要がなくなり、コードの再利用性が向上します。ただし、AppIntentの処理時間には最大30秒の制限があるため、それ以上の処理が必要な場合は、アプリを直接起動して処理を行うなどの工夫が必要です。30秒を超えると、ウィジェットのタイムライン更新処理が実行されない可能性があるため、注意が必要です。

AppIntentをアプリとウィジェットで共通化する方法

AppIntentをアプリとウィジェットの両方で参照するには、以下の2つの方法があります。

  1. ターゲットメンバーシップを設定し、両方のターゲットに追加する方法
  2. 共通のフレームワーク内にAppIntentを宣言し、アプリとウィジェットの両方からリンクさせる方法

WWDC24で発表された新機能により、フレームワーク単体での対応が可能になりました。

フレームワーク内のAppIntentを利用する方法

しかし、フレームワークをそれぞれのターゲットにリンクしても、AppIntentが見つからない実行時エラーが発生することがあります。これは、アプリとウィジェットがフレームワーク内のAppIntentを認識できないためです。

この問題を解決するには、AppIntentsPackageプロトコルに準拠した構造体を定義し、そのincludedPackagesにフレームワーク内のAppIntentを宣言する必要があります。

実装例

`// Framework内のAppIntent
import AppIntents

struct HelloIntent: AppIntent {
    static var title: LocalizedStringResource = "Hello"static var description: IntentDescription = "Say hello"
    
    func perform() async throws -> some IntentResult {
        print("hello")
        return .result()
    }
}`

`// アプリ側
import Framework
import AppIntents

extension HelloIntent: AppIntentsPackage {}

struct AppIntentPackages: AppIntentsPackage {
    static var includedPackages: [any AppIntentsPackage.Type] {
        [HelloIntent.self]
    }
}`

この設定を行うことで、アプリとウィジェットの両方でフレームワーク内のAppIntentを実行できるようになります。

ボタンタップ時などのアニメーション

Buttonなどをタップした際にローディング中のアニメーションを表示したい場合、widgetの制約上、@State などの状態を使ってアニメーションを制御することはできません。

これは、widgetが「ある時点のスナップショット」であり、状態を持続的に変化させることができないためです。

一つの解決策として、invalidatableContent(_:) を活用する方法があります。

これは、インタラクションが発生した際に一時的にviewを無効化し、次の更新までの間に適切なアニメーションを発生させる仕組みです。

ボタンタップ時のフィードバックを実装する

ボタンをタップした際に、ユーザーへフィードバックを返す必要がある場合もあります。

しかし、以下のようなライフサイクルの制約があるため、単純にアニメーションを加えるだけでは対応できません。

  1. ボタンタップ
  2. AppIntentの実行
  3. Timelineの更新

この流れの中で適切なフィードバックを実現するには、エントリを複数作成する ことが有効です。

具体的には、AppIntentの処理結果(成功・失敗など)を保持し、Timelineの更新時にその情報をもとにUIを切り替えるエントリを作成します。

例えば、widgetで表示している写真を保存したい場合

struct SavePhotoIntent: AppIntent {
  ...
  let asset: Asset
  
  func perform() async throws -> some InetntResult {
     try db.save(asset)
     UserDefaults.savedAssetDate = Date.now
    return .result()
  }
}
struct Entry: TimelineEntry {
  let date: Date
  let text: String
}

struct Provider: AppIntentTimelineProvider {
 ...

  func timeline(for configuration: Configuration, in context: Context) async -> Timeline<Entry> {
     var entries = [Entry]()
     if let savedDate = UserDefaults.savedAssetDate, Date.now.timeIntervalSince(savedDate) < 10 {
         entries.append(.init(date: .now, text: "Success"))
         entries.append(.init(date: .now.addingInterval(30), text: "Save")) 
     } else {
         entries.append(.init(date: .now, text: "Save")) 
     }
    return .init(
        entries: entries,
        policy: .atEnd
    )
  }
}

このようにすることで文字が変わり、30秒後にもとにもどる表現ができます。

widget自体、前のUIの状態と次にくるUIの状態の差分をみて変更がある箇所だけアニメーションが実行されるようになっています。

カウンターなども.contentTransition モディファイアなど利用すればそれらしい表現もできます。

  struct CounterView: View {
     var value: Int
     var body: some View {
         Text("\(value)")
           .bold()
           .contentTransition(.numericText(value: Double(value)))
           
         Button(intent: IncrementIntent(), label: {
            Text("increment")
         })
     }
  }

このように工夫することで、ウィジェットの厳しい制約の中でもインタラクティブなフィードバックを提供できます。

スマートな鍵の解施錠を考えてみる

現在、私たちの自社製品であるスマートロックを、アプリを開かずにアクションボタンのみで解施錠できる仕組みの開発にも取り組んでいます。

より良い体験を届けるために

日々、さまざまな試行錯誤を重ねながら、ユーザーにより良い体験を提供できるよう努力しています。

しかし、理想の体験に近づけるにはまだ道半ばで、多くの課題が残っています。

そのため、iOS・Androidエンジニアを積極採用中です!

私たちの環境では、最新の技術を積極的に取り入れられるだけでなく、ハードウェアとソフトウェアの両方を扱うことができる、めずらしい開発環境を提供しています。

少しでも興味があれば、ぜひ以下のリンクからお話しさせてください! 🚀

1
Bitkey Developers

Discussion

ログインするとコメントできます