iOS开发基础12-深入理解UITableView(一)

UITableView 深度解析:数据展示、Cell 重用与不等高布局

UITableView 是 iOS 中展示列表数据的核心控件,继承自 UIScrollView,天然支持垂直滚动和高性能的 Cell 重用机制。本文从基础用法到自定义 Cell、不等高布局,全面解析其工作原理。


一、UITableView 概述

1. 什么是 UITableView

UITableView 用于展示一维列表数据,每一行是一个 UITableViewCell。它继承自 UIScrollView,支持垂直滚动,并通过 Cell 重用机制在大量数据下仍保持流畅。

2. 三种样式

样式 说明
UITableViewStylePlain 标准列表,连续平面,section header 悬浮(iOS 15+ 默认不悬浮,需设置 sectionHeaderTopPadding
UITableViewStyleGrouped 分组样式,每组有独立背景和间距
UITableViewStyleInsetGrouped iOS 13+ 新增,分组样式且内容横向内嵌(设置 App 风格)

二、展示数据

1. 数据源(DataSource)

UITableView 通过 dataSource 获取数据,数据源必须遵守 UITableViewDataSource 协议。数据展示按以下顺序调用:

  1. numberOfSectionsInTableView: — 获取分组数(可选,默认 1)。
  2. tableView:numberOfRowsInSection: — 获取每组行数。
  3. tableView:cellForRowAtIndexPath: — 获取每一行的 Cell。

2. 示例代码

Objective-C

@interface ViewController () <UITableViewDataSource>

@property (weak, nonatomic) IBOutlet UITableView *tableView;
@property (nonatomic, strong) NSArray *dataArray; // 二维数组:外层 section,内层 row

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.tableView.dataSource = self;
    // 注册 Cell(注册后 dequeue 不会返回 nil)
    [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"cell"];
}

#pragma mark - UITableViewDataSource

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return self.dataArray.count;
}

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

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    // 带 indexPath 的 dequeue 方法:注册后保证返回非空 Cell
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
    
    cell.textLabel.text = [NSString stringWithFormat:@"Section %ld Row %ld",
                           (long)indexPath.section, (long)indexPath.row];
    return cell;
}

@end

Swift

class ViewController: UIViewController, UITableViewDataSource {

    @IBOutlet weak var tableView: UITableView!
    var dataArray: [[String]] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }

    func numberOfSections(in tableView: UITableView) -> Int {
        return dataArray.count
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return dataArray[section].count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = "Section \(indexPath.section) Row \(indexPath.row)"
        return cell
    }
}

两种 dequeue 方法的区别

  • dequeueReusableCellWithIdentifier: — 可能返回 nil,需手动判断创建。
  • dequeueReusableCellWithIdentifier:forIndexPath: — 必须提前注册 Cell(registerClass:registerNib:),保证返回非空,且 Cell 尺寸已根据 TableView 配置确定。推荐使用。

三、UITableViewCell 简介

1. 结构

每个 UITableViewCell 内部有一个 contentView,所有自定义子视图应添加到 contentView 上(而非 cell 本身),因为系统在编辑状态下会调整 contentView 的 frame。

2. 辅助指示视图(Accessory)

通过 accessoryType 设置右侧辅助图标:

枚举值 显示 常用场景
UITableViewCellAccessoryNone 默认
UITableViewCellAccessoryDisclosureIndicator 灰色箭头 可点击进入详情页
UITableViewCellAccessoryDetailButton 蓝色信息按钮 点击查看详情
UITableViewCellAccessoryDetailDisclosureButton 信息按钮 + 箭头 两者结合
UITableViewCellAccessoryCheckmark 对勾 选中状态

也可通过 accessoryView 属性完全自定义右侧视图。

3. 系统预设样式

contentView 默认包含 textLabeldetailTextLabel(两个 UILabel)和 imageView(一个 UIImageView),通过 UITableViewCellStyle 决定布局:

样式 布局
UITableViewCellStyleDefault 左图 + 单行文字
UITableViewCellStyleSubtitle 左图 + 标题在上 + 副标题在下
UITableViewCellStyleValue1 左图 + 标题在左 + 副标题在右(蓝色)
UITableViewCellStyleValue2 标题在左(蓝色小字)+ 副标题在右

4. 选中样式

cell.selectionStyle = UITableViewCellSelectionStyleNone;  // 无选中效果
cell.selectionStyle = UITableViewCellSelectionStyleDefault; // 默认灰色
cell.selectionStyle = UITableViewCellSelectionStyleGray;    // 灰色
cell.selectionStyle = UITableViewCellSelectionStyleBlue;    // 蓝色(已废弃效果)

