// // KBNetworkManager.m // CustomKeyboard // #import "KBNetworkManager.h" #import "AFNetworking.h" #import "KBAuthManager.h" NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network"; @interface KBNetworkManager () @property (nonatomic, strong) AFHTTPSessionManager *manager; // AFN 管理器(ephemeral 配置) // 私有错误派发 - (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion; @end #if DEBUG // 在使用到 Debug 辅助方法之前做前置声明,避免出现 // “No visible @interface declares the selector …” 的编译错误。 @interface KBNetworkManager (Debug) - (NSString *)kb_prettyJSONStringFromObject:(id)obj; - (NSString *)kb_textFromData:(NSData *)data; - (NSString *)kb_trimmedString:(NSString *)s maxLength:(NSUInteger)maxLen; @end #endif @implementation KBNetworkManager + (instancetype)shared { static KBNetworkManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBNetworkManager new]; }); return m; } - (instancetype)init { if (self = [super init]) { _enabled = NO; // 键盘扩展默认无网络能力,需外部显式开启 _timeout = 10.0; // 默认接受任意类型,避免下载图片/二进制被服务端基于 Accept 拒绝 _defaultHeaders = @{ @"Accept": @"*/*" }; // 设置基础域名,路径可相对该地址拼接 _baseURL = [NSURL URLWithString:KB_BASE_URL]; } return self; } #pragma mark - Public - (NSURLSessionDataTask *)GET:(NSString *)path parameters:(NSDictionary *)parameters headers:(NSDictionary *)headers completion:(KBNetworkCompletion)completion { if (![self ensureEnabled:completion]) return nil; NSString *urlString = [self buildURLStringWithPath:path]; if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; } // 使用 AFHTTPRequestSerializer 生成带参数与头的请求 AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer]; serializer.timeoutInterval = self.timeout; NSError *serror = nil; NSMutableURLRequest *req = [serializer requestWithMethod:@"GET" URLString:urlString parameters:parameters error:&serror]; if (serror || !req) { if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: @"无效的URL"}]); return nil; } [self applyHeaders:headers toMutableRequest:req contentType:nil]; #if DEBUG // 打印请求信息(GET) NSString *paramStr = [self kb_prettyJSONStringFromObject:parameters] ?: @"(null)"; KBLOG(@"HTTP GET\nURL: %@\nHeaders: %@\n参数: %@", req.URL.absoluteString, req.allHTTPHeaderFields ?: @{}, paramStr); #endif return [self startAFTaskWithRequest:req completion:completion]; } - (NSURLSessionDataTask *)POST:(NSString *)path jsonBody:(id)jsonBody headers:(NSDictionary *)headers completion:(KBNetworkCompletion)completion { if (![self ensureEnabled:completion]) return nil; NSString *urlString = [self buildURLStringWithPath:path]; if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; } // 用 JSON 序列化器生成 JSON Body 的请求 AFJSONRequestSerializer *serializer = [AFJSONRequestSerializer serializer]; serializer.timeoutInterval = self.timeout; NSError *error = nil; NSMutableURLRequest *req = [serializer requestWithMethod:@"POST" URLString:urlString parameters:jsonBody error:&error]; if (error) { if (completion) completion(nil, nil, error); return nil; } [self applyHeaders:headers toMutableRequest:req contentType:nil]; #if DEBUG // 打印请求信息(POST JSON) NSString *bodyStr = [self kb_prettyJSONStringFromObject:jsonBody] ?: @"(null)"; KBLOG(@"HTTP POST\nURL: %@\nHeaders: %@\nJSON: %@", req.URL.absoluteString, req.allHTTPHeaderFields ?: @{}, bodyStr); #endif return [self startAFTaskWithRequest:req completion:completion]; } #pragma mark - Core - (BOOL)ensureEnabled:(KBNetworkCompletion)completion { if (!self.isEnabled) { NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: @"网络未启用(可能未开启完全访问)"}]; if (completion) completion(nil, nil, e); return NO; } return YES; } - (NSString *)buildURLStringWithPath:(NSString *)path { if (path.length == 0) return nil; if ([path hasPrefix:@"http://"] || [path hasPrefix:@"https://"]) { return path; } if (self.baseURL) { return [NSURL URLWithString:path relativeToURL:self.baseURL].absoluteURL.absoluteString; } return path; // 当无 baseURL 且 path 不是完整 URL 时,让 AFN 处理(可能失败) } - (void)applyHeaders:(NSDictionary *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType { // 合并默认头与局部头,并注入授权头(若可用)。局部覆盖优先。 NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new]; NSDictionary *auth = [[KBAuthManager shared] authorizationHeader]; [auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }]; if (contentType) all[@"Content-Type"] = contentType; [headers enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }]; [all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }]; } - (NSURLSessionDataTask *)startAFTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion { // 响应先用原始数据返回,再按 Content-Type 解析 JSON(与原实现一致) self.manager.responseSerializer = [AFHTTPResponseSerializer serializer]; NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) { // AFN 默认对非 2xx 的状态码返回 error;这里先日志,再直接回调上层 if (error) { #if DEBUG NSInteger status = [response isKindOfClass:NSHTTPURLResponse.class] ? ((NSHTTPURLResponse *)response).statusCode : 0; KBLOG(@"请求失败\nURL: %@\n状态: %ld\n错误: %@\nUserInfo: %@", req.URL.absoluteString, (long)status, error.localizedDescription, error.userInfo ?: @{}); #endif if (completion) completion(nil, response, error); return; } NSData *data = (NSData *)responseObject; if (![data isKindOfClass:[NSData class]]) { #if DEBUG KBLOG(@"无效响应\nURL: %@\n说明: %@", req.URL.absoluteString, @"未获取到数据"); #endif if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:@"无数据"}]); return; } NSString *ct = nil; if ([response isKindOfClass:[NSHTTPURLResponse class]]) { ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"]; } // 更宽松的 JSON 判定:Content-Type 里包含 json;或首字符是 { / [ BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]); if (!looksJSON) { // 内容嗅探(不依赖服务端声明) const unsigned char *bytes = data.bytes; NSUInteger len = data.length; for (NSUInteger i = 0; !looksJSON && i < len; i++) { unsigned char c = bytes[i]; if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue; looksJSON = (c == '{' || c == '['); break; } } if (looksJSON) { NSError *jsonErr = nil; id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr]; if (jsonErr) { #if DEBUG KBLOG(@"响应解析失败(JSON)\nURL: %@\n错误: %@", req.URL.absoluteString, jsonErr.localizedDescription); #endif if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:@"JSON解析失败"}]); return; } #if DEBUG NSString *pretty = [self kb_prettyJSONStringFromObject:json] ?: @"(null)"; pretty = [self kb_trimmedString:pretty maxLength:4096]; NSInteger status = [response isKindOfClass:NSHTTPURLResponse.class] ? ((NSHTTPURLResponse *)response).statusCode : 0; KBLOG(@"响应成功(JSON)\nURL: %@\n状态: %ld\nContent-Type: %@\n数据: %@", req.URL.absoluteString, (long)status, ct ?: @"", pretty); #endif if (completion) completion(json, response, nil); } else { #if DEBUG NSString *text = [self kb_textFromData:data]; text = [self kb_trimmedString:text maxLength:4096]; NSInteger status = [response isKindOfClass:NSHTTPURLResponse.class] ? ((NSHTTPURLResponse *)response).statusCode : 0; KBLOG(@"响应成功(Data)\nURL: %@\n状态: %ld\nContent-Type: %@\n数据: %@", req.URL.absoluteString, (long)status, ct ?: @"", text); #endif if (completion) completion(data, response, nil); } }]; [task resume]; return task; } #pragma mark - AFHTTPSessionManager - (AFHTTPSessionManager *)manager { if (!_manager) { NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration]; cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; // 不在会话级别设置超时,避免与 per-request 的 serializer.timeoutInterval 产生不一致 if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; } _manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg]; // 默认不使用 JSON 解析器,保持原生数据,再按需解析 _manager.responseSerializer = [AFHTTPResponseSerializer serializer]; } return _manager; } #pragma mark - Private helpers - (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion { NSString *msg = @"网络错误"; switch (code) { case KBNetworkErrorDisabled: msg = @"网络未启用(可能未开启完全访问)"; break; case KBNetworkErrorInvalidURL: msg = @"无效的URL"; break; case KBNetworkErrorInvalidResponse: msg = @"无效的响应"; break; case KBNetworkErrorDecodeFailed: msg = @"解析失败"; break; default: break; } NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: msg}]; if (completion) completion(nil, nil, e); } @end #pragma mark - Debug helpers #if DEBUG @implementation KBNetworkManager (Debug) // 将对象转为漂亮的 JSON 字符串;否则返回 description - (NSString *)kb_prettyJSONStringFromObject:(id)obj { if (!obj || obj == (id)kCFNull) return nil; if (![NSJSONSerialization isValidJSONObject:obj]) { // 非标准 JSON 对象,直接 description return [obj description]; } NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:NSJSONWritingPrettyPrinted error:NULL]; if (!data) return [obj description]; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: [obj description]; } // 尝试把二进制数据转为可读文本;失败则返回占位带长度 - (NSString *)kb_textFromData:(NSData *)data { if (!data) return @"(null)"; if (data.length == 0) return @""; // 优先 UTF-8 NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (text.length > 0) return text; // 再尝试常见编码 NSArray *encs = @[@(NSASCIIStringEncoding), @(NSISOLatin1StringEncoding), @(NSUnicodeStringEncoding)]; for (NSNumber *n in encs) { text = [[NSString alloc] initWithData:data encoding:n.unsignedIntegerValue]; if (text.length > 0) return text; } return [NSString stringWithFormat:@"", (unsigned long)data.length]; } // 过长时裁剪,避免刷屏 - (NSString *)kb_trimmedString:(NSString *)s maxLength:(NSUInteger)maxLen { if (!s) return @""; if (s.length <= maxLen) return s; NSString *head = [s substringToIndex:maxLen]; return [head stringByAppendingString:@"\n......"]; } @end #endif