iOS17 NSURL解析异常处理

iOS 17 中 Apple 对 NSURL 进行了隐式升级,影响比较广,在此分享其中一种修复方式

1. iOS 17 中 NSURL的改动

本次 Apple iOS 17 升级,对 NSURL 类的 URLWithString 进行了隐式升级( 点名批评  ,应用甚广的API竟然硬升级)

对齐了NSURLNSURLComponents 的执行标准,统一为 RFC 3986 (各执行标准感兴趣可以自行查阅RFC 1808RFC 1738RFC 2732RFC 3986

  • iOS 16:URLWithString 判断参数 urlString 如有非法字符(包含中文字符)就返回 nil
  • iOS 17:URLWithString 默认对非法字符(包含中文字符)进行%转义

直接看文档,iOS 17 以后URL中如果出现中文字符也可以直接进行编码,这看起来很美好,实际测试时发现有以下问题:

  1. 如果URL中没有非法字符,那URL中原有的转义字符(%)不会再次转义
  2. 如果URL中含有非法字符,那URL中原有的转义字符(%)会再次转义 (本次遇到的BUG)
iOS 17 NSURL的改动:.png

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342