MoTLab -Mobility Technologies Engineering Blog-MoTLab -Mobility Technologies Engineering Blog-

「GO」のiOSアプリにLive Activitiesを追加してみた話

iOS
December 01, 2022

この記事は Mobility Technologies Advent Calendar 2022 の2日目です。

タクシーアプリ「GO」の iOS アプリを開発している古屋です。

今回は「GO」にあったら便利になりそうなLive Activitiesのプロトタイプを作ってみたので、その紹介をしたいと思います。


はじめに

弊社では2021年度下期より、エンジニアのスキルアップを促進するための取り組みとして Engineer Challenge Week 施策が導入されました。

この機会に、人それぞれ、普段やれないようなリファクタリングをしたり、新しい機能を導入したりなどをしています。

今年のWWDC 2022のKeynoteでLive Activitiesの発表があり、そこでUberの例があげられていたため、「GO」にも早く導入できるようにと思い、Live Activitiesを触ってみることにしました。

前回の実施内容がWidgetだったのでよりスムーズに試せるというところも大きかったです。

前回についてはこちらをご覧ください。

「GO」のiOSアプリにWidgetを追加してみた話

※Live ActivitiesはiOS 16.1以降、ActivityKit を使うことでロック画面にリアルタイムなコンテンツを表示できる機能です

作る機能はこちらにしました

  • タクシーを配車してから乗車するまでをLive Activityで表示する

Live Activityを作る

  • 表示内容
    • 到着予定の時刻
    • 車両情報
      • ナンバープレート
      • 車両画像
  • 挙動
    • タクシーの配車依頼が成功したらLive Activityを開始する
    • 最新の到着予定の時刻が表示される
    • タクシーから降車したらLive Activityを終了する

表示するデータの定義

Live Activityに渡すためのデータは、静的なものは ActivityAttributes に準拠し、動的なものは    

ActivityAttributes 内にActivity.ContentState を定義する必要があります。

// 静的なデータ
struct TaxiDispatchAttributes: ActivityAttributes {
    typealias TaxiDispatchState = ContentState
    
    // 配車の状況
    enum Status: Codable, Hashable {
        case arriveAfter(String) // xx分後にタクシーが到着
        case arrived // タクシーが到着済み
        case boarding // タクシーに乗車中
    }

    // 動的なデータ
    struct ContentState: Codable, Hashable {
        var status: Status
    }

    // 本来はここに車両画像を持たせたいが4KB制限のために定義しない
    var carRequestID: UInt64
    var numberPlate: NumberPlate
    var companyName: String
    var driverName: String
}

ここで表示内容に必要な車両画像が含まれていませんが、渡せるデータは4KBまでに限られているため、画像データはApp Group経由で別途渡す必要があります。

ドキュメントにはこのようにありますが、静的データにDataに変換した画像を渡してもエラーが起きたため定義には車両画像は含めることができませんでした。 The updated dynamic data for both ActivityKit updates and remote push notification updates can’t exceed 4KB in size.

Live Activityで表示するデータの受け渡し

Live ActivityではAPI通信ができず、以下の方法でデータを渡すことができます。

アプリがキルされたり、Push通知がオフにされる可能性があるので、確実にデータを更新するためにActivity経由とPush通知経由の2つとも(App Group経由は大きいデータを渡す時等必要に応じて)実装するのが推奨されていますが、今回はプロトタイプで動きを見せられれば良かったので、Activity経由とApp Group経由の二つでデータを渡すようにしました。

本体アプリ

class TaxiDispatchLiveActivityManager {
    // タクシーに乗車するまでは、アプリがバックグラウンドでも呼ばれ続ける
    fucn onCarRequestUpdated(_ carRequest: CarRequest) {
       // 個別にLive Activityはオフにできたり、使えない端末もあるのでチェックしておく
       guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }

       // 現在の配車に紐づくActivityを取得
       let activity = Activity<TaxiDispatchAttributes>.activities.first(where: { $0.attributes.carRequestID == carRequest.id }

       // 配車が完了したらLive Activityは消す
       if let activity = activity, carRequest.state.isFinished {
           finishLiveActivityImmediately(activity)
           return
       }

       let status: TaxiDispatchAttributes.Status = {
            switch carRequest.state {
            case .onPickup: return .arriveAfter(carRequest.arrivalTime)
            case .pickupArrived: return .arrived
            default: return .boarding
            }
        }()
        let contentState = TaxiDispatchAttributes.TaxiDispatchState(status: status)

        if let activity = activity {
            // すでにLive Activityがある場合は動的データのみを渡して更新する
            Task {
                await activity.update(using: contentState)
            }
        } else {
            // Activityがない場合はLive Activityを開始する
            let driverName = "\(carRequest.driverName) 乗務員"
            let activityAttributes = TaxiDispatchAttributes(carRequestID: carRequest.id,
                                                            numberPlate: carRequest.numberPlate,
                                                            companyName: carRequest.campanyName
                                                            driverName: driverName)

            do {
                // 4KB制限超えや同時開始数の上限を超えている時はエラーになる
                try Activity.request(attributes: activityAttributes, contentState: contentState)
            } catch {}
        }

        downloadCarImageIfNeeded(carRequest: carRequest)
    }

