iOS探索UITableView的Cell复用原理

简介

在一个偶然的机会看到一位大神讲解UIKitUITableView Cell复用原理。
大神zongmumask地址。
基于开源项目Chameleon来分析UITableViewhacking 源码
阅读完这篇文章后你将会了解 UITableView 的绘制过程和 UITableViewCell 的复用原理。

Chameleon

Chameleon 是一个移植 iOS 的 UIKit 框架到 Mac OS X 下的开源项目。
该项目的目的在于尽可能给出 UIKit 的可替代方案,并且让 Mac OS 的开发者尽可能的开发出类似 iOS 的 UI 界面。

UITableView的简单使用

//创建UITableView对象,并设置代代理和数据源为包含该视图的视图控制器
UITableView *tableView = [[UITableView alloc] initWithFrame:frame style:UITableViewStyleGrouped];   
tableView.delegate = self;
tableView.dataSource = self;
[tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kReuseCellIdentifier];
[self.view addSubview:tableView];

//实现代理和数据源协议中的方法
#pragma mark - UITableViewDelegate

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return kDefaultCellHeight;
}

#pragma mark - UITableViewDataSource

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kReuseCellIdentifier];
    return cell;
}

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

创建UITableView实例对象

UITableView *tableView = [[UITableView alloc] initWithFrame:frame style:UITableViewStyleGrouped];

*initWithFrame: style:方法源码如下:

- (id)initWithFrame:(CGRect)frame style:(UITableViewStyle)theStyle
{
    if ((self=[super initWithFrame:frame])) {
        _style = theStyle;

        //_cachedCells 用于保存正在显示的Cell对象的引用
        _cachedCells = [[NSMutableDictionary alloc] init];

        //在计算完每个 section 包含的 section 头部,尾部视图的高度,和包含的每个 row 的整体高度后,
        //使用 UITableViewSection 对象对这些高度值进行保存,并将该 UITableViewSection 对象的引用
        //保存到 _sections中。在指定完 dataSource 后,至下一次数据源变化调用 reloadData 方法,
        //由于数据源没有变化,section 相关的高度值是不会变化,只需计算一次,所以需要缓存起来。
        _sections = [[NSMutableArray alloc] init];

        //_reusableCells用于保存存在但未显示在界面上的可复用的Cell
        _reusableCells = [[NSMutableSet alloc] init];
        self.separatorColor = [UIColor colorWithRed:.88f green:.88f blue:.88f alpha:1];
        self.separatorStyle = UITableViewCellSeparatorStyleSingleLine;
        self.showsHorizontalScrollIndicator = NO;
        self.allowsSelection = YES;
        self.allowsSelectionDuringEditing = NO;
        self.sectionHeaderHeight = self.sectionFooterHeight = 22;
        self.alwaysBounceVertical = YES;

        if (_style == UITableViewStylePlain) {
            self.backgroundColor = [UIColor whiteColor];
        }
        [self _setNeedsReload];
    }
    return self;
}
  • 这里我们需要关注_cachedCells, _sections, _reusableCells 这三个变量的作用。

设置数据源

tableView.dataSource = self;

下面是 dataSrouce 的 setter 方法源码:

- (void)setDataSource:(id<UITableViewDataSource>)newSource
{
    _dataSource = newSource;

    _dataSourceHas.numberOfSectionsInTableView = [_dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)];
    _dataSourceHas.titleForHeaderInSection = [_dataSource respondsToSelector:@selector(tableView:titleForHeaderInSection:)];
    _dataSourceHas.titleForFooterInSection = [_dataSource respondsToSelector:@selector(tableView:titleForFooterInSection:)];
    _dataSourceHas.commitEditingStyle = [_dataSource respondsToSelector:@selector(tableView:commitEditingStyle:forRowAtIndexPath:)];
    _dataSourceHas.canEditRowAtIndexPath = [_dataSource respondsToSelector:@selector(tableView:canEditRowAtIndexPath:)];

    [self _setNeedsReload];
}

_dataSourceHas 是用于记录该数据源实现了哪些协议方法的结构体,该结构体源码如下:

struct {
        unsigned numberOfSectionsInTableView : 1;
        unsigned titleForHeaderInSection : 1;
        unsigned titleForFooterInSection : 1;
        unsigned commitEditingStyle : 1;
        unsigned canEditRowAtIndexPath : 1;
    } _dataSourceHas;

