
iOS OnboardingViewController 不是 UIKit 提供的官方组件,而是项目自己定义的引导页控制器。它通常负责组织首次启动时的价值说明、分页浏览、跳过与完成动作;真正难点不在“能不能左右滑”,而在首启状态、版本升级、权限时机和根控制器切换能否长期维护。
如果只把它写成一个带三张图片的 UIViewController,页面很快就能跑起来。但 App 进入第二个版本以后,问题会连续出现:老用户要不要重新看、新功能怎样提示、用户中途退出怎么办、通知权限何时请求、UIKit 和 SwiftUI 应该选哪一种实现?
这篇文章从这些工程问题出发,给出一套完整的 UIKit 实现,并说明 SwiftUI 的等价写法与适用边界。
从类型上看,它只是普通的 UIViewController:
import UIKit
final class OnboardingViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
}
}OnboardingViewController 这个名字表达的是业务职责,不代表某个框架能力。项目也可以叫它 WelcomeViewController、IntroViewController 或 FirstRunViewController。真正重要的是明确边界:
UserDefaults 键。Apple 的 Human Interface Guidelines 也没有要求每个 App 都必须做 onboarding。官方建议是:如果产品本身可以通过直接使用被理解,就不必增加引导;确实需要时,流程应该快速、可跳过,并尽量通过交互让用户理解功能。
因此,第一个判断不是“用什么控件”,而是“这个流程是否真的必要”。
一个有效的 onboarding 至少要回答三个问题:
它不是功能清单,更不是把 App Store 描述复制进三张卡片。以订阅续费提醒为例,第一页写 Welcome to DueSight 只是在欢迎用户;写 Never miss a subscription renewal again 才说明用户能获得什么结果。
在本文这版方案里,我把“展示品牌”降为辅助信息,把“避免忘记续费”放到第一屏;同时没有把通知权限请求放进 viewDidAppear。这个取舍让页面职责更单一:onboarding 先解释价值,权限请求留到用户创建第一个提醒的具体场景。
这里需要控制结论的边界。本文没有真实的转化率或权限同意率实验数据,因此不能声称某句文案一定提高多少转化。能确定的是:结果导向的文案更直接,而按上下文请求权限符合 Apple 的官方设计建议。
常见实现有三种,它们没有绝对优劣:
UIPageViewController | |||
UICollectionView | |||
UIScrollView |
本文选择 UIPageViewController。原因不是它代码最少,而是它与材料中的文件结构更匹配:页面模型、单页控制器、流程控制器和状态存储可以各自承担一个职责。

