iOS开发基础19-纯代码自定义不等高 UITableViewCell:手动计算高度与性能优化

纯代码自定义不等高 UITableViewCell:手动计算高度与性能优化

当 Cell 内容高度不固定(如动态文本、可选配图)时,需要手动计算每个 Cell 的高度。本文详细讲解纯代码手动计算不等高 Cell 的完整实现,包括框架模型设计、高度缓存和性能优化。


一、实现思路

手动计算不等高 Cell 的核心是框架模型(Frame Model)模式:

  1. 数据模型中不仅存储业务数据,还预计算每个子控件的 frame 和 Cell 总高度。
  2. 高度计算采用懒加载,首次访问时计算并缓存,后续直接返回。
  3. heightForRowAtIndexPath: 直接返回模型中缓存的高度。
  4. Cell 的 setStatus: 方法中根据模型的 frame 设置子控件位置。

本方案是手动计算高度,不依赖 Auto Layout。不要与 UITableViewAutomaticDimension(自动计算高度方案)混用——如果实现了 heightForRowAtIndexPath: 并返回有效数值,系统会优先使用代理方法返回值,UITableViewAutomaticDimension 不生效。


二、实现代码

1. 自定义 UITableViewCell

Objective-C

// XMGStatusCell.h
@interface XMGStatusCell : UITableViewCell

@property (nonatomic, strong) UIImageView *iconImageView;
@property (nonatomic, strong) UILabel *nameLabel;
@property (nonatomic, strong) UILabel *textLabel;
@property (nonatomic, strong) UIImageView *pictureImageView;
@property (nonatomic, strong) XMGStatus *status;

+ (instancetype)cellWithTableView:(UITableView *)tableView;

@end
// XMGStatusCell.m
@implementation XMGStatusCell

+ (instancetype)cellWithTableView:(UITableView *)tableView {
    static NSString *ID = @"statusCell";
    XMGStatusCell *cell = [tableView dequeueReusableCellWithIdentifier:ID];
    if (!cell) {
        cell = [[XMGStatusCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:ID];
    }
    return cell;
}

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
        [self setupSubviews];
    }
    return self;
}

- (void)setupSubviews {
    self.iconImageView = [[UIImageView alloc] init];
    self.iconImageView.contentMode = UIViewContentModeScaleAspectFill;
    self.iconImageView.clipsToBounds = YES;
    [self.contentView addSubview:self.iconImageView];

    self.nameLabel = [[UILabel alloc] init];
    self.nameLabel.font = [UIFont systemFontOfSize:17];
    [self.contentView addSubview:self.nameLabel];

    self.textLabel = [[UILabel alloc] init];
    self.textLabel.font = [UIFont systemFontOfSize:14];
    self.textLabel.numberOfLines = 0;
    [self.contentView addSubview:self.textLabel];

    self.pictureImageView = [[UIImageView alloc] init];
    self.pictureImageView.contentMode = UIViewContentModeScaleAspectFill;
    self.pictureImageView.clipsToBounds = YES;
    [self.contentView addSubview:self.pictureImageView];
}

- (void)setStatus:(XMGStatus *)status {
    _status = status;

    self.iconImageView.image = [UIImage imageNamed:status.icon];
    self.iconImageView.frame = status.iconFrame;

    self.nameLabel.text = status.name;
    self.nameLabel.frame = status.nameFrame;

    self.textLabel.text = status.text;
    self.textLabel.frame = status.textFrame;

    if (status.picture) {
        self.pictureImageView.hidden = NO;
        self.pictureImageView.image = [UIImage imageNamed:status.picture];
        self.pictureImageView.frame = status.pictureFrame;
    } else {
        self.pictureImageView.hidden = YES;
    }
}

- (void)prepareForReuse {
    [super prepareForReuse];
    self.iconImageView.image = nil;
    self.nameLabel.text = nil;
    self.textLabel.text = nil;
    self.pictureImageView.image = nil;
    self.pictureImageView.hidden = YES;
}

@end

Swift

class StatusCell: UITableViewCell {

    lazy var iconImageView: UIImageView = {
        let iv = UIImageView()
        iv.contentMode = .scaleAspectFill
        iv.clipsToBounds = true
        return iv
    }()

    lazy var nameLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 17)
        return label
    }()

    lazy var contentLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 14)
        label.numberOfLines = 0
        return label
    }()

    lazy var pictureImageView: UIImageView = {
        let iv = UIImageView()
        iv.contentMode = .scaleAspectFill
        iv.clipsToBounds = true
        return iv
    }()

    var status: Status? {
        didSet {
            guard let status = status else { return }
            iconImageView.image = UIImage(named: status.icon)
            iconImageView.frame = status.iconFrame
            nameLabel.text = status.name
            nameLabel.frame = status.nameFrame
            contentLabel.text = status.text
            contentLabel.frame = status.textFrame
            if let picture = status.picture {
                pictureImageView.isHidden = false
                pictureImageView.image = UIImage(named: picture)
                pictureImageView.frame = status.pictureFrame
            } else {
                pictureImageView.isHidden = true
            }
        }
    }

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        contentView.addSubview(iconImageView)
        contentView.addSubview(nameLabel)
        contentView.addSubview(contentLabel)
        contentView.addSubview(pictureImageView)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        iconImageView.image = nil
        nameLabel.text = nil
        contentLabel.text = nil
        pictureImageView.image = nil
        pictureImageView.isHidden = true
    }
}