记录是否实现了某协议可以使用布尔值来表示,布尔变量占用的内存大小一般为一个字节,即8比特。
但该结构体使用了 bitfields 用一个比特(0或1)来记录是否实现了某协议,大大缩小了占用的内存。
在设置好了数据源后需要打一个标记,告诉NSRunLoop数据源已经设置好了,需要在下一次循环中使用数据源进行布局。
下面看看 _setNeedReload 的源码:

- (void)_setNeedsReload
{
    _needsReload = YES;
    [self setNeedsLayout];
}

在调用了 setNeedsLayout 方法后,NSRunloop 会在下一次循环中自动调用 layoutSubViews 方法。

  • 视图的内容需要重绘时可以调用 setNeedsDisplay 方法,该方法会设置该视图的 displayIfNeeded 变量为 YES ,NSRunLoop 在下一次循环检中测到该值为 YES 则会自动调用 drawRect 进行重绘。
  • 视图的内容没有变化,但在父视图中位置变化了可以调用 setNeedsLayout,该方法会设置该视图的 layoutIfNeeded 变量为YES,NSRunLoop 在下一次循环检中测到该值为 YES 则会自动调用 layoutSubViews 进行重绘。

设置代理

tableView.delegate = self;

下面是 delegate 的 setter 方法源码:

- (void)setDelegate:(id<UITableViewDelegate>)newDelegate
{
    [super setDelegate:newDelegate];
    _delegateHas.heightForRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:heightForRowAtIndexPath:)];
    _delegateHas.heightForHeaderInSection = [newDelegate respondsToSelector:@selector(tableView:heightForHeaderInSection:)];
    _delegateHas.heightForFooterInSection = [newDelegate respondsToSelector:@selector(tableView:heightForFooterInSection:)];
    _delegateHas.viewForHeaderInSection = [newDelegate respondsToSelector:@selector(tableView:viewForHeaderInSection:)];
    _delegateHas.viewForFooterInSection = [newDelegate respondsToSelector:@selector(tableView:viewForFooterInSection:)];
    _delegateHas.willSelectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:willSelectRowAtIndexPath:)];
    _delegateHas.didSelectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:didSelectRowAtIndexPath:)];
    _delegateHas.willDeselectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:willDeselectRowAtIndexPath:)];
    _delegateHas.didDeselectRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:didDeselectRowAtIndexPath:)];
    _delegateHas.willBeginEditingRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:willBeginEditingRowAtIndexPath:)];
    _delegateHas.didEndEditingRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:didEndEditingRowAtIndexPath:)];
    _delegateHas.titleForDeleteConfirmationButtonForRowAtIndexPath = [newDelegate respondsToSelector:@selector(tableView:titleForDeleteConfirmationButtonForRowAtIndexPath:)];
}

与设置数据源一样,这里使用了类似的结构体来记录代理实现了哪些协议方法。

UITableView绘制

由于在设置数据源中调用了 setNeedsLayout 方法打上了需要布局的 flag,所以会在 1/60 秒(NSRunLoop的循环周期)后自动调用 layoutSubViewsl源码如下:

- (void)layoutSubviews
{
    //对子视图进行布局,该方法会在第一次设置数据源调用 setNeedsLayout 方法后自动调用。
    //并且 UITableView 是继承自 UIScrollview ,当滚动时也会触发该方法的调用
    _backgroundView.frame = self.bounds;

    //在进行布局前必须确保 section 已经缓存了所有高度相关的信息
    [self _reloadDataIfNeeded];    

    //对 UITableView 的 section 进行布局,包含 section 的头部,尾部,每一行 Cell
    [self _layoutTableView];

    //对 UITableView 的头视图,尾视图进行布局
    [super layoutSubviews];
}

需要注意的是由于 UITableView 是继承于 UIScrollView,所以在 UITableView 滚动时会自动调用该方法。主要看里面三个实现方法

[self _reloadDataIfNeeded]源码如下:

- (void)_reloadDataIfNeeded
{
   if (_needsReload) {
       [self reloadData];
   }
}