四、Cell 重用原理

1. 为什么需要重用

如果列表有 10000 条数据,创建 10000 个 Cell 会耗尽内存。UITableView 只创建屏幕可见的 Cell(约 10~15 个),滚动时将移出屏幕的 Cell 放入重用池,新进入屏幕的行从重用池取出已有 Cell 重新配置内容。

2. 重用流程

用户滚动 → Cell 移出屏幕 → 放入重用池(按 reuseIdentifier 分类)
         → 新行进入屏幕 → 从重用池取出对应 identifier 的 Cell → 配置新内容 → 显示

3. prepareForReuse

当 Cell 即将被重用时,系统调用 prepareForReuse 方法。应在此方法中重置 Cell 的临时状态(如取消图片加载、清空文本、重置选中状态),避免旧内容残留:

- (void)prepareForReuse {
    [super prepareForReuse];
    self.imageView.image = nil;
    self.textLabel.text = nil;
    // 取消正在进行的异步图片加载
}
override func prepareForReuse() {
    super.prepareForReuse()
    imageView?.image = nil
    textLabel?.text = nil
}

4. 多类型 Cell 重用

一个 TableView 中有多种 Cell 时,通过不同的 reuseIdentifier 区分,每种类型独立维护重用池:

if (indexPath.row == 0) {
    HeaderCell *cell = [tableView dequeueReusableCellWithIdentifier:@"headerCell" forIndexPath:indexPath];
    return cell;
} else {
    ContentCell *cell = [tableView dequeueReusableCellWithIdentifier:@"contentCell" forIndexPath:indexPath];
    return cell;
}

五、通过代码自定义 Cell

当系统预设样式无法满足需求时,继承 UITableViewCell 自定义。

1. 自定义步骤

  1. 新建继承自 UITableViewCell 的类。
  2. 重写 initWithStyle:reuseIdentifier:,在 contentView 上添加子控件。
  3. 重写 layoutSubviews(手动 frame 布局)或使用 Auto Layout 设置子控件位置。
  4. 重写 prepareForReuse 重置状态。
  5. 提供模型属性,在 setter 中配置子控件内容。

2. 示例代码

Objective-C

// CustomTableViewCell.h
@interface CustomTableViewCell : UITableViewCell

@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UIImageView *customImageView;
@property (nonatomic, strong) NJShop *shop;

@end
// CustomTableViewCell.m
@implementation CustomTableViewCell

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
        self.titleLabel = [[UILabel alloc] init];
        self.titleLabel.font = [UIFont systemFontOfSize:16];
        [self.contentView addSubview:self.titleLabel];

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

- (void)layoutSubviews {
    [super layoutSubviews];
    CGFloat width = self.contentView.bounds.size.width;
    CGFloat height = self.contentView.bounds.size.height;

    self.customImageView.frame = CGRectMake(10, 10, 40, height - 20);
    self.titleLabel.frame = CGRectMake(60, 0, width - 70, height);
}

- (void)prepareForReuse {
    [super prepareForReuse];
    self.titleLabel.text = nil;
    self.customImageView.image = nil;
}

- (void)setShop:(NJShop *)shop {
    _shop = shop;
    self.titleLabel.text = shop.name;
    self.customImageView.image = [UIImage imageNamed:shop.icon];
}

@end

Swift

class CustomTableViewCell: UITableViewCell {

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

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

    var shop: Shop? {
        didSet {
            titleLabel.text = shop?.name
            customImageView.image = UIImage(named: shop?.icon ?? "")
        }
    }

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        contentView.addSubview(titleLabel)
        contentView.addSubview(customImageView)
    }

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

    override func layoutSubviews() {
        super.layoutSubviews()
        let width = contentView.bounds.width
        let height = contentView.bounds.height

        customImageView.frame = CGRect(x: 10, y: 10, width: 40, height: height - 20)
        titleLabel.frame = CGRect(x: 60, y: 0, width: width - 70, height: height)
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        titleLabel.text = nil
        customImageView.image = nil
    }
}

手动 frame 布局时代码中提到的"框架模型(Frame Model)"是早期优化手段:将 Cell 内所有子控件的 frame 预计算后缓存到模型中,避免 layoutSubviews 重复计算。现代开发使用 Auto Layout 后已不需要这种模式。


六、汽车数据展示案例

