iOS 实战技术相关面试题


问题:沙盒


沙盒有四个文件
Documents
Library (其中Library下面有俩文件夹 Caches和Preferences)
SystemData
tmp

(1)Documents 保存由应用程序生成的数据文件,iTunes、iCloud会备份这个目录
(2)Library/Caches 保存从网络下载的资源,iTunes不备份,重新启动不清除
需要提供功能清除缓存!
(3)Library/Preference 保存少量的重要信息,例如用户名,密码等,iTunes会备份
(4)tmp 保存从网络下载的资源或者临时文件,iTunes不备份,重新启动会清除


问题:单例


单例的实现

单例模式的作用
可以保证在程序运行过程,一个类只有一个实例,而且该实例易于供外界访问
从而方便地控制了实例个数,并节约系统资源

单例模式的使用场合
在整个应用程序中,共享一份资源(这份资源只需要创建初始化1次)
单例永驻内存中

ARC中,单例模式的实现

1.在.m中保留一个全局的static的实例
static id _instance;    // static防止外界访问

2.重写allocWithZone:方法,在这里创建唯一的实例(注意线程安全)
//实现此方法不仅保证sharedInstance走这里,同时如果外界是用alloc、init创建也走这里,从而保证了唯一性。
+ (instancetype)allocWithZone:(struct _NSZone *)zone
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _instance = [super allocWithZone:zone];
    });
    return _instance;
}

3.提供1个类方法让外界访问唯一的实例
+ (instancetype)sharedInstance
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _instance = [[self alloc] init];  //[[self alloc]init]内部调的还是第二步
    });
    return _instance;
}

4.实现copyWithZone:方法      //copy里直接返回是因为,能调用copy时,必须先创建一个对象,才能copy
- (id)copyWithZone:(struct _NSZone *)zone
{
    return _instance;
}

单例一定是线程安全的吗

是线程安全的。

利用系统提供的能力来实现,此处保证
^{
_instance = [[self alloc] init];
});
在应用程序的生命周期里只被执行一次;若_instance被外面被手动释放(arc下无法手动release不存在此问题),则会造成崩溃,崩溃原因时野指针访问,系统错误码应为exc_bad_access.因此调用获取单例对象者是不应该释放该单例对象的,这点需要严格遵守。


问题:通知的收发是同步的吗,可以不同线程来收发吗?


答:收和发会在同一个线程,当你用同步线程发,接收执行也会在同步线程;当你用异步线程发,接收执行也会在同一个异步线程(这个线程什么时候销毁咱不能确定,但是在接收通知时这个线程是在的)。

- (void)viewDidLoad {
    [super viewDidLoad];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(passValue:) name:@"KPassValue" object:nil];
}

- (void)passValue:(NSNotification *)text{
    NSString *valueStr = text.userInfo[@"myValue"];
    NSLog(@"收到值:%@",valueStr);
    NSLog(@"收到线程%@",[NSThread currentThread]);
    sleep(3);
    dispatch_async(dispatch_get_main_queue(), ^{
         NSLog(@"回主赋值线程%@",[NSThread currentThread]);
         NSLog(@"通知赋值完毕");
     });
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    NSDictionary *dict = @{@"myValue":@"ZFJ通知传值"};
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSLog(@"异步开始发送线程%@",[NSThread currentThread]);
    [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:@"KPassValue" object:nil userInfo:dict]];
    NSLog(@"发送出去线程%@",[NSThread currentThread]);
    });
}

@end

以上代码打印如下:

2020-06-02 16:25:17.473457+0800 Interview[1242:36122] 异步开始发送线程{number = 3, name = (null)}
2020-06-02 16:25:17.473861+0800 Interview[1242:36122] 收到值:ZFJ通知传值
2020-06-02 16:25:17.474036+0800 Interview[1242:36122] 收到线程{number = 3, name = (null)}
2020-06-02 16:25:20.481620+0800 Interview[1242:36047] 回主赋值线程{number = 1, name = main}
2020-06-02 16:25:20.481619+0800 Interview[1242:36122] 发送出去线程{number = 3, name = (null)}
2020-06-02 16:25:20.481925+0800 Interview[1242:36047] 通知赋值完毕

