Realm과 Widget(위젯)
WWDC 에선 Personalized하고 glanceable한 위젯이 좋은 위젯이라고 한다.
personalized한 위젯이란?
날씨 위젯처럼 개인이 사는 지역마다 다른 정보를 알려줘야한다. 이런 개인에 따라 맞춤 정보를 제공하는 것을 Personalized라고 한다.
glanceable한 위젯이란?
어떤 동작을 하지 않아도 바로 간편하게 원하는 정보를 확인할 수 있는 것을 glanceable한 위젯이라고 한다.
WWDC에서 강조한 부분은 위젯은 작은 앱이 아님(Widget are not mini-apps)이다.
위젯은 사용자가 필요한 정보를 볼 수 있게 해주는 전광판이라고한다.
하지만 여러 위젯들을 보면 아래처럼 버튼이 있는 것을 볼 수 있는데
실질적으론 위젯 내부에서 송금, 잔액확인들을 하는게 아니라 Link를 해서 앱의 원하는 화면을 띄워주는 방식으로 동작한다.
또한 위젯의 메모리 제한은 30mb이므로 만들 때 주의해야한다.
필자는 디데이 앱의 위젯을 추가했다.
우선 타겟을 추가해줘야한다
IncludeLiveActivity - 다이나믹 아일랜드를 만들 수 있는 설정
다음 Configureation intent는 이 항목을 체크하면 IntentConfiguration 위젯을 만들 수 있다.
IntentConfiguration위젯은 기본 달력 위젯처럼 개인이 설정할 수 있는 항목을 가진 위젯이다.
체크하지 않으면 StaticConfiguration위젯을 말하는데, 설정 항목이 없는 위젯을 말하게 된다.
나는 이미지와, 디데이만 확인할 수 있는 위젯을 만들거기 때문에 StaticConfiguration으로 생성한다.
생성하면 코드가 많이 작성되어 있다.
하나하나 어느 역할을 하는 코드인지 알아보겠지만 위젯은 SwiftUI로 만들 수 있는데!!
난 SwiftUI를 아직 잘 모른다..
Today 구조체는 Widget 프로토콜을 채택하고있다.
Widget프로토콜의 설명을 보면 홈 스크린 또는 알림 센터에 표시할 위젯의 구성 및 내용이라고 정의되어있다.
위에서 설명한 StaticConfiguration으로 생성이 되었다.
kind는 위젯을 식별하기 위한 아이디이고, provider는 위젯을 새로고침할 타임라인을 결정하는 객체이다. 마지막으로 클로저 안에는 위젯 랜더링을 위한 TodayEntryView(SwiftUIView)가 포함되어있다. 그럼 다음으로 TodayEntryView를 보게되면
SwiftUI문법을 통해 실제 뷰를 그리는 객체이다. 위젯의 구성요소들을 여기에 추가해주게되면 된다.
마지막으로 설정 부분이다.
configurationDisplayName은 위젯 추가 화면에서 보여질 이름을 설정하는 부분이며 description은 제목 아래에 위젯의 설명을 추가할 수 있다.
supprotedFamilies를 통해 제공할 위젯의 크기, 스타일을 지정할 수 있다.
Provider는 TimelineProvider를 채택하고 있다. TimeLineProvider 프로토콜은 위젯을 업데이트할 시기를 Widgetkit에 알려주는 프로토콜이다.
위젯의 업데이트 동작 방식은 우리가 업데이트 시키고 싶은 시간을 위젯에게 미리 알려주면위젯이 미리 그 시간의 화면을 구성해 두었다가 변경하는 방식으로 동작한다고 한다. 기본적으로 작성되어있는 코드는 entries 배열에 반복문으로 현재시간의 1, 2, 3, 4, 5시간 뒤까지 시간을 담는 반복문이 작성되어있다. 현재 시간이 1시라고 하면 2시 3시 4시 5시 6시까지 총 5개의 시간이 담기게 되고 담아둔 시간을 timeline에 담아서 complition으로 전달한다. 그럼 담아둔 시간 마다 위젯이 업데이트된다.
그 후에는 업데이트가 되지 않는건가 라고 생각할 수 있지만 timeline에 담긴 policy를 통해 새로운 타임라인을 생성하도록 할 수 있다.
policies
policies는 3개가 존재한다.
atEnd - 타임라인의 마지막 시간이 지난 후 Widgetkit이 새로운 타임라인을 요청하게된다. 6시 이후이므로 7시, 8시, 9시 .. 이렇게!
after - atEnd와 비슷하지만 휴식기? 를 설정할 수 있다고 할까? Date에 3을 넣는다면 6시로부터 3시간이 지난 9시 부터 10, 11, 12, 13,1 4.. 이렇게 시간이 생성된다.
마지막으로 never는 6시 이후로 더이상 갱신하지 않겠다고 설정하는 것이다.
하지만 애플은 우리가 정확히 갱신 시기를 잡아둬도 덥데이트가 그 시간에 정확히 되진 않는다고 한다.
또한 하루에 40-70번 정도의 갱신만 가능하다고 한다.
추가한 Widget에도 Realm을 추가해준다.
이렇게 되면 처음엔 오류가 발생한다 Target설정을 해줘야하기 때문이다.
위젯은 기존의 앱과는 다른 Target으로 존재하게 된다.
Target이란?
빌드할 프로덕트를 정의하고 프로젝트 or 워크스페이스 파일로 부터 빌드되는 프로덕트에 지시들을 포함한다. 또한 Target은 하나의 프로덕트를 정의하고 있다. 쉽게 Target은 하나의 앱이고, 하나의 프로젝트는 여러개의 Target으로 이루어질 수 있다. Target별로 빌드 설정을 달리할 수 있다.
결국 Target은 하나의 앱이니까 Widget또한 별도로 빌드가 가능하다.
이후 Realm으로 위젯에 표시할 데이터를 가져오기 전에 알아야 하는 내용이 있다.
App과 AppExtension은 각각 다른 container를 가지고 있다. 그러므로 App container안에 있는 파일을 가져와서 shared container에 추가하는 과정을 거친 후 위젯에서 읽어야 한다.
이런 shared container는 AppGroup을 통해서 만들 수 있다.
타겟에 AppGroup을 추가할 수 있고 동일한 앱 그룹을 사용하게 설정할 수 있다.
이후 기기에 저장된 realm파일에 접근해야한다.
realm파일에 접근을 해야하는데, realm파일을 shared container로 복사하는 방법, 옮기는 방법, 뭐 여러가지 방법이 있겠지만 간단한 앱이므로 데이터를 shared container에서 쓰고 지워도 상관 없겠다. 싶어서 기존 Realm데이터를 저장하는 위치를 변경했다.
데이터를 쓸 때 마다 파일을 복사하는 코드를 집어넣느니 아예 shared container에서 데이터를 관리하면 어떨까 라는 생각이었다.
// Realm 데이터베이스 객체를 생성한다.
private var realm: Realm{
// 컨테이너를 지정한다 fileManager를 사용해 ApplicationGroupIdentifier를 활용해 containerURL을 가져온다
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.OurToday")
// realm이 저장될 주소를 지정한다. 컨테이너 내부에 default.realm 파일을 사용한다
let realmURL = container?.appendingPathComponent("default.realm")
// 지정된 주소로 Realm Configuration을 사용하여 Realm에 접근할 때 마다 지정된 주소로 사용한다.
let config = Realm.Configuration(fileURL: realmURL, schemaVersion: 1)
return try! Realm(configuration: config)
}
이렇게 주소를 직접 지정해서 sharedContainer를 사용하도록 했고, 앱과 위젯 모두 이 relam을 생성해 같은 파일을 읽고 쓰도록 했다.
위젯에서 사용한 RealmManager이다.
import UIKit
import RealmSwift
final class WidgetRealmManager{
static let shared: WidgetRealmManager = .init()
private init(){}
private var localRealm: Realm {
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.OurToday")
let realmURL = container?.appendingPathComponent("default.realm")
let config = Realm.Configuration(fileURL: realmURL, schemaVersion: 1)
return try! Realm(configuration: config)
}
func fetch() -> LoveModel {
guard let data = localRealm.objects(LoveModel.self).first else {
return LoveModel()
}
return data
}
func getMainBackgroundImage() -> UIImage{
guard let data = localRealm.objects(LoveModel.self).first,
let imageData = data.mainImage else {
return #imageLiteral(resourceName: "mainImage")
}
let image = UIImage(data: imageData)
let size = CGSize(width: 364, height: 180)
if let resizedImage = image?.resize(to: size){
return resizedImage
}
return #imageLiteral(resourceName: "mainImage")
}
}
LoveModel 구조체 내부에는 image를 담을 수 있도록 해놓았는데 문제가 생겼다.
이 문제 때문에 3일을 날린것같다. LoveModel을 가져오고, 내부에서 image를 가져오도록 코드를 작성했는데 이미지가 표시되지 않고 위젯이 표시되지 않고 회색 화면만 나오는 문제가 고생을 시켰다.
구조체가 생성자로 초기화 되기 이전에 이미지를 로드시켜서 그런것인지 정확한 원인은 모르겠으나 이미지를 Fetch하는 함수를 사용해 이미지를 설정해주니 해결됐다. 이런 간단한 문제를 가지고 3일을 낭비하다니..
Before
var mainImage: UIImage{
guard let data = loveData.mainImage else { return #imageLiteral(resourceName: "mainImage")}
let image = UIImage(data: data)
let size = CGSize(width: 364, height: 180)
let resizedImage = image?.resize(to: size)
return resizedImage ?? #imageLiteral(resourceName: "mainImage")
}
var body: some View {
ZStack(alignment: .center){
GeometryReader{ geo in
Image(uiImage: mainImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geo.size.width, height: geo.size.height)
After
var body: some View {
ZStack(alignment: .center){
GeometryReader{ geo in
Image(uiImage: WidgetRealmManager.shared.getMainBackgroundImage())
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geo.size.width, height: geo.size.height)