현재 진행 중인 프로젝트에서 회원 가입 인증에 시간 제한을 두고있다. 5분으로 제한을 두었는데 백그라운드에서는 타이머가 동작하지 않는 것을 늦게 알았다. 찾아보니 Apple에서 백그라운드 동작을 막았다는 듯 하다.
어떻게 할까 고민을 했는데 마침 스터디에서 iOS 생명주기를 공부하고 있었고 이것을 잘 활용하면 구현할 수 있어 보였다.
기존에 사용하던 AppDelegate부터 살펴보자.
총 5가지 AppDelegate 메소드들, 여기서 SceneDelegate 메소드들까지 SwiftUI의 iOS14로 넘어오면서 간단히 변했다. 이것은 @Environment
프로퍼티 래퍼에 있는 ScenePhase
에서 확인할 수 있다.
ScenePhase에는 active, inactive, background
3가지 enum 타입이 있다. 각각 확인해보자.
import SwiftUI
struct BackgroundTimer: View {
@Environment(\.scenePhase) var scenePhase
var body: some View {
Text("Hello, World!")
.onChange(of: scenePhase) { newValue in
switch newValue {
case .active:
print("Active")
case .inactive:
print("Inactive")
case .background:
print("Background")
default:
print("scenePhase err")
}
}
}
}
각 첫번째가 active, 두번째가 inactive, 세번째가 background 상태다.
어플을 내린 후 다시 실행 시켰을 때의 결과다. 특정 뷰에서 onChange를 이용하고 있기때문에 active는 출력되지 않았다. 만약 보고 싶으면 실행의 @main
이되는 struct에서 이용하면 된다.
참고로 3번 이미지에서 2번처럼 되게 해도 inactive 상태가 되지는 않았다.
백그라운드에서 동작하지 않는 타이머를 만들어보자.
import SwiftUI
struct BackgroundTimer: View {
@Environment(\.scenePhase) var scenePhase
@State private var timeRemaining: Double = 4*60
var body: some View {
Text(getTimeString(time:timeRemaining))
.onChange(of: scenePhase) { newValue in
switch newValue {
case .active:
print("Active")
case .inactive:
print("Inactive")
case .background:
print("Background")
default:
print("scenePhase err")
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { Timer in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
} else {
Timer.invalidate()
}
})
}
}
func getTimeString(time: Double) -> String {
let minutes = Int(time) / 60 % 60
let seconds = Int(time) % 60
return String(format: "%02i:%02i", minutes, seconds)
}
}
남은 시간을 Int 타입으로 할 수도 있지만 나중을 위해서 Double로 만들어주었다. 저 타이머는 백그라운드에서 동작하지 않는다.
백그라운드 타이머라고 말은 하고 있지만 정확히 백그라운드 타이머는 아니다. 백그라운드에서 동작한 것 처럼 만들어주는 것 뿐이다. 우선 타이머가 백그라운드에서 동작하는지 출력을 통해 알아보았다.
Inactive 상태에서도 정상적으로 타이머는 동작한다. 쓰레드에 따라 처리되는게 다를거 같은데 백그라운드 상태로 넘어가면 시간이 바뀌지 않거나 딱 한번만 출력되고 끝난다.
import SwiftUI
struct BackgroundTimer: View {
@Environment(\.scenePhase) var scenePhase
@State private var timeRemaining: Double = 4*60
@State private var startTime = Date.now
var body: some View {
Text(getTimeString(time:timeRemaining))
.onChange(of: scenePhase) { newValue in
switch newValue {
case .active:
bgTimer()
case .inactive:
print(".inactive")
case .background:
print("Background")
default:
print("scenePhase err")
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { Timer in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
} else {
Timer.invalidate()
}
})
}
}
func getTimeString(time: Double) -> String {
let minutes = Int(time) / 60 % 60
let seconds = Int(time) % 60
return String(format: "%02i:%02i", minutes, seconds)
}
func bgTimer() {
let curTime = Date.now
let diffTime = curTime.distance(to: startTime)
let result = Double(diffTime.formatted())!
timeRemaining = 4*60 + result
if timeRemaining < 0 {
timeRemaining = 0
}
}
}
로직은 간단하다. 타이머를 시작한 시간을 저장한다. 그리고 scene이 active 상태가 된다면 현재 시간과의 차이를 구한다. 그 값을 최초 설정 시간(여기서는 4분)과 연산을 해서 남은 시간을 구해줄 수 있다. timeRemaining
을 Int로 할 경우 bgTimer()
를 실행시킬 때마다 계속 오차가 커진다. Double로 해주어도 완벽하지는 않겠지만 눈에 띄게 문제가 생기지는 않는다.
https://huniroom.tistory.com/entry/iOS14SwfitUI-SwiftUI-life-cycle-에서-딥링크-처리
안녕하세요 마지막에 잘못된 접근방식이라고 적으셨는데 수정한 다른 방법은 어떤걸까요?