ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

货拉拉iOS笔试题复盘:并发、内存与地图定位实战考点全解析

货拉拉iOS笔试题复盘:并发、内存与地图定位实战考点全解析 2018年秋招那会儿移动端岗位已经卷得厉害。货拉拉的iOS笔试题当时分了三套卷子卷三B我完整做了一遍最大的感受是这套题不是在考你会不会背Objective-C的API而是在考你“能不能在一个以地图定位、订单流转、消息推送为核心业务的App里把基础能力用对”。整卷没有太偏门的题目但几乎每道题都贴着真实业务场景往下挖挖到不少人的知识盲区。无论你是正在准备iOS面试还是想了解货运物流类App到底关心什么技术点这份复盘都值得花二十分钟读完。下面我按卷子的考察维度拆开讲包含原题场景还原、解题思路以及站在改卷人角度的评分参考。1. 并发扣款那道改错题atomic为什么不是万能钥匙1.1 原题场景还原试卷第一道大题是个典型的并发改错场景是司机账户余额扣款。原题大概是这样的interface BankAccount : NSObject property (nonatomic, assign) double balance; - (void)withdraw:(double)amount; end implementation BankAccount - (void)withdraw:(double)amount { if (self.balance amount) { [NSThread sleepForTimeInterval:0.1]; self.balance - amount; } } end题目要求找出线程安全隐患并给出修正方案。先看代码本身的问题。balance声明成了nonatomic这在多线程读写下本身就是数据竞争。但更隐蔽的是if (self.balance amount)和self.balance - amount这两步操作中间隔了一个sleep。这个sleep是出题人故意埋的雷它把“判断余额足够”和“扣款”这两件事拆成了两个时间窗口。即便把balance改成atomic也只是保证单次getter/setter的原子性绝不保证“先检查再扣款”这个复合操作是原子的。两个线程同时读到余额100元都判断可以扣80元结果余额变成-60元这在真实业务里就是事故。1.2 两种正解与使用场景修正思路有很多关键是要区分场景。如果业务是简单的低频操作用锁就行如果是读多写少的账户余额场景更推荐用多读单写模型。先看一种最直白的方案串行队列同步执行。implementation BankAccount { dispatch_queue_t _syncQueue; } - (instancetype)init { if (self [super init]) { _syncQueue dispatch_queue_create(bank.sync, DISPATCH_QUEUE_SERIAL); _balance 0.0; } return self; } - (double)balance { __block double result; dispatch_sync(_syncQueue, ^{ result _balance; }); return result; } - (void)withdraw:(double)amount { dispatch_sync(_syncQueue, ^{ if (_balance amount) { _balance - amount; } }); } end用串行队列把所有对余额的读写都收口到同一队列里天然串行不需要额外加锁。缺点是读操作也会互斥读多写少时性能不够好。因此更好的选择是并发队列加栅栏实现真正意义上的多读单写implementation BankAccount { dispatch_queue_t _concurrentQueue; } - (instancetype)init { if (self [super init]) { _concurrentQueue dispatch_queue_create(bank.concurrent, DISPATCH_QUEUE_CONCURRENT); _balance 0.0; } return self; } - (double)balance { __block double result; dispatch_sync(_concurrentQueue, ^{ result _balance; }); return result; } - (void)withdraw:(double)amount { dispatch_barrier_async(_concurrentQueue, ^{ if (_balance amount) { _balance - amount; } }); } end读操作走dispatch_sync进并发队列多个读可以同时执行写操作通过dispatch_barrier_async放进队列barrier前后的读操作可以并行但barrier块执行时队列里其他任务都会被阻塞保证同一时刻只有一个线程在扣款。这是GCD时代比较经典的读写锁替代写法。如果项目最低支持iOS 10也可以直接用os_unfair_lock#import os/lock.h static os_unfair_lock lock OS_UNFAIR_LOCK_INIT; - (void)withdraw:(double)amount { os_unfair_lock_lock(lock); if (_balance amount) { _balance - amount; } os_unfair_lock_unlock(lock); }关于几种同步方案的选型我直接列个对比表笔试时能写出这个表基本就是满分方案读读并发读写并发适合场景synchronized不并发不并发简单低频保护代码最直观NSLock不并发不并发短临界区性能尚可dispatch_barrier并发写独占读多写少数据量大pthread_rwlock_t并发写独占需要处理递归读的复杂场景os_unfair_lock不并发不并发iOS 10首选的低级锁1.3 改卷时最容易出现的“半对答案”这道题我如果改卷重点看三个层次。第一层能不能发现nonatomic本身的问题这属于基础分第二层能不能看出“检查余额再扣款”不是原子操作这是核心分第三层能不能根据不同并发场景给出不同的锁或队列方案这是加分项。实际情况是大量人只写“加个互斥锁”但说不清锁的粒度该放在哪里。还有一部分人把属性改成atomic就不管了这就是典型的知识盲区——atomic只能保证单次读写的原子性无法保护“读改写”这种复合操作。另外有个细节如果用synchronized(self)锁对象应该是稳定的尽量不要用self以外随时可能重新赋值的对象。我在实际项目里遇到的坑比这还多一层很多场景下光锁住方法不够因为多个方法会形成组合操作。比如“扣款”和“退款”是两个独立方法如果业务要求这两个操作不能同时执行锁就必须加在同一个队列或同一把锁上否则各锁各的依然会出问题。2. Block、Timer和单例组成的内存食物链循环引用排查实录2.1 原题场景还原卷二也好、卷三也好循环引用的题几乎是iOS笔试必出。货拉拉这套卷子问的是NSTimer场景implementation OrderTimerViewController - (void)viewDidLoad { [super viewDidLoad]; self.timer [NSTimer scheduledTimerWithTimeInterval:5.0 target:self selector:selector(checkOrderStatus:) userInfo:nil repeats:YES]; } - (void)checkOrderStatus:(NSTimer *)timer { // 这里会发起网络请求检查订单状态 } - (void)dealloc { NSLog(OrderTimerViewController dealloc called); } end这个类如果被pop掉dealloc不会执行。原因很明确NSTimer的target对self是强引用而self又持有timer属性形成self - timer - self的循环引用。而且NSTimer还会被加入当前RunLoop除非调invalidate否则timer本身也不会被释放。更进阶的问题是很多人以为iOS 10之后改用block版本的NSTimer就没事了实际不是。block版本的初始化和上面的target:selector:一样block内部如果直接调用self的方法block会捕获self依然形成timer - block - self的引用链。2.2 解法与完整修复正确解法是配合weakSelf和strongSelf使用- (void)viewDidLoad { [super viewDidLoad]; __weak typeof(self) weakSelf self; self.timer [NSTimer scheduledTimerWithTimeInterval:5.0 repeats:YES block:^(NSTimer *timer) { __strong typeof(weakSelf) strongSelf weakSelf; [strongSelf checkOrderStatus:timer]; }]; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; if (_timer) { [_timer invalidate]; _timer nil; } } - (void)dealloc { [_timer invalidate]; NSLog(OrderTimerViewController dealloc called); }这里有个细节block内部只写[self checkOrderStatus]就是错误写法写[weakSelf checkOrderStatus]也有讲究如果只用weakSelf当block执行到一半时对象被释放weakSelf变成nil后续代码可能不会执行。所以标准写法是先用__strong typeof(weakSelf) strongSelf weakSelf;锁住对象生命周期再用strongSelf执行。笔试能写出这个细节说明真的踩过坑。另一种更彻底的方案是用GCD定时器替代NSTimerproperty (nonatomic, strong) dispatch_source_t timer; - (void)startTimer { dispatch_queue_t queue dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); self.timer dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); uint64_t interval 5.0 * NSEC_PER_SEC; dispatch_source_set_timer(self.timer, dispatch_walltime(NULL, 0), interval, 0); __weak typeof(self) weakSelf self; dispatch_source_set_event_handler(self.timer, ^{ __strong typeof(weakSelf) strongSelf weakSelf; [strongSelf checkOrderStatus]; }); dispatch_resume(self.timer); } - (void)stopTimer { if (_timer) { dispatch_source_cancel(_timer); _timer nil; } }GCD定时器不持有target天然不存在循环引用而且它不依赖RunLoop子线程也能稳定执行。代价是需要自己管理状态和清理比NSTimer多了几行代码。2.3 三个高频追问原题做完面试官一般还会追问三个问题。第一delegate为什么用weak因为delegate对象通常是持有者如果delegate是strong就会形成持有者持有子对象、子对象又持有持有者的循环weak只是弱引用关系不会增加引用计数。第二dispatch_after会不会造成循环引用dispatch_after的block在指定时间后执行完就释放了block对self是临时持有不构成长期引用环但block执行过程中self还是会因为这个临时引用多活一会儿。有些面试官会追问“那会不会延迟释放”回答“会但不会永久持有”即可。第三NSTimer和RunLoop的关系。主线程的RunLoop是常驻的所以[NSTimer scheduledTimerWithTimeInterval:...]在主线程里能直接跑。但如果在子线程创建timer需要先启动子线程的RunLoop还要把timer手动加到当前RunLoop并设置NSRunLoopCommonModes否则滚动页面或切换模式时定时器可能被暂停。这个坑在真机调试时非常隐蔽我见过不少人把定时器放后台线程结果功能时好时坏最后查下来就是RunLoop mode的问题。还有一个在改卷中经常被忽视的点题目如果把OrderTimerViewController换成一个Singleton那么“循环引用”的分析角度就不同了。单例本身常驻内存它持有timertimer强引用单例这不算严格意义的泄漏因为单例本来就要活到App结束但timer会一直跑持续发起网络请求浪费电和流量。这种情况要单独设计start/stop机制而不是只讨论weakSelf。3. 偏航检测一道把地图、定位、后台省电全串起来的综合题3.1 偏航判定的两种技术路线货拉拉这类货运App核心业务离不开“货车当前位置”和“是否按规划路线行驶”。卷三B有一道题直接要求设计偏航检测方案司机接单后App按规划路线导航司机途中拐进小路、掉头或偏离路线太远App需要及时提示“您已偏航正在重新规划路线”。大多数人第一反应是“用地图SDK的回调”。高德和百度确实提供偏航回调但做工程不能只靠SDK因为有些场景SDK的偏航触发条件不一定符合业务要求。所以我把这类题拆成两条技术路线一是直接复用地图SDK的AMapNaviManager或BMKNavi的偏航回调适合快速上线二是自研判定适合对偏航阈值有精细要求的场景。自研判定的核心思路是整个路线由一系列坐标点组成把这些点按顺序连成若干条线段每次取当前GPS坐标计算它到所有线段的最短距离取最小值与阈值比较。点到线段的距离公式并不复杂function distancePointToSegment(p, a, b): ab b - a ap p - a t clamp(dot(ap, ab) / dot(ab, ab), 0, 1) nearest a t * ab return distance(p, nearest)其中dot(ap, ab) / dot(ab, ab)是投影比例clamp到0到1之间保证最近点在线段内而不是线段延长线上。这个算法每个GPS点要遍历整条路线的所有线段几千个点的路线会比较慢实际工程中可以先做粗筛把路线按矩形分块建立索引只计算当前GPS点附近几个块内的线段。阈值怎么定城市道路GPS误差一般在10到30米高架桥下或楼宇密集区可能更大我一般建议城市道路判定偏航阈值为50米高速或快速路放宽到80到100米。还要加一个“连续判定”逻辑单次超阈值可能是GPS跳点连续3个点每隔1到2秒采集一次都超阈值才触发偏航否则会出现司机明明走得好好的突然被提示偏航的尴尬情况。3.2 定位参数与后台运行配置定位这块是工程细节的重灾区。正确配置如下self.locationManager [[CLLocationManager alloc] init]; self.locationManager.delegate self; if ([self.locationManager respondsToSelector:selector(requestAlwaysAuthorization)]) { [self.locationManager requestAlwaysAuthorization]; } self.locationManager.desiredAccuracy kCLLocationAccuracyBestForNavigation; self.locationManager.distanceFilter kCLDistanceFilterNone; self.locationManager.pausesLocationUpdatesAutomatically NO; self.locationManager.allowsBackgroundLocationUpdates YES; [self.locationManager startUpdatingLocation];desiredAccuracy设置为kCLLocationAccuracyBestForNavigation是为了在导航场景拿到更高精度的位置distanceFilter设为kCLDistanceFilterNone表示不按距离过滤每个GPS回调都走pausesLocationUpdatesAutomatically必须设为NO否则iOS可能由于用户长时间未移动而自动暂停定位导致司机停车后轨迹丢失allowsBackgroundLocationUpdates在后台持续定位时必须打开而且需要在Info.plist里配置UIBackgroundModes为location。这里有个很容易忽略的前提requestAlwaysAuthorization只有在Info.plist里配置了NSLocationAlwaysAndWhenInUseUsageDescriptioniOS 11之后才会弹窗。如果没有这个键系统不会弹权限框定位直接静默失败。很多人写demo时发现定位回调不触发就是死在这个配置上而不是代码逻辑问题。顺带一提坐标系问题。系统的Core Location定位拿到的是WGS-84坐标而国内地图SDK普遍使用GCJ-02百度地图还在此基础上加了BD-09偏移。直接把WGS-84的经纬度拿到高德或百度地图上画点会偏移几百米。工程上必须做坐标转换方案一是使用地图SDK提供的坐标转换接口方案二是引入火星坐标转换库。这道题如果只回答“用CLLocationManager定位、算距离”没提坐标系的坑基本就是半成品答案。3.3 轨迹上报与弱网补传偏航检测不只在客户端做服务端也需要保存司机轨迹用于计费、回放和调度。上报策略通常是每5秒采集一个GPS点通过接口上报到服务端服务端按订单和时间戳落库。但司机在货运场景里经常进出地下车库、经过隧道等无信号区域弱网补传是必考题。我的做法是本地维护一个轨迹缓存表每收到一个GPS点先写入缓存然后尝试上报上报成功就删除对应记录失败就留在本地。定时器每30秒检查一次只要网络恢复就批量补传。补传时必须带上客户端本地时间戳和业务自增序号服务端按这些字段排序避免因为网络延迟打乱轨迹顺序。这个方案还要注意磁盘缓存的大小控制。轨迹点每小时大约720个5秒一个点每个点几十字节正常一天不到1MB不会撑爆磁盘但要是司机连续几天不联网数据会累积所以缓存超过一定上限比如5万条时要丢弃最早的数据并记录一个“轨迹数据已丢失”标记之后上报给服务端做数据完整性评估。3.4 加分项客户端实时判定与服务端离线分析笔试时只答“客户端实时判断偏航然后提示”只能拿中等分。加分做法是区分两个层面客户端负责实时偏航判定和用户提示因为提示必须在几百毫秒内完成不能依赖网络服务端负责离线轨迹分析比如事后判断司机是否有绕路、是否超出配送范围、是否在某个区域停留过久。两者的判定阈值可以不同客户端的阈值要更灵敏确保用户体验服务端的阈值更严格确保订单结算公平。能答出这个分层设计说明你真的理解这类App的高可用要求和业务边界。4. 长连接断线重连移动网络下客户端如何自救4.1 为什么不用轮询货运司机端的核心诉求是“新订单消息要第一时间弹出来”如果App每隔10秒轮询一次订单被别人抢了你才知道用户体验很差。轮询在服务端压力、客户端功耗、消息实时性三个维度都很吃亏所以这类App普遍采用长连接方案。2018年的iOS生态里常见长连接方案有自研TCP长连接、WebSocket、MQTT。自研TCP最灵活但工程量最大要处理粘包拆包、心跳、加密、重连等一堆底层问题WebSocket协议层级更高移动端兼容性好很多场景够用MQTT在IoT和弱网环境里表现不错缺点是包体偏大一点。货拉拉这种业务场景服务端和客户端闭环可控用自研二进制长连接或WebSocket都合理。笔试答题不需要纠结选哪个而是要把“连接断开怎么感知、断开后怎么重连、重连后消息怎么不丢不重”这三件事讲清楚。4.2 心跳设计与断线判定TCP本身没有连接状态连接断开时网络层往往要等很久才能通过读写出错发现。所以应用层必须做心跳机制。常见设计是客户端每30秒发送一个Ping包服务端收到后回一个Pong包。如果客户端连续3个Ping都没有收到Pong就判定连接已断开进入重连流程。为什么是30秒和连续3次30秒是平衡功耗和感知速度的常用值太短了费电、费流量太长了断线感知慢。连续3次而不是1次是为了容忍偶发的网络抖动。有些App还支持动态心跳根据当前网络状态调整心跳间隔Wi-Fi下可以放到45秒移动网络下用30秒。这个细节写进答案会显得非常有实战经验。心跳包本身要尽量小一般就几个字节的协议头加一个命令字。千万不要把心跳做成一次完整业务请求那样既浪费流量又容易跟真正的业务请求互相阻塞。心跳跟业务消息共用同一个连接不做独立连接否则连接数翻倍服务端和客户端都很吃力。4.3 指数退避重连断线后立刻重连是错误操作。移动网络断线往往伴随信号不稳定立刻重连大概率还是失败反而会形成“断线-重连-秒断-再重连”的循环既浪费电又给服务端造成压力。正确的重连策略是指数退避加随机抖动。第一次重连延迟1秒然后2秒、4秒、8秒、16秒到30秒或60秒封顶不再继续翻倍。每次重连前再加一个0到50%的随机值比如4秒变成4到6秒。这个随机抖动非常关键它避免了大量客户端同时断线后在同一时刻发起重连把服务端打挂业内管这个叫避免惊群效应。重连时还有一个细节先判断当前网络状态。iOS上有NWPathMonitoriOS 12之前用Reachability如果系统告诉你当前根本没有网络就不应该做任何重连动作等网络状态恢复通知后再开始重连。把网络监听和重连器解耦代码结构会清晰很多。4.4 消息不丢不重序号、ACK与幂等长连接重连之后断线期间的消息怎么处理是面试官最爱深挖的点。标准解法是给消息编序号。客户端本地维护一个连续自增的lastSeq每收到一条服务端消息lastSeq加一。重连成功后客户端把自己的lastSeq和服务端握手服务端从lastSeq 1开始补发断线期间的消息。这样就能做到不丢消息。但TCP传输本身可能造成消息重复比如客户端明明收到消息ACK包却在网络中丢失服务端重发客户端就收到了两条一模一样的消息。所以每条业务消息还要携带一个业务幂等键比如订单ID加消息类型加时间戳客户端维护一个最近处理过的幂等键集合重复消息直接丢弃。这个幂等机制不仅用于长连接也用于普通HTTP请求。我改卷时见过有人写“客户端重连后拉取离线消息”但这只覆盖了客户端掉线的情况没覆盖“客户端在线但连接假死”的情况。完整答案应该是心跳超时判定断开、指数退避重连、重连时用消息序号补发、业务层做幂等去重四者缺一不可。5. 多页面订单状态不一致缓存架构这样设计才稳5.1 问题本质各页面各拉各的场景题一个订单页面里首页有订单状态卡片、订单详情页有物流节点、消息中心有推送记录。如果每个页面都自己向服务端请求订单状态、各自维护一份本地数据问题就来了用户刚在首页看到“运输中”切到订单详情页却显示“待取货”。这不是服务端数据错误而是客户端多个副本之间没有同步。笔试里这道题问的是“如何设计客户端缓存保证多页面数据一致性”。本质上是解决“数据源唯一”的问题。5.2 中央订单Store设计解法是做一个中央订单Store单例内存中只维护一份订单数据字典所有页面都只从Store读取数据谁拿到服务端最新数据谁负责写入StoreStore再通过KVO、Notification或Block回调通知所有关注页面刷新。interface OrderStore : NSObject (instancetype)sharedStore; - (OrderModel *)orderForID:(NSString *)orderID; - (void)updateOrder:(OrderModel *)order; end内部实现要保证多读单写读操作并发写操作独占implementation OrderStore { dispatch_queue_t _concurrentQueue; NSMutableDictionaryNSString *, OrderModel * *_orders; } - (instancetype)init { if (self [super init]) { _concurrentQueue dispatch_queue_create(order.store.queue, DISPATCH_QUEUE_CONCURRENT); _orders [NSMutableDictionary dictionary]; } return self; } - (OrderModel *)orderForID:(NSString *)orderID { __block OrderModel *result; dispatch_sync(_concurrentQueue, ^{ result _orders[orderID]; }); return result; } - (void)updateOrder:(OrderModel *)order { dispatch_barrier_async(_concurrentQueue, ^{ _orders[order.orderID] order; }); // 发送通知让关注这个订单的页面刷新 } end为什么不用NSMutableDictionary的nonatomic加atomic因为字典的get/set虽然是线程安全的atomic保证但业务上经常需要“先读旧值、再比较、再写入”的复合操作比如“如果新状态比旧状态新才更新”这种操作必须整体加锁或放进队列。页面侧最好通过统一的订阅方法接收变更而不是每个页面自己轮询Store。iOS里可以用KVO也可以用NSNotificationCenter也可以用ReactiveCocoa或RxSwift。重点是“写入入口唯一、数据源唯一、变更通知唯一”这三点做到了多页面不一致问题基本就解决了。5.3 过期响应的时序问题即便有了中央Store还有一个隐蔽的坑网络请求的返回顺序可能和发送顺序不一致。举个例子用户先请求了订单详情返回“待取货”随后司机完成取货推送到达客户端又发起一个刷新请求返回“运输中”。如果第一次请求因为网络慢响应晚于第二次请求才回到客户端直接用这个旧响应覆盖Store界面就会从“运输中”回退到“待取货”。解决方式是在Store的写入口加“过期丢弃”逻辑。客户端在发起请求时就给请求编一个自增序号requestSeq响应回来时带上该序号Store只处理序号大于当前状态的响应过期响应直接丢弃。如果服务端数据结构允许也可以在服务端返回订单自己的updatedAt时间戳客户端比较时间戳只接受更新的数据。两个方案可以叠加使用双保险。5.4 服务端版本号与磁盘缓存内存Store解决的是App运行期间的页面一致但App重启后内存数据全部丢失所以还要有磁盘缓存。写磁盘要注意频率订单状态可能在短时间内连续变化如果每次变化都立即写文件IO开销大。更合理的方式是防抖状态变更后延迟1秒把最新状态合并写入磁盘1秒内再次变更则重新计时。App启动时先从磁盘加载缓存让用户第一眼能看到上一回的订单状态再发起网络刷新。还有一个工程细节磁盘缓存要区分“用户手动退出登录”和“App被杀”。退出登录时应该清空当前账号的订单缓存否则下一个账号登录后会看到上一个账号的订单状态这是非常常见的线上bug。可以在Store里加一个logout方法统一清空内存和磁盘数据同时取消所有在途请求。6. iOS 11安全区、UIStackView与证书签名卷子里的“小分题”6.1 安全区适配的对错对比卷三B的选择和填空里iOS 11适配题占了不少分。iPhone X发布后底部Home指示条会遮挡内容安全区适配成了必考题。正确做法是iOS 11及以上用safeAreaLayoutGuideiOS 11以下继续用topLayoutGuide和bottomLayoutGuideif (available(iOS 11.0, *)) { // 使用 self.view.safeAreaLayoutGuide } else { // 使用 self.topLayoutGuide / self.bottomLayoutGuide }同时要特别注意automaticallyAdjustsScrollViewInsets在iOS 11中已被废弃替代方案是设置scrollView.contentInsetAdjustmentBehaviorif (available(iOS 11.0, *)) { scrollView.contentInsetAdjustmentBehavior UIScrollViewContentInsetAdjustmentNever; }如果不设置这个属性系统会自动调整ScrollView的contentInset导致列表在iPhone X上出现奇怪的额外留白或者在导航栏透明的页面里顶部内容被遮挡。这题很多人会答安全区但漏讲contentInsetAdjustmentBehavior属于会一半。大标题导航栏也是iOS 11的新特性。prefersLargeTitles如果全局开启所有页面都会变成大标题样式但并不是每个页面都适合比如订单详情页通常需要在紧凑空间内展示更多信息。正确的做法是self.navigationController.navigationBar.prefersLargeTitles NO; self.navigationItem.largeTitleDisplayMode UINavigationItemLargeTitleDisplayModeNever;6.2 UIStackView在动态布局里的价值UIStackView在iOS 9就引入了
返回列表