iPhone Duo 适配完整指南:从 Scene 生命周期到动态布局

折叠、展开和旋转会改变页面的可用宽高、安全区和内容密度。按钮位置正确,只是适配的起点;文字需要重新换行,列表需要更新测量结果,浮层需要重新寻找锚点,页面还要保留输入、阅读位置和媒体进度。

本文面向 UIKit 应用,使用 SnapKit 与 Masonry 演示布局,覆盖工程配置、AppDelegate 向 SceneDelegate 迁移、UIScreen 旧代码治理、尺寸变化通知、UITableView 和 UICollectionView 刷新,以及媒体、键盘与验证方法。示例使用通用命名,不依赖任何业务工程。

文中的 iPhone Duo 指折叠、展开等可变视口场景。不要把某个设备名称、固定分辨率或未经核实的硬件行为写成布局前提。具体设备支持能力以对应 SDK、系统文档与实际运行结果为准。配图是布局示例,不代表设备真实外观或屏幕比例。

阅读路线

  • 第一部分:工程配置与 Scene 生命周期迁移。
  • 第二部分:容器尺寸、安全区及 SnapKit / Masonry 布局。
  • 第三部分:旋转、折叠展开与页面刷新机制。
  • 第四部分:UITableView、UICollectionView、媒体与交互。
  • 第五部分:迁移顺序和验收清单。

每个技术要点都配有对应代码、配置或调试示例。可以先看问题说明,再看代码,最后读代码下方的解释。新增的“示例一 / 示例二”通常表示不同方案,除非正文明确说明配合使用,否则不要把互斥方案一起粘贴。

文章中的代码分为可复用骨架与局部示例。局部示例涉及的视图、数据源和业务方法,需要在页面中自行定义;SnapKit 和 Masonry 需要先引入对应依赖。

一、先明确适配的三个边界

窗口不等于屏幕,内容不等于安全区,几何刷新不等于数据刷新。

布局应基于承载控件的当前容器:控制器使用自己的根视图,cell 使用 contentView,媒体使用播放器承载视图。安全区负责避开系统遮挡,背景可以按设计延伸到边缘。尺寸变化通常只需要重新布局和测量,不应触发重复网络请求或重建整个页面。


图 1 不同视口下,按钮跟随容器尾部,文字使用剩余宽度

还要区分旋转与尺寸变化。方向改变时尺寸可能变化,窗口缩放、父容器改变、折叠展开也可能在方向不变时改变尺寸。因此,布局的输入应该是 bounds、safeAreaInsets,以及确实影响展示的字体和 trait;设备方向只能作为辅助信息。

看代码更直观:让卡片跟着父容器走。 下面是 SnapKit 局部示例,假设 cardView 已添加到 containerView。

// 容易出错:初始化时把某一次的屏幕宽度写死。
let initialWidth = UIScreen.main.bounds.width - 32
cardView.frame.size.width = initialWidth

// 推荐:使用约束描述卡片与当前容器的关系。
// 接入时选择这一种方案,不要同时保留上面的 frame 赋值。
cardView.snp.makeConstraints { make in
    make.leading.trailing.equalToSuperview().inset(16)
    make.top.equalToSuperview().offset(16)
    make.height.equalTo(120)
}

父容器变宽时,两侧仍各留 16 点,卡片自动变宽;父容器不是全屏时,同一套约束仍然成立。这里固定的是设计间距和卡片高度,而不是屏幕尺寸。

二、工程配置需要检查什么

2.1 支持方向要与产品规则一致

在 Xcode 的 Target → General / Deployment Info 中检查 Supported Interface Orientations。不同 Xcode 版本的设置位置可能不同,最终以构建产物中的 Info.plist 为准。

需要竖屏和左右横屏时,可使用以下声明:

<key>UISupportedInterfaceOrientations</key>
<array>
    <string>UIInterfaceOrientationPortrait</string>
    <string>UIInterfaceOrientationLandscapeLeft</string>
    <string>UIInterfaceOrientationLandscapeRight</string>
</array>

自动生成 Info.plist 的工程,还要检查 Build Settings 中的 INFOPLIST_KEY_UISupportedInterfaceOrientations。不要同时维护两份冲突配置。支持 iPad 时,核对平台对应的方向声明,例如 UISupportedInterfaceOrientations~ipad。

配置文件允许横屏后,仍需检查控制器的 supportedInterfaceOrientations、shouldAutorotate、容器控制器和应用代理中的方向策略。导航、标签与模态页面的策略必须能够协同,不能只修改 plist 就认为方向适配完成。

示例:让导航容器沿用当前页面的方向规则。

final class RotationNavigationController: UINavigationController {
    override var supportedInterfaceOrientations:
        UIInterfaceOrientationMask {
        topViewController?.supportedInterfaceOrientations
            ?? .allButUpsideDown
    }

    override var shouldAutorotate: Bool {
        topViewController?.shouldAutorotate ?? true
    }
}

// 某个需要竖屏和横屏的内容页面。
final class ReadingViewController: UIViewController {
    override var supportedInterfaceOrientations:
        UIInterfaceOrientationMask {
        .allButUpsideDown
    }
}

这段代码是方向策略,不是强制旋转命令。实际结果还受应用声明、模态呈现和其他容器影响。若 AppDelegate 另有限制方向的回调,也要确保没有把允许范围缩成 portrait。测试时先检查“是否被允许进入横屏”,再检查“横屏布局是否正确”。

2.2 设备范围与窗口策略按需开启

TARGETED_DEVICE_FAMILY 的常见值中,1 表示 iPhone,2 表示 iPad。不要把适配可变视口理解为必须改成 1,2。产品确实支持 iPad 时,再开放对应设备范围并补齐交互测试。

UIRequiresFullScreen 不是通用的折叠适配开关。涉及 iPad 多任务和窗口行为时,应按目标系统版本核对它的当前语义与适用性。不要靠强制全屏掩盖布局问题。

采用 Scene 生命周期,也不等于必须允许多个窗口。可以先使用 Scene 管理单窗口;只有产品确实具备多窗口体验时,再开放多个场景。

示例:单场景应用保持单窗口能力声明。

<!-- 位于 UIApplicationSceneManifest 字典内部 -->
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
# Xcode Build Settings 中的配置示意,按产品实际选择。
TARGETED_DEVICE_FAMILY = 1
# 若产品明确同时支持 iPad,才采用 1,2 并完成对应验收。

上面的 false 不妨碍单个窗口的尺寸变化。它只表示不主动开放多个应用场景。设备范围、窗口数量与页面能否重新布局,是三个不同的问题。

2.3 启动界面与最低版本

检查 UILaunchStoryboardName 或 UILaunchScreen 是否与实际方案一致,启动 storyboard 是否属于正确 target,内部约束是否适应不同视口。启动界面展示静态内容;网络请求、骨架屏和加载动画属于真正的首页。

IPHONEOS_DEPLOYMENT_TARGET 应由产品兼容范围决定。Scene 生命周期和滚动视图 layout guide 的可用性不同,keyboardLayoutGuide 要求 iOS 15 及以上,较早系统需要 availability 判断和替代方案。不要为调用一个接口而无意间提高整款应用的最低版本。

示例:把静态启动屏与真正的首页加载分开。

<!-- 文件名示例:LaunchScreen.storyboard -->
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
// 这是首个 UIViewController 内的代码,不是启动 storyboard。
override func viewDidLoad() {
    super.viewDidLoad()
    view.addSubview(activityIndicator)
    activityIndicator.snp.makeConstraints { make in
        make.center.equalTo(view.safeAreaLayoutGuide)
    }
    activityIndicator.startAnimating()
    // 在数据完成后停止动画并更新内容,保留网络层的取消机制。
}

activityIndicator 是预先创建的 UIActivityIndicatorView 属性。约束先建立,随后真实页面开始显示动画,不需要在启动屏里放动态资源。是否能使用某个 API,应通过 if #available 检查,不能用判断机型替代系统版本判断。

2.4 构建配置和依赖要一致

逐项核对 Debug、Release 与自定义配置的方向声明、Info.plist 来源、资源打包和 SDK 兼容范围。第三方库要同时覆盖目标系统、真机架构和模拟器架构,模拟器专用的排除规则不能直接套到真机。

签名失败应从开发团队、证书、设备、描述文件及具体日志排查。页面布局修改与 CodeSign 属于不同问题,不能通过修改布局代码解决签名错误。

配置变更记录至少包含:键名、原值、新值、作用的 target、构建配置、修改原因和验证结果。已有正确设置无需重复修改。

示例:检查已经构建出来的 .app,而不只看源码文件。

# 将路径替换成自己实际构建产物的路径。
/usr/libexec/PlistBuddy -c 'Print :UISupportedInterfaceOrientations' \
  '/path/to/Example.app/Info.plist'
/usr/libexec/PlistBuddy -c 'Print :UIApplicationSceneManifest' \
  '/path/to/Example.app/Info.plist'
配置变更记录示例
键名:UISupportedInterfaceOrientations
原值:Portrait
新值:Portrait / LandscapeLeft / LandscapeRight
作用范围:主 App target 的 Debug 和 Release
原因:允许内容页面横屏阅读
验证:分别检查两种构建产物,并运行验证导航与模态页面

命令只读,不会修改构建产物。第一次检查找不到键时,先确认自己拿到的是正确 configuration 的 .app,再追踪 Info.plist 的生成来源。这里的记录是格式示例,不能当成某个真实应用已经完成的验证结果。

