阅读视图

发现新文章,点击刷新页面。

swiftui实现vip card的样式方案

让claude生成一些样式参考, 让后让它转换为swiftui 代码如下。这个是claude3.7给的方案,html转换到swiftui还原度没那么好,今天出了claude4, 可能效果更好。

11.gif

代码如下:

//
//  JoinVipCard.swift
//  mc skin
//
//  Created by martin on 2025/5/22.
//  Copyright © 2025 edgewise. All rights reserved.
//

import SwiftUI

// MARK: - VIP卡片主组件
struct VIPFeedCard: View {
    let vipType: VIPType
    let username: String
    let timeAgo: String
    let avatarURL: String?
    
    var body: some View {
        VStack(spacing: 0) {
            switch vipType {
            case .flame:
                FlameVIPCard(username: username, timeAgo: timeAgo, avatarURL: avatarURL)
            case .lava:
                LavaVIPCard(username: username, timeAgo: timeAgo, avatarURL: avatarURL)
            case .gold:
                GoldVIPCard(username: username, timeAgo: timeAgo, avatarURL: avatarURL)
            case .rainbow:
                RainbowVIPCard(username: username, timeAgo: timeAgo, avatarURL: avatarURL)
            }
        }
        .background(Color.black.opacity(0.1))
        .cornerRadius(8)
        .padding(.horizontal, 16)
        .padding(.vertical, 8)
    }
}

// MARK: - VIP类型枚举
enum VIPType {
    case flame   // 烈焰VIP
    case lava    // 熔岩VIP
    case gold    // 黄金VIP
    case rainbow // 彩虹VIP
}

// MARK: - 烈焰VIP卡片
struct FlameVIPCard: View {
    let username: String
    let timeAgo: String
    let avatarURL: String?
    
    @State private var glowIntensity: Double = 0.5
    @State private var shimmerOffset: CGFloat = -200
    
