Live Activities는 배지가 아니라 상태 머신입니다
Return의 Live Activity는 잠금 화면과 Dynamic Island에 표시되는 카운트다운 숫자처럼 보입니다.12 하지만 이것은 숫자가 아닙니다. 이것은 3개의 외부 해제 경로와 자기 자신으로부터 자신을 방어해야 하는 1개의 재진입 시작 경로를 가진 5단계 라이프사이클 상태 머신입니다. 아래의 패턴들은 프로덕션에서 살아남은 것들입니다. 마지막의 잔혹할 정도로 솔직한 푸터에는 아직 모르는 것들을 적었습니다.
Live Activity를 배지처럼 다루는 v1을 출시했습니다. “현재 남은 시간”이 데이터였고, 나머지는 장식이었습니다. 그 버전에는 TestFlight에서 발견한 3개의 버그와 프로덕션에서 발견한 1개의 버그가 있었습니다.
- 시작 작업이 진행 중인 상태에서 시작 버튼을 다시 누르면 두 번째 액티비티가 생성되어 첫 번째 액티비티가 고아가 되었습니다.
- 카운트다운은 Dynamic Island에서는 올바르게 렌더링되었지만, 잠금 화면 뷰에서는 일시정지된 타이머에 대해
endTime <= Date()에 도달해 사용자가 재개할 때까지0:00으로 표시되었습니다. - 해제 정책이
.default로 설정되어 있어서 사용자가 타이머를 리셋한 후에도 Live Activity가 오랫동안 표시된 채로 남아있었습니다. Apple은.default를 최대 4시간까지 표시 상태로 유지합니다. - (프로덕션에서 발견.) 우에서 좌로 쓰는 언어 로케일(아랍어, 히브리어)에서 Dynamic Island의 compact-trailing 영역의 숫자가 거꾸로 렌더링되었습니다. 라틴 숫자에 RTL 레이아웃이 적용된 것이죠. 수정은 한 줄이면 됐습니다.
이들 각각은 모두 상태 머신 버그였습니다. 카운트다운 숫자 자체는 문제가 없었습니다. 숫자는 제품이 아닙니다. 제품은 상태입니다.
아래의 상태 머신은 그 버그들에서 살아남은 결과물입니다.
TL;DR
- 출시된
LiveActivityManager는 5개의 전환 메서드(startActivity,updateActivity,showCycleComplete,showFinalCompletion,endActivity)와 1개의 읽기 메서드(hasActiveActivity)를 노출합니다. 224줄의 프로덕션 코드는startActivity내부의 한 가지 특정 위험을 방어합니다. 동시 시작 호출과 해당 메서드 안의 각await경계를 가로지르는 취소 검사입니다.3 ContentState는 6개의 필드를 가지고 있습니다.endTime,currentCycle,totalCycles,isPaused,isCompleted,remainingSeconds. 처음 5개는 상태 머신의 라벨이고, 6번째(remainingSeconds)는 ActivityKit의 라이브timerInterval이 처리할 수 없는 정적 표시용 폴백입니다.- 해제 정책 결정이 진짜 제품적 판단입니다. 사용자 리셋에는
.immediate, 완료에는.after(Date().addingTimeInterval(3))를 쓰고, 시스템 기본값은 절대 사용하지 않습니다. - Dynamic Island compact-trailing 영역에서는 RTL 시스템 로케일에서도 라틴 숫자가 LTR을 유지하도록 타이머 텍스트에
.environment(\.layoutDirection, .leftToRight)가 필요합니다.
상태 머신
출시된 Live Activity는 1개의 idle 상태, 사용자가 관찰할 수 있는 3개의 라이브 상태, 1개의 종료 상태, 그리고 개발자가 반드시 지켜야 하는 1개의 재진입 게이트를 가지고 있습니다.
┌──────────────────────────────────────────────────────────────────┐
│ Lifecycle states │
├──────────────────────────────────────────────────────────────────┤
│ IDLE currentActivity == nil; no Live Activity present │
│ RUNNING isPaused=false, endTime > Date() │
│ PAUSED isPaused=true, remainingSeconds=N │
│ CYCLE_END isPaused=false, endTime <= Date(), isCompleted=false│
│ COMPLETE isCompleted=true (terminal; transitions to IDLE) │
└──────────────────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────────────────┐
│ Dismissal policies (Apple) │
├──────────────────────────────────────────────────────────────────┤
│ .immediate user reset │
│ .after(now + 3s) completion display window │
│ .default system decides; can stay up to 4 hours │
└──────────────────────────────────────────────────────────────────┘
Reentrancy gate inside startActivity():
isStartingActivity flag + cancellable startActivityTask
prevents two concurrent startActivity() calls from creating
two Live Activities for one timer. Cancellation checks across
each await keep the in-flight task safe to abort.
렌더링 경로는 isPaused를 가장 먼저 확인합니다. 이 순서 덕분에 일시정지된 타이머가 벽시계 시간이 endTime을 넘어섰을 때도 CYCLE_END로 렌더링되지 않습니다.7
상태 이름은 숫자에 붙는 라벨이 아닙니다. 상태 이름은 LiveActivityManager(SwiftUI 뷰가 살아 있는 앱 측)와 ReturnLiveActivity(Apple의 프로세스가 화면을 렌더링하는 위젯 익스텐션) 사이의 계약입니다.
이 계약이 바로 TimerActivityAttributes.ContentState이며, 다음 6개 필드 전부입니다.3
public struct ContentState: Codable, Hashable {
var endTime: Date
var currentCycle: Int
var totalCycles: Int?
var isPaused: Bool
var isCompleted: Bool = false
var remainingSeconds: Int = 0
}
모든 상태 전환은 이 구조체를 변경하고 ActivityKit에게 프로세스 경계를 가로질러 위젯 익스텐션으로 전달해 달라고 요청합니다. 그러면 위젯이 다시 렌더링됩니다. 공유 메모리도 없고, 콜백도 없습니다. 모든 전환마다 프로세스 경계를 가로지르는 Codable 구조체가 있을 뿐입니다.
이 사실은 클로저, 뷰 모델, observable 객체, computed property로 하고 싶은 모든 것을 배제합니다. 상태는 직렬화 가능한 데이터로 표현될 수 있어야 합니다. 인코딩할 수 없다면 전환할 수 없습니다.
재진입 시작
Live Activities는 동시 액티비티 수에 대한 강한 제한과, 진행 중에 Activity.request를 두 번 호출할 때 발생하는 일에 대한 약한 제한을 가지고 있습니다. 강한 제한은 잘 문서화되어 있습니다.4 약한 제한은 “두 번째 호출이 성공해서 고아 액티비티를 만들 수 있다”는 것입니다. 고아 액티비티는 매니저의 currentActivity와 더 이상 연결되지 않은 Live Activity입니다. 이 액티비티는 살아남지만, 코드로 돌아오는 경로가 없습니다. 결국 자체 staleness 타이머에 의해 알아서 해제됩니다. 그때까지 사용자는 중복된 타이머를 보게 됩니다.
이 고아 액티비티가 Return v1의 버그였습니다. 수정은 LiveActivityManager.swift의 재진입 게이트와 취소 가능한 Task의 조합입니다.3
private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?
func startActivity(...) {
#if os(iOS)
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard !isStartingActivity else { return }
isStartingActivity = true
startActivityTask?.cancel()
startActivityTask = Task {
defer {
isStartingActivity = false
startActivityTask = nil
}
guard !Task.isCancelled else { return }
await endActivity() // explicit cleanup of any prior state
guard !Task.isCancelled else { return }
// ... build attributes + contentState ...
do {
let activity = try Activity.request(...)
guard !Task.isCancelled else { return }
currentActivity = activity
} catch {
// log; flag clears via defer
}
}
#endif
}
이 패턴에 대해 문서가 명시하지 않은 세 가지가 있습니다.
isStartingActivity 플래그가 능동적인 보호이고, startActivityTask?.cancel()은 방어적인 정리입니다. 플래그는 첫 번째 작업이 진행 중일 때 두 번째 startActivity 호출을 즉시 차단하므로, 공개 경로에서 실제로 경합이 일어나지는 않습니다. cancel-then-replace 동작이 여전히 중요한 이유는 진행 중인 Task가 비동기이고 짧게 끝나는 호출자보다 더 오래 살 수 있기 때문입니다. 취소는 호출자가 떠난 뒤에 stale Task가 계속 실행되는 것을 막습니다.
각 await 경계마다 들어가는 guard !Task.isCancelled 검사. Swift의 취소는 협력적입니다. cancel이 호출되어도 Task는 명시적으로 검사할 때까지 계속 실행됩니다. 각 await는 검사할 기회입니다. await 이후의 검사가 없다면, 취소된 Task는 액티비티 상태를 계속 빌드하고 Activity.request를 호출해서 성공 시 조용히 고아 액티비티를 만들어냅니다.
defer는 Task 본문이 완료되기 전에 플래그를 클리어합니다. defer 없이는 (취소 검사로 인한) 이른 return이 isStartingActivity = true를 영구적으로 남기고, 앱을 다시 실행할 때까지 액티비티가 다시 시작되지 않습니다. 플래그는 잠금이고, 잠금은 모든 종료 경로에서 풀려야 합니다.
pushType: nil 인자. Return은 APNs로 푸시되는 Live Activity 업데이트를 사용하지 않습니다. 앱이 activity.update를 통해 액티비티를 로컬에서 업데이트합니다. 만약 푸시 기반 업데이트(배송 추적, 스포츠 점수, 실시간 데이터)가 필요하다면 타입은 pushType: .token이고 계약은 극적으로 더 복잡해집니다.5 로컬 업데이트는 더 간단하고 모든 타이머/카운터/단일 앱 워크플로우를 커버합니다.
일시정지 문제
ActivityKit은 앱의 어떤 업데이트 없이도 라이브 카운트다운을 렌더링하는 아름다운 Text(timerInterval: Date()...endTime, countsDown: true) 뷰를 제공합니다.6 종료 시간만 설정하면 시스템이 라이브 타이머를 렌더링합니다. Timer.publish도 없고, 위젯 새로고침도 없고, 배터리 소모도 없습니다.
타이머가 실행 중일 때는 환상적입니다. 타이머가 일시정지되었을 때는 잘못됐습니다.
timerInterval 텍스트는 상태에 어떤 “일시정지” 신호가 있든 상관없이 endTime을 향해 카운트다운합니다. Apple의 API에는 “10:23에 멈춘” 모드가 없습니다. endTime = Date().addingTimeInterval(623)을 전달하고 사용자가 10:23에 일시정지하면, 타이머 텍스트는 위젯에서 0까지 계속 카운트다운됩니다. 상태 필드는 일시정지라고 말하는데, 위젯은 실행 중으로 렌더링합니다.
해법은 같은 상태에서 두 가지 다른 뷰를 렌더링하는 것입니다.7
if context.state.isPaused {
// static text
Text(formatTime(context.state.remainingSeconds))
.monospacedDigit()
} else if context.state.endTime > Date() {
// live countdown
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
} else {
// post-end static
Text("0:00")
.monospacedDigit()
}
이 두 갈래 렌더링이 ContentState가 remainingSeconds를 별도 필드로 가지고 있는 이유입니다. 타이머가 실행 중일 때는 중복입니다(시스템이 endTime에서 계산함). 타이머가 일시정지되었을 때는 유일한 진실의 원천입니다. 구조체의 두 부분은 두 가지 다른 렌더링 모드를 위해 존재하고, isPaused 불리언이 둘 사이에서 선택합니다.
해제 정책
activity.end(_:dismissalPolicy:)는 세 가지 ActivityUIDismissalPolicy 값 중 하나를 받습니다. 잘못된 선택이 v1에서 사용자가 리셋한 후에도 잠금 화면에 영원처럼 느껴질 만큼 오래 남아있게 만든 원인이었습니다.13
| 정책 | 사용 시점 | 결과 |
|---|---|---|
.immediate |
사용자 리셋, 오류, 추적할 액티비티 없이 앱이 백그라운드로 갈 때 | 액티비티가 즉시 사라집니다. 유예 시간 없음 |
.after(date) |
완료 표시: “명상이 완료되었습니다”는 잠시 동안 읽혀야 합니다. 날짜는 Apple이 허용하는 4시간 윈도우 안이어야 합니다 | 액티비티가 최종 상태를 보여준 뒤 date에 해제됩니다 |
.default |
진심으로 Apple의 휴리스틱이 결정하기를 원할 때 | 시스템이 end 호출 후 최대 4시간까지 “한동안”(Apple의 표현) 표시 상태를 유지 |
Return은 자연스러운 완료 경로에 .after(Date().addingTimeInterval(3))를 사용합니다.3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
3초는 사용자가 잠금 화면을 흘끗 보고, 타이머가 끝났다는 것을 인식하고, 체크마크의 만족감을 느끼는 데 필요한 시간입니다. 3초 미만은 갑작스럽고, 3초 이상은 액티비티가 자기가 끝났다는 걸 모르는 것처럼 느껴집니다.
사용자가 트리거한 리셋의 경우, 호출은 dismissalPolicy: .immediate입니다. 윈도우 없음. 사용자는 이미 알고 있습니다.
v1의 잘못된 선택은 .default였습니다. 명상 타이머가 완료된 후에도 시스템이 액티비티를 충분히 오래 표시 상태로 유지해서 사용자들은 앱이 완료를 전혀 인식하지 못했다고 생각했습니다. Apple의 문서는 .default가 종료된 액티비티를 최대 4시간까지 “한동안” 표시 상태로 유지한다고 말합니다.13 타이머에 적합한 자세는 해제를 명시적으로 하는 것입니다.
Dynamic Island Compact 영역
Dynamic Island는 세 가지 렌더링 모드를 가지며, 단순한 타이머에서도 세 가지 모두가 필요합니다.2
- Compact (기본 Dynamic Island 모양): leading 아이콘 + trailing 타이머
- Minimal (다른 Live Activity가 같은 Dynamic Island를 두고 경쟁할 때): leading 아이콘만
- Expanded (길게 누르기): 4개의 명명된 영역(
leading,trailing,center,bottom)
Return에서 자리를 차지한 패턴은 expanded 뷰를 compact와 거의 동일하게 만드는 것입니다.8
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image("AppIconSmall")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
DynamicIslandExpandedRegion(.trailing) {
TimerText(...)
}
DynamicIslandExpandedRegion(.center) { EmptyView() }
DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
Image("AppIconSmall")...
} compactTrailing: {
TimerText(...)
} minimal: {
Image("AppIconSmall")...
}
대부분의 Live Activity 튜토리얼은 expanded 뷰를 “진짜” 디자인으로 강조하고, bottom 영역에 풍부한 콘텐츠를 넣습니다. 명상 타이머에 expansion은 죽은 무게입니다. 사용자는 길게 눌러서 expanded 뷰를 열고, 그 길게 누르는 동작 자체가 이미 무언가 일어났다는 햅틱 피드백을 줍니다. 콘텐츠를 추가하면 사용자가 묻지 않은 무언가를 말하는 셈이 됩니다. expanded 모드의 빈 영역들은 디자인의 실패가 아니라 디자인 그 자체입니다.
RTL 버그
프로덕션 버그입니다. iOS의 아랍어와 히브리어 사용자들이 Dynamic Island compact-trailing 타이머가 숫자를 거꾸로 렌더링한다고 보고했습니다. compact-trailing 레이아웃 방향이 시스템 로케일의 RTL 설정을 상속받았기 때문에 라틴 숫자 문자열 5:23이 32:5로 렌더링되었습니다.
SwiftUI는 위젯 프로세스 내부에서 시스템 레이아웃 방향을 상속받기 때문에, 사용자의 폰이 아랍어나 히브리어로 설정되어 있으면 Dynamic Island 타이머 텍스트가 RTL을 따랐습니다. 라틴 숫자는 그 외에는 RTL UI 안에서도 LTR로 렌더링되어야 합니다. 해법은 숫자 텍스트 뷰에 레이아웃 방향을 고정하는 것입니다.7
.environment(\.layoutDirection, .leftToRight)
이 오버라이드는 전체 뷰가 아니라 TimerText(Dynamic Island compact / expanded)와 잠금 화면 뷰 안의 숫자 Text 뷰에 들어갑니다. 라틴 숫자는 사용자의 시스템 로케일과 상관없이 좌에서 우로 읽히고, “Cycle 2 of 3” 같은 사이클 라벨은 시스템 레이아웃 방향을 따르도록 로컬라이즈된 채로 둡니다.
이 버그는 자국 로케일 TestFlight에서는 드러나지 않습니다. 실제 RTL 사용자가 타이머를 여는 순간 드러납니다. 교훈은 RTL 로케일에서 실행될 수 있는 모든 Live Activity의 모든 라틴 숫자 텍스트 뷰에 LTR 고정 environment 오버라이드를 출시하라는 것입니다.
로컬라이제이션 이야기
TimerActivityAttributes는 액티비티 생성 시 앱이 설정하는 languageCode: String 필드를 가지고 있습니다.9
let attributes = TimerActivityAttributes(
timerDuration: duration,
languageCode: settings.appLanguage // app's selected language, not system's
)
위젯 익스텐션은 이를 읽어 로컬라이즈된 문자열을 렌더링합니다.
private var locale: Locale {
let code = context.attributes.languageCode
return code.isEmpty ? .current : Locale(identifier: code)
}
private func localized(_ key: String.LocalizationValue) -> String {
String(localized: key, locale: locale)
}
위젯이 Locale.current를 읽게 두지 않고 앱이 자체 언어 코드를 전달하는 이유는, 위젯 익스텐션이 자체 프로세스에서 실행되기 때문입니다. 그 Locale.current는 앱이 선택한 로케일이 아니라 시스템 로케일입니다. 사용자가 iPhone은 영어로 두고 Return을 한국어로 설정한 경우, 이 오버라이드 없이는 위젯이 영어로 말하게 됩니다. 앱의 언어 선호도는 액티비티 속성을 통해 전달되고, 위젯이 이를 존중합니다.
Localizable.xcstrings는 앱 타깃과 함께 위젯 타깃에도 존재하지만 별도의 파일입니다. 위젯에서 사용되는 문자열은 같은 문자열이 Return/Localizable.xcstrings에 있더라도 ReturnWidgets/Localizable.xcstrings에 존재해야 합니다. 이를 잊으면 앱은 한국어로 말하는데 위젯은 개발 언어로 폴백됩니다.
다시 만든다면
ContentState를 더 작게 만들기. 6개 필드는 너무 많습니다. endTime과 remainingSeconds 사이의 중복은 timerInterval의 일시정지 모드 부재를 우회하는 데 드는 비용입니다. 다시 시작한다면 단일 displayMode 열거형(running, paused(remainingSeconds: Int), cycleEnd, complete)을 가지고 렌더링 코드가 케이스에 따라 분기하도록 할 것입니다. 6개 필드를 5개 전환 메서드 전반에서 올바르게 변경되도록 유지하는 것은 4개 케이스보다 어렵습니다.
인터랙티브 Live Activity 버튼 추가(iOS 17+). Return은 현재 Dynamic Island에서 일시정지/재개 컨트롤을 노출하지 않습니다. 사용자는 일시정지하려면 앱을 열어야 합니다. iOS 17은 Live Activities 안에서 App Intents를 위한 Button(intent:)을 추가했습니다.10 인터랙티브 일시정지 컨트롤은 명백한 확장이고 Return을 위해 다음에 출시할 것입니다.
기기 간 타이머 동기화를 위한 푸시 업데이트 Live Activities. Return은 NSUbiquitousKeyValueStore를 통해 iPhone, iPad, Watch, Apple TV 사이에서 세션을 동기화합니다(Five Apple Platforms, Three Shared Files에서 다룸). 현재 액티비티는 iPhone이나 iPad 앱에서 로컬로 시작되어 로컬로 업데이트됩니다. 사용자가 Apple Watch에서 타이머를 시작하면 이상적으로는 iPhone의 Live Activity가 실시간으로 그것을 반영할 수 있어야 합니다. APNs를 통한 Live Activity 푸시가 그 경로입니다.5 아직 구축하지 않았습니다.
Live Activities를 사용하면 안 되는 경우
일회성 일시 상태. “저장됨!” 토스트는 Live Activity를 받을 자격이 없습니다. 시스템에 배너가 있습니다. 그것을 사용하세요.
타이머 차원이 없는 자주 변하는 데이터. Live Activities는 명확한 시간 앵커가 있는 것들(타이머, 배송 ETA, 게임 시계, 통화 시간)에 가장 잘 작동합니다. 주식 시세와 스포츠 점수가 작동하는 이유는 세션 윈도우가 있기 때문입니다. 범용 대시보드는 그렇지 않습니다.
잠금 화면 / 스탠바이 사용 사례가 없는 앱. Live Activities는 진정한 엔지니어링 투자(타깃 설정, ContentState 디자인, 해제 정책 결정, RTL 처리, 로컬라이제이션 배관)가 필요합니다. 사용 중에 잠금 화면을 절대 참조하지 않고 사용자가 직접 여는 앱들은 적합한 형태가 아닙니다. 사진 편집기는 필요 없습니다. 운동 추적기는 필요합니다.
iOS 외 다른 플랫폼은 주의사항과 함께. Return의 LiveActivityManager는 타이머가 iPhone이나 iPad 앱에서 시작되기 때문에 구현을 #if os(iOS) 뒤에 출시합니다. ActivityKit 자체는 잠금 화면 배너, Dynamic Island, Apple Watch Smart Stack, Mac, CarPlay를 표시 표면으로 설명하며, iOS 26은 그 중 몇 가지를 확장했습니다.4 watchOS는 여전히 풀스크린 렌더링을 위한 자체 컴플리케이션 API를 가지고 있습니다. macOS는 메뉴바 앱을 가지고 있습니다. iPadOS는 iPadOS 17부터 Dynamic Island 영역 없이 Live Activities를 지원합니다. Return의 매니저는 224줄짜리 한 파일에 8개의 #if os(iOS) 가드를 가지고 있습니다.
iOS 26+에서 출시되는 앱에 이 패턴이 의미하는 것
두 가지 시사점.
-
Live Activity를 숫자가 아니라 상태 머신으로 다루세요. 상태 머신에는 명확한 상태, 명확한 전환, 명확한 해제 규칙이 있습니다. 화면의 숫자는 한 상태의 한 가지 렌더링일 뿐입니다. 상태를 먼저 올바르게 가져가세요.
-
재진입 가드가 아직 만나지 못한 버그입니다.
isStartingActivity+ 취소 가능한 Task를 구현하지 않은 모든 Live Activity 매니저는 적어도 한 번은 고아 액티비티 버그를 출시했습니다. 가드는 6줄입니다. 한 번 작성하세요.
이 글을 같은 앱 패밀리에 대한 이전 글들과 함께 읽어보세요. Apple Intelligence를 위한 타입화된 App Intents, 크로스 LLM 에이전트를 위한 MCP 서버, 시각 레이어를 위한 Liquid Glass 패턴, 기기 간 도달을 위한 멀티 플랫폼 출시. Live Activities는 같은 스택의 iOS 잠금 화면과 Dynamic Island 레이어입니다. 전체 모음은 Apple Ecosystem Series 허브에 있습니다. iOS와 AI 에이전트의 더 넓은 맥락은 iOS Agent Development 가이드를 참조하세요.
FAQ
Live Activities와 WidgetKit 위젯의 차이점은 무엇인가요?
WidgetKit 위젯은 TimelineProvider가 정의한 간격으로 렌더링됩니다. 시스템이 새로고침 시점을 결정하고 위젯은 정적 타임라인에서 다시 렌더링됩니다.11 Live Activities는 특정 앱 주도의 activity.update(...) 호출에 응답해 렌더링되며 기저 액티비티(타이머, 배송, 운동)의 지속 시간 동안 살아 있습니다. 둘 다 위젯 익스텐션 타깃에 출시됩니다. 차이는 트리거 모델입니다.
Live Activities는 iPad에서 작동하나요?
네, iPadOS 17+에서 작동합니다. 잠금 화면 배너가 주된 렌더링 표면입니다. iPad에는 Dynamic Island가 없습니다. 같은 ActivityConfiguration 코드가 작동합니다. Dynamic Island 영역은 iPad에서 절대 렌더링되지 않을 거라고 예상하면 됩니다.
Live Activity가 앱 프로세스보다 오래 살 수 있나요?
네. Activity.request가 성공하면 ActivityKit이 액티비티를 소유합니다. 앱 프로세스는 시스템에 의해 종료될 수 있습니다. 액티비티는 명시적으로 종료하거나(또는 시스템 staleness 규칙이 해제할 때까지) 잠금 화면과 Dynamic Island에서 계속 렌더링됩니다. 그래서 명시적인 endActivity() 호출이 중요합니다. 앱 리셋 시 명시적인 종료가 없으면 액티비티는 타이머보다 더 오래 살아남습니다.
왜 이 글은 푸시 업데이트 Live Activities를 다루지 않나요?
Return에서 푸시 업데이트 Live Activities를 출시하지 않았기 때문입니다. 이 클러스터의 장르 규칙에 따라 출시 코드 글은 프로덕션 코드가 하는 일만 문서화합니다. 푸시 업데이트는 “다시 만든다면”에 나열되어 있습니다. 미래의 글이 출시 후에 다룰 것입니다.
SwiftUI 앱에서 Live Activities의 실제 파일 레이아웃은 어떻게 되나요?
- 메인 앱 타깃에:
LiveActivityManager.swift(액티비티 라이프사이클 관리),TimerActivityAttributes.swift(위젯과 공유되는ActivityAttributes구조체. 두 타깃 모두 이 파일을 컴파일). - 위젯 익스텐션 타깃에:
ReturnLiveActivity.swift(ActivityConfiguration본문을 가진Widget준수),ReturnWidgetsBundle.swift(@main WidgetBundle). - 설정: 앱 타깃의
Info.plist에NSSupportsLiveActivities = YES.
위젯 익스텐션 타깃은 ActivityKit과 WidgetKit 임포트가 필요합니다. TimerActivityAttributes는 두 타깃 사이에 공유되는 유일한 파일이고, 나머지는 모두 타깃별로 격리됩니다.
Live Activity는 잠금 화면 위의 숫자가 아닙니다. 매번 전환 시 프로세스 경계를 가로지르는 상태 머신입니다. 상태를 올바르게 가져가고, 재진입을 보호하고, 해제 정책을 의도를 가지고 선택하고, 레이아웃 방향을 고정하세요. 숫자는 알아서 처리됩니다.
References
-
저자의 Return, 2026년 4월 21일에 App Store에 출시된 SwiftUI 명상 타이머. iPhone, iPad, Mac, Apple Watch, Apple TV에서 사용 가능. Live Activities는 iOS 타깃에서만 출시. ↩
-
Apple Developer, “ActivityKit framework”. 잠금 화면 배너, Dynamic Island compact / minimal / expanded 모드, 액티비티 라이프사이클. iOS 16.1+에서 사용 가능. Dynamic Island는 iPhone 14 Pro 이상에서 사용 가능. ↩↩
-
프로덕션 코드
Return/Return/LiveActivityManager.swift(224줄, 8개의#if os(iOS)블록)와Return/Return/TimerActivityAttributes.swift(43줄). 타깃 멤버십을 통해 앱 타깃과 위젯 익스텐션 타깃에서 공유. ↩↩↩↩↩ -
Apple Developer, “Displaying live data with Live Activities”. 동시성 제한, 지원 플랫폼(iOS 16.1+, iPadOS 17+),
NSSupportsLiveActivitiesInfo.plist 키. ↩↩ -
Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”.
pushType: .token경로는 별도의 APNs 인증 키, 서버 측 푸시 토큰 등록, 그리고 로컬activity.update(...)호출과 다른 업데이트 프로토콜을 필요로 함. ↩↩ -
Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. 라이브 시스템 렌더링 카운트다운 타이머. 액티비티가 실행 중인 동안 앱 업데이트 없이 렌더링됨. ↩
-
프로덕션 코드
Return/ReturnWidgets/ReturnLiveActivity.swift(232줄). 위젯 익스텐션의ActivityConfiguration<TimerActivityAttributes>본문을 가진Widget준수. 61-102줄의TimerText뷰가 일시정지 / 실행 중 / 종료 후 세 상태 렌더링을 처리. ↩↩↩↩ -
Apple Developer, “DynamicIsland”. 4개의 명명된 expanded 영역(
leading,trailing,center,bottom)과 3개의 compact 모드 뷰(compactLeading,compactTrailing,minimal). ↩ -
위젯 익스텐션은 자체 프로세스에서 실행되며 앱이 선택한 로케일이 아니라 시스템 로케일을 상속함. 인앱 언어 전환을 지원하는 앱(Return은 27개 언어 지원)은 위젯이 사용자가 선택한 언어로 렌더링할 수 있도록
ActivityAttributes를 통해 언어 코드를 전달해야 함. 패턴:Locale.current대신Locale(identifier: context.attributes.languageCode). ↩ -
Apple Developer, “Button(intent:)”. iOS 17+의 위젯과 Live Activity 뷰에서 사용 가능. 앱을 포그라운드로 가져오지 않고 App Intents를 잠금 화면 / Dynamic Island 컨트롤로 연결. ↩
-
Apple Developer, “TimelineProvider”. Live Activities 이전부터 있던 위젯 새로고침 모델. 시스템 관리 리로드 윈도우를 가진 사전 계산된 항목들. ↩
-
프로덕션 코드
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16줄).ReturnLiveActivity를 위젯 익스텐션의 유일한 위젯으로 등록하는@main WidgetBundle. 위젯 익스텐션의 필수 패턴. 시스템이 로드하는 것이 번들임. ↩ -
Apple Developer, “ActivityUIDismissalPolicy”. 세 가지 케이스:
.default,.immediate,.after(_:). Apple은.default가 종료된 Live Activity를 최대 4시간까지 “한동안” 표시 상태로 유지하며,.after(_:)는 같은 4시간 윈도우 내의 날짜를 받는다고 명시. ↩↩