夜雨聆风学习资料网

ARTICLE · 1121897

ios开发基础知识 -- 折叠屏篇

ios开发基础知识 -- 折叠屏篇
最近苹果的大动作大家想必也是很清晰了,其发布了iPhone DUO,也就是苹果折叠屏,几乎所有的的主流软件都付出了很大的人力来进行适配
这足以看出ios和安卓的差别,目前基本没什么软件考虑安卓系统折叠屏幕的适配,成本太高和收益不成正比

Resizable 的本质:窗口尺寸不再等于物理屏幕,所有"屏幕 = 固定满屏"的隐含假设都会失效。窗口是会动态变化的,布局需要做到响应式更新。

UIScene是 Resizable 的前置依赖,接入后UI 上下文从“整个 App 全局唯一”变成“每个 UIWindowScene 各自独立”,窗口、尺寸、方向、安全区、状态栏和生命周期等都应围绕当前 scene 获取。

可以把它想成浏览器里的响应式页面,但 UIKit 的处理对象仍是原生视图树。窗口变化后,约束要重新求解,集合视图可能要换列数,图片裁切要重新计算,浮层的位置也要跟着锚点移动。只要其中一个组件偷偷拿了全屏尺寸,页面就会出现“主体正确、局部错位”的割裂感。折叠动作只是触发尺寸变化的一种方式,拖动分屏边缘和调整镜像窗口同样能暴露问题。

故适配目标不是给 iPhone Duo 专门写一个判断(我们之前经常需要为iPad写一份单独的判断),而是让页面对当前可用空间作出正确反应

我们先来了解一下什么是UIScene

你也可以通过这个链接访问苹果官方文档对于UIScene的介绍,这里我只是粗浅的介绍,如果你想完全的了解,更好的方式肯定是官方文档,其介绍的东西也很多

https://developer.apple.com/documentation/uikit/uiscene?utm_source=chatgpt.com

UIScene 的核心作用,是把“App 进程的生命周期”和“每一份界面的生命周期”分开管理。

接入之后,一个 App 可以拥有多份独立的界面:它们共享同一个进程和业务数据,但分别管理自己的窗口、导航状态,以及进入前台、进入后台等行为。普通 UIKit 应用实际使用的通常是 UIScene 的子类 UIWindowScene

假设你开发了一个笔记 App。用户在 iPad 上同时打开两扇窗口:

  • 窗口 A 正在编辑“工作计划”。
  • 窗口 B 正在查看“旅行攻略”。

这时,如果只用一个全局 AppDelegate 管理界面,就会遇到一些问题:当前编辑的是哪篇笔记?页面应该跳转到哪个窗口?窗口 A 进入后台时,是否应该暂停窗口 B 的刷新?

UIScene 为每份界面提供了独立的管理范围。在这个例子中,两扇窗口对应两个 UIWindowScene,可以拥有不同的生命周期状态。

这里有一个很容易混淆的地方:Scene 不等于一个页面。

在同一扇窗口中,从笔记列表 push 到笔记详情,或者 present 一个编辑页面,通常都仍然属于同一个 Scene。用户再打开一扇可以独立管理的 App 窗口,才通常涉及另一个 Scene。

而且,UIWindowScene 与 UIWindow 并非严格一对一。一个 UIWindowScene 可以管理多个 UIWindow,例如主内容窗口和应用自己创建的辅助窗口,它们仍然属于同一份界面实例。

其中,SceneDelegate 只是常用类名;普通窗口场景通常让它遵守 UIWindowSceneDelegate,这个协议继承自 UISceneDelegate。Scene 和 Session 通常由系统创建,应用提供配置,并在连接时准备界面。

理解这些对象之后,下一步是划清 AppDelegate 和 SceneDelegate 的职责。

接入 UIScene 后,AppDelegate 依然存在。数据库、网络服务、账号服务等应用级依赖,可以在应用启动时初始化;每个窗口自己的根控制器、导航栈和界面状态,则由相应的 Scene 管理。