推荐的目录结构如下:
Onboarding/
├── OnboardingItem.swift
├── OnboardingPageViewController.swift
├── OnboardingViewController.swift
├── OnboardingStateStore.swift
└── AppRootRouter.swift它们的关系可以概括为:
AppRootRouter
├── 读取 OnboardingStateStore
├── 需要展示 -> OnboardingViewController
│ └── UIPageViewController
│ └── OnboardingPageViewController
└── 已完成 -> MainViewController下面的代码按 iOS 15 及以上写法组织。为方便阅读放在同一处,实际项目中建议按上面的目录拆分。
不要在控制器中写 if page == 0 再分别设置标题和图片。先把内容抽成值类型:
import UIKit
struct OnboardingItem {
let symbolName: String
let title: String
let message: String
}
extension OnboardingItem {
static let defaultItems: [OnboardingItem] = [
.init(
symbolName: "calendar.badge.clock",
title: "提前知道续费时间",
message: "集中记录订阅,在下一次扣费前收到提醒。"
),
.init(
symbolName: "lock.shield",
title: "数据留在你的设备上",
message: "先说明数据如何保存,再让用户决定是否继续。"
),
.init(
symbolName: "bell.badge",
title: "在需要时开启提醒",
message: "创建第一条订阅后,再请求通知权限。"
)
]
}这里使用 SF Symbols 只是为了让示例不依赖外部图片资源。正式项目可以把 symbolName 换成 Asset Catalog 中的插图名,但页面模型仍然不应该持有视图实例。
final class OnboardingPageViewController: UIViewController {
private let item: OnboardingItem
private let imageView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFit
view.tintColor = .systemIndigo
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private let titleLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .largeTitle)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let messageLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.textColor = .secondaryLabel
label.numberOfLines = 0
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
init(item: OnboardingItem) {
self.item = item
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
imageView.image = UIImage(systemName: item.symbolName)
titleLabel.text = item.title
messageLabel.text = item.message
let stack = UIStackView(arrangedSubviews: [
imageView,
titleLabel,
messageLabel
])
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 20
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
imageView.heightAnchor.constraint(equalToConstant: 132),
stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -24)
])
}
}preferredFont 配合 adjustsFontForContentSizeCategory 可以适配 Dynamic Type。标题允许换行,避免中文、英文或更大字号下被截断。引导页是首次体验,不应该把无障碍适配留到最后补。
final class OnboardingViewController: UIViewController {
var onFinish: (() -> Void)?
private let items: [OnboardingItem]
private lazy var pages = items.map(OnboardingPageViewController.init)
private var currentIndex = 0
private let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal
)
private let pageControl = UIPageControl()
private lazy var primaryButton: UIButton = {
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .large
let button = UIButton(configuration: configuration)
button.addTarget(self, action: #selector(primaryButtonTapped), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
private lazy var skipButton: UIButton = {
var configuration = UIButton.Configuration.plain()
configuration.title = "跳过"
let button = UIButton(configuration: configuration)
button.addTarget(self, action: #selector(finish), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
init(items: [OnboardingItem] = OnboardingItem.defaultItems) {
precondition(!items.isEmpty, "Onboarding 至少需要一个页面")
self.items = items
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
pageViewController.dataSource = self
pageViewController.delegate = self
addChild(pageViewController)
view.addSubview(pageViewController.view)
pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
pageViewController.didMove(toParent: self)
pageControl.numberOfPages = pages.count
pageControl.currentPage = currentIndex
pageControl.isUserInteractionEnabled = false
pageControl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageControl)
view.addSubview(primaryButton)
view.addSubview(skipButton)
NSLayoutConstraint.activate([
skipButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
skipButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
pageViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
pageViewController.view.bottomAnchor.constraint(equalTo: pageControl.topAnchor, constant: -12),
pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
pageControl.bottomAnchor.constraint(equalTo: primaryButton.topAnchor, constant: -16),
primaryButton.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
primaryButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
primaryButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16),
primaryButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 50)
])
showPage(at: 0, direction: .forward, animated: false)
}
private func showPage(
at index: Int,
direction: UIPageViewController.NavigationDirection,
animated: Bool
) {
guard pages.indices.contains(index) else { return }
currentIndex = index
pageViewController.setViewControllers(
[pages[index]],
direction: direction,
animated: animated
)
updateControls()
}
private func updateControls() {
pageControl.currentPage = currentIndex
let isLastPage = currentIndex == pages.count - 1
primaryButton.configuration?.title = isLastPage ? "开始使用" : "继续"
skipButton.isHidden = isLastPage
}
@objc private func primaryButtonTapped() {
let nextIndex = currentIndex + 1
guard pages.indices.contains(nextIndex) else {
finish()
return
}
showPage(at: nextIndex, direction: .forward, animated: true)
}
@objc private func finish() {
onFinish?()
}
}
extension OnboardingViewController: UIPageViewControllerDataSource {
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController
) -> UIViewController? {
guard let index = pages.firstIndex(where: { $0 === viewController }),
pages.indices.contains(index - 1) else {
return nil
}
return pages[index - 1]
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController
) -> UIViewController? {
guard let index = pages.firstIndex(where: { $0 === viewController }),
pages.indices.contains(index + 1) else {
return nil
}
return pages[index + 1]
}
}
extension OnboardingViewController: UIPageViewControllerDelegate {
func pageViewController(
_ pageViewController: UIPageViewController,
didFinishAnimating finished: Bool,
previousViewControllers: [UIViewController],
transitionCompleted completed: Bool
) {
guard completed,
let visiblePage = pageViewController.viewControllers?.first,
let index = pages.firstIndex(where: { $0 === visiblePage }) else {
return
}
currentIndex = index
updateControls()
}
}这里有三个容易漏掉的细节:
transitionCompleted 为 true 时才能更新索引,否则用户滑到一半取消手势会造成页码错位。onFinish 只抛出完成事件,不直接操作 window.rootViewController,便于测试和复用。Skip 与“开始使用”可以共用同一个完成出口,避免状态写入分散在多个按钮回调中。最简单的首启判断通常是:
UserDefaults.standard.set(true, forKey: "hasSeenOnboarding")它能解决 1.0 版本,却无法回答“用户看过哪一版”。如果 2.0 增加数据迁移说明或核心流程改变,单个布尔值无法区分新老引导。
我最先考虑的也是布尔键。把版本升级场景放进去推演后,这个方案立刻暴露了限制,因此最终改成整数版本号:
struct OnboardingStateStore {
private let defaults: UserDefaults
private let currentVersion: Int
private let completedVersionKey = "onboarding.lastCompletedVersion"
init(
defaults: UserDefaults = .standard,
currentVersion: Int
) {
self.defaults = defaults
self.currentVersion = currentVersion
}
var shouldPresent: Bool {
defaults.integer(forKey: completedVersionKey) < currentVersion
}
func markCompleted() {
defaults.set(currentVersion, forKey: completedVersionKey)
}
}版本号由产品含义决定,不必等于 App 的 CFBundleShortVersionString。只有 onboarding 内容发生需要再次展示的实质变化时才递增。否则每次发版都弹一次引导,会把“首次体验”变成干扰。
UserDefaults 适合保存这种非敏感、小体积的启动配置。Apple 明确提醒不要用它存储个人或敏感信息;这类数据应该使用更合适的安全存储方案。
final class MainViewController: UIViewController {}
final class AppRootRouter {
private let window: UIWindow
private let stateStore: OnboardingStateStore
init(window: UIWindow, stateStore: OnboardingStateStore) {
self.window = window
self.stateStore = stateStore
}
func start() {
if stateStore.shouldPresent {
showOnboarding()
} else {
showMainInterface(animated: false)
}
}
private func showOnboarding() {
let controller = OnboardingViewController()
controller.onFinish = { [weak self] in
guard let self else { return }
self.stateStore.markCompleted()
self.showMainInterface(animated: true)
}
window.rootViewController = controller
window.makeKeyAndVisible()
}
private func showMainInterface(animated: Bool) {
let mainController = MainViewController()
guard animated else {
window.rootViewController = mainController
window.makeKeyAndVisible()
return
}
UIView.transition(
with: window,
duration: 0.25,
options: .transitionCrossDissolve,
animations: {
self.window.rootViewController = mainController
}
)
}
}在 SceneDelegate 中创建路由:
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var rootRouter: AppRootRouter?
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 store = OnboardingStateStore(currentVersion: 1)
let router = AppRootRouter(window: window, stateStore: store)
self.window = window
self.rootRouter = router
router.start()
}
}这里把 rootRouter 保存在属性中,是为了避免它在 scene(_:willConnectTo:options:) 结束后被释放。OnboardingViewController 的闭包使用 [weak self],避免控制器和路由之间形成不必要的强引用环。

