尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

iOS短信模块开发实战:Objective-C接口对接、防刷与性能优化全解析

iOS短信模块开发实战:Objective-C接口对接、防刷与性能优化全解析 做iOS开发这些年短信模块几乎是每个App都绕不开的标配。不管是注册登录的验证码、风控触发的二次校验还是支付操作的短信确认短信接口的稳定性和响应速度直接决定了用户能不能顺畅走完流程。很多人觉得接短信不就是调个HTTP接口有什么技术含量但真到了生产环境你会发现通道选型、超时控制、重试策略、幂等设计、防刷机制每一环都有坑。这篇文章我把Objective-C环境下对接短信接口的完整流程梳理一遍从方案选型、密钥签名、代码实现到线上排查希望能帮正在做iOS短信功能的朋友少走弯路。这篇内容适合三类人看刚接手iOS项目的初级开发者、正在给老项目加短信功能的跨端同学、以及想优化现有短信模块性能的负责人。1. 短信接入的第一课先想清楚再动代码1.1 短信在iOS项目里的三种典型用途短信功能在不同产品里扮演的角色不一样接法也不同。我做过的大多数项目里短信主要承担三类职责。第一类是验证码短信这是最普遍的场景。注册、登录、改密、换绑手机号都需要通过短信验证码确认当前操作者是手机号持有者本人。这类短信的特点是请求集中、对到达速度敏感、对安全要求极高。用户在登录页等着收验证码晚个几秒就可能流失所以验证码通道的SLA服务等级协议通常是最高的。第二类是通知类短信比如发货通知、订单状态变更、预约提醒。这类短信一般由服务端业务逻辑触发App端更多是接收结果回执或者做状态展示。App侧要处理的是用户授权通知权限和短信发送结果的闭环一般不会直接在客户端发起请求。第三类是营销短信这个在国内监管比较严格通常需要用户主动订阅、明确授权。客户端很少直接触发多数由运营后台批量发送。在iOS端涉及到的反而更多是合规审查比如《个人信息保护法》要求提供退订入口以及App Store审核时对用户授权流程的检查。搞清楚你的项目属于哪一类才能决定代码层面怎么设计。如果是验证码短信你需要重点投入防刷、限流、超时重试这些能力如果是通知类核心反而是回执处理和异常告警如果是营销类合规前置条件比技术实现更值得花时间。1.2 服务商SDK与原生HTTP调用怎么选国内主流短信服务商基本都提供了iOS SDK阿里云、腾讯云、七牛云等都有现成的Objective-C或Swift封装接入文档也比较完善。直接用SDK的好处很直接服务商把签名算法、加密传输、心跳检测这些脏活累活都做完了你只需要调一行方法。如果你项目工期紧、团队里没有专门做网络层的同学用SDK是最稳妥的起步方式。但SDK不是没有代价。第一个问题是体积和依赖某些SDK会引入一坨无关的类库包体积动不动多出几个MB这对一个只用来发短信的功能来说性价比很低。第二个问题是可控性SDK内部的重试逻辑、超时时间、日志策略是写死的真出了问题你想改底层行为只能等官方发新版本排查问题时也少了很多抓手。第三个问题是版本适配苹果每年发新iOS版本SDK如果不及时更新可能在某个系统版本上出现兼容性Bug你连背锅的机会都没有。原生HTTP调用则反过来所有逻辑都在你手里请求怎么发、超时多久、重试几次、日志怎么记完全自己说了算。缺点是你要自己实现签名、处理异常、考虑安全。我的习惯是如果短信只是项目里一个很小的辅助功能直接HTTP封装就够了如果项目里有复杂的短信业务比如多模板、多签名、多通道容灾那还是用服务商SDK做底座再自己包一层业务框架更省心。下面所有代码示例我按原生HTTP调用来写因为这样能把原理讲透你理解了之后切SDK也好切。1.3 Objective-C这个技术栈到底值不值得用每次提到Objective-C总有人跳出来说该用Swift了。但现实是大量存量App的核心代码依然是Objective-C写的尤其是金融、电商、社交这些体量很大的项目全量Swift化是漫长的过程。只要你的项目还在用Objective-C短信模块就绕不开这门语言。而且说实话短信这种偏网络通信的功能Objective-C和Swift写出来性能差别微乎其微。NSURLSession是两门语言共用的一套底层APIGCD也是C语言层面的能力。短信模块的性能瓶颈几乎都出现在网络链路、服务商接口处理速度上而不是语言本身。真正值得关心的是你团队的维护成本如果团队成员更熟Objective-C强行用Swift写一个新模块反而会增加后续维护的沟通成本。还有个细节是混编。很多项目处于Swift和Objective-C共存的状态Swift项目里也可以通过桥接头文件调用Objective-C写的短信模块。我在实际项目里就经常把短信这类与业务相对独立的模块用Objective-C封装成静态库再通过Module Map暴露给Swift端调用模块边界清晰也不影响主工程的技术栈演进。后面讲的实现方案你完全可以照搬到Swift项目里。2. 接口对接前的关键准备密钥、ATS和签名2.1 短信服务商的账号体系与密钥管理不管接哪家短信服务商你都得先理解它们的密钥体系。以最常见的模式来说服务商会给你一对钥匙AccessKeyId和AccessKeySecret。AccessKeyId相当于你的账号标识AccessKeySecret是签名密钥本质上是一个只能保存在服务端的机密字符串。它的用法是客户端把请求参数加上时间戳、随机数拼成字符串用AccessKeySecret做HMAC-SHA256运算得到签名服务端用同样的算法验签从而确认请求确实来自你本人。这里有一个很多新手会踩的坑AccessKeySecret绝对禁止写死在App代码里。iOS客户端是明文分发的任何人用工具扒一下Mach-O二进制文件就能把密钥提取出来。一旦密钥泄露别人就可以冒充你的身份拿你的短信额度去刷广告短信轻则账号欠费重则通道被封、域名被拉黑。正确做法是客户端请求先打到你自己的服务端由服务端持有AccessKeySecret去调用短信服务商接口。客户端永远只暴露一个你自己的业务接口地址AccessKeySecret藏在服务器上。我自己遇到过最惨的一次事故就是早期项目把密钥直接放在代码里上线两周后被爬虫抓了包反编译提取一夜之间账号被刷了一万多条短信损失倒是其次主要是通道被服务商临时冻结业务整整停了半天。从那以后我定了个死规矩凡是涉及签名的密钥一律只出现在服务端客户端能拿到的永远只是临时凭证。2.2 iOS工程的ATS配置与网络权限苹果从iOS 9开始强制推行ATSApp Transport Security应用传输安全默认情况下App只允许HTTPS请求明文HTTP请求会被直接拦截。短信接口如果走HTTP或者走自签名证书的HTTPS请求发出去根本到不了服务端。如果你的短信服务商提供的接口是标准HTTPS那Info.plist里什么都不用配默认就能用。但如果你在测试环境用的是HTTP域名或者内网自建了模拟短信服务那就得在Info.plist里加例外配置。下面是我常用的测试环境配置注意它只针对特定域名放开千万不要图省事直接NSAllowsArbitraryLoads设成YES。keyNSAppTransportSecurity/key dict keyNSExceptionDomains/key dict keysms-test.example.com/key dict keyNSExceptionAllowsInsecureHTTPLoads/key true/ keyNSIncludesSubdomains/key true/ /dict /dict /dict还有一个容易被忽略的地方是App Store审核。如果你的包在审核期间需要联网访问短信接口而接口域名恰好被ATS拦了审核机器上就会看到网络请求失败可能导致审核被拒。所以正式环境的短信接口务必保证是合规的HTTPS证书链不要用自签证书。2.3 参数签名与防重放设计客户端直连短信服务商的场景虽然不推荐但有些内网环境或者特殊业务确实会这么干。这时候参数签名就是你最后一道防线。签名的作用有两个一是身份认证证明请求是你发的二是防篡改确保参数在传输过程中没被改过。签名方案我一般这样设计所有业务参数按字典序排列拼成keyvaluekeyvalue的字符串再拼上timestamp和nonce最后用HMAC-SHA256计算摘要。timestamp的作用是防重放服务端只接受当前时间前后五分钟内的请求过期直接拒绝。nonce是随机字符串服务端会把它缓存起来同一个nonce出现两次就判定为重放攻击。这里给大家一个代码层面的实现参考- (NSString *)generateSignatureWithParams:(NSDictionary *)params secret:(NSString *)secret { // 1. 参数按key字典序排序 NSArray *keys [params.allKeys sortedArrayUsingSelector:selector(compare:)]; // 2. 拼接成 keyvaluekeyvalue 形式 NSMutableArray *parts [NSMutableArray array]; for (NSString *key in keys) { NSString *value params[key]; [parts addObject:[NSString stringWithFormat:%%, key, value]]; } NSString *baseString [parts componentsJoinedByString:]; // 3. HMAC-SHA256 计算 NSData *secretData [secret dataUsingEncoding:NSUTF8StringEncoding]; NSData *baseData [baseString dataUsingEncoding:NSUTF8StringEncoding]; unsigned char cHMAC[CC_SHA256_DIGEST_LENGTH]; CCHmac(kCCHmacAlgSHA256, secretData.bytes, secretData.length, baseData.bytes, baseData.length, cHMAC); NSData *hmacData [NSData dataWithBytes:cHMAC length:CC_SHA256_DIGEST_LENGTH]; // 4. 转成十六进制字符串 NSMutableString *result [NSMutableString stringWithCapacity:hmacData.length * 2]; for (int i 0; i hmacData.length; i) { [result appendFormat:%02x, ((unsigned char *)hmacData.bytes)[i]]; } return result; }注意这段代码依赖CommonCrypto/CommonHMAC.h我记得在Objective-C工程里不需要额外设置就能直接用但如果你的工程开启了Bitcode需要在Build Settings里确认CommonCrypto没有被裁剪掉。签名逻辑写完之后一定要用服务商文档里的测试用例跑一遍确认你的签名结果和官方示例一致再接着往下写业务逻辑不然后面排查签名问题会非常痛苦。3. 高性能短信模块的代码实现3.1 基于NSURLSession的请求封装短信请求本质上是一个POST请求性能的关键在于连接复用和超时控制。NSURLSession默认就支持HTTP/2和连接池复用所以只要你不每次请求都新建一个session性能基本是有保障的。我推荐的模式是整个App生命周期内只维护一个NSURLSession实例通过配置项区分不同场景的超时策略。#import SMSRequestManager.h static NSString *const kSMSSendURL https://api.yourdomain.com/sms/send; interface SMSRequestManager () property (nonatomic, strong) NSURLSession *session; end implementation SMSRequestManager (instancetype)sharedManager { static SMSRequestManager *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[SMSRequestManager alloc] init]; }); return instance; } - (instancetype)init { self [super init]; if (self) { NSURLSessionConfiguration *config [NSURLSessionConfiguration defaultSessionConfiguration]; config.timeoutIntervalForRequest 10; // 单次请求超时 config.timeoutIntervalForResource 30; // 整个资源请求超时 config.HTTPMaximumConnectionsPerHost 2; // 并发连接数 _session [NSURLSession sessionWithConfiguration:config]; } return self; } - (void)sendSMSCodeWithPhone:(NSString *)phone completion:(void (^)(BOOL success, NSError *error))completion { NSMutableURLRequest *request [NSMutableURLRequest requestWithURL:[NSURL URLWithString:kSMSSendURL]]; request.HTTPMethod POST; [request setValue:application/json forHTTPHeaderField:Content-Type]; [request setValue:application/json forHTTPHeaderField:Accept]; // 注意这里的 token 应该是从你的服务端临时获取的凭证 NSString *accessToken [[SMSAuthManager sharedManager] currentToken]; [request setValue:[NSString stringWithFormat:Bearer %, accessToken] forHTTPHeaderField:Authorization]; NSDictionary *params { phone : phone, scene : register, // 场景标记方便服务端做风控 clientId : [self clientId], // 设备唯一标识 timestamp : ([[NSDate date] timeIntervalSince1970] * 1000) }; NSData *bodyData [NSJSONSerialization dataWithJSONObject:params options:0 error:nil]; request.HTTPBody bodyData; NSURLSessionDataTask *task [self.session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { [self handleResponse:data response:response error:error completion:completion]; }]; [task resume]; } - (void)handleResponse:(NSData *)data response:(NSURLResponse *)response error:(NSError *)error completion:(void (^)(BOOL, NSError *))completion { if (error) { completion(NO, error); return; } NSHTTPURLResponse *httpResponse (NSHTTPURLResponse *)response; if (httpResponse.statusCode ! 200) { NSError *httpError [NSError errorWithDomain:SMSHTTPError code:httpResponse.statusCode userInfo:{NSLocalizedDescriptionKey: [NSString stringWithFormat:HTTP %ld, (long)httpResponse.statusCode]}]; completion(NO, httpError); return; } NSError *parseError; NSDictionary *json [NSJSONSerialization JSONObjectWithData:data options:0 error:parseError]; if (parseError) { completion(NO, parseError); return; } // 约定服务端返回格式{code:0,message:success,data:{}} if ([json[code] isEqualToString:0]) { completion(YES, nil); } else { NSError *bizError [NSError errorWithDomain:SMSBusinessError code:json[code] ? [json[code] integerValue] : -1 userInfo:{NSLocalizedDescriptionKey: json[message] ?: 短信发送失败}]; completion(NO, bizError); } } end这段封装里有两个细节值得注意。一是HTTPMaximumConnectionsPerHost设置为2短信请求数量不会像图片加载那样密集没必要开很高的并发反而容易触发服务端的限流策略。二是把业务错误码和HTTP状态码分开处理HTTP 200不代表业务成功服务端返回的业务错误码才是最终判断依据。很多新手只看statusCode结果明明业务失败还提示用户发送成功这是很严重的体验问题。3.2 验证码倒计时与UI联动验证码短信接完之后紧接着就是验证码按钮的倒计时交互。这个功能看起来简单但实现不好会有两个经典Bug一是倒计时期间用户杀掉App再重进倒计时状态丢失按钮直接变成可点击状态用户可以绕过频率限制疯狂请求二是按钮状态和网络请求不同步用户连续点击十次发了十个请求出去。正确的做法是倒计时的剩余时间要持久化存到本地App冷启动后恢复按钮状态。同时用户点击发送按钮后立即把按钮置为不可点击状态等接口响应后再开始倒计时。我习惯用NSUserDefaults存储倒计时截止时间戳因为用户杀进程的时间不可预测用剩余秒数存的话杀进程期间的时间流逝没法计算而存截止时间戳任何时候恢复都能算出正确的剩余秒数。- (void)startCountdownWithRemainSeconds:(NSInteger)seconds { // 计算截止时间并持久化 NSTimeInterval deadline [[NSDate date] timeIntervalSince1970] seconds; [[NSUserDefaults standardUserDefaults] setDouble:deadline forKey:kSMSVerifyCodeDeadline]; [[NSUserDefaults standardUserDefaults] synchronize]; if (self.timer) { dispatch_source_cancel(self.timer); } __weak typeof(self) weakSelf self; self.timer dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); dispatch_source_set_timer(self.timer, dispatch_walltime(NULL, 0), 1.0 * NSEC_PER_SEC, 0); dispatch_source_set_event_handler(self.timer, ^{ __strong typeof(weakSelf) strongSelf weakSelf; NSTimeInterval now [[NSDate date] timeIntervalSince1970]; NSInteger remain ceil(deadline - now); if (remain 0) { dispatch_source_cancel(strongSelf.timer); strongSelf.sendCodeButton.enabled YES; [strongSelf.sendCodeButton setTitle:重新获取 forState:UIControlStateNormal]; } else { strongSelf.sendCodeButton.enabled NO; [strongSelf.sendCodeButton setTitle:[NSString stringWithFormat:%ld秒后重试, (long)remain] forState:UIControlStateDisabled]; } }); dispatch_resume(self.timer); } - (void)restoreCountdownStateIfNeeded { NSTimeInterval deadline [[NSUserDefaults standardUserDefaults] doubleForKey:kSMSVerifyCodeDeadline]; NSTimeInterval remain deadline - [[NSDate date] timeIntervalSince1970]; if (remain 0) { [self startCountdownWithRemainSeconds:remain]; } }这个实现还有个细腻的地方倒计时的UI更新放在主队列dispatch_source的timer指定了main queue不需要手动切线程避免主线程和子线程竞争导致UI闪烁。我在项目里见过有人用NSTimer做倒计时结果在ScrollView滚动时Timer被RunLoop机制暂停导致按钮卡在某个秒数不动。dispatch_source的timer基于GCD不受RunLoopMode切换的影响这是性能上更稳的选择。3.3 请求队列与并发控制短信接口高并发场景下客户端容易出问题的是请求风暴。比如用户手机网络差点击发送后请求超时用户着急又点了一遍又超时又点。几轮下来实际上有多个请求同时在空中飞服务端可能已经发了多条短信用户只会收到一条其余的下次可能就收不到了因为运营商有频控。这就是我在项目里被反馈验证码收不到的最常见原因——不是通道挂了是客户端重复请求太多被服务端频控了。解决思路是引入请求队列。我封装了一个简单的SMSRequestQueue核心逻辑是同一手机号、同一场景最多只允许一个请求处于进行中状态。新请求到达时如果之前有未完成的请求直接标记为合并不真正发起网络请求而是等待前一个请求的结果回调。interface SMSRequestQueue : NSObject - (void)enqueueRequestWithKey:(NSString *)key handler:(void (^)(void))handler completion:(void (^)(BOOL success, NSError *error))completion; end implementation SMSRequestQueue { NSMutableDictionaryNSString *, NSMutableArray * *_pendingCompletions; NSMutableSetNSString * *_inflightKeys; NSLock *_lock; } - (instancetype)init { self [super init]; if (self) { _pendingCompletions [NSMutableDictionary dictionary]; _inflightKeys [NSMutableSet set]; _lock [[NSLock alloc] init]; } return self; } - (void)enqueueRequestWithKey:(NSString *)key handler:(void (^)(void))handler completion:(void (^)(BOOL, NSError *))completion { [_lock lock]; if ([_inflightKeys containsObject:key]) { // 相同操作已在请求中挂起当前回调 NSMutableArray *completions _pendingCompletions[key]; if (!completions) { completions [NSMutableArray array]; _pendingCompletions[key] completions; } [completions addObject:completion]; [_lock unlock]; return; } // 标记为进行中 [_inflightKeys addObject:key]; [_lock unlock]; handler(); // 注意这里handler内部实际发起请求完成后回调completeRequestWithKey } - (void)completeRequestWithKey:(NSString *)key success:(BOOL)success error:(NSError *)error { [_lock lock]; [_inflightKeys removeObject:key]; NSMutableArray *completions _pendingCompletions[key]; [_pendingCompletions removeObjectForKey:key]; [_lock unlock]; for (void (^completion)(BOOL, NSError *) in completions) { dispatch_async(dispatch_get_main_queue(), ^{ completion(success, error); }); } } end这个队列实现我特意用了NSLock而不是synchronized因为短信请求的回调可能在不同线程触发NSLock的粒度控制更明确。别忘了在dealloc里把所有未完成回调都执行一遍返回错误否则用户界面上会出现请求永远在转圈的Bug。队列的最大价值不是提升速度而是把不可控的并发变成可控的串行降低服务端被刷爆的概率。4. 生产环境下的性能优化与稳定性保障4.1 幂等设计与防重复提交短信请求天然应该具备幂等性也就是同一个请求无论发送多少次服务端都只会发出一条短信。客户端在为用户体验做优化时一定不能破坏这个语义。我见过很多团队在客户端做网络超时自动重试如果服务端没做幂等超时重试就会变成短信轰炸。客户端能做的配合是每次发起短信请求时生成一个唯一的requestId存放在请求参数里传给服务端。服务端拿到requestId先去查有没有处理过处理过就直接返回成功。这样即便是客户端超时后自动重试服务端也只会真正触发一次短信发送。- (NSString *)generateRequestId { // requestId 格式时间戳 随机数 设备标识保证全局唯一 NSTimeInterval timestamp [[NSDate date] timeIntervalSince1970] * 1000; int random arc4random_uniform(100000); NSString *deviceId [self clientId]; return [NSString stringWithFormat:%-%-%d, (timestamp), deviceId, random]; }还有一个容易被忽略的细节用户点击发送验证码后接口还在请求中时按钮应该保持发送中状态。我通常在按钮上加一个转圈小菊花同时禁用按钮等请求完成后再恢复。这个UI状态同步必须在主线程做配合之前说的请求队列可以做到用户不管怎么狂点实际上只会发出去一个请求。4.2 本地频率限制与内存缓存服务端的频控再严格也防不住用户手速快。客户端做本地限流能减轻服务端压力也能避免用户自己把自己锁死。我现在的做法是在同一手机号维度上记录最近一次发送验证码的时间戳和当天发送次数限制每60秒最多发一次、单手机号每天最多10次。这些数据存在本地沙盒里用NSUserDefaults就能搞定不用引入数据库。- (BOOL)canSendSMSWithPhone:(NSString *)phone { NSString *key [NSString stringWithFormat:kSMSFrequency_%, phone]; NSDictionary *record [[NSUserDefaults standardUserDefaults] dictionaryForKey:key]; NSTimeInterval now [[NSDate date] timeIntervalSince1970]; NSTimeInterval lastSent [record[lastSent] doubleValue]; NSInteger todayCount [record[todayCount] integerValue]; // 相隔不足60秒 if (now - lastSent 60) { return NO; } // 单日次数上限 if (todayCount 10) { return NO; } return YES; } - (void)recordSMSSentWithPhone:(NSString *)phone { NSString *key [NSString stringWithFormat:kSMSFrequency_%, phone]; NSDictionary *existing [[NSUserDefaults standardUserDefaults] dictionaryForKey:key]; NSTimeInterval now [[NSDate date] timeIntervalSince1970]; NSInteger todayCount [existing[todayCount] integerValue]; // 跨天重置 NSCalendar *calendar [NSCalendar currentCalendar]; NSDate *lastDate [NSDate dateWithTimeIntervalSince1970:[existing[lastSent] doubleValue]]; NSInteger lastDay [calendar component:NSCalendarUnitDay fromDate:lastDate]; NSInteger currentDay [calendar component:NSCalendarUnitDay fromDate:[NSDate date]]; if (lastDay ! currentDay) { todayCount 0; } NSDictionary *record { lastSent : (now), todayCount : (todayCount 1) }; [[NSUserDefaults standardUserDefaults] setObject:record forKey:key]; }这里有个跨天判断的细节不要用当前时间减去上次时间超过24小时来判断因为用户凌晨12点发了一条到当天23点又发一条间隔超过24小时但其实是同一天。用日历组件取day字段对比才能正确判断是否跨天。这种小坑你不踩一次基本记不住。4.3 日志埋点与问题追溯短信模块是高依赖外部服务的模块出了问题你没法直接查运营商服务端的日志所以客户端日志就格外重要。我每个短信请求都会记录以下信息请求时间、手机号、场景、requestId、服务端返回码、请求耗时、是否命中本地限流、当前网络状态。日志写到统一的日志文件里按天分割保留最近30天用户反馈问题时可以远程拉取分析。- (void)logSMSAction:(NSString *)action detail:(NSDictionary *)detail duration:(NSTimeInterval)duration { NSMutableDictionary *logEntry [NSMutableDictionary dictionary]; logEntry[action] action; logEntry[time] ([[NSDate date] timeIntervalSince1970]); logEntry[duration] (duration); logEntry[network] [self currentNetworkType]; [logEntry addEntriesFromDictionary:detail]; // 写入本地日志文件 NSString *logPath [self smsLogFilePath]; NSString *line [NSString stringWithFormat:%\n, [self jsonStringFromDictionary:logEntry]]; NSFileHandle *fileHandle [NSFileHandle fileHandleForWritingAtPath:logPath]; [fileHandle seekToEndOfFile]; [fileHandle writeData:[line dataUsingEncoding:NSUTF8StringEncoding]]; [fileHandle closeFile]; }日志埋点这件事踩过的坑是千万不要在短信发送的成功回调里做耗时操作。曾经有个版本我在日志模块里直接做文件写入结果文件I/O阻塞了主线程短信Send按钮点击后卡了200毫秒用户体验明显变差。后来把所有日志写入改成异步队列用dispatch_async到一个串行队列里去写文件问题立刻解决了。日志再重要也不能牺牲主线程性能。5. 常见问题与排查技巧实录5.1 短信到达率低怎么查短信发出去了用户却没收到这是最让人头疼的问题。第一步先确认发出去了这个前提是否成立。客户端显示发送成功只代表服务商接口接受了你的请求不代表运营商已经投递成功。很多服务商的响应结果里有更细的状态码比如发送成功和已提交是两个概念已提交可能还在排队。第二个排查点是手机号本身的问题用户是否处于信号弱的环境、是否开启了骚扰拦截、手机号码是否携号转网。携号转网的号码在某些服务商通道下会有路由问题导致短信到达延迟甚至丢失。这时候需要联系服务商排查运营商路由。第三个排查点是内容模板。验证码短信的模板在服务商那边是备案过的内容里如果有验证码、网址链接、【签名】这些字眼某些运营商会做额外审核或限速。尤其是带链接的短信被运营商拦截的概率显著升高所以验证码短信尽量在内容里不放任何链接所有操作都放在App内完成更安全。5.2 验证码永远收不到的排查路径如果用户反馈验证码收不到我有一套固定的排查顺序先看服务商后台的发送记录确认有没有这个手机号的发送请求再查服务商返回的响应码是不是提示号码异常或者触发频控然后看客户端日志确认请求有没有真实发出还是被本地限流拦了最后看用户手机端的拦截短信、信号状态。有一次用户反馈收不到验证码查了一圈服务商后台显示发送正常运营商回执也显示投递成功最后发现是用户手机装了第三方拦截App把营销类短信全挡了。这种问题技术上没法通过客户端解决只能引导用户去垃圾短信里翻一翻。类似的还有iOS系统的过滤未知发件人功能需要用户在设置里手动关闭。所以短信模块的UI上最好加一句提示如未收到短信请检查手机拦截设置能解决很大一部分客诉。5.3 ATS、SSL与抓包调试短信接口联调时Charles抓包是最常用的工具。但iOS 10以上的系统默认信任的证书列表里没有Charles的CA证书你不做配置的话HTTPS流量是解密不了、抓不到的。操作方法是在Mac上打开Charles进入Proxy设置里导出根证书把证书以邮件或描述文件的方式安装到iPhone然后在设置-通用-关于本机-证书信任设置里打开完全信任开关。这一步做完Charles才能抓到短信请求的明文内容。抓包时要注意的一点是如果你开了代理而App的ATS设置没有对Charles的代理流量做兼容可能所有请求都会报错。稳妥的做法是联调阶段临时在Info.plist里加上Charles代理域名的ATS例外调试完记得删掉不然审核会有风险。另外很多人在联调时发现服务端怎么都验签失败用Charles一抓包就能看到签名参数传给服务端之后被URLEncode处理过了服务端解码后字符串跟你本地算的不一致。这种问题定位起来很快用Charles对比一下客户端发出的原始报文和服务端实际收到的解码后报文一目了然。5.4 代码层面的几个隐蔽问题最后整理几个我在代码Review里经常看到的隐蔽问题全是真实踩过的。第一个是时区问题。我的签名代码里用了[[NSDate date] timeIntervalSince1970] * 1000作为timestamp这个值跟时区无关是绝对时间戳。但如果你图方便用NSDateFormatter生成yyyyMMddHHmmss格式的时间戳那就要注意服务端默认用哪个时区解析。国内服务商一般用东八区如果你的测试机时区设置成UTC生成的签名时间戳跨时区解析就会差8小时重放校验直接挂掉。建议一律用UTC绝对时间戳从根上规避时区问题。第二个是长短信的问题。短信服务商对单条短信的长度有限制通常是70个字符纯英文160。如果你的验证码模板拼出来超过70字符服务商会按长短信计费或者自动拆分可能导致用户收到两条内容不完整的短信。我在做OpenAPI对接时就在服务端模板里严格控制变量长度手机号固定11位验证码固定6位内容控制在65字符以内避免触发长短信计费。第三个是内存生命周期问题。NSURLSessionDataTask的block回调里如果捕获了self会造成循环引用网络请求不结束控制器就无法释放。我在写短信模块时统一用weak-strong dance前面代码示例里也体现了。如果不注意这点页面反复进入退出内存会持续上涨最终被系统杀掉这是很多App用着用着就闪退的隐形元凶。还有一点是关于字符编码。短信接口的POST请求体用的是JSON编码如果你的服务端只支持GBK编码而客户端发的UTF-8中文会变成乱码。大多数服务商现在都支持UTF-8但如果是老系统你需要在请求头里明确指定charset或者改用application/x-www-form-urlencoded格式。这个问题在中文短信内容里特别容易出现联调时一定要用中文模板测试一遍。5.5 短信模块的守护线程与内存占用短信请求线程管理这块我建议所有的网络请求都在NSURLSession的回调线程里处理不要额外创建线程去等待结果。NSURLSession自动管理线程池你用dispatch_semaphore去阻塞请求线程等结果会白白浪费一个线程资源而且一旦超时逻辑没写好很容易造成线程泄漏。实际项目中我见过最夸张的情况是开发为了方便在短信按钮点击事件里用dispatch_async到并发队列然后里面嵌套了三个信号量等待。一次点击能吃掉三个线程用户多点几次线程池直接被占满App整体变卡。短信这种轻交互需求完全用异步回调就够了千万不要引入线程阻塞。内存方面短信模块的缓存数据量其实很小主要是验证码状态、频率记录和请求队列。唯一要注意的是不要把请求回调里返回的数据无脑存进数组尤其是错误信息堆叠容易造成内存上涨。及时清理临时对象用weak修饰代理这些老生常谈的规则在短信模块里同样适用。我个人这几年做iOS短信对接最大的心得是短信模块技术难度不高但非常考验细节。密钥管理不善会出事超时设置不合理会体验差幂等设计不到位会骚扰用户日志埋点缺失会出现问题查不了。每一个环节单独看都不复杂组合在一起就构成了短信功能的整体质量。这篇文章里的代码和方案都是我实际项目中验证过的你可以直接拿来改改就用。不过一定要记得具体对接前先仔细读一遍你选用的短信服务商的官方文档不同的服务商在签名算法、参数格式、错误码定义上有差异以官方文档为准永远是第一原则。
返回列表