文章

ViewController 与导航:iOS 页面管理机制深度解析

深入理解 iOS UIViewController 生命周期、导航栈、模态展示与容器控制器,从跨平台开发视角对比 Flutter Navigator 2.0 和 RN Navigation 的设计差异。

ViewController 与导航:iOS 页面管理机制深度解析

一句话概括

iOS 的 ViewController 是页面管理的核心单元,其生命周期方法(从 viewDidLoad 到 viewDidDisappear)定义了页面的整个存在周期,而 UINavigationController、模态展示、Container ViewController 等导航机制构成了一套完整的页面路由体系——理解这些是跨平台开发者驾驭 iOS 原生扩展的必经之路。

背景与意义

对于从 Flutter、React Native 或鸿蒙应用开发转向 iOS 原生扩展的开发者来说,ViewController 体系往往是第一个”文化冲击”。在 Flutter 中,一切都是 Widget,页面切换通过 Navigator.push 和 Navigator.pop 完成,路由配置集中管理;在 React Native 中,开发者依赖 React Navigation 这类第三方库,通过 Stack.Navigator 声明式定义导航结构。而在 iOS 原生开发中,页面管理是一个更加”实体化”的概念——每个页面都是一个 UIViewController 实例,拥有自己的生命周期回调,导航行为直接由 UINavigationController 操作导航栈来完成。

这种差异不仅仅是 API 层面的,它反映了两种完全不同的架构哲学。Flutter 和 RN 的导航倾向于”声明式”——你描述页面的关系,框架替你管理切换。而 iOS 的导航是”命令式”——你直接操作导航栈,push 一个页面、pop 回来,每一步都是显式的指令。对于习惯声明式思维的跨平台开发者而言,这种命令式的控制流需要一定的思维转换。

更重要的是,UIViewController 的生命周期机制渗透在 iOS 开发的方方面面。从页面初始化、视图加载、布局约束生效,到页面即将出现、已经出现、即将消失、已经消失——每个时机都有其特定的用途。不理解这些生命周期方法,你就无法正确地在合适的时间点执行数据加载、动画启动、通知注册与注销等操作。跨平台框架往往封装了这些细节(比如 Flutter 的 initState、didChangeDependencies、dispose),但当你需要编写原生扩展、实现自定义插件或调试原生崩溃时,这些底层知识就变得不可或缺。

本文将系统性地拆解 iOS ViewController 生命周期的每个环节,深入分析 UINavigationController 的导航栈管理机制,探讨模态展示、Storyboard Segue、Container ViewController 等关键概念,并从 Flutter Navigator 2.0 和 RN Navigation 的视角进行对照分析,帮助你建立 iOS 页面管理的完整知识框架。

核心知识点拆解

一、UIViewController 生命周期详解

UIViewController 的生命周期可以分为三个阶段:创建与加载可见性变化销毁与释放。理解每个阶段的方法调用顺序和触发时机,是正确管理页面行为的基础。

1. init 与 loadView

当一个 ViewController 被创建时(无论是通过代码 init 还是从 Storyboard 加载),首先调用的是 init 方法。在这个阶段,ViewController 刚刚被实例化,但视图层次(view hierarchy)尚未创建。随后系统会调用 loadView() 方法——这是创建视图层次的地方。如果你使用 Storyboard,系统会从 storyboard 文件中加载视图;如果你使用纯代码实现界面,你需要在这个方法中创建并赋值 self.view。

需要注意的是,除非你明确需要自定义视图创建过程(例如完全用代码构建界面),否则不应该直接调用或重写 loadView。大多数情况下由系统自动管理即可。

2. viewDidLoad

