BAT面试的准备—iOS篇
Posted 小敏的博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了BAT面试的准备—iOS篇相关的知识,希望对你有一定的参考价值。
本文主要用于记录在准备BAT面试中关于ios遇到的问题和做一些相关面试题的笔记
iOS网络层设计
1、网络层和业务层的对接设计
-
使用哪种交互模式来和业务层对接 : 使用Delegate为主,目的是为了(1)减少代码的分散度(2)减少业务层和网络层的耦合,网络层对于业务层应该是抽象的,隐藏了实现细节的 (3)只采用一种是限制了灵活性,方便进行维护
-
在网络层不要滥用block :(1)block会延长对象的生命期,delegate则不会
(2)block适合于在每次回调的任务都不一样的情况下,如果一样则应使用delegate,苹果内部的网络层封装为delegate(离散型),AF的网络层封装为block(集约型) -
使用一个reformer对象来封装数据转化逻辑,从而节省了业务层进行字典转模型这样类似的繁琐操作,同时为了解决直接使用字典的可读性差的问题,采用KPropertyStudentID这样的const变量来作为字典的key。
-
使用离散型(delegate)的方式做网络层封装需要使用到继承,使用一个BaseAPIManager作为父类,来处理所有需要集约化的操作(例如一些公用信息),然后让很多子类来做离散化的操作
2、网络层的安全防范
-
防止竞争对手使用自己的API,为自己的API设计一个签名,服务端给出一个密钥,在每次使用API的时候进行一个hash算法的操作,将hash出来的值和服务端hash出来的值进行一个对比,如果一样,则表明是自己在使用API
-
防止中间人攻击,使用较为安全的HTTPS协议,防止运营商在请求中加入广告
MVC模式和MVVM模式的区别
1、MVC模式存在Controller中代码臃肿的问题
之所以会出现MVC模式,是因为发现在开发中会有很多代码可以进行复用,同时事实也正是如此,MVC三个没款中,Model和View的代码确实可以因为MVC模式而进行复用,在github上也有很多开源的项目中封装了很多View,我们可以很方便得使用这些view,model类作为一个数据转化逻辑的类也可以在同一个项目中进行多次复用,但是Controller却很难在一个项目中进行复用,所以我们在写代码的时候尽量在Controller中只写那些无法复用的代码,例如将view添加到controller上,将model的数据传给view等等,但是实际上很难做到这一点,往往有很多代码我们都不知道放在哪里,到了最后便放在了controller里面,导致controller变得十分臃肿。
2、对MVC模式中的Controller进行瘦身
我们可以从下面几点对Controller进行瘦身:
- 将添加view到controller上的代码进行抽取到自定义的UIView中去封装
- 将网络请求抽取出来进行封装
- 将数据获取和数据的转化逻辑抽取出来进行封装
- 构造MVVM模式中的viewModel,其中封装了数据的转化逻辑
3、MVVM模式的认知
- MVVM模式存在双向绑定技术,也就是说viewModel变化,那么model也跟着变化
- 双向绑定会导致难以发现bug出现的位置
- 在大型项目中双向绑定会导致内存消耗过大
4、总结
应该结合MVC和MVVM的各自的优点去让Controller进行瘦身,而不应该盲目地去追求新技术,亦或是过于保守,不愿意向前发展。
iOS中如何设置圆角
1、常规的设置方式带来的性能损耗
使用cornerRadius属性设置圆角是不会产生离屏渲染的,同时也不会真正在UI上产生圆角,这时候我们需要将masksToBounds设置为YES,才能够产生在UI上的圆角效果,但是同时,这样也会导致离屏渲染。产生离屏渲染对于性能上有很大的消耗,将会降低FPS帧数,原因是因为离屏渲染需要将图像的处理放在屏幕之外的内存缓存区进行处理,处理结束之后才把得到的图像放到主屏幕上。在这个过程中产生最大消耗的是两次上下文的交互,将处理放到屏幕之外的缓存区,然后把得到的图像放到主屏幕上。
2、使用不产生离屏渲染的方式来创造圆角
使用贝塞尔曲线UIBezierPath和Core Graphics框架画出一个圆角
UIImageView *imageView = [[UIImageView alloc]initWithFrame:CGRectMake(100, 100, 100, 100)]; imageView.image = [UIImage imageNamed:@"1"]; //开始对imageView进行画图 UIGraphicsBeginImageContextWithOptions(imageView.bounds.size, NO, [UIScreen mainScreen].scale); //使用贝塞尔曲线画出一个圆形图 [[UIBezierPath bezierPathWithRoundedRect:imageView.bounds cornerRadius:imageView.frame.size.width] addClip]; [imageView drawRect:imageView.bounds]; imageView.image = UIGraphicsGetImageFromCurrentImageContext(); //结束画图 UIGraphicsEndImageContext(); [self.view addSubview:imageView];
3、总结
- 如果屏幕中没有很多的圆角的话,那么就采用常规的方式设置即可
- 如果屏幕中存在了大量的圆角的话,那么需要对圆角进行优化,防止离屏渲染
微信中点击头像放大动画的思路
创建一个背景和新的UIImageView,UIImageView是位于背景之上的,先把背景的透明度改为0,然后进行动画,动画的效果是将新的UIImageView从原始的位置(这个位置是原来的UIImageView在新的背景上对应的frame)变化到放大的位置,然后监听背景的点击事件,点击的时候进行透明度和frame的相反变化即可。具体过程我封装好了上传到Github了,点击这里查看。
线上项目出现bug怎么解决
这里将会涉及到JSPatch这个框架的使用,这个框架的作用就是对bug进行热修复..后续更新
iOS开发中有哪些情况会产生循环引用
1、block
2、delegate
3、NSTimer
解决办法:使用一个NSTimer的Catagory,然后重写初始化方法,在实现中利用block,从而在调用的时候可以使用weakSelf在block执行任务
autoreleasePool(加入到autoreleasePool中的对象)在什么时候释放?
- RunLoop启动的时候创建autoreleasePool
- RunLoop结束的时候销毁autoreleasePool
- 当RunLoop进行休眠的时候,将会将之前的autoreasePool销毁,同时创建新的autoreleasePool
iOS中的深浅复制
请查看这篇文章,讲得很深入:iOS剖析深浅复制
iOS中的属性修饰符
列举的顺序就是修饰符在声明的时候的顺序
1、原子性修饰符
- nonatomic:一般对于属性都采用nonatomic来修饰,如果需要保证线程安全,则手动添加代码进行保护
- atomic:默认是atomic,使用该修饰符,系统会对属性进行原子性的保护操作,保证线程安全,但是会有性能损耗
2、读写权限修饰符
- readonly:只读
- readwrite:默认为readwrite修饰
3、内存管理修饰符
- strong:强引用,主要有任何strong类型的指针指向对象,那么就不会被ARC销毁
- copy:主要用于NSString、NSArray、NSDictionary以及block,前者是因为他们都有可变类型,后者是因为在MRC中,使用copy能够将block从栈区拷贝到堆区,在ARC中使用strong和copy效果一样,但是写上copy仿佛在时刻提醒着我们编译器帮我们进行了copy操作
- weak:弱引用:表示定义了一种非拥有关系,如果属性所指向的对象被销毁了,那么属性值也会被清空,设置为nil指针
- assign:对于基本数据类型的修饰,只会单纯进行赋值操作
- unsafe_unretained:由unsafe和unretained组成,unretained和weak相似,unsafe表示他是不安全的,可能引起野指针的出现,导致crash
- retain:ARC中引入了strong和weak,retain效果和strong等同
4、读写方法名修饰符
- setter:修改setter方法的名字
- getter:修改getter方法的名字
iOS中属性内存管理修饰符中的那些CP
-
strong vs copy
self.name = anotherName;
例如上面的代码,使用strong表示的是self.name和anotherName这两个指针同时指向了一个对象,过程是self.name指向了anotherName指向的对象,而如果使用copy的话,self.name和anotherName这两个指针同时指向了不同的对象,过程是copy会将anotherName所指向的对象拷贝一份出来(浅拷贝),然后让self.name指向这个被拷贝出来的对象。
-
strong vs weak
只要存在strong类型修饰的属性(指针)指向了一个对象,那么这个对象就不会被ARC销毁,但是对于weak类型修饰的属性(指针)指向了一个对象,如果这个对象被销毁了,那么这个属性(指针)就会被自动设置为nil。可以说weak类型的指针是没有约束作用的,只是简单弱弱地表示了一下关系。
这里还需要分析在声明控件到底应该使用strong还是weak-
如果是使用storyboard:
-
如果是使用纯代码:
-
-
综上,都应该使用weak去声明控件,纯代码中如果使用了strong去声明控件,那么有一种情况:如果将控件remove了,那么controller中的view里面的subviews所引用的那条线将会被切断,但是strong属性(指针)所引用的这条线依然存在,由于采用的是强引用,所以控件将不会被ARC给销毁,那么就会一直占用内存,直到控制器销毁。
-
-
weak vs assign
weak只能用于对象类型,assign可以用于基本类型,weak比起assign有一点更好,如果weak修饰的属性指向的一个对象被销毁了,那么这个属性将会自动被设置为nil指针,如果assign修饰的属性指向的一个对象被销毁了,那么这个属性不会被自动设置为nil,同时他也不知道所指向的对象已经被销毁了,这样就引发了野指针。 -
weak vs unsafe_unretained
如果weak修饰的属性指向的一个对象被销毁了,那么这个属性将会自动被设置为nil指针,如果unsafe_unretained修饰的属性指向的一个对象被销毁了,那么这个属性不会被自动设置为nil,同时他也不知道所指向的对象已经被销毁了,这样就引发了野指针。 -
assign vs unsafe_unretained
assign能修饰基本类型,unsafe_unretained只能修饰对象类型
iOS中的多线程
1、pthread
基于C语言,不常用
2、NSThread
需要自己手管理线程的生命周期,偶尔使用,例如获取当前线程
[NSThread currentThread];
3、GCD(Grand Central Dispatch)
GCD是苹果开发出来的多核编程的解决方案,虽然是基于C语言的,但是采用了block进行封装,使用起来也很方便,同时也很重要,推荐使用GCD进行多线程编程
4、NSOperation
是苹果对于GCD的封装,效率不及GCD
iOS中的GCD
- 常规使用
主队列:是一个特殊的串行队列,在主线程中运行,用于刷新UI,是一个串行队列
//串行队列
dispatch_queue_t queue = dispatch_get_main_queue;
自定义创建队列: 既可以创建串行队列也可以创建并行队列。
//串行队列
dispatch_queue_t queue = dispatch_queue_create("nineteen", NULL);
dispatch_queue_t queue = dispatch_queue_create("nineteen", DISPATCH_QUEUE_SERIAL);
//并行队列
dispatch_queue_t queue = dispatch_queue_create("nineteen", DISPATCH_QUEUE_CONCURRENT);
全局并行队列:系统提供的并行队列
//并行队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
- 其他使用
循环执行任务:dispatch_apply类似一个for循环,并发地执行每一项。所有任务结束后,dispatch_apply才会返回,会阻塞当前线程(类似同步执行)。
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
/*
*count: 循环执行次数
*queue: 队列,可以是串行队列或者是并行队列(使用串行队列可能导致死锁)
*block: 任务
*/
dispatch_apply(count, queue, ^(size_t i) {
NSLog(@"%zu %@", i, [NSThread currentThread]);
});
队列组:队列组将很多队列添加到一个组里,当组里所有任务都执行完后,它会通过一个方法通知我们。基本流程是首先创建一个队列组,然后把任务添加到组中,最后等待队列组的执行结果。
//创建队列组
dispatch_group_t group = dispatch_group_create();
//创建队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
//并行队列执行3次循环 (队列组只能用异步方法执行)
dispatch_group_async(group, queue, ^{
for (NSInteger i = 0; i < 3; i++) {
NSLog(@"group-01 - %@", [NSThread currentThread]);
}
});
//主队列执行5次循环
dispatch_group_async(group, dispatch_get_main_queue(), ^{
for (NSInteger i = 0; i < 5; i++) {
NSLog(@"group-02 - %@", [NSThread currentThread]);
}
});
//都完成后会自动通知
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
NSLog(@"完成 - %@", [NSThread currentThread]);
});
实现单例模式
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
//dispatch_once中的代码只执行一次,常用来实现单例
});
GCD延迟操作
//创建队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
//设置延时,单位秒
double delay = 3;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), queue, ^{
//3秒后需要执行的任务
});
GCD中的死锁场景
五个案例了解GCD的死锁
1、
案例:
NSLog(@"1"); // 任务1
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"2"); // 任务2
});
NSLog(@"3"); // 任务3
结果:
1
2、
案例:
NSLog(@"1"); // 任务1
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
NSLog(@"2"); // 任务2
});
NSLog(@"3"); // 任务3
结果:
1
2
3
3、
案例:
dispatch_queue_t queue = dispatch_queue_create("com.demo.serialQueue", DISPATCH_QUEUE_SERIAL);
NSLog(@"1"); // 任务1
dispatch_async(queue, ^{
NSLog(@"2"); // 任务2
dispatch_sync(queue, ^{
NSLog(@"3"); // 任务3
});
NSLog(@"4"); // 任务4
});
NSLog(@"5"); // 任务5
结果:
1
5
2
// 5和2的顺序不一定
4、
案例:
NSLog(@"1"); // 任务1
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSLog(@"2"); // 任务2
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"3"); // 任务3
});
NSLog(@"4"); // 任务4
});
NSLog(@"5"); // 任务5
结果:
1
2
5
3
4
// 5和2的顺序不一定
5、
案例:
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSLog(@"1"); // 任务1
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"2"); // 任务2
});
NSLog(@"3"); // 任务3
});
NSLog(@"4"); // 任务4
while (1) {
}
NSLog(@"5"); // 任务5
结果:
1
4
// 1和4的顺序不一定
iOS中的递归锁
如果加锁操作处于一个循环或者递归中,在第一次加锁还没有解锁的时候,就进行了第二次加锁,所以就造成死锁现象,这时候应该使用递归锁来防止死锁的发生。
iOS中的ARC是怎么解决内存管理问题的
ARC会自动处理对象的声明周期,编译的时候在合适的地方插入内存管理代码
ARC中autorelease的使用场景
- 函数返回对象的时候:函数对象作为返回值过了作用域的时候应该被销毁,但是这时候可能还没有被赋值(被强引用),所以需要将该对象添加到自动释放池中延长生命周期。
- _weak修饰属性的时候:_weak修饰的属性所指向的对象可能没有一个强引用来引用他,可能会被销毁,这时候就需要对其使用autorelease方法保证他不被销毁
- id 的指针或对象的指针
iOS中的RunLoop
一般主线程会自动运行RunLoop,我们一般情况下不会去管。在其他子线程中,如果需要我们需要去管理。使用RunLoop后,可以把线程想象成进入了一个循环;如果没有这个循环,子线程完成任务后,这个线程就结束了。所以如果需要一个线程处理各种事件而不让它结束,就需要运行RunLoop。
SDWebImage是怎么使用RunLoop的
- (void)start{
...
self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
...
if(self.connection){
...
CFRunLoopRun( )
...
}
}
- (void)cancelInternalAndStop {
if (self.isFinished) return;
[self cancelInternal];
CFRunLoopStop(CFRunLoopGetCurrent());
}
在创建self.connection成功后,执行了CFRunLoopRun(),开启了runloop。在failed或finished的时候会调用CFRunLoopStop停止runloop。如果不开启runloop的话,在执行完start ()后任务就完成了,NSURLConnection的代理就不会执行了。runloop相当于子线程的循环,可以灵活控制子线程的生命周期。
AFNetworking是怎么使用RunLoop的
AFNetworking解决这个问题采用了另一种方法:单独起一个global thread,内置一个runloop,所有的connection都由这个runloop发起,回调也都由它接收。这是个不错的想法,既不占用主线程,又不耗CPU资源:
iOS中的响应链
- 通过官方文档提供的图来看看事件响应
两种方式,由于设置不同,但大致过程是一样的
- 具体的响应过程
- 发生了触摸事件后,系统会将该事件加入到UIApplication管理的一个队列中
- UIApplication从队列中取出最前面的事件,然后将事件传递下去,处理的顺序大致为UIApplication->AppDelegate->UIWindow->UIViewController->superView->subViews
- 通常来说UIApplication会将事件先交给keyWindow来处理,keyWindow会找到一个最合适的视图来处理这个事件,处理的第一步就从这里开始了
- keyWindow是这样来找到合适视图的:调用hitTest:withEvent方法去寻找能够处理触摸事件的视图,hitTest:withEvent方法会递归地检查view以及view的子类是否包含了触摸点,像这样一直递归下去,找到离用户最近同时包含了触摸点的一个view,然后将触摸事件传递给这个view。在这个过程中是通过PointInside:withEvent:方法来判断是否包含了触摸点的,如果包含了,就返回YES,如果没有包含就返回NO,然后hitTest:withEvent这个方法就返回nil,将不再对该视图的子视图进行判断。
NSRunloop、runloop、autoreleasePool、thread
-
NSRunloop:NSRunloop是一个消息循环,它会检测输入元和定时源,然后做回调处理。NSRunloop封装了windows中的消息处理,将SendMessage、PostMessage、GetMessage等细节封装了起来。关于NSRunloop需要着重了解这几点内容:
- NSRunloop用来监听耗时的异步事件,例如网络回调
- NSRunloop解决了CPU空转问题,当没有任何事件需要处理的时候,NSRunloop会把线程调整为休眠状态,从而消除CPU的周期轮询。
- 每一个线程都有一个NSRunloop,主线程是默认运行的,其他线程默认是没有运行的,需要在NSRunloop中添加一个事件,然后去启动这个线程的runloop。
-
runloop:新建iOS项目的时候会看到在main方法中会手动创建一个autoreleasePool,程序开始时创建,结束时销毁,如果只是从表面上来看的话,那么这样和内存泄露是没有什么区别的。其实,对于每一个runloop,系统会隐式地创建一个autoreleasePool,这样所有的autoreleasePool构成一个栈式的结构,在每一个runloop结束的时候,当前栈顶的autoreleasePool就会被弹出,同时销毁,其中的所有对象也同样被销毁。这里所指的runloop不是NSRunloop,这里的runloop可能是一个UI事件,一个timer等等,具体来说指的是从接受到消息,到处理完这个消息的一个完整过程。
-
autoreleasePool和thread:
thread是不会自动创建autoreleasePool的
drawRect的作用
- drawRect方法的目的是进行UIView的绘制,使用的时候将绘制的具体内容写在drawRect方法里面
- 苹果不建议直接使用drawRect方法,而是调用setNeedsDisplay方法,系统接着会自动调用drawRect进行UIView的绘制
layoutSubviews的作用
- layoutSubviews的作用是对子视图进行重新布局
- 苹果不建议直接使用该方法,而是通过调用setLayoutSubviews,让系统去自动调用layoutSubviews方法进行布局
- 下面列出在什么时候会出发layoutSubviews方法
- 直接调用setLayoutSubviews。(这个在上面苹果官方文档里有说明)
- addSubview的时候。
- 当view的frame发生改变的时候。
- 滑动UIScrollView的时候。
- 旋转Screen会触发父UIView上的layoutSubviews事件。
- 改变一个UIView大小的时候也会触发父UIView上的layoutSubviews事件。
自定义控件
- 自定义控件分为两种,一种是通过xib,另一种是通过纯代码的方式
- 自定义控件需要在两个方法中进行重写,达到开发者想要的效果
- initWithFrame:一般来说是重写这个方法而不是init方法,因为init方法最终也会调用到initWithFrame方法,这个方法主要是对控件以及控件的子控件进行一些初始化的设置(如果是通过xib的话则是重写awakFromNib方法)
- layoutSubviews:这个方法是描述子控件如何布局,也就是赋予子控件在自定义控件中的位置关系,所以说对于子控件的frame的设置代码不应该放在initWithFrame中,而是应该放在layoutSubviews这个方法里面
数据持久化的几种方式的对比
-
Plist文件(属性列表):
plist文件是将某些特定的类,通过XML文件的方式保存在目录中,这些类包括(如果存在对应的可变类也包括可变类)
NSArray
NSDictionary
NSData
NSString
NSNumber
NSDate -
Preference(偏好设置):
- 偏好设置是专门用来保存应用程序的配置信息的
- 如果没有调用synchronize方法,系统会根据I/O情况不定时刻地保存到文件中。所以如果需要立即写入文件的就必须调用synchronize方法。
- 偏好设置会将所有数据保存到同一个文件中。即preference目录下的一个以此应用包名来命名的plist文件。
-
NSKeyedArchiver(归档):
归档在iOS中是另一种形式的序列化,只要遵循了NSCoding协议的对象都可以通过它实现序列化 -
SQLite 3:
之前的所有存储方法,都是覆盖存储。如果想要增加一条数据就必须把整个文件读出来,然后修改数据后再把整个内容覆盖写入文件。所以它们都不适合存储大量的内容,而SQLite 3却能更好进行大量内容的读写操作。 -
CoreData:
苹果封装的本地数据库,一般用于规划应用中的对象
app的状态
- Not running:app还没运行
- Inactive:app运行在foreground但没有接收事件
- Active:app运行在foreground和正在接收事件
- Background:运行在background和正在执行代码
- Suspended:运行在background但没有执行代码
UIView和CALayer的区别
- UIView可以响应事件,而CALayer不行
- 一个 Layer 的 frame 是由它的 anchorPoint,position,bounds,和 transform 共同决定的,而一个 View 的 frame 只是简单的返回 Layer的 frame,同样 View 的 center和 bounds 也是返回 Layer 的一些属性。
- UIView主要是对显示内容的管理而 CALayer 主要侧重显示内容的绘制。
- 在做 iOS 动画的时候,修改非 RootLayer的属性(譬如位置、背景色等)会默认产生隐式动画,而修改UIView则不会。
KVO的实现原理
- KVO是什么:
KVO提供一种机制,指定一个被观察对象(例如A类),当对象某个属性(例如A中的字符串name)发生更改时,对象会获得通知,并作出相应处理 - KVO的原理:
-
NSKVONotifying_A:
在这个过程,被观察对象的 isa 指针从指向原来的A类,被KVO机制修改为指向系统新创建的子类 NSKVONotifying_A类,来实现当前类属性值改变的监听;所以当我们从应用层面上看来,完全没有意识到有新的类出现,这是系统“隐瞒”了对KVO的底层实现过程,让我们误以为还是原来的类。 - 子类setter方法剖析:KVO的键值观察通知依赖于 NSObject 的两个方法:willChangeValueForKey:和 didChangevlueForKey:,在存取数值的前后分别调用2个方法:被观察属性发生改变之前,willChangeValueForKey:被调用,通知系统该 keyPath 的属性值即将变更;当改变发生后, didChangeValueForKey: 被调用,通知系统该 keyPath 的属性值已经变更;之后, observeValueForKey:ofObject:change:context: 也会被调用。
-
NSKVONotifying_A:
以上是关于BAT面试的准备—iOS篇的主要内容,如果未能解决你的问题,请参考以下文章
BAT Java面试完整汇总:面试准备(心态+简历)+面试题目+6条面试经验