    var body: some View {
        ZStack {
            // 主背景渐变
            LinearGradient(
                colors: [
                    Color(red: 1.0, green: 0.55, blue: 0.0),
                    Color(red: 1.0, green: 0.65, blue: 0.0),
                    Color(red: 1.0, green: 0.5, blue: 0.0)
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            
            // 闪光扫过效果
            LinearGradient(
                colors: [
                    Color.clear,
                    Color.white.opacity(0.3),
                    Color.clear
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .offset(x: shimmerOffset)
            .animation(
                Animation.linear(duration: 2)
                    .repeatForever(autoreverses: false),
                value: shimmerOffset
            )
            
            VStack(alignment: .leading, spacing: 12) {
                // 用户信息行
                HStack {
                    // 头像
                    AsyncImage(url: URL(string: avatarURL ?? "")) { image in
                        image
                            .resizable()
                            .interpolation(.none) // 像素风格
                    } placeholder: {
                        Rectangle()
                            .fill(Color.gray.opacity(0.6))
                    }
                    .frame(width: 40, height: 40)
                    .border(Color.gray.opacity(0.8), width: 1)
                    
                    VStack(alignment: .leading, spacing: 2) {
                        HStack {
                            Text(username)
                                .font(.custom("Menlo", size: 10))
                                .foregroundColor(.green)
                            
                            Text("🔥 烈焰VIP")
                                .font(.custom("Menlo", size: 7))
                                .foregroundColor(.white)
                                .padding(.horizontal, 6)
                                .padding(.vertical, 2)
                                .background(Color(red: 1.0, green: 0.27, blue: 0.0))
                                .border(Color(red: 1.0, green: 0.15, blue: 0.0), width: 1)
                        }
                        
                        Text(timeAgo)
                            .font(.custom("Menlo", size: 6))
                            .foregroundColor(.gray)
                    }
                    
                    Spacer()
                }
                
                // 内容文本
                VStack(alignment: .leading, spacing: 6) {
                    Text("🔥 恭喜开通烈焰VIP会员!")
                        .font(.custom("Menlo", size: 9))
                        .fontWeight(.bold)
                        .foregroundColor(.black)
                    
                    VStack(alignment: .leading, spacing: 3) {
                        Text("✨ 解锁全部高级皮肤库")
                        Text("🧡 专属烈焰标识闪耀登场")
                        Text("🚀 优先体验最新功能")
                        Text("🎁 每月赠送专属皮肤礼包")
                    }
                    .font(.custom("Menlo", size: 8))
                    .foregroundColor(.black)
                }
                
                // 统计信息
                HStack(spacing: 15) {
                    Text("🔥 烈焰特权已激活")
                    Text("🌟 等级提升至 LV.MAX")
                }
                .font(.custom("Menlo", size: 7))
                .foregroundColor(.black.opacity(0.8))
            }
            .padding(16)
            
            // 浮动粒子效果
            ForEach(0..<3, id: \.self) { index in
                ParticleView(delay: Double(index))
            }
        }
        .border(Color(red: 1.0, green: 0.4, blue: 0.0), width: 2)
        .shadow(color: Color(red: 1.0, green: 0.55, blue: 0.0).opacity(glowIntensity), radius: 10)
        .onAppear {
            // 启动动画
            withAnimation(Animation.easeInOut(duration: 3).repeatForever()) {
                glowIntensity = 1.0
            }
            withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) {
                shimmerOffset = 200
            }
        }
    }
}

// MARK: - 熔岩VIP卡片
struct LavaVIPCard: View {
    let username: String
    let timeAgo: String
    let avatarURL: String?
    
    @State private var glowPulse: Double = 0.7
    
    var body: some View {
        ZStack {
            // 熔岩渐变背景
            LinearGradient(
                colors: [
                    Color(red: 0.8, green: 0.33, blue: 0.0),
                    Color(red: 1.0, green: 0.55, blue: 0.0),
                    Color(red: 1.0, green: 0.7, blue: 0.28)
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            
            VStack(alignment: .leading, spacing: 12) {
                // 用户信息行
                HStack {
                    AsyncImage(url: URL(string: avatarURL ?? "")) { image in
                        image
                            .resizable()
                            .interpolation(.none)
                    } placeholder: {
                        Rectangle().fill(Color.gray.opacity(0.6))
                    }
                    .frame(width: 40, height: 40)
                    .border(Color.gray.opacity(0.8), width: 1)
                    
                    VStack(alignment: .leading, spacing: 2) {
                        Text(username)
                            .font(.custom("Menlo", size: 10))
                            .foregroundColor(.green)
                        
                        Text(timeAgo)
                            .font(.custom("Menlo", size: 6))
                            .foregroundColor(.gray)
                    }
                    
                    Spacer()
                }
                
                // 内容
                VStack(alignment: .leading, spacing: 6) {
                    HStack {
                        Text("🧡")
                            .font(.custom("Menlo", size: 10))
                            .scaleEffect(1.2)
                            .animation(
                                Animation.easeInOut(duration: 1)
                                    .repeatForever(autoreverses: true),
                                value: glowPulse
                            )
                        
                        Text("熔岩之力!开通熔岩VIP会员!")
                            .font(.custom("Menlo", size: 9))
                            .fontWeight(.bold)
                            .foregroundColor(.white)
                    }
                    
                    VStack(alignment: .leading, spacing: 3) {
                        Text("🌋 专属熔岩光环效果")
                        Text("🎭 解锁传奇级皮肤收藏")
                        Text("🧡 加入熔岩VIP专属讨论群")
                        Text("🏆 参与熔岩VIP专属活动资格")
                    }
                    .font(.custom("Menlo", size: 8))
                    .foregroundColor(.white)
                }
                
                // 统计信息
                HStack(spacing: 15) {
                    Text("🌋 999+ 皮肤已解锁")
                    Text("🔥 熔岩圈子邀请中")
                }
                .font(.custom("Menlo", size: 7))
                .foregroundColor(.white.opacity(0.9))
            }
            .padding(16)
        }
        .border(Color(red: 1.0, green: 0.4, blue: 0.0), width: 2)
        .shadow(color: Color(red: 1.0, green: 0.55, blue: 0.0).opacity(glowPulse), radius: 15)
        .onAppear {
            withAnimation(Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
                glowPulse = 1.0
            }
        }
    }
}

// MARK: - 黄金VIP卡片
struct GoldVIPCard: View {
    let username: String
    let timeAgo: String
    let avatarURL: String?
    
    @State private var sparkleOpacity: Double = 0.5
    @State private var sparkleScale: CGFloat = 1.0
    
    var body: some View {
        ZStack {
            // 黄金渐变背景
            LinearGradient(
                colors: [
                    Color(red: 0.72, green: 0.53, blue: 0.04),
                    Color(red: 1.0, green: 0.84, blue: 0.0),
                    Color(red: 1.0, green: 1.0, blue: 0.6)
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            
            VStack(alignment: .leading, spacing: 12) {
                // 用户信息行
                HStack {
                    AsyncImage(url: URL(string: avatarURL ?? "")) { image in
                        image
                            .resizable()
                            .interpolation(.none)
                    } placeholder: {
                        Rectangle().fill(Color.gray.opacity(0.6))
                    }
                    .frame(width: 40, height: 40)
                    .border(Color.gray.opacity(0.8), width: 1)
                    
                    VStack(alignment: .leading, spacing: 2) {
                        Text(username)
                            .font(.custom("Menlo", size: 10))
                            .foregroundColor(.green)
                        
                        Text(timeAgo)
                            .font(.custom("Menlo", size: 6))
                            .foregroundColor(.gray)
                    }
                    
                    Spacer()
                    
                    // 闪电装饰
                    Text("⚡💎⚡")
                        .font(.custom("Menlo", size: 8))
                        .foregroundColor(.yellow)
                        .opacity(sparkleOpacity)
                        .scaleEffect(sparkleScale)
                }
                
                // 内容
                VStack(alignment: .leading, spacing: 6) {
                    Text("⚡ 黄金VIP闪耀登场!")
                        .font(.custom("Menlo", size: 9))
                        .fontWeight(.bold)
                        .foregroundColor(.black)
                    
                    VStack(alignment: .leading, spacing: 3) {
                        Text("⚡ 无限下载次数")
                        Text("🎨 AI皮肤定制服务")
                        Text("💛 专属黄金光效")
                        Text("🎪 优先客服支持")
                    }
                    .font(.custom("Menlo", size: 8))
                    .foregroundColor(.black)
                }
                
                // 统计信息
                HStack(spacing: 15) {
                    Text("💛 黄金特权")
                    Text("⚡ 无限制访问")
                }
                .font(.custom("Menlo", size: 7))
                .foregroundColor(.black.opacity(0.8))
            }
            .padding(16)
        }
        .border(Color(red: 1.0, green: 0.76, blue: 0.03), width: 2)
        .shadow(color: Color.yellow.opacity(0.8), radius: 10)
        .onAppear {
            withAnimation(Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
                sparkleOpacity = 1.0
                sparkleScale = 1.2
            }
        }
    }
}

// MARK: - 彩虹VIP卡片
struct RainbowVIPCard: View {
    let username: String
    let timeAgo: String
    let avatarURL: String?
    
    @State private var rainbowOffset: CGFloat = 0
    
    var body: some View {
        ZStack {
            // 彩虹流动背景
            LinearGradient(
                colors: [
                    .red, .orange, .yellow, .green, .blue, .purple
                ],
                startPoint: .leading,
                endPoint: .trailing
            )
            .offset(x: rainbowOffset)
            .animation(
                Animation.linear(duration: 3)
                    .repeatForever(autoreverses: false),
                value: rainbowOffset
            )
            
            VStack(alignment: .leading, spacing: 12) {
                // 用户信息行
                HStack {
                    AsyncImage(url: URL(string: avatarURL ?? "")) { image in
                        image
                            .resizable()
                            .interpolation(.none)
                    } placeholder: {
                        Rectangle().fill(Color.gray.opacity(0.6))
                    }
                    .frame(width: 40, height: 40)
                    .border(Color.gray.opacity(0.8), width: 1)
                    
                    VStack(alignment: .leading, spacing: 2) {
                        HStack {
                            Text(username)
                                .font(.custom("Menlo", size: 10))
                                .foregroundColor(.green)
                            
                            Text("限量版")
                                .font(.custom("Menlo", size: 6))
                                .foregroundColor(.white)
                                .padding(.horizontal, 4)
                                .padding(.vertical, 1)
                                .background(
                                    LinearGradient(
                                        colors: [.red, .pink],
                                        startPoint: .leading,
                                        endPoint: .trailing
                                    )
                                )
                        }
                        
                        Text(timeAgo)
                            .font(.custom("Menlo", size: 6))
                            .foregroundColor(.gray)
                    }
                    
                    Spacer()
                }
                
                // 内容
                VStack(alignment: .leading, spacing: 6) {
                    Text("🌈 彩虹至尊VIP震撼开启!")
                        .font(.custom("Menlo", size: 9))
                        .fontWeight(.bold)
                        .foregroundColor(.white)
                        .shadow(color: .black.opacity(0.5), radius: 1)
                    
                    VStack(alignment: .leading, spacing: 3) {
                        Text("🎪 独家彩虹皮肤系列")
                        Text("🎊 专属彩虹称号")
                        Text("🎁 限时彩虹礼包")
                        Text("🌟 终身VIP特权")
                    }
                    .font(.custom("Menlo", size: 8))
                    .foregroundColor(.white)
                    .shadow(color: .black.opacity(0.3), radius: 1)
                }
                
                // 统计信息
                HStack(spacing: 15) {
                    Text("🌈 彩虹效果")
                    Text("🎉 限量发售")
                }
                .font(.custom("Menlo", size: 7))
                .foregroundColor(.white.opacity(0.9))
            }
            .padding(16)
        }
        .border(Color.white, width: 2)
        .shadow(color: .purple.opacity(0.6), radius: 15)
        .onAppear {
            withAnimation(Animation.linear(duration: 3).repeatForever(autoreverses: false)) {
                rainbowOffset = 100
            }
        }
    }
}

// MARK: - 粒子效果组件
struct ParticleView: View {
    let delay: Double
    @State private var yOffset: CGFloat = 0
    @State private var opacity: Double = 0.7
    @State private var rotation: Double = 0
    
    var body: some View {
        Rectangle()
            .fill(Color.yellow)
            .frame(width: 4, height: 4)
            .opacity(opacity)
            .offset(y: yOffset)
            .rotationEffect(.degrees(rotation))
            .position(
                x: CGFloat.random(in: 50...250),
                y: CGFloat.random(in: 50...150)
            )
            .onAppear {
                withAnimation(
                    Animation.easeInOut(duration: 3)
                        .repeatForever(autoreverses: true)
                        .delay(delay)
                ) {
                    yOffset = -10
                    opacity = 1.0
                    rotation = 180
                }
            }
    }
}

// MARK: - 预览和使用示例
struct VIPFeedCard_Previews: PreviewProvider {
    static var previews: some View {
        ScrollView {
            VStack(spacing: 20) {
                VIPFeedCard(
                    vipType: .flame,
                    username: "DragonSlayer_2024",
                    timeAgo: "刚刚",
                    avatarURL: nil
                )
                
                VIPFeedCard(
                    vipType: .lava,
                    username: "MasterBuilder",
                    timeAgo: "5分钟前",
                    avatarURL: nil
                )
                
                VIPFeedCard(
                    vipType: .gold,
                    username: "CrystalKnight",
                    timeAgo: "10分钟前",
                    avatarURL: nil
                )
                
                VIPFeedCard(
                    vipType: .rainbow,
                    username: "RainbowWarrior",
                    timeAgo: "15分钟前",
                    avatarURL: nil
                )
            }
            .padding()
        }
//        .background(
//            LinearGradient(
//                colors: [
//                    Color(red: 0.1, green: 0.1, blue: 0.18),
//                    Color(red: 0.09, green: 0.13, blue: 0.24)
//                ],
//                startPoint: .top,
//                endPoint: .bottom
//            )
//        )
    }
}

一场陟遐自迩的 SwiftUI + CoreData 性能优化之旅(上)

在这里插入图片描述

概述

自从 SwiftUI 诞生那天起,我们秃头码农们就仿佛打开了一个全新的撸码世界,再辅以 CoreData 框架的鼎力相助,打造一款持久存储支持的 App 就像探囊取物般的 Easy。

在这里插入图片描述

话虽如此,不过 CoreData 虽好,稍不留神也可能会让代码执行速度“蜗行牛步”,这该如何解决呢?

在本篇博文中,您将学到如下内容:

  1. 当前源代码执行的“小瓶颈”

这是两篇偏向撸码的博文,里面有较多的源代码展示,我们会循序渐进地完成整个优化目标,希望大家能够喜欢。

那还等什么呢?让我们马上开始 CoreData 优化大冒险吧! Let's go!!!;)


1. 当前源代码执行的“小瓶颈”

要想小试拳脚,我们必须先有需要优化的代码。下面我就满足大家吧:

struct MonthCountsView: View {
    @Environment(\.managedObjectContext) var context
    let counter: ProjectCounter
    let year: Int
    let month: Int
    
    @State private var isShowMonthCountsChart = false
    
    var body: some View {
        // #1
        let daysCounts = try! counter.queryDaysCounts(year: year, month: month, context: context)
        // #2
        let days = daysCounts.keys.sorted(using: SortDescriptor(\.self, order: .reverse))
                     
        VStack {
            
            Button(isShowMonthCountsChart ? "月计数" : "月计数图表") {
                withAnimation(.bouncy) {
                    isShowMonthCountsChart.toggle()
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            
            if isShowMonthCountsChart {
                MonthCountsChart(counter: counter, daysCountsDict: daysCounts)
                    .frame(minHeight: 200)
            } else {
                LazyVGrid(columns: [GridItem](repeating: .init(.adaptive(minimum: 100, maximum: 120), spacing: 8), count: 4)) {
                    
                    ForEach(days, id: \.self) { day in
                        let dayCounts = daysCounts[day]!
                        let totalCount = dayCounts.totalCount
                        
                        NavigationLink {
                            List {
                                LabeledContent("当日总计数") {
                                    Text("\(totalCount)\(counter.unit ?? "")")
                                        .font(.title2)
                                        .foregroundStyle(counter.nature.data.color)
                                }
                                
                                Section("单次计数") {
                                    if let counts = dayCounts.counts {
                                        ForEach(counts, id: \.time) { trace in
                                            HStack {
                                                Text(Common.timeHHmmFt.string(from: trace.time))
                                                    .monospacedDigit()
                                                Spacer()
                                                Text("\(trace.count)\(counter.unit ?? "")")
                                                    .font(.title3)
                                                    .foregroundStyle(counter.nature.data.color)
                                            }
                                        }
                                    }
                                    
                                }
                            }
                            .listStyle(.plain)
                            .navigationTitle(Common.onlyDateFt.string(from: dayCounts.date))
                        } label: {
                            VStack {
                                HStack {
                                    Text("\(Common.tinyDateFt.string(from: dayCounts.date))日")
                                        .font(.headline)
                                        .frame(maxWidth: .infinity, alignment: .leading)
                                    
                                }
                                .padding(.leading)
                                .frame(minHeight: 50)
                                
                                Text("\(totalCount)\(Text("\(counter.unit ?? "")").font(.subheadline))")
                                    .font(.title)
                                    .padding(.bottom)
                            }
                            .foregroundStyle(.white)
                            .monospacedDigit()
                            .background(counter.nature.data.color.gradient.opacity(0.88), in: RoundedRectangle(cornerRadius: 10))
                        }
                    }
                }
            }
        }
    }
}

上面的代码虽然有点冗长,但本质上却很简单。我们主要做了以下几件事:

  • 使用 isShowMonthCountsChart 状态切换月计数图表和 Grid 显示;
  • 在 #1 代码处,我们调用计数器的 queryDaysCounts 方法,来获取指定月的计数记录,返回的结果是一个 [Int: DayCountsData] 字典;
  • 在 #2 代码处,我们将上述字典所有键反向排序并生成所有日的数组,这会将最近的日排在最前面;

为了进一步便于还未秃小码农们的理解,我们下面将缺失的、与计数相关的数据结构一并贴出来:

struct YearCountsData: Identifiable {
        
        var id: String {
            "\(year)"
        }
        
        let year: Int
        var totalCount: Int = 0
        var monthlyAvg: Float = 0.0
        var monthsCounts: [Int: MonthCountsData]?
        
        var monthsCountSortedAry: [MonthCountsData]? {
            if let data = monthsCounts {
                let sortedKeys = data.keys.sorted(using: SortDescriptor(\.self, order: .reverse))
                return sortedKeys.map { data[$0]! }
            }
            
            return nil
            
        }
    }
    
    struct MonthCountsData: Identifiable {
        
        var id: String {
            "\(year).\(month)"
        }
        
        let year: Int
        let month: Int
        var totalCount: Int = 0
        var daylyAvg: Float = 0.0
        var daysCounts: [Int: DayCountsData]?
        
        var daysCountSortedAry: [DayCountsData]? {
            if let data = daysCounts {
                let sortedKeys = data.keys.sorted(using: SortDescriptor(\.self, order: .reverse))
                return sortedKeys.map { data[$0]! }
            }
            
            return nil
            
        }
    }
    
    struct DayCountsData: Identifiable {
        
        var id: Date {
            date
        }
        
        let date: Date
        var totalCount: Int = 0
        var counts: [(time: Date, count: Int)]?
    }

如您所见,上面 MonthCountsView 视图的问题在于:每次重新渲染(Rendering)它的 body 内容时,我们都会重新计算 daysCounts 字典的内容(而且这是在主线程中完成的),这无疑有些庸人自扰。

那么,我们该如何进一步优化它的执行效率呢?这看似有些一筹莫展。

别急,在下一篇博文中,我们将会一步步“聚沙成塔”,最终完成整个优化目标,期待吧!

总结

在本篇博文中,我们介绍了 SwiftUI + CoreData 代码在执行时遇到的一个效率瓶颈,并给出了问题相关的详细源代码。

感谢观赏,我们下一篇再见吧!8-)

一发入魂:极简解决 SwiftUI 复杂视图未能正确刷新的问题(下)

在这里插入图片描述

概述

各位似秃非秃小码农们都知道,在 SwiftUI 中视图是状态的函数,这意味着状态的改变会导致界面被刷新。

但是,对于有些复杂布局的 SwiftUI 视图来说,它们的界面并不能直接映射到对应的状态上去。这就会造成一个问题:状态的改变并没有及时的引起 UI 的变化。

在这里插入图片描述

如上图所示:无论英雄挑战关卡的结果是成功还是失败,在视图的显示中都没有体现出来。这该如何是好呢?

在本篇博文中,您将学到如下内容:

  1. 一发入魂:三行代码搞定所有问题!

相信学完本课后,大家都会掌握只需寥寥几行代码就让 SwiftUI 复杂视图乖乖听话的奥义!

那还等什么呢?Let‘s go!!!;)


4. 一发入魂:三行代码搞定所有问题!

对 SwiftUI 开发范式略有了解的小伙伴们都清楚,SwiftUI 框架简洁、稳定和高效的诸多好处都受益于响应式编程思想。

它通过数据绑定(如 @State、@Binding、@ObservedObject 等)实现 UI 与数据的自动同步,这主要体现在:

  • 数据驱动:当数据状态变化时,界面自动更新(如 @Published 属性包装器触发视图刷新);
  • 单向数据流:数据从模型层流向视图层,确保逻辑清晰且避免副作用;

而数据绑定的核心就是状态!其诀窍就在于:当状态自身发生改变时,它会及时的触发相关视图界面的刷新。

长话短说,在 SwiftUI 中对于引用(Class)状态对象来说,会有一个类型为 ObservableObjectPublisher 的发布器对象被自动合成,它就是 objectWillChange 对象:

在这里插入图片描述

这个对象是谁免费赠送给我们这些秃头码农的呢?你猜对了!它就是大名鼎鼎的 ObservableObject 协议:

在这里插入图片描述

你说巧不巧?在 CoreData 中,托管对象基类 NSManagedObject 恰好遵守 ObservableObject 协议,这意味着任何我们派生托管类的实例都可以与 objectWillChange 同舟共济:

在这里插入图片描述

在我们的 App 中,若 ObservableObject 可观察对象发生变化,则其 objectWillChange 发布器(Publisher)会立即发出“信号”,与此可观察对象绑定的 SwiftUI 视图会由此重新计算 body 内容,从而完成界面的刷新。

在了解了这一点之后,我们完全可以利用可观察对象中的 objectWillChange 发布器在任何时候控制特定视图的刷新,只需简单的让它发送消息就可以了:

Button {
    if try! hero.challengeStage() {
        try! hero.moveToNextStage()
    }
    
    stage.objectWillChange.send()    
} label: {
    Label("挑战关卡!", systemImage: "figure.fencing")
        .foregroundStyle(.white)
}

在上面的代码中,我们在英雄挑战关卡后,立即向关卡对象(Stage)的objectWillChange 发布器发送了一条消息,这会促使该 Stage 对象对应的视图被刷新:

在这里插入图片描述

同样,要想关卡挑战后父视图 WorldView 中英雄的挑战状态也得以顺利得到更新,我们只需再对 Word 托管对象如法炮制即可:

Button {
    if try! hero.challengeStage() {
        try! hero.moveToNextStage()
    }
    
    stage.objectWillChange.send()
    
    let world = try! World.getShared(context)
    world.objectWillChange.send()    
    
} label: {
    Label("挑战关卡!", systemImage: "figure.fencing")
        .foregroundStyle(.white)
}

如上所示,最终我们一共只需在挑战操作后增加 3 行代码,即可搞定 StageView 和 WorldView 中所有的刷新问题:

在这里插入图片描述

如您所见:当英雄挑战关卡成功返回到上层 WorldView 视图后,英雄(黄色的五角星)已经妥妥的位于下一关的 StageCell 中了,棒棒哒!💯

总结

在本篇博文中,我们讨论了仅需 3 行代码即可解决 SwiftUI 复杂视图不能及时刷新的小妙招,小伙伴们值得拥有。

感谢观赏,再会啦!8-)

一发入魂:极简解决 SwiftUI 复杂视图未能正确刷新的问题(中)

在这里插入图片描述

概述

各位似秃非秃小码农们都知道,在 SwiftUI 中视图是状态的函数,这意味着状态的改变会导致界面被刷新。

但是,对于有些复杂布局的 SwiftUI 视图来说,它们的界面并不能直接映射到对应的状态上去。这就会造成一个问题:状态的改变并没有及时的引起 UI 的变化。

在这里插入图片描述

如上图所示:无论英雄挑战关卡的结果是成功还是失败,在视图的显示中都没有体现出来。这该如何是好呢?

在本篇博文中,您将学到如下内容:

  1. 始末原由:不刷新的根本原因
  2. 不尽如人意的解决方案

相信学完本课后,大家都会掌握只需寥寥几行代码就让 SwiftUI 复杂视图乖乖听话的奥义!

那还等什么呢?Let‘s go!!!;)


2. 始末原由:不刷新的根本原因

为了追本穷源找到问题根源,我们不妨先来看一个简单场景下,由状态驱动 SwiftUI 视图界面改变的经典例子:

struct ContentView: View {
    @State private var value = 0

    var body: some View {
        NavigationStack {
            Form {
                LabeledContent("战斗力") {
                    Text("\(value)")
                        .contentTransition(.numericText())
                        .font(.largeTitle.bold())
                }
                
                Button("提升战斗力") {
                    withAnimation(.bouncy) {
                        value += Int.random(in: 5...100)
                    }
                }
                .buttonStyle(.borderedProminent)
                .fontWeight(.bold)
            }
            .navigationTitle("极简状态驱动界面示例")
        }
    }
}

运行可以发现,当点击按钮后,战斗力数值会得到随机地提升,即状态 value 的值会随机被递增:

在这里插入图片描述

在这种简单的情况下,之所以界面会根据状态的改变被痛快的刷新,是因为:它们是一种单纯的一一对应关系。

在上面示例代码中,value 状态直接与 Text 视图中的文本相“绑定”,当 value 的值发生改变时,Text 想不改变都难。

如果我们回到上一篇文章开头的代码中,通过仔细观察 StageView 和 WorldView 的实现就可以发现,它们与上面简单场景中的代码有如下几点不同之处:

  1. 视图界面并不与状态直接对应;
  2. 视图界面中创建了多个临时局部变量,它们只是从状态“派生”出来,其本身并不是状态。这意味着,背后状态的改变并不能实际引起这些局部变量发生改变;
  3. 某些实际发生改变的状态只是视图状态中的一个“子状态”,即用户在关卡视图只会造成 StageChallengeRecord 托管对象的改变,而 Stage 对象中包含多个 StageChallengeRecord 托管对象;而 World 对象中又包含多个 Stage 对象;

比如,在 StageView 中,我们在界面中列出的实际是 records 这个临时局部变量,但它并不是状态:

let records = stage.queryAllChallengingRecords()

类似的,在 WorldView 中实际驱动界面显示的也只是 zones 临时变量和它的计算属性 stageSortByNumberAry 而已,它们都不是状态:

let zones = world.zoneSortByNumberAry

ForEach(zone.stageSortByNumberAry) { stage in
    ...
}

这就是当用户挑战关卡时,无论是 StageView 或是 WorldView 视图都未能正确刷新的根本原因:因为它们都让 SwiftUI 视图依赖临时变量,而不是直接与状态绑定!

这样看来,我们产品 App 中代码的生存条件往往会比“童话世界”里残酷的多。

那么,我们又该何去何从呢?

3. 不尽如人意的解决方案

知道了问题的根本原因,解决起来就知道往哪里使劲了。

一种思路是,将所有 SwiftUI 视图的 UI 代码都与状态对应起来。这样做当状态改变时,必定引起界面的刷新。但是,实际情况是:如果这样做的话,会导致代码变得很复杂;若是改写大量现有代码,更会秃头小码农们苦不堪言、无事生非。

除此以外,我们还有另外一种思路,那就是在实际状态发生改变时,手动去刷新视图的指定部分。

通过强制刷新视图可以解决问题,不过这也会带来几个问题:

  1. 迫使视图重建,会造成其自身状态丢失,可能会导致原本丝滑连贯的动画变得僵硬或干脆消失不见;
  2. 视图重建过于频繁,也会降低渲染性能;
  3. 迫使视图重建不是毫无代价的,这需要添加新的辅助状态,并适时的驱动它们刷新视图,这无论如何都会让代码布局和显示逻辑变得臃肿不堪;

如下代码所示,我们通过在 StageView 中驱动 stageCellRefresher 刷新器状态,刷新了 WorldView 视图中对应的显示部分:

// WorldView:
VStack(alignment: .leading) {
    ...
}
.id(model.stageCellRefresher)

// StageView:
Button {
    if try! hero.challengeStage() {
        try! hero.moveToNextStage()
    }
    
    model.stageCellRefresher.toggle()
    
} label: {
    Label("挑战关卡!", systemImage: "figure.fencing")
        .foregroundStyle(.white)
}

因为我们需要跨视图刷新界面,所以必须将 stageCellRefresher 刷新器放到全局 Model 对象中,这也会使代码逻辑变动更复杂,毕竟全局状态太多不是什么好事。

综上所述,通过一些方法我们能够达到勉强刷新视图之目的,但这些方案貌似都不那么优雅。

所以,我们就打算牵萝补屋、削趾适屦了吗?

当然不!Never!

在下一篇博文中,我们将介绍只需 3 行代码就能让问题抽薪止沸的解决之道,敬请期待吧!

总结

在本篇博文中,我们讨论了导致 SwiftUI 复杂视图不能及时刷新的根本问题,并介绍了几种不那么优雅的解决方案。

感谢观赏,下一篇再会啦!8-)

一发入魂:极简解决 SwiftUI 复杂视图未能正确刷新的问题(上)

在这里插入图片描述

概述

各位似秃非秃小码农们都知道,在 SwiftUI 中视图是状态的函数,这意味着状态的改变会导致界面被刷新。

但是,对于有些复杂布局的 SwiftUI 视图来说,它们的界面并不能直接映射到对应的状态上去。这就会造成一个问题:状态的改变并没有及时的引起 UI 的变化。

在这里插入图片描述

如上图所示:无论英雄挑战关卡的结果是成功还是失败,在视图的显示中都没有体现出来。这该如何是好呢?

在本篇博文中,您将学到如下内容:

    1. “固执己见”的 SwiftUI 视图
  • 1.1 关卡视图 StageView
  • 1.2 世界视图 WorldView

相信学完本课后,大家都会掌握只需寥寥几行代码就让 SwiftUI 复杂视图乖乖听话的奥义!

那还等什么呢?Let‘s go!!!;)


1. “固执己见”的 SwiftUI 视图

在上面的示例图中,英雄可以恣意挑战当前关卡,如果挑战成功则进入下一关卡,如果失败则会刷新挑战次数。前者应该在世界视图 WorldView 上有所体现,而后者则必须在关卡视图 StageView 中立即反映出来:

Button {
    if try! hero.challengeStage() {
        try! hero.moveToNextStage()
    }
} label: {
    Label("挑战关卡!", systemImage: "figure.fencing")
        .foregroundStyle(.white)
}

可惜的是,无论何种情况所有视图都将成为不舞之鹤,无动于衷。这是“肿”么回事呢?

1.1 关卡视图 StageView

我们先到 StageView 中看看与此相关的 UI 布局代码:

let records = stage.queryAllChallengingRecords()
                
if records.isEmpty {
    ContentUnavailableView("一片寂静,无人在此逗留...", systemImage: "eyes")
} else {
    ForEach(records) { record in
        if let hero = record.hero {
            VStack {
                heroCell(hero)
                
                HStack {
                    Text("失败次数: \(record.challengeFailedCount)")
                    Spacer(minLength: 0)
                    
                    if let timeString = try! hero.getStageStayRelevantTimeString(stage) {
                        Text("已徘徊 \(timeString)")
                    }
                }
                .foregroundStyle(.gray)
            }
        }
    }
}

理想的情况是:当英雄挑战关卡失败时,将会递增对应关卡挑战记录中挑战的次数,这会引起界面相关显示的变化。

在这里插入图片描述

但实际运行发现,界面并没有立即刷新。而只有当视图重建后,失败的挑战次数才能得以更新:

在这里插入图片描述

1.2 世界视图 WorldView

WorldView 视图是 StageView 的父视图,它的关键代码如下所示:

Form {
    let zones = world.zoneSortByNumberAry
    ForEach(zones) { zone in
        Section {
            HStack {
                Image(systemName: "map")
                Text("\(zone.number). \(zone.name ?? "")")
            }
            .font(.title2.bold())
            
            if let desc = zone.desc, !desc.isEmpty {
                Text(desc)
                    .foregroundStyle(.gray)
            }
            
            ScrollView {
                LazyVGrid(columns: [GridItem](repeating: .init(), count: 3)) {
                    ForEach(zone.stageSortByNumberAry) { stage in
                        NavigationLink {
                            StageView(stage: stage)
                        } label: {
                            VStack(alignment: .leading) {
                                HStack {
                                    stageLogoImage(stage)
                                    Text("\(stage.number)")
                                }
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .font(.title3.bold())
                                .foregroundStyle(stageColor(stage))
                                
                                Text("\(stage.name ?? "")")
                                    .monospaced()
                                    .minimumScaleFactor(0.8)
                                    .foregroundStyle(stageColor(stage))
                                
                                Spacer()
                                
                                HStack {
                                    let challengingHeros = stage.queryAllChallengingRecords().count
                                    let victoryHeros = stage.queryAllVictoryRecords().count
                                    VStack(alignment: .leading) {
                                        Image(systemName: "person.3")
                                        Text("\(challengingHeros)/\(victoryHeros)")
                                    }
                                    .minimumScaleFactor(0.5)
                                    .font(.subheadline)
                                    .foregroundStyle(.teal)
                                    
                                    Spacer(minLength: 0)
                                    
                                    let includeHeros = stageChallengingMyHerosCount(stage)
                                    if includeHeros > 0 {
                                        HStack(spacing: 0) {
                                            Image(systemName: "star.hexagon")
                                            Text("\(includeHeros)")
                                        }
                                        .font(.subheadline)
                                        .foregroundStyle(.yellow)
                                    }
                                }
                            }
                            .padding()
                            .frame(maxWidth: .infinity)
                            .frame(height: 150)
                            .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 11))
                        }
                    }
                }
            }
            .listRowSeparator(.hidden)
        }
    }
}
.navigationTitle("世界地图")

在上面的代码中,我们主要做了这样几件事:

