GLCaledarView 源码解读 & Glow面经

今天选择了GLCalendarView来进行源码解读,一方面是因为它的实现效果简单清晰,第二方面,是我同学让我把Glow的面经给写了。

Glow面经

可能对于大多数来说,Glow相对来说还算一个比较神秘的公司,它主要致力于基于大数据分析女性健康的app的研发。

好吧,其实我也不是很懂,但是我觉得懂一点也挺好,毕竟我是有女朋友的人。

先说说我为什么会投Glow,两点原因:

  • 主要原因我是想去参加他们的开放日吃东西,然后他们的登记页面有个投简历的选项,我就投了,但是后来开放日那天我被导师拉出去开项目会议了结果就没去成。
  • 他们的团队很厉害,学历上都是清华、复旦、交大、同济(其中交大和复旦排名不分先后,你们别喷我)。 初创团队都是Google出身,董事还是Paypal的创始人之一。

后来9月中下旬,Glow给我安排了一次电面,商量了一个时间,电话如约而至。

电面

电面的内容我不记得了,有一个问题是围绕Core Data展开的,问我多线程之间的Core Data搞过没,我说我搞过。后来说到在多个Context之间传输object的问题,一般情况下都是直接mergeChangeFromNotification就可以了,然后你就可以在private context进行数据获取,当时面试官问的一个问题是你是直接merge完了就使用了,还是[context save]提交到persistentStoreCoordinator以后通过objectID去取呢。当时我说了一个我的做法,后来我再打开我自己代码的时候,发现是因为我的app支持删后保存数据,造成了objectID不一致,所以我当时提出了刚启动的时候一定要拉去objectID,现在想想好像应该是答错了。

现场面试

地狱般的6小时连续面试,累晕了。

第一轮 iOS面试

第一轮是iOS面试,同时来了两位iOS面试官, 先上来让自我介绍下。我每次自我介绍都比较喜欢节约时间,把自己做过的所有app都装在手机上带到现场演示。于是第一轮的很多问题都是穿插着我的项目提问,具体问题我就不说了,因人而异的,所以我就说下我没答上来的知识面:

  1. UITransition
  2. UIView的仿射变换对于界面层级显示的一个效果
  3. 他们自家app中一个动画效果制作方式,我应该是答错了

这一轮给自己打个70分吧。

第二轮 数学

第二轮一上来我以为是产品面,结果是服务器端的一位面试官,先聊了聊自己对于做app有没有什么促进app增长的方式。然后,就开始做一道很奇怪的算法题,为什么说奇怪,是因为这题没有正确答案。然后说完思路以后就在白纸上写下来,我用C++写了。面试官问我为啥不用Objective-C写,我说Objective-C手写代码太长了,顺便我还想证明下我还会别的技术。

这一轮给自己打个80分。

第三轮 英语面试

第三轮是他们的产品的创始人来面我,他一上来问说他中文不好,我选择英语面试还是中文面试,我说的英语没问题,于是我们就开始了至少50分钟了全英语面试。

这一轮可以打个95分。

第四轮 CTO面

面试的时候我不知道他就是CTO,他还带我去外出吃了顿中饭,吃完回来就开始了面试。
这轮一开始先问了我怎么修一些关于多线程的Bug。然后问了我道算法题,就是求矩形相交。当时太诚实了,直接说了我做过这题,然后给出了我自己非投影的解法,于是,悲剧就从这里开始了。

然后CTO就给我出了一道在我看来比较奇特的题目,具体题目保密我不说了,反正就是一开始他出难的,我没答上来,他说你先简化下,做简单版本的。我做出来了,然后又切换成了复杂的,在他的提示下我还是没做出来。

这轮只能打30分。

第五轮 CEO面

CEO进来的时候我还在想上一轮的题目,于是悲剧又来了。我在一边想题目一边和CEO进行了握手,然后就被xx了。然后CEO提了一个让我至今非常难忘的面试题:前面面你的所有面试官他们叫什么?这下是真傻眼了。
可能最后比较好的是我用纯英文跟CEO聊了一段时间,估计挽回了一点分数。

这轮打个50分。

反正面试感觉有点晕,尤其是下午,战线拖的太长了,自己表现的不是很理想。具体技术题目不说了,说出来我认为是对以后面试者的一种不尊重,所以就略过了。总体面试的体验是硅谷风格,英语 + 技术 + 智力。

GLCalendarView 源码解析

先来张效果图

效果还挺酷炫的,这里是它的Github地址

从Github上的介绍来说,这款Calendar和别的同类库之间有个比较大的区别就是可以选择Date Range。从上面的效果我们也可以看出,用户可以通过手势选择一段连续日期。

源码解析

图层分解

既然是个UI的开源库,抛开内部逻辑实现来看,我们首先需要剖析它的图层结构,这样才有助于我们理解整个设计思路。

