做iOS开发的老哥,十有八九都接过这种需求:App注册页面要加手机号验证码登录,或者找回密码必须走短信验证,再不然就是运营那边提了个需求,要给用户发通知短信。短信接口这东西技术门槛不算高,但坑是真的多,签名审核、模板报错、真机收不到、审核被拒,哪一个都能让你折腾一整天。这篇文章我把自己这些年用Objective-C对接短信接口、在iOS项目里做短信验证码和高性能集成的经验完整梳理一遍,覆盖方案选型、SDK配置、代码实现、线程优化、防刷、抓包调试一直到上架审核,目标是让正在做objective-c短信接口开发对接的同行们,拿到文章就能直接照做,少踩几个我当年踩过的雷。
不管你是刚入行的iOS开发,还是接手了老项目需要补短信功能的同学,文章里我会尽量把每一步的为什么也讲清楚。不是说照着抄就行,而是你抄完之后,心里对这套链路是通透的。
1. 动手前先想清楚:iOS短信功能到底要集成到什么程度
很多朋友一上来就急着找SDK、写代码,结果做到一半发现需求理解错了,或者选了不合适的方案,只好推倒重来。我在项目里吃了这个亏,所以现在每次接到短信相关的需求,都会先花半小时把需求和边界理清楚。
1.1 iOS项目里最常见的三类短信需求
第一类是验证码短信。注册、登录、改密码、绑定手机号,都需要向用户手机发一条验证码,用户填对之后才允许继续操作。这类需求对时效性要求最高,验证码一般5到10分钟过期,而且必须防刷。
第二类是通知短信。比如订单状态变化、物流提醒、会议通知,属于事务性消息,用户操作触发,或者后台任务触发。这类短信对实时性要求略低一点,但对稳定性和内容准确性要求高,模板固定,不能随意拼接内容。
第三类是营销短信,也就是运营推送的优惠活动、上新通知。这类短信在iOS上最容易翻车,因为平台对UGC内容、营销内容管控很严,短信服务商对营销模板的审核也更严格,而且用户投诉一多,签名和模板可能直接被封。
看清楚需求区别很重要,因为三类短信在接口选择、服务商配置、代码结构上有不小的差异。尤其是营销短信,如果你在App里没有做用户主动订阅的确认流程,审核阶段被拒的概率非常大,这块我后面会专门讲。
1.2 方案选型:第三方SDK还是服务器直连短信网关
这是第一个关键决策点。市面上的做法大致分两条路:
第二条路:App只负责发起请求,真正的短信发送由你自己的业务服务器完成。App调用你自己后端的发送接口,后端拿用户的手机号,去调用短信服务商的OpenAPI(比如阿里云短信、腾讯云短信的HTTP接口),服务商再向用户手机下发短信。App端不需要集成短信服务商的SDK,只需要在自己的工程里发一个HTTP请求就行。
第一条路:在App里直接集成第三方短信SDK。比如MobTech的SMSSDK,这类SDK把发送验证码、校验验证码的流程封装好了,你只需要初始化,调用一个方法,SDK自己去请求服务商下发短信。这条路对客户端开发者最省事,因为不需要自己写HTTP层,也不需要维护后端的短信发送逻辑。
这时候肯定有人问:到底选哪条?
我个人的建议是,只要你的App有后端,就优先走服务器直连。原因很简单:短信服务商的AccessKey和AppSecret属于核心凭据,一旦放在App客户端里,反编译就能被拿到。而第三方SDK虽然把包封装了一层,但核心密钥还是存在于SDK包体内的,这属于一种"便利换安全"的取舍。如果你的App只是个Demo,或者确实没有自己后端,那用第三方SDK快速验证流程没问题。如果是商业级产品,还是把短信逻辑收敛到服务端更稳妥。
1.3 我为什么最终放弃了纯客户端SDK方案
我在一个电商类项目里做过一次完整的技术选型。最开始图省事,直接用第三方短信SDK,App拿到验证码后直接SDK校验,流程是通了,结果上线没两周就遇到两件头疼的事。
第一件事是审核风险。部分短信SDK为了提升验证码到达率,会申请一些特殊权限,或者内部集成了热更新组件,这在App Store审核时属于高危信号。虽然不一定必拒,但审核的不确定性大增,我这个项目就因为这个被驳回了一次,理由还比较模糊。
第二件事是统计和审计困难。短信业务是强合规业务,每次发送的通道、模板、原因都需要记录。SDK在客户端直接发送,日志全在用户的手机上,后端拿不到完整日志,出了问题根本没法定责。后来我按后端直连的方式重构一遍,把每次发送都记录到服务端的日志表,连短信服务商返回的messageId都存下来,排查问题效率翻倍。
第三件事是版本迭代成本。SDK一旦升级,可能带来接口变动和二进制体积增加,而且iOS每出一个版本,SDK适配的节奏不可控。自己封装一层HTTP接口,后端接口永远是稳定的,客户端只需要关心自己服务器的协议,完全不受服务商SDK版本影响。
当然,这不是说第三方SDK一无是处。对于没有后端的小工具型App、个人开发者做的Demo,SDK集成确实是上线最快的路径。但凡是正经商业项目,我都建议走服务端直连,客户端只做一件最纯粹的事:帮我后端接口。这个思路贯穿后面所有章节。
2. 集成前的关键配置:这步没做好,后面全是坑
很多开发者以为短信集成就是从写代码开始的,其实大坑在写代码之前就已经埋好了。签名没申请、模板不匹配、Bundle ID对不上,每一项都能让联调卡壳。
2.1 开发者账号、Bundle ID和App ID的对应关系
无论走哪条方案,只要你的App要上线,Bundle ID都是绕不开的基础。短信服务商的SDK注册时,通常需要你填写App的Bundle ID,这个值必须和Xcode工程里的Bundle Identifier保持一致,否则SDK的初始化可能直接失败,或者在真机调试时调用接口返回异常。
用Xcode打开工程,选中Target,General标签页里的Bundle Identifier就是它。常规格式是com.company.appname,建议一开始就规划好,因为上架之后Bundle ID理论上不能改,改了就是新App。
如果你还涉及推送通知类的短信业务,需要去开发者后台开通Push Notifications能力,生成推送证书。但注意,短信验证码本身走的是蜂窝网络的消息通道,和APNs推送完全是两码事。不要被"短信推送"这种叫法搞混,验证码短信不需要配置APNs,除非你还要做站内信推送。
2.2 短信服务商后台的签名、模板和AccessKey,一个都不能少
短信服务商都有一套审核机制,核心是两个东西:短信签名和短信模板。
短信签名是用户手机上显示的发送方标识,比如【某某科技】这种。签名代表你的企业身份,申请的时候需要提供企业资质或应用的注册信息。个人开发者申请签名比较麻烦,但也不是完全不行,一般用"应用名称"作为签名也能过,关键是名称不能太泛。
短信模板是短信内容的格式。模板里像验证码这种变化的部分,用占位符表示,比如"您的验证码为{code},5分钟内有效",提交后服务商审核通过,你才能用这个模板发送。
这里有个常见的坑:模板内容里的标点、空格必须和审核通过的完全一致。有些服务商在模板里用全角括号,你调用API时用了半角,提交直接报错。我踩过一次,排查了半天,最后发现是括号全半角的问题,实在无语。
AccessKey和AccessSecret也一样,在服务商控制台创建,注意它们是两个不同的概念。调用服务商OpenAPI时,AccessKey写在请求参数里,而AccessSecret是用来签名的,绝对不能出现在请求参数里,更不能出现在客户端代码里。要区分清楚,才能理解为什么密钥不能放客户端。
2.3 ATS配置与网络权限:为什么HTTP请求发不出去
iOS 9之后强制推行ATS(App Transport Security),默认只允许HTTPS请求。如果你后端的短信接口是HTTP(很多内网测试环境都是),直接请求会被系统拦截,报错信息通常长这样:App Transport Security has blocked a cleartext HTTP request。
解决方式是在Info.plist里配置ATS例外。但你得想清楚:
- 如果只是开发调试阶段连HTTP测试环境,可以用NSAllowsLocalNetworking临时放开本地网络;
- 如果你的正式环境是HTTP,不建议全局关闭ATS,而是用NSExceptionDomains精确指定某个域名为例外。
不过现在的短信服务商OpenAPI基本全是HTTPS,你自己的后端接口也应当走HTTPS。这里提醒一句,ATS的配置是全局性的,如果因为短信接口把整个App的HTTP请求都放开了,审核的时候被质疑风险会增高,不如老老实实让后端把HTTPS配上。
还有个小细节:iOS 14之后,本地网络权限(Local Network Privacy)会在应用访问局域网时弹出授权提示。如果你在真机上调试,后端的测试服务跑在局域网里,第一次发起请求会有弹窗,没点允许的话耗时很长,这也是排查"为什么真机连不上后端"的一个参考点。
3. 核心实现:Objective-C代码层面的对接细节
方案定好、配置做完,就进入写代码环节。我按最常见的服务端直连方案来拆解,因为这套代码结构可以完整应用到商业项目里,如果你选的是第三方SDK,代码体例也类似,只是请求对象换成SDK的接口而已。
3.1 网络层封装:一个简单的HTTP管理类
客户端不需要关心短信服务商的OpenAPI细节,只需要关心自己后端的接口。我习惯写一个轻量级的网络管理类,基于NSURLSession封装,专门负责验证码相关的HTTP请求。
// SMSNetworkManager.h #import <Foundation/Foundation.h> typedef void(^SMSCompletionBlock)(BOOL success, NSError *error); @interface SMSNetworkManager : NSObject + (instancetype)sharedManager; /// 请求发送验证码 - (void)sendVerificationCodeWithPhone:(NSString *)phone scene:(NSString *)scene completion:(SMSCompletionBlock)completion; /// 校验验证码 - (void)verifyCode:(NSString *)code withPhone:(NSString *)phone completion:(SMSCompletionBlock)completion; @end// SMSNetworkManager.m #import "SMSNetworkManager.h" static NSString * const kBaseURL = @"https://api.example.com/v1/sms"; @implementation SMSNetworkManager + (instancetype)sharedManager { static SMSNetworkManager *instance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[SMSNetworkManager alloc] init]; }); return instance; } - (void)sendVerificationCodeWithPhone:(NSString *)phone scene:(NSString *)scene completion:(SMSCompletionBlock)completion { NSURL *url = [NSURL URLWithString:[kBaseURL stringByAppendingString:@"/send"]]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; request.HTTPMethod = @"POST"; [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; NSDictionary *bodyDict = @{ @"phone": phone, @"scene": scene }; NSError *serialError = nil; NSData *bodyData = [NSJSONSerialization dataWithJSONObject:bodyDict options:0 error:&serialError]; if (serialError) { completion(NO, serialError); return; } request.HTTPBody = bodyData; NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error) { dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, error); }); return; } // 这里解析后端返回的JSON,判断业务成功与否 NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; BOOL success = [json[@"code"] integerValue] == 0; NSError *bizError = nil; if (!success) { NSString *msg = json[@"message"] ?: @"请求失败"; bizError = [NSError errorWithDomain:@"SMSBusinessError" code:[json[@"code"] integerValue] userInfo:@{NSLocalizedDescriptionKey: msg}]; } dispatch_async(dispatch_get_main_queue(), ^{ completion(success, bizError); }); }]; [task resume]; } @end这里有几个细节想说。
- 所有回调统一在主线程回调,因为后续要操作UI,比如倒计时按钮、弹窗提示,避免在子线程直接刷新UI导致的崩溃。
- 后端返回的code字段要与开发前约定的业务码一致,不要在客户端写死判断逻辑,宁可多一个字段也不要让客户端去猜。
- NSURLSession每次请求都会有自己的delegate queue,如果你要统一控制超时、公共参数,建议自己用NSURLSessionConfiguration创建一个session保存下来,代码会更可控。
3.2 发送验证码的完整调用链与倒计时逻辑
UI层的逻辑其实不复杂,但有一个地方容易写乱:倒计时状态管理和用户重复点击。
- (IBAction)sendCodeButtonTapped:(UIButton *)sender { NSString *phone = self.phoneTextField.text; if (![self isValidPhoneNumber:phone]) { [self showToast:@"请输入正确的手机号"]; return; } sender.enabled = NO; [[SMSNetworkManager sharedManager] sendVerificationCodeWithPhone:phone scene:@"register" completion:^(BOOL success, NSError *error) { if (success) { [self startCountdownWithButton:sender]; [self showToast:@"验证码已发送,请查收"]; } else { sender.enabled = YES; NSString *msg = error.localizedDescription ?: @"发送失败,请稍后重试"; [self showToast:msg]; } }]; } - (void)startCountdownWithButton:(UIButton *)button { __block NSInteger remaining = 60; self.countdownTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); dispatch_source_set_timer(self.countdownTimer, dispatch_walltime(NULL, 0), 1.0 * NSEC_PER_SEC, 0); dispatch_source_set_event_handler(self.countdownTimer, ^{ if (remaining <= 0) { dispatch_source_cancel(self.countdownTimer); self.countdownTimer = nil; button.enabled = YES; [button setTitle:@"重新发送" forState:UIControlStateNormal]; return; } [button setTitle:[NSString stringWithFormat:@"%lds后重试", (long)remaining] forState:UIControlStateNormal]; remaining--; }); dispatch_resume(self.countdownTimer); }手机号校验这个函数虽然简单,但值得单独写。中国手机号第一位是1,第二位是3到9,后面9位是数字。正则表达式简单写一下:
- (BOOL)isValidPhoneNumber:(NSString *)phone { NSString *regex = @"^1[3-9]\\d{9}$"; NSPredicate *pred = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", regex]; return [pred evaluateWithObject:phone]; }注意,这个校验只是客户端UI层的快速提示,真正的有效性校验必须在后端做,因为客户端的校验可以被绕过,后端还需要配合风控去判断这个手机号是否满足发送条件。
倒计时这里容易出问题的是界面销毁时的定时器清理。如果你用了dispatch_source,在viewWillDisappear或者dealloc里一定要dispatch_source_cancel,否则页面退了定时器还在跑,会有内存隐患。还有,如果用户在前台进入后台再回来,dispatch_source的计时是连续的,不会因为你切后台就暂停,这点比NSTimer更符合倒计时预期。
3.3 验证码校验:必须放在服务端做的事
验证码校验是整个流程里最核心的安全环节。客户端拿到用户输入的验证码,传给后端接口,后端比对存储的验证码是否一致,以及是否过期、是否超过最大尝试次数。
- (IBAction)verifyCodeButtonTapped:(UIButton *)sender { NSString *phone = self.phoneTextField.text; NSString *code = self.codeTextField.text; if (code.length != 6) { [self showToast:@"请输入6位验证码"]; return; } __weak typeof(self) weakSelf = self; [[SMSNetworkManager sharedManager] verifyCode:code withPhone:phone completion:^(BOOL success, NSError *error) { typeof(self) strongSelf = weakSelf; if (!strongSelf) return; if (success) { [strongSelf enterMainPage]; } else { [strongSelf showToast:error.localizedDescription ?: @"验证失败"]; } }]; }为什么校验必须在后端做?因为如果客户端本地有个节点保存了验证码,反编译你的App就能看到校验逻辑,用户甚至不需要真收短信,直接用暴力枚举的方式去试。后端的校验逻辑可以做如下几层防护:
- 验证码与手机号绑定,一个验证码只能用于一个手机号;
- 每个手机号每天的发送次数限制,验证失败次数限制;
- 同一IP维度做频率限制。
这些写在后端,客户端完全不需要感知,但短信业务流程的可靠性会大幅上升。
3.4 自动填充验证码:iOS 12+的OneTimeCode技巧
短信发出去了,用户切到短信App看验证码,再切回你的App手动输入,这个体验确实繁琐。iOS 12开始支持验证码自动填充,系统会识别短信中的验证码文本,在键盘上方推荐填充,你的输入框需要做一个小小的适配。
// 在viewDidLoad里设置 self.codeTextField.textContentType = UITextContentTypeOneTimeCode; self.codeTextField.keyboardType = UIKeyboardTypeNumberPad;就这么简单。系统的识别逻辑要求短信文本里有明显的验证码占位信息和有效期描述,比如"您的验证码是123456,5分钟内有效",系统才能准确识别。你只管保证模板内容结构清晰。
还有,如果你的App部署了iOS 17以上的系统,验证码自动填充的隐私策略更严格了,系统只会在短信来自同一应用域名或关联域名时自动推荐,所以如果你自己后端在短信模板里使用了Apple的Universal Link关联域名,一定要确认关联配置无误,否则自动填充会失效。这一块在实测时常常被忽略,建议联调时专门测一下不同系统版本下的填充行为。
4. 高性能集成的几个实战策略:不要把短信接口做成卡顿源
短信功能虽然小,但一旦集成方式粗糙,很容易拖累整体App体验。尤其在大促场景下,瞬时发送量很大,接口响应如果有延迟,用户那边转圈几秒就会骂娘。从客户端角度,高性能集成主要看三个方面:异步非阻塞、缓存与批量思想、频率控制策略。
4.1 异步非阻塞是底线
我见过一些公司里的老代码,直接在调用短信接口的地方加了个同步等待,等接口返回才让用户做下一步操作。这在iOS上是大忌,因为网络请求一旦发起,等待时间受网络环境影响极大,可能几秒甚至超时。用户看到的就是界面卡死。
NSURLSession天生是异步的,但前提是你不要在回调里执行耗时操作。如果你的短信功能需要把手机号、场景、设备信息、经纬度等参数打包发送,这个打包过程一般很快,但如果涉及加密、签名、压缩,就要注意把计算量大的部分放到后台队列处理。
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ // 这里做参数加密、拼装请求体 NSData *encryptedData = [self encryptParam:params]; dispatch_async(dispatch_get_main_queue(), ^{ // 回到主线程发起UI相关的请求 [self sendRequestWithEncryptedData:encryptedData]; }); });这里的核心原则是:UI永远不要因为短信接口的处理逻辑而被阻塞。发送失败、网络超时,都应当以优雅的方式提示用户,而不是让用户干瞪眼。
4.2 缓存与批量思想:不是所有短信都要实时发
这里我说的缓存,是指客户端层面的结果缓存。比如营销短信里的活动通知,不是一个用户单独触发,而是后台运营批量下发。客户端能做什么?在用户点击"活动页"时,先把活动信息请求下来缓存,然后异步去触发推送服务商的短信通知。这不是一条链路的事,而是多条链路的编排。
但如果你接的是验证码类接口,不要做缓存。验证码的安全性在于一次性、时效性,缓存了验证码、客户端只追求"秒开",反而会导致验证码状态不同步,后端判定已过期,前端还让用户填。这种场景需要的是高频监控、快速反馈,而不是缓存优化。
那"批量"思想体现在哪?体现在你的后端接口设计上。如果同一个用户在短时间内由于误操作连续点了多次发送按钮,后端应该合并请求或者直接按频率限制拦截。客户端层面,按钮的倒计时就是一种防止重复发送的天然批量限制,每次发送前先检查距离上次发送是否超过60秒,这个策略很朴素但很有效。
4.3 防刷策略:客户端能做的和不能做的
短信验证码最大的敌人是刷子。他们用脚本肉鸡手机号批量撞库、批量发送骚扰短信,消耗你的短信配额,骚扰用户体验。客户端能做的防刷有限,因为客户端所有逻辑都能被逆向,所以更合理的分层是:客户端做初级限制,后端做核心限制。
客户端层面可以做的:
- 按钮倒计时禁用(最简单,防不住有经验的刷子);
- 简单滑块验证,一个页面发送验证码之前先要求用户滑动确认;
- 埋点记录用户行为,如果监测到非常规频率,上报到后端。
后端层面必须做的:
- 同一手机号每分钟最多1次、每天最多5次,这是底线;
- 同一IP每分钟最多N次,N由你们的压测数据决定;
- 针对异常行为(如短时间内同一设备变换多个手机号)做风控联动。
我在项目里用过一个很有效的策略:发送验证码前,先要求客户端调用后端的预检接口,后端根据设备指纹、IP、用户行为给出是否允许发送的裁决。对于判定为异常的风险请求,后端直接返回"请求过于频繁,请稍后再试",而不是真的去调用短信服务商下发短信。这样即使有刷子把你的短信配额刷爆,也只能刷到预检接口,真正的短信通道风险小很多。
4.4 弱网环境下的体验优化
移动网络环境参差不齐,地铁、电梯、地下车库里的用户也照样要注册。弱网下发送短信验证码,最常见的现象是请求超时,用户等了几秒没反应,再点一次,又超时。客户端这块有几个可以优化的点。
- 超时时间要合理。默认超时时间通常60秒太长了,用户等不了。我一般设置8秒到12秒,超时之后明确提示"网络不给力,请检查网络设置"。短超时能快速失败,让用户尽早进入重试流程。
- 请求幂等性。手机号加场景加本次会话ID,后端收到重复请求时只返回第一次的处理结果,不重复下发。这个策略放在后端,客户端只需要保证同一个按钮点击事件生成的会话ID不变即可。
- 错误分类型展示。网络错误、服务端错误、频控拦截,这三种情况用户看到的文案应该不一样。统一文案"发送失败"会让用户一头雾水,也不利于客服排查。
NSError *error = ...; if (error.code == NSURLErrorTimedOut) { [self showToast:@"网络超时,请检查网络后重试"]; } else if (error.code == NSURLErrorNotConnectedToInternet) { [self showToast:@"当前无网络连接"]; } else if (error.code == 429) { // 服务端定义的频控码 [self showToast:@"发送太频繁,请稍后再试"]; } else { [self showToast:error.localizedDescription]; }5. 常见问题与排查技巧实录:从收不到短信到审核被拒
这部分是实战里最容易让人抓狂的环节。短信功能集成完,联调时经常遇到各种诡异问题,我把真机调试、抓包、审核这几个场景里的典型坑集中写出来。
5.1 收不到短信,先按链路一层层排查
收不到短信是最常见的故障现象。但"收不到"的原因千奇百怪,千万不要一上来就怀疑SDK或者服务商。我按排查顺序写下来:
- 手机号是否输错。这个看似废话,但真很多用户输错自己手机号,验证码发到别人那里去了。所以界面要明确展示"发送至尾号XXXX"的提示。
- 短信是否被拦截。现在的手机都有骚扰拦截功能,尤其iOS 11之后原生支持SMS过滤扩展,有些拦截App会把验证码短信也吞掉。建议用户查一下"设置-信息-未知与过滤信息"名单。
- 短信服务商状态是否正常。去服务商控制台查发送记录,看返回状态,如果发送失败,常见原因是签名或模板审核不通过、当日限额耗尽、服务商通道异常。
- 后端是否真的调用了短信服务商接口。联调时经常出现后端代码没部署,或者配置环境错误,导致实际请求没发出去。这一点可以通过后端日志确认。
如果以上都没问题,但用户还是说收不到,那就要考虑短信延迟的问题。验证码短信偶尔会有分钟级延迟,这通常不是你的问题,而是服务商通道调度问题。如果体验要求高,可以考虑加一条"如果1分钟未收到,可点击语音验证码"的兜底。
5.2 用Charles抓包调试短信接口
联调排查问题,抓包是逃不掉的手段。iOS上最常用的抓包工具是Charles,配合手机代理就能看到App发出的HTTP请求。
前提条件是:手机和电脑连同一个局域网,手机Wi-Fi代理设为电脑IP加8080端口,Charles上开启SSL Proxying并安装证书到手机。这里有个坑,iOS 10.3之后安装Charles证书后还需要到"设置-通用-关于本机-证书信任设置"里手动开启完全信任,否则HTTPS请求还是解密不了。
抓包主要看什么?看短信发送接口的请求参数、响应体、状态码。比如你发现前端发了phone字段,但后端报"签名错误",那说明签名的生成逻辑有问题;如果你看到请求根本没有发出去,那就是ATS或网络权限配置的问题,Charles里连一条记录都不会有。
现在的App普遍做了HTTPS证书校验,也就是防中间人攻击的证书锁定(SSL Pinning)。如果你在代码里做这层加固,Charles抓包会显示证书错误,需要临时在DEBUG模式下关闭证书锁定,或者把Charles的证书加到信任列表。这个属于开发期操作,注意不要把这个关闭逻辑带到线上包。
5.3 真机调试中的开发者模式、证书与签名问题
短信功能必须在真机上才能完整测试,模拟器上收不到短信。真机调试会碰到几个高发问题。
- 设备未进入开发者模式。iOS 16之后,你需要到"设置-隐私与安全性-开发者模式"里手动开启。如果没开启,Xcode运行时会报Could not launch,网上很多人都卡在第一步就是这里。
- 签名和UDID配对问题。免费开发者账号创建的证书只有7天有效期,过期之后再调试就要重新签名。除非你买了99美元一年的开发者账号,否则要做好每次过期都重签的准备。
- 推送证书过期。如果你的短信功能同时关联了推送,推送证书过期后推送发不出去,但短信本身不受影响。排查时注意区分。
遇到真机连接报错,最快的定位方法是看Xcode的Device窗口和Console日志。Console日志里能看到App崩溃堆栈,很多时候短信SDK初始化失败的异常信息都在这里。如果你用的是自签证书,要注意证书信任设置里有没有添加对应的信任,否则接口请求同样会失败。
5.4 上架审核中最容易踩的坑
短信功能最容易在上架环节被App Review盯上,主要集中在几个方面。
第一是短信权限的描述。如果你的App申请了读取短信权限(这几乎用不到,但有些SDK会悄悄申请),审核员会问你要用途说明。实际上验证码自动填充不需要读取短信权限,那是系统级能力。如果你集成第三方SDK时发现权限列表里多了读取短信的权限,建议直接换一个SDK,这种权限在审核中极其危险。
第二是隐私政策。只要App有短信验证码,就必须在隐私政策里明确说明手机号用于什么目的、如何存储、如何保护。审核被拒时最常见的原因就是隐私政策链接打不开,或者内容里没提短信验证码相关的数据使用条款。
第三是过度营销。如果你的App有营销短信功能,审核员会重点看用户是否明确同意接收。也就是说,在用户注册或下单时,营销短信请求必须是独立的、可选的勾选项,不能捆绑在"我已阅读协议"里面。我那次审核被拒,就是因为默认勾选了"同意接收活动通知",后面把这个勾选框改成默认不勾选,同时增加独立的订阅页面,再审就过了。
还有两个细节容易被忽略:
- 短信验证码的发送按钮文案。Apple不太喜欢诱导性文案,比如"立即免费领取验证码"这种带营销倾向的表述,尽量用中性的"获取验证码"就行。
- 应用图标与短信签名的一致性。签名通常放在短信开头,格式是【某某】,这个某某必须和你的应用名称、品牌名一致,否则容易被审核认定为虚假标识。
6. 最后分享几个短信用久了才体会到的经验
上面那条关于审核的分享,是我被拒一次之后老老实实总结出来的,这里就不再重复说"一定要看审核指南"这种空话了。说三个更细的经验,可能对你有用。
第一个经验是会话ID的设计。我在项目里会让后端生成一个sendId返回给客户端,客户端校验验证码时把sendId一起传过去。这样即使同一个手机号短时间内发了多次验证码,后端也能精确定位用户到底在校验哪一次发送的验证码,不会因为验证码覆盖导致用户填了正确的码却报错。
第二个经验是短信回执状态的回传。短信服务商在下发之后会异步回报送达结果,例如成功、失败、已读等状态。我建议后端接一个回调接口,把这些状态记录下来。用户说没收到,客服可以快速查到"短信已到达"还是"运营商网关失败",省去大量扯皮。
第三个经验是关于多渠道降级的。大促期间短信通道非常容易拥堵,这时候如果你能支持语音验证码作为降级方案,用户体验会好很多。很多短信服务商同时提供语音验证码接口,逻辑几乎一样,只是从文字短信变成电话播报六位数字。客户端只需要在后端返回"短信发送失败,建议走语音验证码"时,弹窗让用户选择听语音验证码。这个功能不复杂,却能在关键时刻救回一批用户。
短信接口集成看着是个小功能,实际上涉及客户端、服务端、服务商、运营商、审核一共五层。把它打通不难,但真正做到高性能、不踩坑、稳定上线,需要把每一层的细节都照顾到。希望这篇文章能帮你把链路理清楚,少走点弯路。