如果我们换成同步发送

- (void)viewDidLoad {
    [super viewDidLoad];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(passValue:) name:@"KPassValue" object:nil];
}

- (void)passValue:(NSNotification *)text{
    NSString *valueStr = text.userInfo[@"myValue"];
    NSLog(@"收到值:%@",valueStr);
    NSLog(@"收到线程%@",[NSThread currentThread]);
    sleep(3);
    NSLog(@"赋值线程%@",[NSThread currentThread]);
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    NSDictionary *dict = @{@"myValue":@"ZFJ通知传值"};
    NSLog(@"开始发送线程%@",[NSThread currentThread]);
    [[NSNotificationCenter defaultCenter] postNotification:[NSNotification notificationWithName:@"KPassValue" object:nil userInfo:dict]];
    NSLog(@"发送出去线程%@",[NSThread currentThread]);
}

@end

以上代码打印如下:

2020-06-02 16:29:25.146089+0800 Interview[1273:38577] 开始发送线程{number = 1, name = main}
2020-06-02 16:29:25.146351+0800 Interview[1273:38577] 收到值:ZFJ通知传值
2020-06-02 16:29:25.146497+0800 Interview[1273:38577] 收到线程{number = 1, name = main}
2020-06-02 16:29:28.147971+0800 Interview[1273:38577] 赋值线程{number = 1, name = main}
2020-06-02 16:29:28.148369+0800 Interview[1273:38577] 发送出去线程{number = 1, name = main}

问题:以下代码打印什么


    int a = 1;
    __block int b = 2;
    void(^block1)(void) = ^{
        NSLog(@"block1 -- a = %d",a);
    };
    void(^block2)(void) = ^{
        NSLog(@"block2 -- b = %d",b);
    };
    a = 3;
    b = 4;
    void(^block3)(void) = ^{
        NSLog(@"block3 -- a = %d",a);
    };
    void(^block4)(void) = ^{
        NSLog(@"block4 -- b = %d",b);
    };
    block1();
    block2();
    block3();
    block4();
    
2021-02-07 16:03:11.450619+0800 OC_Test[6407:161519] block1 -- a = 1
2021-02-07 16:03:11.450758+0800 OC_Test[6407:161519] block2 -- b = 4
2021-02-07 16:03:11.450895+0800 OC_Test[6407:161519] block3 -- a = 3
2021-02-07 16:03:11.451011+0800 OC_Test[6407:161519] block4 -- b = 4

问题:KVO、KVC


KVO什么时候会崩溃

监听和释放必须成对出现

KVC什么时候会崩溃

setValue:forKey经过三步走后找不到会直接调用setValue:forUndefinedKey:并抛出异常NSUnknownKeyException
只需重写下面的方法即可解决崩溃,对没有找到的key进行处理
-(void)setValue:(id)value forUndefinedKey:(NSString *)key{
}
ValueForKey经过三步走后如果找不到会调用valueForUndefinedKey:
并抛出异常NSUnknownKeyException,只需重写下面的方法即可解决崩溃,对没有找到的key进行处理
-(id)valueForUndefinedKey:(NSString *)key{
return nil;
}


问题:KVO、通知、代理、block区别


KVO

一对多,便捷监听属性变化
缺点:
1.如果属性重构,代码失效
2.观察者释放时,需要移除被观察者

通知

一对多,便捷的广播以及通讯
缺点:
1.在编译期不会检查通知是否可以被观察者正确的处理(观察者就是接受通知方法的对象)
2.在释放注册的对象时,须要在通知中心取消注册
3.在调试的时候应用的工作以及控制过程难跟踪;

代理

一对一,规范的处理两个对象之间的事件响应、通讯
缺点:
1.代码复杂
2.需要注意循环引用问题

Block

Block对于集中的代码块因为集中起来易读具有很大的优势

Block和代理的区别

相同点:block和代理都是回调的方式。使用场景相同。

