一个蛋疼的需求
双十一前夕的开发周期,我负责了一个有点麻烦的需求,简单来说就是“产品经理”希望在App的导航栏颜色上做文章,他需要h5活动页可配置白色导航栏。其实对于这种需求,在“产品经理”眼中就是一个小case,对于我来说,还是感觉头脑嗡嗡嗡,如闪电轰鸣啊,[参考配图],=_=!。
一种low逼的解决方案
读者也许会嘲笑我,不就是配置导航栏颜色嘛,有什么困难的呢?诸君且听我简单说一下背景,当时负责api开发的同事过两天就要离职了,说实话他的心思已经不在公司的任务,对于他而言,早点脱身,哪管身后洪水滔天。所以他想了一个最简单的实现方案,就是在url链接后面拼接参数,参数名叫做bgColor和textColor,分别表示导航栏颜色和文字颜色,举个例子来说吧,h5的url链接可能是这样的,http://hostname/activity?id=123321&bgColor=0x222222&textColor=0xfffeda
。
看见这样的解决方案时候,我内心感觉这样真实low逼,本身是一个简单的url,现在拼接了莫名其妙的参数,虽然不至于影响显示效果,可是说不定哪一天因为url长度太长,导致不能分享到第三方,例如微信、微博。可是low逼归low逼,还是得硬着头皮做啊。
在做这个需求的时候,我心想,服务端返回的url拼接了参数bgColor和textColor,那我在客户端解析参数就得了呗。当时为了省事也想直接判断url是否包含bgColor和textColor这样的字符串,如果有这些字符串,直接设置导航栏颜色得了,也没必要花多少力气去解析url的参数了。
可是呢,又觉得服务端的方案已经够low逼了,客户端再low逼一下,代码的质量就是这样下降的哦。然后脑子一冲动,心想使用炫酷的方式来对url进行解析吧。
定下了这样解决问题的基调,接下来就是实现url解析参数的工作了,其实呢,动脑子想想,解析url参数其实就是获取url中&
和=
两边的数据,按照编写OC代码的习惯,很容易通过OC来解决这样的问题,如下代码所示,
NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
for (NSString *param in [url componentsSeparatedByString:@"&"]) {
NSArray *elements = [param componentsSeparatedByString:@"="];
if([elements count] < 2)
continue;
[params setObject:[elements lastObject] forKey:[elements firstObject]];
}
这段代码参考了stackoverflow的内容,原文链接parse nsurl query property,其实这段代码已经写的比较简洁明了,并且条件判断if ([elements count] < 2) continue;
是很精髓的代码,至于为什么说这样精髓,稍后再作解释。
这样直接拿到url字符串在ViewController的viewDidLoad中解析,难免增加了ViewController的代码量,所以可以将上面的代码封装一下,作为NSURL的category,简单扩展一下,可以新建NSURL+QueryParse的category,如下代码所示,
// NSURL+QueryParse.h
#import <Foundation/Foundation.h>
@interface NSURL (QueryParse)
@property (strong, nonatomic) NSDictionary *queryValues;
@end
// NSURL+QueryParse.m
#import "NSURL+QueryParse.h"
@implementation NSURL (QueryParse)
- (NSDictionary *)queryValues {
NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
for (NSString *param in [self.query componentsSeparatedByString:@"&"]) {
NSArray *elements = [param componentsSeparatedByString:@"="];
if([elements count] < 2) continue;
[params setObject:[elements lastObject] forKey:[elements firstObject]];
}
return params;
}
@end
对NSURL添加一个category方法-queryValues,是一个好方法,而且上面的代码简单易读,熟悉编写OC代码的iOS开发者应该一看就懂了,而且该category方法是该stackoverflow上面点赞最多的回答。然而我却在想,咱项目都在转向swift开发,而且近期我也在研究了一番swift高阶函数的使用,何不尝试下swift的extension实现呢。后来在后面的回答中,看到了下面的代码,正和我意哦,swift的实现代码如下所示,
extension NSURL {
public var queryValues : [String:String] {
get {
guard let q = self.query else { return [:] }
let dic = [String:String]()
return q.componentsSeparatedByString("&")
.map { $0.componentsSeparatedByString("=") }
.reduce(dic) {
var temp = $0
temp[$1[0]] = $1[1].stringByRemovingPercentEncoding
return temp }
}
}
}
针对上述代码,我来以http://hostname/activity?id=123321&bgColor=0x222222&textColor=0xfffeda
URL为例,做个简单的解释,
该extension为NSURL添加了queryValues属性,self.query就是一个URL从?
之后的查询参数字符串,即为id=123321&bgColor=0x222222&textColor=0xfffeda
;接下来的语法q.componentsSeparatedByString("&")
表示将查询参数字符串以&
作间隔切分为数组,切分过后数组为[id=123321, bgColor=0x222222, textColor=0xfffeda]
;然后呢,又对该数组做map映射操作,就是将数组中的每个元素以=
为间隔来进行切分,得到了这样的结果,[[id, 123321], [bgColor, 0x222222], [textColor, 0xfffeda]]
;最后再使用reduce操作将所有的内容组合成字典,取每个子数组的第0个元素作为key、第1个元素作为value,则形成的temp字典为[id: 123321, bgColor: 0x222222, textColor:0xfffeda]。
我对上面的代码做了简单的解释之后,相信各位读者都明白解析参数的过程。这在正常的情况下,运行都OK;我也多次试验反复确认没问题之后就提交了代码,然后开始开发其他模块的内容。
一种意想不到的崩溃方式
但是,上面的代码存在一个问题,那就是处理不太标准或者说参数不完整的url,就有问题了,例如看看下面的url,string: "http://hostname/?&a=b&c=d&c1=d2&n1=&n2&
,它有什么问题呢。首先,我们来回忆一下,比较符合我们思维中固有常识的url是什么样的标准格式,大概罗列一下,有如下几点,
-
&
两边是key=value
的参数形式 -
=
两边包含key, value
但是上面的url格式,则不符合我们的常识,例如n1=
只有key,而没有value;再比如n2
只有key,连=
都没有。然而这样的url也是合法的url,我却没有考虑这样比较异常的情况,这时,通过调用NSURL扩展的queryValues属性,则直接导致了崩溃,如下代码所示,
let url = URL(string: "http://hostname/?&a=b&c=d&c1=d2&n1=&n2&")!
let params = url.queryValues
分解一下执行的步骤,分析崩溃在什么地方呢,如下过程所示,
- 第一步,
q = self.query
,q的值为&a=b&c=d&c1=d2&n1=&n2&
,OK,没有什么问题, - 第二步,
map { $0.componentsSeparatedByString("&") }
,得到结果为数组[nil, a=b, c=d, n1=, n2]
,OK,也没有什么问题, - 第三步,
map { $0.componentsSeparatedByString("=")
,该过程作用于数组[nil, a=b, c=d, n1=, n2]
中的每个元素,到最后一个元素n2
时,直接调用componentsSeparatedByString("=")
,OK,没问题,它生成了数组[n2]
,继续, - 第四步,
temp[$1[0]] = $1[1].stringByRemovingPercentEncoding
,回顾一下上一步的参数n2
以及数组[n2]
,这时候$1[0]
即为n2
,$1[1]
为nil
,此时将nil
作为字典temp的value,导致崩溃。
好了,分析了过程,我来说说带来的恶果 --- 这个坑直接带来双十一期间App崩溃率急剧上升,达到了0.8%。说真的,别人双十一愉快剁手,我却亚历山大,彻夜难眠。
之所以这个坑影响的范围如此之大,是因为上面的语句调用let params = url.queryValues
直接发生在了一个通用的h5容器里面,在互联网+电商公司工作的人肯定知道,一般来说,稳定的业务比如商品详情、购物车、下单流程基本用原生代码居多;而促销活动或双十一推送,大多是通过h5来展现给用户的。而双十一当天,我公司的运营推送的内容,在h5页面就因为url参数出现了诸如http://hostname/?&a=b&c=d&c1=d2&n1=&n2&
这样不符合我们常识但却合法的参数,导致App推送的内容崩溃。
后来我看了Bugly上面的崩溃日志,当晚大约有2000条左右的崩溃,在解决了运营端的bug之后,崩溃次数趋于减少;第二天崩溃又猛增了500次有做。如果以2500崩溃总数计算,用户购买转化率为5%,每个付费用户购买800元计算,则损失的交易额为tradeMoney = 2500 * 5% * 800 = 100 000
,所以,差不多是10w元的损失。说多也不多,也不能说少,我估计会有很多潜在的损失,比如用户怒删App。
一下午心碎的热修复
双十一是在周五,当天下午崩溃数量又有所上升,我坐立不安,停下手中的任务,开始着手写热修复的JS代码,因为我怕项目经理让我修复的时候时间来不及,还不如及早进行。
热修复时候,想了多种方法,比如在h5容器对应的ViewController内部,当执行viewDidLoad时候,将可能错误的url拼接缺少的=
,但是试了一会,发现url竟然定义成了private的,所以只能另寻其他门道。
后来想想,还是从修改NSURL的queryValues着手,如上所述,我为NSURL扩展了queryValues属性,其实可以把它想象成OC中的-queryValues方法,那么就是用JSPatch修复-queryValues的方法罢了,具体实现过程大体就是上述的OC的category NSURL+QueryParse代码,如下是修复该崩溃的JSPatch代码,
defineClass('NSURL', {
queryValues: function() {
/*
NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
for (NSString *param in [self.query componentsSeparatedByString:@"&"]) {
NSArray *elements = [param componentsSeparatedByString:@"="];
if([elements count] < 2) continue;
[params setObject:[elements lastObject] forKey:[elements firstObject]];
}
return params;
*/
var params = NSMutableDictionary.alloc().init()
var temps = self.query().componentsSeparatedByString("&").toJS() // 第1点
for (var i = 0; i < temps.length; i++) {
var param = temps[i]
var paramStr = NSString.stringWithString(param) // 第2点
var elements = paramStr.componentsSeparatedByString("=")
if (elements.count < 2) {
continue
}
//console("element is " + elements)
params.setObject_forKey(elements.lastObject(), elements.firstObject())
}
return params
},
})
我将OC的源码也写在了注释里面,上面的JSPatch热修复代码并不难理解,有3个地方着重说明一下,
1. 使用toJS()将OC数组转为JavaScript数组
var temps = self.query().componentsSeparatedByString("&").toJS()
for (var i = 0;i < temps.length; i++) {
}
这段代码使用toJS()将切片之后的数组转换为JavaScript的数组,所以在for循环中需要使用length
获取数组长度。
2. 将JavaScript字符串转换为OC字符串,以便调用对应方法
var paramStr = NSString.stringWithString(param)
var elements = paramStr.componentsSeparatedByString("=")
这段代码,将param转换成OC中的NSString字符串,是因为如果不转换,则该字符串是JavaScript字符串,不能代用下面的componentsSeparatedByString
方法。
3. 判断分解的参数是否小于2
if (elements.count < 2) {
continue
}
这条判断语句处理了分解的数组是否小于2,以上面的n2
参数举例,此时分解的数组为[n2]
,遇到此判断条件时候,直接忽略continue
后面的语句,进行for的下一次循环,这也是文章前面所说的比较精髓的地方。
上述3点,我在写JS热修复的时候在前2点耽误了不少时间,也是对JSPatch文档阅读不够到位,希望读者可以避过这些坑。
备注:上面的热修复代码,翻译了
NSURL+QueryParse
的OC代码,这段代码还有点问题,就是没有处理urlencode的情况。
一点小小的总结
写代码还是以稳妥为主,保证不出现重大的问题,毕竟自己以为创新性的实现方式,说不定就要踩到坑里面了。特别是项目中比较底层、通用的模块,更加不能随便改动,比如我这次踩的大坑,就是改动了项目中多数h5页面使用的容器类。一把眼泪啊。