通过一个完整案例演示分组列表、右侧索引、模型转换。

数据模型

Objective-C

// Car.h
@interface Car : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *icon;
+ (instancetype)carWithDict:(NSDictionary *)dict;
@end

// Car.m
@implementation Car
+ (instancetype)carWithDict:(NSDictionary *)dict {
    Car *car = [[self alloc] init];
    car.name = dict[@"name"];
    car.icon = dict[@"icon"];
    return car;
}
@end

// CarGroup.h
@interface CarGroup : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic, strong) NSArray<Car *> *cars;
+ (instancetype)carGroupWithDict:(NSDictionary *)dict;
@end

// CarGroup.m
@implementation CarGroup
+ (instancetype)carGroupWithDict:(NSDictionary *)dict {
    CarGroup *group = [[self alloc] init];
    group.title = dict[@"title"];
    NSMutableArray *cars = [NSMutableArray array];
    for (NSDictionary *carDict in dict[@"cars"]) {
        [cars addObject:[Car carWithDict:carDict]];
    }
    group.cars = cars;
    return group;
}
@end

setValuesForKeysWithDictionary: 要求字典 key 与属性名完全匹配,且遇到不存在的 key 会崩溃(需重写 setValue:forUndefinedKey:)。手动赋值更安全可控。

视图控制器

Objective-C

@interface ViewController () <UITableViewDataSource, UITableViewDelegate>
@property (weak, nonatomic) IBOutlet UITableView *tableView;
@property (nonatomic, copy) NSArray<CarGroup *> *carGroups;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.tableView.dataSource = self;
    self.tableView.delegate = self;
    self.tableView.sectionIndexColor = [UIColor redColor];
    self.tableView.sectionIndexBackgroundColor = [UIColor clearColor];
    [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"carCell"];
}

- (NSArray<CarGroup *> *)carGroups {
    if (!_carGroups) {
        NSString *path = [[NSBundle mainBundle] pathForResource:@"cars" ofType:@"plist"];
        NSArray *dictArray = [NSArray arrayWithContentsOfFile:path];
        NSMutableArray *groups = [NSMutableArray array];
        for (NSDictionary *dict in dictArray) {
            [groups addObject:[CarGroup carGroupWithDict:dict]];
        }
        _carGroups = groups;
    }
    return _carGroups;
}

#pragma mark - DataSource

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return self.carGroups.count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.carGroups[section].cars.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"carCell" forIndexPath:indexPath];
    Car *car = self.carGroups[indexPath.section].cars[indexPath.row];
    cell.textLabel.text = car.name;
    cell.imageView.image = [UIImage imageNamed:car.icon];
    return cell;
}

#pragma mark - Delegate

- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
    return self.carGroups[section].title;
}

- (NSArray<NSString *> *)sectionIndexTitlesForTableView:(UITableView *)tableView {
    // KVC 收集所有 group 的 title
    return [self.carGroups valueForKeyPath:@"title"];
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:YES]; // 点击后取消选中
    Car *car = self.carGroups[indexPath.section].cars[indexPath.row];
    NSLog(@"选中了: %@", car.name);
}

@end

Swift

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    @IBOutlet weak var tableView: UITableView!
    var carGroups: [CarGroup] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
        tableView.delegate = self
        tableView.sectionIndexColor = .red
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "carCell")
        loadData()
    }

    private func loadData() {
        let path = Bundle.main.path(forResource: "cars", ofType: "plist")!
        let dictArray = NSArray(contentsOfFile: path) as! [[String: Any]]
        carGroups = dictArray.map { CarGroup(dict: $0) }
    }

    func numberOfSections(in tableView: UITableView) -> Int { carGroups.count }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        carGroups[section].cars.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "carCell", for: indexPath)
        let car = carGroups[indexPath.section].cars[indexPath.row]
        cell.textLabel?.text = car.name
        cell.imageView?.image = UIImage(named: car.icon)
        return cell
    }

    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        carGroups[section].title
    }

    func sectionIndexTitles(for tableView: UITableView) -> [String]? {
        carGroups.map { $0.title }
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

七、Cell 不等高

方法一:heightForRowAtIndexPath(固定/简单计算)

实现 tableView:heightForRowAtIndexPath: 代理方法,每行返回不同高度:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    if (indexPath.row % 2 == 0) {
        return 60.0;
    } else {
        return 100.0;
    }
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    indexPath.row % 2 == 0 ? 60 : 100
}

适用于高度规则简单、可直接判断的场景。

