当前位置:首页>iOSAPP>iOS OnboardingViewController:从新手引导到权限与首启状态

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

  • 2026-08-14 05:16:55
iOS OnboardingViewController:从新手引导到权限与首启状态

iOS OnboardingViewController 不是 UIKit 提供的官方组件,而是项目自己定义的引导页控制器。它通常负责组织首次启动时的价值说明、分页浏览、跳过与完成动作;真正难点不在“能不能左右滑”,而在首启状态、版本升级、权限时机和根控制器切换能否长期维护。

如果只把它写成一个带三张图片的 UIViewController,页面很快就能跑起来。但 App 进入第二个版本以后,问题会连续出现:老用户要不要重新看、新功能怎样提示、用户中途退出怎么办、通知权限何时请求、UIKit 和 SwiftUI 应该选哪一种实现?

这篇文章从这些工程问题出发,给出一套完整的 UIKit 实现,并说明 SwiftUI 的等价写法与适用边界。

OnboardingViewController 到底是什么

从类型上看,它只是普通的 UIViewController

import UIKit

final
 class OnboardingViewController: UIViewController {
    override
 func viewDidLoad() {
        super
.viewDidLoad()
        view.backgroundColor = .systemBackground
    }
}

OnboardingViewController 这个名字表达的是业务职责,不代表某个框架能力。项目也可以叫它 WelcomeViewControllerIntroViewController 或 FirstRunViewController。真正重要的是明确边界:

  • • 它负责展示引导流程和接收用户操作。
  • • 它不应该自己决定整个 App 的启动路由。
  • • 它不应该散落读写多个 UserDefaults 键。
  • • 它不应该在出现时自动请求所有系统权限。
  • • 它不应该承担登录、订阅购买或首页业务逻辑。

Apple 的 Human Interface Guidelines 也没有要求每个 App 都必须做 onboarding。官方建议是:如果产品本身可以通过直接使用被理解,就不必增加引导;确实需要时,流程应该快速、可跳过,并尽量通过交互让用户理解功能。

因此,第一个判断不是“用什么控件”,而是“这个流程是否真的必要”。

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

一个有效的 onboarding 至少要回答三个问题:

  1. 1. 这个 App 帮用户解决什么问题?
  2. 2. 用户下一步应该做什么?
  3. 3. 为什么某项权限在这个动作中是必要的?

它不是功能清单,更不是把 App Store 描述复制进三张卡片。以订阅续费提醒为例,第一页写 Welcome to DueSight 只是在欢迎用户;写 Never miss a subscription renewal again 才说明用户能获得什么结果。

在本文这版方案里,我把“展示品牌”降为辅助信息,把“避免忘记续费”放到第一屏;同时没有把通知权限请求放进 viewDidAppear。这个取舍让页面职责更单一:onboarding 先解释价值,权限请求留到用户创建第一个提醒的具体场景。

这里需要控制结论的边界。本文没有真实的转化率或权限同意率实验数据,因此不能声称某句文案一定提高多少转化。能确定的是:结果导向的文案更直接,而按上下文请求权限符合 Apple 的官方设计建议。

UIKit 分页方案怎么选

常见实现有三种,它们没有绝对优劣:

方案
优点
代价
适用场景
UIPageViewController
容器职责清晰,原生翻页手势成熟,每页可独立管理
数据源与当前页同步代码较多
每页结构差异明显、希望按控制器拆分
横向 UICollectionView
数据驱动、复用能力强、布局和转场可高度定制
需要处理分页吸附、当前索引和旋转适配
页面结构一致、动画或交互定制较多
UIScrollView
小型静态页面上手快
状态、复用、旋转和无障碍维护成本高
页面极少且不会继续增长的简单流程

本文选择 UIPageViewController。原因不是它代码最少,而是它与材料中的文件结构更匹配:页面模型、单页控制器、流程控制器和状态存储可以各自承担一个职责。

Onboarding 根控制器与分页架构

推荐的目录结构如下:

Onboarding/
├── OnboardingItem.swift
├── OnboardingPageViewController.swift
├── OnboardingViewController.swift
├── OnboardingStateStore.swift
└── AppRootRouter.swift

它们的关系可以概括为:

AppRootRouter
├── 读取 OnboardingStateStore
├── 需要展示 -> OnboardingViewController
│   └── UIPageViewController
│       └── OnboardingPageViewController
└── 已完成 -> MainViewController

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

下面的代码按 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 与“开始使用”可以共用同一个完成出口,避免状态写入分散在多个按钮回调中。