  • 获取 World 中的所有区域(Zones),并将结果存入 zones 局部变量中;
  • 获取每个 Zone 中的所有关卡(Stages),并将它们放入 LazyVGrid 容器中以便显示;
  • 如果我们的英雄恰巧正在挑战某一关卡,则在对应关卡 StageCell 里用黄色的五角星表示出来;

WorldView 视图的显示效果如下所示:

在这里插入图片描述

理想情况下,当英雄成功挑战某一关卡并从关卡视图返回世界视图后,世界视图中关卡 StageCell 中的黄色五角星应该自动移动到下一关。但是,从演示图中可以看到,这些都没有发生。这表示 WorldView 视图内容在 Hero 挑战成功后也没有被及时地刷新:

在这里插入图片描述

那么,我们不禁要问:到底是什么导致了 StageView 和 WorldView 视图刷新不及时呢?

在下一篇博文中,我们将继续介绍导致上述问题的根本原因,并先提出几个不那么优雅地解决方案唏嘘一番。不见不散!

总结

在本篇博文中,我们发现了一个 SwiftUI 复杂视图中状态的改变并未正确引起界面刷新的现象,并随后深入代码初步分析了故事的前因后果。

感谢观赏,我们下一篇再见!8-)

Spotify邮箱注册出错怎么解决|最新Spotify账号一键注册教程(spotify注册一直说发生错误原因分析)

