
1. iPhone Duo 适配到底在适配什么第一次听到“iPhone Duo”这个词很多人会下意识以为是苹果要出双屏手机。其实不是。它指的是在 iPhone 上实现类似“左右两栏、双窗口并行”的交互形态——可能是折叠屏设备展开后的分栏布局可能是大屏 iPhone 上的多窗口协同也可能是应用内主从视图的并排展示。核心就一句话同一块屏幕上让两个内容区域同时存在、各自独立、互不打架。这件事听起来简单做起来全是坑。我前后在三个项目里做过类似适配从最开始用flex硬撑到后来老老实实按尺寸类和生命周期去拆踩过的坑足够写满一个笔记本。这篇内容就是把这套经验完整摊开讲清楚布局怎么选、多窗口生命周期怎么管、不同尺寸下怎么保证不重叠不崩以及那些文档里不会写的实操细节。适合谁看如果你正在做 iOS 端的响应式布局、折叠屏适配、分栏导航或者单纯被“布局重叠”和“页面生命周期错乱”折磨过这篇能直接抄作业。如果你刚接触移动端适配也没关系我会从最基础的判断逻辑讲起用生活化的类比把原理说透。先给一个整体判断iPhone Duo 适配的本质不是“写两套布局”而是建立一套能根据可用空间动态决策的布局系统同时让每个窗口区域拥有独立且正确的生命周期。布局是表象生命周期才是里子。很多人只盯着flex和grid怎么摆结果窗口一旋转、一拖拽数据全乱了就是因为没管住生命周期。2. 布局方案选型flex、grid 还是尺寸类驱动2.1 为什么不能一套 flex 走天下我最早的做法很粗暴外层一个flex容器左边固定宽度右边flex: 1。在 iPhone 竖屏下没问题因为根本不会触发双栏。但一旦进入横屏或者折叠屏展开态问题就来了——左边固定 320pt右边剩多少完全看设备小屏上右边被挤成一条缝大屏上右边空得能跑马。flex的问题在于它是一维布局擅长处理“一行或一列怎么分”但不擅长处理“在不同空间下切换完全不同的结构”。iPhone Duo 场景下竖屏是单栏堆叠横屏是左右分栏折叠展开可能是三栏这已经不是一维能描述的了。那grid呢grid是二维布局确实更适合这种场景。你可以用grid-template-columns配合媒体查询在不同宽度下切换列数。但grid在 iOS 原生开发里并不是第一公民UIKit 和 SwiftUI 各有各的布局体系直接套 Web 那套grid思维会水土不服。所以我的结论是布局方案要跟着平台走但决策逻辑要统一。不管你是用 SwiftUI 的HStack、UIKit 的UIStackView还是跨端框架的flex底层都应该由“可用宽度”这个单一变量来驱动。2.2 尺寸类驱动的决策模型我最终落地的方案是一个三层判断可用宽度范围布局形态典型场景 500pt单栏堆叠iPhone 竖屏500pt - 800pt左右两栏左窄右宽iPhone 横屏、小折叠展开 800pt左右两栏 辅助栏大折叠展开、iPad 分屏这个阈值不是拍脑袋定的。500pt 大约是 iPhone 竖屏可用宽度的上限超过这个值说明设备已经进入横向或展开态。800pt 则是参考了 iPad 分屏时单侧最小可用宽度低于这个值再塞第三栏每栏都会低于 260pt文字会频繁换行体验反而更差。注意阈值不要写死成魔法数字应该定义成常量并加注释。我见过太多项目里到处散落着if width 500改一个地方要全局搜索维护成本极高。在 SwiftUI 里这个判断可以用GeometryReader拿到宽度后做if-else分支。在 UIKit 里用traitCollection配合viewWillTransition更自然。跨端框架比如 uni-app可以用uni.getSystemInfo拿到windowWidth再配合onResize监听变化。2.3 左右两栏的具体实现细节确定了分栏形态后左右两栏的宽度分配也有讲究。我试过三种方案固定左栏 自适应右栏左栏 320pt右栏吃剩余空间。适合左栏是导航列表的场景但大屏上右栏过宽阅读体验差。比例分配左栏 1/3右栏 2/3。简单但小屏上左栏可能不够放下列表项。最小宽度 最大宽度约束左栏最小 280pt、最大 360pt右栏最小 400pt。这个最稳既保证左栏能放下内容又不会在大屏上失控。我最后用的是第三种。具体做法是给左栏设minWidth: 280, maxWidth: 360右栏设minWidth: 400中间用 1pt 的分隔线。分隔线不要用border用独立的View或者Divider这样在暗黑模式下更容易控制颜色。还有一个容易忽略的点分栏之间的间距。很多人直接贴在一起视觉上很挤。我一般留 8pt 到 12pt 的间距用背景色区分两个区域。但间距不能算进任何一栏的宽度里否则总宽会超。正确做法是外层容器减去间距后再分配。3. 多窗口生命周期比布局更容易翻车的地方3.1 为什么多窗口下生命周期会乱单窗口时代一个页面从创建到销毁是一条清晰的线onLoad→onShow→onReady→onHide→onUnload。但多窗口下这条线变成了两条甚至多条交织的线。左边列表在滚动右边详情在加载用户突然把右栏拖到全屏左栏被隐藏——这时候左栏的onHide该不该触发右栏的onShow又该不该重新拉数据我遇到过一个真实 bug用户在分栏状态下打开了详情页然后旋转设备变成单栏详情页被推入导航栈但原来的列表页没有收到任何生命周期回调导致列表的定时刷新还在跑内存一直涨。排查了半天才发现是生命周期没对齐。核心问题在于多窗口下“可见”和“活跃”是两个不同的状态。一个窗口可能可见但不活跃比如被另一个窗口部分遮挡也可能活跃但不可见比如在后台预加载。如果只用传统的onShow/onHide来管理必然出错。3.2 建立窗口级别的生命周期模型我的做法是给每个窗口区域引入三个状态created、visible、active。转换关系如下created→visible窗口首次进入可视区域visible→active窗口获得焦点可以响应交互active→visible窗口失去焦点但仍可见visible→hidden窗口完全移出可视区域任何状态 →destroyed窗口被销毁对应到具体实现SwiftUI 里可以用onAppear、onDisappear配合Environment(\.scenePhase)来近似。UIKit 里更直接用viewDidAppear、viewDidDisappear加上UIScene的状态通知。跨端框架比如 uni-apponShow和onHide只能覆盖页面级别窗口级别需要自己封装一套事件总线。实操心得不要在每个窗口里单独写一套生命周期逻辑抽一个WindowLifecycleManager出来统一分发事件。否则窗口一多回调顺序根本不可控。3.3 数据加载与生命周期的绑定策略生命周期管住了接下来是数据什么时候加载、什么时候释放。我的策略是首次可见时加载不要在created阶段就发请求那时候窗口可能马上被隐藏浪费流量。重新可见时判断是否需要刷新如果距离上次加载超过 5 分钟或者有外部数据变更标记才重新拉取。否则直接用缓存。隐藏时不立即释放保留数据 30 秒防止用户快速切换回来时白屏。超过 30 秒再清理。销毁时彻底释放取消所有网络请求、定时器、监听器。这套策略在分栏场景下特别有用。用户在左栏列表和右栏详情之间来回点如果每次切换都重新加载体验会很卡。用缓存加超时刷新的方式既保证数据不太旧又不会频繁请求。3.4 旋转与尺寸变化时的生命周期处理设备旋转是生命周期最容易出问题的时刻。从竖屏到横屏布局从单栏变双栏原本的一个页面可能被拆成两个窗口或者两个窗口合并成一个。这时候如果生命周期处理不当会出现数据重复加载、页面闪烁、甚至崩溃。我的处理方式是在尺寸变化前先冻结生命周期事件等布局稳定后再统一分发。具体来说监听viewWillTransition在回调开始时设置一个isTransitioning标志所有生命周期事件先入队列。等viewDidLayoutSubviews完成后再根据新的布局形态重新计算每个窗口的状态然后批量分发事件。这样做的好处是避免了中间态触发的无效回调。比如从单栏变双栏时原来的页面会先onDisappear再onAppear如果不拦截就会触发一次无意义的数据刷新。拦截后系统直接判断它在新布局下仍然是可见的就不触发任何回调。4. 实操过程从零搭一个可用的 Duo 适配框架4.1 环境准备与基础结构假设你用的是 SwiftUIUIKit 的思路类似只是 API 不同先建一个DuoContainerView它负责根据宽度决定布局形态。外层用GeometryReader拿到可用宽度内部用一个State记录当前形态。struct DuoContainerViewLeft: View, Right: View: View { State private var layoutMode: LayoutMode .single let left: () - Left let right: () - Right var body: some View { GeometryReader { geo in let width geo.size.width Group { if width 500 { singleColumn } else if width 800 { twoColumn(leftWidth: 320) } else { twoColumn(leftWidth: 360) } } .onChange(of: width) { newWidth in updateLayoutMode(for: newWidth) } } } }这里的关键是onChange里不要直接改布局而是先更新layoutMode让 SwiftUI 自己去做 diff。直接改布局容易触发多次重绘。4.2 左右两栏的布局实现两栏布局用HStack加spacing就行但要注意左栏的宽度约束private func twoColumn(leftWidth: CGFloat) - some View { HStack(spacing: 1) { left() .frame(minWidth: 280, idealWidth: leftWidth, maxWidth: 360) Divider() right() .frame(minWidth: 400, maxWidth: .infinity) } }Divider在这里比border好因为它会自动适配暗黑模式而且不会影响布局计算。左栏的idealWidth是期望宽度但实际会被min和max约束住这样在不同设备上都能保持合理比例。4.3 窗口生命周期管理器的实现生命周期管理器我抽成了一个ObservableObjectclass WindowLifecycleManager: ObservableObject { enum WindowState { case created, visible, active, hidden, destroyed } Published private(set) var states: [String: WindowState] [:] private var eventQueue: [(String, WindowState)] [] private var isTransitioning false func updateState(for windowId: String, to newState: WindowState) { if isTransitioning { eventQueue.append((windowId, newState)) return } applyState(windowId, newState) } func beginTransition() { isTransitioning true } func endTransition() { isTransitioning false eventQueue.forEach { applyState($0.0, $0.1) } eventQueue.removeAll() } private func applyState(_ windowId: String, _ state: WindowState) { guard states[windowId] ! state else { return } states[windowId] state NotificationCenter.default.post( name: .windowStateChanged, object: nil, userInfo: [windowId: windowId, state: state] ) } }这个管理器的核心是isTransitioning标志和事件队列。在布局变化前后分别调用beginTransition和endTransition中间产生的状态变更会被缓存等布局稳定后一次性应用。这样避免了中间态触发的无效回调。4.4 数据加载与缓存的绑定每个窗口区域在收到visible状态时检查自己的数据缓存func onWindowVisible(windowId: String) { let lastLoadTime cache.lastLoadTime(for: windowId) let now Date() if let last lastLoadTime, now.timeIntervalSince(last) 300 { return // 5分钟内不重复加载 } loadData(for: windowId) }缓存用NSCache或者简单的字典都行关键是设置过期时间。我一般设 5 分钟超过就重新拉。这个值可以根据业务调整但不要设太短否则分栏切换时会频繁请求。4.5 旋转与尺寸变化的完整处理流程在DuoContainerView的父视图里监听尺寸变化.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in lifecycleManager.beginTransition() } .onChange(of: geo.size) { _ in DispatchQueue.main.asyncAfter(deadline: .now() 0.1) { lifecycleManager.endTransition() } }这里用了一个 0.1 秒的延迟来等布局稳定。实际测试下来大部分设备在 0.05 到 0.15 秒之间完成布局0.1 秒是个比较稳的值。如果设备性能差可以适当延长但不要超过 0.3 秒否则用户会感觉到卡顿。5. 常见问题与排查技巧实录5.1 布局重叠的三种典型原因布局重叠是 Duo 适配里最高频的问题。我遇到的案例里90% 逃不出这三种现象原因解决方案左右两栏内容互相覆盖左栏用了固定宽度但没设 maxWidth右栏又用了 flex:1给左栏加 maxWidth 约束右栏用 minWidth 兜底旋转后布局错位旋转过程中布局计算用了旧宽度用 isTransitioning 标志冻结布局更新分栏间距消失间距算进了子视图宽度外层容器先减间距再分配宽度第一种最常见。很多人写frame(width: 320)就以为万事大吉但没考虑小屏上 320 可能超过总宽的一半右栏被挤到负宽度。加个maxWidth: 360和右栏的minWidth: 400系统会自动做取舍。5.2 生命周期回调不触发的排查思路如果某个窗口的onAppear或onDisappear没触发按这个顺序查检查窗口是否真的进入了可视区域。有时候布局上它存在但被其他视图完全遮挡系统不会触发onAppear。检查isTransitioning标志是否卡在true。如果endTransition没被调用事件队列会一直堆积所有回调都不执行。检查窗口 ID 是否唯一。如果两个窗口用了同一个 ID状态会互相覆盖导致回调错乱。实操心得给每个窗口加一个调试用的边框颜色随状态变化。created灰色、visible蓝色、active绿色、hidden红色。一眼就能看出哪个窗口状态不对。5.3 数据重复加载的根治方法数据重复加载通常是因为生命周期事件触发了多次。我的根治方法是给每个窗口加一个loadToken每次加载前生成新 token回调时检查 token 是否匹配private var loadToken: UUID? func loadData(for windowId: String) { let token UUID() loadToken token networkService.fetch { result in guard self.loadToken token else { return } // 处理结果 } }这样即使触发了多次加载只有最后一次的结果会被采用前面的自动丢弃。配合生命周期管理器的队列机制基本可以杜绝重复加载。5.4 暗黑模式与无障碍适配的注意事项Duo 适配不只是布局和生命周期视觉和无障碍同样重要。暗黑模式下分隔线和背景色要单独定义不要用系统默认的separator因为它在分栏场景下对比度不够。我一般用Color(uiColor: .separator)再手动调透明度。无障碍方面分栏后 VoiceOver 的阅读顺序会变。默认是从左到右、从上到下但用户可能期望先读完左栏再读右栏。可以用accessibilitySortPriority来调整顺序给左栏设更高的优先级。另外每个窗口区域应该有自己的accessibilityLabel让用户知道当前在哪个区域。6. 几个我踩过的坑和最终建议第一个坑是过早优化。我一开始就想做一套通用的 Duo 框架结果抽象层太多调试困难。后来改成先针对具体场景写死跑通后再抽公共部分效率高很多。建议你也这样先让一个页面在分栏下正常工作再考虑复用。第二个坑是忽略中间态。旋转过程中有大约 100 毫秒的中间态这时候布局是乱的生命周期也是乱的。如果不做冻结处理用户会看到闪烁数据也可能错乱。isTransitioning这个标志看起来简单但能解决大问题。第三个坑是缓存时间设太短。我一开始设了 30 秒结果用户在分栏间切换几次就触发重新加载体验很差。后来改成 5 分钟配合手动下拉刷新体验好很多。缓存时间要根据业务的数据更新频率来定没有标准答案。最后一个建议多设备实测。模拟器上跑得再顺真机上也可能出问题。特别是折叠屏设备展开和折叠的过渡动画期间布局和生命周期都会经历剧烈变化。我一般至少在三台设备上测小屏 iPhone、大屏 iPhone、折叠屏。每台设备都跑一遍旋转、分栏切换、后台恢复基本能覆盖 95% 的场景。这套方案我在最近两个项目里都用了稳定运行了几个月没有出现布局重叠或生命周期错乱的问题。如果你正在做类似适配可以直接把DuoContainerView和WindowLifecycleManager这两个类拿去用改改阈值和缓存时间就能跑。