You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI中NavigationView导致@State属性值异常清空是什么原因?

问题描述

我正在学习斯坦福大学iOS开发课程,在课程示例基础上做自主拓展,尝试实现卡片入场动画效果:通过@State修饰的Set<String>类型变量dealt存储已展示卡片的ID,在content视图的.onAppear回调中遍历所有卡片,将卡片ID逐个插入dealt集合,判断时仅当isUndealt(card)返回true时显示透明占位,否则渲染卡片视图并配置缩放入场过渡动画。

相关代码

import SwiftUI

struct EmojiMemoryGameView: View {
    
    @ObservedObject var game: EmojiMemoryGame
  
    var body: some View {
        NavigationView {
            content
                .navigationTitle(game.themeName)
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItemGroup(placement: .bottomBar) {
                        Text("Score: \(game.score)")
                            .font(.title3)
                            .fontWeight(.semibold)
                        Spacer()
                        shuffleButton
                    }
                }
        }
        .navigationViewStyle(.stack)
    }
    
    @State private var dealt = Set<String>()
  
    private func deal(_ card: EmojiMemoryGame.Card) {
        print("Before insert: \(dealt)")
        print("Inserting \(card.id)")
        dealt.insert(card.id)
        let bah = dealt
        print("After insert: \(dealt)")
    }
    
    private func isUndealt(_ card: EmojiMemoryGame.Card) -> Bool {
        print("dealt: \(dealt)")
        let bah = dealt
        return !dealt.contains(card.id)
    }
    
    var content: some View {
        VStack {
            themeSelector.frame(maxWidth: .infinity)
            AspectVGrid(items: game.cards, aspectRatio: 2/3) { card in
                if isUndealt(card) || (card.isMatched && !card.isFaceUp) {
                    Color.clear
                } else {
                    CardView(card: card)
                        .padding(4)
                        .transition(AnyTransition.asymmetric(insertion: .scale, removal: .opacity))
                        .onTapGesture {
                            withAnimation {
                                game.choose(card)
                            }
                        }
                }
            }
            .onAppear {
                withAnimation {
                    for card in game.cards {
                        deal(card)
                    }
                }
            }
            .foregroundColor(game.themeColor)
            .padding(.horizontal)
        }
    }

...

运行日志

Before insert: []
Inserting 4AE58783-5F32-4359-8E88-300D73F1AA89
After insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89"]
Before insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89"]
Inserting B82B98B1-47AC-4836-BE01-05FEFAC083E5
After insert: ["4AE58783-5F32-4359-8E88-300D73F1AA89", "B82B98B1-47AC-4836-BE01-05FEFAC083E5"]
...

dealt: []
dealt: []
...

异常表现

日志显示deal()方法中所有ID都成功插入dealt集合,但后续isUndealt()方法读取dealt时始终返回空集合,动画无法正常触发。
奇怪的是如果我移除外层的NavigationView,直接在body中渲染content视图,代码改成:

var body: some View {
        VStack {
            content
            shuffleButton
        }
        .padding()
    }

动画就能正常运行,日志也显示dealt集合的值可以正常保留。

疑问

为什么外层视图容器会影响@State属性的工作逻辑?第一版代码的问题出在哪里?


解答

这个问题的核心原因和SwiftUI的视图渲染机制、@State的生命周期有关:

  1. NavigationView的初始渲染流程会触发两次子视图的求值:第一次是为了计算导航栏、工具栏等容器组件的布局参数,这次求值生成的临时视图实例会被直接丢弃;第二次才会生成最终渲染到屏幕上的视图实例。
  2. @State属性的存储是绑定到对应视图实例的,第一次临时视图的onAppear触发时修改的是临时实例的dealt属性,当临时视图被丢弃后,你修改的dealt值也一起被销毁了。第二次渲染生成的正式视图实例的dealt属性会回到初始的空集合,且正式视图的content不会再次触发onAppear回调(SwiftUI会认为onAppear已经被调用过),所以最终你读到的dealt始终是空的。
  3. 你移走NavigationView之后,视图只会渲染一次,onAppear修改的dealt就是最终渲染的视图实例的属性,所以逻辑正常。

修复方案

可以任选其一:

  • 把onAppear回调挂载到外层的NavigationView上,而不是内部的content上,避免临时视图触发的状态修改被丢弃
  • 把dealt属性从@State改成@StateObject包装的独立数据模型,@StateObject的生命周期不受视图轻量重建的影响,状态会被持久保留
  • 给content添加稳定的唯一标识,避免视图实例被重建:content.id("stable_content_id")

内容的提问来源于stack exchange,提问作者Hayden BL

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.25 14:36:01