2025最新spotify申请注册流程分享,为什么国内用邮箱注册spotify一直说发生错误?

Spotify(声破天)是目前全球最大的音乐服务商,但国内用户需要注册账户才能进去享听平台的音乐资源。 国内很多朋友注册spotify时会出现:

你似乎正在使用代理服务。请关闭此类服务,然后再试一次。如需更多帮助,请联系客户服务。

你的帐号并未创建。你的设备似乎已连接至代理或 VPN 服务。请关闭此类服务,然后再试一次。

出现这种情况无非就是梯子不行了换个就行,但是由于换了很多次都不行的话,就比较头疼了。如下图所示

注意到注册页面底部有说明:This site is protected by reCAPTCHA and the Google 网站受谷歌recaptcha验证保护,简答来说,就是为了防止机器人用户滥用注册,spotify采取了来自谷歌的人机验证机制。

在此之前我们首先进行 Spotify 的具体注册教程,Spotify 目前受网络影响注册比较麻烦,如果注册成功后,国内的网络可以正常使用,无需魔法。

我们打开spotify网站入口:open.spotify.com/

点击底部的Sign up free注册账号,spotify跳转到申请页面

这里推荐填谷歌Gmail邮箱申请,

使用国内QQ邮箱的话,spotify会检测到国内用户使用,进行封锁处理。而谷歌邮箱spotify分辨不出来,相对比较稳定。