三、从 AppDelegate 迁移到 SceneDelegate

3.1 为什么要理解 Scene

AppDelegate 管理应用级生命周期,SceneDelegate 管理某个界面场景的连接、激活和窗口。引入 Scene 后,一个进程可以关联多个界面场景,窗口尺寸也应该从当前场景获取。

Scene 迁移不是让约束自动适应折叠的魔法开关,也不是所有旧应用必须先迁移才能修正布局。但它能明确窗口归属,减少依赖全局 keyWindow 和全屏尺寸的旧代码,适合与布局治理一起规划。

示例:从当前页面找到它所属的窗口。

func configureOverlay(for controller: UIViewController) {
    guard controller.isViewLoaded,
          let window = controller.view.window,
          let scene = window.windowScene else { return }

    let usableSize = window.bounds.size
    let sessionID = scene.session.persistentIdentifier
    // 把 overlay 添加到这个 window,或记录这个 session 的状态。
    // usableSize 只是该窗口尺寸,不代替页面内部容器尺寸。
    print("window size:", usableSize, "session:", sessionID)
}

方法应在页面已经挂到窗口后调用,例如 viewDidAppear。它不会误取另一个场景的窗口;在 viewDidLoad 里读到 nil,也不代表窗口创建失败,可能只是页面还没有显示。

3.2 配置 Scene Manifest

以下以纯代码创建根控制器、iOS 13 及以上、单场景为例。将配置写入实际参与构建的 Info.plist;若由构建系统生成,需要在对应配置来源中设置。

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>Default Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>

Swift 类通常需要模块前缀,Objective-C 场景代理类应按实际运行时类名配置。不要直接照抄不同语言或 target 的类名。

纯代码方案中,不要再让 Main Interface / UIMainStoryboardFile 或场景中的 UISceneStoryboardFile 自动创建另一套根界面。若保留 storyboard 方案,则使用系统创建的窗口和控制器,避免再手动创建一个窗口。Launch Screen 与 Main Interface 是两件事,移除主 storyboard 配置不等于移除启动屏幕。

如何验证 manifest 生效? 可以在连接回调中加断点,或在开发环境打印实际 delegate 与场景。若没有进入回调,先查有效 plist、配置名称和代理类名,不要先改页面约束。

// 放入 scene(_:willConnectTo:options:) 内,调试完成后可移除。
assert(scene is UIWindowScene)
print("Connected scene:", session.configuration.name ?? "unnamed")
print("Delegate:", String(describing: scene.delegate))

assert 只用于开发阶段帮助定位,并不是面向用户的异常处理方式。

3.3 应用级初始化留在 AppDelegate

import UIKit

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions:
            [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // 日志、依赖容器等应用级初始化。
        // Scene 模式下不在这里创建主窗口。
        return true
    }
}

以上使用静态 manifest 指定场景配置,无需再重复返回同名配置。已有 application(_:configurationForConnecting:options:) 时,应核对其返回值,确保没有覆盖掉 manifest 中期望的代理或 storyboard 设置。

为什么这里不创建窗口? Scene 模式下,AppDelegate 先初始化进程共享服务,SceneDelegate 再创建某个界面场景的根控制器。

// 应用级共享服务示意;真实业务按自己的依赖管理方式实现。
final class AppServices {
    static let shared = AppServices()
    private init() {}
    private(set) var isPrepared = false

    func prepareOnce() {
        guard !isPrepared else { return }
        isPrepared = true
        // 初始化日志或共享配置,不持有某个场景的导航控制器。
    }
}

// 放入 AppDelegate 的 didFinishLaunchingWithOptions。
AppServices.shared.prepareOnce()

这里适合共享配置,不适合保存“当前正在显示哪个页面”。一个进程中的共享服务可以只有一份,窗口与导航状态则应有各自归属。

3.4 在 SceneDelegate 创建窗口

import UIKit

final 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 window = UIWindow(windowScene: windowScene)
        let root = UIViewController()
        root.view.backgroundColor = .systemBackground
        window.rootViewController = UINavigationController(
            rootViewController: root)
        self.window = window
        window.makeKeyAndVisible()

        // 在根界面就绪后,处理冷启动的 URL / 用户活动。
        // 数据来源是 connectionOptions,不是旧的全局窗口。
    }
}

这里的 root 是可运行的占位页,接入应用时替换为实际入口。window 需要强引用,窗口与传入的 UIWindowScene 绑定,不再用 UIWindow(frame: UIScreen.main.bounds) 创建主窗口。

如何确认没有重复创建窗口? 在 willConnectTo 内创建和持有 window 之后,检查根控制器及场景归属。

// 放在 self.window = window 与 makeKeyAndVisible 之后。
assert(window.windowScene === windowScene)
assert(window.rootViewController != nil)

self.window = window 防止窗口失去持有,UIWindow(windowScene:) 表明它属于哪个场景。已有 storyboard 自动创建窗口时,应使用现成窗口,不能同时再执行这一套纯代码初始化。

3.5 迁移的不只是 window 属性

接入 Scene 后,原先依赖应用级前后台回调的界面逻辑,需要检查是否应该移到以下方法:

  • sceneDidBecomeActive(_:):当前场景变为活跃。
  • sceneWillResignActive(_:):当前场景即将失去活跃状态。
  • sceneWillEnterForeground(_:):当前场景进入前台。
  • sceneDidEnterBackground(_:):保存当前场景状态,释放适合释放的资源。

URL 的冷启动入口在 connectionOptions.urlContexts,运行中的入口是 scene(_:openURLContexts:)。Universal Link 的冷启动信息在 connectionOptions.userActivities,运行中的入口是 scene(_:continue:)。路由应交给该 Scene 对应的导航栈,不能把所有入口都推到“全局第一个窗口”。

需要恢复的分页、草稿和阅读位置可以按 session 归属管理。场景断开并不等于用户永久关闭窗口,不应在 sceneDidDisconnect 无条件删除用户状态。应用级推送注册、共享数据库等仍按其自身职责管理,不要机械地把所有 AppDelegate 代码搬走。

如果最低版本低于 iOS 13,需要保留旧系统的窗口创建路径,并对 Scene 类型做可用性处理。旧路径与 Scene 路径必须互斥,防止同一启动过程中创建两套窗口。

示例:冷启动和运行中的链接使用同一条路由。 以下扩展与前面的 SceneDelegate 配合使用。

extension SceneDelegate {
    // 在 willConnectTo 创建根界面后主动调用这个方法。
    func handleInitialLinks(_ options: UIScene.ConnectionOptions) {
        if let url = options.urlContexts.first?.url {
            openPage(for: url)
        } else if let url = options.userActivities
            .first(where: {
                $0.activityType == NSUserActivityTypeBrowsingWeb
            })?.webpageURL {
            openPage(for: url)
        }
    }

    func scene(_ scene: UIScene,
               openURLContexts contexts: Set<UIOpenURLContext>) {
        guard let url = contexts.first?.url else { return }
        openPage(for: url)
    }

    func scene(_ scene: UIScene, continue activity: NSUserActivity) {
        guard activity.activityType == NSUserActivityTypeBrowsingWeb,
              let url = activity.webpageURL else { return }
        openPage(for: url)
    }

    private func openPage(for url: URL) {
        let isCustomLink = url.scheme == "demo" && url.host == "item"
        let isWebLink = url.scheme == "https"
            && url.host == "example.com"
        guard isCustomLink || isWebLink,
              let navigation = window?.rootViewController
                as? UINavigationController else { return }

        let page = UIViewController()
        page.view.backgroundColor = .systemBackground
        page.title = url.lastPathComponent
        navigation.pushViewController(page, animated: false)
    }
}

在原连接方法的最后添加 handleInitialLinks(connectionOptions)。上面只演示链接分发,根控制器如果是标签页或其他容器,需要使用对应场景的路由对象。自定义 scheme 和 Universal Link 还需要 URL Types、Associated Domains 与网站关联文件等配置,只有回调代码并不能启用这些能力。登录前的链接可暂存到当前场景,认证完成后再消费。

示例:用场景 ID 区分需要保存的页面状态。

func sceneDidEnterBackground(_ scene: UIScene) {
    let key = "scene." + scene.session.persistentIdentifier + ".page"
    let pageID = window?.rootViewController?.restorationIdentifier
    UserDefaults.standard.set(pageID, forKey: key)
}

这个方法放在 SceneDelegate 内。真实页面状态应使用稳定业务 ID;示例中的 restorationIdentifier 需要自行赋值,未赋值时不会凭空产生恢复能力。草稿等敏感或复杂数据应交给合适的持久化层,这里只演示“按场景区分键”的思路。

四、UIScreen 和全局窗口代码如何替换

旧写法或假设 推荐来源 原因
UIScreen.main.bounds.width 布局页面 view.bounds.width 页面由自己的容器决定大小
全局屏宽计算 cell contentView.bounds.width 列表可能有安全边距或嵌套容器
屏幕高度计算弹层 弹层宿主视图的 bounds 与安全区 弹层未必覆盖整块屏幕
UIApplication.shared.keyWindow 页面所属 view.window 窗口有场景归属
从 connectedScenes.first 找界面 已持有的 UIWindowScene 集合顺序没有业务意义
全局缓存安全区或状态栏高度 当前视图的 safeAreaInsets 旋转及容器会改变边界
全局屏幕 scale 做像素对齐 view.window?.screen.scale 明确对应实际显示屏

