diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index 73d4c56..9ef1e72 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -30,6 +30,9 @@ #import "UIImage+KBColor.h" #import #import +#if DEBUG +#import +#endif // #import "KBLog.h" @@ -94,6 +97,8 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, @property(nonatomic, assign) CGFloat kb_lastKeyboardHeight; @property(nonatomic, strong) UIImage *kb_cachedGradientImage; @property(nonatomic, assign) CGSize kb_cachedGradientSize; +@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer; +@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey; @property(nonatomic, strong) NSMutableArray *chatMessages; @property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; @property(nonatomic, assign) BOOL chatPanelVisible; @@ -101,14 +106,45 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, @property(nonatomic, strong, nullable) id kb_skinObserverToken; @end +#if DEBUG +static NSInteger sKBKeyboardVCAliveCount = 0; + +static uint64_t KBPhysFootprintBytes(void) { + task_vm_info_data_t vmInfo; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, + (task_info_t)&vmInfo, &count); + if (kr != KERN_SUCCESS) { + return 0; + } + return (uint64_t)vmInfo.phys_footprint; +} + +static NSString *KBFormatMB(uint64_t bytes) { + double mb = (double)bytes / 1024.0 / 1024.0; + return [NSString stringWithFormat:@"%.1fMB", mb]; +} +#endif + @implementation KeyboardViewController { BOOL _kb_didTriggerLoginDeepLinkOnce; +#if DEBUG + BOOL _kb_debugDidCountAlive; +#endif } - (void)viewDidLoad { [super viewDidLoad]; +#if DEBUG + if (!_kb_debugDidCountAlive) { + _kb_debugDidCountAlive = YES; + sKBKeyboardVCAliveCount += 1; + } + NSLog(@"[Keyboard] KeyboardViewController viewDidLoad alive=%ld self=%p mem=%@", + (long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes())); +#endif // 撤销删除是“上一段删除操作”的临时状态;键盘被系统回收/重建或跨页面回来时应当清空,避免误显示。 [[KBBackspaceUndoManager shared] registerNonClearAction]; [self setupUI]; @@ -149,12 +185,30 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [self kb_applyDefaultSkinIfNeeded]; } +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + // 扩展进程内存上限较小:在系统发出内存警告时主动清理可重建的缓存,降低被系统杀死概率。 + self.kb_cachedGradientImage = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + [[KBSkinManager shared] clearRuntimeImageCaches]; + [[SDImageCache sharedImageCache] clearMemory]; +} + - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // 进入/重新进入输入界面时,清理上一次会话残留的撤销状态与缓存,避免显示“撤销删除”但实际上已不可撤销。 [[KBBackspaceUndoManager shared] registerNonClearAction]; [[KBInputBufferManager shared] resetWithText:@""]; [[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded]; + // 键盘再次出现时,恢复 HUD 容器与主题(viewDidDisappear 里可能已清理图片/缓存)。 + [KBHUD setContainerView:self.view]; + [self kb_ensureKeyBoardMainViewIfNeeded]; + [self kb_applyTheme]; +#if DEBUG + NSLog(@"[Keyboard] viewWillAppear self=%p mem=%@", + self, KBFormatMB(KBPhysFootprintBytes())); +#endif // 注意:微信/QQ 等宿主的 documentContext 可能是“截断窗口”,这里只更新 // liveText,不要把它当作全文 manualSnapshot。 [[KBInputBufferManager shared] @@ -167,6 +221,17 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self kb_releaseMemoryWhenKeyboardHidden]; +#if DEBUG + NSLog(@"[Keyboard] viewWillDisappear self=%p mem=%@", + self, KBFormatMB(KBPhysFootprintBytes())); +#endif +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + // 再兜底一次,防止某些宿主只触发 willDisappear 而未触发 didDisappear。 + [self kb_releaseMemoryWhenKeyboardHidden]; } - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { @@ -196,9 +261,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, CGFloat portraitWidth = [self kb_portraitWidth]; CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; - CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds); - CGFloat outerVerticalInset = KBFit(4.0f); NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:keyboardHeight]; @@ -231,12 +294,6 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.contentView); }]; - // 预置功能面板(默认隐藏),与键盘区域共享相同布局 - self.functionView.hidden = YES; - [self.contentView addSubview:self.functionView]; - [self.functionView mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.contentView); - }]; [self.contentView addSubview:self.keyBoardMainView]; [self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) { @@ -246,15 +303,6 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, make.height.mas_equalTo(keyboardBaseHeight); }]; - [self.contentView addSubview:self.chatPanelView]; - [self.chatPanelView mas_makeConstraints:^(MASConstraintMaker *make) { - make.left.right.equalTo(self.contentView); - make.bottom.equalTo(self.keyBoardMainView.mas_top); - self.chatPanelHeightConstraint = - make.height.mas_equalTo(chatPanelHeight); - }]; - self.chatPanelView.hidden = YES; - // 初始隐藏,避免布局完成前闪烁 self.contentView.hidden = YES; } @@ -396,8 +444,14 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, // 简单显隐切换,复用相同的布局区域 if (show) { [self showChatPanel:NO]; + [self kb_ensureFunctionViewIfNeeded]; + } + if (_functionView) { + _functionView.hidden = !show; + } else if (show) { + // ensure 后按理已存在;这里兜底一次,避免异常情况下状态不一致 + self.functionView.hidden = NO; } - self.functionView.hidden = !show; self.keyBoardMainView.hidden = show; if (show) { @@ -417,7 +471,9 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, // 可选:把当前显示的视图置顶,避免层级遮挡 if (show) { - [self.contentView bringSubviewToFront:self.functionView]; + if (_functionView) { + [self.contentView bringSubviewToFront:_functionView]; + } } else { [self.contentView bringSubviewToFront:self.keyBoardMainView]; } @@ -492,10 +548,13 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, } self.chatPanelVisible = show; if (show) { + [self kb_ensureChatPanelViewIfNeeded]; self.chatPanelView.hidden = NO; self.chatPanelView.alpha = 0.0; [self.contentView bringSubviewToFront:self.chatPanelView]; - self.functionView.hidden = YES; + if (_functionView) { + _functionView.hidden = YES; + } [self hideSubscriptionPanel]; [self showSettingView:NO]; [UIView animateWithDuration:0.2 @@ -506,6 +565,11 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, } completion:nil]; } else { + // 从未创建过聊天面板时,直接返回,避免 show/hide 触发额外内存分配 + if (!_chatPanelView) { + [self kb_updateKeyboardLayoutIfNeeded]; + return; + } [UIView animateWithDuration:0.18 delay:0 options:UIViewAnimationOptionCurveEaseIn @@ -519,6 +583,114 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [self kb_updateKeyboardLayoutIfNeeded]; } +// 延迟创建:仅在用户真正打开功能面板时才创建/布局,降低默认内存占用。 +- (void)kb_ensureFunctionViewIfNeeded { + if (_functionView && _functionView.superview) { + return; + } + KBFunctionView *v = self.functionView; + if (!v.superview) { + v.hidden = YES; + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + } +} + +// 延迟创建:仅在用户打开聊天面板时才创建/布局。 +- (void)kb_ensureChatPanelViewIfNeeded { + if (_chatPanelView && _chatPanelView.superview) { + return; + } + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; + KBChatPanelView *v = self.chatPanelView; + if (!v.superview) { + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.keyBoardMainView.mas_top); + self.chatPanelHeightConstraint = + make.height.mas_equalTo(chatPanelHeight); + }]; + v.hidden = YES; + } +} + +// 延迟创建:键盘主面板(按键区)在隐藏时会被释放;再次显示时需要重建。 +- (void)kb_ensureKeyBoardMainViewIfNeeded { + if (_keyBoardMainView && _keyBoardMainView.superview) { + return; + } + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardBaseHeight = + [self kb_keyboardBaseHeightForWidth:portraitWidth]; + KBKeyBoardMainView *v = self.keyBoardMainView; + if (!v.superview) { + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.contentView); + self.keyBoardMainHeightConstraint = + make.height.mas_equalTo(keyboardBaseHeight); + }]; + } + [self.contentView bringSubviewToFront:v]; +} + +// 键盘隐藏时释放可重建资源(背景图/缓存/非必需面板),降低扩展内存峰值。 +- (void)kb_releaseMemoryWhenKeyboardHidden { + [KBHUD setContainerView:nil]; + self.bgImageView.image = nil; + self.kb_cachedGradientImage = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + [[SDImageCache sharedImageCache] clearMemory]; + + // 聊天相关可能持有音频数据/临时文件,键盘隐藏时直接清空,避免累计占用。 + if (self.chatAudioPlayer) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + if (_chatMessages.count > 0) { + NSString *tmpRoot = NSTemporaryDirectory(); + for (KBChatMessage *msg in _chatMessages.copy) { + if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 && + [msg.audioFilePath hasPrefix:tmpRoot]) { + [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath + error:nil]; + } + } + [_chatMessages removeAllObjects]; + } + + if (_keyBoardMainView) { + [_keyBoardMainView removeFromSuperview]; + _keyBoardMainView = nil; + } + self.keyBoardMainHeightConstraint = nil; + + if (_functionView) { + [_functionView removeFromSuperview]; + _functionView = nil; + } + if (_chatPanelView) { + [_chatPanelView removeFromSuperview]; + _chatPanelView = nil; + } + self.chatPanelVisible = NO; + + if (_subscriptionView) { + [_subscriptionView removeFromSuperview]; + _subscriptionView = nil; + } + if (_settingView) { + [_settingView removeFromSuperview]; + _settingView = nil; + } +} + - (void)showSubscriptionPanel { // 1) 先判断权限:未开启“完全访问”则走引导逻辑 if (![[KBFullAccessManager shared] hasFullAccess]) { @@ -831,7 +1003,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, - (void)chatPanelViewDidTapClose:(KBChatPanelView *)view { // 清空 chatPanelView 内部的消息 - [self.chatPanelView kb_reloadWithMessages:@[]]; + [view kb_reloadWithMessages:@[]]; if (self.chatAudioPlayer.isPlaying) { [self.chatAudioPlayer stop]; } @@ -1553,7 +1725,11 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL); #if DEBUG - NSLog(@"[Keyboard] KeyboardViewController dealloc"); + if (_kb_debugDidCountAlive) { + sKBKeyboardVCAliveCount -= 1; + } + NSLog(@"[Keyboard] KeyboardViewController dealloc alive=%ld self=%p mem=%@", + (long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes())); #endif } @@ -1578,6 +1754,9 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, if (self.contentView.hidden) { self.contentView.hidden = NO; } + if (self.kb_defaultGradientLayer) { + self.kb_defaultGradientLayer.frame = self.bgImageView.bounds; + } } - (void)viewWillTransitionToSize:(CGSize)size @@ -1611,93 +1790,133 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, #pragma mark - Theme - (void)kb_applyTheme { - KBSkinTheme *t = [KBSkinManager shared].current; - UIImage *img = [[KBSkinManager shared] currentBackgroundImage]; - BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t]; - BOOL isDarkMode = [self kb_isDarkModeActive]; - CGSize size = self.bgImageView.bounds.size; - if (isDefaultTheme) { - if (isDarkMode) { + @autoreleasepool { + KBSkinTheme *t = [KBSkinManager shared].current; + UIImage *img = nil; + BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t]; + BOOL isDarkMode = [self kb_isDarkModeActive]; + + NSString *skinId = t.skinId ?: @""; + NSString *themeKey = + [NSString stringWithFormat:@"%@|default=%d|dark=%d", + skinId, isDefaultTheme, isDarkMode]; + BOOL themeChanged = + (self.kb_lastAppliedThemeKey.length == 0 || + ![self.kb_lastAppliedThemeKey isEqualToString:themeKey]); + if (themeChanged) { + self.kb_lastAppliedThemeKey = themeKey; + } + + CGSize size = self.bgImageView.bounds.size; + if (isDefaultTheme) { + if (isDarkMode) { // 暗黑模式:直接使用背景色,不使用图片渲染 // 这样可以避免图片渲染时的色彩空间转换导致颜色不一致 img = nil; self.bgImageView.image = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; // 使用与系统键盘底部完全相同的颜色 if (@available(iOS 13.0, *)) { - // iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E) - // 但为了完美匹配,我们使用动态颜色并直接设置为背景 - UIColor *kbBgColor = - [UIColor colorWithDynamicProvider:^UIColor *_Nonnull( - UITraitCollection *_Nonnull traitCollection) { - if (traitCollection.userInterfaceStyle == - UIUserInterfaceStyleDark) { - // 暗黑模式下系统键盘实际背景色 - return [UIColor colorWithRed:43.0 / 255.0 - green:43.0 / 255.0 - blue:43.0 / 255.0 - alpha:1.0]; - } else { - return [UIColor colorWithRed:209.0 / 255.0 - green:211.0 / 255.0 - blue:219.0 / 255.0 - alpha:1.0]; - } - }]; - self.contentView.backgroundColor = kbBgColor; - self.bgImageView.backgroundColor = kbBgColor; - } else { - UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0 - green:43.0 / 255.0 - blue:43.0 / 255.0 - alpha:1.0]; - self.contentView.backgroundColor = darkColor; - self.bgImageView.backgroundColor = darkColor; - } + // iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E) + // 但为了完美匹配,我们使用动态颜色并直接设置为背景 + UIColor *kbBgColor = + [UIColor colorWithDynamicProvider:^UIColor *_Nonnull( + UITraitCollection *_Nonnull traitCollection) { + if (traitCollection.userInterfaceStyle == + UIUserInterfaceStyleDark) { + // 暗黑模式下系统键盘实际背景色 + return [UIColor colorWithRed:43.0 / 255.0 + green:43.0 / 255.0 + blue:43.0 / 255.0 + alpha:1.0]; + } else { + return [UIColor colorWithRed:209.0 / 255.0 + green:211.0 / 255.0 + blue:219.0 / 255.0 + alpha:1.0]; + } + }]; + self.contentView.backgroundColor = kbBgColor; + self.bgImageView.backgroundColor = kbBgColor; + } else { + UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0 + green:43.0 / 255.0 + blue:43.0 / 255.0 + alpha:1.0]; + self.contentView.backgroundColor = darkColor; + self.bgImageView.backgroundColor = darkColor; + } } else { - // 浅色模式:使用渐变图片 - if (size.width <= 0 || size.height <= 0) { - [self.view layoutIfNeeded]; - size = self.bgImageView.bounds.size; + // 浅色模式:使用渐变层(避免生成大位图导致内存上涨) + if (size.width <= 0 || size.height <= 0) { + [self.view layoutIfNeeded]; + size = self.bgImageView.bounds.size; + } + if (size.width <= 0 || size.height <= 0) { + size = self.view.bounds.size; + } + if (size.width <= 0 || size.height <= 0) { + size = [UIScreen mainScreen].bounds.size; + } + UIColor *topColor = [UIColor colorWithHex:0xDEDFE4]; + UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB]; + UIColor *resolvedTopColor = topColor; + UIColor *resolvedBottomColor = bottomColor; + if (@available(iOS 13.0, *)) { + resolvedTopColor = + [topColor resolvedColorWithTraitCollection:self.traitCollection]; + resolvedBottomColor = [bottomColor + resolvedColorWithTraitCollection:self.traitCollection]; + } + CAGradientLayer *layer = self.kb_defaultGradientLayer; + if (!layer) { + layer = [CAGradientLayer layer]; + layer.startPoint = CGPointMake(0.5, 0.0); + layer.endPoint = CGPointMake(0.5, 1.0); + [self.bgImageView.layer insertSublayer:layer atIndex:0]; + self.kb_defaultGradientLayer = layer; + } + layer.colors = @[ + (id)resolvedTopColor.CGColor, + (id)resolvedBottomColor.CGColor + ]; + layer.frame = (CGRect){CGPointZero, size}; + img = nil; + self.bgImageView.image = nil; + self.contentView.backgroundColor = [UIColor clearColor]; + self.bgImageView.backgroundColor = [UIColor clearColor]; } - if (size.width <= 0 || size.height <= 0) { - size = self.view.bounds.size; - } - if (size.width <= 0 || size.height <= 0) { - size = [UIScreen mainScreen].bounds.size; - } - UIColor *topColor = [UIColor colorWithHex:0xDEDFE4]; - UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB]; -// img = [self kb_defaultGradientImageWithSize:size -// topColor:topColor -// bottomColor:bottomColor]; + NSLog(@"==="); + } else { + // 自定义皮肤:清除背景色,使用皮肤图片 self.contentView.backgroundColor = [UIColor clearColor]; self.bgImageView.backgroundColor = [UIColor clearColor]; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + img = [[KBSkinManager shared] currentBackgroundImage]; } - NSLog(@"==="); - } else { - // 自定义皮肤:清除背景色,使用皮肤图片 - self.contentView.backgroundColor = [UIColor clearColor]; - self.bgImageView.backgroundColor = [UIColor clearColor]; - } - NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil)); - [self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img]; - self.bgImageView.image = img; + NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil)); + [self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img]; + self.bgImageView.image = img; -// [self.chatPanelView kb_setBackgroundImage:img]; - BOOL hasImg = (img != nil); - // 触发键区按主题重绘 - if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) { + // 触发键区按主题重绘 + if (themeChanged && + [self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) { // method declared in KBKeyBoardMainView.h #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [self.keyBoardMainView performSelector:@selector(kb_applyTheme)]; + [self.keyBoardMainView performSelector:@selector(kb_applyTheme)]; #pragma clang diagnostic pop - } - if ([self.functionView respondsToSelector:@selector(kb_applyTheme)]) { + } + // 注意:这里不能直接访问 self.functionView,否则会导致功能面板提前创建,占用内存。 + if (themeChanged && _functionView && + [_functionView respondsToSelector:@selector(kb_applyTheme)]) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [self.functionView performSelector:@selector(kb_applyTheme)]; + [_functionView performSelector:@selector(kb_applyTheme)]; #pragma clang diagnostic pop + } } } diff --git a/CustomKeyboard/View/KBKeyboardView.m b/CustomKeyboard/View/KBKeyboardView.m index 5936411..82bd326 100644 --- a/CustomKeyboard/View/KBKeyboardView.m +++ b/CustomKeyboard/View/KBKeyboardView.m @@ -49,7 +49,6 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5; self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig]; self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self]; [self buildBase]; - [self reloadKeys]; } return self; } diff --git a/CustomKeyboard/View/KBToolBar.m b/CustomKeyboard/View/KBToolBar.m index 9b8d8f2..408dc71 100644 --- a/CustomKeyboard/View/KBToolBar.m +++ b/CustomKeyboard/View/KBToolBar.m @@ -9,6 +9,7 @@ #import "KBResponderUtils.h" // 查找 UIInputViewController,用于系统切换输入法 #import "KBBackspaceUndoManager.h" #import "KBSkinManager.h" +#import @interface KBToolBar () @property (nonatomic, strong) UIView *leftContainer; @@ -20,6 +21,8 @@ @property (nonatomic, assign) BOOL kbNeedsInputModeSwitchKey; @property (nonatomic, assign) BOOL kbUndoVisible; @property (nonatomic, assign) BOOL kbAvatarVisible; +@property (nonatomic, copy, nullable) NSString *kb_cachedPersonaCoverPath; +@property (nonatomic, strong, nullable) UIImage *kb_cachedPersonaCoverImage; @end @implementation KBToolBar @@ -256,10 +259,41 @@ [[containerURL path] stringByAppendingPathComponent:@"persona_cover.jpg"]; if (imagePath.length == 0 || ![[NSFileManager defaultManager] fileExistsAtPath:imagePath]) { + self.kb_cachedPersonaCoverPath = nil; + self.kb_cachedPersonaCoverImage = nil; return nil; } - return [UIImage imageWithContentsOfFile:imagePath]; + if (self.kb_cachedPersonaCoverImage && + [self.kb_cachedPersonaCoverPath isEqualToString:imagePath]) { + return self.kb_cachedPersonaCoverImage; + } + + // 头像仅 40pt,直接按像素上限缩略解码,避免每次显示键盘都 full decode 一张大 JPG 顶爆扩展内存。 + NSUInteger maxPixel = 256; + NSURL *url = [NSURL fileURLWithPath:imagePath]; + CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); + if (!source) { + return nil; + } + NSDictionary *opts = @{ + (__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : @YES, + (__bridge id)kCGImageSourceCreateThumbnailWithTransform : @YES, + (__bridge id)kCGImageSourceThumbnailMaxPixelSize : @(maxPixel), + }; + CGImageRef cg = CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)opts); + CFRelease(source); + if (!cg) { + return nil; + } + UIImage *img = [UIImage imageWithCGImage:cg + scale:[UIScreen mainScreen].scale + orientation:UIImageOrientationUp]; + CGImageRelease(cg); + + self.kb_cachedPersonaCoverPath = imagePath; + self.kb_cachedPersonaCoverImage = img; + return img; } #pragma mark - Actions diff --git a/Shared/KBSkinManager.h b/Shared/KBSkinManager.h index 5eb398f..82f9931 100644 --- a/Shared/KBSkinManager.h +++ b/Shared/KBSkinManager.h @@ -54,6 +54,9 @@ extern NSString * const KBDarwinSkinChanged; // cross-process /// 当前背景图片(若存在) - (nullable UIImage *)currentBackgroundImage; +/// 清理运行时图片缓存(内存缓存)。键盘扩展接近内存上限时可主动调用。 +- (void)clearRuntimeImageCaches; + /// 当前主题下,指定按键标识的文字是否应被隐藏(例如图标里已包含字母) - (BOOL)shouldHideKeyTextForIdentifier:(nullable NSString *)identifier; diff --git a/Shared/KBSkinManager.m b/Shared/KBSkinManager.m index ce973f6..d21a9d3 100644 --- a/Shared/KBSkinManager.m +++ b/Shared/KBSkinManager.m @@ -4,6 +4,7 @@ #import "KBSkinManager.h" #import "KBConfig.h" +#import NSString * const KBSkinDidChangeNotification = @"KBSkinDidChangeNotification"; NSString * const KBDarwinSkinChanged = @"com.loveKey.nyx.skin.changed"; @@ -59,10 +60,45 @@ static NSString * const kKBSkinThemeStoreKey = @"KBSkinThemeCurrent"; @interface KBSkinManager () @property (atomic, strong, readwrite) KBSkinTheme *current; +@property (nonatomic, strong) NSCache *kb_fileImageCache; +@property (nonatomic, copy, nullable) NSString *kb_cachedBgSkinId; +@property (nonatomic, assign) BOOL kb_cachedBgResolved; +@property (nonatomic, strong, nullable) UIImage *kb_cachedBgImage; @end @implementation KBSkinManager +/// 从文件路径解码图片,并按 maxPixel 限制最长边像素(避免加载超大背景图导致键盘扩展内存飙升)。 ++ (nullable UIImage *)kb_imageAtPath:(NSString *)path maxPixel:(NSUInteger)maxPixel { + if (path.length == 0) return nil; + NSURL *url = [NSURL fileURLWithPath:path]; + CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); + if (!source) return nil; + NSDictionary *opts = @{ + (__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : @YES, + (__bridge id)kCGImageSourceCreateThumbnailWithTransform : @YES, + (__bridge id)kCGImageSourceThumbnailMaxPixelSize : @(MAX(1, (NSInteger)maxPixel)), + }; + CGImageRef cg = CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)opts); + CFRelease(source); + if (!cg) return nil; + UIImage *img = [UIImage imageWithCGImage:cg scale:[UIScreen mainScreen].scale orientation:UIImageOrientationUp]; + CGImageRelease(cg); + return img; +} + +static inline NSUInteger KBApproxImageCostBytes(UIImage *img) { + if (!img) return 0; + CGFloat scale = img.scale > 0 ? img.scale : [UIScreen mainScreen].scale; + CGSize s = img.size; + double px = (double)s.width * scale * (double)s.height * scale; + if (px <= 0) return 0; + // RGBA 4 bytes/pixel + double cost = px * 4.0; + if (cost > (double)NSUIntegerMax) return NSUIntegerMax; + return (NSUInteger)cost; +} + /// 返回所有可能的皮肤根目录(优先 App Group,其次当前进程的 Caches)。 + (NSArray *)kb_candidateBaseRoots { NSMutableArray *roots = [NSMutableArray array]; @@ -104,6 +140,14 @@ static NSString * const kKBSkinThemeStoreKey = @"KBSkinThemeCurrent"; - (instancetype)init { if (self = [super init]) { + _kb_fileImageCache = [NSCache new]; + // 键盘扩展内存上限较小,缓存要保守一些;主 App 也共用该实现但不会出问题。 + // iPad 的键盘背景可能更大,适当放宽。 + if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) { + _kb_fileImageCache.totalCostLimit = 24 * 1024 * 1024; + } else { + _kb_fileImageCache.totalCostLimit = 12 * 1024 * 1024; + } KBSkinTheme *t = [self p_loadFromStore]; // 若存储中的皮肤在 App Group 中找不到对应资源目录(如首次安装 / 已被清理),则回退到默认皮肤。 if (!t || ![self.class kb_hasAssetsForSkinId:t.skinId]) { @@ -170,6 +214,7 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, - (BOOL)applyTheme:(KBSkinTheme *)theme { if (!theme) return NO; NSLog(@"🎨[SkinManager] apply theme id=%@ name=%@", theme.skinId, theme.name); + [self clearRuntimeImageCaches]; // 将主题写入 App Group 存储(失败也不影响本次进程内的使用) [self p_saveToStore:theme]; // 始终更新当前主题并广播通知,确保当前进程和扩展之间保持同步。 @@ -187,6 +232,15 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, [self applyTheme:[self.class defaultTheme]]; } +- (void)clearRuntimeImageCaches { + @synchronized (self) { + [self.kb_fileImageCache removeAllObjects]; + self.kb_cachedBgSkinId = nil; + self.kb_cachedBgResolved = NO; + self.kb_cachedBgImage = nil; + } +} + - (BOOL)applyImageSkinWithData:(NSData *)imageData skinId:(NSString *)skinId name:(NSString *)name { // 仅作为“存在背景图”的标记使用:图像文件本身存放在 App Group 容器 // Skins//background.png 中,这里不再把二进制图片写入 Keychain, @@ -216,20 +270,52 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, NSString *skinId = self.current.skinId; if (skinId.length == 0) return nil; + // 同一个 skinId 在键盘的生命周期内会被频繁读取;缓存一份避免反复解码导致内存上涨。 + @synchronized (self) { + if (self.kb_cachedBgResolved && [self.kb_cachedBgSkinId isEqualToString:skinId]) { + return self.kb_cachedBgImage; + } + } + NSArray *roots = [self.class kb_candidateBaseRoots]; NSFileManager *fm = [NSFileManager defaultManager]; NSString *relative = [NSString stringWithFormat:@"Skins/%@/background.png", skinId]; + // 背景图通常远大于键盘实际显示区域,按像素上限做缩略解码,显著降低扩展内存占用。 + NSUInteger maxPixel = (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) ? 2048 : 1024; for (NSString *base in roots) { NSString *bgPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath]; BOOL isDir = NO; if (![fm fileExistsAtPath:bgPath isDirectory:&isDir] || isDir) { continue; } - NSData *data = [NSData dataWithContentsOfFile:bgPath]; - if (data.length == 0) continue; - UIImage *img = [UIImage imageWithData:data scale:[UIScreen mainScreen].scale]; - if (img) return img; + NSString *cacheKey = [NSString stringWithFormat:@"bg|%@", bgPath]; + UIImage *cached = [self.kb_fileImageCache objectForKey:cacheKey]; + if (cached) { + @synchronized (self) { + self.kb_cachedBgSkinId = skinId; + self.kb_cachedBgResolved = YES; + self.kb_cachedBgImage = cached; + } + return cached; + } + + UIImage *img = [self.class kb_imageAtPath:bgPath maxPixel:maxPixel]; + if (img) { + NSUInteger cost = KBApproxImageCostBytes(img); + [self.kb_fileImageCache setObject:img forKey:cacheKey cost:cost]; + @synchronized (self) { + self.kb_cachedBgSkinId = skinId; + self.kb_cachedBgResolved = YES; + self.kb_cachedBgImage = img; + } + return img; + } + } + @synchronized (self) { + self.kb_cachedBgSkinId = skinId; + self.kb_cachedBgResolved = YES; + self.kb_cachedBgImage = nil; } return nil; } @@ -314,7 +400,13 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, if (![fm fileExistsAtPath:fullPath isDirectory:&isDir] || isDir) { continue; } - UIImage *img = [UIImage imageWithContentsOfFile:fullPath]; + NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath]; + UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey]; + if (img) return img; + img = [UIImage imageWithContentsOfFile:fullPath]; + if (img) { + [self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)]; + } if (img) return img; } #if DEBUG @@ -351,7 +443,13 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, NSString *fullPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath]; BOOL isDir = NO; if ([fm fileExistsAtPath:fullPath isDirectory:&isDir] && !isDir) { - UIImage *img = [UIImage imageWithContentsOfFile:fullPath]; + NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath]; + UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey]; + if (img) return img; + img = [UIImage imageWithContentsOfFile:fullPath]; + if (img) { + [self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)]; + } if (img) return img; } } @@ -363,7 +461,13 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, NSString *fullPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath]; BOOL isDir = NO; if ([fm fileExistsAtPath:fullPath isDirectory:&isDir] && !isDir) { - UIImage *img = [UIImage imageWithContentsOfFile:fullPath]; + NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath]; + UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey]; + if (img) return img; + img = [UIImage imageWithContentsOfFile:fullPath]; + if (img) { + [self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)]; + } if (img) return img; } } @@ -449,6 +553,7 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, if (!t || ![self.class kb_hasAssetsForSkinId:t.skinId]) { t = [self.class defaultTheme]; } + [self clearRuntimeImageCaches]; self.current = t; if (broadcast) { [[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil];