본문 바로가기

iOS 앱 개발자 프로젝트

[iOS] App Groups 설정 및 컨테이너 경로 설정 (Feat. Widget)

App Groups ▽

 

Configuring App Groups | Apple Developer Documentation

Enable communication and data sharing between multiple installed apps created by the same developer.

developer.apple.com


 

[iOS] Widget - IntentConfiguration (1)  첫 부분에도 컨테이너와 관련하여 간단히 설명했듯

앱과 위젯은 서로 격리되어 있어 직접적인 데이터 공유가 불가능하기에 앱 그룹을 통해 이런 제한을 극복하고,

앱과 위젯이 서로 데이터를 공유할 수 있도록 해야한다. 오늘은 위젯이 앱 그룹을 통해 메인 앱과 데이터를 공유하는 방식과 그 방법(설정)에 대해 챌린더 프로젝트의 방식을 기록하며 정리한다. 

 

 

 

App Group

 

앱 그룹은 여러 앱 또는 앱 확장이 데이터를 공유할 수 있도록 하는 방법

앱 그룹을 사용하면 동일한 앱 그룹 식별자를 가진 앱들이 동일한 컨테이너에 접근하여 데이터 공유를 가능하게 해준다.



 

App Group을 설정하고, 

CoreDataManager file에서 URL Extension을 사용하여 데이터베이스 파일에 대한 URL을 생성했다. 이 URL은 앱 그룹 컨테이너 내부에 저장된 파일에 접근할 수 있게 하고 이를 통해 앱과 위젯(확장 프로그램)이 동일한 데이터베이스 파일을 공유할 수 있도록 해준다.

 

 

 

CoreDataManager 파일에 추가한 URL Extension ▽

extension URL {
    /// Returns a URL for the given app group and database pointing to the sqlite database.
    static func storeURL(for appGroup: String, databaseName: String) -> URL {
        guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
            fatalError("Shared file container could not be created.")
        }
        return fileContainer.appendingPathComponent("\(databaseName).sqlite")
    }
}

 

 

  • storeURL(for:databaseName:) 메서드
    • 앱 그룹 식별자와 데이터베이스 이름을 받아 해당 데이터베이스 파일에 접근할 수 있는 URL을 반환
  • FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:) 메서드
    • 주어진 앱 그룹 식별자(appGroup)에 해당하는 공유 컨테이너 디렉토리의 URL을 반환
    • 반환된 URL에 데이터베이스 이름을 추가하여 SQLite 데이터베이스 파일의 위치를 지정

 

 

 

챌린더의 데이터(데이터 매니저는) 앱 그룹과 CloudKit을 통해 운영된다. 

 

  • 데이터 공유: 앱 그룹을 사용하여 여러 앱 또는 앱 확장이 동일한 데이터베이스 파일에 접근할 수 있도록 함.
  • 컨테이너 URL 설정: URL.storeURL(for:databaseName:) 메서드를 통해 앱 그룹 컨테이너의 경로를 설정.
  • Persistent Container 설정: Core Data와 CloudKit의 통합을 설정할 때 앱 그룹 컨테이너를 사용하여 데이터베이스 파일을 저장 및 로드.

 

 

** 데이터매니저 코드 분석하기**

import UIKit
import CoreData
import UserNotifications

extension URL {
    /// Returns a URL for the given app group and database pointing to the sqlite database.
    static func storeURL(for appGroup: String, databaseName: String) -> URL {
        guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
            fatalError("Shared file container could not be created.")
        }
        
        return fileContainer.appendingPathComponent("\(databaseName).sqlite")
    }
}

// CoreData 함수용 Manager 싱글톤
class CoreDataManager {

    static let shared = CoreDataManager()
    
    private init() {
        // 요청 알림 권한
        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            if let error = error {
                print("Notification permission error: \(error)")
            }
        }
    }
    
    // Core Data persistent container 초기화
    lazy var persistentContainer: NSPersistentCloudKitContainer = {
        let container = NSPersistentCloudKitContainer(name: "Challendar")
        let storeURL = URL.storeURL(for: "group.com.seungwon.ChallendarGroup", databaseName: "Challendar")
        let storeDescription = NSPersistentStoreDescription(url: storeURL)
        container.persistentStoreDescriptions = [storeDescription]

        
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("Failed to initialize persistent Container")
        }
        
        // CloudKit 및 CoreData 설정