UIScreen 仍可用于屏幕相关信息,不能把所有出现它的代码一律判为错误。媒体输出分辨率、离屏渲染和屏幕能力查询,需要按实际用途判断。需要屏幕信息时,也优先从所属窗口找到 screen。

// 页面尺寸,用于当前页面布局。
let pageSize = view.bounds.size
let contentRect = view.bounds.inset(by: view.safeAreaInsets)

// 屏幕属性,用于屏幕相关处理,而非页面宽度。
if let screen = view.window?.screen {
    let scale = screen.scale
    let pixelAligned = floor(contentRect.width * scale) / scale
    // 使用 pixelAligned 进行像素对齐。
}

不要在 viewDidLoad 中把 window 尚未绑定、safe area 尚未最终确定时读到的值永久缓存。创建约束可以在 viewDidLoad 进行,依赖最终几何信息的计算则放在后续的有效布局阶段。

示例:把旧的全局尺寸工具替换成接受容器的函数。

// 不再让工具函数内部偷偷读取 UIScreen.main。
func contentWidth(in container: UIView, padding: CGFloat) -> CGFloat {
    let inset = container.safeAreaInsets
    return max(0, container.bounds.width
        - inset.left - inset.right - 2 * padding)
}

// 调用方明确指定布局属于谁。
let width = contentWidth(in: view, padding: 16)

如果 container 已经约束在父视图的安全区内,它自己的安全区往往无需再额外扣减;也不要再扣父视图的那一份 inset。先明确边界处理发生在哪一层,再计算可用宽度。优先使用约束,只有手动测量需要宽度时才调用这类方法。

五、使用 SnapKit 和 Masonry 表达容器关系

5.1 背景铺满 内容遵守安全区

图 2 背景延伸到边缘,表单与操作按钮保留安全距离

SnapKit 示例:

import SnapKit

view.addSubview(backgroundView)
view.addSubview(actionButton)
view.addSubview(scrollView)

backgroundView.snp.makeConstraints { make in
    make.edges.equalToSuperview()
}

actionButton.snp.makeConstraints { make in
    make.leading.trailing
        .equalTo(view.safeAreaLayoutGuide).inset(16)
    make.bottom.equalTo(view.safeAreaLayoutGuide).inset(12)
    make.height.equalTo(48)
}

scrollView.snp.makeConstraints { make in
    make.top.equalTo(view.safeAreaLayoutGuide)
    make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
    make.bottom.equalTo(actionButton.snp.top).offset(-12)
}

Masonry 示例:

#import <Masonry/Masonry.h>

[self.view addSubview:self.backgroundView];
[self.view addSubview:self.actionButton];
[self.view addSubview:self.scrollView];

[self.backgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
    make.edges.equalTo(self.view);
}];

[self.actionButton mas_makeConstraints:^(MASConstraintMaker *make) {
    make.left.equalTo(self.view.mas_safeAreaLayoutGuideLeft)
        .offset(16);
    make.right.equalTo(self.view.mas_safeAreaLayoutGuideRight)
        .offset(-16);
    make.bottom.equalTo(self.view.mas_safeAreaLayoutGuideBottom)
        .offset(-12);
    make.height.mas_equalTo(48);
}];

[self.scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
    make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop);
    make.left.equalTo(self.view.mas_safeAreaLayoutGuideLeft);
    make.right.equalTo(self.view.mas_safeAreaLayoutGuideRight);
    make.bottom.equalTo(self.actionButton.mas_top).offset(-12);
}];

这里使用 Masonry 提供的 Left / Right safe area 属性约束物理安全边界,容器内部的文案与操作顺序仍使用 leading / trailing 支持 RTL。Masonry 的 safe area 扩展取决于所用版本。若依赖版本未提供这些属性,可在相应锚点处使用 UIKit 原生 NSLayoutAnchor;不要为了适配而混用两套约束约束同一关系。示例假设现代系统可使用 safe area。

逐句理解上面的约束: background 的 edges 指向整个父视图,负责铺满;按钮的 leading / trailing 指向安全区,负责可点击范围;scrollView 的底部连接按钮顶部,避免正文被按钮盖住。

// 如果需要操作栏底色覆盖到底部边缘,可增加一个背景容器。
view.insertSubview(bottomBarBackground, belowSubview: actionButton)
bottomBarBackground.snp.makeConstraints { make in
    make.leading.trailing.bottom.equalToSuperview()
    make.top.equalTo(actionButton.snp.top).offset(-12)
}

背景容器不接受按钮的安全区宽度限制,因此不会在两侧形成“未铺满”的条带;按钮仍使用原来的安全距离。

5.2 滚动内容需要闭合约束链

SnapKit 可直接使用 UIKit 的滚动布局 guide:

scrollView.addSubview(contentView)
contentView.snp.makeConstraints { make in
    make.edges.equalTo(scrollView.contentLayoutGuide)
    make.width.equalTo(scrollView.frameLayoutGuide)
}

contentView.addSubview(firstField)
contentView.addSubview(lastField)
firstField.snp.makeConstraints { make in
    make.top.leading.trailing.equalToSuperview().inset(16)
    make.height.equalTo(48)
}
lastField.snp.makeConstraints { make in
    make.top.equalTo(firstField.snp.bottom).offset(16)
    make.leading.trailing.equalToSuperview().inset(16)
    make.height.equalTo(48)
    make.bottom.equalToSuperview().inset(24)
}

Masonry 对 UILayoutGuide 的支持需按依赖版本确认。下面用原生锚点表达同一关系,再由 Masonry 管理内部控件,也是合理组合:

UIView *content = self.contentView;
UIScrollView *scroll = self.scrollView;
[scroll addSubview:content];
content.translatesAutoresizingMaskIntoConstraints = NO;

[NSLayoutConstraint activateConstraints:@[
    [content.leadingAnchor constraintEqualToAnchor:
        scroll.contentLayoutGuide.leadingAnchor],
    [content.trailingAnchor constraintEqualToAnchor:
        scroll.contentLayoutGuide.trailingAnchor],
    [content.topAnchor constraintEqualToAnchor:
        scroll.contentLayoutGuide.topAnchor],
    [content.bottomAnchor constraintEqualToAnchor:
        scroll.contentLayoutGuide.bottomAnchor],
    [content.widthAnchor constraintEqualToAnchor:
        scroll.frameLayoutGuide.widthAnchor]
]];

[self.lastField mas_makeConstraints:^(MASConstraintMaker *make) {
    // 其余上方和水平方向约束在这里补齐。
    make.bottom.equalTo(content).offset(-24);
}];

不要同时给 content view 添加固定高度,再期待内容把它撑开。滚动视图的 inset 自动调整与手动安全区避让也应明确分工,避免重复留白。

最容易漏掉的是最后一个控件的 bottom。 滚动视图需要同时知道视口多大、内容多高。frameLayoutGuide 解决前者,contentLayoutGuide 与内部闭合约束链解决后者。

// 纵向表单:固定内容宽度,允许内容高度由内部控件撑开。
// 不要再叠加 contentView.height == scrollView.height 的必需约束。
scrollView.alwaysBounceVertical = true
scrollView.keyboardDismissMode = .interactive

alwaysBounceVertical 只改变滚动体验,不能弥补缺失的约束。内容高度仍由第一个控件的 top、中间连接关系和最后一个控件的 bottom 决定。

5.3 make update 和 remake 的使用边界

makeConstraints / mas_makeConstraints 用于首次建立关系;常量变化可保存 Constraint 引用并 update,或使用相应的 updateConstraints。布局结构从单列切换双列、锚点关系发生变化时,再有针对性地 remake。

不要在每轮布局中重复 make,也不要用 update 试图改变一个约束的关联对象。如果关系没有改变,旋转时通常无需手动改约束,Auto Layout 会按新容器尺寸重新求解。

示例一:只改常量,保存并更新同一条约束。

private var cardHeight: Constraint?

func buildCard() {
    card.snp.makeConstraints { make in
        make.leading.trailing.equalToSuperview().inset(16)
        make.top.equalToSuperview().offset(16)
        cardHeight = make.height.equalTo(160).constraint
    }
}

func setExpanded(_ expanded: Bool) {
    cardHeight?.update(offset: expanded ? 240 : 160)
}

示例二:位置关系改变,再重建该视图的约束。

func arrangePanel(isWide: Bool) {
    panel.snp.remakeConstraints { make in
        if isWide {
            make.leading.equalTo(preview.snp.trailing).offset(16)
            make.trailing.top.bottom.equalToSuperview().inset(16)
        } else {
            make.top.equalTo(preview.snp.bottom).offset(16)
            make.leading.trailing.bottom.equalToSuperview().inset(16)
        }
    }
}

preview 的约束也需要按对应结构配置完整。只有 isWide 真正改变时才调用,不要每一轮布局都 remake。

// Masonry:同一条高度约束改变常量。
[self.card mas_updateConstraints:^(MASConstraintMaker *make) {
    make.height.mas_equalTo(expanded ? 240 : 160);
}];

Swift 和 Objective-C 的原则相同:常量用 update,关联关系变化用 remake,首次创建用 make。

六、旋转与折叠展开应该监听什么

6.1 没有必要依赖单一方向通知

UIDevice.orientationDidChangeNotification 反映设备物理方向,可能出现 faceUp、faceDown、unknown,并不能覆盖全部窗口或父容器尺寸变化。它不适合作为页面刷新唯一入口。

UIKit 页面主要关注:

  • viewWillTransition(to:with:):参与视图尺寸过渡。
  • viewSafeAreaInsetsDidChange():响应当前根视图安全区变化。
  • 承载 UIView 的 layoutSubviews():拿到约束求解后的真实几何信息。
  • 首次展示与重新挂载窗口:重新确认有效尺寸。

