SwiftUI는 MVVM 아키텍처와 어울리지 않는다는 의견이 많이 보입니다. ViewModel 역할을 이미 View에서 하고 있다는 생각에서 출발하는 것으로 보이는데, 그렇다면 어떤 형태가 어울릴까 생각해보려고 아래 프로젝트를 만들어봤습니다.
https://github.com/panther222128/musicvideosearch
항상 참고해오던 아래 링크의 프로젝트를 기반으로 합니다.
https://github.com/kudoleh/iOS-Clean-Architecture-MVVM
프로젝트를 만들면서 SwiftData도 사용해보려고 시도하던 중 마주친 문제가 있었습니다. 로컬 디바이스 데이터베이스 Storage 객체를 별도로 만들고 관리하려 했는데, 아래에서 보이는 @Environment(\.modelContext) private var modelContext
, @Query var scoredHechPlayers: [HECHPlayerEntity]
두 가지 프로퍼티를 다른 곳으로 옮길 수 없었습니다.
import Foundation
import SwiftUI
@main
struct QueryApp: App {
private let modelContainer: ModelContainer = {
do {
return try ModelContainer(for: HECHPlayerEntity.self)
} catch {
fatalError("Model container generation failed.")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(modelContainer)
}
}
}
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
private let hechPlayers: [HECHPlayer] = [.init(id: .init(), name: "김다인", number: 3, position: .setter),
.init(id: .init(), name: "황연주", number: 4, position: .oppositeSpiker),
.init(id: .init(), name: "김사랑", number: 6, position: .setter),
.init(id: .init(), name: "나현수", number: 9, position: .middleBlocker),
.init(id: .init(), name: "고민지", number: 10, position: .outsideHitter),
.init(id: .init(), name: "김주향", number: 11, position: .outsideHitter),
.init(id: .init(), name: "이다현", number: 12, position: .middleBlocker),
.init(id: .init(), name: "정지윤", number: 13, position: .outsideHitter),
.init(id: .init(), name: "양효진", number: 14, position: .middleBlocker),
.init(id: .init(), name: "고예림", number: 17, position: .outsideHitter),
.init(id: .init(), name: "한미르", number: 18, position: .outsideHitter),
.init(id: .init(), name: "정시영", number: 21, position: .outsideHitter),
.init(id: .init(), name: "위파위", number: 23, position: .outsideHitter),
.init(id: .init(), name: "모마", number: 99, position: .oppositeSpiker)]
@Query var scoredHechPlayers: [HECHPlayerEntity]
var body: some View {
HStack {
VStack {
ScrollView {
ForEach(hechPlayers) { player in
Button {
insert(hechPlayer: player)
} label: {
HStack {
Text(String(player.number))
.font(.system(size: 12))
Text(player.name)
.font(.system(size: 12))
Text(player.position.rawValue)
.font(.system(size: 12))
.padding()
Spacer()
}
}
}
}
}
Spacer()
VStack {
ScrollView {
VStack {
ForEach(scoredHechPlayers) { scoredPlayer in
HStack {
Text(String(scoredPlayer.number))
.font(.system(size: 12))
Text(scoredPlayer.name)
.font(.system(size: 12))
Text(scoredPlayer.position.rawValue)
.font(.system(size: 12))
.padding()
Spacer()
}
}
}
}
Spacer()
Button {
deleteLast()
} label: {
Text("Delete last")
}
}
}
.padding()
}
func insert(hechPlayer: HECHPlayer) {
modelContext.insert(HECHPlayerEntity(id: hechPlayer.id, name: hechPlayer.name, number: hechPlayer.number, position: hechPlayer.position))
}
func deleteLast() {
if let last = scoredHechPlayers.last {
modelContext.delete(last)
}
}
}
마침 설명을 위해 만든 작은 프로젝트에서 Model, Entity 객체도 아래에 남깁니다.
import Foundation
enum VolleyballPosition: String, Codable {
case setter = "세터"
case middleBlocker = "미들"
case libero = "리베로"
case oppositeSpiker = "아포짓"
case outsideHitter = "아웃사이드"
}
struct HECHPlayer: Identifiable {
let id: UUID
let name: String
let number: Int
let position: VolleyballPosition
}
import Foundation
import SwiftData
@Model
final class HECHPlayerEntity: Identifiable {
let id: UUID
let name: String
let number: Int
let position: VolleyballPosition
init(id: UUID, name: String, number: Int, position: VolleyballPosition) {
self.id = id
self.name = name
self.number = number
self.position = position
}
}
아래 gif처럼 잘 동작합니다.
먼저 @Environment(\.modelContext) private var modelContext
속성을 이동해보면 아래 메시지의 오류가 발생합니다. (오류가 발생하는 예시는 이 글의 가장 마지막에 남기겠습니다.)
Accessing Environment<ModelContext>'s value outside of being installed on a View. This will always read the default value and will not update.
이 글은 @Query
에 대한 내용이기 때문에 원인이 무엇인지 따로 적지 않겠습니다. View
타입 객체에 남기면서도 사용하려면 다른 객체의 메소드에서 파라미터로 받아 사용할 수 있습니다. 아래와 같습니다.
// Storage methods
func insert(musicVideo: MusicVideo, using modelContext: ModelContext) {
modelContext.insert(MusicVideoEntity(artistName: musicVideo.artistName, trackName: musicVideo.trackName))
}
func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext) {
modelContext.delete(musicVideoEntity)
}
func deleteAll(using modelContext: ModelContext) {
modelContext.container.deleteAllData()
}
이렇게 해도 동작은 잘 합니다. 그러나 @Query
프로퍼티 래퍼를 이동하면 View
가 업데이트되지 않습니다.
@Query
의 공식문서 설명은 아래와 같습니다.
A property wrapper that fetches a set of models and keeps those models in sync with the underlying data.
모델의 셋을 가져오고 기반 데이터를 동기화하는 프로퍼티 래퍼라고 합니다. 흥미로운 점은 마치 @State
프로퍼티 래퍼처럼 View
를 업데이트하기도 한다는 점입니다. Storage 객체에 옮기면서 값을 주입하는 형태도 생각해봤습니다. SwiftData의 Read는 @Query
프로퍼티 래퍼 속성으로 읽을 수도 있지만, 아래 메소드처럼 읽을 수도 있습니다.
func read(using modelContext: ModelContext) throws -> [MusicVideoEntity] {
do {
return try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
} catch {
throw MusicVideoStorageError.readFail
}
}
그렇다면 Storage 객체에 @Query
프로퍼티 래퍼 속성을 정의하고, 이 속성에 읽어온 값을 일치시키는 것이 가능할지도 모른다고 생각했습니다. 아래처럼 프로토콜에 속성을 정의하고, 이 프로토콜을 따르는 Storage 객체에서 @Query
프로퍼티 래퍼 속성을 정의해 값을 할당합니다.
import SwiftUI
import SwiftData
protocol MusicVideoStorage {
var musicVideos: [MusicVideoEntity] { get }
func read(using modelContext: ModelContext) -> [MusicVideoEntity]
func insert(musicVideo: MusicVideo, using modelContext: ModelContext)
func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext)
func deleteAll(using modelContext: ModelContext)
}
final class DefaultMusicVideoStorage: MusicVideoStorage {
@Query var musicVideos: [MusicVideoEntity]
func read(using modelContext: ModelContext) throws {
do {
self.musicVideos = try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
} catch {
throw MusicVideoStorageError.readFail
}
}
}
그러면 @Query
프로퍼티 래퍼 속성은 get-only 속성이기 때문에 할당할 수 없다는 오류가 발생합니다. 당연하다고 생각해볼 것이 로컬 데이터베이스에 접근하는데, CRUD의 안정성을 해칠 수 있기 때문에 이렇게 구현되어 있는 것이 아닐까 싶습니다.
속성을 Storage와 View 사이에서 옮겨가며 시도해보던 중 View 업데이트는 되지 않았지만 실제로 로컬 데이터베이스에 추가는 되고 있었다는 사실을 중간에 알았습니다. 즉 insert()
메소드를 사용하는 동안 View가 업데이트되지 않았는데, 다시 View에 옮겼을 때 데이터가 추가되어 있었습니다. @Query
프로퍼티 래퍼와 관계없이 추가, 삭제가 잘 된다는 점을 생각하고, 읽어오는 방법이 Query
프로퍼티 래퍼 속성만 있는 것은 아니기 때문에 읽어온 값을 @State
프로퍼티 래퍼 속성에 할당하는 방법이 있습니다. 아래와 같습니다.
@main
struct MusicVideoDataApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: [MusicVideoEntity.self])
}
}
}
struct MusicVideo: Identifiable {
let id: UUID
let artistName, trackName: String
}
@Model
final class MusicVideoEntity: Identifiable {
var id: UUID
var artistName: String
var trackName: String
init(artistName: String, trackName: String) {
self.id = .init()
self.artistName = artistName
self.trackName = trackName
}
}
protocol MusicVideoStorage {
func read(using modelContext: ModelContext) -> [MusicVideoEntity]
func insert(musicVideo: MusicVideo, using modelContext: ModelContext)
func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext)
func deleteAll(using modelContext: ModelContext)
}
final class DefaultMusicVideoStorage: MusicVideoStorage {
init() {
}
func read(using modelContext: ModelContext) -> [MusicVideoEntity] {
do {
return try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
} catch {
fatalError("Context fetch failed")
}
}
func insert(musicVideo: MusicVideo, using modelContext: ModelContext) {
modelContext.insert(MusicVideoEntity(artistName: musicVideo.artistName, trackName: musicVideo.trackName))
}
func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext) {
modelContext.delete(musicVideoEntity)
}
func deleteAll(using modelContext: ModelContext) {
modelContext.container.deleteAllData()
}
}
struct MusicVideoView: View {
@Environment(\.modelContext) private var modelContext
private let storage: MusicVideoStorage
private let musicVideo: MusicVideo = .init(id: .init(), artistName: "NewJeans", trackName: "Ditto")
@State var musicVideos: [MusicVideoEntity] = []
var body: some View {
VStack {
ScrollView {
VStack {
ForEach(musicVideos) { musicVideo in
HStack {
Text(musicVideo.artistName)
Text(musicVideo.trackName)
}
}
}
.onAppear {
read()
}
}
HStack {
Button {
self.insert(musicVideo: musicVideo)
} label: {
Text("Insert")
}
Button {
self.delete(musicVideoEntity: musicVideos[0])
} label: {
Text("Delete")
}
}
}
}
init(storage: MusicVideoStorage) {
self.storage = storage
}
func read() {
musicVideos = storage.read(using: modelContext)
}
func insert(musicVideo: MusicVideo) {
storage.insert(musicVideo: musicVideo, using: modelContext)
musicVideos = storage.read(using: modelContext)
}
func delete(musicVideoEntity: MusicVideoEntity) {
storage.delete(musicVideoEntity: musicVideoEntity, using: modelContext)
musicVideos = storage.read(using: modelContext)
}
}
struct ContentView: View {
var body: some View {
MusicVideoView(storage: DefaultMusicVideoStorage())
}
}
주의: 위 프로젝트는 첫 번째 인덱스 아이템을 삭제하고 있으므로 count가 0일 때, 즉 비어있을 때 삭제를 시도하면 오류가 발생합니다.
데이터를 추가, 삭제하는 과정에서 View
의 업데이트가 되려면 추가, 삭제 후 다시 읽어오는 과정이 필요합니다.
func read() {
musicVideos = storage.read(using: modelContext)
}
func insert(musicVideo: MusicVideo) {
storage.insert(musicVideo: musicVideo, using: modelContext)
musicVideos = storage.read(using: modelContext)
}
func delete(musicVideoEntity: MusicVideoEntity) {
storage.delete(musicVideoEntity: musicVideoEntity, using: modelContext)
musicVideos = storage.read(using: modelContext)
}
그런데 아쉬운 부분이 생깁니다. @Query
는 아래처럼 정렬을 돕기도 합니다.
@Query(sort: \.name) var musicVideos: [MusicVideoEntity]
그 외에 order
, animation
등 @Query
가 제공하는 많은 기능을 사용할 수 없게 됩니다.
아키텍처를 시도하던 중 만나게 된 문제를 해결하려 했지만, 이 시도가 애플이 제공하려는 SwiftUI, SwiftData의 다양한 기능을 사용하지 못하도록 하는 것이 아닌가 하는 생각이 듭니다. 글의 시작 부분 남긴 프로젝트는 업데이트될 수 있으며, 다른 방식으로 해결하거나 새로운 문제를 만나게 되면 다시 글을 남겨보도록 하겠습니다.
import SwiftUI
import SwiftData
@main
struct MusicVideoDataApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: [MusicVideoEntity.self])
}
}
}
struct MusicVideo: Identifiable {
let id: UUID
let artistName, trackName: String
}
@Model
final class MusicVideoEntity: Identifiable {
var id: UUID
var artistName: String
var trackName: String
init(artistName: String, trackName: String) {
self.id = .init()
self.artistName = artistName
self.trackName = trackName
}
}
extension MusicVideoEntity {
func toDomain() -> MusicVideo {
return .init(id: id, artistName: artistName, trackName: trackName)
}
}
protocol MusicVideoStorage {
func read() -> [MusicVideoEntity]
func insert(musicVideo: MusicVideo)
func delete(musicVideoEntity: MusicVideoEntity)
func deleteAll()
}
final class DefaultMusicVideoStorage: MusicVideoStorage {
@Environment(\.modelContext) private var modelContext
init() {
}
func read() -> [MusicVideoEntity] {
do {
return try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
} catch {
fatalError("Context fetch failed")
}
}
func insert(musicVideo: MusicVideo) {
modelContext.insert(MusicVideoEntity(artistName: musicVideo.artistName, trackName: musicVideo.trackName))
}
func delete(musicVideoEntity: MusicVideoEntity) {
modelContext.delete(musicVideoEntity)
}
func deleteAll() {
modelContext.container.deleteAllData()
}
}
struct MusicVideoView: View {
private let storage: MusicVideoStorage
private let musicVideo: MusicVideo = .init(id: .init(), artistName: "NewJeans", trackName: "Ditto")
@State var musicVideos: [MusicVideoEntity] = []
var body: some View {
VStack {
ScrollView {
VStack {
ForEach(musicVideos) { musicVideo in
HStack {
Text(musicVideo.artistName)
Text(musicVideo.trackName)
}
}
}
.onAppear {
read()
}
}
HStack {
Button {
self.insert(musicVideo: musicVideo)
} label: {
Text("Insert")
}
Button {
self.delete(musicVideoEntity: musicVideos[0])
} label: {
Text("Delete")
}
}
}
}
init(storage: MusicVideoStorage) {
self.storage = storage
}
func read() {
musicVideos = storage.read()
}
func insert(musicVideo: MusicVideo) {
storage.insert(musicVideo: musicVideo)
musicVideos = storage.read()
}
func delete(musicVideoEntity: MusicVideoEntity) {
storage.delete(musicVideoEntity: musicVideoEntity)
musicVideos = storage.read()
}
}