不同点:

block集中代码块,而代理分散代码块。所以block更适用于轻便、简单的回调,如网络传输。
代理适用于公共接口较多的情况,这样做也更易于解耦代码架构。
block运行成本高。block出栈时,需要将使用的数据从栈内存拷贝到堆内存。当然如果是对象就是加计数,使用完或block置为nil后才消除。
代理只是保存了一个对象指针,直接回调,并没有额外消耗。相对C的函数指针,只是多做了一个查表动作。


问题:MJExtension是怎么实现的


第一步:获取NSObject中的所有属性

 unsigned int propertyCount = 0;
    ///通过运行时获取当前类的属性
    objc_property_t *propertys = class_copyPropertyList([self class], &propertyCount);
    
    //把属性放到数组中
    for (int i = 0; i < propertyCount; i ++) {
    ///取出第一个属性
    objc_property_t property = propertys[i];
    //得到属性对应的名称
    NSString *name = @(property_getName(property));
    
    NSLog(@"name:%@", name);
    }

第二步:在Foundation object(数组、字典等)以name 为key,寻找到对应的value值,然后将对应值填充入相应的Model当中(属性为key,字典里值为value)

- (void)setValue:(id)value forObject:(id)object
{
    if (self.type.KVCDisabled || value == nil) return;
    [object setValue:value forKey:self.name];
}

问题:SDWebimage原理


SDWebImage内部结构

1612841277159.jpg

SDWebImageManger是由一个SDWebImageDownloader(负责下载网络图片),SDImageCache(一个处理缓存的类)共同构成的类
SDWebImage提供了如下三个category来进行缓存。MKAnnotationView + WebCache 地图大头针UIButton + WebCache 给按钮设置图片UIImageView + WebCache imageView的图片

SDWebImage加载图片的流程

1.入口方法
setImageWithURL :placeholderImage: options:
这个方法默认情况下先显示 placeholderImage ,同时由SDWebImageManager根据 URL 来在本地查找图片。
2.进入方法
SDWebImageManager: downloadWithURL:delegate:options:userInfo:
SDWebImageManager是将UIImageView+WebCache同SDImageCache链接起来的类,图片缓存是在内存缓存一份,在磁盘缓存一份.
SDImageCache: queryDiskCacheForKey:delegate:userInfo:
用来(根据CacheKey)查找图片是否已经在内存缓存中
3.如果内存中已经有图片缓存,SDWebImageManager会回调
SDImageCacheDelegate : imageCache:didFindImage:forKey:userInfo:
4.而UIImageView+WebCache则回调
SDWebImageManagerDelegate: webImageManager:didFinishWithImage:前端来显示图片。
5.如果内存中没有图片缓存,那么生成 NSInvocationOperation添加到队列,从硬盘查找图片是否已被下载缓存。
6.根据URLKey 在硬盘缓存目录下尝试读取图片文件。这一步是在 NSOperation 进行的操作,所以回主线程进行结果回调 notifyDelegate:。7.如果上一操作从硬盘读取到了图片,将图片添加到内存缓存中(如果空闲内存过小,会先清空内存缓存)。然后SDImageCacheDelegate回调 
imageCache:didFindImage:forKey:userInfo:。进而回调展示图片。
8.如果从硬盘缓存目录没有读取到图片,说明所有缓存都不存在该图片,需要下载图片,回调 
imageCache:didNotFindImageForKey:userInfo:。