SceneDelegate 中的场景坐标空间更新回调可以辅助观察场景变化,但窗口变化不等于每个嵌套容器的变化。不要把 scene 的尺寸直接当成所有页面的尺寸。

通用 UIKit 代码不应假设存在一个在所有设备与系统版本上都可用的“折叠展开通知”。当平台提供特定能力时可按正式 API 接入;页面的基本布局仍应依据实际容器变化更新。

反例:只根据设备方向决定页面大小。

// 只适合辅助方向相关业务,不适合作为布局唯一输入。
let physicalOrientation = UIDevice.current.orientation

// 布局真正使用的输入来自当前页面。
let currentSize = view.bounds.size
let currentInsets = view.safeAreaInsets

即使 physicalOrientation 没变,currentSize 仍可能因父容器或窗口变化而改变。下一节的骨架比较后两项,因此能覆盖方向通知看不到的变化。

6.2 一份可复用的尺寸变化调度骨架

下面使用根视图观察实际尺寸,把同一轮变化合并到下一次主队列执行。控制器只重写 geometryDidChange,不需要在每轮 viewDidLayoutSubviews 中全量刷新。

import UIKit

struct LayoutGeometry: Equatable {
    let size: CGSize
    let safeArea: UIEdgeInsets
}

final class GeometryRootView: UIView {
    var onGeometryChange: (() -> Void)?
    private var previous: LayoutGeometry?

    override func layoutSubviews() {
        super.layoutSubviews()
        reportIfNeeded()
    }

    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        reportIfNeeded()
    }

    override func didMoveToWindow() {
        super.didMoveToWindow()
        // 重新挂载时,即使尺寸相同也允许重新检查。
        previous = nil
        setNeedsLayout()
    }

    private func reportIfNeeded() {
        guard window != nil,
              bounds.width > 0, bounds.height > 0 else { return }
        let value = LayoutGeometry(
            size: bounds.size, safeArea: safeAreaInsets)
        guard value != previous else { return }
        previous = value
        onGeometryChange?()
    }
}

class AdaptiveViewController: UIViewController {
    private var pending = false
    private var previous: LayoutGeometry?

    override func loadView() {
        let root = GeometryRootView()
        root.backgroundColor = .systemBackground
        root.onGeometryChange = { [weak self] in
            self?.requestGeometryUpdate()
        }
        view = root
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        requestGeometryUpdate()
    }

    override func viewSafeAreaInsetsDidChange() {
        super.viewSafeAreaInsetsDidChange()
        requestGeometryUpdate()
    }

    override func viewWillTransition(
        to size: CGSize,
        with coordinator: UIViewControllerTransitionCoordinator
    ) {
        super.viewWillTransition(to: size, with: coordinator)
        coordinator.animate(alongsideTransition: nil) {
            [weak self] _ in
            self?.requestGeometryUpdate()
        }
    }

    final func requestGeometryUpdate() {
        // 本类与所有 UIKit 布局操作均从主线程调用。
        assert(Thread.isMainThread)
        guard !pending else { return }
        pending = true
        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }
            self.pending = false
            guard self.isViewLoaded, self.view.window != nil,
                  self.view.bounds.width > 0,
                  self.view.bounds.height > 0 else { return }
            let value = LayoutGeometry(
                size: self.view.bounds.size,
                safeArea: self.view.safeAreaInsets)
            guard value != self.previous else { return }
            self.previous = value
            self.geometryDidChange(value)
        }
    }

    func geometryDidChange(_ geometry: LayoutGeometry) {
        // 子类只刷新依赖几何信息的部分。
    }
}

这是一种集成方案,不要求所有应用替换根视图。已有 storyboard、自定义根视图或基类时,应把观察逻辑合并到现有层级,不能直接覆盖已有 loadView。在合适的容器中集中调度,也可达到同样效果。

这里的主队列合并不是固定延迟:后续布局若产生新的尺寸,会再次上报。不要用 asyncAfter 等待一个猜测的时长,也不要在回调里无条件 layoutIfNeeded、重复请求自身刷新或重载业务数据。

页面如何接入? 用子类方法接住最终快照,然后只更新有尺寸依赖的组件。

final class ContentViewController: AdaptiveViewController {
    override func geometryDidChange(_ geometry: LayoutGeometry) {
        super.geometryDidChange(geometry)
        let usableWidth = geometry.size.width
            - geometry.safeArea.left - geometry.safeArea.right
        updateTextMeasurements(width: max(0, usableWidth))
        refreshAnchoredMenu()
    }

    private func updateTextMeasurements(width: CGFloat) {
        // 此处接入本页的文本缓存更新逻辑,见第七节。
    }

    private func refreshAnchoredMenu() {
        // 此处接入弹层锚点更新逻辑,见第十节。
    }
}

geometry 描述根视图。列表和媒体若处于更小的容器中,仍应读取自身最终 bounds。这个骨架先比较快照,再调用业务方法;宽度没有变化时,也可让文本组件继续跳过自己的测量。局部内容或字体改变时,应主动失效对应缓存,而不是假造一个旋转事件。

6.3 需要通知多个组件时如何做

局部子视图优先通过方法、闭包或代理更新。若确实存在多个独立观察者,可在尺寸快照变化后发布应用自定义通知。它不是系统折叠通知。

extension Notification.Name {
    static let pageGeometryDidChange = Notification.Name(
        "PageGeometryDidChange")
}

// 在 geometryDidChange 中发送,object 指向当前页面。
NotificationCenter.default.post(
    name: .pageGeometryDidChange,
    object: self,
    userInfo: ["geometry": geometry])

观察者注册时绑定具体页面,防止其他窗口的变化刷新当前组件:

// hostController 为所属页面;token 是观察者保存的属性。
token = NotificationCenter.default.addObserver(
    forName: .pageGeometryDidChange,
    object: hostController,
    queue: .main
) { [weak self] notification in
    guard let geometry = notification.userInfo?["geometry"]
            as? LayoutGeometry else { return }
    self?.updateLayout(using: geometry)
}

// 观察者释放时或结束观察时执行。
if let token = token {
    NotificationCenter.default.removeObserver(token)
}

这是 Swift 进程内通知示例;跨 Objective-C 使用时可改成 NSObject 数据对象或 NSValue 包装。block 形式的观察 token 应在结束观察时移除,重新进入页面也不要重复注册。通知中传递的是页面几何信息,组件仍要读取自身容器的最终宽度。

为什么注册时需要 object? 可以把 object 理解为通知的“发送页面”。同名通知可能来自多个页面,绑定 hostController 后,其他窗口的消息不会被误用。

// 观察 token 的持有者中添加,避免 block 观察者长期存留。
deinit {
    if let token = token {
        NotificationCenter.default.removeObserver(token)
    }
}

如果同一个组件会切换 host,切换前先移除旧 token,再注册新页面。不要只把 token 赋值为 nil;那不会自动执行 removeObserver。

6.4 Objective-C 页面沿用同一套职责

Masonry 页面不需要因为语言不同而改用另一种尺寸来源。可以在自定义根视图的 layoutSubviews 比较尺寸,在控制器的 viewSafeAreaInsetsDidChange 与尺寸过渡完成回调中调度同一个方法。示例为接入片段:

- (void)viewSafeAreaInsetsDidChange {
    [super viewSafeAreaInsetsDidChange];
    [self requestGeometryUpdate];
}

- (void)viewWillTransitionToSize:(CGSize)size
    withTransitionCoordinator:
        (id<UIViewControllerTransitionCoordinator>)coordinator {
    [super viewWillTransitionToSize:size
        withTransitionCoordinator:coordinator];
    __weak typeof(self) weakSelf = self;
    [coordinator animateAlongsideTransition:nil completion:
        ^(id<UIViewControllerTransitionCoordinatorContext> context) {
            [weakSelf requestGeometryUpdate];
        }];
}

requestGeometryUpdate 按上一节的“主队列合并、有效窗口检查、快照比较”规则实现。单靠这两个回调仍不替代自定义容器中的实际尺寸观察。

补齐 requestGeometryUpdate 的核心实现。 以下属性放在控制器的类扩展中,方法放在 implementation 中;自定义根视图检测到尺寸变化时,也调用该入口。

// @interface ExampleViewController () 内的属性。
@property (nonatomic, assign) BOOL geometryPending;
@property (nonatomic, assign) BOOL hasGeometry;
@property (nonatomic, assign) CGSize previousSize;
@property (nonatomic, assign) UIEdgeInsets previousInsets;
- (void)requestGeometryUpdate {
    NSAssert([NSThread isMainThread], @"UIKit requires main thread");
    if (self.geometryPending) { return; }
    self.geometryPending = YES;
    __weak typeof(self) weakSelf = self;
    dispatch_async(dispatch_get_main_queue(), ^{
        __strong typeof(weakSelf) self = weakSelf;
        if (!self) { return; }
        self.geometryPending = NO;
        if (!self.isViewLoaded || !self.view.window) { return; }
        CGSize size = self.view.bounds.size;
        UIEdgeInsets insets = self.view.safeAreaInsets;
        if (size.width <= 0 || size.height <= 0) { return; }
        if (self.hasGeometry &&
            CGSizeEqualToSize(size, self.previousSize) &&
            UIEdgeInsetsEqualToEdgeInsets(insets, self.previousInsets)) {
            return;
        }
        self.hasGeometry = YES;
        self.previousSize = size;
        self.previousInsets = insets;
        [self updateContentForSize:size safeAreaInsets:insets];
    });
}