- (void)reloadData
{
   //当数据源更新后,需要将所有显示的UITableViewCell和未显示可复用的UITableViewCell全部从父视图移除,
   //重新创建
   [[_cachedCells allValues] makeObjectsPerformSelector:@selector(removeFromSuperview)];
   [_reusableCells makeObjectsPerformSelector:@selector(removeFromSuperview)];
   [_reusableCells removeAllObjects];
   [_cachedCells removeAllObjects];

   _selectedRow = nil;
   _highlightedRow = nil;

   // 重新计算 section 相关的高度值,并缓存起来
   [self _updateSectionsCache];
   [self _setContentSize];

   _needsReload = NO;
}

其中 [self _updateSectionsCache]方法是最重要的,该方法在数据源更新后至下一次数据源更新期间只能调用一次,该方法的源码如下:

- (void)_updateSectionsCache
{
    //该逆向源码只复用了 section 中的每个 UITableViewCell,并没有复用每个 section 的头视图和尾视图,
    //UIKit肯定是实现了所有视图的复用
    // remove all previous section header/footer views
    for (UITableViewSection *previousSectionRecord in _sections) {
        [previousSectionRecord.headerView removeFromSuperview];
        [previousSectionRecord.footerView removeFromSuperview];
    }

    // clear the previous cache
    [_sections removeAllObjects];

    //如果数据源为空,不做任何处理
    if (_dataSource) {
        // compute the heights/offsets of everything
        const CGFloat defaultRowHeight = _rowHeight ?: _UITableViewDefaultRowHeight;
        const NSInteger numberOfSections = [self numberOfSections];
        for (NSInteger section=0; section<numberOfSections; section++) {
            const NSInteger numberOfRowsInSection = [self numberOfRowsInSection:section];

            UITableViewSection *sectionRecord = [[UITableViewSection alloc] init];
            sectionRecord.headerTitle = _dataSourceHas.titleForHeaderInSection? [self.dataSource tableView:self titleForHeaderInSection:section] : nil;
            sectionRecord.footerTitle = _dataSourceHas.titleForFooterInSection? [self.dataSource tableView:self titleForFooterInSection:section] : nil;

            sectionRecord.headerHeight = _delegateHas.heightForHeaderInSection? [self.delegate tableView:self heightForHeaderInSection:section] : _sectionHeaderHeight;
            sectionRecord.footerHeight = _delegateHas.heightForFooterInSection ? [self.delegate tableView:self heightForFooterInSection:section] : _sectionFooterHeight;

            sectionRecord.headerView = (sectionRecord.headerHeight > 0 && _delegateHas.viewForHeaderInSection)? [self.delegate tableView:self viewForHeaderInSection:section] : nil;
            sectionRecord.footerView = (sectionRecord.footerHeight > 0 && _delegateHas.viewForFooterInSection)? [self.delegate tableView:self viewForFooterInSection:section] : nil;

            // make a default section header view if there's a title for it and no overriding view
            if (!sectionRecord.headerView && sectionRecord.headerHeight > 0 && sectionRecord.headerTitle) {
                sectionRecord.headerView = [UITableViewSectionLabel sectionLabelWithTitle:sectionRecord.headerTitle];
            }

            // make a default section footer view if there's a title for it and no overriding view
            if (!sectionRecord.footerView && sectionRecord.footerHeight > 0 && sectionRecord.footerTitle) {
                sectionRecord.footerView = [UITableViewSectionLabel sectionLabelWithTitle:sectionRecord.footerTitle];
            }

            if (sectionRecord.headerView) {
                [self addSubview:sectionRecord.headerView];
            } else {
                sectionRecord.headerHeight = 0;
            }

            if (sectionRecord.footerView) {
                [self addSubview:sectionRecord.footerView];
            } else {
                sectionRecord.footerHeight = 0;
            }

            //section 中每个 row 的高度使用了数组指针来保存
            CGFloat *rowHeights = malloc(numberOfRowsInSection * sizeof(CGFloat));
            CGFloat totalRowsHeight = 0;

            //每行 row 的高度通过数据源实现的协议方法 heightForRowAtIndexPath: 返回,
            //若数据源没有实现该协议方法则使用默认的高度
            for (NSInteger row=0; row<numberOfRowsInSection; row++) {
                const CGFloat rowHeight = _delegateHas.heightForRowAtIndexPath? [self.delegate tableView:self heightForRowAtIndexPath:[NSIndexPath indexPathForRow:row inSection:section]] : defaultRowHeight;
                rowHeights[row] = rowHeight;
                totalRowsHeight += rowHeight;
            }

            sectionRecord.rowsHeight = totalRowsHeight;
            [sectionRecord setNumberOfRows:numberOfRowsInSection withHeights:rowHeights];
            free(rowHeights);

            //将所有高度信息缓存起来
            [_sections addObject:sectionRecord];
        }
    }
}

