iOS OnboardingViewController:从新手引导到权限与首启状态

type
status
date
slug
summary
tags
category
icon
password
wechat_gate
notion image
iOS OnboardingViewController 不是 UIKit 提供的官方组件,而是项目自己定义的引导页控制器。它通常负责组织首次启动时的价值说明、分页浏览、跳过与完成动作;真正难点不在“能不能左右滑”,而在首启状态、版本升级、权限时机和根控制器切换能否长期维护。
如果只把它写成一个带三张图片的 UIViewController,页面很快就能跑起来。但 App 进入第二个版本以后,问题会连续出现:老用户要不要重新看、新功能怎样提示、用户中途退出怎么办、通知权限何时请求、UIKit 和 SwiftUI 应该选哪一种实现?
这篇文章从这些工程问题出发,给出一套完整的 UIKit 实现,并说明 SwiftUI 的等价写法与适用边界。

OnboardingViewController 到底是什么

从类型上看,它只是普通的 UIViewController
OnboardingViewController 这个名字表达的是业务职责,不代表某个框架能力。项目也可以叫它 WelcomeViewControllerIntroViewControllerFirstRunViewController。真正重要的是明确边界:
  • 它负责展示引导流程和接收用户操作。
  • 它不应该自己决定整个 App 的启动路由。
  • 它不应该散落读写多个 UserDefaults 键。
  • 它不应该在出现时自动请求所有系统权限。
  • 它不应该承担登录、订阅购买或首页业务逻辑。
Apple 的 Human Interface Guidelines 也没有要求每个 App 都必须做 onboarding。官方建议是:如果产品本身可以通过直接使用被理解,就不必增加引导;确实需要时,流程应该快速、可跳过,并尽量通过交互让用户理解功能。
因此,第一个判断不是“用什么控件”,而是“这个流程是否真的必要”。

iOS 新手引导页应该解决什么问题

一个有效的 onboarding 至少要回答三个问题:
  1. 这个 App 帮用户解决什么问题?
  1. 用户下一步应该做什么?
  1. 为什么某项权限在这个动作中是必要的?
它不是功能清单,更不是把 App Store 描述复制进三张卡片。以订阅续费提醒为例,第一页写 Welcome to DueSight 只是在欢迎用户;写 Never miss a subscription renewal again 才说明用户能获得什么结果。
在本文这版方案里,我把“展示品牌”降为辅助信息,把“避免忘记续费”放到第一屏;同时没有把通知权限请求放进 viewDidAppear。这个取舍让页面职责更单一:onboarding 先解释价值,权限请求留到用户创建第一个提醒的具体场景。
这里需要控制结论的边界。本文没有真实的转化率或权限同意率实验数据,因此不能声称某句文案一定提高多少转化。能确定的是:结果导向的文案更直接,而按上下文请求权限符合 Apple 的官方设计建议。

UIKit 分页方案怎么选

常见实现有三种,它们没有绝对优劣:
方案
优点
代价
适用场景
UIPageViewController
容器职责清晰,原生翻页手势成熟,每页可独立管理
数据源与当前页同步代码较多
每页结构差异明显、希望按控制器拆分
横向 UICollectionView
数据驱动、复用能力强、布局和转场可高度定制
需要处理分页吸附、当前索引和旋转适配
页面结构一致、动画或交互定制较多
UIScrollView
小型静态页面上手快
状态、复用、旋转和无障碍维护成本高
页面极少且不会继续增长的简单流程
本文选择 UIPageViewController。原因不是它代码最少,而是它与材料中的文件结构更匹配:页面模型、单页控制器、流程控制器和状态存储可以各自承担一个职责。
notion image
推荐的目录结构如下:
它们的关系可以概括为:

用 UIPageViewController 完成可维护的引导流程

下面的代码按 iOS 15 及以上写法组织。为方便阅读放在同一处,实际项目中建议按上面的目录拆分。

第一步:让页面内容成为数据

不要在控制器中写 if page == 0 再分别设置标题和图片。先把内容抽成值类型:
这里使用 SF Symbols 只是为了让示例不依赖外部图片资源。正式项目可以把 symbolName 换成 Asset Catalog 中的插图名,但页面模型仍然不应该持有视图实例。

第二步:单页控制器只负责渲染

preferredFont 配合 adjustsFontForContentSizeCategory 可以适配 Dynamic Type。标题允许换行,避免中文、英文或更大字号下被截断。引导页是首次体验,不应该把无障碍适配留到最后补。

第三步:流程控制器管理分页与按钮状态

这里有三个容易漏掉的细节:
  • 只有 transitionCompletedtrue 时才能更新索引,否则用户滑到一半取消手势会造成页码错位。
  • onFinish 只抛出完成事件,不直接操作 window.rootViewController,便于测试和复用。
  • Skip 与“开始使用”可以共用同一个完成出口,避免状态写入分散在多个按钮回调中。

不要只存 hasSeenOnboarding

