~17 min · beyond-the-app, widgetkit, activitykit, live-activities, push
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"시계에 맞춰 다시 읽는 위젯은 오후 내내 같은 숫자를 보여주려고 배터리를 태울 거야."
위젯은 창이 아니라 알림판이야
여행 일지 위젯이 보여주는 건 딱 하나야. 아직 이 폰에만 있는 담기가 몇 개인지. 주인은 그 숫자를 앱에서도 보는데, 그게 중요한 순간은 신호가 잡히는 곳에 서서 앱 생각은 전혀 안 하고 있을 때야. 홈 화면의 숫자는 호텔 와이파이에서 앱을 열라는 알림이 돼. 위젯은 WidgetKit으로 만든 별도 확장이야. TimelineProvider가 시스템한테 보여줄 항목을 넘기고, 보여줄 내용은 앱이 작은 요약을 써두는 App Group 컨테이너에서 읽어.
이 위젯을 제대로 만드는 결정이 두 가지 있어. 타임라인은 항목 하나에 정책 .never야. 숫자는 앱이 바꿀 때만 바뀌어. 그러니 위젯이 일정에 맞춰 깨어나서 같은 숫자를 또 보여주게 하지 말고, 아웃박스가 바뀐 뒤에 앱이 WidgetCenter로 그 위젯의 타임라인을 다시 불러오게 해. 그리고 위젯은 개수, 상태, 시각만 보여주고 담기 내용이나 장소는 절대 안 보여줘. 홈 화면과 잠금 화면은 폰에서 스크린샷에 가장 잘 찍히는 화면이고, 여행 기록은 주인이 정하기 전까진 사적인 거야.
라이브 액티비티는 한 턴을 따라가
네이티브 Pippa는 메시지가 가는 동안 라이브 액티비티를 보여줘. 메시지가 대기열에 들어가면 시작하고, 보내는 동안 단계가 바뀌고, 답의 첫 마디가 오면 끝난 뒤 잠깐 화면에 남아. 누르면 위젯의 딥 링크로 바로 그 대화가 열려. 속성과 바뀌는 콘텐츠 상태는 앱과 위젯 확장이 같이 쓰는 평범한 Codable 타입이야.
플랫폼은 선을 분명히 그어. 앱은 앞에 있을 때만 라이브 액티비티를 시작할 수 있어. 워치에서 보낸 메시지는 백그라운드에서 폰에 도착하니까, 로컬에서 시작하려고 하면 가장 필요한 바로 그 순간에 거절돼. 답은 푸시야. iOS 17.2부터 앱은 푸시로 시작하는 토큰을 받아서 릴레이한테 넘길 수 있고, 릴레이가 라이브 액티비티 푸시를 보내서 시작해. pushType: .token으로 시작한 액티비티마다 자기 토큰도 있어서, 턴 내내 잠들어 있던 폰에 답이 도착하면 릴레이가 끝낼 수 있어.
시험대가 가르쳐준 것 둘
주인 아이폰의 개발 빌드에서 잰 거야. 평범한 알림 토큰은 sandbox 푸시 호스트만 받았고, 같은 빌드의 라이브 액티비티 토큰은 production만 받았어. 각 호스트는 다른 쪽 토큰에 BadDeviceToken으로 대답했고. 릴레이는 이제 알림 토큰의 호스트로 먼저 보내고, BadDeviceToken이면 다른 쪽으로 한 번 더 보내고, 그 답을 기기마다 기억해. 그리고 앱이 켜질 때 푸시 시작 토큰이 알림 토큰보다 먼저 도착했어. 근데 알림 등록이 기기 기록을 통째로 갈아치우는 바람에, 시작 토큰이 도착 1초 만에 지워져버렸어. 그러니 등록은 덮어쓰지 말고 합쳐.
Code
App Group에서 개수를 읽고, 앱이 알려줄 때만 다시 불러오는 위젯·swift
import SwiftUI
import WidgetKit
/// What the widget may show: a count, a state, a time. Never a note's text or a place:
/// the Home Screen is the most screenshotable surface there is.
struct CaptureSummary: Codable, Sendable {
var waiting: Int
var lastCapturedAt: Date?
static func read(from container: URL?) -> CaptureSummary {
guard let url = container?.appending(path: "summary.json"),
let data = try? Data(contentsOf: url),
let summary = try? JSONDecoder().decode(CaptureSummary.self, from: data)
else { return CaptureSummary(waiting: 0, lastCapturedAt: nil) }
return summary
}
}
struct WaitingEntry: TimelineEntry {
let date: Date
let summary: CaptureSummary
}
struct WaitingProvider: TimelineProvider {
private var container: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.spark")
}
func placeholder(in context: Context) -> WaitingEntry {
WaitingEntry(date: .now, summary: CaptureSummary(waiting: 3, lastCapturedAt: nil))
}
func getSnapshot(in context: Context, completion: @escaping (WaitingEntry) -> Void) {
completion(WaitingEntry(date: .now, summary: .read(from: container)))
}
/// One entry and no schedule: the number changes when the APP changes it, and the app says so.
func getTimeline(in context: Context, completion: @escaping (Timeline<WaitingEntry>) -> Void) {
completion(Timeline(entries: [WaitingEntry(date: .now, summary: .read(from: container))], policy: .never))
}
}
struct WaitingWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "com.example.spark.widget.waiting", provider: WaitingProvider()) { entry in
VStack(alignment: .leading) {
Text("\(entry.summary.waiting)").font(.largeTitle.weight(.semibold))
Text("waiting to sync").font(.caption).foregroundStyle(.secondary)
}
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Waiting to Sync")
.supportedFamilies([.systemSmall, .accessoryRectangular])
}
}
// In the app, after the outbox changes:
@MainActor
func summaryDidChange() {
WidgetCenter.shared.reloadTimelines(ofKind: "com.example.spark.widget.waiting")
}
푸시 토큰과 함께 로컬에서 시작하는 라이브 액티비티, 그리고 넘겨주는 푸시 시작 토큰·swift
import ActivityKit
import Foundation
struct TurnAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable, Sendable {
var phase: String // "sending", "thinking", "answered"
}
var conversationID: String // for the widgetURL deep link back to this conversation
}
@MainActor
final class TurnActivities {
/// Local start needs the app in the foreground. Asking for a push token lets the relay end it later.
func start(conversationID: String) throws -> Activity<TurnAttributes> {
try Activity.request(
attributes: TurnAttributes(conversationID: conversationID),
content: .init(state: .init(phase: "sending"), staleDate: nil),
pushType: .token)
}
/// iOS 17.2+: a push-to-start token lets the relay START an activity when the app cannot,
/// for example after a message handled in the background. Hand it up; merge, never replace.
@available(iOS 17.2, *)
func observePushToStartTokens(send: @escaping @Sendable (String) async -> Void) -> Task<Void, Never> {
Task {
for await token in Activity<TurnAttributes>.pushToStartTokenUpdates {
await send(token.map { String(format: "%02x", $0) }.joined())
}
}
}
}
App Group을 같이 쓰는 SparkMobile 위젯 확장에 WaitingWidget을 넣고, 아웃박스가 바뀔 때마다 앱이 summary.json을 쓰고 쓸 때마다 reloadTimelines(ofKind:)를 부르게 해. 시뮬레이터에서 오프라인으로 메모 셋을 담고, 홈 화면으로 나가서 위젯 숫자가 따라 바뀌는 걸 지켜봐. 이어서 TurnAttributes를 넣고 앱이 앞에 있을 때 버튼으로 액티비티를 시작한 다음, 이번엔 백그라운드 작업에서 부르면 Activity.request가 뭘 하는지 적어.
Hint
위젯 확장에도 App Group 엔타이틀먼트가 필요해. 안 그러면 컨테이너가 nil이라 영원히 0을 보여줘. 라이브 액티비티를 쓰려면 앱 Info.plist에 NSSupportsLiveActivities도 true로 둬야 해.
Progress
Progress is local-only — sign in to sync across devices.