[self _layoutTableView]源码如下:

- (void)_layoutTableView
{
    //这里实现了 UITableViewCell 的复用
    const CGSize boundsSize = self.bounds.size;
    const CGFloat contentOffset = self.contentOffset.y;

    //由于 UITableView 继承于 UIScrollview,所以通过滚动偏移量得到当前可视的 bounds
    const CGRect visibleBounds = CGRectMake(0,contentOffset,boundsSize.width,boundsSize.height);
    CGFloat tableHeight = 0;

    //若有头部视图,则计算头部视图在父视图中的 frame
    if (_tableHeaderView) {
        CGRect tableHeaderFrame = _tableHeaderView.frame;
        tableHeaderFrame.origin = CGPointZero;
        tableHeaderFrame.size.width = boundsSize.width;
        _tableHeaderView.frame = tableHeaderFrame;
        tableHeight += tableHeaderFrame.size.height;
    }

    //_cashedCells 用于记录正在显示的 UITableViewCell 的引用
    //avaliableCells 用于记录当前正在显示但在滚动后不再显示的 UITableViewCell(该 Cell 可以复用)
    //在滚动后将该字典中的所有数据都添加到 _reusableCells 中,
    //记录下所有当前在可视但由于滚动而变得不再可视的 Cell 的引用
    NSMutableDictionary *availableCells = [_cachedCells mutableCopy];
    const NSInteger numberOfSections = [_sections count];
    [_cachedCells removeAllObjects];

    for (NSInteger section=0; section<numberOfSections; section++) {
        CGRect sectionRect = [self rectForSection:section];
        tableHeight += sectionRect.size.height;
        //CGRectIntersectsRect 方法用于判断两个 rect 是否有相交,只处理在当前可视 bounds 内的 section
        if (CGRectIntersectsRect(sectionRect, visibleBounds)) {
            const CGRect headerRect = [self rectForHeaderInSection:section];
            const CGRect footerRect = [self rectForFooterInSection:section];
            UITableViewSection *sectionRecord = [_sections objectAtIndex:section];
            const NSInteger numberOfRows = sectionRecord.numberOfRows;

            if (sectionRecord.headerView) {
                sectionRecord.headerView.frame = headerRect;
            }

            if (sectionRecord.footerView) {
                sectionRecord.footerView.frame = footerRect;
            }

            for (NSInteger row=0; row<numberOfRows; row++) {
                NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
                CGRect rowRect = [self rectForRowAtIndexPath:indexPath];
                //只处理在当前可视 bounds 内的 row
                if (CGRectIntersectsRect(rowRect,visibleBounds) && rowRect.size.height > 0) {
                //在滚动时,如果向上滚动,除去顶部要隐藏的 Cell 和底部要显示的 Cell,中部的 Cell 都可以
                //根据 indexPath 直接获取
                    UITableViewCell *cell = [availableCells objectForKey:indexPath] ?: [self.dataSource tableView:self cellForRowAtIndexPath:indexPath];
                    if (cell) {
                        [_cachedCells setObject:cell forKey:indexPath];

                        //将当前仍留在可视区域的 Cell 从 availableCells 中移除,
                        //availableCells 中剩下的即为顶部已经隐藏的 Cell
                        //后面会将该 Cell 加入 _reusableCells 中以便下次取出进行复用。
                        [availableCells removeObjectForKey:indexPath];

                        cell.highlighted = [_highlightedRow isEqual:indexPath];
                        cell.selected = [_selectedRow isEqual:indexPath];
                        cell.frame = rowRect;
                        cell.backgroundColor = self.backgroundColor;
                        [cell _setSeparatorStyle:_separatorStyle color:_separatorColor];
                        [self addSubview:cell];
                    }
                }
            }
        }
    }

    //把所有因滚动而不再可视的 Cell 从父视图移除并加入 _reusableCells 中,以便下次取出复用
    for (UITableViewCell *cell in [availableCells allValues]) {
        if (cell.reuseIdentifier) {
            [_reusableCells addObject:cell];
        } else {
            [cell removeFromSuperview];
        }
    }

    //把仍在可视区域的 Cell(但不应该在父视图上显示) 但已经被回收至可复用的 _reusableCells 中的 Cell从父视图移除
    NSArray* allCachedCells = [_cachedCells allValues];
    for (UITableViewCell *cell in _reusableCells) {
        if (CGRectIntersectsRect(cell.frame,visibleBounds) && ![allCachedCells containsObject: cell]) {
            [cell removeFromSuperview];
        }
    }

    if (_tableFooterView) {
        CGRect tableFooterFrame = _tableFooterView.frame;
        tableFooterFrame.origin = CGPointMake(0,tableHeight);
        tableFooterFrame.size.width = boundsSize.width;
        _tableFooterView.frame = tableFooterFrame;
    }
}