最简单的首启判断通常是:
它能解决 1.0 版本,却无法回答“用户看过哪一版”。如果 2.0 增加数据迁移说明或核心流程改变,单个布尔值无法区分新老引导。
我最先考虑的也是布尔键。把版本升级场景放进去推演后,这个方案立刻暴露了限制,因此最终改成整数版本号:
版本号由产品含义决定,不必等于 App 的 CFBundleShortVersionString。只有 onboarding 内容发生需要再次展示的实质变化时才递增。否则每次发版都弹一次引导,会把“首次体验”变成干扰。
UserDefaults 适合保存这种非敏感、小体积的启动配置。Apple 明确提醒不要用它存储个人或敏感信息;这类数据应该使用更合适的安全存储方案。

把首启判断放在根路由,而不是页面里

SceneDelegate 中创建路由:
这里把 rootRouter 保存在属性中,是为了避免它在 scene(_:willConnectTo:options:) 结束后被释放。OnboardingViewController 的闭包使用 [weak self],避免控制器和路由之间形成不必要的强引用环。

权限请求应该发生在具体动作之后

notion image
首次打开 App 就连续请求通知、相册、定位和跟踪权限,是 onboarding 最常见的错误之一。系统弹窗提供的信息有限,用户还没理解功能,就必须做不可逆或不容易恢复的决定。
Apple 对权限设计的原则很清楚:只有在 App 明确需要某项数据或能力时才请求;理想情况下,应等到用户实际使用相关功能。通知文档给出的例子是,在任务管理 App 中,用户创建第一项任务后再请求通知权限,而不是在首次启动时自动弹窗。
对于订阅提醒 App,更合理的流程是:
对应代码可以放在创建提醒的业务动作中,而不是 OnboardingViewController.viewDidAppear
还要注意两个边界:
  1. 权限被拒绝后,重复调用 requestAuthorization 不会再次弹出首次授权框。界面应该解释当前状态,并在用户主动操作时提供前往系统设置的入口。
  1. 如果在系统弹窗前增加自定义说明页,不要伪装系统按钮,也不要用奖励诱导同意。Apple 建议只保留一个明确会继续打开系统弹窗的按钮,例如“继续”或“下一步”。
Onboarding 可以解释“为什么需要通知”,但不应该把“同意通知”作为完成整个引导的强制条件。

如何验证首启状态没有写错

视觉分页很容易手测,状态逻辑更适合单元测试。由于 OnboardingStateStore 支持注入独立的 UserDefaults,可以避免污染真实配置:
界面层还应覆盖这些手动场景:
  • 左右滑到一半后取消,页码与按钮文案不能提前变化。
  • 点击“继续”与手势翻页后,当前索引保持一致。
  • 点击“跳过”和最后一页“开始使用”,都只记录一次完成状态。
  • 打开更大字号、深色模式和 VoiceOver,内容仍可读、按钮仍可操作。
  • App 升级但 onboarding 版本未变时,不应重新展示。
  • onboarding 版本递增时,已完成旧版本的用户应看到新流程。

SwiftUI 中不需要保留 ViewController 命名

如果项目主体已经使用 SwiftUI,通常直接定义 OnboardingView,用 TabView.page 样式实现分页。Apple 官方文档确认 .page 是分页滚动的 TabViewStyle
UIKit 与 SwiftUI 的差异主要在界面组合方式,不在产品状态。无论使用哪种框架,都应保留这些共同原则:版本化完成状态、可跳过、权限按场景请求、根路由独立于页面、内容与视图分离。

三个常见失败点

把引导完成和授权成功绑在一起

用户拒绝通知,不代表他不能使用 App。把权限成功当成 onboarding 完成条件,会让流程陷入循环,也可能让用户误以为授权是强制的。

在多个页面分别写完成状态

“跳过”、最后一页按钮、登录成功回调都各写一次 UserDefaults,很快会产生不一致。统一收敛到 onFinish,再由根路由写入状态,路径更容易验证。

用发布版本号替代 onboarding 版本号

App 每次修复 Bug 都增加版本号,但引导内容未必变化。直接绑定发布版本会让老用户频繁看到同一套页面。onboarding 版本应该是独立的产品决策。

总结

OnboardingViewController 本质上只是一个业务命名的 UIViewController。一套可维护的实现,需要把问题拆成四层:
  • 页面模型描述要展示什么。
  • 单页控制器负责怎样展示。
  • 流程控制器负责分页、跳过和完成事件。
  • 根路由与状态存储决定什么时候展示、完成后去哪里。
技术上,UIKit 可以选择 UIPageViewControllerUICollectionView,SwiftUI 可以使用 TabView.page 样式。产品上,更重要的是让流程快速、可跳过,只解释真正必要的内容,并把系统权限留到用户能理解其用途的具体动作中。

参考资料

2026.08.06 18:33 沪 · 赵巷
📌 声明:本文由 AI 辅助完成