9.共享或重新生成一个下载器 SDWebImageDownloader 开始下载图片。图片下载由NSURLConnection来做,实现相关 delegate 来判断图片下载中、下载完成和下载失败。
10.connection:didReceiveData:中利用 ImageIO 做了按图片下载进度加载效果。connectionDidFinishLoading:数据下载完成后交给 SDWebImageDecoder 做图片解码处理。11.图片解码处理在一个 NSOperationQueue 完成,不会拖慢主线程 UI。如果有需要对下载的图片进行二次处理,最好也在这里完成,效率会好很多。
12.在主线程 notifyDelegateOnMainThreadWithInfo:宣告解码完成,imageDecoder:didFinishDecodingImage:userInfo:回调给 SDWebImageDownloader。imageDownloader:didFinishWithImage: 回调给 SDWebImageManager告知图片下载完成。
13.通知所有的 downloadDelegates 下载完成,回调给需要的地方展示图片。
14.将图片保存到 SDImageCache中,内存缓存和磁盘缓存同时进行保存。写文件到磁盘在单独 NSInvocationOperation中完成,避免拖慢主线程。
15.如果是在iOS上运行,SDImageCache 在初始化的时候会注册notification 到 UIApplicationDidReceiveMemoryWarningNotification以及UIApplicationWillTerminateNotification,在内存警告的时候清理内存图片缓存,应用结束的时候清理过期图片。
16.SDWebImagePrefetcher可以预先下载图片,方便后续使用。

问题:推送原理


1612942654033.jpg
  1. 首先是应用程序注册消息推送;
  2. iOS跟APNS Server要deviceToken。应用程序接受deviceToken;
  3. 应用程序将deviceToken发送给PUSH服务端程序;
  4. 服务端程序向APNS服务发送消息;
  5. APNS服务将消息发送给iPhone应用程序。

注意:用户的 iphone 通过 iOS 的系统方法调用与苹果的 APNs 服务器通信,获取设备的 deviceToken,它是由 APNs 服务分配的用于唯一标识不同设备上的不同 App,可以认为是由 deviceID、bundleId 和安装时的相关信息生成的,App 的升级操作 deviceToken 不变,卸载重装 App、恢复和重装操作系统后的 deviceToken 会发生变化。

为什么要使用第三方推送

因为 Android 平台上的官方推送服务经常处于不可用的状态。所以如果我们使用 Android 平台的官方推送的话,就会使得我们的推送服务非常不安全。因为这个原因,我们只能抛弃 Android 平台的官方服务。那现在只有两条路可以走,一个是自建推送服务,另一个是使用第三方推送服务。自建推送耗时费力,所以通常采用第三方推送,而iOS为了保证和Android统一,所以也通常采用第三方了。


问题:启动优化怎么做


一般情况下,App 的启动分为冷启动和热启动。
冷启动是指, App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。
热启动是指 ,App 在冷启动后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。

总结来说,App 的启动主要包括三个阶段:main() 函数执行前;main() 函数执行后;首屏渲染完成后。

那么main() 函数执行前优化事情:
1.减少动态库加载
2.减少加载启动后不会去使用的类或者方法
3.+load() 方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize() 方法替换掉。

那么main() 函数执行后优化事情:
将首屏初始化所需配置文件的读写操作;首屏列表大数据的读取;首屏渲染的大量计算等分别放到合适的阶段进行。

那么首屏渲染完成后优化事情:
首屏渲染后的这个阶段,主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。从函数上来看,这个阶段指的就是截止到 didFinishLaunchingWithOptions 方法作用域内执行首屏渲染之后的所有方法执行完成。简单说的话,这个阶段就是从渲染完成时开始,到 didFinishLaunchingWithOptions 方法作用域结束时结束。这个阶段用户已经能够看到 App 的首页信息了,所以优化的优先级排在最后。但是,那些会卡住主线程的方法还是需要最优先处理的,不然还是会影响到用户后面的交互操作。

问题:防重点


第一种 使用UIButton的enabled属性, 在点击后, 禁止UIButton的交互, 直到完成指定任务之后再将其enable即可.
- (void)buttonClicked:(UIButton *)sender {
    
    sender.enabled = NO;
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        sender.enabled = YES;
    });
}

方案二 通过NSObject的+cancelPreviousPerformRequestsWithTarget:selector:object:方法和-performSelector:withObject:afterDelay:方法控制按钮的响应事件的执行时间间隔。此方案会在连续点击按钮时取消之前的点击事件,从而只执行最后一次点击事件,会出现延迟现象。
- (void)buttonClicked:(UIButton *)sender {
    
    [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(buttonClickedAction:) object:sender];
    [self performSelector:@selector(buttonClickedAction:) withObject:sender afterDelay:2.0];
}
第三种 使用runtime来对sendAction:to:forEvent:方法进行hook

