LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


多様な利用ケースに対応するLINEサービスウィジェットの開発

はじめに

こんにちは、iOS/Androidエクスペリエンス開発チームの羽柴です。この記事では、LINE iOSアプリが提供するLINEサービスウィジェットの開発中に私たちが直面した課題と、それを解決するために採用した手法をご紹介します。

背景

ウィジェットとは、iPhoneのホーム画面やロック画面に配置してアプリの情報を一目で確認できるものです。LINEアプリは現在いくつかのウィジェットを提供しており、そのうちの一つが「LINEサービス」です。LINEサービスウィジェットでは、KeepやLINE PayといったLINEの様々なサービスへのショートカットをロック画面に配置できます(ロック画面ウィジェットにLINEを設定する)。

LINEサービスウィジェットは、ユーザによるカスタマイズが可能なウィジェットです。ロック画面に追加しタップすると、ウィジェットに設定できるLINEサービス一覧が表示されます。ここから任意のサービスを選択し、ショートカットを配置します。

要件

昨年のWWDC 22において、iOS 16よりロック画面にウィジェットが配置可能になることが発表され、LINEでもこのウィジェットへの対応を行うための検討が開始されました。LINEサービスへのショートカットを実装する案は当初からありましたが、実現するまでには解決すべき課題がいくつかありました。

LINEサービスウィジェットを開発するにあたり定められた要件は次の3つです:

  • ユーザによるカスタマイズが可能であること
  • 国ごとに選択肢が調整可能であること
  • サービスの更新・終了に対応していること

まず、ショートカットをどのようにユーザに提供するかを議論しました。最も単純な方法は、一つひとつのサービスを個々のウィジェットとして提供することです。技術調査の結果、一つのアプリで提供できるウィジェットの数にはおそらく制限がない(100個まで確認)ことがわかりましたが、管理コストが高く検索機能もないため、この方法は却下されました。そこで、ショートカットにするLINEサービスをユーザが自由に選択できる一つのウィジェットとして実装することが決まりました。

次に検討されたのがローカライズの問題です。LINEはグローバルに利用されていますが、LINEサービスの中には一部地域でしか利用できないものもあります。例えばLINEクーポンは現在日本でのみ利用可能なサービスです。ショートカットの遷移先で利用をブロックすることもできますが、UXを考慮し、「そのユーザが利用できないサービスは選択肢に表示されるべきではない」という方針に固まりました。

最後に、サービスの更新や終了にどのように対応できるかが議題に上がりました。季節限定のアイコンを表示したい場合やサービスが終了した場合に、古いデータが残り続けることなく速やかに更新される必要があります。ウィジェットの描画時にサーバからサービスの情報を取得する方法も検討されましたが、サーバ側の実装コストを踏まえ、アプリ内にリソースを含め、終了時はアプリのアップデートでリソースを削除する方法が選択されました

実装

このセクションでは、LINEサービスウィジェットで実際に採用した実装をご紹介します。

ユーザによるカスタマイズを可能にする

LINE iOSではSiriKitのカスタムIntentを用いてウィジェットのカスタマイズを行えるようにしています。なお、iOS 17以降ではよりモダンなApp Intentsを使用して同様のことが実現できますが、サポートバージョンの関係で本ウィジェットでは採用していません。

カスタマイズ可能なウィジェットの詳細な作成方法についてはこの記事では省略し、ここではカスタマイズ時にユーザ・OS・アプリ間で発生するやり取りを説明します。

Intentは、ユーザによってカスタマイズされた値を保持するクラスです。ユーザーがウィジェット編集画面を開くと、カスタムIntentの定義に基づいた選択肢が表示されます。

ユーザは任意の選択肢を選び、ウィジェットの編集を完了します。このときOSは選択された値をもとにIntentを更新します。ウィジェットを描画する際は保存されたIntentをOSから受け取って描画します。Intentの値はOSによって保存され、ユーザが再び選択を変更するまで利用され続けます

国ごとに選択肢を出し分ける

