diff --git a/CustomKeyboard/Network/KBStreamFetcher.h b/CustomKeyboard/Network/KBStreamFetcher.h index f077e8c..8ea71b6 100644 --- a/CustomKeyboard/Network/KBStreamFetcher.h +++ b/CustomKeyboard/Network/KBStreamFetcher.h @@ -33,6 +33,10 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error); @property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES;将“/t”替换为“\t” @property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES;首次正文起始的“\t”删一个(忽略前导空白) @property (nonatomic, assign) NSTimeInterval requestTimeout; // 默认 30s +/// UI 刷新节奏:当一次回调内解析出多段(如多条 SSE 事件)时,按该间隔逐条回调(默认 0.10s)。 +@property (nonatomic, assign) NSTimeInterval flushInterval; +/// 非 SSE 且一次性拿到大段文本时,是否按空格切词逐条回调(模拟“逐词流式”),默认 YES。 +@property (nonatomic, assign) BOOL splitLargeDeltasOnWhitespace; // 回调(统一在主线程触发) @property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk; @@ -45,4 +49,3 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error); @end NS_ASSUME_NONNULL_END - diff --git a/CustomKeyboard/Network/KBStreamFetcher.m b/CustomKeyboard/Network/KBStreamFetcher.m index 32338c9..32517d0 100644 --- a/CustomKeyboard/Network/KBStreamFetcher.m +++ b/CustomKeyboard/Network/KBStreamFetcher.m @@ -14,6 +14,9 @@ @property (nonatomic, assign) NSInteger decodedPrefixBytes; // 已解码并写入 sseTextBuffer 的字节数(SSE) @property (nonatomic, assign) NSInteger deliveredCharCount; // 已回传的字符数(非 SSE,用于做增量) @property (nonatomic, assign) BOOL hasEmitted; // 是否已经输出过正文(用于“首段删 1 个 \t”) +@property (nonatomic, strong) NSMutableArray *pendingQueue; // 待回调的分片(节流输出) +@property (nonatomic, strong) NSTimer *flushTimer; // 定时从队列取出一条回调 +@property (nonatomic, strong, nullable) NSError *finishError; // 结束时的错误(需要等队列清空再回调) @end // 计算数据中以 UTF-8 编码可完整解码的“前缀字节长度”,避免切断多字节字符 @@ -53,6 +56,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { _textEncoding = NSUTF8StringEncoding; _buffer = [NSMutableData data]; _sseTextBuffer = [NSMutableString string]; + _pendingQueue = [NSMutableArray array]; + _flushInterval = 0.10; + _splitLargeDeltasOnWhitespace = YES; } return self; } @@ -84,6 +90,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { self.decodedPrefixBytes = 0; self.deliveredCharCount = 0; self.hasEmitted = NO; + [self.pendingQueue removeAllObjects]; + [self.flushTimer invalidate]; self.flushTimer = nil; + self.finishError = nil; self.task = [self.session dataTaskWithRequest:req]; [self.task resume]; @@ -99,6 +108,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { self.decodedPrefixBytes = 0; self.deliveredCharCount = 0; self.hasEmitted = NO; + [self.pendingQueue removeAllObjects]; + [self.flushTimer invalidate]; self.flushTimer = nil; + self.finishError = nil; } #pragma mark - NSURLSessionDataDelegate @@ -172,9 +184,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { if (payload.length > 0 && [payload hasSuffix:@"\n"]) { [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; } - if (payload.length > 0) { - [self emitChunk:payload]; - } + if (payload.length > 0) { [self enqueueChunk:payload]; } } } return; @@ -186,7 +196,21 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { if (self.deliveredCharCount < (NSInteger)prefix.length) { NSString *delta = [prefix substringFromIndex:self.deliveredCharCount]; self.deliveredCharCount = prefix.length; - [self emitChunk:delta]; + if (self.splitLargeDeltasOnWhitespace && delta.length > 16) { + // 按空格切词逐条回调(保留空格,使观感更自然) + NSArray *parts = [delta componentsSeparatedByString:@" "]; + for (NSUInteger i = 0; i < parts.count; i++) { + NSString *w = parts[i]; + if (w.length == 0) { [self enqueueChunk:@" "]; continue; } + if (i + 1 < parts.count) { + [self enqueueChunk:[w stringByAppendingString:@" "]]; + } else { + [self enqueueChunk:w]; + } + } + } else { + [self enqueueChunk:delta]; + } } } @@ -212,8 +236,14 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { } } - if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); }); - [self cancel]; + // 若队列还有待输出内容,等队列清空再回调 finish + if (self.pendingQueue.count > 0) { + self.finishError = error; + [self startFlushTimerIfNeeded]; + } else { + if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); }); + [self cancel]; + } } #pragma mark - Helpers @@ -243,5 +273,34 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { self.hasEmitted = YES; } -@end +#pragma mark - Queue/Flush +- (void)enqueueChunk:(NSString *)s { + if (s.length == 0) return; + [self.pendingQueue addObject:s]; + [self startFlushTimerIfNeeded]; +} + +- (void)startFlushTimerIfNeeded { + if (self.flushTimer) return; + __weak typeof(self) weakSelf = self; + self.flushTimer = [NSTimer scheduledTimerWithTimeInterval:MAX(0.01, self.flushInterval) + repeats:YES + block:^(NSTimer * _Nonnull t) { + __strong typeof(weakSelf) self = weakSelf; if (!self) { [t invalidate]; return; } + if (self.pendingQueue.count == 0) { + [t invalidate]; self.flushTimer = nil; + if (self.finishError || self.finishError == nil) { + NSError *err = self.finishError; self.finishError = nil; + if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(err); }); + [self cancel]; + } + return; + } + NSString *first = self.pendingQueue.firstObject; + [self.pendingQueue removeObjectAtIndex:0]; + [self emitChunk:first]; + }]; +} + +@end diff --git a/CustomKeyboard/View/KBStreamTextView.m b/CustomKeyboard/View/KBStreamTextView.m index 156f4fa..31df995 100644 --- a/CustomKeyboard/View/KBStreamTextView.m +++ b/CustomKeyboard/View/KBStreamTextView.m @@ -56,6 +56,9 @@ _scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; _scrollView.alwaysBounceVertical = YES; _scrollView.showsVerticalScrollIndicator = YES; + // 留一点底部余量,避免最后一行视觉上“贴底被遮住”的感觉 + _scrollView.contentInset = UIEdgeInsetsMake(0, 0, 6, 0); + _scrollView.scrollIndicatorInsets = _scrollView.contentInset; [self addSubview:_scrollView]; } @@ -171,7 +174,6 @@ label.textColor = self.labelTextColor; label.userInteractionEnabled = YES; label.text = @""; - label.backgroundColor = [UIColor redColor]; UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)]; [label addGestureRecognizer:tap]; @@ -196,8 +198,10 @@ NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; current.text = trimmed; self.buffer = trimmed; - [self layoutLabelsForCurrentWidth]; } + // 无论是否裁剪,都重新布局并滚动到底部,避免最后一段显示不全 + [self layoutLabelsForCurrentWidth]; + [self scrollToBottomIfNeeded]; } - (void)layoutSubviews { @@ -247,7 +251,8 @@ CGFloat contentHeight = self.scrollView.contentSize.height; if (contentHeight > height && height > 0) { CGPoint bottomOffset = CGPointMake(0, contentHeight - height); - [self.scrollView setContentOffset:bottomOffset animated:YES]; + // 使用无动画滚动,避免在短时间内多次追加时“最后一行没完全跟上”的错觉 + [self.scrollView setContentOffset:bottomOffset animated:NO]; } }