個人でアプリ開発を 2014 年から続けている廣川政樹です。最近、Rork で叩き台を作った壁紙系の iOS アプリに WidgetKit でウィジェットを後付けする作業をまとめてやり、累計 5,000 万 DL を超えた既存ラインアップにも展開しました。Rork は React Native ベースのコードを素早く吐いてくれますが、Widget Extension は JavaScript ランタイムを持たない別ターゲットなので、後付けするときは「ネイティブ側で完結する小さな世界」を上から重ねるイメージで設計する必要があります。
本ノートは Rork で組み立てたアプリに WidgetKit Extension を後付けし、壁紙アプリ 6 本に同時導入したときの段取りを、現場目線で残したものです。Timeline Provider の選び方や App Group の共有ディスク I/O、30MB のメモリ上限といった、後から触ると沼にハマりやすい部分を中心に書きます。
なぜ Rork ベースのアプリに後から Widget を足したのか
きっかけは、壁紙アプリの一部で「アプリを開かなくても今日のおすすめ壁紙が見たい」という要望が App Store のレビューに連続して書き込まれたことでした。当時の運用では、配信中の壁紙データは Rork が生成した React Native の画面側でしか触っていなかったため、ウィジェットを後付けするには JS 層とネイティブ層の境界を一度引き直す必要がありました。
もう一つの理由は ASO 観点です。App Store の検索結果は、ウィジェットに対応しているアプリでスクリーンショットに「Widget」の表示が出る関係で、検索面のアピールが微妙に変わります。私のラインアップでは、Widget を載せたバージョンを公開した直後の 2 週間で、Beautiful HD Wallpapers の「wallpaper widget」の検索流入が前 2 週間比で約 11% 増えました。額としては絶対値が小さい流入経路ですが、後付けの工数が 1 アプリあたり 1 〜 1.5 営業日で済むなら十分に黒字化できる施策だと判断しました。
最後に運用面でも、Widget があると「アプリを起動しなくても画像を眺めて気分転換できる」という用途が増えるため、ユーザーから見ると「壁紙を変える前のプレビュー的役割」を担えます。これによって、本体アプリ側の壁紙詳細画面の離脱率が体感で改善しました。後で書きますが、6 本のうち継続的に毎週使われているのはホーム画面 Widget で、Lock Screen 側はバージョン公開直後だけ盛り上がる傾向が出ました。
App Group と共有ストレージ — Rork が出した JS 層とネイティブ Widget の橋渡し
WidgetKit Extension は JavaScript を実行できないため、Rork 側で AsyncStorage や React Native の状態として持っているデータは、そのままでは Widget 側から読めません。橋渡しに使うのが App Group の共有 UserDefaults と、共有コンテナディスクのファイルです。
私の構成では、メイン App から JavaScript 経由で叩けるネイティブブリッジを 1 枚挟み、その先で UserDefaults(suiteName:) と FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:) を使って App Group コンテナにデータを書き出す形にしています。Widget Extension 側は同じ App Group の Suite を読むだけです。
// AppGroup.swift(メイン App と Widget Extension の両方に追加するファイル)
import Foundation
enum AppGroup {
static let identifier = "group.net.dolice.wallpapers.shared"
static var defaults: UserDefaults {
guard let d = UserDefaults(suiteName: identifier) else {
fatalError("App Group の Suite が見つかりません。Capabilities を確認してください。")
}
return d
}
static var containerURL: URL {
guard let url = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: identifier
) else {
fatalError("App Group コンテナが取得できません。プロビジョニングを確認してください。")
}
return url
}
}
落とし穴は 2 つあります。まず、AppGroup.identifier をメイン App と Widget Extension の両方の Signing & Capabilities タブで「App Groups」に有効化しておかないと、本番ビルドでだけ Widget が無音で空表示になります。シミュレータ上は通ってしまうことがあるので、毎回 TestFlight に上げて実機で確認する手順を入れています。
もう一つは、共有コンテナのファイル名にロケール依存の文字を入れない方が安全だという点です。Widget は省電力下で読み出しに失敗するケースがあり、ファイル名が長くても短くてもエラーになるパターンを目視で何度か観測しました。今は wallpaper-current.jpg のような ASCII 短名で固定し、メタデータは UserDefaults の Codable で別管理にしています。
WidgetKit Extension をターゲットに追加するときの最小構成
Rork が吐き出した Xcode プロジェクトに Widget Extension を追加する手順は、新規ターゲット作成からの最短ルートで進めると 3 ステップで終わります。
- Xcode で
File > New > Target... から「Widget Extension」を選び、Include Configuration App Intent をオンにする
- メインターゲットと新規 Widget Extension の両方の Signing & Capabilities タブで、同じ App Group ID を有効化する
- メインターゲットの Embed Foundation Extensions に Widget Extension が含まれていることを確認する
ここで気をつけたいのは、Rork が React Native のビルドステップを Build Phases の Run Script に大量に積んでいる点です。Widget Extension ターゲット側に同じスクリプトが付与されると、JavaScript バンドルを Widget の中にコピーしようとしてビルドが赤くなるケースがありました。Widget Extension の Build Phases から Bundle React Native code and images 系の Run Script を外し、Widget は純粋な Swift/SwiftUI だけで完結させるようにしています。
// WallpaperWidgetBundle.swift
import WidgetKit
import SwiftUI
@main
struct WallpaperWidgetBundle: WidgetBundle {
var body: some Widget {
WallpaperHomeWidget()
WallpaperLockWidget()
}
}
@main を Widget Bundle 側に付ける構成にすると、Home Screen Widget と Lock Screen Widget を同一バンドルで配信できます。後で詳しく触れますが、Lock Screen 側はメモリ予算と表示領域が大きく異なるので、Widget の View を共通化し過ぎないようにしています。
Timeline Provider 設計の三択 — 静的 / タイマー / アプリ起動連動
WidgetKit の Timeline Provider は、Widget をどのタイミングで更新するかを宣言する仕組みです。後付けで Widget を入れる際に、私は 3 つのパターンを使い分けています。
- 静的 Timeline(アプリ更新時のみ変わる)
- タイマー Timeline(30 分〜数時間ごとに更新)
- アプリ起動連動 Timeline(
WidgetCenter.shared.reloadAllTimelines() 駆動)
最も小さく入れるなら静的 Timeline です。最低限の利便性は保てますし、Widget の更新予算を消費しません。ただし、壁紙アプリの場合は「毎日少しずつ変化する」体験が魅力なので、6 本のうち 4 本にはタイマー Timeline、残り 2 本(Beautiful HD Wallpapers と Healing Wallpaper Plus)にはアプリ起動連動 Timeline を採用しました。
タイマー Timeline はシンプルですが、policy: .after(date) で指定する更新間隔を短くし過ぎると Widget の予算(システム側で 1 日あたりの更新回数が制限される)を食い尽くしてしまい、ユーザーから見ると「最近 Widget が固まっている」状態になります。実運用では最短でも 30 分間隔にし、本体アプリで明示的にお気に入りを変更したタイミングだけ reloadAllTimelines() で即時更新を入れる組み合わせが安定しました。
struct WallpaperTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> WallpaperEntry {
WallpaperEntry(date: Date(), wallpaper: .placeholder)
}
func getSnapshot(in context: Context, completion: @escaping (WallpaperEntry) -> Void) {
completion(WallpaperEntry(date: Date(), wallpaper: WallpaperRepository.current))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WallpaperEntry>) -> Void) {
let now = Date()
let entries: [WallpaperEntry] = (0..<6).map { offset in
let date = Calendar.current.date(byAdding: .minute, value: 30 * offset, to: now)!
return WallpaperEntry(date: date, wallpaper: WallpaperRepository.pick(at: date))
}
let next = Calendar.current.date(byAdding: .hour, value: 3, to: now)!
completion(Timeline(entries: entries, policy: .after(next)))
}
}
ここでのコツは、getTimeline の中で同時に複数のエントリを返しておき、.after で次回の補充タイミングをやや遠めに置くことです。Widget の更新は OS の都合で前倒し・後倒しされるため、エントリを切れ目なく並べておくと体感の更新感が安定します。
AppIntent ベースの Configuration — カテゴリ選択を Widget に持たせる
iOS 17 以降では AppIntent を使った Widget Configuration が推奨されています。Rork 側で扱っているカテゴリやお気に入りタグを Widget からも選べるようにするには、AppIntentTimelineProvider を組み合わせます。
import AppIntents
import WidgetKit
struct PickCategoryIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = "カテゴリを選ぶ"
@Parameter(title: "カテゴリ")
var category: WallpaperCategory
init() {}
init(category: WallpaperCategory) { self.category = category }
}
struct WallpaperHomeWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "WallpaperHomeWidget",
intent: PickCategoryIntent.self,
provider: WallpaperIntentProvider()
) { entry in
WallpaperWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("今日の壁紙")
.description("選んだカテゴリから 1 枚を表示します。")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
WallpaperCategory 自体は AppEntity に準拠させて、本体アプリ側で配信している全カテゴリを Widget の長押し設定で選べるようにしています。後から Widget を足す場合、AppEntity のデータソースは App Group 経由で取得する設計にしないと、Widget 単体で動かしたときに空のリストが返ってきてユーザーが設定できなくなる問題に遭遇します。これは TestFlight のうちにユーザー検証する価値がある挙動です。
画像表示の落とし穴 — 30MB メモリ上限と App Group のディスク I/O
Widget Extension のメモリ予算は厳しく、概ね 30MB 程度しか使えません。フルサイズの壁紙画像を UIImage(contentsOfFile:) で直接読み込むと、3K 〜 4K の画像で簡単に上限を踏み抜き、Widget が黒画面になります。
対策として、本体アプリ側で書き出すタイミングでサイズを 2 種類用意し、Widget からはサムネイル側だけを読み込む構成にしました。具体的には、@ 2x 想定の 600 × 900 程度に縮小した JPEG を wallpaper-thumb.jpg として App Group コンテナに書き、Widget Extension の View では Image(uiImage:) を経由して描画します。
extension WallpaperEntry {
static func loadThumb() -> UIImage? {
let url = AppGroup.containerURL.appendingPathComponent("wallpaper-thumb.jpg")
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
}
「フルサイズの壁紙をきれいに見せたい」場合でも、Widget 表示時はサムネイルで足り、Widget タップで本体アプリの詳細画面に飛ばす形にしておけば、フル解像度の閲覧体験は維持できます。Lock Screen Widget はさらに小さい領域なので、accentedRenderingMode で単色化されることを前提に、白黒のシンプルなフォルムだけを渡すようにしました。
ディスク I/O も静かな落とし穴で、Widget が一斉に更新するタイミング(ユーザーが画面を点灯した直後など)に App Group コンテナへの書き込みと読み込みが衝突すると、Widget 側でファイルが破損しているように見えるケースがありました。本体アプリでの書き込みは FileCoordinator を経由するか、一時ファイル経由のアトミック置換にするのが安全です。
Lock Screen Widget と Home Screen Widget の差分
iOS 16 以降の Lock Screen Widget は、丸型・矩形・インラインの 3 種類があります。壁紙アプリの相性は丸型・矩形が良く、インライン(時計の下の薄いテキスト 1 行)はあまり使い道がありません。
struct WallpaperLockWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "WallpaperLockWidget",
provider: WallpaperTimelineProvider()
) { entry in
WallpaperLockView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Today's Pick")
.description("ロック画面で今日の 1 枚をミニ表示します。")
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
}
}
Lock Screen Widget の accentedRenderingMode では、画像が自動でアクセントカラーで塗り替えられます。フォトリアリスティックな壁紙そのものを置こうとすると単色化されて意図と違う表示になるので、当初は「アプリのロゴ + 今日の日付」のような象徴的な見た目を用意しました。結果として、本体アプリへの導線として置く意味合いが強く、Home Screen Widget ほどはエンゲージメントが伸びないと割り切っています。
6 本同時導入のロールアウト戦略 — Phased Release と Remote Config ガード
私のラインアップでは Beautiful HD Wallpapers / Healing Wallpaper Plus / Aurora Wallpapers / Cute Animals Wallpapers / Anime Wallpapers HD / Minimal Wallpapers の 6 本に同時導入しました。同時と言っても、本当に同じ日にまとめて TestFlight 経由でビルドしただけで、App Store への露出タイミングは Phased Release で 7 日かけてずらしています。
Remote Config は Firebase Remote Config を使い、widget_enabled というフラグで Widget の表示自体をオン・オフできるようにしました。Widget Extension 側のコードは常にビルドに含まれていますが、WidgetCenter.shared.reloadAllTimelines() を呼び出す箇所と、Widget の Snapshot で表示する内容を、本体アプリ起動時に取得した Remote Config の値で制御しています。これにより、もし運用中に重大な問題が見つかった場合でも、サーバー側でフラグを切れば全アプリの Widget を実質的に黙らせられる構造です。
実際の運用ではこの安全装置が役に立ちました。導入から 4 日目に、Lock Screen Widget で日本語ロケールのときだけ日付フォーマットがクラッシュ気味に見える事象を 1 件報告で受け、調査の間 Lock Screen Widget だけ Remote Config で停止しました。フラグ反映は数分でユーザー側に届くので、ストア審査を待たずに小さく止められるのは個人開発者にはかなり重要です。
ASO への副作用 — 「Widgets」キーワードでの掲載枠と検索流入
App Store では Widget 対応アプリのスクリーンショットに「ウィジェット対応」のバッジが付き、検索結果でも Widget 関連キーワードの絞り込みに該当しやすくなります。Widget 導入前後の 2 週間で、Beautiful HD Wallpapers の「wallpaper widget」関連クエリのインプレッションは約 23% 増、「widget」単体のインプレッションも約 9% 増えました。検索結果のクリック率自体は微増にとどまりますが、表示が増えると累積で効いてくるタイプの施策だと感じています。
ASO 用途で Widget を活かすなら、スクリーンショットの 1 枚に Widget 設置済みの実画面を入れるのが効果的でした。Healing Wallpaper Plus では 7 番目に Widget 表示のスクリーンショットを入れたところ、リスティングの後半まで読まれている割合がわずかに増えました。スクリーンショット枚数の上限が増えた最近の App Store ではむしろ 4 番目に近いところに置く方がよいかもしれません。
1 ヶ月運用して感じた、後付け Widget の費用対効果
1 ヶ月の運用を踏まえて、Rork ベースのアプリへの Widget 後付けは「実装コスト 1 〜 1.5 営業日 × 6 本」で済んだのに対し、検索面でのインプレッションは 1 本あたり数百〜数千の増加が見えました。直接的な広告収益への影響は微増という結果でしたが、本体アプリの 7 日継続率は導入後 2 週間で 1.2 ポイント程度の改善が出ています。これは Widget 経由で「ちょっと開く」を増やせたのが原因と推定しています。
ただし、Widget の追加によってアプリ全体のレビューが伸びたかというと、そこまで顕著な変化はありませんでした。Widget の使い心地そのものはユーザーから語られにくく、レビューでは「便利になった」程度の言及がぽつぽつ入る程度です。ASO 観点での副次効果と、ユーザー体験のリピート訴求としては有効でしたが、「Widget を出したから一気にダウンロードが伸びる」類の施策ではないと割り切っています。
Rork で叩き台を作り、収益化レイヤーや観測レイヤーを後から重ねる進め方は、私個人としては相性が良いと感じています。Widget も同じ流れの上に乗る後付けレイヤーの 1 つで、ネイティブ側のターゲットを冷静に増やせる開発者であれば、Rork が出したコードベースの「外側」に WidgetKit Extension を足すのは難しい仕事ではありません。同じように複数アプリを並行運用している方の参考になれば幸いです。
最後までお読みいただき、ありがとうございました。