カスタムIntentの定義ファイルで "Dynamic Options"を指定することで、動的に選択肢を生成することができるようになります。ここで適宜ユーザ情報を見るなどして、ユーザの地域を取得します。

LINEサービスウィジェットでは、サービスの情報を以下のように定義しています:

enum FamilyService: String, CaseIterable {
    case keep
    case coupon
    // ...
 
    var info: FamilyServiceInfo {
        switch self {
            case .keep: .keep
            case .coupon: .coupon
            // ...
        }
    }
 
    func isAvailable(in region: Region) -> Bool {
        info.availableRegions.contains { $0 == region }
    }
}
 
struct FamilyServiceInfo {
    var url: URL?
    var availableRegions: [Region]
}
 
extension FamilyServiceInfo {
    static let keep: FamilyServiceInfo = .init(
        url: URL(string: "KEEP_URL"),
        availableRegions: Region.allCases
    )
 
     static let coupon: FamilyServiceInfo = .init(
        url: URL(string: "COUPON_URL"),
        availableRegions: [.jp]
    )
 
    // ...
}

FamilyService型としてenumでサービスを列挙し、サービスのURLと対応地域からなるFamilyServiceInfo型の構造体を持ちます。各サービスの情報をFamilyServiceのcomputed propertyとして定義する方法がシンプルに思えますが、それぞれのプロパティ内部でswitch文が必要になり、サービスの数に比例してcaseが増え実装の見通しが悪化します。また、一つのサービスに対する情報が分離してしまうため、実装ミスに気づきにくくなります。そこでFamilyServiceInfoというサービス情報を持つ構造体を用意し、一つのサービス情報を一箇所で記述できるようにしました。

この実装で、以下のようにユーザの居住地域で利用可能なLINEサービスのみを取得することができます:

FamilyService.allCases.filter { $0.isAvailable(in: region) }

サービスの更新・終了に対応する

ウィジェットの表示は reloadTimelines(ofKind:) を呼び出すことで更新することができますが、Intentの値はOSに保存されたものが使われます。従ってIntentにサービス名やURL等を含めると、それらの更新にはユーザの再設定が不可欠になります。ウィジェットの値が変わり得る場合Intentが持つ情報は最低限にし、Widget Extensionでデータを用意するようにします

LINEサービスウィジェットに紐づくカスタムIntentは、LINEサービスのidentifierのみをプロパティとして保持しています。ウィジェットの描画時に先述のFamilyService型をidentifierで初期化することで、最新の値を毎回利用できます。

また、終了したサービスはFamilyServiceにおける該当ケースの削除で対応する予定になっています。終了したサービスは、IntentからFamilyServiceを生成する際に処理します:

extension FamilyService {
    static func makeService(from intent: FamilyServiceWidgetSelectionIntent)
        -> Result<FamilyService, InitializationError> {
        guard let identifier = intent.service?.identifier else {
            return .failure(.emptyServiceID)
        }
 
        guard let service = FamilyService(rawValue: identifier) else {
            return .failure(.unknownServiceID)
        }
 
        return .success(service)
    }
}

カスタムIntentにidentifierは残りますが、アプリ側でケースが削除されているのでFamilyServiceの生成に失敗し、もう存在しないサービスであることをユーザにフィードバックできます。なおカスタムIntentの値は破棄されず、同じrawValueのケースを再び定義するとそのサービスが選択されている状態になるので、注意が必要です。

まとめ

LINEサービスウィジェットの開発における議論と実装についてご紹介しました。SiriKitのカスタムIntentを通じて、多くのサービスを扱えるカスタマイズ可能ウィジェットを作成しました。このカスタムIntentが持つ値を"Dynamic Options"にすることで、ユーザの居住地によって表示する選択肢を調整しています。またIntentが持つ値を最低限することで、ウィジェットの更新を柔軟に行えるようにしました。

大規模かつグローバルなサービスならではの要件を満たすために、LINEサービスウィジェットでは様々な工夫や検討が行われました。みなさんがウィジェットを開発する際にこれらの知見がお役に立てれば幸いです。