手里没有谷歌邮箱的话,可以去申请注册一个

推荐阅读:2024 最新申请谷歌Gmail邮箱遇到手机号无法验证的解决办法|谷歌账号注册完整指南 (多种方法 实时更新)- Haoz同学的文章 - 知乎 zhuanlan.zhihu.com/p/717084369

下一步,填写密码,要求至少十位,要包含# ? ! &其中的符号,不能是纯数字密码,至少要有一个英文字母,否则就会报错。

第三步,填写姓名、生日、性别,继续下一步next

注意注册时的ip环境,不要用国内网络申请,会加载不出谷歌的人机验证,这里推荐TW(台)、JP(日)KR(韩)等小众东亚ip,像香港ip、美国US的ip,失败率会比较高,可能是ip质量问题。觉得麻烦不想折腾的朋友可以参进大神的:2025年Spotify最新有效申请教程

如果是住宅ip的话则比较容易,可以直接跳过谷歌的人机验证

比较纯净的ip是可以顺利进行下一步的

OK,spotify注册成功,点击右上角账号 Account选项进去

点中间的编辑档案,可以看到自己的个人信息

这里地区显示很重要,你注册的ip是哪里,地区就是哪里。spotify规定必须14天用该地区的ip登陆一次,所以注册完成后,平时建议用自己注册时的地区ip(不规定一定是某个固定ip)登陆听歌即可。

spotify免费版,是不限制听歌数量的,但不能拉播放的进度条,只能慢慢听,比较适合慢节奏听歌的用户

至此,Spotify 变注册完成。 没成功的朋友可以参进大神的:2025年Spotify最新有效申请教程

Spotify 使用&客户端下载 注册完以后,就可以安装 Spotify 客户端开始愉快的听歌了。

目前,各个客户端的下载地址如下:

Window 客户端:www.spotify.com/tw/download…

Mac 客户端:www.spotify.com/us/download…

Android 客户端:play.google.com/store/apps/…

❌