- (void)updateContentForSize:(CGSize)size
            safeAreaInsets:(UIEdgeInsets)insets {
    // 只处理本页的测量、列表布局和浮层定位。
}

pending 合并同一轮事件,previous 快照跳过相同输入,weakSelf 避免异步 block 不必要地延长页面生命周期。它们各解决一个具体问题,并不负责清除业务数据。

七、UITableView 的布局刷新与适配

7.1 优先建立完整的自适应 cell

tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 88

cell 内部约束应形成从 contentView 顶部到底部的完整链。多行 label 设置 numberOfLines = 0,水平方向明确可用宽度,不再额外按照全屏宽度计算文本。

SnapKit 示例,位于自定义 cell 初始化阶段:

titleLabel.numberOfLines = 0
contentView.addSubview(titleLabel)
contentView.addSubview(messageButton)

messageButton.snp.makeConstraints { make in
    make.trailing.equalToSuperview().inset(16)
    make.centerY.equalToSuperview()
    make.width.equalTo(80)
    make.height.equalTo(36)
    make.top.greaterThanOrEqualToSuperview().offset(12)
    make.bottom.lessThanOrEqualToSuperview().offset(-12)
}

titleLabel.snp.makeConstraints { make in
    make.leading.equalToSuperview().inset(16)
    make.trailing.equalTo(messageButton.snp.leading).offset(-12)
    make.top.bottom.equalToSuperview().inset(16)
}

固定按钮宽度适用于短且可控的文案。需要本地化或大字号时,应改为内容驱动并设置合理优先级,或者在窄宽度下把按钮移到下一行。不要让按钮挤压后导致标题宽度为负,也不要只检查按钮是否可见而忽略其点击区域。

Masonry 中对应关系可以写成:

self.titleLabel.numberOfLines = 0;
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
    make.leading.equalTo(self.contentView).offset(16);
    make.trailing.equalTo(self.messageButton.mas_leading).offset(-12);
    make.top.equalTo(self.contentView).offset(16);
    make.bottom.equalTo(self.contentView).offset(-16);
}];

这里省略按钮约束,完整关系与上面的 SnapKit 示例一致。RTL 页面使用 leading / trailing,尽量避免通过手动镜像已经变换过的 frame 实现。

为什么 top 和 bottom 都要有? table view 测量 cell 时需要从上边界一路算到下边界。只有 centerY 或 top,没有明确底边,就可能无法推出行高。

// 让多行文本可以增长,同时避免被按钮无意压扁。
titleLabel.numberOfLines = 0
titleLabel.setContentCompressionResistancePriority(
    .required, for: .vertical)

这两行补充的是文本展示规则,真正的宽度与高度关系仍由前面的完整约束决定。不要用提高所有优先级的方式掩盖冲突。

7.2 什么时候需要刷新行高

如果 cell 完全由约束和 automaticDimension 管理,宽度变化后先让正常布局流程处理。只有遇到依赖缓存、内容变化或测量结果未失效时,才显式请求重新计算。

一个依赖宽度的页面可以在尺寸回调中做如下处理:

private var measuredTableWidth: CGFloat = 0

private func refreshTableMetricsIfNeeded() {
    // 前提:tableView 已拥有当前布局后的有效 bounds。
    let width = tableView.bounds.width
        - tableView.adjustedContentInset.left
        - tableView.adjustedContentInset.right
    guard width > 0,
          abs(width - measuredTableWidth) > 0.5 else { return }
    measuredTableWidth = width

    // 清除依赖宽度的业务高度缓存,保留数据与展开状态。
    heightCache.removeAll()
    UIView.performWithoutAnimation {
        tableView.performBatchUpdates(nil, completion: nil)
    }
}

heightCache 是页面自己的缓存。真正用于文本测量的宽度,还要扣除 cell 内部边距、头像、按钮、accessory、缩进等占用;不能直接把上面的表格宽度当作所有 label 的宽度。

以上做法只用于尺寸重算,不应与数据源数量变更、diffable snapshot 更新或另一轮批量更新并发执行。数据变化要通过合法的数据更新流程处理。若现有 cell 将测量 frame 保存在模型中,先按新宽度重算模型,再刷新受影响的可见内容或行;只做空 batch 不会自动改写业务模型。

内容或字体变化时,缓存也应主动失效,即使宽度相同。缓存条件通常涉及内容标识、内容版本、可用宽度、字体与展开状态,不要只用 indexPath。

把缓存条件写出来,比只写“清缓存”更容易维护。

struct TextMeasureKey: Hashable {
    let itemID: UUID
    let contentRevision: Int
    let widthInPixels: Int
    let fontPointSize: CGFloat
    let contentSizeCategory: String
    let expanded: Bool
}

func widthInPixels(_ width: CGFloat, in view: UIView) -> Int {
    let scale = view.window?.screen.scale ?? 1
    return Int((max(0, width) * scale).rounded())
}

文本相同但宽度变化,会得到不同 key;展开状态变化也不会复用收起高度。字体族、富文本样式等仍可能影响结果,真实实现应补齐依赖。缓存还应限制容量,不能把每个连续窗口宽度的结果永久保存。

7.3 tableHeaderView 高度需要单独计算

tableHeaderView 不会因为内部使用 Auto Layout 就自动把其 fitting height 写回 frame。首次进入、宽度变化或头部内容改变后,需要按有效宽度测量并重新设置。

private func updateTableHeaderHeight() {
    guard let header = tableView.tableHeaderView else { return }
    let width = tableView.bounds.width
        - tableView.adjustedContentInset.left
        - tableView.adjustedContentInset.right
    guard width > 0 else { return }

    header.bounds.size.width = width
    header.setNeedsLayout()
    header.layoutIfNeeded()

    let height = header.systemLayoutSizeFitting(
        CGSize(width: width,
               height: UIView.layoutFittingCompressedSize.height),
        withHorizontalFittingPriority: .required,
        verticalFittingPriority: .fittingSizeLevel
    ).height

    guard abs(header.frame.height - height) > 0.5 else { return }
    var frame = header.frame
    frame.size.height = height
    header.frame = frame
    tableView.tableHeaderView = header
}

header 内部同样需要完整约束链。宽度来源要与页面实际的横向 inset 策略一致,避免把已经处理的安全距离再扣一次。普通 section header 可以使用 automaticDimension 和估算高度,但不要把它与 tableHeaderView 的行为混为一谈。

若首个 cell 显示不全,还需检查导航遮挡、contentInset、adjustedContentInset、头部实际 frame、初始 contentOffset,以及是否叠加了两个顶部补偿。

什么时候调用? 首次有效布局、头部内容变化、表格宽度变化后调用。先比较宽度或内容版本,再测量,不要每轮布局都重新设置 tableHeaderView。

// 网络数据回包后的局部示意,所有 UI 更新在主线程进行。
headerTitleLabel.text = newTitle
headerTitleLabel.numberOfLines = 0
updateTableHeaderHeight()

如果这时表格还没有有效宽度,就把更新留给下一次实际尺寸回调。头部测量时使用 layoutIfNeeded 是为了获取本次 fitting 依赖的几何信息;这不同于在控制器所有布局回调中强制刷新整棵视图树。

7.4 保留阅读位置和输入状态

旋转前记录首个可见项的稳定 ID,以及它在视口中的相对纵向位置;测量完成后,按 ID 找到新 indexPath,再恢复相对位置并限制在合法滚动范围内。异步数据更新期间不能只保存旧 indexPath。

聊天界面还需要区分“用户正在看历史消息”和“用户原本停在底部”。后者可在更新后继续保持底部;前者不能被强制滚回最新消息。展开状态、选中状态和输入内容放在数据状态中,而不是依赖旧 cell 实例保存。

示例:按稳定 ID 保存并恢复第一条可见内容。 下面的工具接受 ID 映射闭包,不依赖某一种数据源。

struct TableReadingAnchor {
    let id: UUID
    let relativeY: CGFloat
}

func captureAnchor(
    in table: UITableView,
    idAt: (IndexPath) -> UUID?
) -> TableReadingAnchor? {
    guard let path = table.indexPathsForVisibleRows?.min(by: {
        table.rectForRow(at: $0).minY < table.rectForRow(at: $1).minY
    }), let id = idAt(path) else { return nil }
    return TableReadingAnchor(
        id: id,
        relativeY: table.rectForRow(at: path).minY
            - table.contentOffset.y)
}

func restoreAnchor(
    _ anchor: TableReadingAnchor,
    in table: UITableView,
    indexPathForID: (UUID) -> IndexPath?
) {
    guard let path = indexPathForID(anchor.id) else { return }
    table.layoutIfNeeded()
    let requestedY = table.rectForRow(at: path).minY - anchor.relativeY
    let minimumY = -table.adjustedContentInset.top
    let maximumY = max(minimumY,
        table.contentSize.height - table.bounds.height
            + table.adjustedContentInset.bottom)
    table.setContentOffset(
        CGPoint(x: table.contentOffset.x,
                y: min(maximumY, max(minimumY, requestedY))),
        animated: false)
}

调用顺序是“记录锚点 → 更新测量或批次 → 完成后恢复”。diffable data source 可通过 itemIdentifier(for:) 和 indexPath(for:) 提供两个映射。若锚点项已被删除,可以选择相邻稳定项或保留当前合法位置,不能强行访问旧 indexPath。