首次打开 App 就连续请求通知、相册、定位和跟踪权限,是 onboarding 最常见的错误之一。系统弹窗提供的信息有限,用户还没理解功能,就必须做不可逆或不容易恢复的决定。
Apple 对权限设计的原则很清楚:只有在 App 明确需要某项数据或能力时才请求;理想情况下,应等到用户实际使用相关功能。通知文档给出的例子是,在任务管理 App 中,用户创建第一项任务后再请求通知权限,而不是在首次启动时自动弹窗。
对于订阅提醒 App,更合理的流程是:
打开 App
-> 了解续费提醒的价值
-> 进入主界面
-> 创建第一条订阅记录
-> 选择开启到期提醒
-> 请求系统通知权限对应代码可以放在创建提醒的业务动作中,而不是 OnboardingViewController.viewDidAppear:
import UserNotifications
final class NotificationPermissionRequester {
func requestIfNeeded() async throws -> Bool {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
return try await center.requestAuthorization(
options: [.alert, .sound, .badge]
)
case .authorized, .provisional, .ephemeral:
return true
case .denied:
return false
@unknown default:
return false
}
}
}还要注意两个边界:
requestAuthorization 不会再次弹出首次授权框。界面应该解释当前状态,并在用户主动操作时提供前往系统设置的入口。Onboarding 可以解释“为什么需要通知”,但不应该把“同意通知”作为完成整个引导的强制条件。
视觉分页很容易手测,状态逻辑更适合单元测试。由于 OnboardingStateStore 支持注入独立的 UserDefaults,可以避免污染真实配置:
import XCTest
final class OnboardingStateStoreTests: XCTestCase {
private var defaults: UserDefaults!
override func setUp() {
super.setUp()
defaults = UserDefaults(suiteName: "OnboardingStateStoreTests")
defaults.removePersistentDomain(forName: "OnboardingStateStoreTests")
}
override func tearDown() {
defaults.removePersistentDomain(forName: "OnboardingStateStoreTests")
defaults = nil
super.tearDown()
}
func testFirstLaunchShouldPresentOnboarding() {
let store = OnboardingStateStore(
defaults: defaults,
currentVersion: 1
)
XCTAssertTrue(store.shouldPresent)
}
func testCompletedCurrentVersionShouldNotPresentAgain() {
let store = OnboardingStateStore(
defaults: defaults,
currentVersion: 1
)
store.markCompleted()
XCTAssertFalse(store.shouldPresent)
}
func testNewOnboardingVersionShouldPresentAgain() {
OnboardingStateStore(
defaults: defaults,
currentVersion: 1
).markCompleted()
let upgradedStore = OnboardingStateStore(
defaults: defaults,
currentVersion: 2
)
XCTAssertTrue(upgradedStore.shouldPresent)
}
}界面层还应覆盖这些手动场景:
如果项目主体已经使用 SwiftUI,通常直接定义 OnboardingView,用 TabView 的 .page 样式实现分页。Apple 官方文档确认 .page 是分页滚动的 TabViewStyle。
import SwiftUI
struct SwiftUIOnboardingItem: Identifiable {
let id: Int
let symbolName: String
let title: String
let message: String
}
struct OnboardingView: View {
@AppStorage("onboarding.lastCompletedVersion")
private var completedVersion = 0
@State private var selection = 0
private let currentVersion = 1
private let items: [SwiftUIOnboardingItem] = [
.init(id: 0, symbolName: "calendar.badge.clock", title: "提前知道续费时间", message: "集中记录订阅。"),
.init(id: 1, symbolName: "lock.shield", title: "理解数据去向", message: "先说明隐私边界。"),
.init(id: 2, symbolName: "bell.badge", title: "按需开启提醒", message: "创建记录后再请求权限。")
]
var body: some View {
VStack(spacing: 20) {
TabView(selection: $selection) {
ForEach(items) { item in
VStack(spacing: 20) {
Image(systemName: item.symbolName)
.font(.system(size: 88))
.foregroundStyle(.indigo)
Text(item.title)
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
Text(item.message)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
.tag(item.id)
}
}
.tabViewStyle(.page)
Button(selection == items.count - 1 ? "开始使用" : "继续") {
if selection == items.count - 1 {
completedVersion = currentVersion
} else {
selection += 1
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal)
}
}
}UIKit 与 SwiftUI 的差异主要在界面组合方式,不在产品状态。无论使用哪种框架,都应保留这些共同原则:版本化完成状态、可跳过、权限按场景请求、根路由独立于页面、内容与视图分离。
用户拒绝通知,不代表他不能使用 App。把权限成功当成 onboarding 完成条件,会让流程陷入循环,也可能让用户误以为授权是强制的。
“跳过”、最后一页按钮、登录成功回调都各写一次 UserDefaults,很快会产生不一致。统一收敛到 onFinish,再由根路由写入状态,路径更容易验证。
App 每次修复 Bug 都增加版本号,但引导内容未必变化。直接绑定发布版本会让老用户频繁看到同一套页面。onboarding 版本应该是独立的产品决策。
OnboardingViewController 本质上只是一个业务命名的 UIViewController。一套可维护的实现,需要把问题拆成四层:
技术上,UIKit 可以选择 UIPageViewController 或 UICollectionView,SwiftUI 可以使用 TabView 的 .page 样式。产品上,更重要的是让流程快速、可跳过,只解释真正必要的内容,并把系统权限留到用户能理解其用途的具体动作中。
如果你正在做 iOS 新手引导,欢迎在评论区分享你选择 UIPageViewController、UICollectionView 还是 SwiftUI TabView,以及你如何处理权限请求时机。具体经验会帮助其他国内开发者少走一些弯路。如果本文对你有用,也欢迎点一下「在看」或转发给需要的朋友。
写 AI,写成长,偶尔写投资。
关注沐风,不定期更新,全是干货。
2026.08.06 18:33
沪 · 赵巷
📌 声明:本文由 AI 辅助完成