在看真正的源代码之前,我们首先来猜测下这玩意怎么实现的。从我个人角度出发,我一开始是这么猜测的:

  • 整体的结构应该是个UICollectionView,每个日期都是一个UICollectionViewCell。
  • 每个Cell包含一个圆的背景色(如果是今天),包含一个方形的背景色(连续),左右尽头是两个半圆的,基于这个,猜测在每个日期Cell里面包含一个背景View,填充颜色,设置圆角。
  • 最上面有个悬浮的sunday - saturday的表示星期几的UIView,应该是个独立图层,添加了阴影。国外人为什么喜欢把星期日当成一周的开始呢,真蛋疼。
  • 滚动整个过程中,会出现一个显示对应月份的View,可能是个独立的UIView。
  • 拖动过程中出现的放大镜效果,没猜出来。这个放大镜怎么做的?

带着这些疑问我们下面进入图层分解,来一个个验证我们的猜测正确不正确。

当然,首先我们先大致浏览下结构,整个项目的文件结构大致如下:

-- GLCalendarDateRange.h/.m
-- GLCalendarDayCell
   -- GLCalendarDayCell.h/.m/.xib
   -- GLCalendarDayCellBackgroundCover.h/.m
-- GLCalendarMonthCoverView.h/.m
-- GLCalendarView.h/.m/.xib
-- GLDateUtils.h/.m

GLCalendarDayCell

全局搜索下CollectionViewCell关键字,我们发现,果然在GLCalendarDayCell这个类里面,包含了一个UICollectionView成员。

GLCalendarDayCell采用xib式的图形化开发,只要找到对应的xib文件,对于一个界面的布局很轻松得就了然于胸了.

然后我们打开这个对应的xib文件,发现这个cell的布局包括了:

  • 底层的Cell
  • backgroundCover 的UIView
  • 显示日期的UILabel
  • 显示月份的UILabel

这个类很简单,具体就是做了一大堆细节配置的东西,我们来说一个有意思的地方,在GLCalendarDayCell.h里面,有这个一段定义:

@property (nonatomic, strong) UIColor *evenMonthBackgroundColor UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong) UIColor *oddMonthBackgroundColor UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong) NSDictionary *dayLabelAttributes UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong) NSDictionary *futureDayLabelAttributes UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong) NSDictionary *todayLabelAttributes UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong) NSDictionary *monthLabelAttributes UI_APPEARANCE_SELECTOR;
@property (nonatomic, strong) UIColor *todayBackgroundColor UI_APPEARANCE_SELECTOR;

其中UIAPPEARANCESELECTOR可能对于很多人不熟悉,我们来看看的定义,

#define UI_APPEARANCE_SELECTOR __attribute__((annotate("ui_appearance_selector")))

这个其实是一个宏,那么这个东西的作用是什么呢?是为了全局配置样式!

不知道大家是否还记得我们经常会写[UINavigationBar appearance].tintColor来修改导航栏的颜色,那么这个宏的作用简单来理解就是让你的类拥有可以全局修改样式的能力,如:

[GLCalendarDayCell appearance].todayBackgroundColor = [UIColol redColor];

这个类代码虽然简单,却在实现上有个很好的地方,他把这个Cell的样式(简单来说的就是颜色,边界)交由了下面我们要提到的GLCalendarDayCellBackgroundCover类去做,也就是说,这个Cell仅仅维护状态变更的逻辑,样式有单独的样式类完成绘制。

GLCalendarDayCellBackgroundCover

这个类的作用,无它,就是画。看下他的头文件定义,大家就能瞬间明白:

@interface GLCalendarDayCellBackgroundCover : UIView
@property (nonatomic) RANGE_POSITION rangePosition;
@property (nonatomic) CGFloat paddingLeft;
@property (nonatomic) CGFloat paddingRight;
@property (nonatomic) CGFloat paddingTop;
@property (nonatomic, strong) UIColor *fillColor;
@property (nonatomic, strong) UIColor *strokeColor;
@property (nonatomic, strong) UIImage *backgroundImage;
@property (nonatomic) CGFloat borderWidth;
@property (nonatomic) BOOL inEdit;
@property (nonatomic) BOOL isToday;
@property (nonatomic) BOOL continuousRangeDisplay;
@property (nonatomic) CGFloat pointSize;
@property (nonatomic) CGFloat pointScale;
- (void)enlargeBeginPoint:(BOOL)enlarge;
- (void)enlargeEndPoint:(BOOL)enlarge;
@end

当然,这里要提一点,画圆的几种方法:

  • cornerRadius -> GPU offscreen rendering 低性能
  • CAShaperLayer 当成mask -> GPU offscreen rendering 低性能
  • drawRect + UIBeizerPath -> CPU offerscreen rendering,快,但是内存开销大