viewDidLoad 是 iOS 开发者最熟悉的生命周期方法。它在视图层次加载完成后被调用——此时 self.view 已经存在,所有 IBOutlet 连接已经建立,但视图尚未出现在屏幕上。这是执行以下操作的理想时机:

  • 初始化数据:加载数据模型、准备展示的列表数据
  • 设置 UI 控件初始状态:配置 UILabel 的文本、UIImageView 的图片
  • 注册通知观察者:使用 NotificationCenter 注册感兴趣的通知
  • 配置手势识别器:添加 UITapGestureRecognizer 等
  • 发起网络请求:页面首次加载时需要获取的数据

需要注意:viewDidLoad 在 ViewController 的整个生命周期中只调用一次(除非视图因内存警告被卸载后又重新加载,但这在现代 iOS 中极为罕见)。因此,不要在这里放置需要在每次页面出现时都执行的逻辑——那是 viewWillAppear 的职责。

3. viewWillAppear

viewWillAppear 在视图即将被添加到视图层次并出现在屏幕上时调用。此时视图已经存在于内存中,但还没有开始显示动画。这个方法的调用次数远多于 viewDidLoad——每当页面将要出现时都会触发,包括:

  • 从其他页面导航返回到当前页面
  • 模态展示被解除后回到当前页面
  • 当前页面被重新激活(例如从后台返回前台,但这种情况属于 application 生命周期)

在 viewWillAppear 中执行的操作包括:

  • 刷新页面数据:确保每次显示时数据都是最新的
  • 更新导航栏状态:隐藏/显示导航栏、修改导航栏标题
  • 更新 Tab Bar / Toolbar 状态
  • 开始动画或更新 UI 状态
  • 注册键盘通知等运行时监听

需要特别注意:viewWillAppear 在页面切换动画开始前调用,如果你的操作涉及 UI 布局,此时 Auto Layout 可能尚未完成新一轮的计算。

4. viewDidAppear

viewDidAppear 在视图已经出现在屏幕上、转场动画完成后调用。这是执行以下操作的理想时机:

  • 启动持续性动画:例如轮播图自动滚动、游戏循环
  • 开始视频播放
  • 注册需要页面可见才生效的监听:如加速度计、陀螺仪等传感器监听
  • 统计页面浏览事件(埋点)

因为 viewDidAppear 意味着用户已经能看到页面内容,任何 UI 层面的”第一次展示”效果都应该在这里触发。

5. viewWillDisappear

viewWillDisappear 在视图即将从屏幕上消失时调用。无论是因为 push 到新页面、模态展示其他页面,还是当前页面被 pop/dismiss,都会触发此方法。在这个方法中你应该:

  • 撤销在 viewWillAppear/viewDidAppear 中注册的监听
  • 保存用户输入的草稿数据
  • 停止正在运行的动画
  • 收起键盘

这是进行”清理前准备”的地方,但不是执行最终清理的时机——因为此时页面可能只是暂时被覆盖(如被模态展示的页面遮盖),而不一定是被销毁。

6. viewDidDisappear

viewDidDisappear 在视图已经从屏幕上消失后调用。此时视图已经从视图层次中移除(或隐藏)。这是执行以下操作的时机:

  • 停止资源密集型操作:如停止正在播放的视频、暂停游戏
  • 最终确定某些状态变更

与 viewWillDisappear 不同,viewDidDisappear 意味着页面确实已经不再可见。但同样需要注意的是,viewDidDisappear 不一定意味着 ViewController 即将被销毁——它只是不再显示。

完整的生命周期调用顺序如下:

1
2
3
4
5
首次加载:init → loadView → viewDidLoad → viewWillAppear → viewDidAppear
再次出现:                        → viewWillAppear → viewDidAppear
页面离开:                                          → viewWillDisappear → viewDidDisappear
页面返回:                        → viewWillAppear → viewDidAppear
销毁:                                              → viewWillDisappear → viewDidDisappear → deinit

7. deinit

deinit 是 Swift 中的析构方法,在 ViewController 被释放时调用。ARC(自动引用计数)会在没有任何对象强引用 ViewController 时自动释放它。在 deinit 中,你应该确保:

  • 移除所有通知观察者(在 iOS 9+ 中,使用 block-based API 注册的通知不再需要手动移除,但最佳实践仍然建议显式移除)
  • 释放大型资源(如位图数据、文件句柄)
  • 停止正在进行的网络请求(取消 URLSessionTask)

