iOS 17 中 Apple 对 NSURL 进行了隐式升级,影响比较广,在此分享其中一种修复方式
1. iOS 17 中 NSURL的改动
本次 Apple iOS 17 升级,对 NSURL 类的 URLWithString 进行了隐式升级( 点名批评 ,应用甚广的API竟然硬升级)
对齐了NSURL与 NSURLComponents 的执行标准,统一为 RFC 3986 (各执行标准感兴趣可以自行查阅,RFC 1808、RFC 1738、RFC 2732、RFC 3986)
- iOS 16:URLWithString 判断参数 urlString 如有非法字符(包含中文字符)就返回 nil
- iOS 17:URLWithString 默认对非法字符(包含中文字符)进行%转义
直接看文档,iOS 17 以后URL中如果出现中文字符也可以直接进行编码,这看起来很美好,实际测试时发现有以下问题:
- 如果URL中没有非法字符,那URL中原有的转义字符(%)不会再次转义
- 如果URL中含有非法字符,那URL中原有的转义字符(%)会再次转义 (本次遇到的BUG)
2. 修复代码
/// NSURL+XYAdapter.h 文件
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface NSURL (XYAdapter)
@end
NS_ASSUME_NONNULL_END
/// NSURL+XYAdapter.m 文件
#import "NSURL+XYAdapter.h"
#import <objc/runtime.h>
#pragma mark - URL encode
static NSString * XYAdapterPercentEscapedStringFromString(NSString *string) {
static NSString * const kXYAdapterCharactersGeneralDelimitersToEncode = @":#[]@/?";
static NSString * const kXYAdapterCharactersSubDelimitersToEncode = @"!$&'()*+,;=";
NSMutableCharacterSet * allowedCharacterSet = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
[allowedCharacterSet removeCharactersInString:[kXYAdapterCharactersGeneralDelimitersToEncode stringByAppendingString:kXYAdapterCharactersSubDelimitersToEncode]];
static NSUInteger const batchSize = 50;
NSUInteger index = 0;
NSMutableString *escaped = @"".mutableCopy;
while (index < string.length) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wgnu"
NSUInteger length = MIN(string.length - index, batchSize);
#pragma GCC diagnostic pop
NSRange range = NSMakeRange(index, length);
range = [string rangeOfComposedCharacterSequencesForRange:range];
NSString *substring = [string substringWithRange:range];
NSString *encoded = [substring stringByAddingPercentEncodingWithAllowedCharacters:allowedCharacterSet];
[escaped appendString:encoded];
index += range.length;
}
return escaped;
}
@interface XYAdapterQueryStringPair : NSObject
@property (readwrite, nonatomic, strong) id field;
@property (readwrite, nonatomic, strong) id value;
- (id)initWithField:(id)field value:(id)value;
- (NSString *)URLEncodedStringValue;
@end
@implementation XYAdapterQueryStringPair
- (id)initWithField:(id)field value:(id)value {
self = [super init];
if (self) {
self.field = field;
self.value = value;
}
return self;
}
- (NSString *)URLEncodedStringValue {
if (!self.value || [self.value isEqual:[NSNull null]]) {
return XYAdapterPercentEscapedStringFromString([self.field description]);
} else {
return [NSString stringWithFormat:@"%@=%@", XYAdapterPercentEscapedStringFromString([self.field description]), XYAdapterPercentEscapedStringFromString([self.value description])];
}
}
@end
FOUNDATION_EXPORT NSArray * XYAdapterQueryStringPairsFromDictionary(NSDictionary *dictionary);
FOUNDATION_EXPORT NSArray * XYAdapterQueryStringPairsFromKeyAndValue(NSString *key, id value);
static NSString * XYAdapterQueryStringFromParameters(NSDictionary *parameters) {
NSMutableArray *mutablePairs = [NSMutableArray array];
for (XYAdapterQueryStringPair *pair in XYAdapterQueryStringPairsFromDictionary(parameters)) {
[mutablePairs addObject:[pair URLEncodedStringValue]];
}
return [mutablePairs componentsJoinedByString:@"&"];
}
NSArray * XYAdapterQueryStringPairsFromDictionary(NSDictionary *dictionary) {
return XYAdapterQueryStringPairsFromKeyAndValue(nil, dictionary);
}
NSArray * XYAdapterQueryStringPairsFromKeyAndValue(NSString *key, id value) {
NSMutableArray *mutableQueryStringComponents = [NSMutableArray array];
NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"description" ascending:YES selector:@selector(compare:)];
if ([value isKindOfClass:[NSDictionary class]]) {
NSDictionary *dictionary = value;
for (id nestedKey in [dictionary.allKeys sortedArrayUsingDescriptors:@[sortDescriptor]]) {
id nestedValue = dictionary[nestedKey];
if (nestedValue) {
[mutableQueryStringComponents addObjectsFromArray:XYAdapterQueryStringPairsFromKeyAndValue((key ? [NSString stringWithFormat:@"%@[%@]", key, nestedKey] : nestedKey), nestedValue)];
}
}
} else if ([value isKindOfClass:[NSArray class]]) {
NSArray *array = value;
for (id nestedValue in array) {
[mutableQueryStringComponents addObjectsFromArray:XYAdapterQueryStringPairsFromKeyAndValue([NSString stringWithFormat:@"%@[]", key], nestedValue)];
}
} else if ([value isKindOfClass:[NSSet class]]) {
NSSet *set = value;
for (id obj in [set sortedArrayUsingDescriptors:@[ sortDescriptor ]]) {
[mutableQueryStringComponents addObjectsFromArray:XYAdapterQueryStringPairsFromKeyAndValue(key, obj)];
}
} else {
[mutableQueryStringComponents addObject:[[XYAdapterQueryStringPair alloc] initWithField:key value:value]];
}
return mutableQueryStringComponents;
}
#pragma mark - iOS 17 NSURL Bug
@implementation NSURL (XYAdapter)
+(void)initialize {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (@available(iOS 17.0, *)) {
/*
[NSURL URLWithString:@"A"];
最终调用 [NSURL alloc] initWithString:@"A" relativeToURL:nil];
[[NSURL alloc] initWithString:@"A"];
最终调用 [[NSURL alloc] initWithString:@"A" relativeToURL:nil];
*/
Method oriMethod = class_getInstanceMethod(self, @selector(initWithString:relativeToURL:));
Method newMethod = class_getInstanceMethod(self, @selector(XYAdapter_initWithString:relativeToURL:));
method_exchangeImplementations(oriMethod, newMethod);
}
});
}
- (nullable instancetype)XYAdapter_initWithString:(NSString *)URLString relativeToURL:(nullable NSURL *)baseURL {
if (@available(iOS 17.0, *)) {
/*
不改动 scheme、host、path、params、fragment 以及 baseURL
只对 query 部分重新编码
NSURL 文档:
https://developer.apple.com/documentation/foundation/nsurl?language=objc
URL 标准文档:
RFC 1808: https://datatracker.ietf.org/doc/html/rfc1808
<scheme>://<net_loc>/<path>;<params>?<query>#<fragment>
each of which, except <scheme>, may be absent from a particular URL.
These components are defined as follows (a complete BNF is provided
in Section 2.2):
scheme ":" ::= scheme name, as per Section 2.1 of RFC 1738 [2].
"//" net_loc ::= network location and login information, as per
Section 3.1 of RFC 1738 [2].
"/" path ::= URL path, as per Section 3.1 of RFC 1738 [2].
";" params ::= object parameters (e.g., ";type=a" as in
Section 3.2.2 of RFC 1738 [2]).
"?" query ::= query information, as per Section 3.3 of
RFC 1738 [2].
"#" fragment ::= fragment identifier.
*/
NSString *host = nil; // scheme + net_loc + host + path + params
NSString *query = nil;
NSString *fragment = nil;
NSString *url = URLString ?: @"";
// scheme、net_loc、host、path、params、query
NSRange hostRange = [url rangeOfString:@"?" options:NSBackwardsSearch];
// NSBackwardsSearch 反向查,规避存在多个 ? 的情况
if (hostRange.location != NSNotFound && hostRange.length > 0) {
host = [url substringToIndex:hostRange.location + 1];
if (hostRange.location + 1 < url.length) {
url = [url substringFromIndex:hostRange.location + 1];
} else {
url = @"";
}
} else {
// 无 query
NSRange paramsRange = [url rangeOfString:@";" options:NSBackwardsSearch];
if (paramsRange.location != NSNotFound && paramsRange.length > 0) {
host = [url substringToIndex:paramsRange.location + 1];
if (paramsRange.location + 1 < url.length) {
url = [url substringFromIndex:paramsRange.location + 1];
} else {
url = @"";
}
} else {
// 无 params
NSRange lastPathRange = [url rangeOfString:@"/" options:NSBackwardsSearch];
if (lastPathRange.location != NSNotFound && lastPathRange.length > 0) {
host = [url substringToIndex:lastPathRange.location + 1];
if (lastPathRange.location + 1 < url.length) {
url = [url substringFromIndex:lastPathRange.location + 1];
} else {
url = @"";
}
}
}
}
// fragment
NSRange fragmentRange = [url rangeOfString:@"#"];
if (fragmentRange.location != NSNotFound && fragmentRange.length > 0) {
fragment = [url substringFromIndex:fragmentRange.location];
url = [url substringToIndex:fragmentRange.location];
}
// query
NSRange queryRange1 = [url rangeOfString:@"&"];
NSRange queryRange2 = [url rangeOfString:@"="];
if ((queryRange1.location != NSNotFound && queryRange1.length > 0)
|| (queryRange2.location != NSNotFound && queryRange2.length > 0)) {
NSArray *params = [url componentsSeparatedByString:@"&"];
NSMutableDictionary *kvs = [NSMutableDictionary new];
for (NSString *param in params) {
NSArray *sup = [param componentsSeparatedByString:@"="];
NSString *key = [sup.firstObject stringByRemovingPercentEncoding];
NSString *value = [sup.lastObject stringByRemovingPercentEncoding];
if (sup.lastObject && sup.firstObject) {
[kvs setValue:value forKey:key];
}
}
// encode query
if (kvs.allKeys.count > 0) {
NSString *enodeStr = XYAdapterQueryStringFromParameters(kvs);
if ([enodeStr isKindOfClass:[NSString class]]
&& enodeStr.length > 0) {
query = enodeStr;
url = @"";
}
}
}
NSString *encodeUrl = [NSString stringWithFormat:@"%@%@%@%@", host ?: @"", url?: @"", query?:@"", fragment ?: @""];
if (encodeUrl.length > 0) {
return [self XYAdapter_initWithString:encodeUrl relativeToURL:baseURL];
} else {
return [self XYAdapter_initWithString:URLString relativeToURL:baseURL];
}
} else {
return [self XYAdapter_initWithString:URLString relativeToURL:baseURL];
}
}
@end