    private func finishLiveActivityImmediately(_ activity: Activity<TaxiDispatchAttributes>) {
        Task {
            await activity.end(using: TaxiDispatchAttributes.TaxiDispatchStatus(status: .boarding),
                               dismissalPolicy: ActivityUIDismissalPolicy.immediate)
        }
    }

    private func downloadCarImageIfNeeded(carRequest: CarRequest) {
        let carImageKey = "live-activity-car-image"
        let userDefaults = UserDefaults(suiteName: "group.xxx")!
        guard userDefaults.data(forKey: carImageKey) == nil else { return }

        // Live Activityに車両画像をUserDefaults経由で渡す
        URLSession.shared.dataTask(with: carRequest.carImageUrl) { data, _, error in
            if error == nil, case .some(let result) = data {
                DispatchQueue.main.async {
                    if let image = UIImage(data: result) {
                        userDefaults.setValue(image.pngData(), forKey: carImageKey)
                    }
                }
            }
        }.resume()
    }
}

Live Activityはアプリがバックグラウンド状態の時も情報を更新するために、Live Activity用のPush通知を使うかアプリをバックグラウンドで動かす必要があります。

どのくらいの頻度で更新する必要があるかによって使い分けれられますが、「GO」では迎車中はバックグラウンドで位置情報を取得しており、Live Activityを表示しておくタイミングと同じだったのでそのままBackground Modeで動かし続けました。

Live Activityの開始はアプリがフォアグラウンドである必要がありますが、更新と終了はバックグラウンドで行うことが可能です。

また車両画像はActivity経由では渡せないので、App Group経由で渡して表示しています。

静的な画像の場合はWidgetのAssetに持たせておけばいいので、このような処理は不要です。

Widgetの表示

Live Activity, Dynamic Islandの各状態ごとのViewをSwiftUIで定義することで簡単に表示することができます。

車両画像はActivityの更新とは別タイミングなので、車両画像がないパターンのレイアウトも考慮する必要があります。

struct TaxiDispatchLiveActivity: Widget {
    let carImageKey = "live-activity-car-image"
    let userDefaults = UserDefaults(suiteName: "group.xxx")

    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TaxiDispatchAttributes.self) { context in
            HStack {
                VStack(alignment: .leading, spacing: 2) {
                    TaxiDispatchStatusView(status: context.state.status)
                    Text(context.attributes.companyName)
                    Text(context.attributes.driverName)
                }
                Spacer()
                VStack(spacing: 4) {
                    if let carImageData = userDefaults?.data(forKey: carImageKey), let carImage = UIImage(data: carImageData) {
                        Image(uiImage: carImage)
                            .resizable()
                            .frame(width: 72, height: 46)
                    }
                    
                    Text(context.attributes.numberPlate.area + context.attributes.numberPlate.specifiedNumber)
                    Text(context.attributes.numberPlate.hiragana + context.attributes.numberPlate.individualNumber)
                }
            }
            .padding(20)
            .foregroundColor(Color.black)
            .activityBackgroundTint(Color.white)
            .activitySystemActionForegroundColor(Color.black)
            
        } dynamicIsland: { context in
            // Dynamic Islandのレイアウト
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                }
                DynamicIslandExpandedRegion(.trailing) {
                }
                DynamicIslandExpandedRegion(.bottom) {
                }
            } compactLeading: {
            } compactTrailing: {
            } minimal: {
            }
        }
}

struct TaxiDispatchStatusView:  View {
    var status: TaxiDispatchAttributes.Status
    
    var body: some View {
        Group {
            switch status {
            case .arriveAfter(let arrivalMinutes):
                VStack(alignment: .leading, spacing: 2) {
                    Text("到着予定")
                    Text(arrivalMinutes)
                }
            case .arrived:
                Text("到着しました")
            case .boarding:
                Text("乗車中です")
            }
        }
    }
}

今回はLive Activity内にボタンを設置しなかったですが、ボタンがある場合などはWidgetと同様にwidgetURL(_:) を設定することでdeep linkを設定することができます。

またLive Activity内でのアニメーションはWidget同様使えないので注意が必要です。

完成

An image from Notion

An image from Notion

アプリを閉じている時でもさっとタクシーの到着時間が確認できるようになってとても便利です。

気になる挙動

Xcode上で開発している状態でDynamic Islandを表示すると、他にActiveなLive Activityがないにもかかわらず、compactが一瞬表示された後にminimalな状態に切り替えられてしまいました。

Live Activity開始後にDetachするとminimalからcompactに戻るのでデザインの確認する時は、この一手間が加わるのが少し手間でした。

その他の根本的な解決方法については見つけることができませんでした。

さいごに

前回の MoT Engineer Challenge Week では、次はiOS 16での新機能を使いたいと宣言していたので、デザインやPush通知周りなど一部妥協はしたものの、ちゃんとiOS 16の新機能を使えて良かったです!

このように実際に見えるものを作ることで、実際に案件化していけるように良さを布教していけるので次回以降も色々と新機能を試していきたいと思います。

参考

Displaying live data with Live Activities


We're Hiring!

📢
Mobility Technologies ではともに働くエンジニアを募集しています。

興味のある方は 採用ページ も見ていただけると嬉しいです。

Twitter @mot_techtalk のフォローもよろしくお願いします!