输入与展开状态留在模型层。

struct RowState {
    let id: UUID
    var draft: String
    var isExpanded: Bool
}

// 复用或重新布局时,从状态重新配置 cell。
cell.textField.text = state.draft
cell.setExpanded(state.isExpanded)

state 是当前行状态,setExpanded 是自定义 cell 方法。输入事件也应及时写回对应 ID 的状态。真正编辑中的 cell 尽量局部更新,避免重载导致 first responder 丢失、键盘闪动或输入法组合文本被打断。

八、UICollectionView 的尺寸 列数与布局缓存

8.1 Flow Layout 不能只重新排列旧尺寸

Flow Layout 中,容器变宽后 item 的布局位置可能变化,但 delegate 返回的旧尺寸或业务缓存仍可能继续生效。宽高变化时,需要按依赖规则使布局失效,并在尺寸依赖 delegate 时使 delegate metrics 失效。

final class AdaptiveFlowLayout: UICollectionViewFlowLayout {
    override func shouldInvalidateLayout(
        forBoundsChange newBounds: CGRect
    ) -> Bool {
        guard let collectionView = collectionView else {
            return super.shouldInvalidateLayout(
                forBoundsChange: newBounds)
        }
        return newBounds.size != collectionView.bounds.size
            || super.shouldInvalidateLayout(
                forBoundsChange: newBounds)
    }

    override func invalidationContext(
        forBoundsChange newBounds: CGRect
    ) -> UICollectionViewLayoutInvalidationContext {
        let context = super.invalidationContext(
            forBoundsChange: newBounds)
        if let collectionView = collectionView,
           newBounds.size != collectionView.bounds.size,
           let flow = context
                as? UICollectionViewFlowLayoutInvalidationContext {
            flow.invalidateFlowLayoutDelegateMetrics = true
            flow.invalidateFlowLayoutAttributes = true
        }
        return context
    }
}

不要因为 contentOffset 每一帧变化就重新计算全部 item。上例保留父类行为,同时在 size 变化时明确失效尺寸信息。自定义 layout 还应按自己的依赖检查 inset、字体与数据版本;只监听 bounds 并不能涵盖全部条件。

如何使用这个 layout?

let flow = AdaptiveFlowLayout()
flow.scrollDirection = .vertical
flow.minimumInteritemSpacing = 12
flow.minimumLineSpacing = 16
flow.sectionInset = UIEdgeInsets(
    top: 16, left: 16, bottom: 16, right: 16)
let collectionView = UICollectionView(
    frame: .zero, collectionViewLayout: flow)

子类负责“什么时候失效”,delegate 或布局配置负责“新尺寸是多少”。如果 delegate 仍返回按旧屏宽缓存的值,仅换 layout 子类也不会修复尺寸。

8.2 按可用宽度决定列数与 item 尺寸

以下是竖向 Flow Layout、所有 section 使用同一边距与间距的局部示例:

func collectionView(
    _ collectionView: UICollectionView,
    layout collectionViewLayout: UICollectionViewLayout,
    sizeForItemAt indexPath: IndexPath
) -> CGSize {
    guard let flow = collectionViewLayout
            as? UICollectionViewFlowLayout else { return .zero }

    let inset = collectionView.adjustedContentInset
    let section = flow.sectionInset
    let available = collectionView.bounds.width
        - inset.left - inset.right
        - section.left - section.right
    guard available > 0 else { return .zero }

    let spacing = flow.minimumInteritemSpacing
    let minimumWidth: CGFloat = 150
    let columns = max(1, min(4,
        Int((available + spacing) / (minimumWidth + spacing))))
    let rawWidth = (available - CGFloat(columns - 1) * spacing)
        / CGFloat(columns)
    let scale = collectionView.window?.screen.scale ?? 1
    let width = floor(rawWidth * scale) / scale
    return CGSize(width: width, height: width * 0.75 + 52)
}

150、最多 4 列和高度公式都是示例设计参数。实际应依据内容最小可读宽度、动态字体和卡片内容决定。若 delegate 为不同 section 提供了边距或间距,应读取对应 section 的真实值;不要拿 flow layout 的默认值代替。

横向 Flow Layout 的间距语义与纵向不同,不应直接复用同一公式。sectionInsetReference、自适应 cell 和内容 inset 策略也会影响可用范围,需要保证只扣一次边距。

用一组数字理解公式。

假设可用宽度:360 点(已经扣除外侧与 section 边距)
列间距:12 点
最小卡片宽度:150 点
列数:floor((360 + 12) / (150 + 12)) = 2
卡片宽度:(360 - 12) / 2 = 174 点

如果窗口变到 700 点,同一算法会增加列数,而不是把两张卡片无限拉宽。向下按像素取整可以减少因舍入造成的尾部越界;剩余不到一个像素的空间通常无需额外分配。

8.3 safe area 改变时主动失效必要部分

有些变化只影响 inset 或安全区,不一定改变 collection view 的 bounds size。若尺寸计算依赖这些值,可在页面统一尺寸回调中请求 metrics 刷新:

private func invalidateCollectionMetrics() {
    if let flow = collectionView.collectionViewLayout
            as? UICollectionViewFlowLayout {
        let context = UICollectionViewFlowLayoutInvalidationContext()
        context.invalidateFlowLayoutDelegateMetrics = true
        context.invalidateFlowLayoutAttributes = true
        flow.invalidateLayout(with: context)
    } else {
        collectionView.collectionViewLayout.invalidateLayout()
    }
}

只有几何变化时通常不需要 reloadData(),也无需重新提交 diffable snapshot。数据内容确实改变时,再走对应的数据更新流程。自动尺寸 cell 要保证约束完整;自定义 preferredLayoutAttributesFitting 等测量实现也不能返回旧宽度结果。

接入页面时,先区分数据变更和几何变更。

override func geometryDidChange(_ geometry: LayoutGeometry) {
    super.geometryDidChange(geometry)
    // 只使位置与尺寸失效,不重新提交相同的数据 snapshot。
    invalidateCollectionMetrics()
}

这段放在前述 AdaptiveViewController 的子类中,collectionView 和 invalidateCollectionMetrics 由本页提供。该方法可以收到安全区变化;layout 子类则观察自己的实际 bounds,两者都存在时允许由 UIKit 合并失效请求,不需要再补一轮 reloadData。

8.4 Compositional Layout 使用当前环境

Compositional Layout 的 section provider 能读取当前容器环境,更适合按有效宽度选择布局结构:

let layout = UICollectionViewCompositionalLayout {
    _, environment -> NSCollectionLayoutSection? in
    let available = environment.container.effectiveContentSize.width
    let columns = max(1, min(4, Int(available / 170)))

    let item = NSCollectionLayoutItem(layoutSize:
        NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(1)))
    item.contentInsets = NSDirectionalEdgeInsets(
        top: 6, leading: 6, bottom: 6, trailing: 6)

    let group = NSCollectionLayoutGroup.horizontal(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .absolute(160)),
        subitem: item,
        count: columns)
    return NSCollectionLayoutSection(group: group)
}

示例使用较早系统可用的 group 构建写法,新 SDK 可按部署版本采用对应的新重载。固定 160 的高度只适合内容可控的卡片;长文本应采用 estimated 尺寸并验证自适应测量。

effectiveContentSize 已反映当前布局环境的有效范围,不要在不了解 inset 策略时再减一遍全局安全区。

item 的高度需要随文字增长时,用估算高度表达。

let itemSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1),
    heightDimension: .estimated(180))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(
    layoutSize: NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1),
        heightDimension: .estimated(180)),
    subitems: [item])
let section = NSCollectionLayoutSection(group: group)

这是单列自适应高度的局部配置,替换前面固定高度的示例使用。item 与 group 都允许估算只是第一步,cell 自身仍要有能够确定真实高度的约束。

8.5 旋转后保持分页与可见项

在尺寸切换前保存当前 item 的稳定 ID,布局更新完成后定位该 item;不要继续使用“旧 contentOffset ÷ 新 itemWidth”推算页码。图片浏览器、横向分页和媒体列表都需要这个处理。

滚动中的页面要避免每个中间尺寸都强制 scrollToItem。可以让布局跟随过渡变化,在过渡完成后做一次最终位置校正。异步插入或删除数据时,以最新数据源的 ID 映射为准。

示例:图片浏览器记录当前照片 ID,过渡完成后再定位。

// 切换前:记录数据 ID,currentIndex 由分页状态维护。
let photoID = photos[currentIndex].id

// 尺寸更新完成,且 collection view 已使用最新数据后执行。
if let newIndex = photos.firstIndex(where: { $0.id == photoID }) {
    collectionView.layoutIfNeeded()
    collectionView.scrollToItem(
        at: IndexPath(item: newIndex, section: 0),
        at: .centeredHorizontally,
        animated: false)
}

该片段假设 photos 为单 section、横向分页且 currentIndex 合法。应把 photoID 保存在过渡期间可用的属性中,而不是过渡后再从已经改变的 offset 推算。多 section 和异步数据源使用 ID 到 indexPath 的映射,竖向列表按自己的视觉规则选择定位方式。

九、页面刷新应该做到哪一步

变化 需要做的事 通常不需要做的事
普通约束视图变宽 让约束自动求解 重建全部约束
文字可用宽度变化 使测量缓存失效,更新受影响行高 重新请求文字数据
collection item 尺寸变化 失效 layout 与必要 metrics 全量 reloadData
tableHeaderView 内容换行 fitting 测高并写回 frame 用固定屏高估算头部
媒体容器尺寸变化 更新 layer frame 与显示策略 重建播放器
浮层锚点变化 转换当前坐标,限制展示范围 继续使用旧 frame
内容或数据结构变化 更新模型与数据源,提交合法批次 只调用 setNeedsLayout

