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定时器替代NSTimer:
@property (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,那么“循环引用”的分析角度就不同了。单例本身常驻内存,它持有timer,timer强引用单例,这不算严格意义的泄漏,因为单例本来就要活到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里配置了NSLocationAlwaysAndWhenInUseUsageDescription(iOS 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上有NWPathMonitor(iOS 12之前用Reachability),如果系统告诉你当前根本没有网络,就不应该做任何重连动作,等网络状态恢复通知后再开始重连。把网络监听和重连器解耦,代码结构会清晰很多。
4.4 消息不丢不重:序号、ACK与幂等
长连接重连之后,断线期间的消息怎么处理,是面试官最爱深挖的点。标准解法是给消息编序号。
客户端本地维护一个连续自增的lastSeq,每收到一条服务端消息,lastSeq加一。重连成功后,客户端把自己的lastSeq和服务端握手,服务端从lastSeq + 1开始补发断线期间的消息。这样就能做到不丢消息。
但TCP传输本身可能造成消息重复,比如客户端明明收到消息,ACK包却在网络中丢失,服务端重发,客户端就收到了两条一模一样的消息。所以每条业务消息还要携带一个业务幂等键,比如订单ID加消息类型加时间戳,客户端维护一个最近处理过的幂等键集合,重复消息直接丢弃。这个幂等机制不仅用于长连接,也用于普通HTTP请求。
我改卷时见过有人写“客户端重连后拉取离线消息”,但这只覆盖了客户端掉线的情况,没覆盖“客户端在线但连接假死”的情况。完整答案应该是:心跳超时判定断开、指数退避重连、重连时用消息序号补发、业务层做幂等去重,四者缺一不可。
5. 多页面订单状态不一致:缓存架构这样设计才稳
5.1 问题本质:各页面各拉各的
场景题:一个订单页面里,首页有订单状态卡片、订单详情页有物流节点、消息中心有推送记录。如果每个页面都自己向服务端请求订单状态、各自维护一份本地数据,问题就来了:用户刚在首页看到“运输中”,切到订单详情页却显示“待取货”。
这不是服务端数据错误,而是客户端多个副本之间没有同步。笔试里这道题问的是“如何设计客户端缓存,保证多页面数据一致性”。本质上是解决“数据源唯一”的问题。
5.2 中央订单Store设计
解法是做一个中央订单Store,单例,内存中只维护一份订单数据字典,所有页面都只从Store读取数据,谁拿到服务端最新数据谁负责写入Store,Store再通过KVO、Notification或Block回调通知所有关注页面刷新。
@interface OrderStore : NSObject + (instancetype)sharedStore; - (OrderModel *)orderForID:(NSString *)orderID; - (void)updateOrder:(OrderModel *)order; @end内部实现要保证多读单写,读操作并发,写操作独占:
@implementation OrderStore { dispatch_queue_t _concurrentQueue; NSMutableDictionary<NSString *, 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及以上用safeAreaLayoutGuide,iOS 11以下继续用topLayoutGuide和bottomLayoutGuide:
if (@available(iOS 11.0, *)) { // 使用 self.view.safeAreaLayoutGuide } else { // 使用 self.topLayoutGuide / self.bottomLayoutGuide }同时要特别注意automaticallyAdjustsScrollViewInsets在iOS 11中已被废弃,替代方案是设置scrollView.contentInsetAdjustmentBehavior:
if (@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就引入了,