二、UINavigationController 与导航栈

UINavigationController 是 iOS 中最常用的导航容器,它管理着一个 ViewController 的栈(stack),并提供标准的导航栏交互体验。

1. 导航栈的基本操作

导航栈的核心操作只有两个:push 和 pop。

Push(压栈):将新的 ViewController 推入导航栈顶。用户看到的效果是新的页面从右侧滑入,导航栏左侧自动出现返回按钮。

1
2
let detailVC = DetailViewController()
navigationController?.pushViewController(detailVC, animated: true)

Pop(出栈):从导航栈移除当前视图控制器,返回到上一个页面。用户看到的效果是当前页面从右侧滑出。

1
2
3
4
5
6
7
8
// 返回到上一个页面
navigationController?.popViewController(animated: true)

// 返回到根页面
navigationController?.popToRootViewController(animated: true)

// 返回到指定页面
navigationController?.popToViewController(targetVC, animated: true)

2. 导航栈的结构

导航栈是一个简单的数组结构,类型为 [UIViewController]。栈底是 Root ViewController(根视图控制器),栈顶是当前显示的 ViewController。你可以随时检查导航栈的内容:

1
2
3
4
let viewControllers = navigationController?.viewControllers
let rootVC = navigationController?.viewControllers.first
let topVC = navigationController?.topViewController
let visibleVC = navigationController?.visibleViewController

需要注意:topViewController 是导航栈顶的 ViewController,而 visibleViewController 是当前可见的 ViewController。在大多数情况下它们相等,但当模态展示另一个 ViewController 时,visibleViewController 会是模态展示的那个 VController。

3. 导航栏的自定义

UINavigationController 自带一个 UINavigationBar(导航栏),位于屏幕顶部,包含标题、返回按钮和右侧操作按钮。你可以通过 ViewController 的 navigationItem 属性来配置:

1
2
3
4
5
6
7
8
9
10
11
// 设置标题
title = "详情页"
navigationItem.title = "自定义标题"
navigationItem.titleView = customTitleView  // 使用自定义视图作为标题

// 设置左右按钮
navigationItem.leftBarButtonItem = UIBarButtonItem(...)
navigationItem.rightBarButtonItems = [button1, button2]

// 隐藏返回按钮标题
navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)

4. 与 Flutter Navigator 的对比

Flutter 的 Navigator 1.0 使用类似栈的 push/pop 模型:

1
2
3
4
5
// Flutter push
Navigator.push(context, MaterialPageRoute(builder: (context) => DetailPage()));

// Flutter pop
Navigator.pop(context);

这与 iOS 的 UINavigationController 在概念上非常相似。然而 Flutter Navigator 2.0 引入了声明式的路由管理,通过 RouteInformationParser 和 RouterDelegate 将路由状态与 UI 解耦,更接近 Web 的路由模型。iOS 直到 iOS 16+ 才引入 NavigationStack(SwiftUI)来提供声明式的导航体验,而在 UIKit 层面,命令式的导航栈管理仍然是最主要的方式。

从跨平台开发者的视角来看,关键差异在于:

  • iOS 是命令式的:你直接操作栈对象,每一步都是显式的
  • Flutter 1.0 是命令式的:但通过 BuildContext 隐式获取 Navigator 对象
  • Flutter 2.0 是声明式的:你描述路由配置,框架决定导航行为
  • RN React Navigation 是声明式的:通过 Stack.NavigatorStack.Screen 声明页面关系,使用 navigation.navigate() 触发跳转

三、模态展示(Present / Dismiss)

模态展示是一种独立于导航栈的页面呈现方式,通常用于需要用户完成某个任务后才能继续的场景(如登录、设置、分享)。

