
一. 引言
SwiftUI 作为 Apple 官方推出的响应式 UI 框架,安全、快速、声明式的特点确实非常吸引人。更重要的是,它可以无缝接入现有的 UIKit 项目,甚至是 Objective-C 项目,这使它在许多老项目中也逐渐"站稳脚跟"。
但当我们对 SwiftUI 的运行机制还不够熟悉时,就难免会遇到一些让人摸不着头脑的问题。
今天要讨论的就是这样一个真实场景:
在一个 UIKit 项目中,我们用 UIViewController 包了一层 SwiftUI 页面,并将它放到 UITabBarController 的一级页面中。
乍一看没什么问题,但当我们切换 Tab 时,却发现一个特别诡异的现象:
- SwiftUI 页面会被重建
- 页面内部的数据会被重置
- UI 重新回到加载态
- 而一些标记变量却保持了原来的状态
- 整体呈现出一种"状态错乱"的感觉
这让人非常疑惑,因为在传统的 UIKit 中,TabBar 的一级页面通常只在第一次创建时进行一次布局和数据请求,之后切换 Tab 都是常驻的,不会被重置。
那么问题来了:
在这种 SwiftUI 嵌套 UIKit 的混编结构里,我们该如何实现"一级页面只加载一次"的效果?
又为什么会出现这样奇怪的行为?
本文将结合一个实际的例子,把问题复现出来并彻底分析其原因,同时给出真正可靠的解决方案。
二. 问题场景回顾
这是一个比较典型的场景,在UIKit的UIViewController中使用UIHostingController嵌套了一个SwiftUI的页面。
Swift
class ZMNewsViewController: ZMBaseViewController {
override func viewDidLoad() {
super.viewDidLoad()
setSwiftUI()
}
private func setSwiftUI() {
let newsPage = ZMNewsPage()
let hostingController = UIHostingController(rootView: newsPage)
self.addChild(hostingController)
self.view.addSubview(hostingController.view)
hostingController.view.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.equalToSuperview()
}
}
}
而ZMNewsPage 中包含多个 ZMNewsSubPage;我们只聚焦 ZMNewsSubPage:
Swift
struct ZMNewsSubPage: View {
var category: ZMNewsCategoryItemModel?
@ObservedObject private var presenter = ZMNewsPresenter()
@State private var page = 1
@State private var hasRequest = false
var body: some View {
// ... List / Banner / loading 省略
.onAppear {
if hasRequest { return }
requestListData()
if category == nil { requestBannerData() }
}
}
private func requestListData() {
hasRequest = true
presenter.requestNewsList(page: page, categoryId: category?.id)
}
private func requestBannerData() {
presenter.requestNewsBanner()
}
}
然后出现的现象就是:从其它 tab 切回后,界面回到 loading,但 hasRequest 仍为 true,presenter(以及数据)却被重置为初始状态,导致页面卡在 loading。
三. 问题分析(深入生命周期与修饰符差异)
要把问题分析清楚,需要把几个关键点拼起来看:UIKit ↔ SwiftUI 的桥接 、View 的重建 、各类属性修饰符的生命周期。
3.1 UIHostingController 与 tab 切换的交互
- 在 UIKit 中,把 UIHostingController 作为 UITabBarController 的子控制器通常看起来「常驻」------但实际细节取决于如何创建、持有和内存回收。
- 当切换 tab 时,UIKit 可能会卸载某些视图层级(尤其是在内存紧张或 viewDidUnload 场景中),并在切回时重新创建视图层级。
- 更关键的是:SwiftUI 的 view 树是"值语义"的,SwiftUI 负责根据 identity 决定哪些 @State/@StateObject 等需要保留或重建;而我们在 View 中直接写 @ObservedObject var presenter = ...,这会在 View 被重新创建时重新 init 出一个新的 presenter。
3.2 @ObservedObject vs @StateObject vs @State 行为对比(最容易混淆的点)
- @State:属于 View 的内部状态。只要 View 的 identity 被 SwiftUI 认为是同一个,@State 会被保留(不会每次 body 重建而置零)。因此 hasRequest 有可能在视图重建后仍保留旧值。
- @ObservedObject:不能保证实例生命周期。当你用 @ObservedObject private var presenter = ... 这样的写法时,实例是在 View 初始化时创建、并由 View 持有;但 View 每次被重新实例化时会再次创建新的 presenter。换句话说:@ObservedObject 只是"观察一个对象",并不负责持有/稳定这个对象的创建周期。
- @StateObject(iOS14+):SwiftUI 提供来只创建一次并保持实例的修饰符。把 presenter 放到 @StateObject,SwiftUI 会负责持久化它的实例,即便 view 被重新构建(在大多数场景里)也不会新建。
3.3 为什么会出现"hasRequest 保留但 presenter 重置"的错位
- 我们的 hasRequest 是 @State,当 SwiftUI 决定复用 view identity 时,它保留了 hasRequest = true。
- 但 presenter 是以 @ObservedObject 并在声明处 new 的------视图重建时这个 presenter 被重新初始化,数据为空。
- 结果:页面逻辑判断 if hasRequest { return } 导致不再发请求,但数据又丢失,UI 始终停在 loading(presenter.newsList == nil)。
3.4 其他相关因素(会影响表现的细节)
- 如果我们的 UIHostingController 每次创建不是同一个实例(例如在 setSwiftUI 每次都 new,并且频繁调用setSwiftUI方法),那问题会更明显。确保你把 hostingController 保持为持久变量,而非每次都创建。
- 如果 presenter 的初始化有副作用(例如自动触发网络请求),那么频繁重建会造成重复请求或数据混乱。
- SwiftUI 的更新不是"每帧都重建",但 body 会频繁执行。关键是 view identity(例如 id 修改、父 view 重建)会决定哪些状态被保留。
四. 解决方案
其实解决方案有很多,主要看我们想要实现那种效果,对于这个示例项目而言呢,我们只需要保证 presenter 对象别被重置就是最理想的选择。
4.1 把 presenter 用 @StateObject并把 hasRequest 移到 Presenter
这是最稳妥、与 SwiftUI 生命周期契合的做法:让 Presenter 成为"状态对象"(State),并把请求状态 hasRequested 作为数据状态管理,而不是 UI 状态。
- Presenter 的生命周期由 SwiftUI 管理且稳定(只创建一次)
- hasRequest 与数据同属 Presenter,避免状态分裂
- 清晰:所有数据状态都在 Presenter 中,View 只负责渲染与交互
示例
Swift
final class ZMNewsPresenter: ObservableObject {
@Published var newsList: [NewsItem]? = nil
@Published var bannerList: [BannerItem] = []
@Published var hasMore: Bool = true
@Published var hasRequested: Bool = false
func requestNewsList(page: Int, categoryId: String?) {
guard !hasRequested else { return } // 可根据需要更加细化
hasRequested = true
// 发请求并在完成后更新 newsList、hasMore 等
}
func requestNewsBanner() {
// 请求 banner
}
}
使用:
Swift
struct ZMNewsSubPage: View {
@StateObject private var presenter = ZMNewsPresenter()
@State private var page = 1
var category: ZMNewsCategoryItemModel?
var body: some View {
// ... 同上
.onAppear {
if presenter.newsList == nil {
presenter.requestNewsList(page: page, categoryId: category?.id)
}
if category == nil && presenter.bannerList.isEmpty {
presenter.requestNewsBanner()
}
}
}
}
4.2 由上层或容器创建 Presenter 并注入(依赖注入 )
如果你的多个子页面需要共享同一个 presenter,或者你想显式管理 presenter 的生命周期,可以在 ZMNewsViewController 里创建 presenter 并把它注入进 SwiftUI:
Swift
let presenter = ZMNewsPresenter()
let newsPage = ZMNewsPage().environmentObject(presenter)
let hostingController = UIHostingController(rootView: newsPage)
View 中使用:
Swift
@EnvironmentObject var presenter: ZMNewsPresenter
- 明确生命周期(由 UIKit 持有)
- 便于多个子 page 共享
4.3 单利式
或者直接将presenter设置为单利,自己来管理生命周期,这个方案其实和4.2 类似,只是生命周期更长,但是也增加了管理的难度。
- @StateObject 是为了解决这种"只创建一次"的需求设计的:它把对象的创建与持久化交给 SwiftUI 管理,避免 View 重建带来的重复 init。
- 把 hasRequest(请求状态)放到 Presenter 中则把"数据状态"集中管理,避免把数据状态放在 UI 状态(@State)上引起的不同步问题。
- 结合上层注入(方案 B)可以在更复杂的场景下获得更明确的生命周期控制,但对于大多数 Tab 下一级页面场景,@StateObject + Presenter 内管理状态已经足够且优雅。
五. 结语
SwiftUI 和 UIKit 的混编虽然灵活,但也带来了不少"生命周期不在一个频道上"的微妙问题。这次的例子就是一个典型场景:View 被重建、@ObservedObject 被重置、@State 又被保留,最终导致状态错位,页面卡在 Loading ------ 但理解了每个修饰符的生命周期后,解决方案其实非常清晰。
在实际开发中,你可以根据场景选择不同的处理方式,但最通用、最稳定的方案仍然是:
使用 @StateObject 持有 Presenter,把请求状态也放到 Presenter 中统一管理。
这样既能保证 Presenter 生命周期稳定,也能避免 UI 状态和数据状态分裂。
当然,SwiftUI 的生命机制本身很灵活,在不同的项目结构里可能会遇到更多有趣(或者诡异 😂)的边界问题。如果你在混编项目里遇到类似的生命周期、状态同步、页面恢复等问题,也欢迎在评论区一起讨论交流,说不定下一个坑我们一起填!