//        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
//        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
//
//        // CloudKit 컨테이너 식별자 설정
//        description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.seungwon.Challendar")
//
//        container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
//        container.viewContext.automaticallyMergesChangesFromParent = true
        
        container.loadPersistentStores { storeDescription, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
        
        // 원격 변경 알림 구독
        NotificationCenter.default.addObserver(self, selector: #selector(storeRemoteChange(_:)), name: .NSPersistentStoreRemoteChange, object: container.persistentStoreCoordinator)
        
        return container
    }()
    
    // Core Data context 반환
    var context: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    // 변경 토큰을 저장할 변수
    private var lastHistoryToken: NSPersistentHistoryToken? {
        get {
            guard let data = UserDefaults.standard.data(forKey: "lastHistoryToken") else { return nil }
            return try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSPersistentHistoryToken.self, from: data)
        }
        set {
            guard let token = newValue else { return }
            let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
            UserDefaults.standard.set(data, forKey: "lastHistoryToken")
        }
    }
    
    // 원격 변경 알림 처리
    @objc private func storeRemoteChange(_ notification: Notification) {
        fetchHistoryAndUpdateContext()
    }
    
    // 앱 시작 시 동기화 트리거
    func triggerSync() {
        fetchHistoryAndUpdateContext()
    }
    
    // 변경 사항 가져와서 컨텍스트 업데이트
    private func fetchHistoryAndUpdateContext() {
        context.perform {
            do {
                // 변경 사항 가져오기
                let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastHistoryToken)
                if let historyResult = try self.context.execute(fetchRequest) as? NSPersistentHistoryResult,
                   let transactions = historyResult.result as? [NSPersistentHistoryTransaction] {
                    for transaction in transactions {
                        self.context.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
                    }
                    // 마지막 토큰 업데이트
                    self.lastHistoryToken = transactions.last?.token
                }
                try self.context.save()
                NotificationCenter.default.post(name: NSNotification.Name("CoreDataChanged"), object: nil, userInfo: nil)
                // 로컬 알림 생성
//                self.sendLocalNotification()
                
            } catch {
                print("Failed to process remote change notification: \(error)")
            }
        }
    }
    
    // 로컬 알림 생성 메서드
    private func sendLocalNotification() {
        let content = UNMutableNotificationContent()
        content.title = "Data Updated"
        content.body = "Data has been synchronized with CloudKit."
        content.sound = UNNotificationSound.default
        
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
        
        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
    }
    
    // CRUD Operations
    
    // Create
    func createTodo(newTodo: Todo) {
        let todo = TodoModel(context: context)
        todo.id = UUID()
        todo.title = newTodo.title
        todo.memo = newTodo.memo
        todo.startDate = newTodo.startDate
        todo.endDate = newTodo.endDate
        todo.completed = newTodo.completed
        
        todo.isChallenge = newTodo.isChallenge
        todo.percentage = newTodo.percentage
        if let imagesArray = newTodo.images {
            let imageData = imagesArray.map { $0.pngData() }
            todo.images = try? JSONEncoder().encode(imageData)
        }
        todo.isCompleted = newTodo.iscompleted
        saveContext()
    }
    
    // Save context
    func saveContext() {
        if context.hasChanges {
            do {
                try context.save()
                NotificationCenter.default.post(name: NSNotification.Name("CoreDataChanged"), object: nil, userInfo: nil)
                print("NOTIFICATIONSD")
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
    
    // Read
    func fetchTodos() -> [Todo] {
        let fetchRequest: NSFetchRequest<TodoModel> = TodoModel.fetchRequest()
        
        // 날짜 내림차순 정렬 요청
        let dateOrder = NSSortDescriptor(key: "startDate", ascending: false)
        fetchRequest.sortDescriptors = [dateOrder]
        
        do {
            let todoModels = try context.fetch(fetchRequest)
            return todoModels.map { model in
                let images: [UIImage]? = {
                    if let imageData = model.images, let decodedData = try? JSONDecoder().decode([Data].self, from: imageData) {
                        return decodedData.compactMap { UIImage(data: $0) }
                    }
                    return []
                }()
                return Todo(
                    id: model.id,
                    title: model.title,
                    memo: model.memo,
                    startDate: model.startDate,
                    endDate: model.endDate,
                    completed: model.completed,
                    isChallenge: model.isChallenge,
                    percentage: model.percentage,
                    images: images,
                    iscompleted: model.isCompleted
                )
            }
        } catch {
            print("Failed to fetch todos: \(error)")
            return []
        }
    }
    
    func fetchTodoById(id: UUID) -> TodoModel? {
        let fetchRequest: NSFetchRequest<TodoModel> = TodoModel.fetchRequest()
        fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
        
        do {
            let todos = try context.fetch(fetchRequest)
            return todos.first
        } catch {
            print("Failed to fetch todo by id: \(error)")
            return nil
        }
    }
    
    // Update
    func updateTodoById(id: UUID, newTitle: String? = nil, newMemo: String? = nil, newStartDate: Date? = nil, newEndDate: Date? = nil, newCompleted: [Bool]? = nil, newIsChallenge: Bool? = nil, newPercentage: Double? = nil, newImages: [UIImage]? = nil, newIsCompleted: Bool? = nil) {
        guard let todoToUpdate = fetchTodoById(id: id) else {
            print("Todo not found")
            return
        }
        
        if let newTitle = newTitle {
            todoToUpdate.title = newTitle
        }
        if let newMemo = newMemo {
            todoToUpdate.memo = newMemo
        }
        if let newStartDate = newStartDate {
            todoToUpdate.startDate = newStartDate
        }
        if let newEndDate = newEndDate {
            todoToUpdate.endDate = newEndDate
        }
        if let newCompleted = newCompleted {
            todoToUpdate.completed = newCompleted
            todoToUpdate.percentage = Double(newCompleted.filter{$0 == true}.count) / Double(newCompleted.count)
        }
        if let newIsChallenge = newIsChallenge {
            todoToUpdate.isChallenge = newIsChallenge
        }
        if let newPercentage = newPercentage {
            todoToUpdate.percentage = newPercentage
        }
        if let newImages = newImages {
            let imageData = newImages.map { $0.pngData() }
            todoToUpdate.images = try? JSONEncoder().encode(imageData)
        }
        if let newIsCompleted = newIsCompleted {
            todoToUpdate.isCompleted = newIsCompleted
        }
        
        saveContext()
    }
    
    // Delete
    func deleteTodoById(id: UUID) {
        guard let todoToDelete = fetchTodoById(id: id) else {
            print("Todo not found")
            return
        }
        
        context.delete(todoToDelete)
        saveContext()
    }
    
    func deleteAllTodos() {
        let fetchRequest: NSFetchRequest<NSFetchRequestResult> = TodoModel.fetchRequest()
        let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
        
        do {
            try context.execute(deleteRequest)
            saveContext()
        } catch {
            print("Failed to delete all todos: \(error)")
        }
    }
}