1. Present 与 Dismiss

1
2
3
4
5
6
7
// 模态展示
let modalVC = ModalViewController()
modalVC.modalPresentationStyle = .fullScreen  // 全屏展示
present(modalVC, animated: true, completion: nil)

// 解除模态
dismiss(animated: true, completion: nil)

2. 模态展示样式

iOS 13+ 对模态展示做了重要改变。默认样式从 .fullScreen 变更为 .pageSheet(也称为卡片式展示),用户可以通过下滑手势 dismiss 页面。这带来了一些新的考虑:

  • 默认不可下滑关闭:设置 isModalInPresentation = true 可以禁用滑动手势
  • 页面不会自动 deinit:卡片样式下,被覆盖的页面不会调用 viewDidDisappear
  • 需要处理手势冲突:如果页面中有横向滑动交互(如 UISlider),可能与 dismiss 手势冲突

常见的 modalPresentationStyle 选项:

  • .fullScreen:全屏覆盖,覆盖页面的生命周期方法(viewWillDisappear/viewDidDisappear)会被调用
  • .pageSheet(默认):卡片样式,覆盖页面在下方可见
  • .formSheet:较小尺寸的卡片,在 iPad 上常用
  • .overFullScreen / .overCurrentContext:透明背景的模态,下层页面仍然可见但不可交互

3. 模态与导航栈的关系

模态展示独立于导航栈,但它可以包含导航栈。常见的模式是:

1
2
let navController = UINavigationController(rootViewController: modalVC)
present(navController, animated: true, completion: nil)

这样模态展示的内容本身拥有独立的导航栈。在 Flutter 中,这对应着 showDialogshowModalBottomSheet,但 iOS 的模态展示更为通用——它可以承载完整的导航体验。

四、Segue——Storyboard 的导航方式

Segue 是 Storyboard 中的连线方式,用于在界面之间建立导航关系。在代码层面,Segue 本质上是对导航操作的封装。

1. Segue 的类型

  • Show(Push):在导航栈中 push 目标页面
  • Show Detail(Replace):在 UISplitViewController 中替换详细面板
  • Present Modally:模态展示目标页面
  • Present As Popover:在 iPad 上以弹出框形式展示
  • Custom:自定义转场动画

2. Perform Segue

在代码中触发 Segue:

1
performSegue(withIdentifier: "showDetail", sender: self)

3. Prepare for Segue

Segue 执行前的数据传递通过 prepare(for:sender:) 方法完成:

1
2
3
4
5
6
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "showDetail" {
        let detailVC = segue.destination as! DetailViewController
        detailVC.itemID = selectedItemID
    }
}

从跨平台视角看,Segue 类似于 Flutter 的命名路由(Named Routes)或 RN Navigation 的 navigation.navigate('Detail', params),但其配置是通过可视化界面完成的,更偏向设计时(design-time)而非运行时(runtime)。

五、Container ViewController

Container ViewController(容器视图控制器)是 iOS 中实现”页面中的页面”的核心机制。UINavigationController、UITabBarController、UISplitViewController、UIPageViewController 等都是系统提供的 Container ViewController。

1. 自定义 Container ViewController

你可以创建自己的 Container ViewController,管理多个子 ViewController 的生命周期:

1
2
3
4
5
6
7
8
9
10
// 添加子 ViewController
addChild(childVC)
childVC.view.frame = containerView.bounds
containerView.addSubview(childVC.view)
childVC.didMove(toParent: self)

// 移除子 ViewController
childVC.willMove(toParent: nil)
childVC.view.removeFromSuperview()
childVC.removeFromParent()

在添加子 ViewController 时,生命周期方法的调用顺序为:

  1. 父 VC 调用 addChild(childVC) —— 子 VC 的 willMove(toParent:) 自动调用
  2. 添加子 VC 的 view 到视图层次
  3. 父 VC 调用 childVC.didMove(toParent:)
  4. 此时子 VC 的 viewWillAppear/viewDidAppear 会按需调用

移除子 ViewController 的调用顺序为:

  1. 父 VC 调用 childVC.willMove(toParent: nil)
  2. 从父视图中移除子 VC 的 view
  3. 父 VC 调用 childVC.removeFromParent()
  4. 此时子 VC 的 didMove(toParent:) 自动调用(参数为 nil)

2. UITabBarController

UITabBarController 是 iOS 中最常见的容器——它管理一组 ViewController,每个对应一个 Tab。每个 Tab 可以有自己的导航栈:

1
2
3
4
let tabBarController = UITabBarController()
let firstVC = UINavigationController(rootViewController: HomeViewController())
let secondVC = UINavigationController(rootViewController: SettingsViewController())
tabBarController.viewControllers = [firstVC, secondVC]

从跨平台视角看,UITabBarController 对应 Flutter 的 BottomNavigationBar + IndexedStack,或 RN Navigation 的 Bottom Tab Navigator。关键区别在于 iOS 的 UITabBarController 默认会保持每个 Tab 的状态——切换 Tab 时,之前的 Tab 页面状态会被保留,但视图可能会被卸载(在内存不足时)。

3. UIPageViewController

UIPageViewController 实现了翻页导航(类似电子书或引导页):

1
2
3
4
5
6
let pageVC = UIPageViewController(
    transitionStyle: .scroll,
    navigationOrientation: .horizontal
)
pageVC.dataSource = self
pageVC.setViewControllers([firstVC], direction: .forward, animated: true)

UIPageViewController 支持两种导航风格:卷曲(.pageCurl)和滑动(.scroll)。它有两种 Spine Location(书籍装订线位置),可以模拟单页和双页图书效果。

实战案例

案例一:构建一个带 TabBar 和导航栈的新闻应用

场景:构建一个包含”首页”、”分类”、”我的”三个 Tab 的新闻阅读应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import UIKit

// AppDelegate 中配置 TabBarController
func setupTabBarController() -> UITabBarController {
    let tabBarController = UITabBarController()
    
    // 每个 Tab 用导航栈包裹
    let homeNav = UINavigationController(rootViewController: HomeViewController())
    homeNav.tabBarItem = UITabBarItem(title: "首页", image: UIImage(systemName: "house"), tag: 0)
    
    let categoryNav = UINavigationController(rootViewController: CategoryViewController())
    categoryNav.tabBarItem = UITabBarItem(title: "分类", image: UIImage(systemName: "square.grid.2x2"), tag: 1)
    
    let profileNav = UINavigationController(rootViewController: ProfileViewController())
    profileNav.tabBarItem = UITabBarItem(title: "我的", image: UIImage(systemName: "person"), tag: 2)
    
    tabBarController.viewControllers = [homeNav, categoryNav, profileNav]
    return tabBarController
}

// 首页 ViewController
class HomeViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
    
    private var tableView: UITableView!
    private var newsItems: [NewsItem] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "新闻首页"
        setupTableView()
        loadNewsData()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 每次出现时刷新导航栏状态
        navigationController?.setNavigationBarHidden(false, animated: true)
    }
    
    private func setupTableView() {
        tableView = UITableView(frame: view.bounds, style: .plain)
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        view.addSubview(tableView)
        
        // Auto Layout 约束
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
    
    private func loadNewsData() {
        // 模拟网络请求
        newsItems = (1...20).map { NewsItem(id: $0, title: "新闻标题 #\($0)") }
        tableView.reloadData()
    }
    
    // MARK: - UITableViewDelegate
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        
        // Push 到详情页
        let detailVC = NewsDetailViewController()
        detailVC.newsItem = newsItems[indexPath.row]
        navigationController?.pushViewController(detailVC, animated: true)
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return newsItems.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = newsItems[indexPath.row].title
        cell.accessoryType = .disclosureIndicator
        return cell
    }
}

案例二:模态展示登录页面

