UI相关知识
⏺ | 特性| Plain| Grouped|return section == 0?| 设置页面| .insetGrouped (iOS 13+)| 清晰分组,现代设计|| 表单页面| .grouped 或 .insetGrouped | 分组清晰|
> import UIKit
class SCDisplayLinkProxy: NSObject {
// MARK: Lifecycle
init(target: AnyObject, selector: Selector) {
self.target = target
self.selector = selector
}
// MARK: Internal
weak var target: AnyObject?
let selector: Selector
@objc
func onDisplayLinkTick(_ displayLink: CADisplayLink) {
_ = target?.perform(selector, with: displayLink)
}
}帮我分析为什么这么卸
⏺ CADisplayLink 代理模式分析
这是一个经典的循环引用解决方案,专门用于 CADisplayLink。
⏺ 1. 问题背景:CADisplayLink 的循环引用
错误写法(会导致内存泄漏):
class MyViewController: UIViewController {
var displayLink: CADisplayLink?
override func viewDidLoad() {
super.viewDidLoad()
// ❌ 错误:CADisplayLink 强引用 self
displayLink = CADisplayLink(
target: self, // CADisplayLink 强引用 self
selector: #selector(onTick)
)
displayLink?.add(to: .main, forMode: .common)
}
@objc func onTick() {
// 更新动画
}
deinit {
print("不会被调用!因为循环引用")
}
}
循环引用链:
ViewController (强引用) → displayLink
↑ ↓
└──────── (强引用) ← target (self)
后果:
- ViewController 永远不会释放
- deinit 永远不会被调用
- 内存泄漏
---
⏺ 2. 解决方案:SCDisplayLinkProxy 的设计原理
关键设计点:
(1) weak var target: AnyObject?
weak var target: AnyObject? // 👈 弱引用,不增加引用计数
- 弱引用目标对象,不会造成循环引用
- 目标对象释放时自动变为 nil
(2) 中间代理对象
init(target: AnyObject, selector: Selector) {
self.target = target // 弱引用目标
self.selector = selector // 保存要调用的方法
}
- Proxy 强引用 selector(值类型,无引用计数)
- Proxy 弱引用 target
(3) 转发调用
@objc
func onDisplayLinkTick(_ displayLink: CADisplayLink) {
_ = target?.perform(selector, with: displayLink)
// 如果 target 已释放,这里什么都不做
}
- CADisplayLink 调用 Proxy 的方法
- Proxy 再转发给真正的 target
- 如果 target 已释放,安全地跳过
---
⏺ 3. 正确使用方式
class MyViewController: UIViewController {
var displayLink: CADisplayLink?
var proxy: SCDisplayLinkProxy? // 保存 proxy 引用
override func viewDidLoad() {
super.viewDidLoad()
// ✅ 正确:使用 Proxy 打破循环引用
proxy = SCDisplayLinkProxy(
target: self,
selector: #selector(onTick)
)
displayLink = CADisplayLink(
target: proxy!, // CADisplayLink 强引用 proxy
selector: #selector(SCDisplayLinkProxy.onDisplayLinkTick(_:))
)
displayLink?.add(to: .main, forMode: .common)
}
@objc func onTick(_ displayLink: CADisplayLink) {
// 更新动画
print("FPS: \(1.0 / displayLink.duration)")
}
deinit {
displayLink?.invalidate() // 必须手动停止
print("ViewController 正常释放 ✅")
}
}
引用关系:
ViewController (强引用) → proxy (弱引用) → ViewController
↓
displayLink (强引用) → proxy
- displayLink 强引用 proxy
- proxy 弱引用 ViewController
- 没有循环引用 ✅
---
4. 完整工作流程
1. CADisplayLink 每帧触发
↓
2. 调用 proxy.onDisplayLinkTick(_:)
↓
3. proxy 检查 target 是否存在
↓
4. 如果存在,调用 target.perform(selector, with: displayLink)
↓
5. 目标对象的 onTick 方法执行
如果 ViewController 已释放:
3. proxy.target == nil
4. 安全跳过,不会崩溃
---
⏺ 5. 设计优缺点分析
优点:
1. ✅ 解决循环引用: 通过弱引用打破引用环
2. ✅ 安全性: target 释放后不会崩溃
3. ✅ 简洁: 代码量少,易于理解
4. ✅ 通用性: 可用于所有类似场景(Timer、NSRunLoop等)
潜在问题:
1. ⚠️ 需要手动 invalidate: 即使用了 Proxy,仍需在 deinit 中调用 displayLink?.invalidate()
2. ⚠️ 多一层间接调用: 轻微性能开销(实际可忽略不计)
---
6. 为什么不能直接用 [weak self]?
// ❌ CADisplayLink 不支持闭包,只能用 target-action
displayLink = CADisplayLink(
target: self, // 必须传入对象,不能用闭包
selector: #selector(onTick)
)
// ⚠️ Timer 有类似问题(iOS 10+ 可以用闭包)
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.onTick() // ✅ iOS 10+ 可以这样写
}
---
7. 其他解决方案对比
方案1: iOS 10+ 使用闭包API
if #available(iOS 10.0, *) {
displayLink = CADisplayLink(block: { [weak self] displayLink in
self?.onTick(displayLink)
})
}
方案2: 使用 NSProxy (更高级)
class WeakProxy: NSProxy {
weak var target: AnyObject?
override func forwardingTarget(for aSelector: Selector!) -> Any? {
return target
}
}
你们的方案是最经典的解决方式,在 iOS 10 之前是唯一正确方法。
---
总结
这个 SCDisplayLinkProxy 类是一个循环引用打破器:
1. 问题: CADisplayLink 强引用 target,导致内存泄漏
2. 方案: 插入中间代理,用 weak 引用目标对象
3. 原理:
- CADisplayLink → (强) → Proxy → (弱) → ViewController
- 打破了循环引用链
4. 适用场景: 所有需要 target-action 且会造成循环引用的地方
这是 iOS 开发中处理 CADisplayLink、Timer 等对象的标准最佳实践。
需要我展示如何在你们项目中使用这个类吗?
> layoutIfNeeded()能在ViewDidLoad中调用吗
⏺ layoutIfNeeded() 在 viewDidLoad 中调用的问题
简短回答
技术上可以调用,但通常无效或不推荐 ⚠️
⏺ ---
1. 视图生命周期和布局时机
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// ❌ 此时视图的 frame 可能还是错误的
print("viewDidLoad: \(view.frame)")
// 可能输出: (0, 0, 0, 0) 或者 (0, 0, 375, 667)
view.layoutIfNeeded() // ⚠️ 调用了,但可能无效
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// ⚠️ frame 可能还不准确(取决于约束)
print("viewWillAppear: \(view.frame)")
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// ✅ 此时 frame 已经确定
print("viewDidLayoutSubviews: \(view.frame)")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// ✅ 视图已经在屏幕上,frame 确定
print("viewDidAppear: \(view.frame)")
}
}
生命周期顺序:
1. init / loadView
2. viewDidLoad ← 视图加载到内存,但 frame 未确定
3. viewWillAppear
4. viewWillLayoutSubviews
5. viewDidLayoutSubviews ← Auto Layout 约束已应用
6. viewDidAppear ← 视图已在屏幕上
⏺ ---
2. 为什么在 viewDidLoad 中通常无效?
问题 1: 约束还没应用
override func viewDidLoad() {
super.viewDidLoad()
let label = UILabel()
view.addSubview(label)
label.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(CGSize(width: 100, height: 50))
}
// ❌ 此时约束虽然添加了,但系统还没计算布局
print(label.frame) // 输出: (0, 0, 0, 0)
view.layoutIfNeeded() // ⚠️ 可能触发布局,但 view.frame 可能不准
print(label.frame) // 可能还是 (0, 0, 0, 0) 或错误值
}
问题 2: 父视图的 frame 未确定
override func viewDidLoad() {
super.viewDidLoad()
// view.frame 可能是:
// - (0, 0, 0, 0) - 如果还没加入窗口
// - (0, 0, 375, 667) - Storyboard 的模拟尺寸
// 但不是最终屏幕尺寸
print(view.frame) // 不准确
}
---
3. 正确的调用时机
⏺ 场景 1: 获取准确 frame
// ✅ 推荐:在 viewDidLayoutSubviews
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 此时所有约束已应用,frame 准确
print(myLabel.frame) // 正确的值
// 可以安全地使用 frame 做计算
setupGradientLayer()
}
// ✅ 或者在 viewDidAppear
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// 视图已在屏幕上,frame 100% 准确
startAnimation()
}
场景 2: 强制立即布局(动画前)
// ✅ 约束动画的正确写法
func animateButton() {
// 1. 修改约束
buttonTopConstraint.constant = 100
// 2. 标记需要布局(不立即执行)
view.setNeedsLayout()
// 3. 在动画块中强制立即布局
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded() // ✅ 这里是正确的用法
}
}
场景 3: 动态添加子视图后需要立即获取 frame
func addCustomView() {
let customView = UIView()
view.addSubview(customView)
customView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(100)
}
// ✅ 如果需要立即获取 frame
view.layoutIfNeeded() // 强制立即计算布局
print(customView.frame) // 现在可以得到正确的值
// 使用 frame 做后续操作
addShadow(to: customView)
}
---
4. 实际问题案例
案例 1: CAGradientLayer 位置错误
// ❌ 错误写法
override func viewDidLoad() {
super.viewDidLoad()
let gradientLayer = CAGradientLayer()
gradientLayer.frame = myView.bounds // ⚠️ bounds 可能是 (0,0,0,0)
myView.layer.addSublayer(gradientLayer)
}
// ✅ 正确写法
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if gradientLayer == nil {
gradientLayer = CAGradientLayer()
myView.layer.addSublayer(gradientLayer!)
}
gradientLayer?.frame = myView.bounds // bounds 已确定
}
案例 2: 圆角和阴影
// ❌ 错误
override func viewDidLoad() {
super.viewDidLoad()
// frame 不准,圆角半径错误
myView.layer.cornerRadius = myView.frame.height / 2
}
// ✅ 正确
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 在布局完成后设置
myView.layer.cornerRadius = myView.frame.height / 2
}
---
⏺ 5. 总结和最佳实践
| 方法 | 适合调用 layoutIfNeeded() | 原因 |
|-----------------------|-----------------------|-----------------|
| viewDidLoad | ❌ 不推荐 | frame 未确定,约束未应用 |
| viewWillAppear | ⚠️ 谨慎使用 | frame 可能还不准确 |
| viewDidLayoutSubviews | ✅ 推荐 | 约束已应用,frame 准确 |
| viewDidAppear | ✅ 可以 | 视图已在屏幕上 |
| 动画块内 | ✅ 常用 | 强制立即应用约束变化 |
最佳实践:
class MyViewController: UIViewController {
// ✅ viewDidLoad: 只做视图初始化
override func viewDidLoad() {
super.viewDidLoad()
setupUI() // 添加子视图
setupConstraints() // 添加约束
// ❌ 不要调用 layoutIfNeeded()
}
// ✅ viewDidLayoutSubviews: 依赖 frame 的操作
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateGradientFrame() // 更新图层 frame
updateCornerRadius() // 更新圆角
}
// ✅ 动画中使用
func animate() {
constraint.constant = 100
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded() // 立即应用约束
}
}
}
简记:
- viewDidLoad: 不要调用 layoutIfNeeded()
- viewDidLayoutSubviews: 可以用 frame 了
- 动画块: 必须调用 layoutIfNeeded() 来驱动约束动画
需要我帮你分析具体的使用场景吗?
> tableView的plain样式和Group样式区别
⏺ UITableView: Plain vs Grouped 样式详解
1. 视觉外观对比
⏺ Plain 样式 (.plain)
┌─────────────────────────────┐
│ Section Header (浮动悬停) │ ← 滚动时会悬停在顶部
├─────────────────────────────┤
│ Row 1 │ ← 左右贴边,无圆角
│─────────────────────────────│
│ Row 2 │
│─────────────────────────────│
│ Row 3 │
├─────────────────────────────┤
│ Section Header 2 (浮动) │ ← 滚动时替换上一个 Header
├─────────────────────────────┤
│ Row 4 │
│─────────────────────────────│
│ Row 5 │
└─────────────────────────────┘
Grouped 样式 (.grouped)
┌─────────────────────────────┐
│ │ ← 上边距
│ Section Header (不悬停) │ ← 跟随内容滚动
│ ┌─────────────────────────┐ │
│ │ Row 1 │ │ ← 有左右边距
│ ├─────────────────────────┤ │
│ │ Row 2 │ │ ← 圆角分组
│ ├─────────────────────────┤ │
│ │ Row 3 │ │
│ └─────────────────────────┘ │
│ Section Footer │
│ │ ← Section 间距
│ Section Header 2 │
│ ┌─────────────────────────┐ │
│ │ Row 4 │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────┘
关键视觉差异:
| 特性 | Plain | Grouped |
|------------|--------|---------------|
| Cell 宽度 | 铺满屏幕宽度 | 左右有边距(约16pt) |
| Section 背景 | 无明显分组 | 有圆角白色背景卡片 |
| Section 间距 | 紧密相连 | Section 之间有间距 |
| 整体背景色 | 白色 | 浅灰色 (系统分组背景色) |
---
2. Section Header/Footer 行为差异
⏺ Plain 样式的 Section Header
// ✅ Plain 样式特性
- Header 会浮动悬停在顶部(Sticky Header)
- 向下滚动时,新的 Header 会"推开"上一个 Header
- 适合快速定位当前所在的 Section
演示:
初始状态:
┌─────────────────┐
│ Header A │ ← 固定在顶部
├─────────────────┤
│ Row A1 │
│ Row A2 │
│ Row A3 │
└─────────────────┘
向下滚动后:
┌─────────────────┐
│ Header B │ ← "推开"了 Header A
├─────────────────┤
│ Row B1 │
│ Row B2 │
│ Row B3 │
└─────────────────┘
Grouped 样式的 Section Header
// ✅ Grouped 样式特性
- Header 不悬停,跟随内容一起滚动
- 滚动离开屏幕后就看不见了
- 强调分组的独立性
代码验证:
// Plain 样式
let plainTableView = UITableView(frame: .zero, style: .plain)
// Section Header 会悬停
// Grouped 样式
let groupedTableView = UITableView(frame: .zero, style: .grouped)
// Section Header 不会悬停
---
3. 技术特性对比
⏺ | 特性 | Plain | Grouped |
|-------------------|------------------------|-------------------------------|
| Section Header 悬停 | ✅ 是(Sticky) | ❌ 否(跟随滚动) |
| 默认背景色 | .systemBackground (白色) | .systemGroupedBackground (浅灰) |
| Cell 分隔线 | 铺满宽度 | 有左侧缩进 |
| Section 间距 | 0 | 约 35pt(可自定义) |
| 默认 Header 高度 | 如果未实现代理方法,会有默认高度 | 如果返回 0,高度为 0 |
| 适合长列表 | ✅ 是 | ⚠️ 不太适合 |
| 内存占用 | 较低(无额外间距) | 稍高(Section 间距) |
默认高度差异
// Plain 样式
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
// 如果不实现,系统会返回默认值(约 28pt)
}
// Grouped 样式
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
// 如果不实现,系统会返回默认值(约 35pt)
// 如果返回 0 或 .leastNormalMagnitude,可以完全隐藏
}
// ⚠️ 注意:要完全隐藏 Header,需要这样做
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return .leastNormalMagnitude // 使用极小值而非 0
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
return nil
}
---
4. iOS 13+ 新增样式
// iOS 13+ 引入了更多样式
let tableView = UITableView(frame: .zero, style: .insetGrouped)
// .insetGrouped - 类似 Grouped,但边距更大,更圆润
// 用于 iPad 和现代设计
// iOS 13+ 样式对比
.plain // 传统 Plain
.grouped // 传统 Grouped
.insetGrouped // ✨ 新增,更大的圆角和边距(推荐)
InsetGrouped 特点:
┌─────────────────────────────┐
│ │
│ ┌───────────────────────┐ │ ← 边距更大
│ │ Row 1 │ │ ← 圆角更圆润
│ ├───────────────────────┤ │
│ │ Row 2 │ │
│ └───────────────────────┘ │
│ │
└─────────────────────────────┘
---
5. 使用场景建议
⏺ 使用 Plain 样式的场景
// ✅ 联系人列表(按字母分组)
class ContactsViewController: UITableViewController {
init() {
super.init(style: .plain) // Header 悬停显示当前字母
}
// Section Header 显示 A, B, C... 并悬停
}
// ✅ 聊天消息列表
// ✅ 邮件列表
// ✅ 通知列表
// ✅ 搜索结果
典型 App 示例:
- 通讯录 App - Plain 样式,字母索引悬停
- 微信消息列表 - Plain 样式
- 邮件 App - Plain 样式
使用 Grouped 样式的场景
// ✅ 设置页面(iOS 设置 App)
class SettingsViewController: UITableViewController {
init() {
super.init(style: .grouped) // 清晰的分组
}
// Section: 账号设置
// Section: 隐私设置
// Section: 关于
}
// ✅ 表单页面
// ✅ 详情页面(用户信息、商品详情)
// ✅ 配置页面
典型 App 示例:
- iOS 设置 App - Grouped/InsetGrouped
- 支付宝设置页 - Grouped
- 用户资料编辑页 - Grouped
---
6. 实际代码示例
// Plain 样式 - 联系人列表
class ContactsTableViewController: UITableViewController {
let contacts = [
"A": ["Alice", "Andy"],
"B": ["Bob", "Betty"],
"C": ["Charlie"]
]
override func viewDidLoad() {
super.viewDidLoad()
tableView = UITableView(frame: .zero, style: .plain) // Plain 样式
}
override func numberOfSections(in tableView: UITableView) -> Int {
return contacts.keys.count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return Array(contacts.keys.sorted())[section] // "A", "B", "C"
// ✅ 滚动时会悬停在顶部
}
}
// Grouped 样式 - 设置页面
class SettingsTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 13.0, *) {
tableView = UITableView(frame: .zero, style: .insetGrouped) // 现代样式
} else {
tableView = UITableView(frame: .zero, style: .grouped)
}
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 3 // 账号、隐私、关于
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return ["账号设置", "隐私设置", "关于"][section]
// ✅ 不会悬停,跟随内容滚动
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
if section == 0 {
return "您可以在此修改账号信息" // Section Footer 说明文字
}
return nil
}
}
---
7. 如何消除 Grouped 样式的顶部/底部空白
// ❌ Grouped 样式默认有顶部和底部空白
let tableView = UITableView(frame: .zero, style: .grouped)
// 顶部有约 35pt 的空白
// ✅ 解决方案 1: 设置 Header/Footer 高度
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return section == 0 ? .leastNormalMagnitude : 10 // 第一个 Section 移除顶部空白
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return .leastNormalMagnitude // 移除底部空白
}
// ✅ 解决方案 2: 调整 contentInset
tableView.contentInset = UIEdgeInsets(top: -35, left: 0, bottom: 0, right: 0)
---
8. 快速对比表格
| 需求 | 选择样式 | 原因 |
|-----------------|--------------------------|----------------|
| 长列表(如通讯录、消息) | .plain | Header 悬停,快速定位 |
| 设置页面 | .insetGrouped (iOS 13+) | 清晰分组,现代设计 |
| 表单页面 | .grouped 或 .insetGrouped | 分组清晰 |
| 搜索结果 | .plain | 无需分组视觉 |
| 商品详情(多 Section) | .grouped | 分组展示不同信息 |
| 短列表(< 20 项) | .grouped | 视觉更舒适 |
| 超长列表(> 1000 项) | .plain | 性能更好 |
---
总结
Plain 样式:
- 适合长列表、快速滚动场景
- Header 悬停,方便定位
- 视觉紧凑,无分组感
Grouped 样式:
- 适合设置页、表单、详情页
- 清晰的分组视觉
- Header 不悬停,更独立
现代推荐:
- iOS 13+ 优先使用 .insetGrouped 替代 .grouped
- 更符合 Apple 设计规范
需要我帮你分析具体的使用场景吗?
更多推荐

所有评论(0)