setNeedsLayout 是请求布局,不会自动清除业务高度缓存;layoutIfNeeded 是立即求解当前子树,不会替代数据源更新;invalidateLayout 是使 collection layout 失效,不等同于重新获取网络数据。

尺寸调度、数据更新和动画过渡应协调到主线程。若页面正在执行批量数据更新,可合并一次待处理的几何刷新,在该批次完成后应用,不要叠加彼此冲突的更新。

示例:把数据返回与尺寸变化分成两个入口。

// 数据确实变化时:更新模型与数据源。
func didReceiveItems(_ newItems: [Item]) {
    items = newItems
    applySnapshot(for: newItems)
}

// 仅尺寸变化时:不动网络,不重新提交同一批数据。
func didChangeContainerSize() {
    textMeasureCache.removeAll()
    collectionView.collectionViewLayout.invalidateLayout()
    updateTableHeaderHeight()
}

Item、缓存、视图及 applySnapshot 都由所在页面定义,这里展示职责划分。一个页面没有 table header 时就不调用对应方法,不必给所有页面统一执行同一组操作。

如果数据批次正在更新,合并一次待处理的尺寸刷新。

// 页面属性。
private var applyingData = false
private var needsGeometryRefresh = false

func requestLocalGeometryRefresh() {
    guard !applyingData else {
        needsGeometryRefresh = true
        return
    }
    didChangeContainerSize()
}

func finishApplyingData() {
    applyingData = false
    guard needsGeometryRefresh else { return }
    needsGeometryRefresh = false
    didChangeContainerSize()
}

开始数据更新前将 applyingData 设为 true,在合法批次或 snapshot 的完成回调中调用 finishApplyingData。实际数据层还需要按自身规则串行化多个数据批次。这里的“合并一次”只处理几何刷新,不应丢弃真实数据更新。

十、键盘 弹层与媒体需要一起适配

10.1 键盘避让优先使用布局 guide

在 iOS 15 及以上,底部操作区域可以连接 view.keyboardLayoutGuide.topAnchor。若同时使用第三方键盘管理库,要明确哪一套机制负责移动当前页面,避免双重避让。

if #available(iOS 15.0, *) {
    actionButton.bottomAnchor.constraint(
        equalTo: view.keyboardLayoutGuide.topAnchor,
        constant: -12
    ).isActive = true
}

这是替换按钮底部关系的局部片段,使用时应移除前面绑定 safe area 底部的 required 约束,不能把两条冲突关系同时激活。键盘收起时也要验证底部留白符合设计。兼容旧系统时,通过键盘 frame 与当前容器的坐标转换计算实际遮挡,并移除不再使用的通知观察。

Masonry 页面也可以使用系统键盘锚点。

// bottomConstraint 是原先添加的按钮底部约束引用。
if (@available(iOS 15.0, *)) {
    self.bottomConstraint.active = NO;
    [self.actionButton.bottomAnchor constraintEqualToAnchor:
        self.view.keyboardLayoutGuide.topAnchor
        constant:-12].active = YES;
}

bottomConstraint 在这里指 NSLayoutConstraint;如果原来保存的是 MASConstraint,则调用它的 uninstall 移除那一条关系。其他边界继续由 Masonry 管理,不能误把整组约束都卸载。

// 若保存的是 Masonry 的约束引用,移除方式是:
[self.savedBottomMASConstraint uninstall];

两种移除方式按实际约束类型选择,不要混用。测试时同时检查键盘收起、弹出、交互式收起和旋转中的位置。

10.2 浮层跟随源控件

图 3 时间菜单在不同视口下重新对齐源按钮

保存源控件的弱引用,在尺寸变化后将其当前 bounds 转换到弹层宿主坐标系。根据上下可用空间选择展开方向,并将菜单限制在宿主安全区域内;高度不足时让菜单内部滚动。

使用系统 popover 时,正确设置 sourceView 和 sourceRect,必要时在重定位代理回调中提供新锚点。不能缓存初始化时的屏幕坐标,也不能因为菜单在外屏正常,就假定展开后仍然正确。

示例:重新计算锚点并限制菜单位置。 下面适用于与宿主视图坐标轴对齐、未施加 transform 的自定义菜单。

func placeMenu(_ menu: UIView,
               from button: UIView,
               in host: UIView) {
    guard button.window != nil,
          button.window === host.window else { return }
    let safe = host.bounds.inset(by: host.safeAreaInsets)
        .insetBy(dx: 12, dy: 12)
    guard safe.width > 0, safe.height > 0 else { return }

    let anchor = button.convert(button.bounds, to: host)
    let width = min(220, safe.width)
    let desiredHeight: CGFloat = 180
    let below = max(0, safe.maxY - anchor.maxY - 8)
    let above = max(0, anchor.minY - safe.minY - 8)
    let showBelow = below >= desiredHeight || below >= above
    let height = min(desiredHeight, safe.height,
                     showBelow ? below : above)
    guard height > 0 else { menu.isHidden = true; return }

    let x = min(max(anchor.maxX - width, safe.minX),
                safe.maxX - width)
    let preferredY = showBelow
        ? anchor.maxY + 8 : anchor.minY - 8 - height
    let y = min(max(preferredY, safe.minY), safe.maxY - height)
    menu.frame = CGRect(x: x, y: y, width: width, height: height)
    menu.isHidden = false
}

menu 应由调用方添加到 host,再调用定位方法;本示例使用 frame,不应同时用约束管理同一菜单的 frame。菜单高度被限制后,内部内容要支持滚动。若源按钮已滚出安全区,可以隐藏菜单或采用产品定义的替代呈现,而不是继续跟随屏幕外的锚点。

系统 popover 的基础锚点配置更简单:

controller.modalPresentationStyle = .popover
controller.popoverPresentationController?.sourceView = button
controller.popoverPresentationController?.sourceRect = button.bounds
present(controller, animated: true)

controller 为待展示控制器。紧凑环境下系统可能自适应为其他呈现方式,仍需测试实际结果。

10.3 视频与背景使用同一几何基准

视频如果构成背景的一部分,应由同一承载视图决定缩放和裁剪。独立视频则按自己的容器和 videoGravity 展示。背景使用 aspectFill、视频却按另一层根视图的 bounds 显示,容易在尺寸变化后错位。

final class VideoContainerView: UIView {
    override class var layerClass: AnyClass {
        AVPlayerLayer.self
    }

    var playerLayer: AVPlayerLayer {
        layer as! AVPlayerLayer
    }
}

文件需要 import AVFoundation。用 AVPlayerLayer 作为 backing layer 时,其尺寸跟随 UIView;若是手动添加的子图层,则在承载视图的 layoutSubviews 更新 frame,并按需要关闭隐式动画。无论哪种方式,都不需要在旋转时重建播放器。

相机预览布局与录制输出方向分别处理,不要为了预览适配随意修改正在录制的输出配置。

示例:让视频层与背景共享一个容器,而不是各取全屏尺寸。

backgroundView.addSubview(videoView)
videoView.snp.makeConstraints { make in
    make.edges.equalToSuperview()
}
videoView.clipsToBounds = true
videoView.playerLayer.videoGravity = .resizeAspectFill
videoView.playerLayer.player = player

videoView 是前面的 VideoContainerView,player 是已有 AVPlayer。仅当视频与背景应该使用同一填充规则时,才使用 resizeAspectFill;它会裁剪内容。若要保证视频全部可见,应选择 resizeAspect 并接受可能出现的留白。承载容器的坐标一致是前提,具体内容如何对齐仍取决于两份素材的比例与构图。

10.4 动画与 WebP 空白要分层排查

有 transform 的动画视图应明确 bounds、center 和 transform 各自的职责。尺寸变化需要调整路径时,应保留动画进度或设计明确过渡,避免每轮布局都把动画重置。

WebP 加载空白按下面的顺序检查:资源是否进入正确 bundle,解码器是否提供多帧,展示控件是否播放多帧,首次加载时容器是否有有效尺寸,异步回调是否更新到了仍在显示的实例。

能显示静态首帧不代表能播放动画;手动触发正常也不能排除首次进入时序。只有确认库版本或能力不满足要求时,再升级或更换依赖,并回归包体、内存和播放性能。

示例一:变换视图更新尺寸时,不直接改 frame。

// 在承载视图尺寸真正变化时执行,不要每轮重启动画。
animatedView.bounds = CGRect(x: 0, y: 0, width: 80, height: 80)
animatedView.center = CGPoint(x: container.bounds.midX,
                              y: container.bounds.midY)
// transform 由现有动画管理,此处不重置为 identity。

bounds 表示自身尺寸,center 表示在父视图中的位置。动画施加 transform 后,frame 是变换后的包围矩形,不宜再把它当作原始布局输入。

示例二:用系统当前可用的解码能力检查资源帧数。

import ImageIO

func inspectImage(at url: URL) {
    guard let source = CGImageSourceCreateWithURL(
        url as CFURL, nil) else {
        print("当前解码器无法读取资源")
        return
    }
    let count = CGImageSourceGetCount(source)
    print("当前解码器可见的帧数:", count)
}

这个函数是诊断入口,不是完整 WebP 播放器。ImageIO 对具体格式与动画元数据的支持取决于系统版本;当前应用若使用另一套解码库,应检查那套库的实际返回结果。帧数大于 1 也只证明解码路径看到了多帧,播放控件、帧时长与生命周期仍需单独确认。

十一、把可读性与操作体验纳入设计

宽屏并不意味着把所有内容等比例放大。表单和长文可以设置最大可读宽度,外层背景仍铺满;列表可以增加列数,关键操作保持稳定的视觉位置。横屏高度不足时,优先滚动或调整内容层级,不要压缩到无法点击。

按钮至少保留合适的触控区域,避免 overlay、透明遮罩或超出父视图 bounds 的子控件阻断点击。遇到“按钮看得见但点不到”,除了检查 action,也应检查 hitTest、userInteractionEnabled、父视图边界和覆盖层。

动态字体、RTL、VoiceOver 和减少动态效果设置应进入同一套适配规则。用文字表达的关键信息不要只存在于动画或颜色中。对重要页面同时提供窄宽度、宽宽度和短高度的设计状态,研发与测试才有一致预期。

11.1 给长文设置最大可读宽度

问题:展开后,正文一行横跨整个窗口,读起来费力。 可以让背景铺满,内容在安全容器中保持合理最大宽度。

// readableContent 已添加到 safeContainer。
readableContent.snp.makeConstraints { make in
    make.centerX.equalToSuperview()
    make.top.bottom.equalToSuperview()
    make.leading.greaterThanOrEqualToSuperview().offset(16)
    make.trailing.lessThanOrEqualToSuperview().offset(-16)
    make.width.lessThanOrEqualTo(640)
    make.width.equalToSuperview().offset(-32).priority(750)
}

640 是示例阅读宽度,不是设备常量。窄视口中内容跟随父视图,宽视口中内容不超过上限。750 的等宽关系表达“空间允许时尽量铺开”,不会强行与 640 的上限冲突。

11.2 大字号与 RTL 不只靠重新算宽度

titleLabel.font = UIFont.preferredFont(forTextStyle: .body)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 0

// 默认跟随当前界面语言,不强制每个子视图永远从左向右。
contentView.semanticContentAttribute = .unspecified

多行文本还需完整约束。手动测量缓存要把字体和 content size category 纳入依赖;字体变化后,宽度不变也要重新测量。金额、电话号码等局部内容的阅读方向,应按语义单独设计,而不是把整个页面强制翻转。

11.3 点击失效时 检查真实命中对象

// 在界面显示并布局完成后,用于开发调试。
let localPoint = CGPoint(x: button.bounds.midX,
                         y: button.bounds.midY)
if let window = button.window {
    let windowPoint = button.convert(localPoint, to: window)
    let hitView = window.hitTest(windowPoint, with: nil)
    print("按钮中心实际命中的视图:", String(describing: hitView))
}

如果命中了透明遮罩,先检查层级;如果点落在父视图范围外,先检查父子 bounds;如果命中正确但事件没执行,再检查 target/action、isEnabled 等状态。不要一上来就把按钮 frame 改大。

11.4 给无障碍与减少动态效果提供对应行为

confirmButton.accessibilityLabel = "确认选择"
confirmButton.accessibilityHint = "保存当前设置"

if UIAccessibility.isReduceMotionEnabled {
    // 使用稳定的最终状态,不让核心信息依赖循环动画。
    indicatorView.layer.removeAllAnimations()
    indicatorView.alpha = 1
} else {
    startDecorativeAnimation()
}

startDecorativeAnimation 是页面自己的动画方法。需要响应用户运行中修改设置时,还应监听减少动态效果变化通知并清理观察者。关闭动画不能同时隐藏关键状态提示,文本状态需要保留。

十二、推荐迁移顺序

  1. 建立基线。 保存旧页面截图、入口路径、方向与构建配置,标记已知问题。
  2. 明确窗口归属。 需要时迁移 Scene,核对入口路由、前后台和状态恢复。
  3. 检查工程配置。 方向、启动资源、设备范围和各 configuration 保持一致。
  4. 治理尺寸来源。 从全局屏宽转向当前容器,移除永久缓存的安全区常量。
  5. 改布局关系。 用 SnapKit / Masonry 表达边界,短高度页面补齐滚动。
  6. 接入尺寸调度。 比较快照,合并事件,局部更新,避免重复观察和全量刷新。
  7. 修复列表依赖。 处理行高、header、item metrics、分页与阅读位置。
  8. 回归媒体与交互。 验证播放、动画、菜单、键盘、触控和无障碍。
  9. 分别验收。 编译、模拟器运行、真机调试和发布配置验证分别记录。

不要一次把全部固定值都替换掉。按钮高度、设计间距、媒体输出尺寸等固定值可能合理,首先确认它们描述的是业务规则还是错误的全屏假设。

示例:先列出旧写法的候选位置,再逐个判断用途。

# 在自己的源码目录执行;结果只是待检查清单。
rg -n 'UIScreen\.main|mainScreen|keyWindow|statusBarFrame' \
  --glob '*.swift' --glob '*.[mh]'
rg -n 'viewDidLayoutSubviews|viewWillTransition|orientationDidChange' \
  --glob '*.swift' --glob '*.[mh]'

命中不等于错误。例如导出视频的分辨率与页面布局宽度不同,不能见到一个固定数值就改成 view.bounds。先确认调用方、容器与更新时机,再分批修改并回归。

给每一批修改留下可复查的记录。

范围:某一类列表与其详情页
旧依赖:初始化时的全屏宽度
新依赖:列表内容区域的实际宽度
触发:首次有效布局、宽度变化、字体或内容变化
更新:文字测量、行高;不重新请求数据
保留:可见项 ID、阅读偏移、展开状态
验证:窄屏 → 横屏 → 展开 → 折叠,并在切换中返回数据

这份记录把“改了哪些代码”变成“修复了哪个失效条件”,后续维护者也知道什么时候必须重新计算。

十三、验收清单

工程与生命周期

  • 有效 Info.plist 中的方向和 Scene 配置符合预期。
  • 主窗口只由一条路径创建,没有重复根控制器。
  • 冷启动与运行中的 URL、Universal Link 都进入正确场景。
  • 前后台、断开重连和状态恢复行为正确。
  • Debug、Release 与自定义配置均已核对。

对应的调试代码:确认回调属于哪个场景。

// 在 SceneDelegate 的连接或激活回调中临时使用。
print("session:", scene.session.persistentIdentifier)
print("window:", String(describing: window?.bounds))

同一条外部链接应进入预期的 session;若出现两套根页面,回查窗口创建路径,不要靠隐藏其中一个窗口掩盖问题。

页面与布局

  • 首次进入窄视口时,加载态、空态和正常态都可见。
  • 横屏左右安全距离正确,背景没有多余白边。
  • 短高度页面可滚动,最后一个操作可以触达。
  • 折叠展开后文字重新换行,没有截断或重叠。
  • 菜单、弹层与点击区域跟随真实控件更新。

对应的调试代码:递归查找存在歧义的约束。

func reportAmbiguousViews(_ root: UIView) {
    if root.hasAmbiguousLayout {
        print("Ambiguous:", type(of: root), root.frame)
    }
    root.subviews.forEach(reportAmbiguousViews)
}

// 在有效布局完成后,开发环境调用。
reportAmbiguousViews(view)

没有歧义不等于没有错位:控件也可能被清晰地约束到了错误位置。因此仍要人工检查安全区、内容截断与首尾可达性,并查看控制台中的约束冲突日志。

列表与状态

  • UITableView 的首尾 cell 完整,行高缓存已更新。
  • tableHeaderView 高度正确,没有覆盖首个 cell。
  • UICollectionView 的列数、item 尺寸和间距正确。
  • 阅读位置、当前分页、选择和展开状态得到保留。
  • 旋转期间数据回包,不产生非法批量更新或跳动。

对应的检查方法:切换前后比较稳定 ID。

// 在开始尺寸切换前记录。
let selectedIDBefore = selectedItemID
let draftBefore = draftText

// 在布局更新完成后,开发调试时检查。
assert(selectedItemID == selectedIDBefore)
assert(draftText == draftBefore)

这些变量由页面状态层提供,并跨过渡保存。断言适合验证“不应被布局改变”的状态;用户恰好在过渡期间主动修改选择或文字时,应按事件顺序判断,不能把合法业务变化误报成布局错误。

媒体与体验

  • 视频与背景的裁剪规则一致,播放进度得到保留。
  • 首次启动动画正常,切换尺寸没有不断重置。
  • 键盘弹出和收起后,输入与保存操作正常。
  • 长文案、RTL、大字号、VoiceOver 均已覆盖。
  • 真机性能与发布配置分别验证,结果有记录。

适配的目标是让页面理解自己的容器、尺寸依赖和用户状态。工程配置提供运行条件,Scene 明确窗口归属,约束表达布局关系,尺寸调度负责更新必要的测量结果。这些环节配合起来,页面才能持续应对旋转、折叠展开和其他视口变化。

对应的检查方法:几何变化不应该重新创建播放器。

// 过渡前保存当前播放器引用,过渡后核对。
let playerBefore = videoView.playerLayer.player
// 完成尺寸变化后:
assert(videoView.playerLayer.player === playerBefore)

播放器引用未变只是一个检查项,还需要观察播放是否连续、视频是否与背景对齐、菜单是否跟随锚点。运行中切换尺寸的验证结果,应与静态截图和编译结果分开记录。

参考资料

以下为官方文档入口,具体接口可用性与行为请按目标 SDK 核对。

最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容