这里在实现圆角的时候,就用了第三种:

path = [UIBezierPath bezierPathWithOvalInRect:rect];
[path closePath];
[self.fillColor setFill];
[path fill];

别的也没什么,都是具体的数学求画的位置。以如下代码为例子说明下

if (self.rangePosition == RANGE_POSITION_BEGIN) {
    [path moveToPoint:CGPointMake(radius + borderWidth + paddingLeft, paddingTop + borderWidth)];
    [path addArcWithCenter:CGPointMake(radius + borderWidth + paddingLeft, midY) radius:radius startAngle: - M_PI / 2 endAngle: M_PI / 2 clockwise:NO];

    [path addLineToPoint:CGPointMake(width, height - borderWidth - paddingTop)];
    [path addLineToPoint:CGPointMake(width, borderWidth + paddingTop)];

    [path closePath];
}

RANGEPOSITIONBEGIN表示为左侧开头的cell,所以有个左弧度。画法过程如下:

  • 左弧度的上部顶点作为贝塞尔曲线的初始点
  • 画左弧度
  • 画左弧度下方的一条水平横线
  • 画水平横线右端顶点向上的竖直线
  • 封闭,自动会在竖直线和上部初始顶点间添加一条水平横线。

GLCalendarView

最后来说下对外暴露的类,GLCalendarView。
这个类的实现就是一对控制逻辑,主要说下我特别感兴趣的拖动range的过程中放大镜的实现。

  • 首先这个放大镜,是个中心区域透明的图片。我一开始是纯代码编程的,真蛋疼。
  • 这个放大镜底下偷偷埋了个UIImageView,每次拖动的更新的时候,都自动去截图,把截图放到这个UIImageView里面,就完成了放大镜的效果。

具体的实现如下:

- (void)showMagnifierAboveDate:(NSDate *)date
{
    if (!self.showMagnifier) {
        return;
    }
    GLCalendarDayCell *cell = (GLCalendarDayCell *)[self collectionView:self.collectionView cellForItemAtIndexPath:[self indexPathForDate:date]];
    CGFloat delta = self.cellWidth / 2;
    if (self.draggingBeginDate) {
        delta = delta;
    } else {
        delta = -delta;
    }
    UIGraphicsBeginImageContextWithOptions(self.maginifierContentView.frame.size, YES, 0.0);
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextFillRect(context, self.maginifierContentView.bounds);
    CGContextTranslateCTM(context, -cell.center.x + delta, -cell.center.y);
    CGContextTranslateCTM(context, self.maginifierContentView.frame.size.width / 2, self.maginifierContentView.frame.size.height / 2);
    [self.collectionView.layer renderInContext:context];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();

    self.maginifierContentView.image = image;
    self.magnifierContainer.center = [self convertPoint:CGPointMake(cell.center.x - delta - 58, cell.center.y - 90) fromView:self.collectionView];
    self.magnifierContainer.hidden = NO;
}

主要说下CGContext那块,因为发现好多人还不是特别能理解。

首先,坐标系和UIKit是反过来的!
然后这段代码做了啥呢?

  • 获取当前拖拽日期的对应的Cell。

  • 创建一个画布,画布大小就是放大镜的大小。

    UIGraphicsBeginImageContextWithOptions(self.maginifierContentView.frame.size, YES, 0.0);
    
  • 位移到cell的位置处进行截图,因为是CGContext画图,所以向上是正的,向下负的。

    CGContextTranslateCTM(context, -cell.center.x + delta, -cell.center.y);
    
  • 向上移到放大镜的中心截图,否则因为放大镜的大小是cell的高度,会包含上cell的下半部分,和下cell的上半部分。

    CGContextTranslateCTM(context, self.maginifierContentView.frame.size.width / 2, self.maginifierContentView.frame.size.height / 2);
    

Thread Local 变量

在GLCalendarView里面,有一个比较有亮点的实现:

+ (NSCalendar *)calendar {
    NSMutableDictionary *threadDictionary = [[NSThread currentThread] threadDictionary];

    NSCalendar *cal = [threadDictionary objectForKey:@"GLCalendar"];
    if (!cal) {
        cal = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian];
        cal.locale = [NSLocale currentLocale];
        [threadDictionary setObject:cal forKey:@"GLCalendar"];
    }
    return cal;
}

我们都知道,NSCalendar的初始化是比较耗时的,所以经验上来说一般会构建一个单例子使用。但是在这里可以看到,这里用了threadDictionary去获取thread local variable。相当于在每个使用到这个函数的线程里都构建一个thread local的单例子,那么thread local的好处有啥呢?

  • 线程安全
  • thread local cache,访问快,避免flush cache line。

但是其实我个人不是很理解这么做的优势究竟有多大,请大家指点一下。