不要只存 hasSeenOnboarding

最简单的首启判断通常是:

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],避免控制器和路由之间形成不必要的强引用环。

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

iOS Onboarding 通知权限请求时机

首次打开 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
        }
    }
}

还要注意两个边界:

  1. 1. 权限被拒绝后,重复调用 requestAuthorization 不会再次弹出首次授权框。界面应该解释当前状态,并在用户主动操作时提供前往系统设置的入口。
  2. 2. 如果在系统弹窗前增加自定义说明页,不要伪装系统按钮,也不要用奖励诱导同意。Apple 建议只保留一个明确会继续打开系统弹窗的按钮,例如“继续”或“下一步”。

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)
    }
}

界面层还应覆盖这些手动场景:

  • • 左右滑到一半后取消,页码与按钮文案不能提前变化。
  • • 点击“继续”与手势翻页后,当前索引保持一致。
  • • 点击“跳过”和最后一页“开始使用”,都只记录一次完成状态。
  • • 打开更大字号、深色模式和 VoiceOver,内容仍可读、按钮仍可操作。
  • • App 升级但 onboarding 版本未变时,不应重新展示。
  • • onboarding 版本递增时,已完成旧版本的用户应看到新流程。

SwiftUI 中不需要保留 ViewController 命名

如果项目主体已经使用 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,再由根路由写入状态,路径更容易验证。

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

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

总结

OnboardingViewController 本质上只是一个业务命名的 UIViewController。一套可维护的实现,需要把问题拆成四层:

  • • 页面模型描述要展示什么。
  • • 单页控制器负责怎样展示。
  • • 流程控制器负责分页、跳过和完成事件。
  • • 根路由与状态存储决定什么时候展示、完成后去哪里。

