LazyViewContainer-iOS中节省内存的UI创建方式
背景
iOS工程中,创建自定义UI组件时有一个常见的写法:
- UI组件初始化时,创建所有子组件实例;并将子组件添加到自定义组件层级中,再使用Autolayout做好约束布局
- 后续有数据时,直接为组件的每个子组件填充数据
这样的写法是会导致一定的内存浪费,比如
- 有的场景可能仅在特定情况下才会拿到数据显示UI组件,如果没有数据,若仍然初始化了某个UI组件,则导致该组件占用了一部分长期不会用到的内存
- 这样的浪费在像直播房等需要大量UI、非UI组件的场景下比较常见
当然,根本的解决方案也是很容易想到的:
- 延迟加载:仅在需要某个UI组件时才去创建和使用
没错,这无疑可以从根本上解决该问题,但是,延迟加载写起来其实要更麻烦一些,所以为了方便快捷,最开始提到的写法其实更普遍
下面会提到一种折中的方案,能部分降低内存的浪费
LazyViewContainer
- 红框为普通UI组件直接创建和内存分配过程;绿框中是
LazyViewContainer
工作原理 - 普通UI组件初始化完后,内存中就有了该组件完整的对象
- 而
LazyViewContainer
只是一个有很少轻量属性(3个)的普通类,所以LazyViewContainer
初始化后,此时内存中的占用较小(约48 bytes)。仅当执行ensureView
方法时,才会尝试创建实际的UI组件 - 最右侧紫色框展示了不同情况下内存占用情况
- 简单总结一下
LazyViewContainer
的工作原理,就是在实际UI组件创建之前添加了一个中间层(使用泛型和closure来做到这一点),尽可能延迟实际UI组件及子组件的创建
内存占用测试
使用了几个最常见的系统UI组件进行了一下内存占用的测试
- 测试环境:
- iPhone 13 mini/iOS 18.1.1, Release环境
- 测试方法:
- 使用不同UI组件,使用不同创建方式,验证对应情况下,看单个UI组件实例实际的内存占用
不同UI组件的创建逻辑代码如下所示:
// UILabel
let label = UILabel()
label.text = "Test Label"
label.font = .systemFont(ofSize: 14)
label.textColor = .red
label.backgroundColor = .blue
label.frame = CGRect(x: 100, y: 100, width: 100, height: 20)
// UIButton
let button = UIButton(type: .system)
button.setTitle("Test Button", for: .normal)
button.backgroundColor = .green
button.frame = CGRect(x: 100, y: 200, width: 100, height: 40)
// UIImageView
let imageView = UIImageView()
imageView.backgroundColor = .yellow
imageView.frame = CGRect(x: 100, y: 300, width: 100, height: 100)
组件类型 | 创建方式 | 单个实例内存(bytes) | 节省内存(bytes) | 节省比例 |
---|---|---|---|---|
UILabel | 直接创建 | 896 | - | - |
Lazy创建 | 48 | 768 | 94.64% | |
Lazy Ensure | 944 | -48 | -5.36% | |
UIButton | 直接创建 | 768 | - | - |
Lazy创建 | 48 | 1792 | 93.75% | |
Lazy Ensure | 816 | -48 | -6.25% | |
UIImageView | 直接创建 | 512 | - | - |
Lazy创建 | 48 | 384 | 90.62% | |
Lazy Ensure | 560 | -48 | -9.38% |
根据测试结果,总结一下结论:
-
LazyViewContainer
单个实例(在没有通过ensureView
触发实际UI组件创建时)占用的内存约为 48 bytes - 基于结论1,所以当
LazyViewContainer
真的要创建实际UI组件时,会比直接创建组件占用的内存更多,但也仅多48 bytes,至于精确的内存占比可以参考表格中“节省比例”列的绿字数据 - 同时也能预见到,当自定义UI组件越复杂时,“仅Lazy创建”的方式下,带来的内存节省比例更显著
LazyViewContainer使用场景
首先要明确两点:
- 这并不是创建UI组件的最节省内存的方式,只是一种在编码便捷性和内存性能之间的一种折中手段
- 不是所有情况下都推荐使用
LazyViewContainer
那么,什么场景下推荐使用?
- 用户可能长时间进行操作的、复杂UI层级的页面(如语音、视频、直播房间)
- 这样的页面支持的功能比较多,但又不是需要同时运行这些功能,当多数用户、多数情况下仅使用一小部分功能时,那么如果仍然在进入页面时创建所有功能的业务或UI组件,就是会造成比较严重的内存浪费
- 现有代码中业务逻辑太复杂,不宜短时间做大重构时,适合这样的折中方案
- 复杂的自定义UI组件
- 子组件越多,UI层级越复杂,当该UI组件使用率低的时候,所带来的内存节省效果越明显
什么场景下不推荐使用?
- 列表页面
- 列表滚动时,伴随多个cell的复用,虽然每个cell的数据不尽相同,有的UI元素可能不需要显示。但可能会被复用,所以此情况下,cell初始化时将所有子UI组件都创建,合理性更强,这样会更方便滚动时的复用
源码与使用方式
源码:
//
// LazyViewContainer.swift
//
// Created by songgeb on 2025/1/1.
// Copyright © 2025 songgeb. All rights reserved.
//
import UIKit
/// 将创建自定义UIView逻辑延迟到数据到来时,同时避免当数据无效时仍创建自定义UIView,导致无意义的内存占用问题
final class LazyViewContainer<T: UIView> {
private(set) var view: T?
private let createView: () -> T
private weak var parentView: UIView?
init(createView: @escaping () -> T) {
self.createView = createView
}
init() {
createView = { T() }
}
/// 获取或创建视图,交给调用方处理布局
/// - Parameters:
/// - parent: 父视图
/// - customAddition: 自定义添加视图逻辑
/// - onFirstAddition: 首次创建、同时添加到parentView的时机
/// - Returns: 自定义视图
@discardableResult
func ensureView(
in parent: UIView,
customAddition: ((T) -> Void)? = nil,
onFirstAddition: ((T) -> Void)? = nil) -> T
{
if let existingView = view {
return existingView
}
let newView = Thread.isMainThread ? createView() : DispatchQueue.main.sync { createView() }
// 使用自定义添加方式或默认 addSubview
if let customAddition {
customAddition(newView)
} else {
parent.addSubview(newView)
}
onFirstAddition?(newView)
view = newView
parentView = parent
return newView
}
/// 移除视图
/// - Parameter customRemoval: 自定义移除视图逻辑
func removeView(using customRemoval: ((T) -> Void)? = nil) {
guard let view = view, view.superview != nil else { return }
func doRemoving() {
if let customRemoval {
customRemoval(view)
} else {
view.removeFromSuperview()
}
}
if Thread.isMainThread {
doRemoving()
} else {
DispatchQueue.main.async { doRemoving() }
}
self.view = nil
parentView = nil
}
// 检查视图是否创建
var isCreated: Bool {
view != nil
}
deinit {
removeView()
}
}
使用方式:
class TestViewController: UIViewController {
private lazy var leftVerticalStack: UIStackView = {
let vstack = UIStackView(frame: .zero)
vstack.axis = .vertical
vstack.alignment = .leading
vstack.distribution = .equalSpacing
return vstack
}()
private lazy var detailLabelLoader = LazyViewContainer<UILabel> {
let label = UILabel()
label.font = .systemFont(withSize: 14)
label.textColor = .bSecondary
return label
}
private func updateDetailLabel(_ shouldShowDetail: Bool) {
if shouldShowDetail {
detailLabelLoader.ensureView(in: leftVerticalStack, customAddition: { [weak leftVerticalStack] label in
guard let leftVerticalStack else { return }
leftVerticalStack.addArrangedSubview(label)
})
detailLabelLoader.view?.text = "abc"
} else {
detailLabelLoader.removeView { [weak self] label in
guard let self else { return }
leftVerticalStack.removeArrangedSubview(label)
}
}
}
}