场景:用户点击”收藏”按钮时,如果未登录则弹出登录页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
extension HomeViewController {
    @objc func favoriteButtonTapped() {
        guard isLoggedIn else {
            presentLoginViewController()
            return
        }
        // 已登录,执行收藏操作
        performFavoriteAction()
    }
    
    private func presentLoginViewController() {
        let loginVC = LoginViewController()
        loginVC.delegate = self
        
        // 用导航栈包裹模态页面,支持多步导航
        let navController = UINavigationController(rootViewController: loginVC)
        
        // iOS 13+ 使用全屏样式,避免下滑 dismiss
        navController.modalPresentationStyle = .fullScreen
        
        present(navController, animated: true) {
            // 模态展示完成后的回调
            print("Login screen presented")
        }
    }
}

protocol LoginViewControllerDelegate: AnyObject {
    func loginDidSucceed()
}

class LoginViewController: UIViewController {
    weak var delegate: LoginViewControllerDelegate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "登录"
        setupUI()
        
        // 添加取消按钮
        navigationItem.leftBarButtonItem = UIBarButtonItem(
            title: "取消",
            style: .plain,
            target: self,
            action: #selector(cancelTapped)
        )
    }
    
    @objc private func cancelTapped() {
        dismiss(animated: true, completion: nil)
    }
    
    @objc private func loginTapped() {
        // 登录逻辑...
        delegate?.loginDidSucceed()
        dismiss(animated: true, completion: nil)
    }
}

案例三:实现自定义 Container ViewController——页面轮播器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
class CarouselContainerViewController: UIViewController {
    
    private var pageViewController: UIPageViewController!
    private var pages: [UIViewController] = []
    private var pageControl: UIPageControl!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupPages()
        setupPageViewController()
        setupPageControl()
    }
    
    private func setupPages() {
        let colors: [UIColor] = [.systemRed, .systemBlue, .systemGreen, .systemOrange]
        pages = colors.enumerated().map { index, color in
            let vc = UIViewController()
            vc.view.backgroundColor = color
            let label = UILabel()
            label.text = "页面 \(index + 1)"
            label.textColor = .white
            label.font = .systemFont(ofSize: 24, weight: .bold)
            label.translatesAutoresizingMaskIntoConstraints = false
            vc.view.addSubview(label)
            NSLayoutConstraint.activate([
                label.centerXAnchor.constraint(equalTo: vc.view.centerXAnchor),
                label.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor)
            ])
            return vc
        }
    }
    
    private func setupPageViewController() {
        pageViewController = UIPageViewController(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal
        )
        pageViewController.dataSource = self
        pageViewController.delegate = self
        
        // 将 PageViewController 添加为子容器
        addChild(pageViewController)
        view.addSubview(pageViewController.view)
        pageViewController.didMove(toParent: self)
        
        pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            pageViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
            pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            pageViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50)
        ])
        
        pageViewController.setViewControllers([pages[0]], direction: .forward, animated: false)
    }
    
    private func setupPageControl() {
        pageControl = UIPageControl()
        pageControl.numberOfPages = pages.count
        pageControl.currentPage = 0
        pageControl.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(pageControl)
        
        NSLayoutConstraint.activate([
            pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            pageControl.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10)
        ])
    }
}

extension CarouselContainerViewController: UIPageViewControllerDataSource {
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        guard let index = pages.firstIndex(of: viewController), index > 0 else { return nil }
        return pages[index - 1]
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let index = pages.firstIndex(of: viewController), index < pages.count - 1 else { return nil }
        return pages[index + 1]
    }
}

extension CarouselContainerViewController: UIPageViewControllerDelegate {
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if completed, let visibleVC = pageViewController.viewControllers?.first, let index = pages.firstIndex(of: visibleVC) {
            pageControl.currentPage = index
        }
    }
}

常见问题

Q1:viewDidLoad 和 viewWillAppear 中做数据加载有什么区别?