// 应用级共享状态AccountServiceNotesRepositoryNetworkClient// 每个 Scene 分别持有UINavigationController当前选中的笔记 ID当前窗口的搜索条件当前窗口的页面路由器

这是一个架构示例:是否共享,取决于数据的业务含义。用户账号通常需要共享,但“当前选中的笔记”通常应当属于某个窗口。

迁移时,原来处理界面前后台切换的四个 AppDelegate 回调,会对应到 SceneDelegate:

采用 Scene 生命周期后,UIKit 不再调用表中这些 AppDelegate 生命周期方法。新的回调只代表对应 Scene 的变化,不能把一次 sceneDidEnterBackground 当作整个 App 都进入后台。

对于“用户点击图标,进程冷启动,并创建首个前台 Scene”这一常见情况,可以按下面顺序理解:

  1. AppDelegate 收到应用启动回调。
  2. UIKit 根据配置准备 Scene 和它的代理。
  3. 调用 scene(_:willConnectTo:options:),准备界面。
  4. 调用 sceneWillEnterForeground(_:)。
  5. 调用 sceneDidBecomeActive(_:)。

你也可以通过 scene.activationState 读取当前状态:

UISceneSession最值得理解的地方,是它让会话身份与当前内存中的界面对象分开。

sceneDidDisconnect(_:):用户主动关闭窗口可能触发它,系统回收资源也可能触发它。仅仅切换到另一个 App,并不意味着系统马上断开 Scene。

会话被丢弃时,AppDelegate 有相应入口:

