普通视图

发现新文章,点击刷新页面。
昨天 — 2025年5月16日首页

LazyViewContainer-iOS中节省内存的UI创建方式

作者 songgeb
2025年5月16日 14:48

背景

iOS工程中,创建自定义UI组件时有一个常见的写法:

  1. UI组件初始化时,创建所有子组件实例;并将子组件添加到自定义组件层级中,再使用Autolayout做好约束布局
  2. 后续有数据时,直接为组件的每个子组件填充数据

这样的写法是会导致一定的内存浪费,比如

  • 有的场景可能仅在特定情况下才会拿到数据显示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%

根据测试结果,总结一下结论:

  1. LazyViewContainer单个实例(在没有通过ensureView触发实际UI组件创建时)占用的内存约为 48 bytes
  2. 基于结论1,所以当LazyViewContainer真的要创建实际UI组件时,会比直接创建组件占用的内存更多,但也仅多48 bytes,至于精确的内存占比可以参考表格中“节省比例”列的绿字数据
  3. 同时也能预见到,当自定义UI组件越复杂时,“仅Lazy创建”的方式下,带来的内存节省比例更显著

LazyViewContainer使用场景

首先要明确两点:

  1. 这并不是创建UI组件的最节省内存的方式,只是一种在编码便捷性和内存性能之间的一种折中手段
  2. 不是所有情况下都推荐使用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)
            }
        }
    }
}
❌
❌