方法二:Auto Layout 自动计算(iOS 8+)

iOS 8 起支持根据约束自动计算 Cell 高度,是现代开发的首选。

配置 TableView

self.tableView.estimatedRowHeight = 80.0;          // 估算高度,接近实际平均值即可
self.tableView.rowHeight = UITableViewAutomaticDimension; // 自动计算
tableView.estimatedRowHeight = 80
tableView.rowHeight = .automaticDimension

Cell 约束要求

子视图必须从 contentView 顶部到底部形成完整的约束链,系统才能计算出高度:

// CustomTableViewCell.m
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
        self.titleLabel = [[UILabel alloc] init];
        self.titleLabel.numberOfLines = 0; // 多行
        self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
        [self.contentView addSubview:self.titleLabel];

        self.detailLabel = [[UILabel alloc] init];
        self.detailLabel.numberOfLines = 0;
        self.detailLabel.translatesAutoresizingMaskIntoConstraints = NO;
        [self.contentView addSubview:self.detailLabel];

        [NSLayoutConstraint activateConstraints:@[
            // titleLabel:顶部、左右
            [self.titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10],
            [self.titleLabel.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor constant:15],
            [self.titleLabel.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor constant:-15],
            // detailLabel:在 titleLabel 下方,左右,底部闭合
            [self.detailLabel.topAnchor constraintEqualToAnchor:self.titleLabel.bottomAnchor constant:8],
            [self.detailLabel.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor constant:15],
            [self.detailLabel.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor constant:-15],
            [self.detailLabel.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor constant:-10]
        ]];
    }
    return self;
}

关键:底部必须有一个视图约束到 contentView.bottomAnchor,形成完整的垂直约束链,否则系统无法计算高度。

方法三:预计算高度并缓存(高性能方案)

当高度计算复杂(如大量文本、动态内容)时,在 heightForRowAtIndexPath: 中实时计算会导致滚动卡顿。应提前计算并缓存。

最佳实践:估算 + 精确缓存

@interface ViewController ()
@property (nonatomic, strong) NSMutableDictionary<NSIndexPath *, NSNumber *> *heightCache;
@end

- (void)viewDidLoad {
    [super viewDidLoad];
    self.heightCache = [NSMutableDictionary dictionary];
    self.tableView.estimatedRowHeight = 80.0; // 估算高度,用于滚动条计算
}

// 返回估算高度(快速)
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSNumber *cached = self.heightCache[indexPath];
    return cached ? cached.floatValue : 80.0;
}

// 返回精确高度(计算后缓存)
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSNumber *cached = self.heightCache[indexPath];
    if (cached) {
        return cached.floatValue;
    }

    CGFloat height = [self calculateHeightForIndexPath:indexPath];
    self.heightCache[indexPath] = @(height);
    return height;
}

- (CGFloat)calculateHeightForIndexPath:(NSIndexPath *)indexPath {
    NSString *text = self.dataArray[indexPath.row];
    CGSize maxSize = CGSizeMake([UIScreen mainScreen].bounds.size.width - 30, CGFLOAT_MAX);
    CGRect rect = [text boundingRectWithSize:maxSize
                                     options:NSStringDrawingUsesLineFragmentOrigin
                                  attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:17]}
                                     context:nil];
    return rect.size.height + 20; // 上下各 10pt 内边距
}

多 section 时用 NSIndexPath 作为缓存 key(不要只用 row),因为不同 section 的相同 row 是不同的行。

三种方案对比

方案 实现难度 性能 适用场景
heightForRowAtIndexPath: 高(固定值) 高度规则简单
Auto Layout 自动计算 中(系统计算) 内容动态、约束清晰
预计算 + 缓存 最高 复杂文本、高性能列表

八、总结

  • UITableView 通过 dataSource 获取数据,通过 delegate 处理交互和布局。
  • Cell 重用是高性能的核心:只创建屏幕可见的 Cell,滚动时复用。务必注册 Cell 并使用 dequeueReusableCellWithIdentifier:forIndexPath:
  • 自定义 Cell 时子视图添加到 contentView,重写 prepareForReuse 重置状态。
  • 不等高 Cell 优先使用 Auto Layout 自动计算(UITableViewAutomaticDimension + estimatedRowHeight),复杂场景用预计算缓存方案。
  • setValuesForKeysWithDictionary: 方便但不安全,推荐手动赋值或使用 YYModel / Codable

posted @ 2015-07-19 22:52  Mr.陳  阅读(1572)  评论(0)    收藏  举报