iOS开发基础19-纯代码自定义不等高 UITableViewCell:手动计算高度与性能优化
纯代码自定义不等高 UITableViewCell:手动计算高度与性能优化
当 Cell 内容高度不固定(如动态文本、可选配图)时,需要手动计算每个 Cell 的高度。本文详细讲解纯代码手动计算不等高 Cell 的完整实现,包括框架模型设计、高度缓存和性能优化。
一、实现思路
手动计算不等高 Cell 的核心是框架模型(Frame Model)模式:
- 数据模型中不仅存储业务数据,还预计算每个子控件的
frame和 Cell 总高度。 - 高度计算采用懒加载,首次访问时计算并缓存,后续直接返回。
heightForRowAtIndexPath:直接返回模型中缓存的高度。- 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混用,实现了代理方法后自动计算不生效。
将来的你会感谢今天如此努力的你!
版权声明:本文为博主原创文章,未经博主允许不得转载。

浙公网安备 33010602011771号