技术上,UIKit 可以选择 UIPageViewController 或 UICollectionView,SwiftUI 可以使用 TabView 的 .page 样式。产品上,更重要的是让流程快速、可跳过,只解释真正必要的内容,并把系统权限留到用户能理解其用途的具体动作中。

    如果你正在做 iOS 新手引导,欢迎在评论区分享你选择 UIPageViewControllerUICollectionView 还是 SwiftUI TabView,以及你如何处理权限请求时机。具体经验会帮助其他国内开发者少走一些弯路。如果本文对你有用,也欢迎点一下「在看」或转发给需要的朋友。

    写 AI,写成长,偶尔写投资。
    关注沐风,不定期更新,全是干货。

    2026.08.06 18:33
    沪 · 赵巷

    📌 声明:本文由 AI 辅助完成

    最新文章

    随机文章

    基本 文件 流程 错误 SQL 调试
    1. 请求信息 : 2026-08-19 05:37:13 HTTP/2.0 GET : https://c.mffb.com.cn/a/502986.html
    2. 运行时间 : 0.229606s [ 吞吐率:4.36req/s ] 内存消耗:4,366.01kb 文件加载:140
    3. 缓存信息 : 0 reads,0 writes
    4. 会话信息 : SESSION_ID=34afa435c235b02b5c0eb28d693620fa
    1. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/public/index.php ( 0.79 KB )
    2. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/autoload.php ( 0.17 KB )
    3. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/autoload_real.php ( 2.49 KB )
    4. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/platform_check.php ( 0.90 KB )
    5. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/ClassLoader.php ( 14.03 KB )
    6. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/composer/autoload_static.php ( 4.90 KB )
    7. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/helper.php ( 8.34 KB )
    8. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-validate/src/helper.php ( 2.19 KB )
    9. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/helper.php ( 1.47 KB )
    10. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/stubs/load_stubs.php ( 0.16 KB )
    11. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Exception.php ( 1.69 KB )
    12. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-container/src/Facade.php ( 2.71 KB )
    13. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/deprecation-contracts/function.php ( 0.99 KB )
    14. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/polyfill-mbstring/bootstrap.php ( 8.26 KB )
    15. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/polyfill-mbstring/bootstrap80.php ( 9.78 KB )
    16. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/var-dumper/Resources/functions/dump.php ( 1.49 KB )
    17. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-dumper/src/helper.php ( 0.18 KB )
    18. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/symfony/var-dumper/VarDumper.php ( 4.30 KB )
    19. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/App.php ( 15.30 KB )
    20. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-container/src/Container.php ( 15.76 KB )
    21. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/container/src/ContainerInterface.php ( 1.02 KB )
    22. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/provider.php ( 0.19 KB )
    23. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Http.php ( 6.04 KB )
    24. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/helper/Str.php ( 7.29 KB )
    25. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Env.php ( 4.68 KB )
    26. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/common.php ( 0.03 KB )
    27. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/helper.php ( 18.78 KB )
    28. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Config.php ( 5.54 KB )
    29. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/app.php ( 0.95 KB )
    30. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/cache.php ( 0.78 KB )
    31. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/console.php ( 0.23 KB )
    32. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/cookie.php ( 0.56 KB )
    33. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/database.php ( 2.48 KB )
    34. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/facade/Env.php ( 1.67 KB )
    35. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/filesystem.php ( 0.61 KB )
    36. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/lang.php ( 0.91 KB )
    37. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/log.php ( 1.35 KB )
    38. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/middleware.php ( 0.19 KB )
    39. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/route.php ( 1.89 KB )
    40. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/session.php ( 0.57 KB )
    41. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/trace.php ( 0.34 KB )
    42. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/config/view.php ( 0.82 KB )
    43. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/event.php ( 0.25 KB )
    44. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Event.php ( 7.67 KB )
    45. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/service.php ( 0.13 KB )
    46. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/AppService.php ( 0.26 KB )
    47. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Service.php ( 1.64 KB )
    48. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Lang.php ( 7.35 KB )
    49. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/lang/zh-cn.php ( 13.70 KB )
    50. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/initializer/Error.php ( 3.31 KB )
    51. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/initializer/RegisterService.php ( 1.33 KB )
    52. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/services.php ( 0.14 KB )
    53. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/service/PaginatorService.php ( 1.52 KB )
    54. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/service/ValidateService.php ( 0.99 KB )
    55. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/service/ModelService.php ( 2.04 KB )
    56. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-trace/src/Service.php ( 0.77 KB )
    57. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Middleware.php ( 6.72 KB )
    58. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/initializer/BootService.php ( 0.77 KB )
    59. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/Paginator.php ( 11.86 KB )
    60. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-validate/src/Validate.php ( 63.20 KB )
    61. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/Model.php ( 23.55 KB )
    62. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/Attribute.php ( 21.05 KB )
    63. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/AutoWriteData.php ( 4.21 KB )
    64. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/Conversion.php ( 6.44 KB )
    65. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/DbConnect.php ( 5.16 KB )
    66. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/ModelEvent.php ( 2.33 KB )
    67. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/concern/RelationShip.php ( 28.29 KB )
    68. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/contract/Arrayable.php ( 0.09 KB )
    69. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/contract/Jsonable.php ( 0.13 KB )
    70. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/model/contract/Modelable.php ( 0.09 KB )
    71. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Db.php ( 2.88 KB )
    72. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/DbManager.php ( 8.52 KB )
    73. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Log.php ( 6.28 KB )
    74. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Manager.php ( 3.92 KB )
    75. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/log/src/LoggerTrait.php ( 2.69 KB )
    76. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/log/src/LoggerInterface.php ( 2.71 KB )
    77. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Cache.php ( 4.92 KB )
    78. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/psr/simple-cache/src/CacheInterface.php ( 4.71 KB )
    79. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/helper/Arr.php ( 16.63 KB )
    80. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/cache/driver/File.php ( 7.84 KB )
    81. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/cache/Driver.php ( 9.03 KB )
    82. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/CacheHandlerInterface.php ( 1.99 KB )
    83. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/Request.php ( 0.09 KB )
    84. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Request.php ( 55.78 KB )
    85. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/middleware.php ( 0.25 KB )
    86. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Pipeline.php ( 2.61 KB )
    87. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-trace/src/TraceDebug.php ( 3.40 KB )
    88. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/middleware/SessionInit.php ( 1.94 KB )
    89. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Session.php ( 1.80 KB )
    90. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/session/driver/File.php ( 6.27 KB )
    91. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/SessionHandlerInterface.php ( 0.87 KB )
    92. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/session/Store.php ( 7.12 KB )
    93. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Route.php ( 23.73 KB )
    94. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/RuleName.php ( 5.75 KB )
    95. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/Domain.php ( 2.53 KB )
    96. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/RuleGroup.php ( 22.43 KB )
    97. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/Rule.php ( 26.95 KB )
    98. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/RuleItem.php ( 9.78 KB )
    99. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/route/app.php ( 1.72 KB )
    100. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/facade/Route.php ( 4.70 KB )
    101. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/dispatch/Controller.php ( 4.74 KB )
    102. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/route/Dispatch.php ( 10.44 KB )
    103. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/controller/Index.php ( 4.81 KB )
    104. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/app/BaseController.php ( 2.05 KB )
    105. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/facade/Db.php ( 0.93 KB )
    106. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/connector/Mysql.php ( 5.44 KB )
    107. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/PDOConnection.php ( 52.47 KB )
    108. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/Connection.php ( 8.39 KB )
    109. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/ConnectionInterface.php ( 4.57 KB )
    110. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/builder/Mysql.php ( 16.58 KB )
    111. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/Builder.php ( 24.06 KB )
    112. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/BaseBuilder.php ( 27.50 KB )
    113. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/Query.php ( 15.71 KB )
    114. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/BaseQuery.php ( 45.13 KB )
    115. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/TimeFieldQuery.php ( 7.43 KB )
    116. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/AggregateQuery.php ( 3.26 KB )
    117. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php ( 20.07 KB )
    118. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/ParamsBind.php ( 3.66 KB )
    119. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/ResultOperation.php ( 7.01 KB )
    120. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/WhereQuery.php ( 19.37 KB )
    121. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/JoinAndViewQuery.php ( 7.11 KB )
    122. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/TableFieldInfo.php ( 2.63 KB )
    123. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-orm/src/db/concern/Transaction.php ( 2.77 KB )
    124. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/log/driver/File.php ( 5.96 KB )
    125. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/LogHandlerInterface.php ( 0.86 KB )
    126. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/log/Channel.php ( 3.89 KB )
    127. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/event/LogRecord.php ( 1.02 KB )
    128. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-helper/src/Collection.php ( 16.47 KB )
    129. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/facade/View.php ( 1.70 KB )
    130. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/View.php ( 4.39 KB )
    131. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Response.php ( 8.81 KB )
    132. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/response/View.php ( 3.29 KB )
    133. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/Cookie.php ( 6.06 KB )
    134. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-view/src/Think.php ( 8.38 KB )
    135. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/framework/src/think/contract/TemplateHandlerInterface.php ( 1.60 KB )
    136. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-template/src/Template.php ( 46.61 KB )
    137. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-template/src/template/driver/File.php ( 2.41 KB )
    138. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-template/src/template/contract/DriverInterface.php ( 0.86 KB )
    139. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/runtime/temp/cefbf809ba1a84190cb04b0cb7abcf79.php ( 11.98 KB )
    140. /yingpanguazai/ssd/ssd1/www/c.mffb.com.cn/vendor/topthink/think-trace/src/Html.php ( 4.42 KB )
    1. CONNECT:[ UseTime:0.001114s ] mysql:host=127.0.0.1;port=3306;dbname=c_mffb;charset=utf8mb4
    2. SHOW FULL COLUMNS FROM `fenlei` [ RunTime:0.001486s ]
    3. SELECT * FROM `fenlei` WHERE `fid` = 0 [ RunTime:0.000729s ]
    4. SELECT * FROM `fenlei` WHERE `fid` = 63 [ RunTime:0.000708s ]
    5. SHOW FULL COLUMNS FROM `set` [ RunTime:0.001356s ]
    6. SELECT * FROM `set` [ RunTime:0.000634s ]
    7. SHOW FULL COLUMNS FROM `article` [ RunTime:0.001408s ]
    8. SELECT * FROM `article` WHERE `id` = 502986 LIMIT 1 [ RunTime:0.001260s ]
    9. UPDATE `article` SET `lasttime` = 1787089033 WHERE `id` = 502986 [ RunTime:0.030247s ]
    10. SELECT * FROM `fenlei` WHERE `id` = 65 LIMIT 1 [ RunTime:0.000748s ]
    11. SELECT * FROM `article` WHERE `id` < 502986 ORDER BY `id` DESC LIMIT 1 [ RunTime:0.002035s ]
    12. SELECT * FROM `article` WHERE `id` > 502986 ORDER BY `id` ASC LIMIT 1 [ RunTime:0.005663s ]
    13. SELECT * FROM `article` WHERE `id` < 502986 ORDER BY `id` DESC LIMIT 10 [ RunTime:0.015876s ]
    14. SELECT * FROM `article` WHERE `id` < 502986 ORDER BY `id` DESC LIMIT 10,10 [ RunTime:0.002457s ]
    15. SELECT * FROM `article` WHERE `id` < 502986 ORDER BY `id` DESC LIMIT 20,10 [ RunTime:0.001619s ]
    0.233299s