func application(    _ application: UIApplication,    didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {    // 清理这些会话专属、且已经不再需要的状态记录。}

Scene 的界面恢复可以通过 NSUserActivity 配合 stateRestorationActivity(for:) 等接口实现。例如保存当前文档 ID、选中的页面等,再据此重建界面。状态恢复不等于业务数据持久化,笔记正文仍应正常保存到数据库或文件。

AppDelegate 保留应用级启动工作

然后由 SceneDelegate 创建这份界面的窗口

import UIKitfinal class SceneDelegate: UIResponder, UIWindowSceneDelegate {    var window: UIWindow?    func scene(        _ scene: UIScene,        willConnectTo session: UISceneSession,        options connectionOptions: UIScene.ConnectionOptions    ) {        guard let windowScene = scene as? UIWindowScene else {            return        }        let homeController = UIViewController()        homeController.title = "笔记"        homeController.view.backgroundColor = .systemBackground        let navigationController = UINavigationController(            rootViewController: homeController        )        let mainWindow = UIWindow(windowScene: windowScene)        mainWindow.rootViewController = navigationController        self.window = mainWindow        mainWindow.makeKeyAndVisible()        print("连接 Scene:", session.persistentIdentifier)    }    func sceneDidBecomeActive(_ scene: UIScene) {        // 恢复当前 Scene 的交互相关任务。    }    func sceneWillResignActive(_ scene: UIScene) {        // 暂停当前 Scene 中需要持续交互的任务。    }    func sceneDidEnterBackground(_ scene: UIScene) {        // 保存当前窗口的界面状态。    }    func sceneDidDisconnect(_ scene: UIScene) {        // 清理当前 Scene 专属的资源。    }}

这里最关键的几行是:

let mainWindow = UIWindow(windowScene: windowScene)mainWindow.rootViewController = navigationControllerself.window = mainWindowmainWindow.makeKeyAndVisible()

它们分别完成:把窗口关联到指定 Scene、配置根控制器、持有窗口,以及显示窗口并使其成为该 Scene 的 key window。

接入后,业务代码中最重要的改变之一,是明确界面操作属于哪个 Scene。

connectedScenes中可能有多个 Scene,也可能包含处于后台的 Scene,即使筛选 .foregroundActive,也不应该默认筛选结果一定唯一。
如果操作来自某个视图控制器,优先沿着它所在的界面获取上下文
// 在 UIViewController 内部guard let currentWindow = viewIfLoaded?.window,      let currentScene = currentWindow.windowScene else {    return}// currentWindow 和 currentScene 对应当前控制器所在的界面。
这里 window 可能为 nil:例如视图还没有加入窗口,或已经离开了窗口。特别是在 viewDidLoad() 中,不应默认窗口关联已经完成。
如果只是展示一个普通弹窗,通常直接从发起操作的控制器展示即可
let alert = UIAlertController(    title: "保存成功",    message: nil,    preferredStyle: .alert)alert.addAction(    UIAlertAction(title: "确定", style: .default))present(alert, animated: true)
如果一个业务服务需要触发跳转,可以让调用方传入对应窗口的路由器或展示上下文。这样,“在哪个窗口打开笔记”是明确的,不需要在业务层猜测全局窗口。
举例说明:你改造的时候,之前通过url跳转的函数jumpURl(url:String),就可以增加一个来源参数 jumpURl(url:String, fromView:UIView?)

这也解释了 UIScene 与 Resizable,也就是窗口尺寸可变之间的关系。需要分别处理三个问题:

在窗口可调整的环境中,物理屏幕大小、窗口大小和某个页面容器大小可能各不相同。窗口拖动缩放、设备旋转等变化,最终都可能导致界面可用尺寸改变。
因此,计算当前页面布局时,不应该把下面的值当作页面宽度
let width = UIScreen.main.bounds.width
应根据你实际要布局的对象,选择相应信息:
即使从 windowScene.screen 获取了屏幕,它的尺寸仍然是屏幕尺寸,不能直接替代当前窗口尺寸。
对于一般布局,优先使用 Auto Layout、布局容器和安全区域。例如:
contentView.translatesAutoresizingMaskIntoConstraints = falseNSLayoutConstraint.activate([    contentView.leadingAnchor.constraint(        equalTo: view.safeAreaLayoutGuide.leadingAnchor    ),    contentView.trailingAnchor.constraint(        equalTo: view.safeAreaLayoutGuide.trailingAnchor    ),    contentView.topAnchor.constraint(        equalTo: view.safeAreaLayoutGuide.topAnchor    ),    contentView.bottomAnchor.constraint(        equalTo: view.safeAreaLayoutGuide.bottomAnchor    )])
这样表达的是“内容跟随当前容器的安全区域”,窗口尺寸变化后,布局系统可以重新求解这些约束。

如上:总结为适配折叠屏的原则

1. 就近取值,不取全局

布局需要的尺寸 / 安全区 / 朝向,一律从离自己最近的 view / window / trait 取,不要从全局 UIScreen.main / UIApplication 取。

此外,任何尺寸都不要进行缓存,在屏幕折叠后会不正确

布局代码需要的是容器分配给自己的空间。在 UIView 中看 bounds、safeAreaLayoutGuide 和 traitCollection;在控制器中看 view.bounds;确实要做 scene 级判断时,从 view.window?.windowScene 向上找。UIScreen 仍可用于真正与显示设备有关的能力,但不要拿主屏幕边界当作页面宽度不要把启动时读取的宽高当成永不变化的常量。性能需要时可以缓存派生结果,但缓存键要包含相关尺寸或 trait,并在变化时失效。

2. 布局用关系,不要使用相对坐标

优先 AutoLayout(Masonry / AWEMasMaker/Snapkit)描述"相对关系"

必须 frame 时,frame 计算放进 layoutSubviews,保证每次布局都重算

3. 尺寸变化优先用生命周期

能被 UIKit 生命周期覆盖的,一律用生命周期,例如

View 使用 layoutSubviews()

VC 使用 viewDidLayoutSubviews、viewWillTransitionToSize 等

刚好这里做一个投票

相关学习资料