UIControl的sendAction:to:forEvent:方法用于处理事件响应.
如果我们在该方法的实现中, 添加针对点击事件的时间间隔相关的处理代码, 则能够做到在指定时间间隔中防止重复点击.
首先, 为UIButton添加一个Category:

@interface UIButton (CS_FixMultiClick)

@property (nonatomic, assign) NSTimeInterval cs_acceptEventInterval; // 重复点击的间隔

@property (nonatomic, assign) NSTimeInterval cs_acceptEventTime;

@end
#import "UIControl+CS_FixMultiClick.h"
#import 

@implementation UIButton (CS_FixMultiClick)

// 因category不能添加属性,只能通过关联对象的方式。
static const char *UIControl_acceptEventInterval = "UIControl_acceptEventInterval";

- (NSTimeInterval)cs_acceptEventInterval {
    return  [objc_getAssociatedObject(self, UIControl_acceptEventInterval) doubleValue];
}

- (void)setCs_acceptEventInterval:(NSTimeInterval)cs_acceptEventInterval {
    objc_setAssociatedObject(self, UIControl_acceptEventInterval, @(cs_acceptEventInterval), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

static const char *UIControl_acceptEventTime = "UIControl_acceptEventTime";

- (NSTimeInterval)cs_acceptEventTime {
    return  [objc_getAssociatedObject(self, UIControl_acceptEventTime) doubleValue];
}

- (void)setCs_acceptEventTime:(NSTimeInterval)cs_acceptEventTime {
    objc_setAssociatedObject(self, UIControl_acceptEventTime, @(cs_acceptEventTime), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}


// 在load时执行hook
+ (void)load {
    Method before   = class_getInstanceMethod(self, @selector(sendAction:to:forEvent:));
    Method after    = class_getInstanceMethod(self, @selector(cs_sendAction:to:forEvent:));
    method_exchangeImplementations(before, after);
}

- (void)cs_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    if ([NSDate date].timeIntervalSince1970 - self.cs_acceptEventTime < self.cs_acceptEventInterval) {
        return;
    }

    if (self.cs_acceptEventInterval > 0) {
        self.cs_acceptEventTime = [NSDate date].timeIntervalSince1970;
    }

    [self cs_sendAction:action to:target forEvent:event];
}

@end

如何使用呢?

btn.cs_acceptEventInterval = 1;

这样, 就给UIButton指定了1s的时间间隔用于防止重复点击.


问题:埋点


UIViewController PV统计,页面的统计较为简单,利用Method Swizzing hook 系统的viewDidLoad, 直接通过页面名称即可锁定页面的展示代码如下:

//给UIViewController新建一个分类
#import "UIViewController+S.h"
#import 
@implementation UIViewController (S)
+(void)load{
    Method org_viewdidload = class_getInstanceMethod(self, @selector(viewDidLoad));
    Method new_viewdidload = class_getInstanceMethod(self, @selector(myViewDidLoad));
    method_exchangeImplementations(org_viewdidload, new_viewdidload);
    
}
- (void) myViewDidLoad {
    NSLog(@"[self myViewDidLoad]-----");
    [self myViewDidLoad];
}
@end

UIControl 点击统计,主要通过hook sendAction:to:forEvent: 来实现


问题: == 、isEqual、hash


对于基本类型, ==运算符比较的是值;
对于对象类型, ==运算符比较的是对象的地址

isEqual方法就是用来判断两个对象是否相等,更像是比较对象里所有元素均相等才相等。

UIColor *color1 = [UIColor colorWithRed:0.5 green:0.5 blue:0.5 alpha:1.0];
UIColor *color2 = [UIColor colorWithRed:0.5 green:0.5 blue:0.5 alpha:1.0];
NSArray *a1 = @[@1,@2,@3];
NSArray *a2 = @[@2,@1,@3];

NSLog(@"color1 == color2 = %@", color1 == color2 ? @"YES" : @"NO");
NSLog(@"[color1 isEqual:color2] = %@", [color1 isEqual:color2] ? @"YES" : @"NO");
NSLog(@"%d",[a1 isEqual:a2]);
打印:
2019-06-05 15:49:42.219681+0800 Test[6542:625122] color1 == color2 = NO
2019-06-05 15:49:42.219795+0800 Test[6542:625122] [color1 isEqual:color2] = YES
2019-06-05 15:49:42.219936+0800 Test[6542:625122] 0

实例对象的类对象是类对象,类对象的类对象是元类对象

isKindOfClass
isMemberOfClass
二者都有+方法和—方法

看下底层实现,实现直接在源码公开的

- (BOOL)isMemberOfClass:(Class)cls { //方法调用的左边这个类的类对象是不是刚好等于右边的类对象
    return [self class] == cls;
}

+ (BOOL)isMemberOfClass:(Class)cls { //方法调用的左边这个类对象的元类是不是刚好等于右边的元类
    return object_getClass((id)self) == cls;   //+号开头的方法,self就是类对象,不是实例对象了
}

- (BOOL)isKindOfClass:(Class)cls {  //方法调用的左边这个类的类对象是不是等于右边的类对象,或者是右边类对象的子类,满足返回yes
    for (Class tcls = [self class]; tcls; tcls = tcls->superclass) {
        if (tcls == cls) return YES;
    }
    return NO;
}

+ (BOOL)isKindOfClass:(Class)cls { //方法调用的左边这个类对象的元类是不是等于右边的元类,或者是右边元类的子元类,满足返回yes
    for (Class tcls = object_getClass((id)self); tcls; tcls = tcls->superclass) {
        if (tcls == cls) return YES;
    }
    return NO;
}

简单应用:

#import 

NSObject *obj = [[NSObject alloc]init];
NSLog(@"%d",[obj isMemberOfClass:[NSObject class]]);  //1
NSLog(@"%d",[NSObject isMemberOfClass:object_getClass([NSObject class])]);  //1
NSLog(@"%d",[obj isKindOfClass:[NSObject class]]);   //1
NSLog(@"%d",[NSObject isKindOfClass:object_getClass([NSObject class])]);    //1

结合上面所学知识,看下面两个打印,会发现第三个很奇怪为什么会是1,,不是该传元类吗。因为基类元类对象的superclass指向基类类对象,在遍历到基类时的superclass已经变成类对象了,类对象于是乎等于右边传进来的类对象。

NSLog(@"%d", [MJPerson isMemberOfClass:[MJPerson class]]); //0
NSLog(@"%d", [MJPerson isKindOfClass:[MJPerson class]]); //0   MJPerson的superClass是NSObject类对象
NSLog(@"%d", [MJPerson isKindOfClass:[NSObject class]]); //1

针对为什么第三个是1,结果源码开始分析

+ (BOOL)isKindOfClass:(Class)cls {
    for (Class tcls = object_getClass((id)self); tcls; tcls = tcls->superclass) {
        if (tcls == cls) return YES;
    }
    return NO;
}

首先cls在这里面传进来的是[NSObject class],tcls首先是MJPerson的元类对象,判断不等于,开始去找MJPerson的元类对象的superclass,找到了NSObject的元类对象,还不等于,再找superclass,找成了[NSObject class],此时tcls变成了[NSObject class],于是tcls == cls了,所以打印为1

看下面四道题

NSLog(@"%d", [[NSObject class] isKindOfClass:[NSObject class]]); //1
NSLog(@"%d", [[NSObject class] isMemberOfClass:[NSObject class]]); //0
NSLog(@"%d", [[MJPerson class] isKindOfClass:[MJPerson class]]); //0
NSLog(@"%d", [[MJPerson class] isMemberOfClass:[MJPerson class]]); //0

问题:GCD定时器一定准确吗


GCD的定时器,是依赖于内核,不依赖于RunLoop,因此通常更加准时。但是所有定时器都不能100%准确。

你可能感兴趣的:(iOS 实战技术相关面试题)