【Swift】既存アプリケーションに WidgetKit を追加するまでの軌跡

この記事は [and factory Advent Calendar 2020](https://qiita.com/advent-calendar/2020/andfactory) の15日目の記事です。
昨日は @MatsuNaoPen さんの [GASを使ったwrikeのwebhook](https://qiita.com/MatsuNaoPen/items/91f3f2b64188853ec3d3) でした。

## はじめに
Apple が今年の [WWDC2020](https://developer.apple.com/wwdc20/) で WidgetKit を発表して早半年。
瞬く間に iOS エンジニア内で WidgetKit の知見が共有されました。

そこで今回は個人開発しているアプリケーションに WidgetKit を追加するまでの軌跡を記事にしました。

## 個人アプリの紹介
[クラッシュ・ロワイヤル](https://apps.apple.com/jp/app/%E3%82%AF%E3%83%A9%E3%83%83%E3%82%B7%E3%83%A5-%E3%83%AD%E3%83%AF%E3%82%A4%E3%83%A4%E3%83%AB-clash-royale/id1053012308)(以下クラロワと呼びます)というゲームのツールアプリを現在開発しています。
主な機能としては以下3つ。

|トロフィー(強さを示す数値)の期間毎の推移|デッキを作成してクラロワ本体のアプリにコピー機能|[RoyaleAPI](https://royaleapi.com/)というウェブを閲覧できる機能|
|----|----|----|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/4c4a94cc-1c38-5bcd-deb1-9b4ca748626a.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/d9320ded-598f-ee22-3e53-141c8b887e84.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/5bf71ed6-8484-fb79-b3d9-2b0bbc9e6460.png">|

そこで今回はウィジェットと相性が良さそうな下記機能でウィジェットを作ってみることにしました。

> トロフィー(強さを示す数値)の期間毎の推移

この機能を選んだ理由としてクラロワをプレイしたあとにアプリを起動せずともホームに置いてあるウィジェットにその内容が反映されたら便利だと思ったためです。

【完成図】
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/339d0fe0-2e4b-276d-8489-17da4d89078b.png", width=300>
直近の勝敗とトロフィー数がどれだけ変わったのか、あとは現在のトロフィー数と最後に更新された時間を載せたシンプルなウィジェットになっています。

## 実装

### 1. Widget Extension を追加する

![スクリーンショット 2020-12-13 18.09.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/0cfa47e5-42bc-72d1-41b0-5bf35b5397af.png)
Xcode の `File -> New -> Target` を選択。

![スクリーンショット 2020-12-13 18.09.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/c50d9bd8-d77e-822e-d048-dccf9f0c0439.png)

`Widget` と検索して `Widget Extension` を追加します。

![スクリーンショット 2020-12-13 18.36.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/1413dda3-6e2b-a165-1cef-edd2b11d10f2.png)
Extension 作成後に出てくる上記は `Activate` を選択します。

![スクリーンショット 2020-12-13 18.38.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/bb8d43bc-4d87-df30-8d4c-78d2d838d44e.png)

そうすると上記のようにスキーマに `TodaysWidgetExtension` になっていて左のプロジェクトフォルダに `TodaysWidget` があれば作成成功です。
この時点でビルドすると現在時刻だけが表示されるウィジェットがシミュレータ上で確認できます！(すごく簡単！)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/80d7431f-3952-8ebd-1fd5-1b48e570fece.png", width="300">

### 2. Widget のソースコードに手を加える

先ほどのスクショでも挙げたように基本となるソースコードは Extension 以下にある1つのファイルです。
(今回だとTodaysWidget.swift)
なのでそのソースコードを見ていきます

```swift:TodaysWidget.swift
//
//  TodaysWidget.swift
//  TodaysWidget
//
//  Created by nakandakari on 2020/12/13.
//

import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
}

struct TodaysWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}

@main
struct TodaysWidget: Widget {
    let kind: String = "TodaysWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            TodaysWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct TodaysWidget_Previews: PreviewProvider {
    static var previews: some View {
        TodaysWidgetEntryView(entry: SimpleEntry(date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}
```

#### 2.1 Widget 本体部分
まずはエントリーポイントである `@main` が付いた以下の箇所から紐解いていきます

```swift:
@main
struct TodaysWidget: Widget {
    let kind: String = "TodaysWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            TodaysWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}
```

まずは Configuration についてですが、ウィジェットで表す情報をユーザが動的に変更できるかどうかで Configuration が変わってきます。

|種類|内容|
|----|----|
|StaticConfiguration|ユーザが特にデータをカスタマイズしない場合に使用される。|
|IntentConfiguration|ユーザがデータをカスタマイズしたい場合に使用される。<br>例えば天気アプリなどで現在地ではなく指定した場所の情報を取得したいケースなどに使われる。|

IntentConfiguration だと下記のようにウィジェットを長押しする事で取得するデータをカスタマイズする事が出来ます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/7c0a259c-2498-ee8f-e777-033bad4b2daf.png", width=300> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/a9b9dac1-76c1-1c72-0d27-75990b14b13b.png", width=300>
各プロパティの説明は以下の通りです。

|プロパティ|意味|
|----|----|
|Kind|そのウィジェットを示す固有の文字列。基本的にはそのウィジェットを説明する文字列が推奨される。|
|Provider|ウィジェットにはタイムラインという概念があり、一定時間毎にどういったデータ(=Entryと呼ばれるオブジェクト)をウィジェットに伝えるかというルールを決める役割。|
|Content Closure|Provider から受け取ったデータ(=Entry)を使って SwiftUI の View を返す。|
|ConfigurationDisplayName|ウィジェットを追加する際に表示されるタイトル文言。|
|Description|ウィジェットを追加する際に表示される説明文言。|

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/b09a22ac-c77d-438e-78e9-12f7ab16a654.png", width=300>

#### 2.2 Entry と View 部分
続いてはウィジェットの中身を表す SwiftUI の View 部分を見ていきます

```swift
struct SimpleEntry: TimelineEntry {
    let date: Date
}

struct TodaysWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
    }
}
```

まず View は Entry というデータを受け取って中身を表示すると説明しました。
ここでは `SimpleEntry` がそのデータになります。
また補足ですが `TimelineEntry` のプロトコルは以下のようになっています

```swift
public protocol TimelineEntry {

    /// The date for WidgetKit to render a widget.
    var date: Date { get }

    /// The relevance of a widget’s content to the user.
    var relevance: TimelineEntryRelevance? { get }
}
```

また View も `TodaysWidgetEntryView` というカスタムビューの Struct 定義して、さきほどの SimpleEntry を使って、時間を表示しています。
※今回は SwiftUI の記法や実装についてはあまり言及しません。そこまで複雑な見た目では無いのでソースコードの雰囲気でなんとなく分かるかと思います。

この SimpleEntry がデータであり、それを受け取って TodaysWidgetEntryView で表示するビューを作っている事が分かりました。
なので今回はそこに手を加えていけば良いので、以下のようにしてみました。

```swift
struct SimpleEntry: TimelineEntry {
    let date: Date
    let todaysResult: TodaysResultInfo // 必要なデータ群を追加
}

struct TodaysWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack(alignment: .center, spacing: 5, content: {
            Text("Recenlty Result")
            // self.entry.todaysResult を使ってビューを作っていく
        })
    }
}
```
```swift:TodaysResultInfo
struct TodaysResultInfo {
    let win           : Int
    let lose          : Int
    let draw          : Int
    let changes       : Int
    let currentTrophy : Int
    
    init(win: Int, lose: Int, draw: Int, changes: Int, currentTrophy: Int) {
        self.win           = win
        self.lose          = lose
        self.draw          = draw
        self.changes       = changes
        self.currentTrophy = currentTrophy
    }
}

extension TodaysResultInfo {

    var changesLabel: String {
        if self.changes > 0 {
            return "+\(changes)" // プラスの時は +◯ と表記したい
        }
        return "\(changes)"
    }
}

extension TodaysResultInfo {
    // Preview のテスト用データ
    static var dummyData: TodaysResultInfo {
        TodaysResultInfo(win: 4, lose: 2, draw: 1, changes: 50, currentTrophy: 2300)
    }
}
```

それでは View も作っていきます。

```swift:TodaysResultVIew
//
//  TodaysResultView.swift
//  WidgetTest
//
//  Created by nakandakari on 2020/12/13.
//

import SwiftUI
import WidgetKit

struct TodaysResultView: View {
    
    let entry: SimpleEntry
    
    var body: some View {
        VStack(alignment: .center, spacing: 10, content: {
            Text("Recentlty Result")
            HStack {
                LabelAndCountView(label: "Win", count: self.entry.todaysResult.win)
                LabelAndCountView(label: "Lose", count: self.entry.todaysResult.lose)
                LabelAndCountView(label: "Draw", count: self.entry.todaysResult.draw)
            }
            HStack {
                Text("Changes")
                Text(self.entry.todaysResult.changesLabel)
            }
            HStack {
                Image("player_trophy")
                    .resizable()
                    .frame(width: 28, height: 28)
                Text("\(self.entry.todaysResult.currentTrophy)")
                Text(self.entry.date, style: .time)
            }
        })
    }
}

struct LabelAndCountView: View {
    
    let label: String
    let count: Int

    var body: some View {
        VStack(alignment: .center, spacing: 3, content: {
            Text(label)
            Text("\(count)")
        })
    }
}

struct TodaysResultView_Previews: PreviewProvider {
    static var previews: some View {
        TodaysResultView(entry: SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.dummyData))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}
```

```swift:TodaysWidget.swift
struct TodaysWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        TodaysResultView(entry: entry) // ここを変更
    }
}
```
ここまでやると Preview 画面でこうなります。
![スクリーンショット 2020-12-13 20.06.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/dafe467d-4a9c-b1ba-b4fa-ce7d8420f7ae.png)

#### 2.3 Timeline 部分
最後に時間毎にどのようなデータを生成するかの Provider 部分を見ていきます

```swift:TodaysWidget.swift
struct Provider: TimelineProvider {
    
    typealias Entry = SimpleEntry // ここはさっきの View を作る時に追加しています
    
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.placeHolderDummyData)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), todaysResult: TodaysResultInfo.snapShotDummyData)
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []

        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, todaysResult: TodaysResultInfo.dummyData)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}
```

|メソッド|自分の解釈|補足画像|
|----|----|----|
|func placeholder|ウィジェットが初めて表示される際に扱われるビューと Apple のドキュメントに有りました。<br>プレースホルダーのビューはウィジェットの一般的な見た目をユーザに提供して、このウィジェットがどういうウィジェットかを示すのに使われるそうです。<br>(が、正直ここはよく理解できていません...)|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/4ba6f1f0-78ab-fca6-7e2c-4d1bae94671a.png", width=100><br>ビューの部分がプレースホルダー(モザイク)がかかった状態で表示されている|
|func getSnapshot|ウィジェットを追加する際のギャラリー(ウィジェットギャラリー)でプレビューとして表示されるデータを返すメソッドとなります。<br>ここではユーザに素早くプレビューのスナップショットを見せることが大事だとドキュメントにあったので、何か重たい処理をして Entry を生成するような事はしない方が良いかもしれません。|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/733699fd-9a9d-3364-dc2d-e8a0afeb1787.png", width=100><br>ウィジェット追加する際のウィジェットギャラリーでの見た目を定義出来る|
|func getTimeline|getSnapshot が最初に呼ばれた後にこの timeline メソッドが呼ばれます。<br>ここではどのぐらいの頻度で更新するかやどのようなデータを次に反映させるかなどを決めます。|

ここでは主に作成するウィジェットが「どのぐらいの頻度で更新して欲しいか」に合わせて `getTimeline` メソッドをカスタマイズしていく必要がありそうです。
以下は「30分後にウィジェットを更新する」例になります。

```swift
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    let date = Date()
    var entries: [SimpleEntry] = []
    entries.append(SimpleEntry(date: date, todaysResult: TodaysResultInfo.dummyUpdatedData))
    // 30分後にウィジェットを更新する
    let nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 30, to: date)!
    let timeline = Timeline(entries: entries, policy: .after(nextUpdateDate))
    completion(timeline)
}
```
※ただし必ず指定時間後に更新される保証はない＆最低でも15分は更新間隔を設けたほうが良いとのこと。
詳しくはコチラ：[Keeping a Widget Up To Date](https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date)

※記事が長文になってきてしまったので詳細を省いておりますが、今回のウィジェットではこの `getTimeline` メソッド内で [RoyaleAPI](https://docs.royaleapi.com/#/) からプレイヤーデータを取得してタイムラインを生成しております。

## その他

今回特に使用しなかった機能で WidgetKit で押さえるべきいくつか機能をここでは挙げておきます。

|機能|説明|補足画像|
|----|----|----|
|[supportedFamilies](https://developer.apple.com/documentation/WidgetKit/StaticConfiguration/supportedFamilies(_:))|利用できるウィジェットのサイズを指定する。サイズは3種類。<br>systemSmall, systemMedium, systemLarge<br><br><br>※特に指定しない場合はウィジェットギャラリーに全てのサイズのウィジェットが追加できます。|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/ba34ae98-1772-a218-fc65-d406f3eab94e.png", width=300>|
|[widgetFamily](https://developer.apple.com/documentation/SwiftUI/EnvironmentValues/widgetFamily)|サポートしているウィジェットの大きさに合わせて特定の SwiftUI View を返すようにすることが出来る。|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/46f1588c-194e-4a9a-3a34-0412af2c0b29.png", width=300>|
|[widgetURL](https://developer.apple.com/documentation/SwiftUI/View/widgetURL(_:))|ウィジェットのビューに特定のURLを持たせてアプリ起動時に特定の処理をさせる事が出来る機能。<br><br>systemMedium と systemLarge は ウィジェット内に複数の SwiftUI View を設けることで複数の URL を設定することが可能。|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/9d6697c8-383d-1f18-287c-a8d34be7c8af.png", width=300>|
|[WidgetBundle](https://developer.apple.com/documentation/SwiftUI/WidgetBundle)|1つの WidgetKit Extension で複数のウィジェットを定義することが出来る機能。<br><br>例) PayPay アプリだと「残高・支払い」用のウィジェットと「ボーナス運用」のウィジェットで複数のウィジェットが存在する|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/1220437a-7964-8786-749e-a0b9f138a753.png", width=300><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/1b28b801-696c-0b7a-0ef0-aba0c6497a61.png", width=300>|


## ハマったポイント

### シミュレータで Widget が表示されない
本来であればホーム画面から長押しタップで Widget 一覧が出るはずがシミュレータだと一向に表示されず...
シミュレータではアプリケーション本体をビルドしてもダメなようなので Widget の Extension をビルドターゲットにしてビルドしてデバッグする必要があるらしい

参考：[Widgets Not Appearing in Simulator](https://developer.apple.com/forums/thread/650781)

### Failed to get descriptors for extensionBundleID

結論から言うと `@main` のエントリーポイントの記述漏れが原因でした。
Apple が提供している [サンプルコード(※DLします)](https://docs-assets.developer.apple.com/published/7df8ad67ab/BuildingWidgetsUsingWidgetKitAndSwiftUI.zip) を見ながらやっていたのですが、そこにある `EmojiRangerWidget.swift` には `@main` のエントリーポイントが無く真似していたのが原因でした。
ただもう1つの `LeaderboardWidget.swift` の方にちゃんとエントリポイントがありました...

```swift:LeaderboardWidget.swift
@main
struct EmojiRangerBundle: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        EmojiRangerWidget()
        LeaderboardWidget()
    }
}
```

ただ自分の場合は1度 Clean Build した後には以下のスクショのようにクラッシュログが出て原因が分かりやすかったのですが、最初はこのログが出ずにそもそもビルドが出来ていないエラーでした

![スクリーンショット 2020-12-09 23.02.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/a0231f22-1e05-d36e-01ec-e960f1dab156.png)

参考：[Failed to get descriptors for extensionBundleID
](https://stackoverflow.com/questions/62625506/failed-to-get-descriptors-for-extensionbundleid)

### linking against a dylib which is not safe for use in application extensions:
Widget extension (というか app-extension)で Embedded Framework を利用する際にこの Warning が出るようです。
Warning を消すには Embedded Framework の `General -> DevelopmentInfo` にある `Allow app extension API only` にチェックを入れる。

![スクリーンショット 2020-12-10 0.59.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/14300275-d361-d56b-3249-ebd6220cf9aa.png)

app-extension 側で利用される Embedded Framework は App Extension で利用できないコード(UIApplicationなど)を利用した場合にコンパイルエラーを発生して指摘してくれる。
(Warning の意味的に 「extension側で呼び出せないコードをリンクしているdylibで呼び出す可能性があるから安全じゃないよ！」みたいなニュアンスなのかな)

 参考：[App Extension #2 – Embedded Framework を利用して共有コードを Framework 化する](https://dev.classmethod.jp/references/ios-8-embedded-framework)

### Realm や UserDefault の値が WidgetKit(App Extesion) で使えない
これも WidgetKit ではなく App Extension の話なのですが、メインプロジェクトで Realm や UserDefault を使って端末に保存しているデータを WidgetKit 側で使うには `App Groups` という Capabilities を追加する必要があります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/77d06430-4225-e5e6-37a6-572ca49fe708.png", width=300>
Signing ＆ Capabilities から `App　Groups` を追加する。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/709b83e6-5f03-431a-7283-e9f8aa4da749.png", width=300>
適当なグループ名(`group.WidgetTest`)を設定する。

※画像は WidgetKitExtension 側で行っていますが、メインのターゲットでも同様に設定します。

そしたら Realm 側を以下のように修正します。

```swift:Realm
// Before
private func realm() -> Realm? {
    var config = Realm.Configuration()
    config.fileURL = config.fileURL!.appendingPathComponent("test_db.realm")
    return try? Realm(configuration: config)
}

// After
private func realm() -> Realm? {
    var config = Realm.Configuration()

    // 追加
    let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.WidgetTest")!

    config.fileURL = url.appendingPathComponent("test_db.realm")
    return try? Realm(configuration: config)
}
```

UserDefault もほとんど同様で `UserDefaults.standard.XXX` と使用していた所を `UserDefaults(suiteName: "group.WidgetTest")?.XXX` と使うようにすれば OK です。

参考：[【iOS14】Realmで保存したデータをWidgetに表示してみた](https://rightcode.co.jp/blog/information-technology/ios14-realm-data-widget-display)

## おわりに
WidgetKit の「あれもこれも」となってしまいかなり長文の記事になってしまいましたが、 WidgetKit の導入自体はそこまで難しくないと思います。
自分は App Extension 自体が初めてで、 XcodeGen や Embedded Framework の絡みがあってそこで時間がかかった印象です。
WidgetKit 単体だけなら楽しく開発出来ると思います！
※ちなみにアプリでのリリースはまだしていないので「画面は開発中ものになります」というやつです！


## 参考サイト
[Creating a Widget Extension](https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension)
[Keeping a Widget Up To Date](https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date)
[【iOS14】Widget(WidgetKit) まとめ](https://qiita.com/shiz/items/309349d9cdb75084e74e)