2. 数据模型(含框架计算)

模型中预计算所有子控件 frame 和 Cell 总高度,采用懒加载缓存。

Objective-C

// XMGStatus.h
@interface XMGStatus : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *text;
@property (nonatomic, copy) NSString *icon;
@property (nonatomic, copy) NSString *picture;
@property (nonatomic, assign) BOOL vip;

// 框架数据(预计算)
@property (nonatomic, assign) CGRect iconFrame;
@property (nonatomic, assign) CGRect nameFrame;
@property (nonatomic, assign) CGRect textFrame;
@property (nonatomic, assign) CGRect pictureFrame;
@property (nonatomic, assign) CGFloat cellHeight; // 用 -1 表示未计算

@end
// XMGStatus.m
@implementation XMGStatus

- (instancetype)init {
    if (self = [super init]) {
        _cellHeight = -1; // 初始化为 -1,表示尚未计算
    }
    return self;
}

- (CGFloat)cellHeight {
    if (_cellHeight == -1) { // 未计算时才计算
        CGFloat margin = 10;
        CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;

        // 头像
        CGFloat iconWH = 30;
        self.iconFrame = CGRectMake(margin, margin, iconWH, iconWH);

        // 昵称
        CGFloat nameX = CGRectGetMaxX(self.iconFrame) + margin;
        NSDictionary *nameAttrs = @{NSFontAttributeName: [UIFont systemFontOfSize:17]};
        CGSize nameSize = [self.name sizeWithAttributes:nameAttrs];
        self.nameFrame = (CGRect){{nameX, margin}, nameSize};

        // 正文(多行)
        CGFloat textX = margin;
        CGFloat textY = CGRectGetMaxY(self.iconFrame) + margin;
        CGFloat textWidth = screenWidth - 2 * textX;
        CGSize textMaxSize = CGSizeMake(textWidth, CGFLOAT_MAX);
        NSDictionary *textAttrs = @{NSFontAttributeName: [UIFont systemFontOfSize:14]};
        CGFloat textHeight = [self.text boundingRectWithSize:textMaxSize
                                                      options:NSStringDrawingUsesLineFragmentOrigin
                                                   attributes:textAttrs
                                                      context:nil].size.height;
        self.textFrame = CGRectMake(textX, textY, textWidth, textHeight);

        // 配图(可选)
        if (self.picture) {
            CGFloat pictureWH = 100;
            CGFloat pictureY = CGRectGetMaxY(self.textFrame) + margin;
            self.pictureFrame = CGRectMake(textX, pictureY, pictureWH, pictureWH);
            _cellHeight = CGRectGetMaxY(self.pictureFrame) + margin;
        } else {
            _cellHeight = CGRectGetMaxY(self.textFrame) + margin;
        }
    }
    return _cellHeight;
}

@end

Swift

class Status: NSObject {
    var name: String = ""
    var text: String = ""
    var icon: String = ""
    var picture: String?
    var vip: Bool = false

    var iconFrame: CGRect = .zero
    var nameFrame: CGRect = .zero
    var textFrame: CGRect = .zero
    var pictureFrame: CGRect = .zero
    private(set) var cellHeight: CGFloat = -1

    func calculateCellHeight() -> CGFloat {
        if cellHeight != -1 { return cellHeight }

        let margin: CGFloat = 10
        let screenWidth = UIScreen.main.bounds.width

        let iconWH: CGFloat = 30
        iconFrame = CGRect(x: margin, y: margin, width: iconWH, height: iconWH)

        let nameX = iconFrame.maxX + margin
        let nameAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 17)]
        let nameSize = (name as NSString).size(withAttributes: nameAttrs)
        nameFrame = CGRect(origin: CGPoint(x: nameX, y: margin), size: nameSize)

        let textX = margin
        let textY = iconFrame.maxY + margin
        let textWidth = screenWidth - 2 * textX
        let textMaxSize = CGSize(width: textWidth, height: .greatestFiniteMagnitude)
        let textAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 14)]
        let textHeight = (text as NSString).boundingRect(
            with: textMaxSize,
            options: .usesLineFragmentOrigin,
            attributes: textAttrs,
            context: nil
        ).height
        textFrame = CGRect(x: textX, y: textY, width: textWidth, height: textHeight)

        if let picture = picture, !picture.isEmpty {
            let pictureWH: CGFloat = 100
            let pictureY = textFrame.maxY + margin
            pictureFrame = CGRect(x: textX, y: pictureY, width: pictureWH, height: pictureWH)
            cellHeight = pictureFrame.maxY + margin
        } else {
            cellHeight = textFrame.maxY + margin
        }
        return cellHeight
    }
}

