被拆掉的 NSHostingView 还会再建一次 View
内容提要
Prowl 切换 Canvas 视图时终端卡片出现白屏。排查发现:外层 NSViewRepresentable 被 dismantle 后,嵌套的 NSHostingView 仍留在窗口中并重建 root view,导致已退场的视图树再次调用 makeNSView,抢占终端 surface。修复分两部分:在 dismantleNSView 中移除并释放内层 NSHostingView;将 surface 所有权规则改为以窗口为准,使 surface 离开窗口后能回到仍在窗口中的宿主。
延伸解读
嵌套 NSHostingView 的生命周期盲区
文章揭示了一个反直觉现象:外层 NSViewRepresentable 被 dismantle 后,内嵌的 NSHostingView 只要还在 window 上,就会继续更新并重建 root view,甚至再次调用 makeNSView。这意味着 SwiftUI 的 dismantle 并不保证整棵子树停止工作。对于在 AppKit 中嵌套 SwiftUI 的架构,需要意识到内层宿主有独立的 graph 和布局循环,外层无法自动管理其生命周期。
makeNSView 不等于视图会上屏
作者强调,makeNSView 被调用并不代表该视图会出现在屏幕上,SwiftUI 从未承诺这一点。因此,在 makeNSView 或 view 的 init 中修改外部共享状态(例如抢占一个长寿的 view)存在风险。更稳妥的做法是等视图进入 window 后再接管,或者像文中那样让所有权规则能够自我恢复,避免一次多余的 make 造成持久副作用。
修复思路:主动拆解与所有权自愈
修复分两部分:一是在外层 dismantleNSView 中主动移除并释放内层 NSHostingView,使其离开 window 后不再 flush 事务;二是将 surface 所有权规则从“看父视图”改为“看 window”,并让 surface 在离开 window 时能回到仍在 window 中的宿主。前者堵住已知路径,后者提供兜底,防止未来 SwiftUI 行为变化再次引发类似问题。
排查启示:复现路径与最小样本的价值
作者发现点击控件和快捷键触发 bug 的表现不同,说明视图刷新顺序会影响复现。二分查找时若复现方式不一致,会导致结论偏差。此外,通过构建与项目无关的最小 sample,确认了这是 SwiftUI 的嵌套宿主行为,而非项目代码的偶然问题。这提醒开发者:面对生命周期类 bug,应统一复现路径,并用最小样本隔离变量。
Q&A
Prowl 切换到 Canvas 视图时终端卡片白屏的原因是什么?
根本原因是 SwiftUI 和 AppKit 混用时的生命周期问题:外层 NSViewRepresentable 被 dismantle 后,嵌套的 NSHostingView 仍留在窗口中并重建 root view,导致已退场的视图树再次调用 makeNSView,新建的宿主抢走了终端 surface,使 Canvas 卡片无法绘制。
为什么一个空的 .task 修饰符会导致多调用一次 makeNSView?
文章指出 .task 本身并不直接导致问题,它只是让问题暴露出来。根因在于嵌套的 NSHostingView 在外层 dismantle 后仍会 flush 事务并重建 root view。在 Prowl 复杂的视图树中,平时 flush 不会计算 leaf 的几何信息,但加上 .task 后,flush 会走到 AnimatableFrameAttribute 去计算 leaf 的 frame,从而触发 makeNSView。
如何修复嵌套 NSHostingView 导致的生命周期问题?
修复分两部分:1. 在外层的 dismantleNSView 中调用 detachHostingView(),将内层 NSHostingView 从视图树移除并释放,使其离开窗口后不再 flush 事务;2. 修改 surface 所有权规则,以窗口为准:宿主在窗口内时可以从不在窗口内的宿主手中收回 surface,并且 surface 离开窗口时会自动寻找仍在窗口中的宿主重新挂载。
surface 所有权规则具体做了哪些调整?
原规则是“不从活着的宿主手里抢”,但“活着”仅看 surface 是否有父视图。新规则改为以窗口为准:终端宿主可以领养孤儿 surface(superview 为 nil),窗口内的宿主也可以从不在任何窗口中的宿主手里收回 surface。此外,每个宿主在 init 时注册到 surface 的弱引用表,surface 离开窗口时会通知仍在窗口中的宿主接回自己。
如何复现这个白屏 bug?
在 Prowl 中,通过点击侧边栏切换 Canvas 可以稳定复现;但使用快捷键 ⌘⌥↩ 则不会复现。复现时,切换到 Canvas 后,原终端宿主被 dismantle,但随后又新建了一个终端宿主并抢走 surface,导致 Canvas 卡片空白。
这个 bug 是 SwiftUI 的 bug 还是 Prowl 的实现问题?
文章认为这是两个问题叠加的结果:一是 SwiftUI 的行为(外层 dismantle 后嵌套 NSHostingView 仍更新并调用 makeNSView),作者认为这反直觉,已作为 bug 报告给 Apple;二是 Prowl 自身的实现放大了问题(宿主 init 无条件拿走 surface,且 Canvas 宿主不主动收回)。
在 SwiftUI 中使用 NSViewRepresentable 嵌套 NSHostingView 时需要注意什么?
需要注意:外层 NSViewRepresentable 的 dismantleNSView 不会自动清理内嵌的 NSHostingView,必须手动在 dismantleNSView 中将其移除并释放,否则它可能继续更新并重建视图树。此外,避免在 makeNSView 或 view 的 init 中修改外部共享状态,应等视图进入窗口后再接管。