viewDidLoad 在整个 ViewController 生命周期中只调用一次,适合初始化性加载(如配置 UI、建立数据模型)。viewWillAppear 在每次页面出现时都调用,适合刷新性加载(如从其他页面返回后更新显示内容)。如果你的页面显示时需要每次都获取最新数据(如新闻列表),应在 viewWillAppear 中刷新,结合一个”最后刷新时间”检查机制来避免不必要的重复加载。从跨平台视角看,这类似于 Flutter 的 initState 与 didChangeDependencies 的关系。

Q2:为什么我的页面 pop 回来之后状态没有重置?

这是因为 ViewController 实例在 pop 之后可能没有被释放(存在循环引用),或者你在 viewWillAppear 中没有重置 UI 状态。解决方法是:1) 确保使用 weak 避免循环引用;2) 在 viewWillAppear 中重置 UI 控件的状态(如清除输入框的文本);3) 如果需要全新的页面实例,可以在 push 时每次创建新的 ViewController,而不是复用旧的实例。

Q3:Navigation Bar 在页面 push 和 pop 时动画异常怎么解决?

常见原因包括:1) 各页面的导航栏隐藏状态不一致(一个页面隐藏、另一个显示);2) 导航栏的样式(translucent、backgroundColor)被不一致地修改。解决方法是:在每个页面的 viewWillAppear 中显式设置导航栏状态,而不是依赖上一个页面的设置。例如在 viewWillAppear 中调用 navigationController?.setNavigationBarHidden(false, animated: true)

Q4:Container ViewController 和普通的 UIView 嵌入有什么区别?

Container ViewController 会参与页面的生命周期管理——子 ViewController 的生命周期方法会被正常调用(viewDidLoad 到 deinit),子 VC 可以处理自己的旋转事件、内存警告等。而简单的 UIView 嵌入只是视图层面的嵌套,没有生命周期回调。对于承载独立业务逻辑的页面单元,应该使用 Container ViewController;对于单纯的 UI 组件,使用 UIView 嵌入即可。从跨平台视角看,Container ViewController 类似于 Flutter 的 NestedScrollView 或 CustomScrollView 中的 Sliver 行为,但更加”重”。

Q5:iOS 导航栈最多能 push 多少页面?

理论上没有硬性限制,但实践中不要 push 超过约 20-30 个页面。原因有两方面:一是内存开销——每个 ViewController 都会持有其视图层次,大量的页面会导致内存压力;二是用户体验——没有用户愿意在导航栈中点击返回按钮十几次才能回到根页面。如果确实需要深度导航(如目录层次非常深),考虑使用 UISplitViewController(类似 Flutter 的 Master-Detail 布局)或模态展示来”重置”导航栈。

总结

UIViewController 及其生命周期机制是 iOS 原生扩展开发的基础。从跨平台开发者的视角来看,iOS 的导航体系与 Flutter Navigator、RN Navigation 在核心理念上相通——都是管理页面的创建、显示、隐藏和销毁。关键差异在于 iOS 采用了更显式的命令式风格:你直接操作导航栈的数组,显式调用 push/pop,手动管理子容器的生命周期。

在工程实践中,理解生命周期方法对编写健壮的 iOS 原生代码至关重要——viewDidLoad 做初始化、viewWillAppear 做数据刷新、viewDidAppear 开始动画、viewWillDisappear 清理监听、viewDidDisappear 停止资源消耗。这种精细化的生命周期管理虽然增加了代码量,但也提供了比跨平台框架更精细的控制力。

最后,记住 iOS 导航体系的核心原则:UIViewController 是页面实体,UINavigationController 是栈管理器,UITabBarController/UIPageViewController 是容器,模态展示是独立导航的一种手段。将这些概念与 Flutter/RN 中的对应模式建立映射关系,你就能够轻松地在 iOS 原生扩展开发中搭建出结构清晰、体验流畅的页面导航体系。

本文由作者按照 CC BY 4.0 进行授权