为什么用 -1 而不是 0 判断:Cell 高度理论上不可能为负数,用 -1 作为"未计算"的哨兵值比 0 更严谨(避免极端情况下高度恰好为 0 的误判)。

屏幕宽度注意事项:[UIScreen mainScreen].bounds.size.width 在 iPhone 全屏应用中准确,但在 iPad 分屏模式下可能不等于 tableView 实际宽度。如需适配分屏,应将 tableView 宽度传入模型计算。

3. 控制器中使用

Objective-C

@interface ViewController () <UITableViewDataSource, UITableViewDelegate>

@property (nonatomic, strong) NSArray<XMGStatus *> *statuses;

@end

@implementation ViewController

- (NSArray<XMGStatus *> *)statuses {
    if (!_statuses) {
        _statuses = [XMGStatus objectArrayWithFilename:@"statuses.plist"];
    }
    return _statuses;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    // 手动计算高度方案:不需要设置 UITableViewAutomaticDimension
    // 可设置一个估算值提升初始滚动性能(可选)
    self.tableView.estimatedRowHeight = 200;
}

#pragma mark - DataSource

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.statuses.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    XMGStatusCell *cell = [XMGStatusCell cellWithTableView:tableView];
    cell.status = self.statuses[indexPath.row];
    return cell;
}

#pragma mark - Delegate

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    XMGStatus *status = self.statuses[indexPath.row];
    return status.cellHeight; // 直接返回缓存的高度
}

@end

Swift

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    @IBOutlet weak var tableView: UITableView!
    var statuses: [Status] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
        tableView.delegate = self
        tableView.estimatedRowHeight = 200
        loadData()
    }

    private func loadData() {
        // 从 plist 或网络加载数据
        // ...
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        statuses.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = StatusCell(style: .default, reuseIdentifier: "statusCell")
        cell.status = statuses[indexPath.row]
        return cell
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        statuses[indexPath.row].calculateCellHeight()
    }
}

三、底层逻辑与性能优化

高度调用机制

  • heightForRowAtIndexPath: 在 reloadData 时会对所有行调用一次,用于计算 contentSize(决定滚动条高度和可滚动范围)。
  • 系统不会自动缓存该代理方法的返回值,每次需要高度时都会重新调用。因此必须在模型中自行缓存计算结果(即 cellHeight 的懒加载)。
  • estimatedRowHeight 在手动计算模式下的作用:提供一个近似值用于初始 contentSize 估算,减少首次加载时全部行都调用 heightForRowAtIndexPath: 的性能开销。Cell 滚动到可见区域时,系统仍会调用 heightForRowAtIndexPath: 获取精确高度。

优化方案

优化手段 说明
模型中缓存高度 cellHeight 懒加载计算,首次计算后缓存,后续直接返回
estimatedRowHeight 设置接近实际的估算值,减少首次加载时全量计算
子控件 frame 预计算 模型中同时计算所有子控件 frame,Cell 中直接赋值,避免 layoutSubviews 中重复计算
批量更新 插入/删除多行用 beginUpdates / endUpdates 包裹,避免逐行刷新
异步加载图片 Cell 中的网络图片异步加载,不阻塞主线程;加载完成后按需刷新对应行
Instruments 调优 Time Profiler 检测高 CPU 方法,重点优化 heightForRowAtIndexPath: 和 cellForRowAtIndexPath:

批量更新示例

[self.tableView beginUpdates];
[self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationAutomatic];
[self.tableView endUpdates];

刷新单行(图片加载完成后)

NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0];
[self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];

手动计算 vs Auto Layout 自动计算

维度 手动计算(本文方案) Auto Layout 自动计算
实现复杂度 高(需手动计算所有 frame) 低(设置约束即可)
性能 高(预计算后直接返回) 中(系统运行时计算约束)
灵活性 高(完全控制布局逻辑) 中(受约束系统限制)
适用场景 高性能列表、复杂自定义布局 普通动态内容、快速开发
旋转/分屏适配 需重新计算宽度 自动适配

四、总结

  • 手动计算不等高 Cell 的核心是框架模型模式:数据模型中预计算子控件 frame 和 Cell 总高度,采用懒加载缓存。
  • heightForRowAtIndexPath: 直接返回模型中缓存的高度,避免重复计算。
  • Cell 的 setStatus: 中根据模型 frame 直接设置子控件位置,无需在 layoutSubviews 中重复计算。
  • 系统不会自动缓存 heightForRowAtIndexPath: 的返回值,必须自行在模型中缓存。
  • 手动计算方案性能高但实现复杂,适合高性能列表;普通场景推荐使用 UITableViewAutomaticDimension + Auto Layout。
  • 不要将手动计算高度与 UITableViewAutomaticDimension 混用,实现了代理方法后自动计算不生效。

posted @ 2015-07-23 23:21  Mr.陳  阅读(4237)  评论(1)    收藏  举报