diff --git a/CustomKeyboard/Network/KBStreamFetcher.h b/CustomKeyboard/Network/KBStreamFetcher.h index 8ea71b6..6b29832 100644 --- a/CustomKeyboard/Network/KBStreamFetcher.h +++ b/CustomKeyboard/Network/KBStreamFetcher.h @@ -38,6 +38,9 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error); /// 非 SSE 且一次性拿到大段文本时,是否按空格切词逐条回调(模拟“逐词流式”),默认 YES。 @property (nonatomic, assign) BOOL splitLargeDeltasOnWhitespace; +/// 调试日志:默认 YES。输出起止时刻、首包耗时、各分片内容(截断)等关键信息。 +@property (nonatomic, assign) BOOL loggingEnabled; + // 回调(统一在主线程触发) @property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk; @property (nonatomic, copy, nullable) KBStreamFetcherFinishHandler onFinish; diff --git a/CustomKeyboard/Network/KBStreamFetcher.m b/CustomKeyboard/Network/KBStreamFetcher.m index 2dd5605..c7264a2 100644 --- a/CustomKeyboard/Network/KBStreamFetcher.m +++ b/CustomKeyboard/Network/KBStreamFetcher.m @@ -18,6 +18,12 @@ @property (nonatomic, strong) NSMutableArray *pendingQueue; // 待回调的分片(节流输出) @property (nonatomic, strong) NSTimer *flushTimer; // 定时从队列取出一条回调 @property (nonatomic, strong, nullable) NSError *finishError; // 结束时的错误(需要等队列清空再回调) + +// Metrics +@property (nonatomic, assign) CFAbsoluteTime tStart; // start() 被调用的时刻 +@property (nonatomic, assign) CFAbsoluteTime tFirstByte; // 第一次拿到可解码内容 +@property (nonatomic, assign) CFAbsoluteTime tFinish; // 完成/失败时刻 +@property (nonatomic, assign) NSInteger emittedChunkCount; // 已输出分片数量 @end // 计算数据中以 UTF-8 编码可完整解码的“前缀字节长度”,避免切断多字节字符 @@ -60,6 +66,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { _pendingQueue = [NSMutableArray array]; _flushInterval = 0.1; _splitLargeDeltasOnWhitespace = YES; + _loggingEnabled = YES; } return self; } @@ -96,6 +103,18 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { [self.flushTimer invalidate]; self.flushTimer = nil; self.finishError = nil; + self.tStart = CFAbsoluteTimeGetCurrent(); + self.tFirstByte = 0; + self.tFinish = 0; + self.emittedChunkCount = 0; + if (self.loggingEnabled) { + NSLog(@"[KBStream] start url=%@ acceptSSE=%@ disableCompression=%@ flush=%.0fms splitWords=%@", + self.url.absoluteString, + self.acceptEventStream?@"YES":@"NO", + self.disableCompression?@"YES":@"NO", + self.flushInterval*1000.0, + self.splitLargeDeltasOnWhitespace?@"YES":@"NO"); + } self.task = [self.session dataTaskWithRequest:req]; [self.task resume]; } @@ -150,6 +169,15 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { NSUInteger validLen = (self.textEncoding == NSUTF8StringEncoding) ? kb_validUTF8PrefixLen(self.buffer) : self.buffer.length; + if (validLen > 0 && self.tFirstByte == 0) { + self.tFirstByte = CFAbsoluteTimeGetCurrent(); + if (self.loggingEnabled) { + NSLog(@"[KBStream] first-bytes after %.0fms (encoding=%@, SSE=%@)", + (self.tFirstByte - self.tStart)*1000.0, + (self.textEncoding==NSUTF8StringEncoding?@"UTF-8":@"Other"), + self.isSSE?@"YES":@"NO"); + } + } if (validLen == 0) return; // 末尾可能卡着半个字符 if (self.isSSE) { @@ -181,12 +209,8 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { NSString *v = [ln substringFromIndex:5]; if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; [payload appendString:v ?: @""]; - [payload appendString:@"\n"]; // 多 data 行合并 } } - if (payload.length > 0 && [payload hasSuffix:@"\n"]) { - [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; - } if (payload.length > 0) { [self enqueueChunk:payload]; } } } @@ -228,17 +252,28 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { NSString *v = [ln substringFromIndex:5]; if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; [payload appendString:v ?: @""]; - [payload appendString:@"\n"]; } } - if (payload.length > 0 && [payload hasSuffix:@"\n"]) { - [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; - } if (payload.length > 0) { - [self emitChunk:payload]; + NSString *delta = nil; + if ((NSInteger)payload.length >= self.deliveredCharCount) { + delta = [payload substringFromIndex:self.deliveredCharCount]; + } else { + delta = payload; + } + self.deliveredCharCount = payload.length; + if (delta.length > 0) { [self emitChunk:delta]; } } } + self.tFinish = CFAbsoluteTimeGetCurrent(); + if (self.loggingEnabled) { + double t0 = (self.tFirstByte>0? (self.tFirstByte - self.tStart)*1000.0 : -1); + double t1 = (self.tFirstByte>0? (self.tFinish - self.tFirstByte)*1000.0 : -1); + double tt = (self.tFinish - self.tStart)*1000.0; + NSLog(@"[KBStream] finish chunks=%ld firstByte=%.0fms after start, tail=%.0fms, total=%.0fms error=%@", + (long)self.emittedChunkCount, t0, t1, tt, error); + } // 若队列还有待输出内容,等队列清空再回调 finish if (self.pendingQueue.count > 0) { self.finishError = error; @@ -280,6 +315,11 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { text = [text stringByReplacingOccurrencesOfString:@"\t " withString:@"\t"]; } if (text.length == 0) { self.lastChunkEndedWithTab = NO; return; } + self.emittedChunkCount += 1; + if (self.loggingEnabled) { + NSLog(@"[KBStream] chunk#%ld len=%lu text=\"%@\"", + (long)self.emittedChunkCount, (unsigned long)text.length, KBPrintableSnippet(text, 160)); + } if (self.onChunk) dispatch_async(dispatch_get_main_queue(), ^{ self.onChunk(text); }); self.hasEmitted = YES; // 记录末尾是否为分段分隔符 \t(用于跨分片处理) @@ -317,4 +357,15 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { }]; } +#pragma mark - Logging helpers + +static NSString *KBPrintableSnippet(NSString *s, NSUInteger maxLen) { + if (!s) return @""; + NSString *x = [s stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]; + if (x.length > maxLen) { + x = [[x substringToIndex:maxLen] stringByAppendingString:@"…"]; + } + return x; +} + @end diff --git a/CustomKeyboard/View/Function/KBFunctionTagListView.h b/CustomKeyboard/View/Function/KBFunctionTagListView.h index bdfec03..55c72bb 100644 --- a/CustomKeyboard/View/Function/KBFunctionTagListView.h +++ b/CustomKeyboard/View/Function/KBFunctionTagListView.h @@ -21,7 +21,9 @@ NS_ASSUME_NONNULL_BEGIN - (void)setItems:(NSArray *)items; +/// 在指定 index 上显示/隐藏加载指示(若 cell 不可见,内部会记录状态,待出现时应用) +- (void)setLoading:(BOOL)loading atIndex:(NSInteger)index; + @end NS_ASSUME_NONNULL_END - diff --git a/CustomKeyboard/View/Function/KBFunctionTagListView.m b/CustomKeyboard/View/Function/KBFunctionTagListView.m index 3fe2297..f5e5364 100644 --- a/CustomKeyboard/View/Function/KBFunctionTagListView.m +++ b/CustomKeyboard/View/Function/KBFunctionTagListView.m @@ -10,6 +10,7 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2"; @interface KBFunctionTagListView () @property (nonatomic, strong) UICollectionView *collectionViewInternal; @property (nonatomic, copy) NSArray *items; +@property (nonatomic, strong) NSMutableSet *loadingIndexes; // 记录需要展示loading的index @end @implementation KBFunctionTagListView @@ -31,6 +32,7 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2"; [_collectionViewInternal.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], ]]; _items = @[]; + _loadingIndexes = [NSMutableSet set]; } return self; } @@ -46,6 +48,8 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2"; - (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { KBFunctionTagCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kKBFunctionTagCellId2 forIndexPath:indexPath]; cell.titleLabel.text = (indexPath.item < self.items.count) ? self.items[indexPath.item] : @""; + BOOL loading = [self.loadingIndexes containsObject:@(indexPath.item)]; + [cell setLoading:loading]; return cell; } @@ -65,4 +69,15 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2"; } } +- (void)setLoading:(BOOL)loading atIndex:(NSInteger)index { + NSNumber *key = @(index); + if (loading) { [self.loadingIndexes addObject:key]; } + else { [self.loadingIndexes removeObject:key]; } + NSIndexPath *ip = [NSIndexPath indexPathForItem:index inSection:0]; + if (ip && ip.item < [self.collectionViewInternal numberOfItemsInSection:0]) { + KBFunctionTagCell *cell = (KBFunctionTagCell *)[self.collectionViewInternal cellForItemAtIndexPath:ip]; + if ([cell isKindOfClass:[KBFunctionTagCell class]]) { [cell setLoading:loading]; } + } +} + @end diff --git a/CustomKeyboard/View/KBFunctionTagCell.h b/CustomKeyboard/View/KBFunctionTagCell.h index e4c00f7..3455bb7 100644 --- a/CustomKeyboard/View/KBFunctionTagCell.h +++ b/CustomKeyboard/View/KBFunctionTagCell.h @@ -17,7 +17,9 @@ NS_ASSUME_NONNULL_BEGIN /// 头像/图标 @property (nonatomic, strong, readonly) UIImageView *iconView; +/// 显示/隐藏加载指示(小菊花) +- (void)setLoading:(BOOL)loading; + @end NS_ASSUME_NONNULL_END - diff --git a/CustomKeyboard/View/KBFunctionTagCell.m b/CustomKeyboard/View/KBFunctionTagCell.m index 06df6b3..d848f63 100644 --- a/CustomKeyboard/View/KBFunctionTagCell.m +++ b/CustomKeyboard/View/KBFunctionTagCell.m @@ -11,6 +11,7 @@ @interface KBFunctionTagCell () @property (nonatomic, strong) UILabel *titleLabelInternal; @property (nonatomic, strong) UIImageView *iconViewInternal; +@property (nonatomic, strong) UIActivityIndicatorView *loadingView; @end @implementation KBFunctionTagCell @@ -34,6 +35,14 @@ make.right.equalTo(self.contentView.mas_right).offset(-10); make.centerY.equalTo(self.contentView.mas_centerY); }]; + + // 小菊花:默认隐藏,放在标题右侧 + [self.contentView addSubview:self.loadingView]; + [self.loadingView mas_makeConstraints:^(MASConstraintMaker *make) { + make.centerY.equalTo(self.contentView); + make.right.equalTo(self.contentView.mas_right).offset(-10); + make.width.height.mas_equalTo(16); + }]; } return self; } @@ -64,10 +73,35 @@ return _titleLabelInternal; } +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 +static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleMedium; } +#else +static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleGray; } +#endif + +- (UIActivityIndicatorView *)loadingView { + if (!_loadingView) { + _loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:KBSpinnerStyle()]; + _loadingView.hidesWhenStopped = YES; + _loadingView.color = [UIColor grayColor]; + _loadingView.hidden = YES; + } + return _loadingView; +} + #pragma mark - Expose - (UILabel *)titleLabel { return self.titleLabelInternal; } - (UIImageView *)iconView { return self.iconViewInternal; } -@end +- (void)setLoading:(BOOL)loading { + if (loading) { + self.loadingView.hidden = NO; + [self.loadingView startAnimating]; + } else { + [self.loadingView stopAnimating]; + self.loadingView.hidden = YES; + } +} +@end diff --git a/CustomKeyboard/View/KBFunctionView.m b/CustomKeyboard/View/KBFunctionView.m index 25fad12..24ecba2 100644 --- a/CustomKeyboard/View/KBFunctionView.m +++ b/CustomKeyboard/View/KBFunctionView.m @@ -40,6 +40,8 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; // 网络流式(封装) @property (nonatomic, strong, nullable) KBStreamFetcher *streamFetcher; @property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用) +@property (nonatomic, strong, nullable) NSNumber *loadingTagIndex; // 当前显示loading的标签index +@property (nonatomic, copy, nullable) NSString *loadingTagTitle; // Data @property (nonatomic, strong) NSArray *itemsInternal; @@ -212,8 +214,7 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; }]; self.streamOverlay = overlay; - // 优先拉取后端测试数据(GET);失败则回落到本地演示 - [self kb_startNetworkStreamingWithSeed:title]; + // 只创建 UI;网络在点击 cell 时启动,避免重复 start 导致首包重复 } - (void)kb_onTapStreamDelete { @@ -258,8 +259,13 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/ }; fetcher.onFinish = ^(NSError * _Nullable error) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; + // 若还未出现任何数据且仍在 loading,则取消小菊花 + if (!self.streamHasOutput && self.loadingTagIndex) { + [self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue]; + self.loadingTagIndex = nil; self.loadingTagTitle = nil; + } if (error) { [KBHUD showInfo:@"拉取失败"]; } - [self.streamOverlay finish]; + if (self.streamOverlay) { [self.streamOverlay finish]; } }; self.streamFetcher = fetcher; [self.streamFetcher start]; @@ -277,7 +283,16 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/ /// - 目前网络层(KBStreamFetcher)已做 “/t->\t、首段去一个 \t、段间去一个空格” /// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动 - (void)kb_appendChunkToStreamView:(NSString *)chunk { - if (chunk.length == 0 || !self.streamOverlay) return; + if (chunk.length == 0) return; + // 第一次有数据才创建 overlay,并取消 cell 上的小菊花 + if (!self.streamOverlay) { + [self kb_showStreamTextViewIfNeededWithTitle:self.loadingTagTitle ?: @""]; + if (self.loadingTagIndex) { + [self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue]; + self.loadingTagIndex = nil; self.loadingTagTitle = nil; + } + } + if (!self.streamOverlay) return; [self.streamOverlay appendChunk:chunk]; self.streamHasOutput = YES; } @@ -287,10 +302,14 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/ - (void)tagListView:(KBFunctionTagListView *)view didSelectIndex:(NSInteger)index title:(NSString *)title { // 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。 if ([[KBFullAccessManager shared] hasFullAccess]) { - [self kb_showStreamTextViewIfNeededWithTitle:title ?: @""]; + // 先在 cell 上显示小菊花,等有数据回来再弹出 overlay + [self.tagListView setLoading:YES atIndex:index]; + self.loadingTagIndex = @(index); + self.loadingTagTitle = title ?: @""; + [self kb_startNetworkStreamingWithSeed:self.loadingTagTitle]; return; } - // 保留原有拉起主 App 的逻辑(若需要) + // 未开启完全访问:保持原有引导路径 [KBHUD showInfo:@"处理中…"]; UIInputViewController *ivc = KBFindInputViewController(self); if (!ivc) return;