SwiftUI中如何向Firestore添加动态子集合数据(角色解锁场景)
Swift+Firebase 步数换积分解锁角色功能问题修复
问题背景
- 基于Swift + Firebase实现HealthKit步数兑换积分解锁角色功能,已完成
characters集合搭建与App内展示 - 核心逻辑:步数兑换积分→消耗积分解锁角色,解锁的角色存入解锁列表后展示在个人页
- 待实现需求:
- 将用户正在解锁的角色数据存入Firebase对应用户文档,采用子集合存储方案
- 限制单用户同时解锁角色数量上限为2
- 用户上传步数兑换积分后,自动累加对应解锁中角色的总积分,角色总积分为所有解锁该角色的用户贡献积分总和
- 当前存在三类报错:UserModel递归引用报错、Firestore写入自定义对象类型不匹配报错、Codable协议不匹配报错
现有代码梳理
Character模型
struct Character: Identifiable, Decodable { @DocumentID var id: String? var character_name: String var character_type: String var character_image: String var character_details: String var character_usersUnlocking: Int var character_totalPoints: Int var user: UserModel? var didUnlock: Bool? = false // 标记是否处于解锁中状态 var isUnlocking: Bool = false }
UnlockingCharacters模型
struct UnlockingCharacters: Identifiable { var id = UUID().uuidString var character: Character }
SharedDataModel
class SharedDataModel: ObservableObject { // 正在解锁的角色列表 @Published var unlockingCharacters: [Character] = [] }
现有业务函数
func isUnlocked() -> Bool { return sharedData.unlockingCharacters.contains { characterData in return self.characterData.id == characterData.id } } func addToUnlocking() { if let index = sharedData.unlockingCharacters.firstIndex(where: { characterData in return self.characterData.id == characterData.id }){ // 从解锁列表移除 sharedData.unlockingCharacters.remove(at: index) } else { // 加入解锁列表 sharedData.unlockingCharacters.append(characterData) } }
现有Firestore读写逻辑
let ref = Firestore.firestore() func fetchUser(uid: String,completion: @escaping (UserModel) -> ()){ let db = Firestore.firestore() ref.collection("Users").document(uid).getDocument { (doc, err) in guard let user = doc else{return} let username = user.data()?["username"] as? String ?? "No Username" let pic = user.data()?["imageurl"] as? String ?? "No image URL" let bio = user.data()?["bio"] as? String ?? "No bio" let uid = user.data()?["uid"] as? String ?? "" do { try db.collection("Users").document("\(uid)").setData(from: UnlockingCharacters) } catch let error { print("Error writing object to Firestore: \(error)") } DispatchQueue.main.async { completion(UserModel(username: username, pic: pic, bio: bio, uid: uid, activeUnlockingCharacters: UnlockingCharacters)) } } }
报错原因说明
- UserModel递归引用报错:
Character结构体是值类型,内部持有UserModel?类型属性,而UserModel同样是值类型,内部又持有包含Character的UnlockingCharacters属性,二者嵌套形成递归引用,系统无法计算值类型的固定内存大小直接报错。 - Encodable协议不匹配报错:
UnlockingCharacters和关联模型没有遵守Codable协议,Firestore的setData(from:)方法要求传入参数必须遵守Encodable;同时代码里直接传入了UnlockingCharacters类型本身,而非实际的类型实例。 - 参数类型不匹配报错:
UserModel中activeUnlockingCharacters定义为单个UnlockingCharacters类型,实际代码传入的是类型名而非数组实例,和预期的[UnlockingCharacters]数组类型完全不匹配。
修复方案
1. 解决递归引用问题
删除Character模型里冗余的var user: UserModel?属性,角色本身不需要嵌套持有完整用户信息,仅存储贡献用户的uid即可,从根源切断值类型递归引用链。
修改后的Character模型,统一遵守Codable协议适配Firestore序列化:
struct Character: Identifiable, Codable { @DocumentID var id: String? var character_name: String var character_type: String var character_image: String var character_details: String var character_usersUnlocking: Int var character_totalPoints: Int var contributedUserIds: [String] = [] // 替换原user属性,仅存贡献用户uid var didUnlock: Bool? = false var isUnlocking: Bool = false }
2. 重构解锁记录模型
将原复数命名的UnlockingCharacters改为单条记录模型,遵守Codable协议,不嵌套完整Character对象,仅存储关联角色id和当前用户对该角色的贡献积分,减少冗余数据存储,适配子集合存储结构:
struct UnlockingCharacter: Identifiable, Codable { @DocumentID var id: String? var characterId: String var contributedPoints: Int = 0 var joinTime: Date = Date() }
子集合存储路径为
Users/{用户uid}/activeUnlocks/{解锁记录id},每条记录对应一个正在解锁的角色,比把整个数组存在用户主文档中更灵活,方便做查询、更新和权限控制。
3. 修改UserModel
移除UserModel中嵌套的解锁角色属性,解锁记录独立存储在子集合中,不需要作为UserModel的嵌套属性,彻底避免递归引用:
struct UserModel: Identifiable, Codable { var username : String var pic : String var bio: String var uid : String var totalPoints: Int = 0 // 用户累计可用积分 var id: String { uid } }
4. 修复业务逻辑与Firestore读写逻辑
在加入解锁列表的逻辑中补充2个角色的上限判断,修复原代码传类型而非实例的问题,使用Firestore原子操作保证积分累加一致性:
let db = Firestore.firestore() // 获取用户信息 func fetchUser(uid: String, completion: @escaping (UserModel) -> ()){ db.collection("Users").document(uid).getDocument { (doc, err) in guard let doc = doc, err == nil else { return } // 直接用Codable解析模型,无需手动取字段 do { let user = try doc.data(as: UserModel.self) DispatchQueue.main.async { completion(user) } } catch { print("用户数据解析失败: \(error)") } } } // 切换角色解锁状态,限制最多同时解锁2个 func toggleUnlockStatus(character: Character, userId: String) async throws { let userUnlockRef = db.collection("Users").document(userId).collection("activeUnlocks") // 查询当前角色是否已在解锁列表 let existingUnlocks = try await userUnlockRef.whereField("characterId", isEqualTo: character.id ?? "").getDocuments() if !existingUnlocks.documents.isEmpty { // 已存在则删除记录,取消解锁 for doc in existingUnlocks.documents { try await doc.reference.delete() } // 同步更新角色的解锁人数 if let charId = character.id { try await db.collection("characters").document(charId).updateData([ "character_usersUnlocking": FieldValue.increment(Int64(-1)) ]) } } else { // 不存在则先校验当前解锁总数是否超过上限 let allUnlocks = try await userUnlockRef.getDocuments() guard allUnlocks.documents.count < 2 else { print("最多只能同时解锁2个角色") return } // 新增解锁记录 let newUnlock = UnlockingCharacter(characterId: character.id ?? "", contributedPoints: 0) try await userUnlockRef.addDocument(from: newUnlock) // 同步更新角色的解锁人数 if let charId = character.id { try await db.collection("characters").document(charId).updateData([ "character_usersUnlocking": FieldValue.increment(Int64(1)) ]) } } } // 上传步数兑换积分,自动累加对应解锁角色的总积分 func uploadStepsConvertPoints(steps: Int, userId: String) async throws { // 积分兑换规则可自行调整,此处示例为10步兑换1积分 let earnedPoints = steps / 10 let batch = db.batch() // 1. 累加用户总积分 let userRef = db.collection("Users").document(userId) batch.updateData(["totalPoints": FieldValue.increment(Int64(earnedPoints))], forDocument: userRef) // 2. 查询用户当前所有解锁中的角色,同步累加贡献积分和角色总积分 let unlocksRef = userRef.collection("activeUnlocks") let unlockDocs = try await unlocksRef.getDocuments() for doc in unlockDocs.documents { let unlock = try doc.data(as: UnlockingCharacter.self) // 累加用户对该角色的贡献积分 batch.updateData(["contributedPoints": FieldValue.increment(Int64(earnedPoints))], forDocument: doc.reference) // 累加角色全局总积分 let charRef = db.collection("characters").document(unlock.characterId) batch.updateData(["character_totalPoints": FieldValue.increment(Int64(earnedPoints))], forDocument: charRef) } // 批量提交所有更新,保证数据一致性 try await batch.commit() }
关键逻辑说明
- 所有积分累加操作使用Firestore的
FieldValue.increment原子操作,避免多端同时写入导致的积分计算错误 - 所有存储模型统一遵守
Codable协议,直接使用Firestore内置的序列化方法,不需要手动逐字段解析数据 - 解锁数量判断在写入前执行查询校验,超过2个角色直接拦截写入请求
- 批量写入操作保证用户积分、用户贡献值、角色总积分三个数据维度同时更新成功或失败,不会出现数据不一致
内容的提问来源于stack exchange,提问作者Mxyb
相关产品推荐
相关产品推荐

