我偶然发现了 SwiftUI 中的一个错误,但可能有一个解释,但我无法弄清楚。所以这就是“错误”。也许你们每个人都知道。
简而言之 我发现,当您将逻辑从闭包范围抽象为一个应该从目标类型返回所需视图的函数时,
.navigationDestination(for: ...)
会运行多次。但是,如果将逻辑保留在闭包范围内,则每个目的地仅运行一次。
较长的解释 我已经设置了一个小示例,我计划在其上构建整个应用程序。基本上它是带有协调器模式的 MVVVM。在本示例中,我放弃了协调器以最大程度地减少代码,但路由器仍然存在,并且是应用程序如何工作的关键:导航从应用程序根部的中心位置进行。
您应该能够将所有代码复制到一个文件中并运行应用程序,看看会发生什么。这是一个简单的应用程序,带有一个根视图,可以导航到“Cookies”或“Milk”。您可以设置一个量(以具有某种状态,当导航回视图时,您可以测试该状态是否仍然存在)+导航到另一个视图。
有问题的错误发生在 RootView:
.navigationDestination(for: Destination.self) { destination in
let _ = print("||| Destination: \(destination.rawValue)")
// Method #1
// anyViewFor(destination: destination)
// Method #2
// itemViewFor(destination: destination)
// Method #3
// cookieViewFor(destination: destination)
// Method #4
switch destination {
case .cookie:
let vm = CookieViewModel()
CookieView(vm: vm)
case .milk:
let vm = MilkViewModel()
MilkView(vm: vm)
}
}
如果您注释掉方法 4 并在方法 1、2、3 中的任何一个中进行注释,您将在控制台中看到该问题。
假设您从 RootView -> CookieView(设置 2 块饼干)-> MilkView(设置 1 杯牛奶)-> CookieView 导航,然后导航回 RootView。
方法 4 产生以下打印结果:
||| Router: add to navPath: 1
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 960, num: 0
||| Router: add to navPath: 2
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 254, num: 0
||| Router: add to navPath: 3
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 348, num: 0
||| Router: remove from navPath: 2
||| Deinit 🔥: CookieViewModel, id: 348, num: 0
||| Router: remove from navPath: 1
||| Deinit 🔥: MilkViewModel, id: 254, num: 1
||| Router: remove from navPath: 0
||| Deinit 🔥: CookieViewModel, id: 960, num: 2
这是有道理的。所需的视图+视图模型(我们只有来自虚拟机的打印)被初始化和取消初始化。
方法 1、2、3 产生以下打印结果:
||| Router: add to navPath: 1
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 893, num: 0
||| Router: add to navPath: 2
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 747, num: 0
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 384, num: 0
||| Router: add to navPath: 3
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 578, num: 0
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 409, num: 0
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 468, num: 0
||| Deinit 🔥: CookieViewModel, id: 384, num: 0
||| Router: remove from navPath: 2
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 859, num: 0
||| Deinit 🔥: CookieViewModel, id: 468, num: 0
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 250, num: 0
||| Deinit 🔥: MilkViewModel, id: 409, num: 0
||| Deinit 🔥: CookieViewModel, id: 578, num: 0
||| Router: remove from navPath: 1
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 211, num: 0
||| Deinit 🔥: CookieViewModel, id: 859, num: 0
||| Deinit 🔥: MilkViewModel, id: 250, num: 0
||| Deinit 🔥: MilkViewModel, id: 747, num: 1
||| Router: remove from navPath: 0
||| Deinit 🔥: CookieViewModel, id: 211, num: 0
||| Deinit 🔥: CookieViewModel, id: 893, num: 2
这就是奇怪的地方。当它是一个将给定目的地所需视图返回到
.navigationDestination(for: ...)
的函数时,它似乎正在运行 NavigationPath
对象中的 n * 个项目。您可以在 deinit 打印中的 num: x
上看到,实例已被初始化并被定义,而我们从未接触过。
你们中有人有资格猜测为什么会发生这种情况吗?对我来说这似乎是一个错误。
可测试代码:
public enum Destination: String, Codable, Hashable {
case cookie = "Cookie 🍪"
case milk = "Milk 🥛"
}
final class Router: ObservableObject {
@Published var navPath = NavigationPath()
func navigate(to destination: Destination) {
navPath.append(destination)
print("||| Router: add to navPath: \(navPath.count)")
}
func navigateBack() {
guard navPath.count > 0 else { return }
navPath.removeLast()
print("||| Router: remove from navPath: \(navPath.count)")
}
func navigateToRoot() {
guard navPath.count > 1 else { return }
navPath.removeLast(navPath.count)
}
}
struct RootView: View {
@ObservedObject var router = Router()
var body: some View {
NavigationStack(path: $router.navPath) {
List {
Button(action: {
router.navigate(to: .cookie)
}, label: {
Text(Destination.cookie.rawValue)
})
Button(action: {
router.navigate(to: .milk)
}, label: {
Text(Destination.milk.rawValue)
})
}
.navigationBarBackButtonHidden()
.navigationDestination(for: Destination.self) { destination in
let _ = print("||| Destination: \(destination.rawValue)")
// Method #1
// anyViewFor(destination: destination)
// Method #2
// itemViewFor(destination: destination)
// Method #3
// cookieViewFor(destination: destination)
// Method #4
switch destination {
case .cookie:
let vm = CookieViewModel()
CookieView(vm: vm)
case .milk:
let vm = MilkViewModel()
MilkView(vm: vm)
}
}
}
.environmentObject(router)
}
func anyViewFor(destination: Destination) -> AnyView {
switch destination {
case .cookie:
let vm = CookieViewModel()
return AnyView(CookieView(vm: vm))
case .milk:
let vm = MilkViewModel()
return AnyView(MilkView(vm: vm))
}
}
func itemViewFor(destination: Destination) -> ItemView {
switch destination {
case .cookie:
let vm = CookieViewModel()
let view = CookieView(vm: vm)
let anyView = AnyView(view)
return ItemView(childView: anyView)
case .milk:
let vm = MilkViewModel()
let view = MilkView(vm: vm)
let anyView = AnyView(view)
return ItemView(childView: anyView)
}
}
func cookieViewFor(destination: Destination) -> CookieView {
switch destination {
case .cookie:
let vm = CookieViewModel()
return CookieView(vm: vm)
case .milk:
let vm = CookieViewModel()
return CookieView(vm: vm)
}
}
}
struct ItemView: View {
var childView: AnyView
var body: some View {
childView
}
}
struct CookieView: View {
// MARK: Properties
@EnvironmentObject var router: Router
@StateObject var vm: CookieViewModel
// MARK: - Views
var body: some View {
List {
Stepper("Amount: \(vm.amount)") {
vm.incrementAmount()
} onDecrement: {
vm.decrementAmount()
}
.minimumScaleFactor(0.2)
.padding(.top, 12)
Button(action: {
router.navigate(to: .milk)
}, label: {
Text("Get \(Destination.milk.rawValue)")
})
}
.navigationTitle(Destination.cookie.rawValue)
.navigationBarBackButtonHidden()
.toolbar(content: {
ToolbarItem(placement: .topBarLeading) {
Button(action: {
router.navigateBack()
}, label: {
Text("Back")
})
}
})
}
}
class CookieViewModel: ObservableObject {
@Published var amount: Int = 0
let id: Int
init() {
self.id = Int.random(in: 1...1000)
print("||| Init ☀️: CookieViewModel, id: \(id), num: \(amount)")
}
deinit {
print("||| Deinit 🔥: CookieViewModel, id: \(id), num: \(amount)")
}
func incrementAmount() {
amount += 1
}
func decrementAmount() {
amount -= 1
}
}
struct MilkView: View {
// MARK: Properties
@EnvironmentObject var router: Router
@StateObject var vm: MilkViewModel
// MARK: - Views
var body: some View {
List {
Stepper("Amount: \(vm.amount)") {
vm.incrementAmount()
} onDecrement: {
vm.decrementAmount()
}
.minimumScaleFactor(0.2)
.padding(.top, 12)
Button(action: {
router.navigate(to: .cookie)
}, label: {
Text("Get \(Destination.cookie.rawValue)")
})
}
.navigationTitle(Destination.milk.rawValue)
.navigationBarBackButtonHidden()
.toolbar(content: {
ToolbarItem(placement: .topBarLeading) {
Button(action: {
router.navigateBack()
}, label: {
Text("Back")
})
}
})
}
}
class MilkViewModel: ObservableObject {
@Published var amount: Int = 0
let id: Int
init() {
self.id = Int.random(in: 1...1000)
print("||| Init ☀️: MilkViewModel, id: \(id), num: \(amount)")
}
deinit {
print("||| Deinit 🔥: MilkViewModel, id: \(id), num: \(amount)")
}
func incrementAmount() {
amount += 1
}
func decrementAmount() {
amount -= 1
}
}
这不是有效的 SwiftUI,您不应该拥有视图模型或路由器对象,而应采用
View
结构、@State
/@Binding
和视图数据的计算变量。此外 @ObservedObject var router = Router()
是一个内存泄漏,因此也将其删除并使用多个 navigationDestination
,例如一个用于 navigationDestination(for: Milk.self)
和 navigationDestination(for: Cookie.self)
,它们可以位于 NavigationStack
以下层次结构中的任何位置。
如果您使用视图模型对象的目的是为了可测试性,那么您可以学习将
@State
与自定义结构一起使用,并使用 mutating func
实现逻辑。