diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index bc23c2f..84b5647 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -33,10 +33,13 @@ 04FC95D82EB1EA16007BD342 /* BaseCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95D42EB1EA16007BD342 /* BaseCell.m */; }; 04FC95DD2EB202A3007BD342 /* KBGuideVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95DC2EB202A3007BD342 /* KBGuideVC.m */; }; 04FC95E52EB220B5007BD342 /* UIColor+Extension.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95E42EB220B5007BD342 /* UIColor+Extension.m */; }; + 04FC95E92EB23B67007BD342 /* KBNetworkManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95E72EB23B67007BD342 /* KBNetworkManager.m */; }; 04FC97002EB30A00007BD342 /* KBGuideTopCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC96FF2EB30A00007BD342 /* KBGuideTopCell.m */; }; 04FC97032EB30A00007BD342 /* KBGuideKFCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC97022EB30A00007BD342 /* KBGuideKFCell.m */; }; 04FC97062EB30A00007BD342 /* KBGuideUserCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC97052EB30A00007BD342 /* KBGuideUserCell.m */; }; 04FC97092EB31B14007BD342 /* KBHUD.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC97082EB31B14007BD342 /* KBHUD.m */; }; + 04FC970E2EB334F8007BD342 /* UIImageView+KBWebImage.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC970B2EB334F8007BD342 /* UIImageView+KBWebImage.m */; }; + 04FC970F2EB334F8007BD342 /* KBWebImageManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC970D2EB334F8007BD342 /* KBWebImageManager.m */; }; 7A36414DFDA5BEC9B7D2E318 /* Pods_CustomKeyboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C1092FB2B452F95B15D4263 /* Pods_CustomKeyboard.framework */; }; A1B2C3D42EB0A0A100000001 /* KBFunctionTagCell.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D32EB0A0A100000001 /* KBFunctionTagCell.m */; }; A1B2C3E22EB0C0A100000001 /* KBNetworkManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3E12EB0C0A100000001 /* KBNetworkManager.m */; }; @@ -119,6 +122,8 @@ 04FC95DC2EB202A3007BD342 /* KBGuideVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBGuideVC.m; sourceTree = ""; }; 04FC95E32EB220B5007BD342 /* UIColor+Extension.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIColor+Extension.h"; sourceTree = ""; }; 04FC95E42EB220B5007BD342 /* UIColor+Extension.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIColor+Extension.m"; sourceTree = ""; }; + 04FC95E62EB23B67007BD342 /* KBNetworkManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBNetworkManager.h; sourceTree = ""; }; + 04FC95E72EB23B67007BD342 /* KBNetworkManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBNetworkManager.m; sourceTree = ""; }; 04FC96FE2EB30A00007BD342 /* KBGuideTopCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBGuideTopCell.h; sourceTree = ""; }; 04FC96FF2EB30A00007BD342 /* KBGuideTopCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBGuideTopCell.m; sourceTree = ""; }; 04FC97012EB30A00007BD342 /* KBGuideKFCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBGuideKFCell.h; sourceTree = ""; }; @@ -127,6 +132,10 @@ 04FC97052EB30A00007BD342 /* KBGuideUserCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBGuideUserCell.m; sourceTree = ""; }; 04FC97072EB31B14007BD342 /* KBHUD.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBHUD.h; sourceTree = ""; }; 04FC97082EB31B14007BD342 /* KBHUD.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBHUD.m; sourceTree = ""; }; + 04FC970A2EB334F8007BD342 /* UIImageView+KBWebImage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIImageView+KBWebImage.h"; sourceTree = ""; }; + 04FC970B2EB334F8007BD342 /* UIImageView+KBWebImage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIImageView+KBWebImage.m"; sourceTree = ""; }; + 04FC970C2EB334F8007BD342 /* KBWebImageManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBWebImageManager.h; sourceTree = ""; }; + 04FC970D2EB334F8007BD342 /* KBWebImageManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBWebImageManager.m; sourceTree = ""; }; 2C1092FB2B452F95B15D4263 /* Pods_CustomKeyboard.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_CustomKeyboard.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 51FE7C4C42C2255B3C1C4128 /* Pods-keyBoard.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-keyBoard.release.xcconfig"; path = "Target Support Files/Pods-keyBoard/Pods-keyBoard.release.xcconfig"; sourceTree = ""; }; 727EC7532EAF848B00B36487 /* keyBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = keyBoard.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -338,6 +347,7 @@ 04FC95BF2EB1E3B1007BD342 /* Class */ = { isa = PBXGroup; children = ( + 04FC95E82EB23B67007BD342 /* Network */, 04FC95E22EB2208F007BD342 /* Categories */, 04FC95E12EB20AD1007BD342 /* Guard */, 04FC95C62EB1E4AB007BD342 /* Base */, @@ -429,12 +439,25 @@ children = ( 04FC95E32EB220B5007BD342 /* UIColor+Extension.h */, 04FC95E42EB220B5007BD342 /* UIColor+Extension.m */, + 04FC970A2EB334F8007BD342 /* UIImageView+KBWebImage.h */, + 04FC970B2EB334F8007BD342 /* UIImageView+KBWebImage.m */, + 04FC970C2EB334F8007BD342 /* KBWebImageManager.h */, + 04FC970D2EB334F8007BD342 /* KBWebImageManager.m */, 04FC97072EB31B14007BD342 /* KBHUD.h */, 04FC97082EB31B14007BD342 /* KBHUD.m */, ); path = Categories; sourceTree = ""; }; + 04FC95E82EB23B67007BD342 /* Network */ = { + isa = PBXGroup; + children = ( + 04FC95E62EB23B67007BD342 /* KBNetworkManager.h */, + 04FC95E72EB23B67007BD342 /* KBNetworkManager.m */, + ); + path = Network; + sourceTree = ""; + }; 2C53A0856097DCFBE7B55649 /* Pods */ = { isa = PBXGroup; children = ( @@ -615,10 +638,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks.sh\"\n"; @@ -672,6 +699,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 04FC95E92EB23B67007BD342 /* KBNetworkManager.m in Sources */, 04FC95D22EB1E7AE007BD342 /* MyVC.m in Sources */, 043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */, 04C6EABE2EAF86530089C901 /* AppDelegate.m in Sources */, @@ -684,6 +712,8 @@ 04FC97032EB30A00007BD342 /* KBGuideKFCell.m in Sources */, 04FC97062EB30A00007BD342 /* KBGuideUserCell.m in Sources */, 04FC97092EB31B14007BD342 /* KBHUD.m in Sources */, + 04FC970E2EB334F8007BD342 /* UIImageView+KBWebImage.m in Sources */, + 04FC970F2EB334F8007BD342 /* KBWebImageManager.m in Sources */, 04FC95CF2EB1E7A1007BD342 /* HomeVC.m in Sources */, 04C6EABF2EAF86530089C901 /* main.m in Sources */, 04FC95CC2EB1E780007BD342 /* BaseTabBarController.m in Sources */, diff --git a/keyBoard/Class/Categories/KBWebImageManager.h b/keyBoard/Class/Categories/KBWebImageManager.h new file mode 100644 index 0000000..ce81b7e --- /dev/null +++ b/keyBoard/Class/Categories/KBWebImageManager.h @@ -0,0 +1,31 @@ +// +// KBWebImageManager.h +// keyBoard +// +// SDWebImage 的二次封装:全局配置、预取、缓存管理 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface KBWebImageManager : NSObject + +/// 预取一组图片 ++ (void)prefetchURLs:(NSArray *)urlList + progress:(void(^_Nullable)(NSUInteger finished, NSUInteger total))progress + completed:(void(^_Nullable)(NSUInteger finished, NSUInteger skipped))completed; + +/// 异步计算磁盘缓存大小(字节) ++ (void)calculateDiskSize:(void(^)(NSUInteger bytes))completion; + +/// 清理内存+磁盘缓存 ++ (void)clearAllCache:(void(^_Nullable)(void))completion; + +/// 设置全局 HTTP Header(如鉴权 Token) ++ (void)setHTTPHeaderValue:(nullable NSString *)value forKey:(NSString *)field; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/keyBoard/Class/Categories/KBWebImageManager.m b/keyBoard/Class/Categories/KBWebImageManager.m new file mode 100644 index 0000000..4d40ff3 --- /dev/null +++ b/keyBoard/Class/Categories/KBWebImageManager.m @@ -0,0 +1,51 @@ +// +// KBWebImageManager.m +// keyBoard +// + +#import "KBWebImageManager.h" +#import + +static inline NSURL *_KBURL(id url) { + if (!url) return nil; + if ([url isKindOfClass:NSURL.class]) return url; + if ([url isKindOfClass:NSString.class]) return [NSURL URLWithString:(NSString *)url]; + return nil; +} + +@implementation KBWebImageManager + ++ (void)prefetchURLs:(NSArray *)urlList + progress:(void(^)(NSUInteger finished, NSUInteger total))progress + completed:(void(^)(NSUInteger finished, NSUInteger skipped))completed { + NSMutableArray *urls = [NSMutableArray arrayWithCapacity:urlList.count]; + for (id u in urlList) { NSURL *nu = _KBURL(u); if (nu) [urls addObject:nu]; } + [[SDWebImagePrefetcher sharedImagePrefetcher] prefetchURLs:urls progress:^(NSUInteger noOfFinishedUrls, NSUInteger noOfTotalUrls) { + if (progress) progress(noOfFinishedUrls, noOfTotalUrls); + } completed:^(NSUInteger finishedCount, NSUInteger skippedCount) { + if (completed) completed(finishedCount, skippedCount); + }]; +} + ++ (void)calculateDiskSize:(void(^)(NSUInteger bytes))completion { + [[SDImageCache sharedImageCache] calculateSizeWithCompletionBlock:^(NSUInteger fileCount, NSUInteger totalSize) { + if (completion) completion(totalSize); + }]; +} + ++ (void)clearAllCache:(void(^)(void))completion { + SDImageCache *cache = SDImageCache.sharedImageCache; + [cache clearMemory]; + [cache clearDiskOnCompletion:^{ if (completion) completion(); }]; +} + ++ (void)setHTTPHeaderValue:(NSString *)value forKey:(NSString *)field { + if (value.length == 0) { + [[SDWebImageDownloader sharedDownloader] setValue:nil forHTTPHeaderField:field]; + } else { + [[SDWebImageDownloader sharedDownloader] setValue:value forHTTPHeaderField:field]; + } +} + +@end + diff --git a/keyBoard/Class/Categories/UIImageView+KBWebImage.h b/keyBoard/Class/Categories/UIImageView+KBWebImage.h new file mode 100644 index 0000000..c014bdf --- /dev/null +++ b/keyBoard/Class/Categories/UIImageView+KBWebImage.h @@ -0,0 +1,49 @@ +// +// UIImageView+KBWebImage.h +// keyBoard +// +// 在 SDWebImage 基础上的便捷封装: +// - 统一默认 options(失败重试、后台续传、降采样等) +// - 支持按视图大小缩略解码,减少内存占用 +// - 常用形状:普通、圆角、头像圆形 +// - 支持占位图、渐隐动画、取消下载 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIImageView (KBWebImage) + +/// 简单加载(默认 options,自动渐隐) +- (void)kb_setImageURL:(nullable id)url placeholder:(nullable UIImage *)placeholder; + +/// 自定义 options +- (void)kb_setImageURL:(nullable id)url + placeholder:(nullable UIImage *)placeholder + options:(NSUInteger)options; + +/// 按视图大小缩略解码(推荐:内存友好)。若视图尚未布局,可传入 `targetSize` 期望像素尺寸(点×scale)。 +- (void)kb_setImageURL:(nullable id)url + placeholder:(nullable UIImage *)placeholder + thumbnailToFit:(BOOL)thumbnailToFit + targetSize:(CGSize)targetPixelSize; + +/// 圆角图(在解码阶段裁圆角,避免离屏开销) +- (void)kb_setImageURL:(nullable id)url + placeholder:(nullable UIImage *)placeholder + cornerRadius:(CGFloat)radius + corners:(UIRectCorner)corners + borderWidth:(CGFloat)borderWidth + borderColor:(nullable UIColor *)borderColor; + +/// 头像:自动按最短边裁成圆形(若视图尚未布局,会使用当前 bounds 估算) +- (void)kb_setAvatarURL:(nullable id)url placeholder:(nullable UIImage *)placeholder; + +/// 取消当前图的下载 +- (void)kb_cancelImageLoad; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/keyBoard/Class/Categories/UIImageView+KBWebImage.m b/keyBoard/Class/Categories/UIImageView+KBWebImage.m new file mode 100644 index 0000000..7b3a98e --- /dev/null +++ b/keyBoard/Class/Categories/UIImageView+KBWebImage.m @@ -0,0 +1,142 @@ +// +// UIImageView+KBWebImage.m +// keyBoard +// + +#import "UIImageView+KBWebImage.h" +#import + +// 默认下载配置:失败重试、后台续传、降采样、大图优先级 +static inline SDWebImageOptions KBWebImageDefaultOptions(void) { + return SDWebImageRetryFailed | + SDWebImageContinueInBackground | + SDWebImageHighPriority | + SDWebImageScaleDownLargeImages | + SDWebImageProgressiveLoad; // 渐进式 +} + +static inline NSURL *_KBURL(id url) { + if (!url) return nil; + if ([url isKindOfClass:NSURL.class]) return url; + if ([url isKindOfClass:NSString.class]) return [NSURL URLWithString:(NSString *)url]; + return nil; +} + +@implementation UIImageView (KBWebImage) + +- (void)kb_setImageURL:(id)url placeholder:(UIImage *)placeholder { + [self kb_setImageURL:url placeholder:placeholder options:KBWebImageDefaultOptions()]; +} + +- (void)kb_setImageURL:(id)url placeholder:(UIImage *)placeholder options:(NSUInteger)options { + NSURL *u = _KBURL(url); + // 默认渐隐动画 + __weak typeof(self) weakSelf = self; + [self sd_setImageWithURL:u + placeholderImage:placeholder + options:options + progress:nil + completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) { + if (!error && image && cacheType == SDImageCacheTypeNone) { + // 网络新图,做一次淡入动画 + weakSelf.alpha = 0.0; + [UIView animateWithDuration:0.25 animations:^{ weakSelf.alpha = 1.0; }]; + } + }]; +} + +- (void)kb_setImageURL:(id)url + placeholder:(UIImage *)placeholder + thumbnailToFit:(BOOL)thumbnailToFit + targetSize:(CGSize)targetPixelSize { + NSURL *u = _KBURL(url); + SDWebImageOptions options = KBWebImageDefaultOptions(); + NSMutableDictionary *context = [NSMutableDictionary dictionary]; + + if (thumbnailToFit) { + CGFloat scale = UIScreen.mainScreen.scale; + CGSize size = self.bounds.size; + if (CGSizeEqualToSize(size, CGSizeZero) && !CGSizeEqualToSize(targetPixelSize, CGSizeZero)) { + size = CGSizeMake(targetPixelSize.width/scale, targetPixelSize.height/scale); + } + if (!CGSizeEqualToSize(size, CGSizeZero)) { + CGSize pixelSize = CGSizeMake(size.width * scale, size.height * scale); + context[SDWebImageContextImageThumbnailPixelSize] = @(pixelSize); + } + } + + __weak typeof(self) weakSelf = self; + [self sd_setImageWithURL:u + placeholderImage:placeholder + options:options + context:context + progress:nil + completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) { + if (!error && image && cacheType == SDImageCacheTypeNone) { + weakSelf.alpha = 0.0; + [UIView animateWithDuration:0.25 animations:^{ weakSelf.alpha = 1.0; }]; + } + }]; +} + +- (void)kb_setImageURL:(id)url + placeholder:(UIImage *)placeholder + cornerRadius:(CGFloat)radius + corners:(UIRectCorner)corners + borderWidth:(CGFloat)borderWidth + borderColor:(UIColor *)borderColor { + NSURL *u = _KBURL(url); + SDWebImageOptions options = KBWebImageDefaultOptions(); + + // 组合圆角 + 缩略(按视图大小) + CGFloat scale = UIScreen.mainScreen.scale; + CGSize size = self.bounds.size; + NSMutableArray> *trans = [NSMutableArray array]; + if (!CGSizeEqualToSize(size, CGSizeZero)) { + // 先按像素缩到视图大小,避免在解码后再裁切浪费内存 + CGSize pixel = CGSizeMake(size.width * scale, size.height * scale); + [trans addObject:[SDImageResizingTransformer transformerWithSize:pixel scaleMode:SDImageScaleModeAspectFill]]; + } + [trans addObject:[SDImageRoundCornerTransformer transformerWithRadius:radius + corners:(SDRectCorner)corners + borderWidth:borderWidth + borderColor:borderColor]]; + SDImagePipelineTransformer *pipeline = [SDImagePipelineTransformer transformerWithTransformers:trans]; + + NSDictionary *context = @{ SDWebImageContextImageTransformer : pipeline }; + + __weak typeof(self) weakSelf = self; + [self sd_setImageWithURL:u + placeholderImage:placeholder + options:options + context:context + progress:nil + completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) { + if (!error && image && cacheType == SDImageCacheTypeNone) { + weakSelf.alpha = 0.0; + [UIView animateWithDuration:0.25 animations:^{ weakSelf.alpha = 1.0; }]; + } + }]; +} + +- (void)kb_setAvatarURL:(id)url placeholder:(UIImage *)placeholder { + // 圆形:以视图最短边的一半为半径 + CGSize sz = self.bounds.size; + CGFloat r = MIN(sz.width, sz.height) * 0.5; + if (r <= 0) { + // 若尚未布局,使用占位图尺寸估算 + r = MIN(placeholder.size.width, placeholder.size.height) * 0.5; + } + [self kb_setImageURL:url + placeholder:placeholder + cornerRadius:r + corners:UIRectCornerAllCorners + borderWidth:0 + borderColor:nil]; +} + +- (void)kb_cancelImageLoad { + [self sd_cancelCurrentImageLoad]; +} + +@end diff --git a/keyBoard/Class/Home/VC/HomeVC.m b/keyBoard/Class/Home/VC/HomeVC.m index e359f66..792532f 100644 --- a/keyBoard/Class/Home/VC/HomeVC.m +++ b/keyBoard/Class/Home/VC/HomeVC.m @@ -26,6 +26,10 @@ label.center = self.view.center; label.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; [self.view addSubview:label]; + + [[KBNetworkManager shared] GET:@"https://m1.apifoxmock.com/m1/5438099-5113192-default/app/config" parameters:nil headers:nil completion:^(id _Nullable jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error) { + NSLog(@"===="); + }]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ diff --git a/keyBoard/Class/Network/KBNetworkManager.h b/keyBoard/Class/Network/KBNetworkManager.h new file mode 100644 index 0000000..c0dfc9e --- /dev/null +++ b/keyBoard/Class/Network/KBNetworkManager.h @@ -0,0 +1,57 @@ +// +// KBNetworkManager.h +// CustomKeyboard +// +// 轻量网络层封装(扩展安全)。支持 GET/POST(JSON)。 +// 注意:键盘扩展需要"允许完全访问"后才可联网, +// 建议由宿主控制器在确认后调用 `setEnabled:YES` 再发起请求。 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +extern NSErrorDomain const KBNetworkErrorDomain; +typedef NS_ERROR_ENUM(KBNetworkErrorDomain, KBNetworkError) { + KBNetworkErrorDisabled = 1, // 未启用网络(例如未开启完全访问) + KBNetworkErrorInvalidURL = 2, + KBNetworkErrorInvalidResponse = 3, + KBNetworkErrorDecodeFailed = 4, +}; + +/// 简单的 JSON 回调:json 为 NSDictionary/NSArray 或者在非 JSON 情况下返回 NSData +typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error); + +@interface KBNetworkManager : NSObject + +/// 单例 ++ (instancetype)shared; + +/// 是否允许网络(默认为 NO,宿主在合适时机置 YES) +@property (atomic, assign, getter=isEnabled) BOOL enabled; + +/// 可选的基础域名,例如 https://api.example.com +@property (nonatomic, strong, nullable) NSURL *baseURL; + +/// 全局默认请求头(每次请求会与局部 headers 合并,局部优先) +@property (nonatomic, copy) NSDictionary *defaultHeaders; + +/// 超时时间(默认 10s) +@property (nonatomic, assign) NSTimeInterval timeout; + +/// GET 请求,parameters 会拼到 URL 上 +- (nullable NSURLSessionDataTask *)GET:(NSString *)path + parameters:(nullable NSDictionary *)parameters + headers:(nullable NSDictionary *)headers + completion:(KBNetworkCompletion)completion; + +/// POST JSON 请求,jsonBody 会以 application/json 发送 +- (nullable NSURLSessionDataTask *)POST:(NSString *)path + jsonBody:(nullable id)jsonBody + headers:(nullable NSDictionary *)headers + completion:(KBNetworkCompletion)completion; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/keyBoard/Class/Network/KBNetworkManager.m b/keyBoard/Class/Network/KBNetworkManager.m new file mode 100644 index 0000000..a1c415c --- /dev/null +++ b/keyBoard/Class/Network/KBNetworkManager.m @@ -0,0 +1,164 @@ +// +// KBNetworkManager.m +// CustomKeyboard +// + +#import "KBNetworkManager.h" +#import "AFNetworking.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 + +@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; + _defaultHeaders = @{ @"Accept": @"application/json" }; + } + 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; + NSMutableURLRequest *req = [serializer requestWithMethod:@"GET" + URLString:urlString + parameters:parameters + error:NULL]; + [self applyHeaders:headers toMutableRequest:req contentType:nil]; + 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]; + 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]; + 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) { + if (error) { if (completion) completion(nil, response, error); return; } + NSData *data = (NSData *)responseObject; + if (![data isKindOfClass:[NSData class]]) { + 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"]; + } + BOOL looksJSON = (ct && [ct.lowercaseString containsString:@"application/json"]); + if (looksJSON) { + NSError *jsonErr = nil; + id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr]; + if (jsonErr) { if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:@"JSON解析失败"}]); return; } + if (completion) completion(json, response, nil); + } else { + if (completion) completion(data, response, nil); + } + }]; + [task resume]; + return task; +} + +#pragma mark - AFHTTPSessionManager + +- (AFHTTPSessionManager *)manager { + if (!_manager) { + NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + cfg.timeoutIntervalForRequest = self.timeout; + cfg.timeoutIntervalForResource = MAX(self.timeout, 30.0); + 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 diff --git a/keyBoard/KeyBoardPrefixHeader.pch b/keyBoard/KeyBoardPrefixHeader.pch index 6b28040..73dca94 100644 --- a/keyBoard/KeyBoardPrefixHeader.pch +++ b/keyBoard/KeyBoardPrefixHeader.pch @@ -19,6 +19,8 @@ /// 系统 #import #import "KBHUD.h" +#import "KBNetworkManager.h" + /// 项目 #import "BaseNavigationController.h"