这里使用了三个容器 _cachedCells, availableCells, _reusableCells 完成了 Cell 的复用,这是 UITableView 最核心的地方。
下面一起看看三个容器在创建到滚动整个过程中所包含的元素的变化情况。
在第一次设置了数据源调用该方法时,三个容器的内容都为空,在调用完该方法后 _cachedCells 包含了当前所有可视 Cell 与其对应的indexPath 的键值对,availableCells 与 _reusableCells 仍然为空。只有在滚动起来后 _reusableCells 中才会出现多余的未显示可复用的 Cell。

  • 刚创建 UITableView 时的状态如下图(红色为屏幕内容即可视区域,蓝色为超出屏幕的内容,即不可视区域):


    初始状态.jpg

    如图,当前 _cachedCells 的元素为当前可视的所有 Cell 与其对应的 indexPath 的键值对。

  • 向上滚动一个 Cell 的过程中,由于 availableCells 为 _cachedCells 的拷贝,所以可根据 indexPath 直接取到对应的 Cell,这时从底部滚上来的第7行,由于之前的 _reusableCells 为空,所以该 Cell 是直接创建的而并非复用的,由于顶部 Cell 滚动出了可视区域,所以被加入了 _reusableCells 中以便后续滚动复用。滚动完一行后的状态变为了 _cachedCells 包含第 2 行到第 7 行 Cell 的引用,_reusableCells 包含第一行 之前滚动出可视区域的第一行 Cell 的引用。


    向上滚动1个cell.jpg
  • 当向上滚动两个 Cell 的过程中,同理第 3 行到第 7 行的 Cell 可以通过对应的 indexPath 从 _cachedCells 中获取。这时 _reusableCells 中正好有一个可以复用的 Cell 用来从底部滚动上来的第 8 行。滚动出顶部的第 2 行 Cell 被加入 _reusableCells 中。


    向上滚动2个cell

总结

通过了解UITableView Cell复用原理,可以让自己在实际开发中借鉴这样的思路自定义类似UITableView高效控件。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容

  • 简介 在我们的日常开发中,绝大多数情况下只要详细阅读类头文件里的注释,组合UIKit框架里的大量控件就能很好的满足...
    zongmumask阅读 13,483评论 9 109
  • *面试心声:其实这些题本人都没怎么背,但是在上海 两周半 面了大约10家 收到差不多3个offer,总结起来就是把...
    Dove_iOS阅读 27,121评论 29 470
  • 概述在iOS开发中UITableView可以说是使用最广泛的控件,我们平时使用的软件中到处都可以看到它的影子,类似...
    liudhkk阅读 8,982评论 3 38
  • 1 东华禅动功 20分钟 2次 2 跺脚 81下 2次 3 拍打眼部 81下 2次 4 辣木籽 早中晚 5颗 随笔...
    永勉阅读 286评论 0 0
  • 看罢西湖想东湖, 杭州没有白鹭洲。 雷锋塔前留个影, 不及挤身黄鹤楼。 闲寂寞,苦旅游。 为留青春強作秀。 天南地...
    曹焕甫阅读 341评论 0 0